From bbbb23821c001ca3a15881a32369ce22fceb3b4d Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 03:58:14 -0800 Subject: [PATCH 01/27] cli: run cold blockless VMs through the verified lifecycle Implement issue1109 SL3.4a2 on qualified SL2.10 adeb1d47, without replaying the donor's old stop-producer history. Keep restore and restore-network-policy on their existing path. Render CLI-owned guest tokens from the planned export, preserve cold foreground streams, terminal mode, exact exit status and outcome publication, and keep ordinary library spawns detached. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- THIRD_PARTY_NOTICES.md | 14 +- doc/usage.md | 15 +- guest/common/nvx-hostmount | 17 +- nvx-rs/Cargo.lock | 114 ++++ nvx-rs/Cargo.toml | 3 +- nvx-rs/public-api/linux.txt | 1 + nvx-rs/public-api/windows.txt | 1 + nvx-rs/src/host/artifacts/dir.rs | 46 +- nvx-rs/src/host/artifacts/tests.rs | 25 + nvx-rs/src/host/cli/run.rs | 639 ++++++++++++++++-- nvx-rs/src/host/cli/run/guest_tokens.rs | 86 +++ nvx-rs/src/host/cli/run/guest_tokens/tests.rs | 78 +++ nvx-rs/src/host/cli/run/tests.rs | 77 ++- nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs | 4 +- .../src/host/cli/sandbox/lifecycle/outcome.rs | 44 +- nvx-rs/src/host/cli/sandbox/mod.rs | 5 +- nvx-rs/src/host/cli/sandbox/options.rs | 7 +- nvx-rs/src/host/config/mod.rs | 13 + nvx-rs/src/host/config/tests.rs | 35 + nvx-rs/src/host/platform/linux/endpoint.rs | 9 +- nvx-rs/src/host/platform/linux/mod.rs | 4 +- nvx-rs/src/host/platform/windows/endpoint.rs | 5 + nvx-rs/src/host/platform/windows/mod.rs | 6 +- nvx-rs/src/host/platform/windows/spawn.rs | 35 +- nvx-rs/src/host/platform/windows/tests.rs | 48 +- nvx-rs/src/host/spawn.rs | 31 +- nvx-rs/src/host/vm.rs | 56 +- nvx-rs/src/host/vm/tests.rs | 44 ++ nvx-rs/src/testing/fake_openvmm.rs | 201 +++++- nvx-rs/src/testing/fake_openvmm/tests.rs | 2 +- nvx-rs/tests/cli.rs | 28 +- nvx-rs/tests/cli/download.rs | 4 +- nvx-rs/tests/cli/dry_run.rs | 214 +++++- nvx-rs/tests/cli/harness.rs | 10 +- nvx-rs/tests/cli/one_shot.rs | 406 ++++++++++- nvx-rs/tests/cli/openvmm.rs | 350 +++++++++- nvx-rs/tests/cli/validation.rs | 27 +- 37 files changed, 2473 insertions(+), 231 deletions(-) create mode 100644 nvx-rs/src/host/cli/run/guest_tokens.rs create mode 100644 nvx-rs/src/host/cli/run/guest_tokens/tests.rs diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 322214b80..6091d4321 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -14,7 +14,7 @@ the distribution tooling. | Rust `libc` crate 0.2.189 | linked into the guest binaries and the Linux `bin/nvx-rs` through `nix` and `getrandom` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are at [`rust-lang/libc` tag 0.2.189](https://github.com/rust-lang/libc/tree/0.2.189). | | Rust `nix` crate 0.31.3 | system-call wrappers used by the guest binaries and the Linux `bin/nvx-rs` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`nix-rust/nix`](https://crates.io/crates/nix/0.31.3). | | Rust `getrandom` crate 0.4.3 | random bytes for the guest binaries, such as `nvx-reseed --sample`, and for `bin/nvx-rs[.exe]`, such as control capabilities | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`rust-random/getrandom`](https://crates.io/crates/getrandom/0.4.3). | -| Rust `bitflags` crate 2.13.2 | transitive `nix` dependency in the guest binaries and the Linux `bin/nvx-rs` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`bitflags/bitflags`](https://crates.io/crates/bitflags/2.13.2). | +| Rust `bitflags` crate 2.13.2 | transitive `nix` dependency in the guest binaries and `nix` or `crossterm` dependency in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`bitflags/bitflags`](https://crates.io/crates/bitflags/2.13.2). | | Rust `cfg-if` crate 1.0.5 | transitive `nix`, `getrandom`, and `sha2` dependency in the guest binaries and `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`rust-lang/cfg-if`](https://crates.io/crates/cfg-if/1.0.5). | | Rust `memoffset` crate 0.9.1 | transitive `nix` dependency in the guest binaries and the Linux `bin/nvx-rs` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`Gilnaa/memoffset`](https://crates.io/crates/memoffset/0.9.1). | | Rust `serde` crate 1.0.229 | serialization framework of `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`serde-rs/serde`](https://crates.io/crates/serde/1.0.229). | @@ -37,6 +37,18 @@ the distribution tooling. | Rust `typenum` crate 1.20.1 | transitive `sha2` dependency in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`paholg/typenum`](https://crates.io/crates/typenum/1.20.1). | | Rust `windows-sys` crate 0.61.2 | Windows API bindings of `bin/nvx-rs.exe` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`microsoft/windows-rs`](https://crates.io/crates/windows-sys/0.61.2). | | Rust `windows-link` crate 0.2.1 | transitive `windows-sys` dependency in `bin/nvx-rs.exe` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`microsoft/windows-rs`](https://crates.io/crates/windows-link/0.2.1). | +| Rust `crossterm` crate 0.29.0 | terminal-mode preservation in `bin/nvx-rs[.exe]` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`crossterm`](https://crates.io/crates/crossterm/0.29.0). | +| Rust `crossterm_winapi` crate 0.9.1 | transitive `crossterm` Windows console support in `bin/nvx-rs.exe` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`crossterm_winapi`](https://crates.io/crates/crossterm_winapi/0.9.1). | +| Rust `winapi` crate 0.3.9 | transitive `crossterm` and `crossterm_winapi` Windows API bindings in `bin/nvx-rs.exe` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`winapi`](https://crates.io/crates/winapi/0.3.9). | +| Rust `parking_lot` crate 0.12.5 | transitive `crossterm` synchronization in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`parking_lot`](https://crates.io/crates/parking_lot/0.12.5). | +| Rust `parking_lot_core` crate 0.9.12 | transitive `parking_lot` synchronization in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`parking_lot_core`](https://crates.io/crates/parking_lot_core/0.9.12). | +| Rust `lock_api` crate 0.4.14 | transitive `parking_lot` synchronization in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`lock_api`](https://crates.io/crates/lock_api/0.4.14). | +| Rust `scopeguard` crate 1.2.0 | transitive `lock_api` cleanup guards in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`scopeguard`](https://crates.io/crates/scopeguard/1.2.0). | +| Rust `smallvec` crate 1.16.5 | transitive `parking_lot_core` storage in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`smallvec`](https://crates.io/crates/smallvec/1.16.5). | +| Rust `document-features` crate 0.2.12 | build-time procedural macro used by `crossterm`, not linked into `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`document-features`](https://crates.io/crates/document-features/0.2.12). | +| Rust `litrs` crate 1.0.1 | build-time `document-features` dependency, not linked into `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`litrs`](https://crates.io/crates/litrs/1.0.1). | +| Rust `rustix` crate 1.1.5 | transitive `crossterm` terminal-mode support in the Linux `bin/nvx-rs` | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`rustix`](https://crates.io/crates/rustix/1.1.5). | +| Rust `linux-raw-sys` crate 0.12.1 | transitive `rustix` Linux API bindings in the Linux `bin/nvx-rs` | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`linux-raw-sys`](https://crates.io/crates/linux-raw-sys/0.12.1). | | musl libc | statically linked into `/sbin/nvx-console-pending`, `/sbin/nvx-mmio-write`, `/sbin/nvx-reseed`, `/sbin/nvx-exit`, `/sbin/nvx-device-io`, `/sbin/nvx-managed-agent`, `/sbin/nvx-ingress`, `/sbin/nvx-time`, `/sbin/nvx-time-probe`, and `/sbin/nvx-init` in every generated initramfs, and into `bin/nvx-rs` of the `linux-kvm` and `linux-mshv` release packages | MIT; Copyright © 2005-2020 Rich Felker, et al. The exact implementation is supplied by the pinned Rust release's `x86_64-unknown-linux-musl` target. Upstream source and the full copyright notice are at [musl libc](https://musl.libc.org/). | | LLVM `libunwind` | statically linked, with musl, into the ten guest binaries and the Linux `bin/nvx-rs`, which unwind and print backtraces through it | Apache-2.0 WITH LLVM-exception, whose exception lifts the attribution conditions for code embedded in a compiled program. The exact implementation is the `libunwind.a` that the pinned Rust release's `x86_64-unknown-linux-musl` target supplies, which the Rust project builds from its fork of [`llvm-project/libunwind`](https://github.com/llvm/llvm-project/tree/main/libunwind). | | Microsoft C runtime: the Visual C++ runtime and the Universal C Runtime | statically linked into `bin/nvx-rs.exe` of the `windows-whp` release package, which CI builds with `-C target-feature=+crt-static`, so that it runs without the Visual C++ Redistributable | Proprietary Microsoft object code, linked in by the Visual Studio build tools and the Windows SDK of CI's Windows runner under their Microsoft Software License Terms, which permit distributing the programs built with them; see [Redistribute Visual C++ files](https://learn.microsoft.com/cpp/windows/redistributing-visual-cpp-files). No source is distributed. | diff --git a/doc/usage.md b/doc/usage.md index 84074e9ba..f0b6e793a 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -164,7 +164,7 @@ nvx-rs/target/release/nvx-rs run | `--memory-capacity-mib MIB` | none | Reserve an immutable, 128 MiB-aligned RAM capacity for a fresh microVM snapshot, in the range of `--memory-mib`. | | `--processors {1,2,4,8}` | `1` | Select the microVM processor count, written as one of the choices. | | `--cpu-profile ID` | `auto` | Select the guest's [CPU profile](#cpu-profiles): `auto` for the built-in profile of the host's CPU, which falls back with a warning to a development profile derived from this host on an Intel or AMD CPU that no built-in profile serves, a built-in profile ID, or `host` for that development profile. A restore uses the snapshot's profile, which `auto` and, for a host profile, `host` also name. | -| `--mount GUEST_TARGET,HOST_PATH[,ro\|rw]` | none | Expose a host directory, or a single regular host file without its directory, at the guest target: an absolute, canonical path other than `/`, without whitespace, control characters, double quotes, backslashes, `=`, or any UTF-8 byte that Linux treats as whitespace, such as the `0xa0` in `à`. The mode follows the last comma, so `HOST_PATH` may contain commas when the mode is given. Repeat to expose more directories or files, each with its own target and mode, as the children of an aggregate on the microVM's one virtio-fs device, which the guest binds at their targets; a shared file always uses the aggregate. Targets and host paths must not overlap, and the shares' kernel command-line tokens must fit its 1024-byte budget. An `rw` mapping accepts guest-created symbolic links, which the host never follows. Active snapshot restore requires the same mappings in the same order, with the same canonical paths, kinds, targets, modes, and ownership mode; a dormant-slot restore may attach one new directory mapping that the resumed guest mounts explicitly. | +| `--mount GUEST_TARGET,HOST_PATH[,ro\|rw]` | none | Expose a host directory, or a single regular host file without its directory, at the guest target: an absolute, canonical path other than `/`, without whitespace, control characters, double quotes, backslashes, `=`, or any UTF-8 byte that Linux treats as whitespace, such as the `0xa0` in `à`. Cold boots also protect the system paths listed below. The mode follows the last comma, so `HOST_PATH` may contain commas when the mode is given. Repeat to expose more directories or files, each with its own target and mode, as the children of an aggregate on the microVM's one virtio-fs device, which the guest binds at their targets; a shared file always uses the aggregate. Targets and host paths must not overlap, and the shares' kernel command-line tokens must fit its 1024-byte budget. An `rw` mapping accepts guest-created symbolic links, which the host never follows. Active snapshot restore requires the same mappings in the same order, with the same canonical paths, kinds, targets, modes, and ownership mode; a dormant-slot restore may attach one new directory mapping that the resumed guest mounts explicitly. | | `--mount-deny HOST_PATH` | none | Hide one existing file or directory inside a mounted host root; repeat to deny multiple paths. It may name the root itself when `--mount-allow` paths inside it remain. With several mappings, or a shared file, the path must be absolute; a shared file takes none. | | `--mount-allow HOST_PATH` | none | Expose one existing file or directory inside a `--mount-deny` path again; the hidden directories on the way list only the entries that lead to it. Repeat to allow multiple paths. With several mappings, or a shared file, the path must be absolute; a shared file takes none. See [Access policy](run.md#access-policy). | | `--mount-write HOST_PATH` | none | Make one existing file or directory one of the only writable parts of an `rw` mapping; the guest's writes anywhere else in it fail with `EROFS`. Repeat to declare multiple writable paths. With several mappings, or a shared file, the path must be absolute; a shared file takes none. Active snapshot restore requires the same denied, allowed, and writable paths. | @@ -179,8 +179,8 @@ nvx-rs/target/release/nvx-rs run | `--host-loopback {allow,deny}` | existing mapping | Control access between the guest and host loopback services. Explicit `allow` is bidirectional: the guest reaches host loopback through its gateway, and every TCP and UDP port on which the guest listens is published on host loopback for the host alone, or with `--network-ingress allow` admits the host as well; it needs a `--net` prefix of `/29` or shorter, which OpenVMM checks; see [Host-loopback access](run.md#host-loopback-access). Snapshot capture rejects `allow`, and so does a restore, unless `--restore-network-policy` installs a new policy. | | `--network-proxy IPV4:TCP-PORT` | none | Allow one explicit host TCP proxy endpoint. | | `--host-loopback-forward PROTOCOL:HOST_PORT:GUEST_PORT` | none | Publish one TCP or UDP localhost port to the guest; repeat to add forwards. Snapshot capture rejects forwards, and so does a restore, unless `--restore-network-policy` installs a new policy. | -| `--outcome-report PATH` | none | Write a bounded local JSON outcome report. | -| `--cmdline TEXT` | empty | Append kernel parameters; `nvx_*` and `tsc=` tokens are reserved. | +| `--outcome-report PATH` | none | Write a bounded local JSON outcome report to a new file in an existing plain directory. A cold boot rejects an existing destination before launch and never replaces a file or follows a destination symlink. A missing report or publication failure is diagnosed without replacing OpenVMM's exit status. | +| `--cmdline TEXT` | empty | Append kernel parameters; `nvx_*` and `tsc=` tokens are reserved. Cold boots reject double quotes and a standalone `--` token, which the VM control console cannot represent; snapshot restore retains its existing command-line handling. | | `--restore-snapshot PATH` | none | Restore the immutable machine contract and saved state from a snapshot directory. A snapshot captured with `--net` needs `--network-profile portable` and the network policy options of its capture, unless `--restore-network-policy` installs a new policy; see [Networked snapshots](run.md#networked-snapshots). | | `--restore-processors {1,2,4,8}` | none | Bring this contiguous processor prefix online before restore readiness. Requires an opt-in microVM snapshot and cannot exceed `--processors` capacity. | | `--restore-memory-mib MIB` | none | Select the 128 MiB-aligned RAM target for an expansion-capable snapshot restore, in the range of `--memory-mib`. | @@ -188,6 +188,15 @@ nvx-rs/target/release/nvx-rs run | `--restore-network-policy` | off | Install the policy of the network options, which may include `--network-ingress allow`, explicit `--host-loopback allow`, and forwards, in place of the complete deny-all policy, `--network-egress deny` and `--host-loopback deny` without egress rules, a proxy, or ingress, that the snapshot's NIC was captured with. The guest applies the new policy's DNS server and listener announcements before the host releases its input; see [Networked snapshots](run.md#networked-snapshots). Requires `--restore-snapshot`. | | `--dry-run` | off | Print the generated OpenVMM command without running it. | +Cold `--mount` targets may not cover, equal, or lie below `/bin`, `/boot`, +`/dev`, `/etc`, `/lib`, `/proc`, `/root`, `/run`, `/sbin`, `/sys`, `/usr`, +`/var`, or `/init`: these paths belong to the guest's blockless system. +For example, use `/config/single.txt` rather than `/etc/single.txt` for a +shared configuration file. Snapshot restore keeps its existing mapping rules. +Cold mount-policy options require a `--mount`, and network-policy options +require networking. A proxy is the guest's sole egress path, so it cannot be +combined with `--network-egress-allow` rules for direct connections. + `nvx-rs run` checks its options, prints the OpenVMM command after `>> `, and unless `--dry-run` is given, runs OpenVMM with the standard streams. A cold boot attaches the guest's console, the boot console `hvc1`, to them, so that diff --git a/guest/common/nvx-hostmount b/guest/common/nvx-hostmount index 837ec640e..944b5223b 100644 --- a/guest/common/nvx-hostmount +++ b/guest/common/nvx-hostmount @@ -4,7 +4,8 @@ # virtfs_mode= tokens describe. When virtfs_aggregate=1 marks it as an # aggregate of host directories and files, its root lists one child per # directory or file, which only root can enter, and each -# nvx_share=CHILD,TARGET,MODE[,file] token binds one child at TARGET with +# nvx_share=SOURCE,TARGET,MODE[,file] token binds a child or child-relative +# source from the planned export at TARGET with # MODE, without set-user-ID programs or device nodes; the file suffix marks a # child that is a host file, which is bound at a file. With --unmount, the # binds and then the share are unmounted. @@ -53,8 +54,20 @@ parse_share() { esac share_target=${share_rest%,*} share_mode=${share_rest##*,} + case "/$share_child/" in + *"//"* | *"/./"* | *"/../"*) fail "invalid live share $1" ;; + esac case "$share_child" in - '' | . | .. | *[!A-Za-z0-9._-]*) fail "invalid live share $1" ;; + */*) + share_root=${share_child%%/*} + case "$share_root" in + '' | *[!0-9]*) fail "invalid live share $1" ;; + esac + case "$share_child" in + *\\*) fail "invalid live share $1" ;; + esac + ;; + *[!A-Za-z0-9._-]*) fail "invalid live share $1" ;; esac case "$share_mode" in ro | rw) ;; *) fail "invalid live share mode in $1" ;; esac case "$share_target" in /?*) ;; *) fail "invalid live share target in $1" ;; esac diff --git a/nvx-rs/Cargo.lock b/nvx-rs/Cargo.lock index 311a844ef..8a1f16bff 100644 --- a/nvx-rs/Cargo.lock +++ b/nvx-rs/Cargo.lock @@ -70,6 +70,29 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -90,6 +113,15 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "errno" version = "0.3.14" @@ -146,6 +178,21 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "memchr" version = "2.8.3" @@ -187,6 +234,7 @@ dependencies = [ name = "nvx" version = "0.1.0-alpha.3" dependencies = [ + "crossterm", "getrandom", "nix", "proptest", @@ -203,6 +251,29 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -268,6 +339,15 @@ dependencies = [ "rand_core", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex-syntax" version = "0.8.11" @@ -287,6 +367,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.229" @@ -341,6 +427,12 @@ dependencies = [ "digest", ] +[[package]] +name = "smallvec" +version = "1.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46" + [[package]] name = "syn" version = "3.0.6" @@ -389,6 +481,28 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/nvx-rs/Cargo.toml b/nvx-rs/Cargo.toml index 131b63937..7caed59f5 100644 --- a/nvx-rs/Cargo.toml +++ b/nvx-rs/Cargo.toml @@ -43,7 +43,7 @@ bundled = ["host", "dep:sha2"] testing = ["dep:getrandom", "dep:serde", "dep:serde_json", "dep:nix", "dep:windows-sys"] # NVX's own `nvx-rs` binary, the command-line interface that runs NVX's microVMs and sandboxes, # compiles egress policies, looks up artifacts, and downloads releases. It adds no API. -cli = ["host"] +cli = ["host", "dep:crossterm"] # The serde forms of `Hypervisor` and `net::Ipv4Cidr`. The `host` feature enables it. serde = ["dep:serde"] # The programs that run in Linux guests: the `guest` module and the `nvx-*` guest binaries. Build @@ -51,6 +51,7 @@ serde = ["dep:serde"] guest = ["dep:nix", "dep:getrandom"] [dependencies] +crossterm = { version = "0.29", default-features = false, features = ["windows"], optional = true } getrandom = { version = "0.4", optional = true } serde = { version = "1.0.200", features = ["derive"], optional = true } serde_json = { version = "1.0.120", optional = true } diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 7ca14db6c..e9fa8fdf9 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -2086,6 +2086,7 @@ field nvx::host::config::BlockGeometry::physical_block_size struct nvx::host::config::Boot field nvx::host::config::Boot::capture field nvx::host::config::Boot::command_line +field nvx::host::config::Boot::memory_capacity_mib const nvx::host::config::COMMAND_LINE_BUDGET struct nvx::host::config::Capacity field nvx::host::config::Capacity::boot diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index 312906a37..655540281 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -672,6 +672,7 @@ field nvx::host::config::BlockGeometry::physical_block_size struct nvx::host::config::Boot field nvx::host::config::Boot::capture field nvx::host::config::Boot::command_line +field nvx::host::config::Boot::memory_capacity_mib const nvx::host::config::COMMAND_LINE_BUDGET struct nvx::host::config::Capacity field nvx::host::config::Capacity::boot diff --git a/nvx-rs/src/host/artifacts/dir.rs b/nvx-rs/src/host/artifacts/dir.rs index dddab5c11..08d80a541 100644 --- a/nvx-rs/src/host/artifacts/dir.rs +++ b/nvx-rs/src/host/artifacts/dir.rs @@ -235,8 +235,13 @@ impl ArtifactDir { /// unreadable, or names no valid digest of an artifact. A file of metadata that is not a /// regular file, such as a FIFO, is refused without being waited on. pub fn runtime_identity(&self) -> Result { + self.runtime_identity_for(Guest::Alpine) + } + + /// Identifies the selected guest through its install metadata, without reading its image. + pub(crate) fn runtime_identity_for(&self, guest: Guest) -> Result { let [openvmm, kernel, initramfs] = match self.layout { - Layout::Release => self.listed_identities()?, + Layout::Release => self.listed_identities(guest)?, Layout::Repository => [ provenance_identity( &self.artifact(OPENVMM_PROVENANCE_NAME), @@ -248,11 +253,33 @@ impl ArtifactDir { "kernel", "kernel_sha256", )?, - provenance_identity( - &self.artifact(INITRAMFS_PROVENANCE_NAME), - "initramfs", - "initramfs_sha256", - )?, + if guest == Guest::Alpine { + provenance_identity( + &self.artifact(INITRAMFS_PROVENANCE_NAME), + "initramfs", + "initramfs_sha256", + )? + } else { + let path = self.package_manifest(guest); + let text = read_metadata( + &path, + "guest package manifest", + "build or install the selected guest", + )?; + let invalid = || { + Error::unavailable(format!( + "guest package manifest {} has no valid artifact SHA-256 digest", + path.display() + )) + }; + let manifest: serde_json::Value = serde_json::from_str(&text) + .map_err(|error| invalid().with_source(error))?; + manifest + .get("artifact_sha256") + .and_then(serde_json::Value::as_str) + .and_then(sha256_identity) + .ok_or_else(invalid)? + }, ], }; Ok(RuntimeIdentity::new(openvmm, kernel, initramfs)) @@ -261,7 +288,7 @@ impl ArtifactDir { /// Returns the identities of the OpenVMM executable, kernel, and Alpine initramfs of a /// release, as its `SHA256SUMS` lists their digests: one line for each file, its 64 /// lowercase hexadecimal digits, two spaces, and its path in the release. - fn listed_identities(&self) -> Result<[ArtifactIdentity; 3]> { + fn listed_identities(&self, guest: Guest) -> Result<[ArtifactIdentity; 3]> { let path = self.root.join(CHECKSUMS_NAME); let description = "release checksum list"; let text = read_metadata(&path, description, "the release is incomplete")?; @@ -295,10 +322,7 @@ impl ArtifactDir { Ok([ listed(format!("{RELEASE_BIN_DIR}/{}", openvmm_executable()))?, listed(format!("{RELEASE_GUEST_DIR}/{KERNEL_NAME}"))?, - listed(format!( - "{RELEASE_GUEST_DIR}/{}", - Guest::Alpine.initramfs_name() - ))?, + listed(format!("{RELEASE_GUEST_DIR}/{}", guest.initramfs_name()))?, ]) } } diff --git a/nvx-rs/src/host/artifacts/tests.rs b/nvx-rs/src/host/artifacts/tests.rs index 3e4de6351..8d0b14be1 100644 --- a/nvx-rs/src/host/artifacts/tests.rs +++ b/nvx-rs/src/host/artifacts/tests.rs @@ -574,6 +574,31 @@ fn a_checkout_identifies_its_runtime_by_the_build_provenance_of_its_artifacts() ); } +#[test] +fn selected_guests_take_their_own_initramfs_digest_from_install_metadata() { + let directory = tempfile::tempdir().unwrap(); + let dir = ArtifactDir::repository(directory.path()).unwrap(); + let digests = ["a", "b", "c"].map(|digit| digit.repeat(64)); + record_provenance(directory.path(), &digests); + for guest in [Guest::Ubuntu, Guest::AzureLinux] { + let selected = "d".repeat(64); + let manifest = dir.package_manifest(guest); + fs::write(&manifest, format!("{{\"artifact_sha256\":\"{selected}\"}}")).unwrap(); + let identity = dir.runtime_identity_for(guest).unwrap(); + assert_eq!( + identity.initramfs, + ArtifactIdentity::sha256(&selected).unwrap() + ); + assert_eq!(identity.openvmm, identity_of(&digests).openvmm); + fs::write(&manifest, "{\"artifact_sha256\":\"invalid\"}").unwrap(); + assert_eq!( + dir.runtime_identity_for(guest).unwrap_err().kind(), + ErrorKind::Unavailable + ); + } + assert_eq!(dir.runtime_identity().unwrap(), identity_of(&digests)); +} + #[test] fn a_release_identifies_its_runtime_by_its_checksum_list() { let directory = tempfile::tempdir().unwrap(); diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index a7e8bc29c..914d2e587 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -7,10 +7,9 @@ //! `nvx.py run` with their names, defaults, types, and choices, and ends a usage error with //! status 2. //! -//! A cold boot attaches the guest's console, the boot console `hvc1`, to the terminal with -//! `--virtio-console console`, so that the terminal's input reaches the guest through it rather -//! than through the byte channel, `hvc0`, whose output, such as the kernel's early messages, goes -//! to standard error. A restore attaches the boot console as its snapshot had it: it reads the +//! A cold boot forwards the terminal through the VM's boot-console listener, `hvc1`, and the +//! VM's log to standard error. Its temporary private VM directory is removed after confirmed +//! exit. A restore attaches the boot console as its snapshot had it: it reads the //! snapshot's manifest, `manifest.bin` in the snapshot's directory, dry run or not, and passes //! `--virtio-console console` when the manifest's machine contract holds the attachment of the //! boot console, `console:microvm-virtio0`, with the reconnect policy @@ -31,41 +30,61 @@ //! new policy's DNS server and listener announcements before the host releases its input. //! //! Its shares differ from those of `sandbox`. `--mount-deny`, `--mount-allow`, and `--mount-write` -//! name host paths, which OpenVMM attributes to the share that holds them, and which must be -//! absolute with several shares or a shared file. One directory share uses `--mount`, several -//! shares or a file the children of an aggregate, whose `nvx_share=` tokens a cold boot adds to -//! the kernel command line, and a restore does not, since its guest keeps the binds that its boot -//! made. +//! name host paths, which must be absolute with several shares or a shared file. A cold boot +//! checks their ownership in the filesystem export plan, exports its numbered children, and adds +//! the plan's `nvx_share=` rows to the kernel command line. A restore keeps its existing lowering: +//! one directory uses `--mount`, several shares or a file use an aggregate, and no new share tokens +//! replace the binds that its saved guest already made. //! //! After OpenVMM exits with a status other than 0 from a cold boot, on a host whose CPU no built-in //! CPU profile serves, it prints the guidance of [`cpu_profile`] to standard error, which says //! what `--cpu-profile auto` does on that CPU and how to request a built-in profile for it. It //! never prints it after a dry run, a restore, a refused option, or Ctrl-C. +mod guest_tokens; + +use std::fs::{self, File}; +#[cfg(windows)] +use std::io::Write; +use std::io::{self, IsTerminal, Read}; use std::path::Path; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +use sha2::{Digest, Sha256}; use super::args::{Parser, Receiver, Spec, Syntax, choice, integer}; use super::artifacts::{Located, locate}; use super::cpu_profile::{self, HostCpu}; -use super::download; +use super::download::{self, Hold}; use super::network::{NativeRules, NetworkOptions}; use super::sandbox::{ - HYPERVISORS, MAX_MEMORY_MIB, MOUNT_OWNERS, error_with_cause, hypervisor, path_string, - path_text, run_openvmm, + HYPERVISORS, MAX_MEMORY_MIB, MOUNT_OWNERS, copy_openvmm_outcome, error_with_cause, hypervisor, + path_string, path_text, run_openvmm, sandbox_network, validate_outcome_destination, }; +use super::session::Session; use super::shell::format_command; use super::{Exit, Result, check_interrupt, output}; use crate::ErrorCode; -use crate::host::artifacts::{Guest, require_artifact}; +use crate::activation::{MachineIdMode, View}; +use crate::host::artifacts::{ArtifactDir, Guest, require_artifact}; +use crate::host::config::{Boot, ConsoleAttachment, NetworkConfig, RootConfig, VmConfig}; +use crate::host::filesystem::{ + self, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, +}; +use crate::host::platform::{self, Transport}; +use crate::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime}; use crate::host::sandbox::mount::{ - AGGREGATE_MOUNT_TARGET, MountKind, MountOwner, POLICY_KINDS, PolicyKind, + AGGREGATE_MOUNT_TARGET, MountAccess, MountKind, MountOwner, POLICY_KINDS, PolicyKind, aggregate_child_argument, aggregate_child_name, aggregate_command_line_fragment, - aggregate_share_tokens, mount_argument, mount_kind, parse_mount_value, - require_mount_owner_supported, + aggregate_share_tokens, export_command_line_fragment, mount_argument, mount_kind, + parse_mount_value, require_mount_owner_supported, }; use crate::host::sandbox::options::validate_run_share_targets; use crate::host::sandbox::{SANDBOX_COMMAND_LINE_MAX_SIZE, has_kernel_separator}; use crate::host::template::read_attachments; +use crate::host::vm::{self, VmDir}; /// The usage and help of the parser. pub(super) const SYNTAX: Syntax = Syntax { @@ -413,8 +432,12 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { // nothing, though it reads the artifacts under the lock of downloads all the same. Their // arguments are built again once the artifacts are in place, as a share's kind depends on what // its host path holds, which a download may install. - let refused = terminal_console.as_ref().is_some_and(Result::is_err) - || extend_shares_and_network(&mut Vec::new(), &options, &rules).is_err(); + let refused = if options.restore_snapshot.is_some() { + terminal_console.as_ref().is_some_and(Result::is_err) + || extend_shares_and_network(&mut Vec::new(), &options, &rules).is_err() + } else { + cold_config(&options, &rules).is_err() + }; let located = locate(root)?; // A restore boots no kernel or initramfs: the snapshot holds the guest. let guest = options.restore_snapshot.is_none().then_some(options.guest); @@ -425,6 +448,9 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { }; let Located { dir, openvmm } = located; require_artifact(&openvmm, "OpenVMM release binary")?; + if options.restore_snapshot.is_none() { + return run_cold(dir, openvmm, &options, &rules, hold); + } let mut command = vec![ path_text(&path_string(&openvmm)?), "--single-process".to_owned(), @@ -449,21 +475,6 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { if terminal_console.transpose()? == Some(true) { push(&mut command, "--virtio-console", "console"); } - } else { - let kernel = dir.require_kernel()?; - let initrd = dir.require_initramfs(options.guest)?; - push( - &mut command, - "--memory", - format!("{}M", memory_mib(&options)), - ); - push(&mut command, "--kernel", path_string(&kernel)?); - push(&mut command, "--initrd", path_string(&initrd)?); - if let Some(capacity) = &options.memory_capacity_mib { - push(&mut command, "--memory-capacity", format!("{capacity}M")); - } - // The guest's console takes the terminal. - push(&mut command, "--virtio-console", "console"); } if let Some(profile) = &options.cpu_profile { push(&mut command, "--cpu-profile", profile.as_str()); @@ -478,19 +489,561 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { return Ok(()); } let status = run_openvmm(&command, &[], hold)?; - if status != 0 - && options.restore_snapshot.is_none() - && let Some(guidance) = cpu_profile::guidance( - options.cpu_profile.as_deref(), - HostCpu::current().as_ref(), - &format_command(&command[..1]), - hypervisor(options.hypervisor), - ) + Err(Exit::Status(status)) +} + +fn cold_config(options: &Options, rules: &NativeRules) -> Result { + let shares = options + .mounts + .iter() + .map(|value| parse_mount_value(value)) + .collect::>>()?; + validate_run_share_targets( + &shares + .iter() + .map(|(target, _, _)| target.as_str()) + .collect::>(), + )?; + if shares.is_empty() && (options.mount_owner.is_some() || !options.policy_paths.is_empty()) { + return Err(Exit::error( + ErrorCode::Unsupported, + if options.mount_owner.is_some() { + "--mount-owner requires --mount" + } else { + "mount policy options require --mount" + }, + )); + } + let hosts: Vec<_> = shares + .iter() + .map(|(_, host, _)| Path::new(host).to_owned()) + .collect(); + let aggregate = + shares.len() > 1 || hosts.iter().any(|host| mount_kind(host) == MountKind::File); + let mut policies: [Vec; 3] = Default::default(); + for kind in POLICY_KINDS { + for (_, given) in options.policy_paths.iter().filter(|(of, _)| *of == kind) { + let given = path_text(given); + let path = Path::new(&given); + if aggregate && !path.is_absolute() { + return Err(Exit::error( + ErrorCode::Unsupported, + format!( + "with several --mount shares, or a shared file, {} {given} must be an absolute host path", + kind.option() + ), + )); + } + let path = if path.is_absolute() { + path.to_owned() + } else { + hosts[0].join(path) + }; + policies[match kind { + PolicyKind::Denied => 0, + PolicyKind::Allowed => 1, + PolicyKind::Writable => 2, + }] + .push(path); + } + } + let mappings: Vec<_> = shares + .iter() + .zip(&hosts) + .map(|((target, _, access), host)| Mapping { + name: "--mount", + path: host, + target: Target::Guest(target), + read_only: *access == MountAccess::ReadOnly, + }) + .collect(); + let filesystem = filesystem::plan(&ExportRequest { + mappings: &mappings, + denied: PathList { + name: "--mount-deny", + paths: &policies[0], + }, + allowed: PathList { + name: "--mount-allow", + paths: &policies[1], + }, + write: PathList { + name: "--mount-write", + paths: &policies[2], + }, + owner: if options.mount_owner == Some(MountOwner::Caller) { + Owner::Caller + } else { + Owner::Vmm + }, + reserved: ReservedTargets::BLOCKLESS_ROOT, + }) + .map_err(error_with_cause)?; + let network = sandbox_network(&options.network, rules)? + .map(|network| -> Result { + Ok(NetworkConfig { + guest_network: Some( + crate::net::Ipv4Cidr::parse(network.guest_network()) + .map_err(|error| Exit::error(ErrorCode::Unsupported, error.to_string()))?, + ), + policy: network.policy().clone(), + }) + }) + .transpose()?; + let config = VmConfig { + processors: options.processors, + memory_mib: memory_mib(options), + root: RootConfig::Blockless, + view: View { + hostname: b"nvx".to_vec(), + machine_id: MachineIdMode::Image, + }, + network, + console: ConsoleAttachment::Listener, + boot: Some(Boot { + command_line: guest_tokens::render(&options.cmdline, filesystem.as_ref()) + .map_err(error_with_cause)?, + memory_capacity_mib: options.memory_capacity_mib, + capture: None, + }), + filesystem, + }; + if let Some(export) = &config.filesystem { + let line = config.kernel_command_line().map_err(error_with_cause)?; + if line.len() + export_command_line_fragment(export).len() + 1 + > SANDBOX_COMMAND_LINE_MAX_SIZE + { + return Err(Exit::error( + ErrorCode::Unsupported, + "the --mount shares and --cmdline exceed the 1024-byte budget of the kernel command \ + line; attach fewer shares or use shorter targets", + )); + } + } + config.check_command_line().map_err(error_with_cause)?; + Ok(config) +} + +fn remove_vm_directory(dir: &VmDir) -> Result { + for name in [ + "openvmm.log", + "outcome.json", + "control.sock", + "console.sock", + ] { + match fs::remove_file(dir.path().join(name)) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(Exit::from_os_error(error)), + } + } + fs::remove_dir(dir.path()).map_err(Exit::from_os_error) +} + +fn connect_running( + launched: &mut vm::Launched, + endpoint: &str, +) -> Result>> { + let deadline = Instant::now() + Duration::from_secs(60); + loop { + check_interrupt()?; + match platform::connect_endpoint( + endpoint, + launched.process.reference().pid, + Duration::from_millis(250), + ) { + Ok(transport) => return Ok(Some(transport)), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound + | io::ErrorKind::ConnectionRefused + | io::ErrorKind::TimedOut + | io::ErrorKind::WouldBlock + ) => {} + Err(error) => return Err(Exit::from_os_error(error)), + } + if launched + .wait_status(Duration::ZERO) + .map_err(Exit::from_os_error)? + .is_some() + { + return Ok(None); + } + if Instant::now() >= deadline { + return Err(Exit::error( + ErrorCode::TimedOut, + format!("VM console endpoint did not become available: {endpoint}"), + )); + } + thread::sleep(Duration::from_millis(10)); + } +} + +struct TerminalGuard(bool); + +impl TerminalGuard { + fn new() -> Result { + let terminal = io::stdin().is_terminal(); + if terminal { + crossterm::terminal::enable_raw_mode().map_err(Exit::from_os_error)?; + } + Ok(Self(terminal)) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + if self.0 + && let Err(error) = crossterm::terminal::disable_raw_mode() + { + output::print_stderr(&format!("cannot restore terminal mode: {error}")); + } + } +} + +type InputCompletion = mpsc::Receiver>; + +fn console_input(console: Box) -> Result<(Box, InputCompletion)> { + #[cfg(any(target_os = "linux", windows))] + { + let duplicate = || { + console + .try_clone_handle() + .map_err(Exit::from_os_error)? + .ok_or_else(|| { + Exit::error( + ErrorCode::Unsupported, + "the VM console cannot be duplicated", + ) + }) + }; + let reader = duplicate()?; + let writer = duplicate()?; + drop(console); + let reader = platform::transport_from_handle(reader).map_err(Exit::from_os_error)?; + let mut writer = platform::transport_from_handle(writer).map_err(Exit::from_os_error)?; + let (sender, receiver) = mpsc::sync_channel(1); + thread::Builder::new() + .name("nvx-run-input".to_owned()) + .spawn(move || { + let result = (|| { + let mut stdin = io::stdin().lock(); + let mut buffer = [0; 8192]; + loop { + let count = stdin.read(&mut buffer)?; + if count == 0 { + return Ok(()); + } + match writer.write_all(&buffer[..count], None) { + Ok(()) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionReset + | io::ErrorKind::WriteZero + ) => + { + return Ok(()); + } + Err(error) => return Err(error), + } + } + })(); + let _ = sender.send(result); + }) + .map_err(Exit::from_os_error)?; + Ok((reader, receiver)) + } + #[cfg(not(any(target_os = "linux", windows)))] + { + drop(console); + Err(Exit::error( + ErrorCode::Unsupported, + "foreground console I/O requires a supported NVX host", + )) + } +} + +fn write_console(data: &[u8]) -> io::Result<()> { + #[cfg(windows)] + { + use std::os::windows::io::AsHandle; + + if platform::interrupted() { + return Err(io::Error::new(io::ErrorKind::Interrupted, "interrupted")); + } + let mut stdout = io::stdout().lock(); + stdout.flush()?; + File::from(stdout.as_handle().try_clone_to_owned()?).write_all(data) + } + #[cfg(not(windows))] + output::write_stdout(data) +} + +fn foreground( + launched: &mut vm::Launched, + log: &mut File, + managed: bool, + control: &mut Option, +) -> Result { + let _terminal = TerminalGuard::new()?; + let endpoint = launched + .outputs + .console + .clone() + .expect("a cold run requests a console listener"); + let (mut console, input) = if let Some(console) = connect_running(launched, &endpoint)? { + let (console, input) = console_input(console)?; + (Some(console), Some(input)) + } else { + (None, None) + }; + let mut attach_closed = None; + if managed && console.is_some() { + let endpoint = launched.outputs.endpoint.clone(); + let capability = *launched.capability.as_bytes(); + match Session::connect_launched( + &endpoint, + launched.child_mut().map_err(Exit::from_os_error)?, + &capability, + 60.0, + ) { + Ok(session) => *control = Some(session), + Err(Exit::EndpointClosed) => { + attach_closed = Some(Instant::now() + Duration::from_secs(60)); + } + Err(error) => return Err(error), + } + } + let mut buffer = [0; 8192]; + loop { + check_interrupt()?; + let count = log.read(&mut buffer).map_err(Exit::from_os_error)?; + output::write_stderr(&buffer[..count]).map_err(Exit::from_os_error)?; + let status = launched + .wait_status(Duration::ZERO) + .map_err(Exit::from_os_error)?; + if let Some(input) = &input + && status.is_none() + && let Ok(result) = input.try_recv() + { + result.map_err(Exit::from_os_error)?; + } + if let Some(transport) = &mut console { + match transport.read(&mut buffer, Some(Duration::from_millis(10))) { + Ok(0) => console = None, + Ok(count) => write_console(&buffer[..count]).map_err(Exit::from_os_error)?, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::TimedOut + | io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionReset + ) => {} + Err(error) => return Err(Exit::from_os_error(error)), + } + } + if let Some(status) = status { + loop { + let count = log.read(&mut buffer).map_err(Exit::from_os_error)?; + if count == 0 { + break; + } + output::write_stderr(&buffer[..count]).map_err(Exit::from_os_error)?; + } + if let Some(transport) = &mut console { + loop { + match transport.read(&mut buffer, Some(Duration::from_millis(10))) { + Ok(0) => break, + Ok(count) => { + write_console(&buffer[..count]).map_err(Exit::from_os_error)? + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::TimedOut + | io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionReset + ) => + { + break; + } + Err(error) => return Err(Exit::from_os_error(error)), + } + } + } + return Ok(status); + } + if attach_closed.is_some_and(|deadline| Instant::now() >= deadline) { + return Err(Exit::EndpointClosed); + } + thread::sleep(Duration::from_millis(10)); + } +} + +fn run_cold( + dir: ArtifactDir, + openvmm: std::path::PathBuf, + options: &Options, + rules: &NativeRules, + hold: Hold, +) -> Result { + let kernel = dir.require_kernel()?; + let initramfs = dir.require_initramfs(options.guest)?; + let config = cold_config(options, rules)?; + if !options.dry_run + && let Some(report) = &options.outcome_report { + validate_outcome_destination(report)?; + } + let mut identity = dir + .runtime_identity_for(options.guest) + .map_err(error_with_cause)?; + if openvmm != dir.openvmm() { + let mut file = File::open(&openvmm).map_err(Exit::from_os_error)?; + let mut digest = Sha256::new(); + let mut buffer = [0; 8192]; + loop { + let count = file.read(&mut buffer).map_err(Exit::from_os_error)?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + } + identity.openvmm = ArtifactIdentity::sha256(&format!("{:x}", digest.finalize()))?; + } + let runtime = Runtime { + openvmm, + kernel, + initramfs, + identity, + hypervisor: hypervisor(options.hypervisor).parse()?, + cpu_profile: options.cpu_profile.clone(), + windows_breakaway: false, + options: LaunchOptions::default(), + }; + let mut random = [0; 16]; + getrandom::fill(&mut random).map_err(|error| { + Exit::error( + ErrorCode::Io, + format!("cannot name the temporary VM: {error}"), + ) + })?; + let name: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); + let path = std::env::temp_dir().join(format!("nvx-run-{name}")); + if path.try_exists().map_err(Exit::from_os_error)? { + return Err(Exit::error( + ErrorCode::Io, + "the temporary VM directory already exists", + )); + } + vm::create_private_dir(&path)?; + let vm_dir = VmDir::open(&path)?; + let mut cleanup = true; + let result = (|| { + let prepared = vm::create(&runtime, &config, None, &vm_dir) + .map_err(error_with_cause)? + .for_foreground(); + let command: Vec<_> = std::iter::once(&runtime.openvmm) + .map(|path| path_string(path)) + .chain(prepared.arguments().iter().map(|arg| { + arg.to_str().map(str::to_owned).ok_or_else(|| { + Exit::error(ErrorCode::Unsupported, "an OpenVMM argument is not Unicode") + }) + })) + .collect::>()?; + output::print_stdout(&format!(">> {}", format_command(&command))) + .map_err(Exit::from_os_error)?; + if options.dry_run { + return Ok(()); + } + let mut log = File::open(&prepared.outputs().log).map_err(Exit::from_os_error)?; + if platform::opened_file_id(&log).map_err(Exit::from_os_error)? + != platform::opened_file_id(prepared.log()).map_err(Exit::from_os_error)? + { + return Err(Exit::error( + ErrorCode::Io, + "the VM log was replaced before its reader opened", + )); + } + cleanup = false; + let mut launched = vm::spawn(prepared).map_err(error_with_cause)?; + drop(hold); + let mut control = None; + let managed = config.boot.as_ref().is_some_and(|boot| { + boot.command_line + .split_ascii_whitespace() + .any(|token| token == "nvx_mode=managed") + }); + let status = foreground(&mut launched, &mut log, managed, &mut control); + let now = Instant::now(); + let mut graceful_error = None; + let stopped = vm::stop( + &launched.process, + &launched.outputs, + |deadline| { + let timeout = deadline.saturating_duration_since(Instant::now()); + if timeout.is_zero() { + return false; + } + control + .as_mut() + .is_some_and(|session| match session.stop(timeout.as_secs_f64()) { + Ok(()) => true, + Err(error) => { + graceful_error = Some(error); + false + } + }) + }, + now + Duration::from_millis(250), + now + Duration::from_secs(10), + ) + .map_err(error_with_cause)?; + cleanup = true; + if let Some(error) = graceful_error { + output::print_stderr(&format!("VM graceful stop failed: {error:?}")); + } check_interrupt()?; - output::print_stderr(&guidance); + let status = status?; + if let Err(error) = stopped.report { + output::print_stderr(&format!( + "error[io]: {}", + error_with_cause(error) + .message() + .unwrap_or("cannot read VM outcome") + )); + } + if let Some(report) = &options.outcome_report + && let Err(error) = copy_openvmm_outcome(&launched.outputs.outcome, report) + { + error.report(); + } + if status != 0 + && let Some(guidance) = cpu_profile::guidance( + options.cpu_profile.as_deref(), + HostCpu::current().as_ref(), + &format_command(&command[..1]), + hypervisor(options.hypervisor), + ) + { + check_interrupt()?; + output::print_stderr(&guidance); + } + Err(Exit::Status(status)) + })(); + if cleanup && let Err(error) = remove_vm_directory(&vm_dir) { + output::print_stderr(&format!( + "cannot remove the temporary VM directory {}: {error:?}", + vm_dir.path().display() + )); + if result.is_ok() || matches!(result, Err(Exit::Status(0))) { + return Err(error); + } } - Err(Exit::Status(status)) + result } #[cfg(test)] diff --git a/nvx-rs/src/host/cli/run/guest_tokens.rs b/nvx-rs/src/host/cli/run/guest_tokens.rs new file mode 100644 index 000000000..9659e568c --- /dev/null +++ b/nvx-rs/src/host/cli/run/guest_tokens.rs @@ -0,0 +1,86 @@ +//! The blockless guest settings owned by the CLI, beside the library's root and view tokens. + +use crate::host::filesystem::ExportPlan; +use crate::host::sandbox::{has_kernel_separator, parse_workload_identity}; +use crate::{Error, Result}; + +/// Renders both blockless modes and the planned boot binds, keeping other caller tokens. +pub(super) fn render(command_line: &str, export: Option<&ExportPlan>) -> Result { + let mut mode = "one-shot"; + let (mut uid, mut gid, mut identity) = (None, None, None); + let mut caller = String::new(); + let mut tokens = Vec::new(); + for part in command_line.split_inclusive(|ch: char| ch.is_ascii_whitespace()) { + let token = part.trim_ascii_end(); + let (name, value) = token.split_once('=').unwrap_or((token, "")); + match name { + "nvx_mode" | "nvx_lifecycle" => mode = value, + "nvx_workload_uid" => uid = Some(value), + "nvx_workload_gid" => gid = Some(value), + "nvx_identity" => identity = Some(value.to_owned()), + _ => { + caller.push_str(part); + continue; + } + } + caller.push_str(&part[token.len()..]); + } + if !matches!(mode, "one-shot" | "managed") { + return Err(Error::unsupported( + "blockless mode must be one-shot or managed", + )); + } + if uid.is_some() || gid.is_some() { + let (Some(uid), Some(gid)) = (uid, gid) else { + return Err(Error::unsupported("a workload needs both its UID and GID")); + }; + if identity.is_some() { + return Err(Error::unsupported( + "a workload identity may be specified only once", + )); + } + identity = Some(format!("{uid}:{gid}")); + } + tokens.push(format!("nvx_mode={mode}")); + if let Some(identity) = identity { + let (uid, gid) = parse_workload_identity(&identity)?; + tokens.push(format!("nvx_identity={uid}:{gid}")); + } + if let Some(export) = export { + for (row, bind) in export.guest_table().into_iter().zip(&export.binds) { + if [&row.source, &row.target] + .into_iter() + .any(|text| has_kernel_separator(text) || text.contains([',', '\\', '='])) + { + return Err(Error::unsupported( + "a planned boot share's source and target must each fit one kernel token", + )); + } + let child = &export.children[bind.child]; + let file = if bind.source.is_empty() { + child.file + } else { + let path = child.root.join(&bind.source); + std::fs::metadata(&path) + .map_err(|error| { + Error::io(format!("cannot inspect boot share {}", path.display())) + .with_source(error) + })? + .is_file() + }; + let access = if row.read_only { "ro" } else { "rw" }; + let kind = if file { ",file" } else { "" }; + tokens.push(format!( + "nvx_share={},{},{access}{kind}", + row.source, row.target + )); + } + } + if !caller.is_empty() { + tokens.insert(0, caller); + } + Ok(tokens.join(" ")) +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/host/cli/run/guest_tokens/tests.rs b/nvx-rs/src/host/cli/run/guest_tokens/tests.rs new file mode 100644 index 000000000..ed46ea5f1 --- /dev/null +++ b/nvx-rs/src/host/cli/run/guest_tokens/tests.rs @@ -0,0 +1,78 @@ +use super::*; +use crate::host::filesystem::{ExportRequest, Mapping, ReservedTargets, Target}; + +#[test] +fn blockless_modes_and_workloads_use_cli_owned_names() { + for mode in ["one-shot", "managed"] { + let line = render( + &format!( + "nvx_lifecycle={mode} nvx_workload_uid=1000 nvx_workload_gid=1001 \ + nvx_entrypoint=/bin/echo nvx_arg=a nvx_arg=--help nvx_workload_account=create" + ), + None, + ) + .unwrap(); + assert!(line.contains(&format!("nvx_mode={mode}"))); + assert!(line.contains("nvx_identity=1000:1001")); + assert!(line.contains("nvx_entrypoint=/bin/echo nvx_arg=a nvx_arg=--help")); + assert!(line.contains("nvx_workload_account=create")); + assert!(!line.contains("nvx_lifecycle=")); + assert!(!line.contains("nvx_workload_uid=")); + assert!(!line.contains("nvx_workload_gid=")); + } + assert_eq!( + render("quiet nvx_exec=/sbin/nvx-exit", None).unwrap(), + "quiet nvx_exec=/sbin/nvx-exit nvx_mode=one-shot" + ); + let caller = " quiet\tloglevel=3\nnvx_arg='x' "; + assert_eq!( + render(caller, None).unwrap(), + format!("{caller} nvx_mode=one-shot") + ); +} + +#[test] +fn partial_or_root_workload_identities_and_unknown_modes_are_refused() { + for line in [ + "nvx_workload_uid=1000", + "nvx_identity=0:1000", + "nvx_identity=1000:4294967295", + "nvx_mode=other", + ] { + assert!(render(line, None).is_err(), "{line}"); + } +} + +#[test] +fn planned_boot_shares_keep_table_sources_targets_access_and_file_kind() { + let root = tempfile::tempdir().unwrap(); + let directory = root.path().join("share"); + std::fs::create_dir(&directory).unwrap(); + let file = directory.join("single.txt"); + std::fs::write(&file, "data").unwrap(); + let mappings = [ + Mapping { + name: "--mount", + path: &directory, + target: Target::Guest("/workspace"), + read_only: true, + }, + Mapping { + name: "--mount", + path: &file, + target: Target::Guest("/config/single.txt"), + read_only: true, + }, + ]; + let plan = crate::host::filesystem::plan(&ExportRequest::new( + &mappings, + ReservedTargets::BLOCKLESS_ROOT, + )) + .unwrap() + .unwrap(); + let table = plan.guest_table(); + assert_eq!(table[1].source, "0/single.txt"); + let line = render("", Some(&plan)).unwrap(); + assert!(line.contains("nvx_share=0,/workspace,ro")); + assert!(line.contains("nvx_share=0/single.txt,/config/single.txt,ro,file")); +} diff --git a/nvx-rs/src/host/cli/run/tests.rs b/nvx-rs/src/host/cli/run/tests.rs index 2aa8f99b5..1578d6a74 100644 --- a/nvx-rs/src/host/cli/run/tests.rs +++ b/nvx-rs/src/host/cli/run/tests.rs @@ -1,22 +1,19 @@ -//! The shares of `run`: their targets stay single kernel tokens, and OpenVMM, not option order, -//! attributes their policy paths. +//! Cold-run shares stay kernel tokens through the checked VM configuration; restore keeps its +//! existing policy-path lowering. use proptest::prelude::*; use super::*; -use crate::host::sandbox::mount::MountAccess; use crate::protocol::cmdline::{self, parameters, virtio_mmio_devices}; -/// Returns the kernel command line that OpenVMM composes from `cmdline`, the `--cmdline` that -/// `run` gives it for the cold boot of an aggregate of read-write shares, as its -/// `build_microvm_command_line` and `append_microvm_virtio_discovery` do: its console parameters, -/// `cmdline`, the virtio-mmio devices of its virtio-fs slot and of its boot virtio-console, and -/// the aggregate's bootstrap tokens. -fn effective_command_line(cmdline: &str) -> String { +/// Models the kernel's discovery of the filesystem, control console, and boot console around the +/// command line rendered by the actual cold VM configuration. +fn effective_command_line(config: &VmConfig) -> String { format!( - "earlycon=xe9 console=hvc1 reboot=t panic=-1 {cmdline} \ - virtio_mmio.device=0x1000@0xd0001000:17 virtio_mmio.device=0x1000@0xd0002000:18{}", - aggregate_command_line_fragment(&[MountAccess::ReadWrite]) + "earlycon=xe9 console=hvc1 reboot=t panic=-1 {} \ + virtio_mmio.device=0x1000@0xd0001000:17 virtio_mmio.device=0x1000@0xd0002000:18 \ + virtio_mmio.device=0x1000@0xd0003000:19", + config.kernel_command_line().unwrap() ) } @@ -49,7 +46,44 @@ fn single_share_targets_must_remain_one_kernel_token() { ..Options::default() }; let rules = options.network.egress_rules().unwrap(); - assert!(extend_shares_and_network(&mut Vec::new(), &options, &rules).is_err()); + assert!(cold_config(&options, &rules).is_err()); + } +} + +#[test] +fn cold_share_command_lines_fit_the_kernel_budget_to_the_byte() { + let directory = tempfile::tempdir().unwrap(); + let first = directory.path().join("first"); + let second = directory.path().join("second"); + std::fs::create_dir(&first).unwrap(); + std::fs::create_dir(&second).unwrap(); + let mut options = Options { + mounts: vec![ + format!("/a,{},ro", first.display()), + format!("/b,{},rw", second.display()), + ], + ..Options::default() + }; + let rules = options.network.egress_rules().unwrap(); + let base = cold_config(&options, &rules).unwrap(); + let fragment = export_command_line_fragment(base.filesystem.as_ref().unwrap()); + let room = SANDBOX_COMMAND_LINE_MAX_SIZE + - base.kernel_command_line().unwrap().len() + - fragment.len() + - 2; + for unicode in [false, true] { + options.cmdline = if unicode { + "\u{e9}".repeat(room / 2) + &"x".repeat(room % 2) + } else { + "x".repeat(room) + }; + let fitting = cold_config(&options, &rules).unwrap(); + assert_eq!( + fitting.kernel_command_line().unwrap().len() + fragment.len() + 1, + SANDBOX_COMMAND_LINE_MAX_SIZE + ); + options.cmdline.push('x'); + assert!(cold_config(&options, &rules).is_err()); } } @@ -63,24 +97,27 @@ proptest! { fn share_tokens_hide_no_virtio_mmio_device( components in prop::collection::vec(component(), 2..5), ) { + let directory = tempfile::tempdir().unwrap(); + let roots: Vec<_> = (0..components.len()).map(|index| directory.path().join(format!("host-{index}"))).collect(); + for root in &roots { std::fs::create_dir(root).unwrap(); } let options = Options { mounts: components .iter() .enumerate() - .map(|(index, component)| format!("/share-{index}/{component},host-{index},rw")) + .map(|(index, component)| format!("/share-{index}/{component},{},rw", roots[index].display())) .collect(), ..Options::default() }; let rules = options.network.egress_rules().unwrap(); - let result = extend_shares_and_network(&mut Vec::new(), &options, &rules); + let result = cold_config(&options, &rules); let separated = components.iter().any(|component| { component .bytes() .any(|byte| matches!(byte, b'\0' | b'"') || cmdline::is_space(byte)) }); prop_assert_eq!(result.is_err(), separated); - if let Ok(user) = result { - let line = effective_command_line(&user); + if let Ok(config) = result { + let line = effective_command_line(&config); let read: Vec = parameters(line.as_bytes()) .map(|parameter| { let mut text = parameter.name.to_vec(); @@ -92,12 +129,12 @@ proptest! { }) .collect(); prop_assert_eq!(read, line.split(' ').collect::>()); - prop_assert_eq!(virtio_mmio_devices(line.as_bytes()).count(), 2); + prop_assert_eq!(virtio_mmio_devices(line.as_bytes()).count(), 3); } } #[test] - fn policy_paths_attach_to_their_shares( + fn restore_policy_paths_attach_to_their_shares( events in prop::collection::vec((any::(), 0_u8..3, any::()), 0..40), ) { let directory = tempfile::tempdir().unwrap(); @@ -152,7 +189,7 @@ proptest! { ].map(str::to_owned); let (options, _) = parse(&arguments).unwrap(); let rules = options.network.egress_rules().unwrap(); - prop_assert!(extend_shares_and_network(&mut Vec::new(), &options, &rules).is_err()); + prop_assert!(cold_config(&options, &rules).is_err()); } } } diff --git a/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs b/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs index 10a08c8ad..8f582f44e 100644 --- a/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs @@ -77,7 +77,9 @@ use crate::host::supervisor::{self, Reattached, Vmm}; use crate::protocol::{CAPABILITY_LEN, GuestFeatures}; pub(crate) use self::directory::{create_state_directory, prepare_state_directory}; -pub(crate) use self::outcome::{validate_outcome_destination, write_exec_outcome}; +pub(crate) use self::outcome::{ + copy_openvmm_outcome, validate_outcome_destination, write_exec_outcome, +}; pub(crate) use self::start::{exec_workload, start}; pub(crate) use self::stop::{deprovision, stop}; diff --git a/nvx-rs/src/host/cli/sandbox/lifecycle/outcome.rs b/nvx-rs/src/host/cli/sandbox/lifecycle/outcome.rs index a50cee787..d473b2c9d 100644 --- a/nvx-rs/src/host/cli/sandbox/lifecycle/outcome.rs +++ b/nvx-rs/src/host/cli/sandbox/lifecycle/outcome.rs @@ -1,7 +1,7 @@ //! Publication and reading of managed-workload and OpenVMM outcome reports. -use std::fs::OpenOptions; -use std::io::Write; +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; use serde_json::{Value, json}; @@ -39,11 +39,15 @@ fn outcome_destination(path: &str) -> Result { let resolved = paths::resolve(parent) .map_err(Exit::from_os_error)? .join(name); - if std::fs::symlink_metadata(&resolved).is_ok() { - return Err(Exit::error( - ErrorCode::OutcomeReportExists, - format!("outcome report already exists: {}", resolved.display()), - )); + match std::fs::symlink_metadata(&resolved) { + Ok(_) => { + return Err(Exit::error( + ErrorCode::OutcomeReportExists, + format!("outcome report already exists: {}", resolved.display()), + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(os_error(&error, &resolved)), } Ok(resolved) } @@ -57,6 +61,24 @@ pub(crate) fn validate_outcome_destination(path: &str) -> Result { /// Writes the JSON `text` to a new file at `path`: through a temporary file of mode 0600, synced, /// then linked to its name, which must not exist, with `\n` line ends on every host. fn write_new_json(path: &str, text: &str) -> Result { + let text = format!("{text}\n"); + write_new_file(path, |file| file.write_all(text.as_bytes())) +} + +/// Publishes an OpenVMM report's exact bytes without replacing an existing destination. +pub(crate) fn copy_openvmm_outcome(source: &Path, path: &str) -> Result { + let mut report = File::open(source).map_err(|error| os_error(&error, source))?; + let permissions = report + .metadata() + .map_err(|error| os_error(&error, source))? + .permissions(); + write_new_file(path, |file| { + io::copy(&mut report, file)?; + file.set_permissions(permissions) + }) +} + +fn write_new_file(path: &str, write: impl FnOnce(&mut File) -> io::Result<()>) -> Result { let resolved = outcome_destination(path)?; let temporary = temporary_path(&resolved)?; let mut options = OpenOptions::new(); @@ -74,8 +96,7 @@ fn write_new_json(path: &str, text: &str) -> Result { Err(error) => return Err(os_error(&error, &temporary)), }; let written = (|| -> Result { - let text = format!("{text}\n"); - file.write_all(text.as_bytes()) + write(&mut file) .and_then(|()| file.sync_all()) .map_err(Exit::from_os_error)?; match std::fs::hard_link(&temporary, &resolved) { @@ -84,10 +105,7 @@ fn write_new_json(path: &str, text: &str) -> Result { ErrorCode::OutcomeReportExists, format!("outcome report already exists: {}", resolved.display()), )), - Err(_) => Err(Exit::error( - ErrorCode::Io, - format!("failed to publish outcome report: {}", resolved.display()), - )), + Err(error) => Err(os_error(&error, &resolved)), } })(); drop(file); diff --git a/nvx-rs/src/host/cli/sandbox/mod.rs b/nvx-rs/src/host/cli/sandbox/mod.rs index 8ab296e7f..60100036e 100644 --- a/nvx-rs/src/host/cli/sandbox/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/mod.rs @@ -43,8 +43,9 @@ use crate::host::store::RuntimeIdentity; use crate::protocol::root::RootUpper; pub(crate) use self::lifecycle::ProvisionConfig; +pub(super) use self::lifecycle::{copy_openvmm_outcome, validate_outcome_destination}; pub(super) use self::one_shot::run_openvmm; -use self::options::sandbox_network; +pub(super) use self::options::sandbox_network; pub(super) use self::options::{ HYPERVISORS, MAX_MEMORY_MIB, MOUNT_OWNERS, hypervisor, parse, path_text, }; @@ -202,7 +203,7 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { // `provision` records its binding, which `start` checks without reading the layer again. let binding = validate_sandbox_systemd_policy(&launch)?; // The network's policy is checked before anything launches or is recorded. - let launch = launch.with_network(sandbox_network(&options, &rules)?); + let launch = launch.with_network(sandbox_network(&options.network, &rules)?); Some((launch, binding)) } else { None diff --git a/nvx-rs/src/host/cli/sandbox/options.rs b/nvx-rs/src/host/cli/sandbox/options.rs index 33f041d17..48fc36e55 100644 --- a/nvx-rs/src/host/cli/sandbox/options.rs +++ b/nvx-rs/src/host/cli/sandbox/options.rs @@ -363,7 +363,7 @@ pub(crate) fn path_string(path: &Path) -> Result { }) } -/// Returns the network that the network options of `options` describe, with `rules`, the egress +/// Returns the network that `network` describes, with `rules`, the egress /// rules that [`NetworkOptions::egress_rules`] resolved: the typed policy at the guest network of /// `--net`, which [`SandboxNetwork::new`] checks before any launch, or `None` without `--net`, which /// no other network option may then accompany. @@ -373,11 +373,10 @@ pub(crate) fn path_string(path: &Path) -> Result { /// egress rules only with an explicit `--network-egress`, and reads `--network-egress-allow`, /// `--network-egress-deny`, and `--host-loopback-forward` in OpenVMM's form. `--network-proxy` must /// name the guest's gateway, and makes the policy proxy-only, with no scheme in its URL. -pub(super) fn sandbox_network( - options: &Options, +pub(in crate::host::cli) fn sandbox_network( + network: &NetworkOptions, rules: &NativeRules, ) -> Result> { - let network = &options.network; let Some(net) = &network.net else { let network_options = network.network_egress.is_some() || network.network_ingress.is_some() diff --git a/nvx-rs/src/host/config/mod.rs b/nvx-rs/src/host/config/mod.rs index e19c69b9c..bc92eaa28 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -176,6 +176,9 @@ pub struct Boot { /// tier, processor, console, device or network settings the builder supplies. It requires /// exactly one `nvx_sandbox=1` token, as the guest's init splits the command line. pub command_line: String, + /// Memory capacity of a cold VM, in MiB, if it reserves more than its boot memory. + /// It must be at least [`VmConfig::memory_mib`] and fit a byte count. + pub memory_capacity_mib: Option, /// What a capture of the VM adds, if the VM may be captured. pub capture: Option, } @@ -722,6 +725,9 @@ impl VmConfig { .to_string() .into(), ]; + if let Some(capacity) = self.boot.as_ref().and_then(|boot| boot.memory_capacity_mib) { + arguments.extend(["--memory-capacity".into(), format!("{capacity}M").into()]); + } if let RootConfig::Layered(root) = &self.root { let scratch = match &root.upper { UpperConfig::Scratch { path, .. } => Some(path), @@ -822,6 +828,13 @@ impl VmConfig { capture.check()?; } if let Some(boot) = &self.boot { + if let Some(capacity) = boot.memory_capacity_mib + && (capacity < self.memory_mib || capacity > u64::MAX >> 20) + { + return Err(Error::unsupported( + "a cold boot's memory capacity must be at least its memory and fit a byte count", + )); + } let guest = cmdline::init_tokens(boot.command_line.as_bytes()).map(|token| Parameter { name: token.name, value: token.value, diff --git a/nvx-rs/src/host/config/tests.rs b/nvx-rs/src/host/config/tests.rs index 49decd2dc..2ffbdafbc 100644 --- a/nvx-rs/src/host/config/tests.rs +++ b/nvx-rs/src/host/config/tests.rs @@ -23,6 +23,29 @@ fn missing(name: &str) -> PathBuf { std::env::temp_dir().join("nvx-config-missing").join(name) } +#[test] +fn cold_memory_capacity_is_checked_and_lowered_without_a_capture() { + let mut config = cold(blockless()); + config.boot.as_mut().unwrap().memory_capacity_mib = Some(1024); + let arguments = config + .openvmm_arguments( + &runtime(), + "/control", + Path::new("/report"), + Some("/console"), + ) + .unwrap(); + assert_eq!(values(&arguments, "--memory"), [OsStr::new("512M")]); + assert_eq!( + values(&arguments, "--memory-capacity"), + [OsStr::new("1024M")] + ); + for capacity in [511, u64::MAX] { + config.boot.as_mut().unwrap().memory_capacity_mib = Some(capacity); + assert_eq!(config.check().unwrap_err().kind(), ErrorKind::Unsupported); + } +} + /// Returns a blockless configuration of one processor and 512 MiB. fn blockless() -> VmConfig { VmConfig { @@ -132,10 +155,12 @@ fn configurations_pass_with_or_without_a_nic_address_and_a_boot() { let boots = [ None, Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), Some(Boot { + memory_capacity_mib: None, command_line: "quiet loglevel=0".to_owned(), capture: None, }), @@ -570,6 +595,7 @@ fn cold_command_lines_keep_the_old_root_view_and_mapping_values() { let mut config = VmConfig { filesystem: filesystem.clone(), boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -634,6 +660,7 @@ fn cold_command_lines_keep_the_old_root_view_and_mapping_values() { fn the_check_of_a_configuration_checks_its_boots_capture() { let booting = |capture: Option| VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture, }), @@ -682,6 +709,7 @@ fn captured() -> VmConfig { policy: NetworkPolicy::new(Access::Deny), }), boot: Some(Boot { + memory_capacity_mib: None, command_line: "quiet".to_owned(), capture: Some(capture()), }), @@ -916,6 +944,7 @@ fn caller_tokens_follow_rendered_tokens_without_any_rewriting() { let caller = " \tquiet nvx_entrypoint=/bin/work nvx_arg=\"x y\" nvx_share=0,/work\r\n "; let mut config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -939,6 +968,7 @@ fn only_a_checked_cold_boot_has_a_kernel_command_line() { let config = VmConfig { processors: 0, boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -995,6 +1025,7 @@ fn caller_tokens_cannot_replace_rendered_tokens_under_kernel_name_matching() { for config in [blockless(), layered(erofs())] { let config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: command_line.clone(), capture: None, }), @@ -1028,6 +1059,7 @@ fn caller_owned_guest_tokens_and_noncolliding_names_stay_unchanged() { ] { let mut config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -1066,6 +1098,7 @@ fn mapping_table_is_the_exact_export_projection_counted_by_the_boot_token() { let mut config = VmConfig { filesystem: Some(plan.clone()), boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -1124,6 +1157,7 @@ fn public_maps_codec_keeps_the_protocol_root_reexports_usable() { fn cold(config: VmConfig) -> VmConfig { VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: "quiet nvx_entrypoint=/bin/work nvx_arg=ab".to_owned(), capture: None, }), @@ -1310,6 +1344,7 @@ fn cold_boot_command_lines_refuse_native_control_console_syntax() { ] { let config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: caller.to_owned(), capture: None, }), diff --git a/nvx-rs/src/host/platform/linux/endpoint.rs b/nvx-rs/src/host/platform/linux/endpoint.rs index 38bd463c3..1b63bd9b6 100644 --- a/nvx-rs/src/host/platform/linux/endpoint.rs +++ b/nvx-rs/src/host/platform/linux/endpoint.rs @@ -268,7 +268,14 @@ pub(crate) fn connect_endpoint( )); } check_original_peer(&stream)?; - Ok(Box::new(UnixTransport { stream })) + transport_from_handle(stream.into()) +} + +/// Adapts an owned duplicate of an already verified connection without changing its blocking mode. +pub(crate) fn transport_from_handle(handle: TransportHandle) -> io::Result> { + Ok(Box::new(UnixTransport { + stream: UnixStream::from(handle), + })) } /// Returns the process that listens on a control endpoint, or `None` if nothing listens. diff --git a/nvx-rs/src/host/platform/linux/mod.rs b/nvx-rs/src/host/platform/linux/mod.rs index 13e6437d9..988ced72d 100644 --- a/nvx-rs/src/host/platform/linux/mod.rs +++ b/nvx-rs/src/host/platform/linux/mod.rs @@ -14,11 +14,11 @@ use crate::hypervisor::Hypervisor; #[cfg(feature = "cli")] pub(crate) use crate::host::sys::{interrupted, watch_interrupts, write_interruptibly}; -#[cfg(feature = "cli")] -pub(crate) use self::endpoint::endpoint_server_pid_until; pub(crate) use self::endpoint::{ ReadyListener, connect_endpoint, control_endpoint, endpoint_server_pid, listen_ready, }; +#[cfg(feature = "cli")] +pub(crate) use self::endpoint::{endpoint_server_pid_until, transport_from_handle}; pub use self::identity::file_identity; pub(crate) use self::identity::{ Directory, file_id, is_deleted, link_count, opened_file_id, opened_file_identity, diff --git a/nvx-rs/src/host/platform/windows/endpoint.rs b/nvx-rs/src/host/platform/windows/endpoint.rs index 943e7b681..25741febf 100644 --- a/nvx-rs/src/host/platform/windows/endpoint.rs +++ b/nvx-rs/src/host/platform/windows/endpoint.rs @@ -234,6 +234,11 @@ pub(crate) fn connect_endpoint( ), )); } + transport_from_handle(pipe) +} + +/// Gives an already verified pipe duplicate its own overlapped-operation event. +pub(crate) fn transport_from_handle(pipe: TransportHandle) -> io::Result> { // SAFETY: creates an unnamed manual-reset event with default security. let event = owned(unsafe { CreateEventW(ptr::null(), 1, 0, ptr::null()) }) .ok_or_else(io::Error::last_os_error)?; diff --git a/nvx-rs/src/host/platform/windows/mod.rs b/nvx-rs/src/host/platform/windows/mod.rs index dca1e75a0..a8333febb 100644 --- a/nvx-rs/src/host/platform/windows/mod.rs +++ b/nvx-rs/src/host/platform/windows/mod.rs @@ -22,11 +22,11 @@ use windows_sys::Win32::System::Threading::OpenProcess; use crate::hypervisor::Hypervisor; -#[cfg(feature = "cli")] -pub(crate) use self::endpoint::endpoint_server_pid_until; pub(crate) use self::endpoint::{ ReadyListener, connect_endpoint, control_endpoint, endpoint_server_pid, listen_ready, }; +#[cfg(feature = "cli")] +pub(crate) use self::endpoint::{endpoint_server_pid_until, transport_from_handle}; pub use self::identity::file_identity; pub(crate) use self::identity::{ Directory, file_id, is_deleted, link_count, opened_file_id, opened_file_identity, @@ -46,7 +46,7 @@ pub(crate) use self::process::{ }; #[cfg(feature = "cli")] pub(crate) use self::spawn::spawn_foreground; -pub(crate) use self::spawn::{LaunchedProcess, spawn_detached}; +pub(crate) use self::spawn::{LaunchedProcess, SpawnMode, spawn_with_stdio}; /// Whether this host can run NVX microVMs. pub const SUPPORTED: bool = true; diff --git a/nvx-rs/src/host/platform/windows/spawn.rs b/nvx-rs/src/host/platform/windows/spawn.rs index dfe398380..e93a082f0 100644 --- a/nvx-rs/src/host/platform/windows/spawn.rs +++ b/nvx-rs/src/host/platform/windows/spawn.rs @@ -252,19 +252,27 @@ impl Drop for InheritableForSpawn { /// [`SpawnLock`]: crate::host::platform::inherit::SpawnLock /// [`spawn_command`]: crate::host::platform::inherit::spawn_command /// [`log_claim_held`]: crate::host::platform::windows::log_claim_held -pub(crate) fn spawn_detached( +pub(crate) fn spawn_with_stdio( program: &Path, arguments: &[OsString], working_dir: &Path, stdio: [OwnedHandle; 3], inherit: &[Inherited<'_>], - breakaway_from_job: bool, + mode: SpawnMode, job: Option, ) -> io::Result { - let mut flags = CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW; - if breakaway_from_job { - flags |= CREATE_BREAKAWAY_FROM_JOB; - } + let flags = match mode { + SpawnMode::Foreground => 0, + SpawnMode::Detached { breakaway_from_job } => { + CREATE_NEW_PROCESS_GROUP + | CREATE_NO_WINDOW + | if breakaway_from_job { + CREATE_BREAKAWAY_FROM_JOB + } else { + 0 + } + } + }; spawn_listed( program, arguments, @@ -276,12 +284,23 @@ pub(crate) fn spawn_detached( ) } +/// Whether the child stays in the caller's console and job. +pub(crate) enum SpawnMode { + /// Keeps the caller's console, process group, and job membership. + Foreground, + /// Starts without a console, in a new process group. + Detached { + /// Leaves the caller's job when that job permits breakaway. + breakaway_from_job: bool, + }, +} + /// Starts OpenVMM in the caller's console, process group, and working directory, as /// `subprocess.run` starts a child, with copies of the caller's standard handles as its own. /// /// Unlike `std::process::Command`, which lets the child inherit every inheritable handle of the /// caller, it passes the child no other handle of the caller than the handles `inherit`, which -/// keep their values in the child, through the handle list of [`spawn_detached`], with the same +/// keep their values in the child, through the handle list of [`spawn_with_stdio`], with the same /// window, under the same lock, for processes that the caller spawns concurrently without one. /// Unlike `std::process::Command` too, which escapes the arguments of a batch file for `cmd.exe`, /// it fails with [`io::ErrorKind::InvalidInput`] for a batch file, as [`refuse_batch_file`] says. @@ -331,7 +350,7 @@ fn standard_handle(which: STD_HANDLE) -> io::Result> { /// Starts `program` with `arguments` in `working_dir`, or in the caller's working directory, with /// the creation flags `flags`, the handles `stdio` as its standard handles, a missing one null, /// and the caller's handles `inherit`, and no other handle of the caller, in the job object -/// `job`, if any, as [`spawn_detached`] describes. Fails with [`io::ErrorKind::InvalidInput`] for +/// `job`, if any, as [`spawn_with_stdio`] describes. Fails with [`io::ErrorKind::InvalidInput`] for /// a working directory beyond Windows' limit or for a batch file, as [`refuse_batch_file`] says. fn spawn_listed( program: &Path, diff --git a/nvx-rs/src/host/platform/windows/tests.rs b/nvx-rs/src/host/platform/windows/tests.rs index 9193ede23..ef63f5c01 100644 --- a/nvx-rs/src/host/platform/windows/tests.rs +++ b/nvx-rs/src/host/platform/windows/tests.rs @@ -232,13 +232,15 @@ fn children_inherit_only_their_standard_handles() { let stderr = stdout.try_clone().unwrap(); let system = std::env::var_os("SystemRoot").unwrap(); let cmd = Path::new(&system).join("System32").join("cmd.exe"); - let child = spawn_detached( + let child = spawn_with_stdio( &cmd, &[OsString::from("/c"), OsString::from("ping -n 30 127.0.0.1")], &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &[], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); @@ -317,13 +319,15 @@ fn a_child_loses_only_the_inherited_handle_that_is_closed_in_it() { let stderr = stdout.try_clone().unwrap(); let system = std::env::var_os("SystemRoot").unwrap(); let ping = Path::new(&system).join("System32").join("PING.EXE"); - let child = spawn_detached( + let child = spawn_with_stdio( &ping, &["-n", "30", "127.0.0.1"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &[Inherited::new(&closed), Inherited::new(&kept)], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); @@ -375,13 +379,15 @@ fn children_inherit_the_allowed_handles_while_they_are_created() { Inherited::new(&kept), Inherited::new(&allowed), ]; - let child = spawn_detached( + let child = spawn_with_stdio( &ping, &["-n", "30", "127.0.0.1"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &inherit, - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); @@ -461,13 +467,15 @@ fn batch_files_are_refused_before_cmd_could_read_their_arguments() { let (stdin, _stdin_writer) = io::pipe().unwrap(); let (_stdout_reader, stdout) = io::pipe().unwrap(); let stderr = stdout.try_clone().unwrap(); - refused(spawn_detached( + refused(spawn_with_stdio( &program, &arguments, directory.path(), [stdin.into(), stdout.into(), stderr.into()], &[], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, )); } @@ -496,13 +504,15 @@ fn concurrent_spawns_that_allow_one_handle_all_inherit_it_and_restore_it() { let (stdin, _stdin_writer) = io::pipe().unwrap(); let (_stdout_reader, stdout) = io::pipe().unwrap(); let stderr = stdout.try_clone().unwrap(); - let child = spawn_detached( + let child = spawn_with_stdio( &ping, &["-n", "30", "127.0.0.1"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &[Inherited::new(&shared)], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); @@ -528,13 +538,15 @@ fn a_spawn_that_panics_in_its_window_restores_the_handles_and_lets_other_spawns_ let (stdin, _stdin_writer) = io::pipe()?; let (_stdout_reader, stdout) = io::pipe()?; let stderr = stdout.try_clone()?; - spawn_detached( + spawn_with_stdio( &cmd, &["/c", "exit", "0"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], inherit, - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) } @@ -653,13 +665,15 @@ impl HeldSpawn { }))); let (stdin, _stdin_writer) = io::pipe()?; let stderr = file.try_clone()?; - spawn_detached( + spawn_with_stdio( &program, &arguments, &std::env::temp_dir(), [stdin.into(), file.into(), stderr.into()], &[], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, job, ) }); @@ -1210,13 +1224,15 @@ fn a_thread_that_holds_the_spawn_lock_spawns_under_it_until_its_last_lock_is_dro let stderr = stdout.try_clone().unwrap(); let system = std::env::var_os("SystemRoot").unwrap(); let cmd = Path::new(&system).join("System32").join("cmd.exe"); - let vmm = spawn_detached( + let vmm = spawn_with_stdio( &cmd, &["/c", "exit", "0"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &[], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); diff --git a/nvx-rs/src/host/spawn.rs b/nvx-rs/src/host/spawn.rs index 11561d7be..1c6df3fd1 100644 --- a/nvx-rs/src/host/spawn.rs +++ b/nvx-rs/src/host/spawn.rs @@ -1,4 +1,4 @@ -//! The detached spawn of a VMM, with an explicit allowlist of the descriptors or handles of the +//! The spawn of a VMM, with an explicit allowlist of the descriptors or handles of the //! caller that it inherits, and the child that the spawn returns until its caller releases it. use std::ffi::OsString; @@ -106,7 +106,7 @@ pub fn spawn( capability: &[u8; CAPABILITY_LEN], log: File, ) -> io::Result { - spawn_inner(command, capability, log, false) + spawn_inner(command, capability, log, false, false) } /// Starts a VMM with the strict launch contract of [`crate::host::vm::VmmCommand`]. @@ -124,8 +124,9 @@ pub fn spawn_vm( command: &VmmCommand<'_>, capability: &[u8; CAPABILITY_LEN], log: File, + foreground: bool, ) -> io::Result { - spawn_inner(command, capability, log, true) + spawn_inner(command, capability, log, true, foreground) } /// Shares the legacy spawn mechanics without its optional Windows log-job fallback for a VM. @@ -134,6 +135,7 @@ fn spawn_inner( capability: &[u8; CAPABILITY_LEN], log: File, require_log_job: bool, + foreground: bool, ) -> io::Result { #[cfg(not(windows))] let _ = require_log_job; @@ -150,13 +152,20 @@ fn spawn_inner( "the VM log has no trusted log-named job", )); } - platform::spawn_detached( + let mode = if foreground { + platform::SpawnMode::Foreground + } else { + platform::SpawnMode::Detached { + breakaway_from_job: command.breakaway_from_job, + } + }; + platform::spawn_with_stdio( &command.program, &command.arguments, &command.working_dir, [stdin.into(), log.into(), stderr.into()], &command.inherit, - command.breakaway_from_job, + mode, job, ) .map(|process| Launched { process }) @@ -173,7 +182,17 @@ fn spawn_inner( .stdin(Stdio::from(stdin)) .stdout(Stdio::from(log)) .stderr(Stdio::from(stderr)); - platform::detach(&mut child, &command.inherit)?; + #[cfg(feature = "cli")] + if foreground { + platform::inherit_only(&mut child, &command.inherit)?; + } else { + platform::detach(&mut child, &command.inherit)?; + } + #[cfg(not(feature = "cli"))] + { + let _ = foreground; + platform::detach(&mut child, &command.inherit)?; + } child.spawn().map(|child| Launched { child, reaped: false, diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index 847c5795e..cd93e7558 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -16,6 +16,8 @@ use std::fmt; use std::fs::{self, File, OpenOptions}; use std::io; use std::path::{Path, PathBuf}; +#[cfg(feature = "cli")] +use std::time::Duration; use std::time::Instant; use crate::activation::{ @@ -292,6 +294,8 @@ pub struct Prepared { working_dir: PathBuf, /// Whether the child leaves the caller's Windows job. windows_breakaway: bool, + /// Whether this CLI-owned child keeps the caller's session, group, and console. + foreground: bool, /// The control credential, never written to disk. capability: Capability, /// The launch's fixed file paths and chosen endpoints. @@ -405,6 +409,12 @@ impl Prepared { }) } + #[cfg(feature = "cli")] + pub(crate) fn for_foreground(mut self) -> Self { + self.foreground = true; + self + } + /// Returns the credential to journal before a spawn or use when attaching a session. pub fn capability(&self) -> &Capability { &self.capability @@ -639,6 +649,7 @@ pub fn create( program: runtime.openvmm.clone(), working_dir: dir.path().to_owned(), windows_breakaway: runtime.windows_breakaway, + foreground: false, capability, outputs, log, @@ -692,6 +703,38 @@ pub struct Launched { pub outputs: VmOutputs, /// The control credential, never written to disk. pub capability: Capability, + #[cfg(feature = "cli")] + child: ForegroundChild, +} + +#[cfg(feature = "cli")] +#[derive(Debug)] +struct ForegroundChild(Option); + +#[cfg(feature = "cli")] +impl Drop for ForegroundChild { + fn drop(&mut self) { + if let Some(child) = self.0.take() { + child.detach(); + } + } +} + +#[cfg(feature = "cli")] +impl Launched { + pub(crate) fn wait_status(&mut self, timeout: Duration) -> io::Result> { + Ok(self + .child_mut()? + .wait_timeout(timeout)? + .map(|status| status as i32)) + } + + pub(crate) fn child_mut(&mut self) -> io::Result<&mut spawn::Launched> { + self.child + .0 + .as_mut() + .ok_or_else(|| io::Error::other("the VM has no child owned by this caller")) + } } /// Starts a prepared cold launch and returns its verified, detached process. @@ -797,6 +840,7 @@ fn spawn_verified( program, working_dir, windows_breakaway, + foreground, capability, outputs, log, @@ -811,7 +855,7 @@ fn spawn_verified( breakaway_from_job: windows_breakaway, inherit: inherit.iter().map(Inherited::new).collect(), }; - let child = spawn::spawn_vm(&command, capability.as_bytes(), log) + let child = spawn::spawn_vm(&command, capability.as_bytes(), log, foreground) .map_err(|error| Error::io("cannot spawn the VM").with_source(error))?; let process = match verify(&child) { Ok(process) => process, @@ -826,11 +870,21 @@ fn spawn_verified( return Err(Error::io(message).with_source(error)); } }; + #[cfg(feature = "cli")] + let child = if foreground { + Some(child) + } else { + child.detach(); + None + }; + #[cfg(not(feature = "cli"))] child.detach(); Ok(Launched { process, outputs, capability, + #[cfg(feature = "cli")] + child: ForegroundChild(child), }) } diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index edf102390..3a9b5ce4a 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -84,6 +84,7 @@ fn cold_config() -> VmConfig { filesystem: None, console: ConsoleAttachment::Listener, boot: Some(Boot { + memory_capacity_mib: None, command_line: "nvx_entrypoint=/bin/sh nvx_arg=-c nvx_arg=exit".to_owned(), capture: None, }), @@ -127,6 +128,7 @@ fn capture_config(_vm: &VmDir) -> VmConfig { VmConfig { processors: 2, boot: Some(Boot { + memory_capacity_mib: None, command_line: "nvx_sandbox=1".to_owned(), capture: Some(CaptureConfig { disks: DiskCapture::Fresh { @@ -1540,10 +1542,51 @@ fn a_library_vm_outlives_the_caller_that_spawned_it_without_retaining_its_output process, outputs: VmDir::open(&vm).unwrap().outputs(false).unwrap(), capability: Capability([0; CAPABILITY_LEN]), + #[cfg(feature = "cli")] + child: ForegroundChild(None), }); assert!(!launched.0.process.has_exited().unwrap()); } +#[cfg(all(feature = "cli", any(target_os = "linux", windows)))] +#[test] +fn an_owned_child_status_is_kept_when_its_outcome_is_missing_or_malformed() { + for malformed in [false, true] { + let (_directory, dir) = vm_dir(); + let mut prepared = stand_in(&dir).for_foreground(); + #[cfg(target_os = "linux")] + { + prepared.program = PathBuf::from("/bin/sh"); + prepared.arguments = ["-c", "exit 37"].map(OsString::from).to_vec(); + } + #[cfg(windows)] + { + prepared.program = PathBuf::from(std::env::var_os("ComSpec").unwrap()); + prepared.arguments = ["/d", "/c", "exit 37"].map(OsString::from).to_vec(); + } + let mut launched = RunningVm(spawn(prepared).unwrap()); + assert_eq!( + launched.0.wait_status(Duration::from_secs(10)).unwrap(), + Some(37) + ); + if malformed { + fs::write(&launched.0.outputs.outcome, b"not-json").unwrap(); + } + let now = Instant::now(); + let stopped = stop( + &launched.0.process, + &launched.0.outputs, + |_| false, + now, + now, + ) + .unwrap(); + assert!(stopped.self_exited); + assert_eq!(stopped.report.is_err(), malformed); + assert_eq!(launched.0.wait_status(Duration::ZERO).unwrap(), Some(37)); + } +} + #[cfg(any(target_os = "linux", windows))] #[test] #[ignore = "invoked in a separate caller by the caller-survival test"] @@ -1685,6 +1728,7 @@ fn current_cold_spawn_boots_a_guest_accepts_shell_input_and_exits_cleanly() { let vm = VmDir::open(&path).unwrap(); let config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: "quiet loglevel=0".to_owned(), capture: None, }), diff --git a/nvx-rs/src/testing/fake_openvmm.rs b/nvx-rs/src/testing/fake_openvmm.rs index c92651949..9ef3d8382 100644 --- a/nvx-rs/src/testing/fake_openvmm.rs +++ b/nvx-rs/src/testing/fake_openvmm.rs @@ -83,7 +83,9 @@ //! or with `STATUS` if the kernel command line has `fake_exit_status=STATUS`. //! //! The fake also runs the microVMs of `nvx-rs run`, whose command lines have -//! neither a `--microvm-lifecycle` nor a sandbox block device, on the terminal. Such a command +//! neither a `--microvm-lifecycle` nor a sandbox block device, on the terminal or a boot-console +//! listener. A cold library launch also supplies the authenticated control endpoint and +//! `--guest-shutdown-action exit`, without selecting a lifecycle through an OpenVMM flag. Such a command //! line takes `--processors` and at most one `--cpu-profile`, shares through one `--mount` or the //! children of an aggregate at `/run/nvx/shares`, whose relative policy paths name paths in the //! only share, as OpenVMM resolves them, and either boots a guest, with `--memory`, an optional @@ -353,6 +355,8 @@ struct Options { restore: Option, /// The `--virtio-console` of a run's restore, which the restore checks against its snapshot. restore_console: Option, + /// The listener attachment of a cold library launch's boot console. + boot_console: Option, /// Control endpoint at which the fake accepts host sessions; empty for a one-shot run. endpoint: String, /// Path to the synthetic OpenVMM outcome report, which a one-shot run may omit. @@ -377,6 +381,8 @@ struct Options { exit_status: Option, /// Artificial delay before the fake guest becomes ready. boot_delay: Duration, + /// Whether the boot console emits the binary-output regression fixture. + binary_console: bool, /// Artificial delay before the fake OpenVMM crashes, when requested. crash_after: Option, /// Whether stop requests are deliberately left unanswered. @@ -494,23 +500,48 @@ pub fn main() -> ExitCode { return ExitCode::from(2); } }; + let launch_capability = if options.boot_console.is_some() { + match read_capability() { + Ok(capability) => Some(capability), + Err(message) => { + eprintln!("{program}: {message}"); + return ExitCode::from(2); + } + } + } else { + None + }; + let mut console = match boot_console(&options) { + Ok(console) => console, + Err(error) => { + eprintln!("{program}: {error}"); + return ExitCode::from(1); + } + }; let ended = match options.lifecycle { - Lifecycle::OneShot => Some(run_one_shot(&options)), - Lifecycle::Run => Some(run_microvm(&options)), + Lifecycle::OneShot => Some(run_one_shot(&options, &mut console)), + Lifecycle::Run => Some(run_microvm(&options, &mut console)), Lifecycle::Capture => Some(run_capture(&options)), Lifecycle::Managed => None, }; if let Some(status) = ended { + if let Err(error) = console.flush() { + eprintln!("{program}: {error}"); + return ExitCode::from(1); + } let _ = io::stdout().flush(); let _ = io::stderr().flush(); return exit_code(status); } - let capability = match read_capability() { - Ok(capability) => capability, - Err(message) => { - eprintln!("{program}: {message}"); - return ExitCode::from(2); - } + let capability = match launch_capability { + Some(capability) => capability, + None => match read_capability() { + Ok(capability) => capability, + Err(message) => { + eprintln!("{program}: {message}"); + return ExitCode::from(2); + } + }, }; thread::sleep(options.boot_delay); if let Some(after) = options.crash_after { @@ -720,7 +751,7 @@ fn writers_of(_reader: &fs::File) -> Option { /// The entrypoint is `/bin/echo`, which prints its arguments, `/bin/sleep`, which sleeps for its /// argument in seconds, `/bin/true` or `/bin/false`, or any other program, which is not found; /// the `fake_exit_status=STATUS` token replaces the status. -fn run_one_shot(options: &Options) -> i32 { +fn run_one_shot(options: &Options, console: &mut dyn Write) -> i32 { let (program, arguments) = options .workload .split_first() @@ -729,7 +760,10 @@ fn run_one_shot(options: &Options) -> i32 { }); let status = match program { "/bin/echo" => { - println!("{}", arguments.join(" ")); + if let Err(error) = writeln!(console, "{}", arguments.join(" ")) { + eprintln!("{}: {error}", options.program); + return 1; + } 0 } "/bin/sleep" => { @@ -767,9 +801,9 @@ fn run_one_shot(options: &Options) -> i32 { /// of the `fake_exit_status=STATUS` token. A restore without its snapshot directory fails with 1, /// as OpenVMM fails a restore that it cannot read, and so does one whose console /// [`check_restored_console`] refuses; neither writes a report. -fn run_microvm(options: &Options) -> i32 { +fn run_microvm(options: &Options, console: &mut dyn Write) -> i32 { thread::sleep(options.boot_delay); - match &options.restore { + let message = match &options.restore { Some(snapshot) if !snapshot.is_dir() => { eprintln!( "{}: cannot restore {}: no such snapshot directory", @@ -785,9 +819,18 @@ fn run_microvm(options: &Options) -> i32 { eprintln!("{}: {message}", options.program); return 1; } - println!("{}: restored the microVM", options.program); + format!("{}: restored the microVM", options.program) } - None => println!("{}: booted the microVM", options.program), + None => format!("{}: booted the microVM", options.program), + }; + let written = if options.binary_console { + console.write_all(&[b'A', 0xff, 0x80, b'\n']) + } else { + writeln!(console, "{message}") + }; + if let Err(error) = written { + eprintln!("{}: {error}", options.program); + return 1; } let status = options.exit_status.unwrap_or(0); let (category, status_code) = if status == 0 { @@ -1203,7 +1246,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { "--host-loopback-forward", ]; // The options that only the microVMs of `nvx-rs run` and of captures take. - const RUN_VALUED: [&str; 7] = [ + const RUN_VALUED: [&str; 8] = [ "--processors", "--cpu-profile", "--memory-capacity", @@ -1211,6 +1254,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { "--restore-processors", "--restore-memory", "--restore-ready-path", + "--guest-shutdown-action", ]; // The options that only the VMs of templates' captures take. const CAPTURE_VALUED: [&str; 4] = [ @@ -1297,9 +1341,24 @@ fn parse(program: &str, arguments: &[String]) -> Result { if !flags.contains("--single-process") { return Err("--single-process is required".to_owned()); } - let (endpoint, report) = match lifecycle { - Lifecycle::Managed | Lifecycle::Capture => { - expect("--virtio-console", "none")?; + let boot_console = if run && values.contains_key("--guest-shutdown-action") { + if values.contains_key("--restore-snapshot") { + return Err("--guest-shutdown-action is only for a cold boot".to_owned()); + } + expect("--guest-shutdown-action", "exit")?; + let endpoint = single("--virtio-console")? + .strip_prefix("listen=") + .filter(|endpoint| !endpoint.is_empty()) + .ok_or("--virtio-console must name a listen= endpoint")?; + Some(endpoint.to_owned()) + } else { + None + }; + let (endpoint, report) = + if matches!(lifecycle, Lifecycle::Managed | Lifecycle::Capture) || boot_console.is_some() { + if boot_console.is_none() { + expect("--virtio-console", "none")?; + } if !flags.contains("--microvm-control-auth-stdin") { return Err("--microvm-control-auth-stdin is required".to_owned()); } @@ -1313,8 +1372,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { ); } (endpoint, Some(PathBuf::from(single("--microvm-report")?))) - } - Lifecycle::OneShot | Lifecycle::Run => { + } else { let kind = if run { "a microVM run" } else { @@ -1346,8 +1404,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { Some(_) => return Err("--microvm-report must be given at most once".to_owned()), }; (String::new(), report) - } - }; + }; let hypervisor = single("--hypervisor")?; if !["kvm", "mshv", "whp"].contains(&hypervisor) { return Err(format!("unsupported hypervisor {hypervisor}")); @@ -1508,7 +1565,12 @@ fn parse(program: &str, arguments: &[String]) -> Result { // CLI's tokens may name a one-shot workload and place the children of an aggregate. The // guest of a workload of either mode has the targets of its directory shares, as a // one-shot sandbox's has: a direct share's, and those that the tokens place. - let (children, mut share_targets) = sandbox_shares(&values)?; + let (children, mut share_targets) = if boot_console.is_some() { + let maps = mapping_count(&token_values(MAPS_TOKEN))?; + (mapped_children(&values, maps)?, Vec::new()) + } else { + sandbox_shares(&values)? + }; match lifecycle { Lifecycle::Run => share_targets.clear(), _ => share_targets.extend(bound_targets(&token_values("nvx_share="))?), @@ -1649,6 +1711,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { run, restore, restore_console, + boot_console, endpoint, args_dump: anchor.with_file_name(format!("fake-openvmm-{token}.json")), maps_dump: anchor.with_file_name(format!("fake-openvmm-{token}-maps.json")), @@ -1661,6 +1724,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { exit_on_start: knob("fake_exit_on_start").and_then(|value| value.parse().ok()), exit_status: knob("fake_exit_status").and_then(|value| value.parse().ok()), boot_delay: millis("fake_boot_delay_ms").unwrap_or_default(), + binary_console: knob("fake_binary_console") == Some("1"), crash_after: millis("fake_crash_after_ms"), ignore_stop: knob("fake_ignore_stop") == Some("1"), exit_delay: millis("fake_exit_delay_ms").unwrap_or_default(), @@ -2850,6 +2914,46 @@ fn record_session(options: &Options) { } } +fn boot_console(options: &Options) -> io::Result> { + let Some(endpoint) = &options.boot_console else { + return Ok(Box::new(io::stdout())); + }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + use std::os::unix::net::UnixListener; + + let listener = UnixListener::bind(endpoint)?; + fs::set_permissions(endpoint, fs::Permissions::from_mode(0o600))?; + Ok(Box::new(listener.accept()?.0)) + } + #[cfg(windows)] + { + let pipe = pipe_listener(endpoint)?; + accept_pipe(&pipe)?; + Ok(Box::new(PipeConsole(pipe))) + } + #[cfg(not(any(unix, windows)))] + Err(io::Error::new( + io::ErrorKind::Unsupported, + "no boot-console listener on this host", + )) +} + +#[cfg(windows)] +struct PipeConsole(fs::File); + +#[cfg(windows)] +impl Write for PipeConsole { + fn write(&mut self, data: &[u8]) -> io::Result { + self.0.write(data) + } + + fn flush(&mut self) -> io::Result<()> { + self.0.sync_all() + } +} + #[cfg(unix)] /// Serves the fake control endpoint as a Unix-domain socket until a stop request. fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> { @@ -2873,24 +2977,20 @@ fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> } #[cfg(windows)] -/// Serves the fake control endpoint as a Windows named pipe until a stop request. -fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> { +fn pipe_listener(endpoint: &str) -> io::Result { use std::fs::File; use std::os::windows::ffi::OsStrExt; - use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle}; + use std::os::windows::io::{FromRawHandle, OwnedHandle, RawHandle}; - use windows_sys::Win32::Foundation::{ - ERROR_NO_DATA, ERROR_PIPE_CONNECTED, GetLastError, HANDLE, INVALID_HANDLE_VALUE, - }; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; use windows_sys::Win32::Storage::FileSystem::{ FILE_FLAG_FIRST_PIPE_INSTANCE, PIPE_ACCESS_DUPLEX, }; use windows_sys::Win32::System::Pipes::{ - ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, PIPE_READMODE_BYTE, - PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_WAIT, + CreateNamedPipeW, PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_WAIT, }; - let name: Vec = std::ffi::OsStr::new(&options.endpoint.replace('/', "\\")) + let name: Vec = std::ffi::OsStr::new(&endpoint.replace('/', "\\")) .encode_wide() .chain([0]) .collect(); @@ -2913,16 +3013,26 @@ fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> return Err(io::Error::last_os_error()); } // SAFETY: CreateNamedPipeW returned a fresh handle that nothing else owns. - let mut file = File::from(unsafe { OwnedHandle::from_raw_handle(handle as RawHandle) }); + Ok(File::from(unsafe { + OwnedHandle::from_raw_handle(handle as RawHandle) + })) +} + +#[cfg(windows)] +fn accept_pipe(file: &fs::File) -> io::Result<()> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::{ + ERROR_NO_DATA, ERROR_PIPE_CONNECTED, GetLastError, HANDLE, + }; + use windows_sys::Win32::System::Pipes::{ConnectNamedPipe, DisconnectNamedPipe}; + let pipe = file.as_raw_handle() as HANDLE; - let mut guest = Guest::new(options); - let mut epoch = 0; loop { // SAFETY: the handle is a pipe server instance; a null OVERLAPPED waits synchronously. if unsafe { ConnectNamedPipe(pipe, std::ptr::null_mut()) } == 0 { // SAFETY: GetLastError has no preconditions. match unsafe { GetLastError() } { - ERROR_PIPE_CONNECTED => {} + ERROR_PIPE_CONNECTED => return Ok(()), // The client left before the connection was accepted. ERROR_NO_DATA => { // SAFETY: the handle is a pipe server instance. @@ -2932,6 +3042,23 @@ fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> error => return Err(io::Error::from_raw_os_error(error as i32)), } } + return Ok(()); + } +} + +#[cfg(windows)] +/// Serves the fake control endpoint as a Windows named pipe until a stop request. +fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::System::Pipes::DisconnectNamedPipe; + + let mut file = pipe_listener(&options.endpoint)?; + let pipe = file.as_raw_handle() as HANDLE; + let mut guest = Guest::new(options); + let mut epoch = 0; + loop { + accept_pipe(&file)?; epoch += 1; record_session(options); let flow = serve_client(&mut file, options, capability, epoch, &mut guest); diff --git a/nvx-rs/src/testing/fake_openvmm/tests.rs b/nvx-rs/src/testing/fake_openvmm/tests.rs index 068ac0d36..39d2ec29f 100644 --- a/nvx-rs/src/testing/fake_openvmm/tests.rs +++ b/nvx-rs/src/testing/fake_openvmm/tests.rs @@ -648,7 +648,7 @@ fn restores_of_runs_resume_their_saved_guest_whatever_the_cli_tokens() { assert_eq!(options.lifecycle, Lifecycle::Run, "{snapshot}"); assert!(options.workload.is_empty(), "{snapshot}"); if snapshot.ends_with("missing") { - assert_eq!(run_microvm(&options), 1); + assert_eq!(run_microvm(&options, &mut io::sink()), 1); } } } diff --git a/nvx-rs/tests/cli.rs b/nvx-rs/tests/cli.rs index 0c32142f2..157ae9784 100644 --- a/nvx-rs/tests/cli.rs +++ b/nvx-rs/tests/cli.rs @@ -2541,7 +2541,7 @@ fn run_guides_failed_cold_boots_on_cpus_that_no_built_in_profile_serves() { let dry_run = text(&run(&root, &arguments(&["--dry-run"])).stdout); let (openvmm, _) = dry_run .strip_prefix(">> ") - .and_then(|command| command.split_once(" --single-process ")) + .and_then(|command| command.split_once(" --machine ")) .unwrap(); let default = if cfg!(windows) { "whp" } else { "kvm" }; // OpenVMM fails the cold boot, or refuses its command line with the status of a usage error; @@ -2562,13 +2562,6 @@ fn run_guides_failed_cold_boots_on_cpus_that_no_built_in_profile_serves() { unserved, default, ), - ( - &["--host-loopback-forward", "tcp:8080:80"][..], - 2, - "openvmm: --host-loopback-forward requires explicit --host-loopback allow\n", - unserved, - default, - ), ( &[ "--cpu-profile", @@ -2659,6 +2652,7 @@ fn run_guides_failed_cold_boots_on_cpus_that_no_built_in_profile_serves() { ), (&["--restore-snapshot", "missing"][..], 1), (&["--net", "10.0.0.2/24"][..], 1), + (&["--host-loopback-forward", "tcp:8080:80"][..], 1), ( &["--mount", "/data", "--cmdline", "fake_exit_status=1"][..], 1, @@ -2669,6 +2663,13 @@ fn run_guides_failed_cold_boots_on_cpus_that_no_built_in_profile_serves() { ] { let output = run(&root, &arguments(options)); assert_eq!(output.status.code(), Some(status), "{options:?}"); + if options.first() == Some(&"--host-loopback-forward") { + assert!(output.stdout.is_empty(), "{options:?}"); + assert!( + text(&output.stderr).starts_with("error[unsupported]:"), + "{options:?}" + ); + } assert!( !text(&output.stderr).contains("nvx: "), "{options:?}: {}", @@ -2705,9 +2706,8 @@ fn run_boots_each_guest_with_its_default_memory() { #[test] fn run_shares_relative_roots_with_absolute_policy_paths() { - // nvx-rs run passes the host path of each share as given, so the children of an aggregate may - // be relative to the working directory, while their policy paths must be absolute: OpenVMM - // attributes each one to the share whose root contains it once it has resolved both. + // The cold path resolves each relative root in its checked export plan, while an aggregate's + // policy paths must be absolute. let root = Root::new(); let path = |parts: &[&str]| { parts @@ -2731,9 +2731,11 @@ fn run_shares_relative_roots_with_absolute_policy_paths() { let output = run(&root, &arguments); assert!(output.status.success(), "{}", text(&output.stderr)); let stdout = text(&output.stdout); - let child = nvx::host::sandbox::mount::aggregate_child_name(0, "/data"); + let canonical = fs::canonicalize(root.path.join("share")).unwrap(); + let child = canonical.to_str().unwrap(); + let child = child.strip_prefix(r"\\?\").unwrap_or(child); assert!( - stdout.contains(&format!(" --mount-child {child},share,rw ")), + stdout.contains(&format!(" --mount-child 0,{child},rw ")), "{stdout}" ); assert!( diff --git a/nvx-rs/tests/cli/download.rs b/nvx-rs/tests/cli/download.rs index f9d758cea..9a041cedb 100644 --- a/nvx-rs/tests/cli/download.rs +++ b/nvx-rs/tests/cli/download.rs @@ -10,13 +10,15 @@ use std::time::{Duration, Instant}; use crate::harness::{LAYER, NVX_RS, OPENVMM, Root, SCRATCH, UUID}; /// The files of a test root that a release installs. -const INSTALLED: [&str; 6] = [ +const INSTALLED: [&str; 8] = [ OPENVMM, "build/vmlinux", "build/initramfs.cpio.gz", "build/openvmm.provenance.json", "build/vmlinux.provenance.json", "build/initramfs.provenance.json", + "build/initramfs-ubuntu.cpio.gz.packages.json", + "build/initramfs-azurelinux.cpio.gz.packages.json", ]; /// Returns a root that is a checkout of NVX, with its `SOURCE-MANIFEST.json` and a `.git` that is diff --git a/nvx-rs/tests/cli/dry_run.rs b/nvx-rs/tests/cli/dry_run.rs index f8d796528..ec2c6984d 100644 --- a/nvx-rs/tests/cli/dry_run.rs +++ b/nvx-rs/tests/cli/dry_run.rs @@ -41,6 +41,8 @@ struct DryRun { arguments: Vec, /// The options of the OpenVMM command line that it prints, with placeholders. options: Options, + /// The specific unsupported configuration this case expects instead of a command. + refusal: Option<&'static str>, } /// Returns the dry run `name` of `arguments`, which prints an OpenVMM command line of `options`, @@ -61,10 +63,16 @@ fn dry_run( .map(|argument| argument.to_string()) .collect(), options, + refusal: None, } } impl DryRun { + fn refusing(mut self, message: &'static str) -> Self { + self.refusal = Some(message); + self + } + /// Returns the case with the file `relative` written first, with `content`. fn with_file(mut self, relative: &'static str, content: Content) -> Self { self.files.push((relative, content)); @@ -412,8 +420,7 @@ fn run_dry_runs() -> Vec { let many_children: Vec = (0..20) .map(|index| mount_child(index, &format!("/share/{index:02}"), "{root/share}", "ro")) .collect(); - let long = "x".repeat(1100); - let fits = "x".repeat(841); + let fits = "x".repeat(crate::openvmm::two_share_command_line_room()); vec![ dry_run("run: Alpine default", run(&[]), cold_boot()), dry_run( @@ -721,7 +728,8 @@ fn run_dry_runs() -> Vec { cold_boot() .with("--mount-deny", ["{root/share/hidden}"]) .with("--mount-allow", ["x"]), - ), + ) + .refusing("mount policy options require --mount"), dry_run( "run: several shares with policy paths", run(&[ @@ -778,7 +786,8 @@ fn run_dry_runs() -> Vec { )], ) .kernel(&[share_token(0, "/etc/single.txt", "ro", true)]), - ), + ) + .refusing("the guest target /etc/single.txt, which would cover the guest's /etc"), dry_run( "run: one relative shared file", run(&["--mount", "/etc/single.txt,single.txt"]), @@ -789,6 +798,29 @@ fn run_dry_runs() -> Vec { [mount_child(0, "/etc/single.txt", "single.txt", "ro,file")], ) .kernel(&[share_token(0, "/etc/single.txt", "ro", true)]), + ) + .refusing("the guest target /etc/single.txt, which would cover the guest's /etc"), + dry_run( + "run: one shared file outside system paths", + run(&["--mount", "/config/single.txt,{root/single.txt},ro"]), + cold_boot() + .with("--mount-aggregate", ["/run/nvx/shares"]) + .with( + "--mount-child", + [mount_child(0, "/config/single.txt", "{root/single.txt}", "ro,file")], + ) + .kernel(&[share_token(0, "/config/single.txt", "ro", true)]), + ), + dry_run( + "run: one relative shared file outside system paths", + run(&["--mount", "/config/single.txt,single.txt"]), + cold_boot() + .with("--mount-aggregate", ["/run/nvx/shares"]) + .with( + "--mount-child", + [mount_child(0, "/config/single.txt", "single.txt", "ro,file")], + ) + .kernel(&[share_token(0, "/config/single.txt", "ro", true)]), ), dry_run( "run: a directory and a file", @@ -860,12 +892,14 @@ fn run_dry_runs() -> Vec { "run: host path ending in a mode", run(&["--mount", "/data,{root/share},rw,ro"]), cold_boot().with("--mount", ["/data,{root/share},rw,ro"]), - ), + ) + .with_file("share,rw/data.txt", Content::Text("data\n")), dry_run( "run: host path ending in a comma", run(&["--mount", "/data,{root/share},,rw"]), cold_boot().with("--mount", ["/data,{root/share},,rw"]), - ), + ) + .with_file("share,/data.txt", Content::Text("data\n")), dry_run( "run: vmm owner", run(&["--mount", "/data,{root/share}", "--mount-owner", "vmm"]), @@ -934,7 +968,7 @@ fn run_dry_runs() -> Vec { ) .with("--cmdline", ["quiet"]), ), - // `nvx-rs run` passes the network options to OpenVMM as they are. + // A proxy is exclusive of direct egress allow rules. dry_run( "run: network", run(&[ @@ -974,12 +1008,69 @@ fn run_dry_runs() -> Vec { .with("--host-loopback", ["allow"]) .with("--network-proxy", ["10.0.0.1:3128"]) .with("--host-loopback-forward", ["tcp:8080:80", "udp:5353:53"]), + ) + .refusing("a network proxy takes no --network-egress-allow rules"), + dry_run( + "run: direct network rules and forwards", + run(&[ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "allow", + "--network-ingress", + "allow", + "--network-egress-allow", + "10.0.0.0/8:tcp:443", + "--network-egress-allow", + "2001:db8::/32", + "--network-egress-deny", + "10.1.0.0/16", + "--host-loopback", + "allow", + "--host-loopback-forward", + "tcp:8080:80", + "--host-loopback-forward", + "udp:5353:53", + ]), + cold_boot() + .with("--net", ["10.0.0.2/24"]) + .with("--network-profile", ["portable"]) + .with("--network-egress", ["allow"]) + .with("--network-ingress", ["allow"]) + .with("--network-egress-allow", ["10.0.0.0/8:tcp:443", "2001:db8::/32"]) + .with("--network-egress-deny", ["10.1.0.0/16"]) + .with("--host-loopback", ["allow"]) + .with("--host-loopback-forward", ["tcp:8080:80", "udp:5353:53"]), + ), + dry_run( + "run: proxy-only network", + run(&[ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + "--host-loopback", + "deny", + "--network-proxy", + "10.0.0.1:3128", + ]), + cold_boot() + .with("--net", ["10.0.0.2/24"]) + .with("--network-profile", ["portable"]) + .with("--network-egress", ["deny"]) + .with("--host-loopback", ["deny"]) + .with("--network-proxy", ["10.0.0.1:3128"]), ), dry_run( "run: egress default", run(&["--network-egress", "deny"]), cold_boot().with("--network-egress", ["deny"]), - ), + ) + .refusing("network policy options require --net"), // A restore takes the addressing of its NIC from the snapshot, so it passes the profile // and the policy without `--net`. dry_run( @@ -1072,16 +1163,23 @@ fn run_dry_runs() -> Vec { "run: host loopback forward", run(&["--host-loopback-forward", "tcp:8080:80"]), cold_boot().with("--host-loopback-forward", ["tcp:8080:80"]), - ), + ) + .refusing("network policy options require --net"), dry_run( "run: egress policy file", run(&[ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", "--network-egress", "deny", "--network-egress-policy-file", "{root/policy.json}", ]), cold_boot() + .with("--net", ["10.0.0.2/24"]) + .with("--network-profile", ["portable"]) .with("--network-egress", ["deny"]) .with("--network-egress-allow", COMPILED_ALLOW) .with("--network-egress-deny", COMPILED_DENY), @@ -1090,12 +1188,19 @@ fn run_dry_runs() -> Vec { dry_run( "run: empty egress policy file", run(&[ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", "--network-egress", "allow", "--network-egress-policy-file", "{root/./policy.json}", ]), - cold_boot().with("--network-egress", ["allow"]), + cold_boot() + .with("--net", ["10.0.0.2/24"]) + .with("--network-profile", ["portable"]) + .with("--network-egress", ["allow"]), ) .with_file("policy.json", Content::Text("{}")), dry_run( @@ -1107,6 +1212,27 @@ fn run_dry_runs() -> Vec { "run: quoted command line", run(&["--cmdline", "a=\"b c\" d"]), cold_boot().with("--cmdline", ["a=\"b c\" d"]), + ) + .refusing( + "boot.command_line cannot contain double quotes or a standalone -- with the OpenVMM control console", + ), + dry_run( + "run: standalone command line delimiter", + run(&["--cmdline", "quiet -- debug"]), + cold_boot().with("--cmdline", ["quiet -- debug"]), + ) + .refusing( + "boot.command_line cannot contain double quotes or a standalone -- with the OpenVMM control console", + ), + dry_run( + "run: restore retains quoted command line and delimiter", + run(&[ + "--restore-snapshot", + "{root/snapshot}", + "--cmdline", + "a=\"b c\" -- d", + ]), + restore().with("--cmdline", ["a=\"b c\" -- d"]), ), dry_run( "run: command line starting with a dash", @@ -1133,14 +1259,6 @@ fn run_dry_runs() -> Vec { run(&["--cmdline", "a", "--cmdline", "b"]), cold_boot().with("--cmdline", ["b"]), ), - // One directory takes no boot token, so no budget limits the command line. - dry_run( - "run: long command line of one directory share", - run(&["--mount", "/data,{root/share}", "--cmdline", &long]), - cold_boot() - .with("--mount", ["/data,{root/share},ro"]) - .with("--cmdline", [long.as_str()]), - ), dry_run( "run: restore without the budget", run(&many), @@ -1202,8 +1320,35 @@ fn dry_runs_print_the_openvmm_command_and_launch_nothing() { } let before = root.snapshot(); let arguments: Vec<&str> = case.arguments.iter().map(String::as_str).collect(); + let cold = case + .arguments + .first() + .is_some_and(|command| command == "run") + && !case + .arguments + .iter() + .any(|argument| argument == "--restore-snapshot"); let outcome = root.nvx_rs(&arguments); eprintln!("case: {}", case.name); + if let Some(message) = case.refusal { + assert_eq!(outcome.status, Some(1), "{}", outcome.describe()); + assert!(outcome.stdout.is_empty(), "{}", outcome.describe()); + assert!( + outcome.stderr().starts_with("error[unsupported]:"), + "{}", + outcome.describe() + ); + assert!(outcome.stderr().contains(message), "{}", outcome.describe()); + assert_eq!(root.snapshot(), before, "{}", case.name); + return; + } + let expected = if cold { + case.options + .cold_run_expected(&root, "one-shot") + .unwrap_or_else(|error| panic!("{} unexpectedly refused: {error}", case.name)) + } else { + case.options.expanded(&root) + }; outcome.assert_success(); let (command, rest) = outcome.command(); assert!(rest.is_empty(), "{}: {}", case.name, outcome.describe()); @@ -1214,8 +1359,13 @@ fn dry_runs_print_the_openvmm_command_and_launch_nothing() { "{}", case.name ); - case.options - .assert_matches(&root, &Options::parse(arguments), case.name); + let actual = Options::parse(arguments); + let actual = if cold { + actual.normalized_cold_outputs(true) + } else { + actual + }; + expected.assert_matches(&root, &actual, case.name); // The run launched no OpenVMM, which records its command line once it has accepted it. assert_eq!(root.snapshot(), before, "{}", case.name); }); @@ -1238,8 +1388,7 @@ fn command_lines() -> impl Strategy { #[test] fn the_openvmm_command_of_a_run_splits_back_into_its_arguments() { - // `nvx-rs run` passes its kernel command line to OpenVMM as it is, so the `>> ` line, split as - // this host splits command lines, must give it back whatever characters it holds: with a + // Accepted caller text survives the `>> ` line: with a // shell-word splitter on POSIX, and with `CommandLineToArgvW` on Windows. let root = Root::new(); let openvmm = root.below(OPENVMM).to_str().unwrap().to_owned(); @@ -1257,16 +1406,27 @@ fn the_openvmm_command_of_a_run_splits_back_into_its_arguments() { ]; let output = root.command(&arguments).output().unwrap(); let outcome = Outcome::new(arguments, output); + if command_line.contains('"') + || command_line.split_ascii_whitespace().any(|token| token == "--") + { + prop_assert_eq!(outcome.status, Some(1), "{}", outcome.describe()); + prop_assert!(outcome.stdout.is_empty(), "{}", outcome.describe()); + prop_assert_eq!( + outcome.stderr(), + format!("error[unsupported]: boot.command_line cannot contain double quotes or a standalone -- with the OpenVMM control console{}", crate::harness::LINE_END) + ); + return Ok(()); + } + let expected = cold_boot() + .with("--cmdline", [command_line.clone()]) + .cold_run_expected(&root, "one-shot") + .unwrap(); outcome.assert_success(); let (command, rest) = outcome.command(); prop_assert!(rest.is_empty(), "{}", outcome.describe()); prop_assert_eq!(&command[0], &openvmm); - let mut expected = cold_boot().expanded(&root); - if !command_line.is_empty() { - expected = expected.with("--cmdline", [command_line.clone()]); - } prop_assert_eq!( - Options::parse(&command[1..]), + Options::parse(&command[1..]).normalized_cold_outputs(true), expected, "{}", outcome.describe() diff --git a/nvx-rs/tests/cli/harness.rs b/nvx-rs/tests/cli/harness.rs index 1470a1cf5..acdd23e19 100644 --- a/nvx-rs/tests/cli/harness.rs +++ b/nvx-rs/tests/cli/harness.rs @@ -90,9 +90,17 @@ pub(crate) const PROVENANCE: [(&str, &str, &str); 3] = [ /// The other files of a root, by their paths below it, and their contents: the guest kernel and /// Alpine initramfs of a checkout, the layers of the sandboxes, of which only the distro layer /// has a role, their scratch image, and the directories and the file that they share. -const FILES: [(&str, &str); 12] = [ +const FILES: [(&str, &str); 14] = [ ("build/vmlinux", "kernel\n"), ("build/initramfs.cpio.gz", "initramfs\n"), + ( + "build/initramfs-ubuntu.cpio.gz.packages.json", + "{\"artifact_sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"}\n", + ), + ( + "build/initramfs-azurelinux.cpio.gz.packages.json", + "{\"artifact_sha256\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\"}\n", + ), ("distro.erofs", "distro\n"), ("runtime.erofs", "runtime\n"), ("custom.erofs", "custom\n"), diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index cef3c22a5..b14be2d20 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -49,6 +49,8 @@ struct Launch { stderr: &'static str, /// The outcome report that OpenVMM writes, if the run asks for one. report: Option, + /// A specific checked-configuration refusal, with no OpenVMM launch. + refusal: Option<&'static str>, } /// Returns the launch `name` of `arguments`, which starts OpenVMM with `options` and exits with @@ -75,10 +77,17 @@ fn launch( stdout, stderr: "", report: None, + refusal: None, } } impl Launch { + fn refusing(mut self, message: &'static str) -> Self { + self.refusal = Some(message); + self.status = 1; + self + } + /// Returns the case with the file `relative` written first, with `content`. fn with_file(mut self, (relative, content): (&'static str, Content)) -> Self { self.files.push((relative, content)); @@ -642,7 +651,16 @@ fn run_launches() -> Vec { "", ) .with_stderr("openvmm: --host-loopback-forward requires explicit --host-loopback allow\n") - .recorded_in(None), + .recorded_in(None) + .refusing("network policy options require --net"), + launch( + "run: OpenVMM exits with a usage status", + run(&["--cmdline", "fake_exit_on_start=2"]), + cold_boot().with("--cmdline", ["fake_exit_on_start=2"]), + 2, + "", + ) + .with_stderr("openvmm: failing the launch as requested\n"), // A relative NVX_OPENVMM is relative to the working directory, and the command names it // by its absolute path. launch( @@ -678,8 +696,30 @@ fn one_shot_runs_exit_with_the_workload_or_openvmm_status() { .iter() .map(|(name, value)| (*name, value.as_str())) .collect(); + let cold = case + .arguments + .first() + .is_some_and(|command| command == "run") + && !case + .arguments + .iter() + .any(|argument| argument == "--restore-snapshot"); + let before = case.refusal.map(|_| root.snapshot()); let outcome = root.nvx_rs_with(&arguments, &environment); eprintln!("case: {}", case.name); + if let Some(message) = case.refusal { + assert_eq!(outcome.status, Some(case.status), "{}", outcome.describe()); + assert!(outcome.stdout.is_empty(), "{}", outcome.describe()); + assert!( + outcome.stderr().starts_with("error[unsupported]:"), + "{}", + outcome.describe() + ); + assert!(outcome.stderr().contains(message), "{}", outcome.describe()); + assert_eq!(Some(root.snapshot()), before); + return; + } + let expected_cold = cold.then(|| case.options.cold_run_expected(&root, "one-shot")); assert_eq!(outcome.status, Some(case.status), "{}", outcome.describe()); let (command, rest) = outcome.command(); // The workload's output follows the command byte for byte, as does OpenVMM's. @@ -705,11 +745,28 @@ fn one_shot_runs_exit_with_the_workload_or_openvmm_status() { case.name ); // The `>> ` line splits back into the arguments that OpenVMM received. - if let Some(record) = case.record { - assert_eq!(root.openvmm_arguments(record), arguments, "{}", case.name); - } let mut options = Options::parse(arguments); - let mut expected = case.options.clone(); + let record = case.record.map(|record| { + if cold { + let report = std::path::Path::new(options.value("--microvm-report")); + let token = report + .parent() + .unwrap() + .file_name() + .unwrap() + .to_str() + .unwrap(); + format!("build/fake-openvmm-{token}.json") + } else { + record.to_owned() + } + }); + if let Some(record) = record { + assert_eq!(root.openvmm_arguments(&record), arguments, "{}", case.name); + } + let mut expected = expected_cold + .map(|expected| expected.unwrap()) + .unwrap_or_else(|| case.options.clone()); // A sandbox's OpenVMM opens the images through the files that the run checked: the distro // layer alone for an upper in RAM, and the scratch image too for one on scratch. let blocks = expected.take("--microvm-sandbox-block"); @@ -728,6 +785,9 @@ fn one_shot_runs_exit_with_the_workload_or_openvmm_status() { assert_opened_images(&root, token); } } + if cold { + options = options.normalized_cold_outputs(true); + } expected.assert_matches(&root, &options, case.name); if let Some(report) = &case.report { assert_outcome_report( @@ -740,6 +800,342 @@ fn one_shot_runs_exit_with_the_workload_or_openvmm_status() { }); } +#[test] +fn both_cold_modes_keep_their_foreground_output_and_owned_exit_status() { + for (mode, caller, status, stdout) in [ + ( + "one-shot", + "fake_exit_status=7", + 7, + "openvmm: booted the microVM\n", + ), + ("managed", "fake_crash_after_ms=100", 3, ""), + ] { + let root = Root::new(); + let identity = if mode == "managed" { + " nvx_identity=1001:1001" + } else { + "" + }; + let outcome = root.nvx_rs(&[ + "run", + "--cmdline", + &format!("{caller} nvx_mode={mode}{identity}"), + ]); + assert_eq!(outcome.status, Some(status), "{}", outcome.describe()); + let (command, output) = outcome.command(); + assert_eq!(output, stdout.as_bytes(), "{}", outcome.describe()); + assert_eq!(command[0], root.below(OPENVMM).to_str().unwrap()); + let mut expected = cold_boot() + .with( + "--cmdline", + [format!( + "{caller}{}", + if identity.is_empty() { " " } else { " " } + )], + ) + .cold_run_expected(&root, mode) + .unwrap(); + if !identity.is_empty() { + let line = format!("{}{identity}", expected.value("--cmdline")); + expected = expected.with("--cmdline", [line]); + } + let actual = Options::parse(&command[1..]).normalized_cold_outputs(true); + expected.assert_matches(&root, &actual, mode); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn cold_terminal_runs_forward_raw_output_and_restore_the_callers_terminal() { + use std::fs::File; + use std::io::{self, Read}; + use std::process::Stdio; + use std::time::{Duration, Instant}; + + use nix::fcntl::{FcntlArg, OFlag, fcntl}; + use nix::pty::openpty; + use nix::sys::termios::{LocalFlags, OutputFlags, tcgetattr}; + + for status in [0, 7] { + let root = Root::new(); + let terminal = openpty(None, None).unwrap(); + let original = tcgetattr(&terminal.slave).unwrap(); + let mut process = root + .command(&[ + "run", + "--cmdline", + &format!("fake_boot_delay_ms=250 fake_exit_status={status}"), + ]) + .stdin(Stdio::from(terminal.slave.try_clone().unwrap())) + .stdout(Stdio::from(terminal.slave.try_clone().unwrap())) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let mut observed_raw = false; + while process.try_wait().unwrap().is_none() { + let current = tcgetattr(&terminal.slave).unwrap(); + observed_raw |= !current + .local_flags + .intersects(LocalFlags::ICANON | LocalFlags::ECHO) + && !current.output_flags.contains(OutputFlags::OPOST); + if Instant::now() >= deadline { + process.kill().unwrap(); + process.wait().unwrap(); + panic!("terminal run did not exit within its deadline"); + } + std::thread::sleep(Duration::from_millis(5)); + } + let outcome = process.wait_with_output().unwrap(); + assert_eq!( + outcome.status.code(), + Some(status), + "{}", + String::from_utf8_lossy(&outcome.stderr) + ); + assert!(observed_raw, "cold run left its terminal cooked"); + let restored = tcgetattr(&terminal.slave).unwrap(); + assert_eq!(restored.input_flags, original.input_flags); + assert_eq!(restored.output_flags, original.output_flags); + assert_eq!(restored.local_flags, original.local_flags); + assert_eq!(restored.control_flags, original.control_flags); + assert_eq!(restored.control_chars, original.control_chars); + fcntl(&terminal.master, FcntlArg::F_SETFL(OFlag::O_NONBLOCK)).unwrap(); + let mut master = File::from(terminal.master); + let mut output = Vec::new(); + let mut buffer = [0; 8192]; + loop { + match master.read(&mut buffer) { + Ok(0) => break, + Ok(count) => output.extend_from_slice(&buffer[..count]), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => break, + Err(error) => panic!("cannot read terminal output: {error}"), + } + } + assert!( + output.ends_with(b"openvmm: booted the microVM\n"), + "{output:?}" + ); + } +} + +#[test] +fn cold_runs_refuse_existing_and_missing_parent_reports_before_launch() { + let root = Root::new(); + root.write("reports/existing.json", "keep\n"); + for (path, message) in [ + ( + "{root/reports/existing.json}", + "error[outcome-report-exists]:", + ), + ( + "{root/missing/outcome.json}", + "outcome report parent is not a plain directory", + ), + ] { + let before = root.snapshot(); + let outcome = root.nvx_rs(&["run", "--outcome-report", path]); + assert_eq!(outcome.status, Some(1), "{}", outcome.describe()); + assert!(outcome.stdout.is_empty(), "{}", outcome.describe()); + assert!(outcome.stderr().contains(message), "{}", outcome.describe()); + assert_eq!(root.snapshot(), before); + } +} + +#[test] +fn missing_cold_reports_do_not_replace_the_owned_child_status() { + for line in [ + "fake_exit_on_start=3", + "fake_crash_after_ms=100 nvx_mode=managed nvx_identity=1001:1001", + ] { + let root = Root::new(); + let outcome = root.nvx_rs(&[ + "run", + "--cmdline", + line, + "--outcome-report", + "{root/reports/outcome.json}", + ]); + assert_eq!(outcome.status, Some(3), "{}", outcome.describe()); + assert!( + outcome.stderr().contains("outcome.json"), + "{}", + outcome.describe() + ); + assert!(!root.below("reports/outcome.json").exists()); + } +} + +#[test] +fn cold_report_publication_never_clobbers_a_destination_created_during_boot() { + use std::io::{BufRead, BufReader}; + use std::process::Stdio; + + for symlink in [false, true] + .into_iter() + .filter(|symlink| !symlink || cfg!(unix)) + { + let root = Root::new(); + let destination = root.below("reports/outcome.json"); + root.write("reports/victim.txt", "keep\n"); + let mut process = root + .command(&[ + "run", + "--cmdline", + "fake_boot_delay_ms=250 fake_exit_status=7", + "--outcome-report", + destination.to_str().unwrap(), + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdout = BufReader::new(process.stdout.take().unwrap()); + let mut command = String::new(); + stdout.read_line(&mut command).unwrap(); + assert!(command.starts_with(">> "), "{command}"); + if symlink { + #[cfg(unix)] + std::os::unix::fs::symlink(root.below("reports/victim.txt"), &destination).unwrap(); + } else { + root.write("reports/outcome.json", "keep\n"); + } + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(7), "{output:?}"); + assert!( + String::from_utf8(output.stderr) + .unwrap() + .contains("outcome report already exists"), + "the publication failure must be reported" + ); + assert_eq!(std::fs::read(&destination).unwrap(), b"keep\n"); + assert_eq!( + std::fs::read(root.below("reports/victim.txt")).unwrap(), + b"keep\n" + ); + if symlink { + assert!( + std::fs::symlink_metadata(&destination) + .unwrap() + .is_symlink() + ); + } + } +} + +#[test] +fn cold_console_backpressure_does_not_block_output_or_replace_the_child_status() { + use std::io::{self, Write}; + use std::process::Stdio; + + let root = Root::new(); + let arguments = [ + "run", + "--cmdline", + "fake_boot_delay_ms=2500 fake_exit_status=7", + ]; + let mut process = root + .command(&arguments) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdin = process.stdin.take().unwrap(); + let writer = std::thread::spawn(move || stdin.write_all(&vec![b'x'; 16 * 1024 * 1024])); + let output = process.wait_with_output().unwrap(); + if let Err(error) = writer.join().unwrap() { + assert_eq!(error.kind(), io::ErrorKind::BrokenPipe, "{error}"); + } + let outcome = crate::harness::Outcome::new( + arguments + .iter() + .map(|argument| (*argument).to_owned()) + .collect(), + output, + ); + assert_eq!(outcome.status, Some(7), "{}", outcome.describe()); + assert_eq!( + outcome.command().1, + b"openvmm: booted the microVM\n", + "{}", + outcome.describe() + ); +} + +#[test] +fn cold_console_output_preserves_non_utf8_bytes() { + let root = Root::new(); + let outcome = root.nvx_rs(&[ + "run", + "--cmdline", + "fake_binary_console=1 fake_exit_status=7", + ]); + assert_eq!(outcome.status, Some(7), "{}", outcome.describe()); + assert_eq!( + outcome.command().1, + &[b'A', 0xff, 0x80, b'\n'], + "{}", + outcome.describe() + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn cold_runs_keep_the_native_child_in_the_callers_session_and_process_group() { + use std::process::Stdio; + use std::time::{Duration, Instant}; + + use nix::unistd::{Pid, getpgid, getsid}; + + let root = Root::new(); + let process = root + .command(&["run", "--cmdline", "fake_boot_delay_ms=500"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let caller = Pid::from_raw(process.id() as i32); + let deadline = Instant::now() + Duration::from_secs(10); + let child = loop { + let children = std::fs::read_to_string(format!( + "/proc/{}/task/{}/children", + caller.as_raw(), + caller.as_raw() + )) + .unwrap(); + let native = children.split_ascii_whitespace().find_map(|pid| { + let arguments = match std::fs::read(format!("/proc/{pid}/cmdline")) { + Ok(arguments) => arguments, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, + Err(error) => panic!("cannot inspect the owned child {pid}: {error}"), + }; + arguments + .split(|byte| *byte == 0) + .any(|argument| argument == b"--microvm-control-console") + .then(|| Pid::from_raw(pid.parse().unwrap())) + }); + if let Some(child) = native { + break child; + } + assert!( + Instant::now() < deadline, + "the owned native child did not appear" + ); + std::thread::sleep(Duration::from_millis(10)); + }; + let caller_session = getsid(Some(caller)).unwrap(); + let caller_group = getpgid(Some(caller)).unwrap(); + let child_session = getsid(Some(child)).unwrap(); + let child_group = getpgid(Some(child)).unwrap(); + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(0), "{output:?}"); + assert_eq!(child_session, caller_session); + assert_eq!(child_group, caller_group); +} + /// A run leaves an outcome report that the caller can read whatever its umask, even one that /// clears the owner's own permissions, and whose group's and others' permissions the caller's /// umask narrows: OpenVMM starts with the caller's umask without the owner's bits. diff --git a/nvx-rs/tests/cli/openvmm.rs b/nvx-rs/tests/cli/openvmm.rs index b25d02c20..48360f4cf 100644 --- a/nvx-rs/tests/cli/openvmm.rs +++ b/nvx-rs/tests/cli/openvmm.rs @@ -6,11 +6,27 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; +use std::path::{Path, PathBuf}; use std::sync::LazyLock; use serde_json::{Value, json}; -use crate::harness::{Content, HYPERVISOR, Root, is_hex}; +use crate::harness::{Content, HYPERVISOR, OPENVMM, Root, is_hex}; + +/// Caller bytes left by the two-directory fixture, its native bootstrap, and a terminating NUL. +pub(crate) fn two_share_command_line_room() -> usize { + use nvx::host::filesystem::GUEST_EXPORT; + use nvx::host::sandbox::mount::{ + AGGREGATE_MOUNT_TARGET, MountAccess, aggregate_command_line_fragment, + }; + + let kernel = "nvx_hostname=6e7678 nvx_machine_id=image nvx_maps=2 nvx_mode=one-shot \ + nvx_share=0,/a,ro nvx_share=1,/b,rw"; + let fragment = + aggregate_command_line_fragment(&[MountAccess::ReadOnly, MountAccess::ReadWrite]) + .replace(AGGREGATE_MOUNT_TARGET, GUEST_EXPORT); + nvx::host::config::COMMAND_LINE_BUDGET - kernel.len() - fragment.len() - 2 +} /// The options of OpenVMM that take no value. const FLAGS: [&str; 3] = [ @@ -169,6 +185,338 @@ impl Options { } panic!("{context}: the OpenVMM options differ:\n{differences}"); } + + /// Projects an old cold-run fixture onto the checked public VM contract, independently of + /// the CLI parser and its private token renderer. Restore and sandbox fixtures stay old. + pub(crate) fn cold_run_expected(&self, root: &Root, mode: &str) -> nvx::Result { + use nvx::activation::{MachineIdMode, View}; + use nvx::host::config::{Boot, ConsoleAttachment, NetworkConfig, RootConfig, VmConfig}; + use nvx::host::filesystem::{ + self, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, + }; + use nvx::host::network::{ + Access, EgressRule, EgressRules, HostLoopback, LoopbackForward, NetworkPolicy, + NetworkProxy, + }; + use nvx::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; + use nvx::host::sandbox::mount::{MountAccess, parse_mount_value}; + use nvx::host::vm::{self, VmDir}; + + use nvx::host::network::{ForwardProtocol, Protocol}; + + let old = self.expanded(root); + let mut caller = old.values("--cmdline").first().cloned().unwrap_or_default(); + let mut shares = Vec::new(); + let mut files = BTreeSet::new(); + for value in old.values("--mount") { + let (target, host, access) = parse_mount_value(value)?; + let host = PathBuf::from(host); + let host = if host.is_absolute() { + host + } else { + root.below(host.to_str().unwrap()) + }; + if host.is_file() { + files.insert(target.clone()); + } + shares.push((target, host, access)); + } + let tokens: Vec<_> = caller.split_ascii_whitespace().map(str::to_owned).collect(); + let mut generated = Vec::new(); + for value in old.values("--mount-child") { + let child = value.strip_suffix(",file").unwrap_or(value); + let (name, host, access) = parse_mount_value(child)?; + let token = tokens + .iter() + .find(|token| { + token + .strip_prefix("nvx_share=") + .is_some_and(|share| share.starts_with(&format!("{name},"))) + }) + .expect("the aggregate fixture names every child's target"); + let parts: Vec<_> = token + .strip_prefix("nvx_share=") + .unwrap() + .split(',') + .collect(); + let target = parts[1].to_owned(); + let host = PathBuf::from(host); + let host = if host.is_absolute() { + host + } else { + root.below(host.to_str().unwrap()) + }; + if value.ends_with(",file") { + files.insert(target.clone()); + } + shares.push((target, host, access)); + generated.push(token.clone()); + } + for token in generated.iter().rev() { + caller = if caller == *token { + String::new() + } else { + caller + .strip_suffix(&format!(" {token}")) + .expect("fixture boot shares follow caller text") + .to_owned() + }; + } + let aggregate = shares.len() > 1 || !files.is_empty(); + let mut policies: [Vec; 3] = Default::default(); + for (index, option) in ["--mount-deny", "--mount-allow", "--mount-write"] + .into_iter() + .enumerate() + { + for value in old.values(option) { + let path = PathBuf::from(value); + if shares.is_empty() || (aggregate && !path.is_absolute()) { + return Err(nvx::Error::unsupported( + "boot share policies need a mapped path and absolute aggregate paths", + )); + } + policies[index].push(if path.is_absolute() { + path + } else { + shares[0].1.join(path) + }); + } + } + let mappings: Vec<_> = shares + .iter() + .map(|(target, host, access)| Mapping { + name: "--mount", + path: host, + target: Target::Guest(target), + read_only: *access == MountAccess::ReadOnly, + }) + .collect(); + let export = filesystem::plan(&ExportRequest { + mappings: &mappings, + denied: PathList { + name: "--mount-deny", + paths: &policies[0], + }, + allowed: PathList { + name: "--mount-allow", + paths: &policies[1], + }, + write: PathList { + name: "--mount-write", + paths: &policies[2], + }, + owner: if old + .values("--mount-owner") + .first() + .is_some_and(|value| value == "caller") + { + Owner::Caller + } else { + Owner::Vmm + }, + reserved: ReservedTargets::BLOCKLESS_ROOT, + })?; + let mut boot_tokens = vec![format!("nvx_mode={mode}")]; + if let Some(export) = &export { + boot_tokens.extend(export.guest_table().into_iter().map(|row| { + let access = if row.read_only { "ro" } else { "rw" }; + let kind = if files.contains(&row.target) { + ",file" + } else { + "" + }; + format!("nvx_share={},{},{access}{kind}", row.source, row.target) + })); + } + if !caller.is_empty() { + boot_tokens.insert(0, caller); + } + if old.values("--net").is_empty() + && [ + "--network-egress", + "--network-ingress", + "--network-egress-allow", + "--network-egress-deny", + "--host-loopback", + "--host-loopback-forward", + "--network-proxy", + ] + .into_iter() + .any(|option| !old.values(option).is_empty()) + { + return Err(nvx::Error::unsupported( + "network policy options require --net", + )); + } + let network = if let Some(net) = old.values("--net").first() { + let access = |option, default| match old.values(option).first().map(String::as_str) { + Some("allow") => Access::Allow, + Some("deny") => Access::Deny, + _ => default, + }; + let mut policy = NetworkPolicy::new(access("--network-egress", Access::Allow)); + for (option, destination) in [ + ("--network-egress-allow", &mut policy.allow), + ("--network-egress-deny", &mut policy.deny), + ] { + *destination = EgressRules { + name: option.to_owned(), + rules: old + .values(option) + .iter() + .map(|value| { + for (suffix, protocol) in [ + (":tcp", Protocol::Tcp), + (":udp", Protocol::Udp), + (":icmp", Protocol::Icmp), + ] { + if let Some((destination, ports)) = value.split_once(suffix) { + let rule = EgressRule::to(destination); + return if ports.is_empty() { + rule.on_protocol(protocol) + } else { + let ports = ports.strip_prefix(':').unwrap(); + if let Some((first, last)) = ports.split_once('-') { + rule.on_port_range( + protocol, + first.parse().unwrap(), + last.parse().unwrap(), + ) + } else { + rule.on_port(protocol, ports.parse().unwrap()) + } + }; + } + } + EgressRule::to(value) + }) + .collect(), + }; + } + policy.ingress = access("--network-ingress", Access::Deny); + policy.host_loopback = match old.values("--host-loopback").first().map(String::as_str) { + Some("allow") => HostLoopback::Allow, + Some("deny") => HostLoopback::Deny, + _ => HostLoopback::Outbound, + }; + policy.host_loopback_forwards = old + .values("--host-loopback-forward") + .iter() + .map(|value| { + let fields: Vec<_> = value.split(':').collect(); + LoopbackForward { + protocol: match fields[0] { + "tcp" => ForwardProtocol::Tcp, + "udp" => ForwardProtocol::Udp, + _ => panic!("a fixture names tcp or udp"), + }, + host_port: fields[1].parse().unwrap(), + guest_port: fields[2].parse().unwrap(), + } + }) + .collect(); + if let Some(proxy) = old.values("--network-proxy").first() { + let (gateway, port) = proxy.rsplit_once(':').unwrap(); + assert_eq!(gateway, nvx::net::guest_gateway(net).unwrap().to_string()); + policy.proxy = Some(NetworkProxy::new(port.parse().unwrap())); + } + Some(NetworkConfig { + guest_network: Some(nvx::net::Ipv4Cidr::parse(net).unwrap()), + policy, + }) + } else { + None + }; + let config = VmConfig { + processors: old.value("--processors").parse().unwrap(), + memory_mib: old.value("--memory").trim_end_matches('M').parse().unwrap(), + root: RootConfig::Blockless, + view: View { + hostname: b"nvx".to_vec(), + machine_id: MachineIdMode::Image, + }, + network, + filesystem: export, + console: ConsoleAttachment::Listener, + boot: Some(Boot { + command_line: boot_tokens.join(" "), + memory_capacity_mib: old + .values("--memory-capacity") + .first() + .map(|value| value.trim_end_matches('M').parse().unwrap()), + capture: None, + }), + }; + config.check_command_line()?; + let identity = ArtifactIdentity::new("test-fixture").unwrap(); + let runtime = Runtime { + openvmm: root.below(OPENVMM), + kernel: PathBuf::from(old.value("--kernel")), + initramfs: PathBuf::from(old.value("--initrd")), + identity: RuntimeIdentity::new(identity.clone(), identity.clone(), identity), + hypervisor: old.value("--hypervisor").parse().unwrap(), + cpu_profile: old.values("--cpu-profile").first().cloned(), + windows_breakaway: false, + options: LaunchOptions::default(), + }; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("expected-vm"); + vm::create_private_dir(&path)?; + let prepared = vm::create(&runtime, &config, None, &VmDir::open(&path)?)?; + let arguments: Vec<_> = prepared + .arguments() + .iter() + .map(|value| value.to_str().unwrap().to_owned()) + .collect(); + let expected = Self::parse(&arguments).normalized_cold_outputs(false); + drop(prepared); + Ok(expected) + } + + /// Checks fresh output names rather than making random endpoint values an assertion escape. + pub(crate) fn normalized_cold_outputs(&self, removed: bool) -> Self { + let mut options = self.clone(); + let report = PathBuf::from(options.value("--microvm-report")); + assert!(report.is_absolute()); + assert_eq!(report.file_name().unwrap(), "outcome.json"); + let parent = report.parent().unwrap(); + if removed { + let name = parent.file_name().unwrap().to_str().unwrap(); + let suffix = name + .strip_prefix("nvx-run-") + .expect("a cold CLI run owns a fresh VM directory"); + assert_eq!(suffix.len(), 32); + assert!(suffix.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert!( + !parent.exists(), + "the foreground run left its VM directory: {}", + parent.display() + ); + } + let console = options + .value("--virtio-console") + .strip_prefix("listen=") + .unwrap(); + let control = options + .value("--microvm-control-console") + .strip_prefix("listen=") + .unwrap(); + assert_ne!(console, control); + for (endpoint, name) in [(console, "console.sock"), (control, "control.sock")] { + if cfg!(windows) { + let suffix = endpoint.strip_prefix("//./pipe/openvmm-microvm-").unwrap(); + assert_eq!(suffix.len(), 32); + assert!(suffix.bytes().all(|byte| byte.is_ascii_hexdigit())); + } else { + assert_eq!(Path::new(endpoint), parent.join(name)); + } + } + options = options + .with("--virtio-console", ["listen="]) + .with("--microvm-control-console", ["listen="]) + .with("--microvm-report", [""]); + options + } } /// Returns the options with which a one-shot run of the minimal sandbox, `sandbox run --layer diff --git a/nvx-rs/tests/cli/validation.rs b/nvx-rs/tests/cli/validation.rs index 441e54fc9..7cb9f24ff 100644 --- a/nvx-rs/tests/cli/validation.rs +++ b/nvx-rs/tests/cli/validation.rs @@ -729,6 +729,7 @@ fn sandbox_refusals() -> Vec { fn run_refusals() -> Vec { let targets = "with several shares, or a shared file"; let budget = &["--mount", "--cmdline", "1024"][..]; + let overflow = crate::openvmm::two_share_command_line_room() + 1; let two_shares = |cmdline: &str| { run_dry(&[ "--mount", @@ -740,7 +741,7 @@ fn run_refusals() -> Vec { ]) }; let mut many = vec!["run".to_owned()]; - many.extend(shares(20)); + many.extend(shares(42)); many.push("--dry-run".to_owned()); vec![ refusal( @@ -1230,13 +1231,12 @@ fn run_refusals() -> Vec { ]), &["--mount target", "\"/voil\u{e0}\"", targets], ), - // The target of one directory share reaches the kernel command line in OpenVMM's - // virtfs_dir= token. + // A single directory is now checked for the same planned nvx_share= row as an aggregate. refusal( "run: quoted target of one share", "unsupported", run_dry(&["--mount", "/a\"b,{root/share}"]), - &["--mount target", "\"/a\\\"b\"", "one kernel token"], + &["--mount target", "\"/a\\\"b\"", "quotes"], ), refusal( "run: target with a dot component", @@ -1331,14 +1331,27 @@ fn run_refusals() -> Vec { refusal( "run: budget exceeded by a byte", "unsupported", - two_shares(&"x".repeat(842)), + two_shares(&"x".repeat(overflow)), budget, ), // The budget is of bytes: each of these characters takes two. refusal( "run: budget in UTF-8 bytes", "unsupported", - two_shares(&"\u{e9}".repeat(421)), + two_shares(&("\u{e9}".repeat(overflow / 2) + &"x".repeat(overflow % 2))), + budget, + ), + // A single directory also has a planned boot bind now; keep the former unbounded + // positive case as a checked refusal rather than dropping its input. + refusal( + "run: long command line of one directory share", + "unsupported", + run_dry(&[ + "--mount", + "/data,{root/share}", + "--cmdline", + &"x".repeat(1100), + ]), budget, ), refusal( @@ -1352,7 +1365,7 @@ fn run_refusals() -> Vec { "run: caller owner on Windows", "unsupported", run_dry(&["--mount", "/data,{root/share}", "--mount-owner", "caller"]), - &["--mount-owner caller", "Linux"], + &["caller owner", "Linux"], ) .on(Hosts::Windows), refusal( From 1e3cea27ed86989e2943393c2792aa37c673d186 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:16:15 -0800 Subject: [PATCH 02/27] docs: document CLI foreground child ownership Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/vm.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index cd93e7558..b67a2e2e8 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -409,6 +409,7 @@ impl Prepared { }) } + /// Keeps this CLI launch in its caller's session, process group, and console. #[cfg(feature = "cli")] pub(crate) fn for_foreground(mut self) -> Self { self.foreground = true; @@ -703,10 +704,12 @@ pub struct Launched { pub outputs: VmOutputs, /// The control credential, never written to disk. pub capability: Capability, + /// Retains only a foreground caller's child for status and control attachment. #[cfg(feature = "cli")] child: ForegroundChild, } +/// Reaps a foreground child on release without adding `Drop` to the public launch value. #[cfg(feature = "cli")] #[derive(Debug)] struct ForegroundChild(Option); @@ -722,6 +725,7 @@ impl Drop for ForegroundChild { #[cfg(feature = "cli")] impl Launched { + /// Polls the owned child independently of its optional outcome report. pub(crate) fn wait_status(&mut self, timeout: Duration) -> io::Result> { Ok(self .child_mut()? @@ -729,6 +733,7 @@ impl Launched { .map(|status| status as i32)) } + /// Gives the CLI connector the same owned child whose exit it observes. pub(crate) fn child_mut(&mut self) -> io::Result<&mut spawn::Launched> { self.child .0 From b51da00e4afb2843e23a3b97e8f1cbe0598ead00 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:52:51 -0800 Subject: [PATCH 03/27] cli: preserve unrecorded run artifacts and planned-share runtime coverage Do not require install provenance or hash an override for a cold run that neither captures nor reattaches. Restore the public artifact identity implementation and remove its unused private extension and fixtures. Exercise metadata-free dry runs and overrides. Update the real comma-path guest scenario for planned aggregate binds, including all-read-only root remounts while retaining host write-denial checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/artifacts/dir.rs | 46 +++++-------------- nvx-rs/src/host/artifacts/tests.rs | 25 ---------- nvx-rs/src/host/cli/run.rs | 40 +++++----------- nvx-rs/tests/cli/dry_run.rs | 4 ++ nvx-rs/tests/cli/harness.rs | 10 +--- nvx-rs/tests/cli/one_shot.rs | 5 +- .../filesystem-comma-paths.sh.in | 19 +++----- scripts/nvx_tools/microvm_tests.py | 8 ++-- scripts/test_microvm_tests.py | 6 ++- 9 files changed, 49 insertions(+), 114 deletions(-) diff --git a/nvx-rs/src/host/artifacts/dir.rs b/nvx-rs/src/host/artifacts/dir.rs index 08d80a541..dddab5c11 100644 --- a/nvx-rs/src/host/artifacts/dir.rs +++ b/nvx-rs/src/host/artifacts/dir.rs @@ -235,13 +235,8 @@ impl ArtifactDir { /// unreadable, or names no valid digest of an artifact. A file of metadata that is not a /// regular file, such as a FIFO, is refused without being waited on. pub fn runtime_identity(&self) -> Result { - self.runtime_identity_for(Guest::Alpine) - } - - /// Identifies the selected guest through its install metadata, without reading its image. - pub(crate) fn runtime_identity_for(&self, guest: Guest) -> Result { let [openvmm, kernel, initramfs] = match self.layout { - Layout::Release => self.listed_identities(guest)?, + Layout::Release => self.listed_identities()?, Layout::Repository => [ provenance_identity( &self.artifact(OPENVMM_PROVENANCE_NAME), @@ -253,33 +248,11 @@ impl ArtifactDir { "kernel", "kernel_sha256", )?, - if guest == Guest::Alpine { - provenance_identity( - &self.artifact(INITRAMFS_PROVENANCE_NAME), - "initramfs", - "initramfs_sha256", - )? - } else { - let path = self.package_manifest(guest); - let text = read_metadata( - &path, - "guest package manifest", - "build or install the selected guest", - )?; - let invalid = || { - Error::unavailable(format!( - "guest package manifest {} has no valid artifact SHA-256 digest", - path.display() - )) - }; - let manifest: serde_json::Value = serde_json::from_str(&text) - .map_err(|error| invalid().with_source(error))?; - manifest - .get("artifact_sha256") - .and_then(serde_json::Value::as_str) - .and_then(sha256_identity) - .ok_or_else(invalid)? - }, + provenance_identity( + &self.artifact(INITRAMFS_PROVENANCE_NAME), + "initramfs", + "initramfs_sha256", + )?, ], }; Ok(RuntimeIdentity::new(openvmm, kernel, initramfs)) @@ -288,7 +261,7 @@ impl ArtifactDir { /// Returns the identities of the OpenVMM executable, kernel, and Alpine initramfs of a /// release, as its `SHA256SUMS` lists their digests: one line for each file, its 64 /// lowercase hexadecimal digits, two spaces, and its path in the release. - fn listed_identities(&self, guest: Guest) -> Result<[ArtifactIdentity; 3]> { + fn listed_identities(&self) -> Result<[ArtifactIdentity; 3]> { let path = self.root.join(CHECKSUMS_NAME); let description = "release checksum list"; let text = read_metadata(&path, description, "the release is incomplete")?; @@ -322,7 +295,10 @@ impl ArtifactDir { Ok([ listed(format!("{RELEASE_BIN_DIR}/{}", openvmm_executable()))?, listed(format!("{RELEASE_GUEST_DIR}/{KERNEL_NAME}"))?, - listed(format!("{RELEASE_GUEST_DIR}/{}", guest.initramfs_name()))?, + listed(format!( + "{RELEASE_GUEST_DIR}/{}", + Guest::Alpine.initramfs_name() + ))?, ]) } } diff --git a/nvx-rs/src/host/artifacts/tests.rs b/nvx-rs/src/host/artifacts/tests.rs index 8d0b14be1..3e4de6351 100644 --- a/nvx-rs/src/host/artifacts/tests.rs +++ b/nvx-rs/src/host/artifacts/tests.rs @@ -574,31 +574,6 @@ fn a_checkout_identifies_its_runtime_by_the_build_provenance_of_its_artifacts() ); } -#[test] -fn selected_guests_take_their_own_initramfs_digest_from_install_metadata() { - let directory = tempfile::tempdir().unwrap(); - let dir = ArtifactDir::repository(directory.path()).unwrap(); - let digests = ["a", "b", "c"].map(|digit| digit.repeat(64)); - record_provenance(directory.path(), &digests); - for guest in [Guest::Ubuntu, Guest::AzureLinux] { - let selected = "d".repeat(64); - let manifest = dir.package_manifest(guest); - fs::write(&manifest, format!("{{\"artifact_sha256\":\"{selected}\"}}")).unwrap(); - let identity = dir.runtime_identity_for(guest).unwrap(); - assert_eq!( - identity.initramfs, - ArtifactIdentity::sha256(&selected).unwrap() - ); - assert_eq!(identity.openvmm, identity_of(&digests).openvmm); - fs::write(&manifest, "{\"artifact_sha256\":\"invalid\"}").unwrap(); - assert_eq!( - dir.runtime_identity_for(guest).unwrap_err().kind(), - ErrorKind::Unavailable - ); - } - assert_eq!(dir.runtime_identity().unwrap(), identity_of(&digests)); -} - #[test] fn a_release_identifies_its_runtime_by_its_checksum_list() { let directory = tempfile::tempdir().unwrap(); diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index 914d2e587..0dd64df23 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -52,8 +52,6 @@ use std::sync::mpsc; use std::thread; use std::time::{Duration, Instant}; -use sha2::{Digest, Sha256}; - use super::args::{Parser, Receiver, Spec, Syntax, choice, integer}; use super::artifacts::{Located, locate}; use super::cpu_profile::{self, HostCpu}; @@ -74,7 +72,7 @@ use crate::host::filesystem::{ self, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, }; use crate::host::platform::{self, Transport}; -use crate::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime}; +use crate::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; use crate::host::sandbox::mount::{ AGGREGATE_MOUNT_TARGET, MountAccess, MountKind, MountOwner, POLICY_KINDS, PolicyKind, aggregate_child_argument, aggregate_child_name, aggregate_command_line_fragment, @@ -898,40 +896,26 @@ fn run_cold( { validate_outcome_destination(report)?; } - let mut identity = dir - .runtime_identity_for(options.guest) - .map_err(error_with_cause)?; - if openvmm != dir.openvmm() { - let mut file = File::open(&openvmm).map_err(Exit::from_os_error)?; - let mut digest = Sha256::new(); - let mut buffer = [0; 8192]; - loop { - let count = file.read(&mut buffer).map_err(Exit::from_os_error)?; - if count == 0 { - break; - } - digest.update(&buffer[..count]); - } - identity.openvmm = ArtifactIdentity::sha256(&format!("{:x}", digest.finalize()))?; - } + let mut random = [0; 16]; + getrandom::fill(&mut random).map_err(|error| { + Exit::error( + ErrorCode::Io, + format!("cannot name the temporary VM: {error}"), + ) + })?; + let name: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); + // Cold CLI runs neither capture nor reattach; this per-invocation identity is not persisted. + let identity = ArtifactIdentity::new(format!("unrecorded-run:{name}"))?; let runtime = Runtime { openvmm, kernel, initramfs, - identity, + identity: RuntimeIdentity::new(identity.clone(), identity.clone(), identity), hypervisor: hypervisor(options.hypervisor).parse()?, cpu_profile: options.cpu_profile.clone(), windows_breakaway: false, options: LaunchOptions::default(), }; - let mut random = [0; 16]; - getrandom::fill(&mut random).map_err(|error| { - Exit::error( - ErrorCode::Io, - format!("cannot name the temporary VM: {error}"), - ) - })?; - let name: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); let path = std::env::temp_dir().join(format!("nvx-run-{name}")); if path.try_exists().map_err(Exit::from_os_error)? { return Err(Exit::error( diff --git a/nvx-rs/tests/cli/dry_run.rs b/nvx-rs/tests/cli/dry_run.rs index ec2c6984d..104482699 100644 --- a/nvx-rs/tests/cli/dry_run.rs +++ b/nvx-rs/tests/cli/dry_run.rs @@ -423,6 +423,10 @@ fn run_dry_runs() -> Vec { let fits = "x".repeat(crate::openvmm::two_share_command_line_room()); vec![ dry_run("run: Alpine default", run(&[]), cold_boot()), + dry_run("run: no install metadata", run(&[]), cold_boot()) + .without("build/openvmm.provenance.json") + .without("build/vmlinux.provenance.json") + .without("build/initramfs.provenance.json"), dry_run( "run: Ubuntu default", run(&["--guest", "ubuntu"]), diff --git a/nvx-rs/tests/cli/harness.rs b/nvx-rs/tests/cli/harness.rs index acdd23e19..1470a1cf5 100644 --- a/nvx-rs/tests/cli/harness.rs +++ b/nvx-rs/tests/cli/harness.rs @@ -90,17 +90,9 @@ pub(crate) const PROVENANCE: [(&str, &str, &str); 3] = [ /// The other files of a root, by their paths below it, and their contents: the guest kernel and /// Alpine initramfs of a checkout, the layers of the sandboxes, of which only the distro layer /// has a role, their scratch image, and the directories and the file that they share. -const FILES: [(&str, &str); 14] = [ +const FILES: [(&str, &str); 12] = [ ("build/vmlinux", "kernel\n"), ("build/initramfs.cpio.gz", "initramfs\n"), - ( - "build/initramfs-ubuntu.cpio.gz.packages.json", - "{\"artifact_sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"}\n", - ), - ( - "build/initramfs-azurelinux.cpio.gz.packages.json", - "{\"artifact_sha256\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\"}\n", - ), ("distro.erofs", "distro\n"), ("runtime.erofs", "runtime\n"), ("custom.erofs", "custom\n"), diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index b14be2d20..7723f5ac6 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -670,7 +670,10 @@ fn run_launches() -> Vec { 0, booted, ) - .with_variable("NVX_OPENVMM", native(OPENVMM)), + .with_variable("NVX_OPENVMM", native(OPENVMM)) + .without("build/openvmm.provenance.json") + .without("build/vmlinux.provenance.json") + .without("build/initramfs.provenance.json"), ] } diff --git a/scripts/nvx_tools/microvm_test_scripts/filesystem-comma-paths.sh.in b/scripts/nvx_tools/microvm_test_scripts/filesystem-comma-paths.sh.in index 8468e6a6a..96aee1e1c 100644 --- a/scripts/nvx_tools/microvm_test_scripts/filesystem-comma-paths.sh.in +++ b/scripts/nvx_tools/microvm_test_scripts/filesystem-comma-paths.sh.in @@ -16,14 +16,10 @@ expect_read_only() { esac } -# A lone share is the root of the virtio-fs device, and several are the -# children of an aggregate, which the guest binds at their targets. -aggregate=@AGGREGATE@ -if [ "$aggregate" = 1 ]; then - grep -q 'virtfs_dir=/run/nvx/shares virtfs_tag=microvm virtfs_mode=rw virtfs_aggregate=1' /proc/cmdline || fail 10 -elif grep -q 'virtfs_aggregate=' /proc/cmdline; then - fail 11 -fi +# The checked export plan always uses an aggregate, even for one bind. +aggregate=/run/nvx/hostfs/root +export_mode=@EXPORT_MODE@ +grep -q "virtfs_dir=$aggregate virtfs_tag=microvm virtfs_mode=$export_mode virtfs_aggregate=1" /proc/cmdline || fail 10 # Each TARGET:MODE:SEED names a share's guest target, its mode, and the # contents of its seed file. @@ -51,11 +47,10 @@ for share in @SHARES@; do expect_read_only "$error" $((code + 6)) # OpenVMM enforces the mode on the host, so the share refuses writes # even after guest root makes its mount read-write. - if [ "$aggregate" = 1 ]; then - mount -o remount,bind,rw "$target" || fail $((code + 7)) - else - mount -o remount,rw "$target" || fail $((code + 7)) + if [ "$export_mode" = ro ]; then + mount -o remount,rw "$aggregate" || fail $((code + 7)) fi + mount -o remount,bind,rw "$target" || fail $((code + 7)) grep -q "^microvm $target virtiofs rw," /proc/mounts || fail $((code + 8)) if error=$(touch "$target/from-guest" 2>&1); then fail $((code + 9)) diff --git a/scripts/nvx_tools/microvm_tests.py b/scripts/nvx_tools/microvm_tests.py index 41fec1495..c4ace8282 100644 --- a/scripts/nvx_tools/microvm_tests.py +++ b/scripts/nvx_tools/microvm_tests.py @@ -9985,8 +9985,8 @@ def run_filesystem_comma_paths( ) -> None: """Share host directories whose paths contain commas through `nvx-rs run`: a read-write `alpha,beta` and a read-only `2026, final`, each with a - denied subdirectory, first each alone, as OpenVMM's `--mount`, and then - both, as the children of an aggregate. The guest must find each exact + denied subdirectory, first each alone and then both through the checked + export plan's aggregate and guest binds. The guest must find each exact directory, rather than the one beside it that the text before the last comma of its path names, with its mode, which OpenVMM enforces, and without its denied subdirectory.""" @@ -10031,7 +10031,9 @@ def run_filesystem_comma_paths( command, _render_script( "filesystem-comma-paths.sh.in", - AGGREGATE="1" if len(attached) > 1 else "0", + EXPORT_MODE=( + "rw" if any(shares[share][1] == "rw" for share in attached) else "ro" + ), SHARES=" ".join(":".join(shares[share]) for share in attached), ), FILESYSTEM_COMMA_PATHS_MARKER, diff --git a/scripts/test_microvm_tests.py b/scripts/test_microvm_tests.py index aeeb7f3a2..1ab994326 100644 --- a/scripts/test_microvm_tests.py +++ b/scripts/test_microvm_tests.py @@ -3909,7 +3909,11 @@ def test_scenario_shares_each_directory_alone_and_both_together(self): self.assertEqual((host.parent.name, host.name), (parent, name)) self.assertEqual(Path(deny), host / "secret") self.assertIn(f"{target}:{mode}:{seed}", script) - self.assertIn(f"\naggregate={int(len(shares) > 1)}\n", script) + export_mode = "rw" if any(share[3] == "rw" for share in shares) else "ro" + self.assertIn("\naggregate=/run/nvx/hostfs/root\n", script) + self.assertIn(f"\nexport_mode={export_mode}\n", script) + self.assertIn('mount -o remount,rw "$aggregate"', script) + self.assertIn('mount -o remount,bind,rw "$target"', script) self.assertNotIn("@", script) def test_scenario_rejects_a_write_to_the_read_only_directory(self): From 1bc21ea043e91bab98c0fb8a2810c08e09e69251 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:58:57 -0800 Subject: [PATCH 04/27] style: format planned-share scenario export mode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- scripts/nvx_tools/microvm_tests.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/nvx_tools/microvm_tests.py b/scripts/nvx_tools/microvm_tests.py index c4ace8282..e4dc5e1e8 100644 --- a/scripts/nvx_tools/microvm_tests.py +++ b/scripts/nvx_tools/microvm_tests.py @@ -10032,7 +10032,9 @@ def run_filesystem_comma_paths( _render_script( "filesystem-comma-paths.sh.in", EXPORT_MODE=( - "rw" if any(shares[share][1] == "rw" for share in attached) else "ro" + "rw" + if any(shares[share][1] == "rw" for share in attached) + else "ro" ), SHARES=" ".join(":".join(shares[share]) for share in attached), ), From 2822089281be45151d7d4e18f5a507e24fdead38 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 05:32:29 -0800 Subject: [PATCH 05/27] test: align download cleanup with restored cold-run fixtures Remove the two no-longer-installed guest metadata entries from the fixture cleanup list, restoring this file exactly to the qualified base. Keep all six download test bodies and assertions unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/tests/cli/download.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nvx-rs/tests/cli/download.rs b/nvx-rs/tests/cli/download.rs index 9a041cedb..f9d758cea 100644 --- a/nvx-rs/tests/cli/download.rs +++ b/nvx-rs/tests/cli/download.rs @@ -10,15 +10,13 @@ use std::time::{Duration, Instant}; use crate::harness::{LAYER, NVX_RS, OPENVMM, Root, SCRATCH, UUID}; /// The files of a test root that a release installs. -const INSTALLED: [&str; 8] = [ +const INSTALLED: [&str; 6] = [ OPENVMM, "build/vmlinux", "build/initramfs.cpio.gz", "build/openvmm.provenance.json", "build/vmlinux.provenance.json", "build/initramfs.provenance.json", - "build/initramfs-ubuntu.cpio.gz.packages.json", - "build/initramfs-azurelinux.cpio.gz.packages.json", ]; /// Returns a root that is a checkout of NVX, with its `SOURCE-MANIFEST.json` and a `.git` that is From 4c3653e55626a39e0dfb465027df12bfacda3fda Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 08:51:05 -0800 Subject: [PATCH 06/27] cli: address foreground connection, output, and report findings Recheck the owned child before surfacing endpoint authentication races, use the standard Unicode-aware stdout path, avoid idle sleeps while streams advance, and publish requested native reports after confirmed exit even on foreground errors. Add focused peer, private Windows console, streaming, output-error and interruption regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/cli/run.rs | 91 +++++++++-------- nvx-rs/src/host/cli/run/tests.rs | 151 +++++++++++++++++++++++++++++ nvx-rs/src/testing/fake_openvmm.rs | 35 +++++++ nvx-rs/tests/cli/one_shot.rs | 129 ++++++++++++++++++++++++ 4 files changed, 368 insertions(+), 38 deletions(-) diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index 0dd64df23..8efd7c6e4 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -44,8 +44,6 @@ mod guest_tokens; use std::fs::{self, File}; -#[cfg(windows)] -use std::io::Write; use std::io::{self, IsTerminal, Read}; use std::path::Path; use std::sync::mpsc; @@ -642,32 +640,55 @@ fn connect_running( launched: &mut vm::Launched, endpoint: &str, ) -> Result>> { + let pid = launched.process.reference().pid; + connect_before_exit( + endpoint, + || platform::connect_endpoint(endpoint, pid, Duration::from_millis(250)), + |timeout| launched.wait_status(timeout), + ) +} + +/// Connects to the verified endpoint, or yields to the owned child's confirmed exit. +fn connect_before_exit( + endpoint: &str, + mut connect: impl FnMut() -> io::Result, + mut wait_status: impl FnMut(Duration) -> io::Result>, +) -> Result> { let deadline = Instant::now() + Duration::from_secs(60); loop { check_interrupt()?; - match platform::connect_endpoint( - endpoint, - launched.process.reference().pid, - Duration::from_millis(250), - ) { - Ok(transport) => return Ok(Some(transport)), - Err(error) - if matches!( - error.kind(), - io::ErrorKind::NotFound - | io::ErrorKind::ConnectionRefused - | io::ErrorKind::TimedOut - | io::ErrorKind::WouldBlock - ) => {} - Err(error) => return Err(Exit::from_os_error(error)), + if wait_status(Duration::ZERO) + .map_err(Exit::from_os_error)? + .is_some() + { + return Ok(None); } - if launched - .wait_status(Duration::ZERO) + let error = match connect() { + Ok(transport) => return Ok(Some(transport)), + Err(error) => error, + }; + let retry = matches!( + error.kind(), + io::ErrorKind::NotFound + | io::ErrorKind::ConnectionRefused + | io::ErrorKind::TimedOut + | io::ErrorKind::WouldBlock + ); + // Peer authentication may observe exit just before the child status becomes available. + let exit_wait = if retry { + Duration::ZERO + } else { + Duration::from_millis(250).min(deadline.saturating_duration_since(Instant::now())) + }; + if wait_status(exit_wait) .map_err(Exit::from_os_error)? .is_some() { return Ok(None); } + if !retry { + return Err(Exit::from_os_error(error)); + } if Instant::now() >= deadline { return Err(Exit::error( ErrorCode::TimedOut, @@ -765,18 +786,6 @@ fn console_input(console: Box) -> Result<(Box, Inp } fn write_console(data: &[u8]) -> io::Result<()> { - #[cfg(windows)] - { - use std::os::windows::io::AsHandle; - - if platform::interrupted() { - return Err(io::Error::new(io::ErrorKind::Interrupted, "interrupted")); - } - let mut stdout = io::stdout().lock(); - stdout.flush()?; - File::from(stdout.as_handle().try_clone_to_owned()?).write_all(data) - } - #[cfg(not(windows))] output::write_stdout(data) } @@ -819,6 +828,7 @@ fn foreground( loop { check_interrupt()?; let count = log.read(&mut buffer).map_err(Exit::from_os_error)?; + let mut progressed = count != 0; output::write_stderr(&buffer[..count]).map_err(Exit::from_os_error)?; let status = launched .wait_status(Duration::ZERO) @@ -832,7 +842,10 @@ fn foreground( if let Some(transport) = &mut console { match transport.read(&mut buffer, Some(Duration::from_millis(10))) { Ok(0) => console = None, - Ok(count) => write_console(&buffer[..count]).map_err(Exit::from_os_error)?, + Ok(count) => { + write_console(&buffer[..count]).map_err(Exit::from_os_error)?; + progressed = true; + } Err(error) if matches!( error.kind(), @@ -877,7 +890,9 @@ fn foreground( if attach_closed.is_some_and(|deadline| Instant::now() >= deadline) { return Err(Exit::EndpointClosed); } - thread::sleep(Duration::from_millis(10)); + if !progressed { + thread::sleep(Duration::from_millis(10)); + } } } @@ -990,6 +1005,11 @@ fn run_cold( if let Some(error) = graceful_error { output::print_stderr(&format!("VM graceful stop failed: {error:?}")); } + if let Some(report) = &options.outcome_report + && let Err(error) = copy_openvmm_outcome(&launched.outputs.outcome, report) + { + error.report(); + } check_interrupt()?; let status = status?; if let Err(error) = stopped.report { @@ -1000,11 +1020,6 @@ fn run_cold( .unwrap_or("cannot read VM outcome") )); } - if let Some(report) = &options.outcome_report - && let Err(error) = copy_openvmm_outcome(&launched.outputs.outcome, report) - { - error.report(); - } if status != 0 && let Some(guidance) = cpu_profile::guidance( options.cpu_profile.as_deref(), diff --git a/nvx-rs/src/host/cli/run/tests.rs b/nvx-rs/src/host/cli/run/tests.rs index 1578d6a74..7892e519a 100644 --- a/nvx-rs/src/host/cli/run/tests.rs +++ b/nvx-rs/src/host/cli/run/tests.rs @@ -87,6 +87,157 @@ fn cold_share_command_lines_fit_the_kernel_budget_to_the_byte() { } } +#[test] +fn a_console_peer_exit_waits_for_the_owned_child_status() { + let result = connect_before_exit::<()>( + "unused", + || { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "peer exited", + )) + }, + |timeout| Ok((!timeout.is_zero()).then_some(7)), + ); + assert!(matches!(result, Ok(None)), "{result:?}"); +} + +#[test] +fn an_unverified_console_is_refused_while_the_owned_child_runs() { + let result = connect_before_exit::<()>( + "unused", + || { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "wrong peer", + )) + }, + |_| Ok(None), + ); + assert!( + matches!(&result, Err(Exit::Error { code: ErrorCode::Io, message }) if message == "wrong peer"), + "{result:?}" + ); +} + +#[cfg(windows)] +#[test] +fn windows_console_output_handles_non_utf8_code_pages() { + use std::os::windows::process::CommandExt; + use windows_sys::Win32::System::Threading::CREATE_NEW_CONSOLE; + + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "host::cli::run::tests::windows_console_output_helper", + "--ignored", + "--nocapture", + "--test-threads=1", + ]) + .env("NVX_TEST_PRIVATE_CONSOLE", "cp437") + .creation_flags(CREATE_NEW_CONSOLE) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); +} + +/// Runs only as the parent's isolated-console helper, never in the shared test process. +#[cfg(windows)] +#[test] +#[ignore] +fn windows_console_output_helper() { + use std::io::Write; + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows_sys::Win32::Foundation::{ + GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, + }; + use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE}; + use windows_sys::Win32::System::Console::{ + CONSOLE_TEXTMODE_BUFFER, COORD, CreateConsoleScreenBuffer, GetConsoleOutputCP, + GetConsoleProcessList, GetStdHandle, ReadConsoleOutputCharacterW, STD_OUTPUT_HANDLE, + SetConsoleOutputCP, SetStdHandle, + }; + + /// Restores only the helper's private console and its original redirected stdout. + struct RestoreConsole { + /// The original borrowed standard-output handle. + stdout: HANDLE, + /// The private console's original output code page. + code_page: u32, + } + impl Drop for RestoreConsole { + fn drop(&mut self) { + // SAFETY: both settings belong to this isolated helper, and stdout stays open. + unsafe { + assert_ne!(SetStdHandle(STD_OUTPUT_HANDLE, self.stdout), 0); + assert_ne!(SetConsoleOutputCP(self.code_page), 0); + } + } + } + + assert_eq!( + std::env::var("NVX_TEST_PRIVATE_CONSOLE").as_deref(), + Ok("cp437") + ); + let mut members = [0; 4]; + // SAFETY: the array is writable, and only this helper may belong to its new console. + let count = unsafe { GetConsoleProcessList(members.as_mut_ptr(), members.len() as u32) }; + assert_eq!(count, 1); + assert_eq!(members[0], std::process::id()); + // SAFETY: a private, inactive screen buffer has no inherited security attributes. + let raw = unsafe { + CreateConsoleScreenBuffer( + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + std::ptr::null(), + CONSOLE_TEXTMODE_BUFFER, + std::ptr::null_mut(), + ) + }; + assert_ne!(raw, INVALID_HANDLE_VALUE); + // SAFETY: CreateConsoleScreenBuffer returned this newly owned handle. + let buffer = unsafe { OwnedHandle::from_raw_handle(raw.cast()) }; + io::stdout().flush().unwrap(); + // SAFETY: these getters return the helper's own current console settings. + let _restore = RestoreConsole { + stdout: unsafe { GetStdHandle(STD_OUTPUT_HANDLE) }, + code_page: unsafe { GetConsoleOutputCP() }, + }; + // SAFETY: only this helper is attached, and the owned buffer remains open until restoration. + unsafe { + assert_ne!(SetConsoleOutputCP(437), 0); + assert_ne!( + SetStdHandle(STD_OUTPUT_HANDLE, buffer.as_raw_handle().cast()), + 0 + ); + } + for chunk in [ + b"caf\xc3".as_slice(), + b"\xa9 \xe2".as_slice(), + b"\x82\xac".as_slice(), + ] { + write_console(chunk).unwrap(); + } + let expected: Vec = "caf\u{e9} \u{20ac}".encode_utf16().collect(); + let mut actual = vec![0; expected.len()]; + let mut read = 0; + // SAFETY: the buffer is owned, and actual has room for the requested UTF-16 characters. + assert_ne!( + unsafe { + ReadConsoleOutputCharacterW( + buffer.as_raw_handle().cast(), + actual.as_mut_ptr(), + actual.len() as u32, + COORD { X: 0, Y: 0 }, + &mut read, + ) + }, + 0 + ); + assert_eq!(read as usize, expected.len()); + assert_eq!(actual, expected); +} + proptest! { /// A target that `run` accepts stays inside its share's `nvx_share=` token, which the kernel /// reads as one parameter. Linux splits a parameter at a byte that it reads as whitespace, diff --git a/nvx-rs/src/testing/fake_openvmm.rs b/nvx-rs/src/testing/fake_openvmm.rs index 9ef3d8382..90ac917a3 100644 --- a/nvx-rs/src/testing/fake_openvmm.rs +++ b/nvx-rs/src/testing/fake_openvmm.rs @@ -50,6 +50,8 @@ //! `fake_boot_delay_ms=MS` delays the control endpoint, `fake_crash_after_ms=MS` makes the VM //! die, `fake_ignore_stop=1` ignores stop requests, `fake_exit_delay_ms=MS` delays the exit after //! an acknowledged stop, `fake_ignore_cancel=1` ignores cancellation, +//! `fake_console_bytes=N` emits up to 16 MiB of `x` bytes on a run's console, and +//! `fake_report_before_console=1` writes a run's report before console output or its boot delay. //! `fake_guest_features=MASK` advertises the decimal feature mask `MASK` instead of the current //! guest's, `fake_legacy_guest=1` refuses the features request like a guest agent that //! predates it, and `fake_refuse_maps=1` fails every `MAPS` request like a guest that cannot @@ -383,6 +385,10 @@ struct Options { boot_delay: Duration, /// Whether the boot console emits the binary-output regression fixture. binary_console: bool, + /// Number of bytes in the sustained-console regression fixture. + console_bytes: usize, + /// Whether a run publishes its regression report before console output or boot delay. + report_before_console: bool, /// Artificial delay before the fake OpenVMM crashes, when requested. crash_after: Option, /// Whether stop requests are deliberately left unanswered. @@ -518,6 +524,17 @@ pub fn main() -> ExitCode { return ExitCode::from(1); } }; + if options.report_before_console + && matches!(options.lifecycle, Lifecycle::OneShot | Lifecycle::Run) + { + let status = options.exit_status.unwrap_or(0); + write_report( + &options, + "run", + if status == 0 { "success" } else { "guest-exit" }, + status, + ); + } let ended = match options.lifecycle { Lifecycle::OneShot => Some(run_one_shot(&options, &mut console)), Lifecycle::Run => Some(run_microvm(&options, &mut console)), @@ -825,6 +842,13 @@ fn run_microvm(options: &Options, console: &mut dyn Write) -> i32 { }; let written = if options.binary_console { console.write_all(&[b'A', 0xff, 0x80, b'\n']) + } else if options.console_bytes != 0 { + let buffer = [b'x'; 8192]; + (0..options.console_bytes) + .step_by(buffer.len()) + .try_for_each(|offset| { + console.write_all(&buffer[..buffer.len().min(options.console_bytes - offset)]) + }) } else { writeln!(console, "{message}") }; @@ -1725,6 +1749,17 @@ fn parse(program: &str, arguments: &[String]) -> Result { exit_status: knob("fake_exit_status").and_then(|value| value.parse().ok()), boot_delay: millis("fake_boot_delay_ms").unwrap_or_default(), binary_console: knob("fake_binary_console") == Some("1"), + console_bytes: match knob("fake_console_bytes") { + None => 0, + Some(value) => value + .parse::() + .ok() + .filter(|&count| count <= 16 * 1024 * 1024) + .ok_or_else(|| { + "fake_console_bytes must be an integer from 0 through 16777216".to_owned() + })?, + }, + report_before_console: knob("fake_report_before_console") == Some("1"), crash_after: millis("fake_crash_after_ms"), ignore_stop: knob("fake_ignore_stop") == Some("1"), exit_delay: millis("fake_exit_delay_ms").unwrap_or_default(), diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index 7723f5ac6..3de2633d0 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -1084,6 +1084,135 @@ fn cold_console_output_preserves_non_utf8_bytes() { ); } +#[test] +fn a_ready_console_stream_is_not_throttled_by_idle_sleeps() { + use std::time::{Duration, Instant}; + + let root = Root::new(); + let started = Instant::now(); + let outcome = root.nvx_rs(&[ + "run", + "--cmdline", + "fake_console_bytes=8388608 fake_exit_status=7", + ]); + assert_eq!(outcome.status, Some(7), "the bulk-console run failed"); + let output = outcome.command().1; + assert_eq!(output.len(), 8 * 1024 * 1024); + assert!(output.iter().all(|&byte| byte == b'x')); + assert!( + started.elapsed() < Duration::from_secs(8), + "the ready console stream was throttled: {:?}", + started.elapsed() + ); +} + +#[test] +fn a_foreground_output_error_keeps_the_requested_native_report() { + use std::io::{BufRead, BufReader}; + use std::process::Stdio; + + let root = Root::new(); + let mut process = root + .command(&[ + "run", + "--cmdline", + "fake_boot_delay_ms=100 fake_report_before_console=1", + ]) + .arg("--outcome-report") + .arg(root.below("reports/outcome.json")) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdout = BufReader::new(process.stdout.take().unwrap()); + let mut command = String::new(); + stdout.read_line(&mut command).unwrap(); + assert!(command.starts_with(">> "), "{command}"); + drop(stdout); + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("error[io]:"), + "{output:?}" + ); + assert_outcome_report( + &root.json("reports/outcome.json"), + crate::harness::HYPERVISOR, + ("run", "success", 0), + NO_NETWORK, + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn an_interrupted_cold_run_keeps_its_already_written_native_report() { + use std::process::Stdio; + use std::time::{Duration, Instant}; + + use nix::sys::signal::{Signal, kill}; + use nix::unistd::Pid; + + let root = Root::new(); + let temporary = tempfile::Builder::new().prefix("nvx-c-").tempdir().unwrap(); + let mut process = root + .command(&[ + "run", + "--cmdline", + "fake_boot_delay_ms=60000 fake_report_before_console=1", + ]) + .arg("--outcome-report") + .arg(root.below("reports/outcome.json")) + .env("TMPDIR", temporary.path()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let original = loop { + let report = std::fs::read_dir(temporary.path()) + .unwrap() + .map(|entry| entry.unwrap().path().join("outcome.json")) + .find(|path| path.is_file()); + if let Some(report) = report { + let bytes = std::fs::read(report).unwrap(); + if bytes.ends_with(b"\n") && serde_json::from_slice::(&bytes).is_ok() + { + break bytes; + } + } + if process.try_wait().unwrap().is_some() { + panic!( + "the cold run exited before writing its report: {:?}", + process.wait_with_output() + ); + } + if Instant::now() >= deadline { + kill( + Pid::from_raw(process.id().try_into().unwrap()), + Signal::SIGINT, + ) + .unwrap(); + let output = process.wait_with_output().unwrap(); + panic!("the fake did not write its early report: {output:?}"); + } + std::thread::sleep(Duration::from_millis(5)); + }; + kill( + Pid::from_raw(process.id().try_into().unwrap()), + Signal::SIGINT, + ) + .unwrap(); + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(130), "{output:?}"); + assert_eq!( + std::fs::read(root.below("reports/outcome.json")).unwrap(), + original + ); + assert_eq!(std::fs::read_dir(temporary.path()).unwrap().count(), 0); +} + #[cfg(target_os = "linux")] #[test] fn cold_runs_keep_the_native_child_in_the_callers_session_and_process_group() { From e2f037d60b84332243a17d5bb4d8d96a588e3216 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 09:47:16 -0800 Subject: [PATCH 07/27] cli: stream lossy UTF-8 only to Windows consoles Render invalid guest UTF-8 as replacement characters only when stdout is an actual Windows console. Carry at most three split-sequence bytes between reads and finish them only at console EOF or final drain. Keep redirected pipes and files byte-exact and preserve real I/O failures. Extend the isolated CP437 console regression with invalid sequences, split-invalid input, incomplete endings, and raw pipe/file controls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/cli/run.rs | 86 ++++++++++++++++++++++++++++---- nvx-rs/src/host/cli/run/tests.rs | 51 +++++++++++++++++-- 2 files changed, 122 insertions(+), 15 deletions(-) diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index 8efd7c6e4..30107442a 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -785,8 +785,62 @@ fn console_input(console: Box) -> Result<(Box, Inp } } -fn write_console(data: &[u8]) -> io::Result<()> { - output::write_stdout(data) +struct ConsoleOutput { + #[cfg(windows)] + console: bool, + #[cfg(windows)] + pending: [u8; 3], + #[cfg(windows)] + pending_len: usize, +} + +impl ConsoleOutput { + fn new() -> Self { + Self { + #[cfg(windows)] + console: io::stdout().is_terminal(), + #[cfg(windows)] + pending: [0; 3], + #[cfg(windows)] + pending_len: 0, + } + } + + fn write_all(&mut self, data: &[u8]) -> io::Result<()> { + #[cfg(windows)] + if self.console { + let combined; + let bytes = if self.pending_len == 0 { + data + } else { + combined = [&self.pending[..self.pending_len], data].concat(); + combined.as_slice() + }; + // Only a final incomplete sequence crosses reads; other invalid bytes render now. + let incomplete = + bytes.utf8_chunks().last().map_or(0, |chunk| { + match std::str::from_utf8(chunk.invalid()) { + Err(error) if error.error_len().is_none() => chunk.invalid().len(), + _ => 0, + } + }); + let ready = bytes.len() - incomplete; + output::write_stdout(String::from_utf8_lossy(&bytes[..ready]).as_bytes())?; + self.pending[..incomplete].copy_from_slice(&bytes[ready..]); + self.pending_len = incomplete; + return Ok(()); + } + output::write_stdout(data) + } + + fn finish(&mut self) -> io::Result<()> { + #[cfg(windows)] + if self.pending_len != 0 { + output::write_stdout("\u{fffd}".as_bytes())?; + self.pending_len = 0; + } + Ok(()) + } } fn foreground( @@ -824,6 +878,7 @@ fn foreground( Err(error) => return Err(error), } } + let mut console_output = ConsoleOutput::new(); let mut buffer = [0; 8192]; loop { check_interrupt()?; @@ -841,18 +896,26 @@ fn foreground( } if let Some(transport) = &mut console { match transport.read(&mut buffer, Some(Duration::from_millis(10))) { - Ok(0) => console = None, + Ok(0) => { + console = None; + console_output.finish().map_err(Exit::from_os_error)?; + } Ok(count) => { - write_console(&buffer[..count]).map_err(Exit::from_os_error)?; + console_output + .write_all(&buffer[..count]) + .map_err(Exit::from_os_error)?; progressed = true; } + Err(error) if error.kind() == io::ErrorKind::TimedOut => {} Err(error) if matches!( error.kind(), - io::ErrorKind::TimedOut - | io::ErrorKind::BrokenPipe - | io::ErrorKind::ConnectionReset - ) => {} + io::ErrorKind::BrokenPipe | io::ErrorKind::ConnectionReset + ) => + { + console = None; + console_output.finish().map_err(Exit::from_os_error)?; + } Err(error) => return Err(Exit::from_os_error(error)), } } @@ -868,9 +931,9 @@ fn foreground( loop { match transport.read(&mut buffer, Some(Duration::from_millis(10))) { Ok(0) => break, - Ok(count) => { - write_console(&buffer[..count]).map_err(Exit::from_os_error)? - } + Ok(count) => console_output + .write_all(&buffer[..count]) + .map_err(Exit::from_os_error)?, Err(error) if matches!( error.kind(), @@ -885,6 +948,7 @@ fn foreground( } } } + console_output.finish().map_err(Exit::from_os_error)?; return Ok(status); } if attach_closed.is_some_and(|deadline| Instant::now() >= deadline) { diff --git a/nvx-rs/src/host/cli/run/tests.rs b/nvx-rs/src/host/cli/run/tests.rs index 7892e519a..763d297bb 100644 --- a/nvx-rs/src/host/cli/run/tests.rs +++ b/nvx-rs/src/host/cli/run/tests.rs @@ -120,6 +120,9 @@ fn an_unverified_console_is_refused_while_the_owned_child_runs() { ); } +#[cfg(windows)] +const REDIRECTED_CONSOLE_BYTES: &[u8] = b"nvx-raw:\xff\x80\xc3"; + #[cfg(windows)] #[test] fn windows_console_output_handles_non_utf8_code_pages() { @@ -139,6 +142,13 @@ fn windows_console_output_handles_non_utf8_code_pages() { .output() .unwrap(); assert!(output.status.success(), "{output:?}"); + assert!( + output + .stdout + .windows(REDIRECTED_CONSOLE_BYTES.len()) + .any(|bytes| bytes == REDIRECTED_CONSOLE_BYTES), + "{output:?}" + ); } /// Runs only as the parent's isolated-console helper, never in the shared test process. @@ -146,7 +156,7 @@ fn windows_console_output_handles_non_utf8_code_pages() { #[test] #[ignore] fn windows_console_output_helper() { - use std::io::Write; + use std::io::{Read, Seek, Write}; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use windows_sys::Win32::Foundation::{ GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, @@ -197,12 +207,32 @@ fn windows_console_output_helper() { assert_ne!(raw, INVALID_HANDLE_VALUE); // SAFETY: CreateConsoleScreenBuffer returned this newly owned handle. let buffer = unsafe { OwnedHandle::from_raw_handle(raw.cast()) }; + let mut file = tempfile::tempfile().unwrap(); io::stdout().flush().unwrap(); // SAFETY: these getters return the helper's own current console settings. let _restore = RestoreConsole { stdout: unsafe { GetStdHandle(STD_OUTPUT_HANDLE) }, code_page: unsafe { GetConsoleOutputCP() }, }; + assert!(!io::stdout().is_terminal()); + let mut redirected = ConsoleOutput::new(); + redirected.write_all(REDIRECTED_CONSOLE_BYTES).unwrap(); + redirected.finish().unwrap(); + // SAFETY: the file is owned and remains open until stdout has been restored. + unsafe { + assert_ne!( + SetStdHandle(STD_OUTPUT_HANDLE, file.as_raw_handle().cast()), + 0 + ); + } + assert!(!io::stdout().is_terminal()); + let mut redirected = ConsoleOutput::new(); + redirected.write_all(REDIRECTED_CONSOLE_BYTES).unwrap(); + redirected.finish().unwrap(); + file.rewind().unwrap(); + let mut redirected_bytes = Vec::new(); + file.read_to_end(&mut redirected_bytes).unwrap(); + assert_eq!(redirected_bytes, REDIRECTED_CONSOLE_BYTES); // SAFETY: only this helper is attached, and the owned buffer remains open until restoration. unsafe { assert_ne!(SetConsoleOutputCP(437), 0); @@ -211,14 +241,27 @@ fn windows_console_output_helper() { 0 ); } + let mut console = ConsoleOutput::new(); for chunk in [ b"caf\xc3".as_slice(), b"\xa9 \xe2".as_slice(), - b"\x82\xac".as_slice(), + b"\x82\xac|A\xff\x80|".as_slice(), + b"\xc3".as_slice(), + b"A".as_slice(), ] { - write_console(chunk).unwrap(); + console.write_all(chunk).unwrap(); + } + console.finish().unwrap(); + for incomplete in [b"\xc3".as_slice(), b"\xe2\x82", b"\xf0\x9f\x92"] { + let mut console = ConsoleOutput::new(); + console.write_all(b"|").unwrap(); + console.write_all(incomplete).unwrap(); + console.finish().unwrap(); } - let expected: Vec = "caf\u{e9} \u{20ac}".encode_utf16().collect(); + let expected: Vec = + "caf\u{e9} \u{20ac}|A\u{fffd}\u{fffd}|\u{fffd}A|\u{fffd}|\u{fffd}|\u{fffd}" + .encode_utf16() + .collect(); let mut actual = vec![0; expected.len()]; let mut read = 0; // SAFETY: the buffer is owned, and actual has room for the requested UTF-16 characters. From 1578158fba67d7f8c0c1fd1b37db0766aef41cc3 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 10:09:59 -0800 Subject: [PATCH 08/27] test: scope unsafe console calls to the isolated Windows helper Allow only the isolated Windows console test helper to exercise documented Win32 calls, matching existing Windows endpoint-test opt-outs. Keep the host-wide unsafe-code denial and production code unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/cli/run/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/nvx-rs/src/host/cli/run/tests.rs b/nvx-rs/src/host/cli/run/tests.rs index 763d297bb..ede248caf 100644 --- a/nvx-rs/src/host/cli/run/tests.rs +++ b/nvx-rs/src/host/cli/run/tests.rs @@ -155,6 +155,7 @@ fn windows_console_output_handles_non_utf8_code_pages() { #[cfg(windows)] #[test] #[ignore] +#[allow(unsafe_code)] fn windows_console_output_helper() { use std::io::{Read, Seek, Write}; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; From 6f010ee26daa60bc6be403cd021d5a11cbe60b03 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 10:18:45 -0800 Subject: [PATCH 09/27] test: initialize boot capacity in the integrated launch fixture Initialize the cold-boot memory-capacity option in the newly integrated CLI journal fixture. None preserves its prior boot-memory behavior and all existing assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/cli/store/tests/launch.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/nvx-rs/src/host/cli/store/tests/launch.rs b/nvx-rs/src/host/cli/store/tests/launch.rs index 8712ea4ff..80b4f4ca2 100644 --- a/nvx-rs/src/host/cli/store/tests/launch.rs +++ b/nvx-rs/src/host/cli/store/tests/launch.rs @@ -50,6 +50,7 @@ pub(super) fn prepared( console, boot: Some(Boot { command_line: "nvx_entrypoint=/bin/sh".to_owned(), + memory_capacity_mib: None, capture: None, }), }; From 9d66d7ec8aa555fa9476c6bd7a2a2e99ed25c8ca Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 12:48:54 -0800 Subject: [PATCH 10/27] fix: preserve cold-run diagnostics and confirmed cleanup Defer filesystem planning until artifacts are acquired, forward boot streams during managed attachment, decode only actual Windows console logs, and retain temporary evidence only when child exit is unconfirmed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35f2ad55-f31c-4ab3-9c78-f119b029a3c0 --- nvx-rs/src/host/cli/run.rs | 266 +++++++++++++++------- nvx-rs/src/host/cli/run/tests.rs | 164 ++++++++++++- nvx-rs/src/host/platform/windows/spawn.rs | 8 +- nvx-rs/src/host/vm.rs | 47 +++- nvx-rs/src/host/vm/tests.rs | 31 +++ nvx-rs/src/testing/fake_openvmm.rs | 21 +- nvx-rs/tests/cli/download.rs | 32 +++ nvx-rs/tests/cli/one_shot.rs | 227 ++++++++++++++++++ 8 files changed, 704 insertions(+), 92 deletions(-) diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index 30107442a..e68580fb3 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -67,7 +67,7 @@ use crate::activation::{MachineIdMode, View}; use crate::host::artifacts::{ArtifactDir, Guest, require_artifact}; use crate::host::config::{Boot, ConsoleAttachment, NetworkConfig, RootConfig, VmConfig}; use crate::host::filesystem::{ - self, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, + self, ExportPlan, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, }; use crate::host::platform::{self, Transport}; use crate::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; @@ -432,7 +432,7 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { terminal_console.as_ref().is_some_and(Result::is_err) || extend_shares_and_network(&mut Vec::new(), &options, &rules).is_err() } else { - cold_config(&options, &rules).is_err() + cold_preflight(&options, &rules).is_err() }; let located = locate(root)?; // A restore boots no kernel or initramfs: the snapshot holds the guest. @@ -488,7 +488,34 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { Err(Exit::Status(status)) } -fn cold_config(options: &Options, rules: &NativeRules) -> Result { +/// Checks only options, not paths that artifact installation may create or replace. +fn cold_preflight(options: &Options, rules: &NativeRules) -> Result { + let shares = cold_shares(options)?; + for (target, _, _) in &shares { + if let Some(reserved) = ReservedTargets::BLOCKLESS_ROOT.reservation(target) { + return Err(Exit::error( + ErrorCode::Unsupported, + format!("--mount target {target} overlaps reserved guest path {reserved}"), + )); + } + } + if shares.len() > 1 { + for (kind, given) in &options.policy_paths { + if !Path::new(&path_text(given)).is_absolute() { + return Err(Exit::error( + ErrorCode::Unsupported, + format!( + "with several --mount shares, {} must be an absolute host path", + kind.option() + ), + )); + } + } + } + cold_vm_config(options, rules, None).map(|_| ()) +} + +fn cold_shares(options: &Options) -> Result> { let shares = options .mounts .iter() @@ -510,6 +537,14 @@ fn cold_config(options: &Options, rules: &NativeRules) -> Result { }, )); } + if let Some(owner) = options.mount_owner { + require_mount_owner_supported(owner)?; + } + Ok(shares) +} + +fn cold_config(options: &Options, rules: &NativeRules) -> Result { + let shares = cold_shares(options)?; let hosts: Vec<_> = shares .iter() .map(|(_, host, _)| Path::new(host).to_owned()) @@ -575,6 +610,14 @@ fn cold_config(options: &Options, rules: &NativeRules) -> Result { reserved: ReservedTargets::BLOCKLESS_ROOT, }) .map_err(error_with_cause)?; + cold_vm_config(options, rules, filesystem) +} + +fn cold_vm_config( + options: &Options, + rules: &NativeRules, + filesystem: Option, +) -> Result { let network = sandbox_network(&options.network, rules)? .map(|network| -> Result { Ok(NetworkConfig { @@ -786,6 +829,7 @@ fn console_input(console: Box) -> Result<(Box, Inp } struct ConsoleOutput { + write: fn(&[u8]) -> io::Result<()>, #[cfg(windows)] console: bool, #[cfg(windows)] @@ -797,6 +841,7 @@ struct ConsoleOutput { impl ConsoleOutput { fn new() -> Self { Self { + write: output::write_stdout, #[cfg(windows)] console: io::stdout().is_terminal(), #[cfg(windows)] @@ -806,6 +851,15 @@ impl ConsoleOutput { } } + fn stderr() -> Self { + Self { + write: output::write_stderr, + #[cfg(windows)] + console: io::stderr().is_terminal(), + ..Self::new() + } + } + fn write_all(&mut self, data: &[u8]) -> io::Result<()> { #[cfg(windows)] if self.console { @@ -825,27 +879,129 @@ impl ConsoleOutput { } }); let ready = bytes.len() - incomplete; - output::write_stdout(String::from_utf8_lossy(&bytes[..ready]).as_bytes())?; + (self.write)(String::from_utf8_lossy(&bytes[..ready]).as_bytes())?; self.pending[..incomplete].copy_from_slice(&bytes[ready..]); self.pending_len = incomplete; return Ok(()); } - output::write_stdout(data) + (self.write)(data) } fn finish(&mut self) -> io::Result<()> { #[cfg(windows)] if self.pending_len != 0 { - output::write_stdout("\u{fffd}".as_bytes())?; + (self.write)("\u{fffd}".as_bytes())?; self.pending_len = 0; } Ok(()) } } +struct ForegroundOutput<'a> { + log: &'a mut File, + console: Option>, + stdout: ConsoleOutput, + stderr: ConsoleOutput, +} + +impl<'a> ForegroundOutput<'a> { + fn new(log: &'a mut File) -> Self { + Self { + log, + console: None, + stdout: ConsoleOutput::new(), + stderr: ConsoleOutput::stderr(), + } + } + + fn forward(&mut self) -> Result { + let mut buffer = [0; 8192]; + let count = self.log.read(&mut buffer).map_err(Exit::from_os_error)?; + let mut progressed = count != 0; + self.stderr + .write_all(&buffer[..count]) + .map_err(Exit::from_os_error)?; + if let Some(transport) = &mut self.console { + match transport.read(&mut buffer, Some(Duration::from_millis(10))) { + Ok(0) => { + self.console = None; + self.stdout.finish().map_err(Exit::from_os_error)?; + } + Ok(count) => { + self.stdout + .write_all(&buffer[..count]) + .map_err(Exit::from_os_error)?; + progressed = true; + } + Err(error) if error.kind() == io::ErrorKind::TimedOut => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::BrokenPipe | io::ErrorKind::ConnectionReset + ) => + { + self.console = None; + self.stdout.finish().map_err(Exit::from_os_error)?; + } + Err(error) => return Err(Exit::from_os_error(error)), + } + } + Ok(progressed) + } + + fn drain(&mut self) -> Result { + while self.forward()? {} + Ok(()) + } + + fn finish(&mut self) -> Result { + self.drain()?; + self.stdout.finish().map_err(Exit::from_os_error)?; + self.stderr.finish().map_err(Exit::from_os_error) + } +} + +/// Keeps both boot streams moving during the synchronous control handshake. +fn attach_with_output( + attach: impl FnOnce() -> Result + Send, + mut forward: impl FnMut() -> Result, +) -> Result { + thread::scope(|scope| { + let attaching = thread::Builder::new() + .name("nvx-run-attach".to_owned()) + .spawn_scoped(scope, attach) + .map_err(Exit::from_os_error)?; + while !attaching.is_finished() { + check_interrupt()?; + if !forward()? { + thread::sleep(Duration::from_millis(10)); + } + } + let attached = attaching + .join() + .unwrap_or_else(|panic| std::panic::resume_unwind(panic)); + if attached.is_err() { + let deadline = Instant::now() + Duration::from_millis(250); + loop { + match forward() { + Ok(true) if Instant::now() < deadline => {} + Ok(_) => break, + Err(error) => { + if let Err(attach) = attached { + attach.report(); + } + return Err(error); + } + } + } + } + attached + }) +} + fn foreground( launched: &mut vm::Launched, - log: &mut File, + output: &mut ForegroundOutput<'_>, managed: bool, control: &mut Option, ) -> Result { @@ -855,21 +1011,21 @@ fn foreground( .console .clone() .expect("a cold run requests a console listener"); - let (mut console, input) = if let Some(console) = connect_running(launched, &endpoint)? { + let input = if let Some(console) = connect_running(launched, &endpoint)? { let (console, input) = console_input(console)?; - (Some(console), Some(input)) + output.console = Some(console); + Some(input) } else { - (None, None) + None }; let mut attach_closed = None; - if managed && console.is_some() { + if managed && output.console.is_some() { let endpoint = launched.outputs.endpoint.clone(); let capability = *launched.capability.as_bytes(); - match Session::connect_launched( - &endpoint, - launched.child_mut().map_err(Exit::from_os_error)?, - &capability, - 60.0, + let child = launched.child_mut().map_err(Exit::from_os_error)?; + match attach_with_output( + || Session::connect_launched(&endpoint, child, &capability, 60.0), + || output.forward(), ) { Ok(session) => *control = Some(session), Err(Exit::EndpointClosed) => { @@ -878,13 +1034,9 @@ fn foreground( Err(error) => return Err(error), } } - let mut console_output = ConsoleOutput::new(); - let mut buffer = [0; 8192]; loop { check_interrupt()?; - let count = log.read(&mut buffer).map_err(Exit::from_os_error)?; - let mut progressed = count != 0; - output::write_stderr(&buffer[..count]).map_err(Exit::from_os_error)?; + let progressed = output.forward()?; let status = launched .wait_status(Duration::ZERO) .map_err(Exit::from_os_error)?; @@ -894,61 +1046,8 @@ fn foreground( { result.map_err(Exit::from_os_error)?; } - if let Some(transport) = &mut console { - match transport.read(&mut buffer, Some(Duration::from_millis(10))) { - Ok(0) => { - console = None; - console_output.finish().map_err(Exit::from_os_error)?; - } - Ok(count) => { - console_output - .write_all(&buffer[..count]) - .map_err(Exit::from_os_error)?; - progressed = true; - } - Err(error) if error.kind() == io::ErrorKind::TimedOut => {} - Err(error) - if matches!( - error.kind(), - io::ErrorKind::BrokenPipe | io::ErrorKind::ConnectionReset - ) => - { - console = None; - console_output.finish().map_err(Exit::from_os_error)?; - } - Err(error) => return Err(Exit::from_os_error(error)), - } - } if let Some(status) = status { - loop { - let count = log.read(&mut buffer).map_err(Exit::from_os_error)?; - if count == 0 { - break; - } - output::write_stderr(&buffer[..count]).map_err(Exit::from_os_error)?; - } - if let Some(transport) = &mut console { - loop { - match transport.read(&mut buffer, Some(Duration::from_millis(10))) { - Ok(0) => break, - Ok(count) => console_output - .write_all(&buffer[..count]) - .map_err(Exit::from_os_error)?, - Err(error) - if matches!( - error.kind(), - io::ErrorKind::TimedOut - | io::ErrorKind::BrokenPipe - | io::ErrorKind::ConnectionReset - ) => - { - break; - } - Err(error) => return Err(Exit::from_os_error(error)), - } - } - } - console_output.finish().map_err(Exit::from_os_error)?; + output.finish()?; return Ok(status); } if attach_closed.is_some_and(|deadline| Instant::now() >= deadline) { @@ -1031,8 +1130,11 @@ fn run_cold( "the VM log was replaced before its reader opened", )); } + let mut launched = vm::spawn(prepared).map_err(|error| { + cleanup = error.kind() != crate::ErrorKind::StillRunning; + error_with_cause(error) + })?; cleanup = false; - let mut launched = vm::spawn(prepared).map_err(error_with_cause)?; drop(hold); let mut control = None; let managed = config.boot.as_ref().is_some_and(|boot| { @@ -1040,7 +1142,8 @@ fn run_cold( .split_ascii_whitespace() .any(|token| token == "nvx_mode=managed") }); - let status = foreground(&mut launched, &mut log, managed, &mut control); + let mut output = ForegroundOutput::new(&mut log); + let status = foreground(&mut launched, &mut output, managed, &mut control); let now = Instant::now(); let mut graceful_error = None; let stopped = vm::stop( @@ -1074,6 +1177,15 @@ fn run_cold( { error.report(); } + let drained = output.finish(); + let status = match (status, drained) { + (Err(error), Err(drain)) => { + drain.report(); + Err(error) + } + (status, Ok(())) => status, + (Ok(_), Err(error)) => Err(error), + }; check_interrupt()?; let status = status?; if let Err(error) = stopped.report { diff --git a/nvx-rs/src/host/cli/run/tests.rs b/nvx-rs/src/host/cli/run/tests.rs index ede248caf..4d6a5d5de 100644 --- a/nvx-rs/src/host/cli/run/tests.rs +++ b/nvx-rs/src/host/cli/run/tests.rs @@ -120,6 +120,83 @@ fn an_unverified_console_is_refused_while_the_owned_child_runs() { ); } +#[test] +fn cold_preflight_defers_installable_sources_but_the_launch_checks_them() { + let directory = tempfile::tempdir().unwrap(); + for (name, file) in [("build", false), ("vmlinux", true)] { + let source = directory.path().join(name); + let options = Options { + mounts: vec![format!("/workspace,{},ro", source.display())], + ..Options::default() + }; + let rules = options.network.egress_rules().unwrap(); + cold_preflight(&options, &rules).unwrap(); + assert!(cold_config(&options, &rules).is_err()); + if file { + fs::write(&source, b"installed kernel").unwrap(); + } else { + fs::create_dir(&source).unwrap(); + } + let config = cold_config(&options, &rules).unwrap(); + let export = config.filesystem.unwrap(); + assert_eq!(export.children[0].file, file); + assert_eq!( + export.children[0].root, + filesystem::canonicalize(&source).unwrap() + ); + } +} + +#[test] +fn attachment_keeps_forwarding_until_the_handshake_completes() { + let (observed, progress) = mpsc::sync_channel(0); + let mut sent = false; + let result = attach_with_output( + || { + progress.recv_timeout(Duration::from_secs(2)).unwrap(); + Ok(7) + }, + || { + if !sent { + observed.send(()).unwrap(); + sent = true; + Ok(true) + } else { + Ok(false) + } + }, + ); + assert!(matches!(result, Ok(7))); + assert!(sent); +} + +#[test] +fn failed_attachment_drains_output_that_arrived_with_its_failure() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let complete = AtomicBool::new(false); + let mut drained = 0; + let result = attach_with_output::<()>( + || { + complete.store(true, Ordering::SeqCst); + Err(Exit::error(ErrorCode::Protocol, "attachment refused")) + }, + || { + if complete.load(Ordering::SeqCst) && drained < 3 { + drained += 1; + Ok(true) + } else { + Ok(false) + } + }, + ); + assert!( + matches!(&result, Err(Exit::Error { code: ErrorCode::Protocol, message }) if message == "attachment refused"), + "{result:?}" + ); + assert_eq!(drained, 3); +} + #[cfg(windows)] const REDIRECTED_CONSOLE_BYTES: &[u8] = b"nvx-raw:\xff\x80\xc3"; @@ -149,6 +226,13 @@ fn windows_console_output_handles_non_utf8_code_pages() { .any(|bytes| bytes == REDIRECTED_CONSOLE_BYTES), "{output:?}" ); + assert!( + output + .stderr + .windows(REDIRECTED_CONSOLE_BYTES.len()) + .any(|bytes| bytes == REDIRECTED_CONSOLE_BYTES), + "{output:?}" + ); } /// Runs only as the parent's isolated-console helper, never in the shared test process. @@ -165,14 +249,16 @@ fn windows_console_output_helper() { use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE}; use windows_sys::Win32::System::Console::{ CONSOLE_TEXTMODE_BUFFER, COORD, CreateConsoleScreenBuffer, GetConsoleOutputCP, - GetConsoleProcessList, GetStdHandle, ReadConsoleOutputCharacterW, STD_OUTPUT_HANDLE, - SetConsoleOutputCP, SetStdHandle, + GetConsoleProcessList, GetStdHandle, ReadConsoleOutputCharacterW, STD_ERROR_HANDLE, + STD_OUTPUT_HANDLE, SetConsoleCursorPosition, SetConsoleOutputCP, SetStdHandle, }; /// Restores only the helper's private console and its original redirected stdout. struct RestoreConsole { /// The original borrowed standard-output handle. stdout: HANDLE, + /// The original borrowed standard-error handle. + stderr: HANDLE, /// The private console's original output code page. code_page: u32, } @@ -181,6 +267,7 @@ fn windows_console_output_helper() { // SAFETY: both settings belong to this isolated helper, and stdout stays open. unsafe { assert_ne!(SetStdHandle(STD_OUTPUT_HANDLE, self.stdout), 0); + assert_ne!(SetStdHandle(STD_ERROR_HANDLE, self.stderr), 0); assert_ne!(SetConsoleOutputCP(self.code_page), 0); } } @@ -213,12 +300,16 @@ fn windows_console_output_helper() { // SAFETY: these getters return the helper's own current console settings. let _restore = RestoreConsole { stdout: unsafe { GetStdHandle(STD_OUTPUT_HANDLE) }, + stderr: unsafe { GetStdHandle(STD_ERROR_HANDLE) }, code_page: unsafe { GetConsoleOutputCP() }, }; assert!(!io::stdout().is_terminal()); let mut redirected = ConsoleOutput::new(); redirected.write_all(REDIRECTED_CONSOLE_BYTES).unwrap(); redirected.finish().unwrap(); + let mut redirected = ConsoleOutput::stderr(); + redirected.write_all(REDIRECTED_CONSOLE_BYTES).unwrap(); + redirected.finish().unwrap(); // SAFETY: the file is owned and remains open until stdout has been restored. unsafe { assert_ne!( @@ -280,6 +371,75 @@ fn windows_console_output_helper() { ); assert_eq!(read as usize, expected.len()); assert_eq!(actual, expected); + + let mut raw = tempfile::tempfile().unwrap(); + // SAFETY: this helper owns every changed handle, and restores them before closing files. + unsafe { + assert_ne!( + SetStdHandle(STD_ERROR_HANDLE, raw.as_raw_handle().cast()), + 0 + ); + } + let mut log = tempfile::NamedTempFile::new().unwrap(); + let mut reader = File::open(log.path()).unwrap(); + let mut output = ForegroundOutput::new(&mut reader); + log.write_all(REDIRECTED_CONSOLE_BYTES).unwrap(); + log.flush().unwrap(); + output.finish().unwrap(); + raw.rewind().unwrap(); + let mut bytes = Vec::new(); + raw.read_to_end(&mut bytes).unwrap(); + assert_eq!(bytes, REDIRECTED_CONSOLE_BYTES); + + // SAFETY: the screen buffer belongs only to this helper's verified private console. + unsafe { + assert_ne!( + SetStdHandle(STD_ERROR_HANDLE, buffer.as_raw_handle().cast()), + 0 + ); + assert_ne!( + SetConsoleCursorPosition(buffer.as_raw_handle().cast(), COORD { X: 0, Y: 0 }), + 0 + ); + } + let mut log = tempfile::NamedTempFile::new().unwrap(); + let mut reader = File::open(log.path()).unwrap(); + let mut output = ForegroundOutput::new(&mut reader); + for chunk in [ + b"caf\xc3".as_slice(), + b"\xa9 \xe2".as_slice(), + b"\x82\xac|A\xff\x80|".as_slice(), + b"\xc3".as_slice(), + b"A".as_slice(), + ] { + log.write_all(chunk).unwrap(); + log.flush().unwrap(); + assert!(output.forward().unwrap()); + } + output.finish().unwrap(); + for incomplete in [b"\xc3".as_slice(), b"\xe2\x82", b"\xf0\x9f\x92"] { + log.write_all(b"|").unwrap(); + log.write_all(incomplete).unwrap(); + log.flush().unwrap(); + output.finish().unwrap(); + } + let mut actual = vec![0; expected.len()]; + let mut read = 0; + // SAFETY: the owned buffer and writable output have the same valid bounds as above. + assert_ne!( + unsafe { + ReadConsoleOutputCharacterW( + buffer.as_raw_handle().cast(), + actual.as_mut_ptr(), + actual.len() as u32, + COORD { X: 0, Y: 0 }, + &mut read, + ) + }, + 0 + ); + assert_eq!(read as usize, expected.len()); + assert_eq!(actual, expected); } proptest! { diff --git a/nvx-rs/src/host/platform/windows/spawn.rs b/nvx-rs/src/host/platform/windows/spawn.rs index e93a082f0..bcaf571e3 100644 --- a/nvx-rs/src/host/platform/windows/spawn.rs +++ b/nvx-rs/src/host/platform/windows/spawn.rs @@ -500,8 +500,12 @@ fn spawn_listed( drop(lock); created?; drop(owned(information.hThread)); - let process = owned(information.hProcess) - .ok_or_else(|| io::Error::other("CreateProcessW returned no process handle"))?; + let process = owned(information.hProcess).ok_or_else(|| { + io::Error::other(crate::Error::new( + crate::ErrorKind::StillRunning, + "CreateProcessW succeeded but returned no process handle to confirm exit", + )) + })?; Ok(LaunchedProcess { process, pid: information.dwProcessId, diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index b67a2e2e8..246341385 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -759,7 +759,8 @@ impl Launched { /// The caller keeps the directory and runtime inputs stable through creation and spawn. /// A failure after child creation terminates only that child and waits for its confirmed exit. /// Output paths are not removed. Host failures have kind [`ErrorKind::Io`](crate::ErrorKind::Io), -/// retaining their source; an exit-confirmation failure is reported with the original error. +/// retaining their source. An unconfirmed exit has kind +/// [`ErrorKind::StillRunning`](crate::ErrorKind::StillRunning), with the original error. pub fn spawn(prepared: Prepared) -> Result { spawn_verified(prepared, spawn::Launched::process) } @@ -840,6 +841,15 @@ pub fn stop( fn spawn_verified( prepared: Prepared, verify: impl FnOnce(&spawn::Launched) -> io::Result, +) -> Result { + spawn_verified_with_cleanup(prepared, verify, spawn::Launched::kill_and_confirm_exit) +} + +/// Keeps cleanup proof distinct from a request to terminate the owned child. +fn spawn_verified_with_cleanup( + prepared: Prepared, + verify: impl FnOnce(&spawn::Launched) -> io::Result, + cleanup: impl FnOnce(spawn::Launched) -> io::Result<()>, ) -> Result { let Prepared { program, @@ -860,19 +870,36 @@ fn spawn_verified( breakaway_from_job: windows_breakaway, inherit: inherit.iter().map(Inherited::new).collect(), }; - let child = spawn::spawn_vm(&command, capability.as_bytes(), log, foreground) - .map_err(|error| Error::io("cannot spawn the VM").with_source(error))?; + let child = + spawn::spawn_vm(&command, capability.as_bytes(), log, foreground).map_err(|error| { + let unconfirmed = error + .get_ref() + .and_then(|source| source.downcast_ref::()) + .is_some_and(|source| source.kind() == crate::ErrorKind::StillRunning); + Error::new( + if unconfirmed { + crate::ErrorKind::StillRunning + } else { + crate::ErrorKind::Io + }, + "cannot spawn the VM", + ) + .with_source(error) + })?; let process = match verify(&child) { Ok(process) => process, Err(error) => { - let cleanup = child.kill_and_confirm_exit(); - let message = match cleanup { - Ok(()) => "cannot verify the spawned VM".to_owned(), - Err(cleanup) => { - format!("cannot verify the spawned VM; cannot confirm its exit: {cleanup}") - } + let (kind, message) = match cleanup(child) { + Ok(()) => ( + crate::ErrorKind::Io, + "cannot verify the spawned VM".to_owned(), + ), + Err(cleanup) => ( + crate::ErrorKind::StillRunning, + format!("cannot verify the spawned VM; cannot confirm its exit: {cleanup}"), + ), }; - return Err(Error::io(message).with_source(error)); + return Err(Error::new(kind, message).with_source(error)); } }; #[cfg(feature = "cli")] diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index 3a9b5ce4a..7eecb2efe 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -1393,6 +1393,37 @@ fn a_failure_after_child_creation_terminates_only_that_child_and_confirms_its_ex assert!(vm.path().join(LOG).exists()); } +#[cfg(any(target_os = "linux", windows))] +#[test] +fn a_failed_verification_with_unconfirmed_exit_keeps_its_typed_cleanup_refusal() { + let (_directory, vm) = vm_dir(); + let mut process = None; + let error = spawn_verified_with_cleanup( + stand_in(&vm), + |child| { + process = Some(child.process().unwrap()); + Err(io::Error::other("injected process verification failure")) + }, + |child| { + child.kill_and_confirm_exit().unwrap(); + Err(io::Error::other("injected missing exit confirmation")) + }, + ) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::StillRunning); + assert_eq!( + std::error::Error::source(&error).unwrap().to_string(), + "injected process verification failure" + ); + assert!( + error + .to_string() + .contains("injected missing exit confirmation") + ); + assert!(vm.path().join(LOG).exists()); + assert!(process.unwrap().has_exited().unwrap()); +} + #[cfg(any(target_os = "linux", windows))] #[test] fn spawn_failure_keeps_caller_owned_files_and_reports_the_host_error() { diff --git a/nvx-rs/src/testing/fake_openvmm.rs b/nvx-rs/src/testing/fake_openvmm.rs index 90ac917a3..de777d797 100644 --- a/nvx-rs/src/testing/fake_openvmm.rs +++ b/nvx-rs/src/testing/fake_openvmm.rs @@ -52,6 +52,8 @@ //! an acknowledged stop, `fake_ignore_cancel=1` ignores cancellation, //! `fake_console_bytes=N` emits up to 16 MiB of `x` bytes on a run's console, and //! `fake_report_before_console=1` writes a run's report before console output or its boot delay. +//! `fake_boot_diagnostics=1` emits console output and a binary log before control attachment. +//! `fake_refuse_attach=1` refuses attachment after the endpoint has accepted its client. //! `fake_guest_features=MASK` advertises the decimal feature mask `MASK` instead of the current //! guest's, `fake_legacy_guest=1` refuses the features request like a guest agent that //! predates it, and `fake_refuse_maps=1` fails every `MAPS` request like a guest that cannot @@ -389,6 +391,10 @@ struct Options { console_bytes: usize, /// Whether a run publishes its regression report before console output or boot delay. report_before_console: bool, + /// Whether boot emits the early-console and binary-log regression fixtures. + boot_diagnostics: bool, + /// Whether the control handshake refuses even the correct credential. + refuse_attach: bool, /// Artificial delay before the fake OpenVMM crashes, when requested. crash_after: Option, /// Whether stop requests are deliberately left unanswered. @@ -524,6 +530,17 @@ pub fn main() -> ExitCode { return ExitCode::from(1); } }; + if options.boot_diagnostics { + let written = console + .write_all(b"boot-console before control\n") + .and_then(|()| console.flush()) + .and_then(|()| io::stderr().write_all(b"boot-log:\xff\x80\xc3")) + .and_then(|()| io::stderr().flush()); + if let Err(error) = written { + eprintln!("{program}: {error}"); + return ExitCode::from(1); + } + } if options.report_before_console && matches!(options.lifecycle, Lifecycle::OneShot | Lifecycle::Run) { @@ -1760,6 +1777,8 @@ fn parse(program: &str, arguments: &[String]) -> Result { })?, }, report_before_console: knob("fake_report_before_console") == Some("1"), + boot_diagnostics: knob("fake_boot_diagnostics") == Some("1"), + refuse_attach: knob("fake_refuse_attach") == Some("1"), crash_after: millis("fake_crash_after_ms"), ignore_stop: knob("fake_ignore_stop") == Some("1"), exit_delay: millis("fake_exit_delay_ms").unwrap_or_default(), @@ -2845,7 +2864,7 @@ fn serve_client( let Ok(Some((header, payload))) = read_record(stream) else { return Flow::Continue; }; - if header.record_type != OUTER_HOST_ATTACH || payload != capability { + if options.refuse_attach || header.record_type != OUTER_HOST_ATTACH || payload != capability { let code = ErrorCode::Authentication.code().to_le_bytes(); let _ = stream.write_all(&control_record(OUTER_ERROR, &[0; 16], 0, &code)); return Flow::Continue; diff --git a/nvx-rs/tests/cli/download.rs b/nvx-rs/tests/cli/download.rs index f9d758cea..4fc5487da 100644 --- a/nvx-rs/tests/cli/download.rs +++ b/nvx-rs/tests/cli/download.rs @@ -77,6 +77,26 @@ fn run_downloads_the_release_of_a_checkout_without_artifacts() { } } +#[test] +fn run_downloads_before_checking_mount_sources_that_installation_can_create() { + for source in ["build", "build/vmlinux"] { + let root = checkout(&INSTALLED); + fs::remove_dir(root.below("build")).unwrap(); + let mount = format!("/workspace,{},ro", root.below(source).display()); + let outcome = root.nvx_rs(&["run", "--dry-run", "--mount", &mount]); + outcome.assert_error( + "unavailable", + &["download needs a Git checkout to find its release"], + ); + assert!( + outcome.stderr().contains("holds no NVX artifacts"), + "{}", + outcome.describe() + ); + assert_no_staging(&root); + } +} + #[test] fn run_downloads_nothing_over_some_artifacts() { let root = checkout(&["build/vmlinux"]); @@ -120,6 +140,18 @@ fn commands_download_nothing_for_options_that_they_refuse() { // root, without the download that would provide it, and leaves the checkout as it is. for arguments in [ vec!["run", "--dry-run", "--mount-owner", "vmm"], + vec!["run", "--dry-run", "--cmdline", "nvx_mode=invalid"], + vec!["run", "--dry-run", "--mount", "/etc,{root/build},ro"], + vec![ + "run", + "--dry-run", + "--mount", + "/a,{root/build},ro", + "--mount", + "/b,{root/build},ro", + "--mount-deny", + "relative", + ], vec![ "sandbox", "run", diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index 3de2633d0..56876f629 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -1084,6 +1084,233 @@ fn cold_console_output_preserves_non_utf8_bytes() { ); } +#[test] +fn a_cold_spawn_without_a_child_removes_its_private_directory() { + let root = Root::new(); + let temporary = tempfile::tempdir().unwrap(); + root.write("not-executable{exe}", "not an executable\n"); + let outcome = root.nvx_rs_with( + &["run"], + &[ + ("NVX_OPENVMM", "{root/not-executable{exe}}"), + ("TMPDIR", temporary.path().to_str().unwrap()), + ("TMP", temporary.path().to_str().unwrap()), + ("TEMP", temporary.path().to_str().unwrap()), + ], + ); + assert_eq!(outcome.status, Some(1), "{}", outcome.describe()); + assert!( + outcome.stderr().contains("error[io]: cannot spawn the VM"), + "{}", + outcome.describe() + ); + assert_eq!( + std::fs::read_dir(temporary.path()).unwrap().count(), + 0, + "{}", + outcome.describe() + ); +} + +#[test] +fn managed_boot_diagnostics_arrive_before_the_control_endpoint() { + use std::io::{BufRead, BufReader, Read}; + use std::process::Stdio; + + let root = Root::new(); + let mut process = root + .command(&[ + "run", + "--cmdline", + "nvx_mode=managed nvx_identity=1001:1001 fake_boot_diagnostics=1 \ + fake_boot_delay_ms=2000 fake_crash_after_ms=100", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdout = BufReader::new(process.stdout.take().unwrap()); + let mut command = String::new(); + stdout.read_line(&mut command).unwrap(); + assert!(command.starts_with(">> "), "{command}"); + let mut console = String::new(); + stdout.read_line(&mut console).unwrap(); + let mut stderr = process.stderr.take().unwrap(); + let mut log = [0; 12]; + stderr.read_exact(&mut log).unwrap(); + let attached = || { + std::fs::read_dir(root.below("build")) + .unwrap() + .any(|entry| { + entry + .unwrap() + .file_name() + .to_string_lossy() + .ends_with("-sessions.log") + }) + }; + let before_attach = !attached(); + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(3), "{output:?}"); + assert_eq!(console, "boot-console before control\n"); + assert_eq!(&log, b"boot-log:\xff\x80\xc3"); + assert!( + attached(), + "the managed control handshake was never exercised" + ); + assert!( + before_attach, + "boot diagnostics were withheld until control attachment" + ); +} + +#[test] +fn failed_managed_attachment_keeps_boot_diagnostics_before_cleanup() { + let root = Root::new(); + let temporary = tempfile::tempdir().unwrap(); + let outcome = root.nvx_rs_with( + &[ + "run", + "--cmdline", + "nvx_mode=managed nvx_identity=1001:1001 fake_boot_diagnostics=1 \ + fake_refuse_attach=1", + ], + &[ + ("TMPDIR", temporary.path().to_str().unwrap()), + ("TMP", temporary.path().to_str().unwrap()), + ("TEMP", temporary.path().to_str().unwrap()), + ], + ); + assert_eq!(outcome.status, Some(1), "{:?}", outcome.status); + assert_eq!(outcome.command().1, b"boot-console before control\n"); + assert!(outcome.stderr.starts_with(b"boot-log:\xff\x80\xc3")); + assert!( + outcome.stderr[b"boot-log:\xff\x80\xc3".len()..] + .windows(b"error[".len()) + .any(|bytes| bytes == b"error["), + "{:?}", + outcome.stderr + ); + assert_eq!(std::fs::read_dir(temporary.path()).unwrap().count(), 0); +} + +#[test] +fn redirected_boot_logs_preserve_invalid_bytes_and_an_incomplete_eof() { + let root = Root::new(); + let outcome = root.nvx_rs(&["run", "--cmdline", "fake_boot_diagnostics=1"]); + assert_eq!(outcome.status, Some(0), "{:?}", outcome.status); + assert_eq!(&outcome.stderr, b"boot-log:\xff\x80\xc3"); + assert_eq!( + outcome.command().1, + b"boot-console before control\nopenvmm: booted the microVM\n" + ); +} + +#[cfg(windows)] +#[test] +fn cold_logs_render_invalid_utf8_in_a_real_windows_console() { + use std::os::windows::process::CommandExt; + use windows_sys::Win32::System::Threading::CREATE_NEW_CONSOLE; + + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "one_shot::windows_cold_log_console_helper", + "--ignored", + "--nocapture", + "--test-threads=1", + ]) + .env("NVX_TEST_COLD_LOG_CONSOLE", "cp437") + .creation_flags(CREATE_NEW_CONSOLE) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); +} + +/// Runs only in the parent's new private console, never the shared test process. +#[cfg(windows)] +#[test] +#[ignore] +#[allow(unsafe_code)] +fn windows_cold_log_console_helper() { + use std::fs::File; + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use std::process::Stdio; + use windows_sys::Win32::Foundation::{GENERIC_READ, GENERIC_WRITE, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE}; + use windows_sys::Win32::System::Console::{ + CONSOLE_TEXTMODE_BUFFER, COORD, CreateConsoleScreenBuffer, GetConsoleProcessList, + ReadConsoleOutputCharacterW, SetConsoleOutputCP, + }; + + assert_eq!( + std::env::var("NVX_TEST_COLD_LOG_CONSOLE").as_deref(), + Ok("cp437") + ); + let mut members = [0; 4]; + // SAFETY: this helper must be the sole member of its newly created console. + let count = unsafe { GetConsoleProcessList(members.as_mut_ptr(), members.len() as u32) }; + assert_eq!(count, 1); + assert_eq!(members[0], std::process::id()); + // SAFETY: only this verified helper owns the private console's output code page. + assert_ne!(unsafe { SetConsoleOutputCP(437) }, 0); + // SAFETY: this creates a new, non-inheritable screen buffer owned by this helper. + let raw = unsafe { + CreateConsoleScreenBuffer( + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + std::ptr::null(), + CONSOLE_TEXTMODE_BUFFER, + std::ptr::null_mut(), + ) + }; + assert_ne!(raw, INVALID_HANDLE_VALUE); + // SAFETY: the successful call returned this newly owned handle. + let screen = unsafe { OwnedHandle::from_raw_handle(raw.cast()) }; + let root = Root::new(); + let arguments = [ + "run", + "--cmdline", + "fake_boot_diagnostics=1 fake_binary_console=1", + ]; + let output = root + .command(&arguments) + .stdout(Stdio::piped()) + .stderr(Stdio::from(File::from(screen.try_clone().unwrap()))) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(0), "{output:?}"); + let outcome = crate::harness::Outcome::new( + arguments + .iter() + .map(|argument| (*argument).to_owned()) + .collect(), + output, + ); + assert_eq!( + outcome.command().1, + b"boot-console before control\nA\xff\x80\n" + ); + let expected: Vec = "boot-log:\u{fffd}\u{fffd}\u{fffd}".encode_utf16().collect(); + let mut actual = vec![0; expected.len()]; + let mut read = 0; + // SAFETY: the screen stays open and actual holds the requested UTF-16 characters. + assert_ne!( + unsafe { + ReadConsoleOutputCharacterW( + screen.as_raw_handle().cast(), + actual.as_mut_ptr(), + actual.len() as u32, + COORD { X: 0, Y: 0 }, + &mut read, + ) + }, + 0 + ); + assert_eq!(read as usize, expected.len()); + assert_eq!(actual, expected); +} + #[test] fn a_ready_console_stream_is_not_throttled_by_idle_sleeps() { use std::time::{Duration, Instant}; From 8cdf727d9886934efd829facacda641eb9710e63 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 12:54:13 -0800 Subject: [PATCH 11/27] test: move the attachment receiver into its scoped worker Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35f2ad55-f31c-4ab3-9c78-f119b029a3c0 --- nvx-rs/src/host/cli/run/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nvx-rs/src/host/cli/run/tests.rs b/nvx-rs/src/host/cli/run/tests.rs index 4d6a5d5de..1d1169bd7 100644 --- a/nvx-rs/src/host/cli/run/tests.rs +++ b/nvx-rs/src/host/cli/run/tests.rs @@ -152,7 +152,7 @@ fn attachment_keeps_forwarding_until_the_handshake_completes() { let (observed, progress) = mpsc::sync_channel(0); let mut sent = false; let result = attach_with_output( - || { + move || { progress.recv_timeout(Duration::from_secs(2)).unwrap(); Ok(7) }, From 6add6260a6a173a3c6bf5a2a6ab0c47aff481148 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 13:01:04 -0800 Subject: [PATCH 12/27] fix: preserve cold-run refusal diagnostics after preflight Keep source-independent owner refusal in preflight without replacing the checked planner diagnostic. Check the download notification and final error separately in its regression fixture, and initialize console carry directly on each platform. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35f2ad55-f31c-4ab3-9c78-f119b029a3c0 --- nvx-rs/src/host/cli/run.rs | 11 +++++++---- nvx-rs/tests/cli/download.rs | 12 +++++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index e68580fb3..4d2b6413b 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -491,6 +491,9 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { /// Checks only options, not paths that artifact installation may create or replace. fn cold_preflight(options: &Options, rules: &NativeRules) -> Result { let shares = cold_shares(options)?; + if let Some(owner) = options.mount_owner { + require_mount_owner_supported(owner)?; + } for (target, _, _) in &shares { if let Some(reserved) = ReservedTargets::BLOCKLESS_ROOT.reservation(target) { return Err(Exit::error( @@ -537,9 +540,6 @@ fn cold_shares(options: &Options) -> Result> }, )); } - if let Some(owner) = options.mount_owner { - require_mount_owner_supported(owner)?; - } Ok(shares) } @@ -856,7 +856,10 @@ impl ConsoleOutput { write: output::write_stderr, #[cfg(windows)] console: io::stderr().is_terminal(), - ..Self::new() + #[cfg(windows)] + pending: [0; 3], + #[cfg(windows)] + pending_len: 0, } } diff --git a/nvx-rs/tests/cli/download.rs b/nvx-rs/tests/cli/download.rs index 4fc5487da..d7f67bc85 100644 --- a/nvx-rs/tests/cli/download.rs +++ b/nvx-rs/tests/cli/download.rs @@ -84,9 +84,15 @@ fn run_downloads_before_checking_mount_sources_that_installation_can_create() { fs::remove_dir(root.below("build")).unwrap(); let mount = format!("/workspace,{},ro", root.below(source).display()); let outcome = root.nvx_rs(&["run", "--dry-run", "--mount", &mount]); - outcome.assert_error( - "unavailable", - &["download needs a Git checkout to find its release"], + assert_eq!(outcome.status, Some(1), "{}", outcome.describe()); + assert!(outcome.stdout.is_empty(), "{}", outcome.describe()); + let stderr = outcome.stderr(); + assert!( + stderr.lines().last().unwrap().starts_with( + "error[unavailable]: download needs a Git checkout to find its release: " + ), + "{}", + outcome.describe() ); assert!( outcome.stderr().contains("holds no NVX artifacts"), From 8ab2b7b63bf28b209d93c8f42f257d315ce38254 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 13:15:36 -0800 Subject: [PATCH 13/27] fix: preserve canonical interruption after cold-run report publication Do not retry stopped output or report secondary drain errors after Ctrl-C. Recheck interruption around final drain and attachment failure reporting; exercise pending stopped logs in the existing isolated real-console software seam without starting or signaling a VM. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35f2ad55-f31c-4ab3-9c78-f119b029a3c0 --- nvx-rs/src/host/cli/run.rs | 33 ++++++++++------ nvx-rs/src/host/cli/run/tests.rs | 64 ++++++++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 16 deletions(-) diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index 4d2b6413b..dc8085585 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -990,7 +990,9 @@ fn attach_with_output( Ok(true) if Instant::now() < deadline => {} Ok(_) => break, Err(error) => { - if let Err(attach) = attached { + if !platform::interrupted() + && let Err(attach) = attached + { attach.report(); } return Err(error); @@ -1062,6 +1064,23 @@ fn foreground( } } +/// Completes stopped output after the caller has published the requested native report. +fn finish_foreground(output: &mut ForegroundOutput<'_>, status: Result) -> Result { + check_interrupt()?; + let drained = output.finish(); + let status = match (status, drained) { + (Err(error), Err(drain)) => { + check_interrupt()?; + drain.report(); + Err(error) + } + (status, Ok(())) => status, + (Ok(_), Err(error)) => Err(error), + }; + check_interrupt()?; + status +} + fn run_cold( dir: ArtifactDir, openvmm: std::path::PathBuf, @@ -1180,17 +1199,7 @@ fn run_cold( { error.report(); } - let drained = output.finish(); - let status = match (status, drained) { - (Err(error), Err(drain)) => { - drain.report(); - Err(error) - } - (status, Ok(())) => status, - (Ok(_), Err(error)) => Err(error), - }; - check_interrupt()?; - let status = status?; + let status = finish_foreground(&mut output, status)?; if let Err(error) = stopped.report { output::print_stderr(&format!( "error[io]: {}", diff --git a/nvx-rs/src/host/cli/run/tests.rs b/nvx-rs/src/host/cli/run/tests.rs index 1d1169bd7..b1fec1ec6 100644 --- a/nvx-rs/src/host/cli/run/tests.rs +++ b/nvx-rs/src/host/cli/run/tests.rs @@ -248,12 +248,13 @@ fn windows_console_output_helper() { }; use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE}; use windows_sys::Win32::System::Console::{ - CONSOLE_TEXTMODE_BUFFER, COORD, CreateConsoleScreenBuffer, GetConsoleOutputCP, - GetConsoleProcessList, GetStdHandle, ReadConsoleOutputCharacterW, STD_ERROR_HANDLE, - STD_OUTPUT_HANDLE, SetConsoleCursorPosition, SetConsoleOutputCP, SetStdHandle, + CONSOLE_TEXTMODE_BUFFER, COORD, CTRL_C_EVENT, CreateConsoleScreenBuffer, + GenerateConsoleCtrlEvent, GetConsoleOutputCP, GetConsoleProcessList, GetStdHandle, + ReadConsoleOutputCharacterW, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE, SetConsoleCursorPosition, + SetConsoleOutputCP, SetStdHandle, }; - /// Restores only the helper's private console and its original redirected stdout. + /// Restores the helper's private console and its original redirected standard streams. struct RestoreConsole { /// The original borrowed standard-output handle. stdout: HANDLE, @@ -440,6 +441,61 @@ fn windows_console_output_helper() { ); assert_eq!(read as usize, expected.len()); assert_eq!(actual, expected); + + platform::watch_interrupts().unwrap(); + // SAFETY: verify the event can reach only this helper in its private console. + let count = unsafe { GetConsoleProcessList(members.as_mut_ptr(), members.len() as u32) }; + assert_eq!(count, 1); + assert_eq!(members[0], std::process::id()); + // SAFETY: this console contains only the helper; no VM or foreign process is signaled. + assert_ne!(unsafe { GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0) }, 0); + let deadline = Instant::now() + Duration::from_secs(2); + while !platform::interrupted() { + assert!( + Instant::now() < deadline, + "the private Ctrl-C was not recorded" + ); + thread::sleep(Duration::from_millis(5)); + } + let mut log = tempfile::NamedTempFile::new().unwrap(); + log.write_all(b"pending stopped log").unwrap(); + log.flush().unwrap(); + let mut reader = File::open(log.path()).unwrap(); + let mut output = ForegroundOutput::new(&mut reader); + assert!(matches!( + finish_foreground(&mut output, Err(Exit::Interrupted)), + Err(Exit::Interrupted) + )); + let mut trailing = [0; 24]; + let mut read = 0; + // SAFETY: inspect the same owned buffer immediately after the prior verified output. + assert_ne!( + unsafe { + ReadConsoleOutputCharacterW( + buffer.as_raw_handle().cast(), + trailing.as_mut_ptr(), + trailing.len() as u32, + COORD { + X: expected.len() as i16, + Y: 0, + }, + &mut read, + ) + }, + 0 + ); + // SAFETY: restore the helper's borrowed pipes so a failed assertion is visible to its parent. + unsafe { + assert_ne!(SetStdHandle(STD_OUTPUT_HANDLE, _restore.stdout), 0); + assert_ne!(SetStdHandle(STD_ERROR_HANDLE, _restore.stderr), 0); + } + assert_eq!(read as usize, trailing.len()); + assert!( + trailing + .iter() + .all(|character| *character == u16::from(b' ')), + "interrupted output printed a secondary diagnostic: {trailing:?}" + ); } proptest! { From 4bb2b7f71de876485469e7dfcc98e1ffbb9969d9 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 14:58:31 -0800 Subject: [PATCH 14/27] fix: cancel foreground attachment and preserve terminal interrupts Keep fair output forwarding genuinely nonblocking while logs are ready, cancel both attachment phases before scoped join, and preserve the original forwarding cause and verified VM cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80075afd-7139-47a9-90c2-923b6b22bda5 --- nvx-rs/src/host/cli/run.rs | 97 ++++++++- nvx-rs/src/host/cli/run/tests.rs | 55 ++++- nvx-rs/src/host/cli/session.rs | 123 +++++++++++- nvx-rs/src/host/cli/session/tests.rs | 201 +++++++++++++++++-- nvx-rs/src/host/platform/linux/endpoint.rs | 8 +- nvx-rs/src/host/platform/windows/endpoint.rs | 2 +- nvx-rs/src/testing/fake_openvmm.rs | 55 ++++- nvx-rs/tests/cli/one_shot.rs | 173 ++++++++++++++++ 8 files changed, 679 insertions(+), 35 deletions(-) diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index dc8085585..cf5027707 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -59,7 +59,7 @@ use super::sandbox::{ HYPERVISORS, MAX_MEMORY_MIB, MOUNT_OWNERS, copy_openvmm_outcome, error_with_cause, hypervisor, path_string, path_text, run_openvmm, sandbox_network, validate_outcome_destination, }; -use super::session::Session; +use super::session::{AttachmentCancellation, Session}; use super::shell::format_command; use super::{Exit, Result, check_interrupt, output}; use crate::ErrorCode; @@ -750,7 +750,48 @@ impl TerminalGuard { if terminal { crossterm::terminal::enable_raw_mode().map_err(Exit::from_os_error)?; } - Ok(Self(terminal)) + let guard = Self(terminal); + if terminal { + Self::enable_interrupts().map_err(Exit::from_os_error)?; + } + Ok(guard) + } + + #[cfg(unix)] + fn enable_interrupts() -> io::Result<()> { + use nix::sys::termios::{LocalFlags, SetArg, tcgetattr, tcsetattr}; + + let stdin = io::stdin(); + let mut mode = tcgetattr(&stdin)?; + mode.local_flags.insert(LocalFlags::ISIG); + tcsetattr(&stdin, SetArg::TCSANOW, &mode)?; + Ok(()) + } + + #[cfg(windows)] + #[allow(unsafe_code)] + fn enable_interrupts() -> io::Result<()> { + use windows_sys::Win32::System::Console::{ + ENABLE_PROCESSED_INPUT, GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE, + SetConsoleMode, + }; + + let mut mode = 0; + // SAFETY: stdin is this process's terminal, whose saved entry mode the guard restores. + unsafe { + let input = GetStdHandle(STD_INPUT_HANDLE); + if GetConsoleMode(input, &mut mode) == 0 + || SetConsoleMode(input, mode | ENABLE_PROCESSED_INPUT) == 0 + { + return Err(io::Error::last_os_error()); + } + } + Ok(()) + } + + #[cfg(not(any(unix, windows)))] + fn enable_interrupts() -> io::Result<()> { + Err(io::ErrorKind::Unsupported.into()) } } @@ -925,7 +966,12 @@ impl<'a> ForegroundOutput<'a> { .write_all(&buffer[..count]) .map_err(Exit::from_os_error)?; if let Some(transport) = &mut self.console { - match transport.read(&mut buffer, Some(Duration::from_millis(10))) { + let timeout = if progressed { + Duration::ZERO + } else { + Duration::from_millis(10) + }; + match transport.read(&mut buffer, Some(timeout)) { Ok(0) => { self.console = None; self.stdout.finish().map_err(Exit::from_os_error)?; @@ -965,20 +1011,43 @@ impl<'a> ForegroundOutput<'a> { } /// Keeps both boot streams moving during the synchronous control handshake. -fn attach_with_output( - attach: impl FnOnce() -> Result + Send, +pub(super) fn attach_with_output( + attach: impl FnOnce(AttachmentCancellation) -> Result + Send, mut forward: impl FnMut() -> Result, ) -> Result { thread::scope(|scope| { + let cancellation = AttachmentCancellation::default(); + let pending = cancellation.clone(); let attaching = thread::Builder::new() .name("nvx-run-attach".to_owned()) - .spawn_scoped(scope, attach) + .spawn_scoped(scope, move || attach(pending)) .map_err(Exit::from_os_error)?; - while !attaching.is_finished() { - check_interrupt()?; - if !forward()? { + let forwarded: Result = (|| { + while !attaching.is_finished() { + check_interrupt()?; + if !forward()? { + thread::sleep(Duration::from_millis(10)); + } + } + Ok(()) + })(); + if let Err(error) = forwarded { + let mut cancel_failure = None; + while !attaching.is_finished() { + if let Err(failure) = cancellation.cancel() { + cancel_failure.get_or_insert(failure); + } thread::sleep(Duration::from_millis(10)); } + let _ = attaching + .join() + .unwrap_or_else(|panic| std::panic::resume_unwind(panic)); + if !platform::interrupted() + && let Some(failure) = cancel_failure + { + Exit::from_os_error(failure).report(); + } + return Err(error); } let attached = attaching .join() @@ -1029,7 +1098,15 @@ fn foreground( let capability = *launched.capability.as_bytes(); let child = launched.child_mut().map_err(Exit::from_os_error)?; match attach_with_output( - || Session::connect_launched(&endpoint, child, &capability, 60.0), + |cancellation| { + Session::connect_launched_cancellable( + &endpoint, + child, + &capability, + 60.0, + cancellation, + ) + }, || output.forward(), ) { Ok(session) => *control = Some(session), diff --git a/nvx-rs/src/host/cli/run/tests.rs b/nvx-rs/src/host/cli/run/tests.rs index b1fec1ec6..8b9c81e05 100644 --- a/nvx-rs/src/host/cli/run/tests.rs +++ b/nvx-rs/src/host/cli/run/tests.rs @@ -152,7 +152,7 @@ fn attachment_keeps_forwarding_until_the_handshake_completes() { let (observed, progress) = mpsc::sync_channel(0); let mut sent = false; let result = attach_with_output( - move || { + move |_| { progress.recv_timeout(Duration::from_secs(2)).unwrap(); Ok(7) }, @@ -177,7 +177,7 @@ fn failed_attachment_drains_output_that_arrived_with_its_failure() { let complete = AtomicBool::new(false); let mut drained = 0; let result = attach_with_output::<()>( - || { + |_| { complete.store(true, Ordering::SeqCst); Err(Exit::error(ErrorCode::Protocol, "attachment refused")) }, @@ -197,6 +197,57 @@ fn failed_attachment_drains_output_that_arrived_with_its_failure() { assert_eq!(drained, 3); } +#[test] +fn ready_logs_poll_the_console_without_waiting_and_still_serve_it_each_pass() { + use std::io::Write; + use std::sync::{Arc, Mutex}; + + struct Console(Arc>>>); + impl Transport for Console { + fn read(&mut self, buffer: &mut [u8], timeout: Option) -> io::Result { + let mut reads = self.0.lock().unwrap(); + reads.push(timeout); + if reads.len() == 2 { + buffer[0] = b'c'; + Ok(1) + } else { + Err(io::ErrorKind::TimedOut.into()) + } + } + + fn write_all(&mut self, _: &[u8], _: Option) -> io::Result<()> { + unreachable!("this is the console reader") + } + } + + fn accept(_: &[u8]) -> io::Result<()> { + Ok(()) + } + + let mut log = tempfile::NamedTempFile::new().unwrap(); + log.write_all(&vec![b'l'; 3 * 8192]).unwrap(); + log.flush().unwrap(); + let mut reader = File::open(log.path()).unwrap(); + let reads = Arc::new(Mutex::new(Vec::new())); + let mut output = ForegroundOutput::new(&mut reader); + output.stderr.write = accept; + output.stdout.write = accept; + output.console = Some(Box::new(Console(Arc::clone(&reads)))); + for _ in 0..3 { + assert!(output.forward().unwrap()); + } + assert!(!output.forward().unwrap()); + assert_eq!( + *reads.lock().unwrap(), + [ + Some(Duration::ZERO), + Some(Duration::ZERO), + Some(Duration::ZERO), + Some(Duration::from_millis(10)), + ] + ); +} + #[cfg(windows)] const REDIRECTED_CONSOLE_BYTES: &[u8] = b"nvx-raw:\xff\x80\xc3"; diff --git a/nvx-rs/src/host/cli/session.rs b/nvx-rs/src/host/cli/session.rs index 4e0a6aabb..ca9cd8f7a 100644 --- a/nvx-rs/src/host/cli/session.rs +++ b/nvx-rs/src/host/cli/session.rs @@ -63,6 +63,75 @@ const SENDING: u8 = 1; /// See [`SENDING`]. const RECEIVING: u8 = 2; +/// Scoped foreground cancellation, including I/O already pending on its verified connection. +#[derive(Clone, Default)] +pub(super) struct AttachmentCancellation { + /// Whether forwarding ended before the attachment did. + cancelled: Arc, + /// A duplicate of this attachment's connection, never another endpoint or process. + #[cfg(any(target_os = "linux", windows))] + transport: Arc>>, +} + +impl AttachmentCancellation { + /// Refuses new work after the foreground owner cancelled attachment. + fn check(&self) -> io::Result<()> { + if self.cancelled.load(Ordering::SeqCst) { + Err(io::Error::other("foreground attachment cancelled")) + } else { + Ok(()) + } + } + + /// Retains only the already verified connection before the handshake may use it. + fn register(&self, transport: &dyn Transport) -> io::Result<()> { + #[cfg(any(target_os = "linux", windows))] + { + let handle = transport.try_clone_handle()?.ok_or_else(|| { + io::Error::new(io::ErrorKind::Unsupported, "attachment I/O cannot be cancelled") + })?; + *self.transport.lock().expect("attachment lock poisoned") = Some(handle); + } + #[cfg(not(any(target_os = "linux", windows)))] + let _ = transport; + self.check() + } + + /// Ends pending I/O; repeated calls also cover cancellation racing with an I/O submission. + #[allow(unsafe_code)] + pub(super) fn cancel(&self) -> io::Result<()> { + self.cancelled.store(true, Ordering::SeqCst); + #[cfg(any(target_os = "linux", windows))] + if let Some(handle) = &*self.transport.lock().expect("attachment lock poisoned") { + #[cfg(target_os = "linux")] + { + use std::os::fd::AsRawFd; + use nix::sys::socket::{Shutdown, shutdown}; + + match shutdown(handle.as_raw_fd(), Shutdown::Both) { + Ok(()) | Err(nix::errno::Errno::ENOTCONN) => {} + Err(error) => return Err(error.into()), + } + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::ERROR_NOT_FOUND; + use windows_sys::Win32::System::IO::CancelIoEx; + + // SAFETY: the retained handle belongs only to this attachment's connection. + if unsafe { CancelIoEx(handle.as_raw_handle().cast(), std::ptr::null()) } == 0 { + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_NOT_FOUND as i32) { + return Err(error); + } + } + } + } + Ok(()) + } +} + /// What the transport observed of the failure of a transfer. #[derive(Default)] struct Probe { @@ -72,6 +141,8 @@ struct Probe { closed: AtomicBool, /// Whether Ctrl-C ended a wait. interrupted: AtomicBool, + /// The foreground owner's cancellation, absent for ordinary sessions. + cancellation: Option, } /// A transport that records what [`Probe`] reports, and waits in slices that Ctrl-C can end. @@ -111,6 +182,9 @@ impl Transport for ProbedTransport { if platform::interrupted() { return Err(self.interrupted()); } + if let Some(cancellation) = &self.probe.cancellation { + cancellation.check()?; + } let left = deadline .map(|deadline| deadline.saturating_duration_since(std::time::Instant::now())); let slice = left.map_or(POLL_SLICE, |left| left.min(POLL_SLICE)); @@ -139,6 +213,9 @@ impl Transport for ProbedTransport { fn write_all(&mut self, data: &[u8], timeout: Option) -> io::Result<()> { self.probe.direction.store(SENDING, Ordering::SeqCst); + if let Some(cancellation) = &self.probe.cancellation { + cancellation.check()?; + } // The platform's transports bound the whole write by `timeout`, however slowly the // endpoint drains it. match self.inner.write_all(data, timeout) { @@ -205,7 +282,7 @@ impl Session { capability: &[u8; CAPABILITY_LEN], timeout: f64, ) -> Result { - Self::connect_unless_exited(endpoint, pid, capability, timeout, || false) + Self::connect_unless_exited(endpoint, pid, capability, timeout, || false, None) } /// Connects to `endpoint`, which `launched`, the OpenVMM process that a start launched, @@ -221,11 +298,33 @@ impl Session { timeout: f64, ) -> Result { let pid = launched.id(); - Self::connect_unless_exited(endpoint, pid, capability, timeout, || { - launched - .wait_timeout(Duration::ZERO) - .is_ok_and(|status| status.is_some()) - }) + Self::connect_unless_exited( + endpoint, + pid, + capability, + timeout, + || launched.wait_timeout(Duration::ZERO).is_ok_and(|status| status.is_some()), + None, + ) + } + + /// Connects an owned foreground launch whose output owner can cancel both wait phases. + pub(super) fn connect_launched_cancellable( + endpoint: &str, + launched: &mut Launched, + capability: &[u8; CAPABILITY_LEN], + timeout: f64, + cancellation: AttachmentCancellation, + ) -> Result { + let pid = launched.id(); + Self::connect_unless_exited( + endpoint, + pid, + capability, + timeout, + || launched.wait_timeout(Duration::ZERO).is_ok_and(|status| status.is_some()), + Some(cancellation), + ) } /// Connects as [`connect`](Self::connect) does, but fails as soon as `exited`, which it calls @@ -238,10 +337,14 @@ impl Session { capability: &[u8; CAPABILITY_LEN], timeout: f64, mut exited: impl FnMut() -> bool, + cancellation: Option, ) -> Result { let deadline = Deadline::after(timeout); let transport = loop { check_interrupt()?; + if let Some(cancellation) = &cancellation { + cancellation.check().map_err(Exit::from_os_error)?; + } let slice = deadline .remaining() .map_or(POLL_SLICE, |left| left.min(POLL_SLICE)); @@ -272,7 +375,7 @@ impl Session { } } }; - Self::attach(transport, capability, timeout) + Self::attach(transport, capability, timeout, cancellation) } /// Attaches over a connected `transport` with `capability` within `timeout` seconds, as the @@ -281,8 +384,12 @@ impl Session { transport: Box, capability: &[u8; CAPABILITY_LEN], timeout: f64, + cancellation: Option, ) -> Result { - let probe = Arc::new(Probe::default()); + if let Some(cancellation) = &cancellation { + cancellation.register(transport.as_ref()).map_err(Exit::from_os_error)?; + } + let probe = Arc::new(Probe { cancellation, ..Probe::default() }); let transport = Box::new(ProbedTransport { inner: transport, probe: Arc::clone(&probe), diff --git a/nvx-rs/src/host/cli/session/tests.rs b/nvx-rs/src/host/cli/session/tests.rs index 270f75a55..1eedf831f 100644 --- a/nvx-rs/src/host/cli/session/tests.rs +++ b/nvx-rs/src/host/cli/session/tests.rs @@ -164,7 +164,7 @@ fn session_with( guest.accept(); serve(&mut guest); }); - let session = Session::attach(Box::new(host), &CAPABILITY, 5.0).unwrap(); + let session = Session::attach(Box::new(host), &CAPABILITY, 5.0, None).unwrap(); (session, worker) } @@ -611,7 +611,7 @@ fn operations_bound_their_requests_by_their_deadlines() { // response, so attach, ping, exec, and stop each write within the time in which they then // read: no transfer gets more than what is left of its operation's timeout. let (transport, transfers) = Recording::new(None); - let mut session = Session::attach(Box::new(transport), &CAPABILITY, 7.0).unwrap(); + let mut session = Session::attach(Box::new(transport), &CAPABILITY, 7.0, None).unwrap(); let mut operations = vec![(7.0, transfers.lock().unwrap().split_off(0))]; session.ping(5.0).unwrap(); operations.push((5.0, transfers.lock().unwrap().split_off(0))); @@ -646,7 +646,7 @@ fn failed_transfer_closes_the_session() { ] { // The attachment's write and read succeed. let (transport, transfers) = Recording::new(Some((failure, 2))); - let mut session = Session::attach(Box::new(transport), &CAPABILITY, 5.0).unwrap(); + let mut session = Session::attach(Box::new(transport), &CAPABILITY, 5.0, None).unwrap(); assert_eq!( self::failure(session.ping(5.0)), (ErrorCode::TimedOut, expected.to_owned()) @@ -679,7 +679,7 @@ fn request_to_a_stalled_endpoint_times_out_and_closes_the_session() { // The endpoint reads nothing more until the client gives up. let _ = released.recv_timeout(Duration::from_secs(10)); }); - let mut session = Session::attach(Box::new(host), &CAPABILITY, 5.0).unwrap(); + let mut session = Session::attach(Box::new(host), &CAPABILITY, 5.0, None).unwrap(); let started = Instant::now(); assert_eq!( message(session.exec(&request(&["/bin/true"]), 0.25)), @@ -737,10 +737,17 @@ fn check_waits_for_openvmm_that_exits(endpoint: &str) { let mut checks = 0; let started = Instant::now(); let failure = - Session::connect_unless_exited(endpoint, std::process::id(), &CAPABILITY, 60.0, || { - checks += 1; - checks == 3 - }) + Session::connect_unless_exited( + endpoint, + std::process::id(), + &CAPABILITY, + 60.0, + || { + checks += 1; + checks == 3 + }, + None, + ) .err() .unwrap(); assert!( @@ -753,10 +760,17 @@ fn check_waits_for_openvmm_that_exits(endpoint: &str) { let mut checks = 0; let started = Instant::now(); let failure = - Session::connect_unless_exited(endpoint, std::process::id(), &CAPABILITY, 0.2, || { - checks += 1; - false - }) + Session::connect_unless_exited( + endpoint, + std::process::id(), + &CAPABILITY, + 0.2, + || { + checks += 1; + false + }, + None, + ) .err() .unwrap(); assert!(started.elapsed() >= Duration::from_millis(200)); @@ -767,6 +781,73 @@ fn check_waits_for_openvmm_that_exits(endpoint: &str) { assert_ne!(checks, 0); } +#[cfg(any(target_os = "linux", windows))] +fn check_forward_failure_during_connect(endpoint: &str) { + let (attempted, attempt) = mpsc::sync_channel(1); + let started = Instant::now(); + let result = super::super::run::attach_with_output( + |cancellation| { + Session::connect_unless_exited( + endpoint, + std::process::id(), + &CAPABILITY, + 60.0, + || { + let _ = attempted.try_send(()); + false + }, + Some(cancellation), + ) + }, + || { + attempt.recv_timeout(Duration::from_secs(5)).unwrap(); + Err(Exit::error(ErrorCode::Io, "original forwarding failure")) + }, + ); + assert_eq!( + result.err().unwrap().message(), + Some("original forwarding failure") + ); + assert!(started.elapsed() < Duration::from_secs(2)); +} + +#[cfg(any(target_os = "linux", windows))] +fn check_forward_failure_during_handshake( + client: Box, + mut server: impl io::Read + Send + 'static, +) { + let (waiting, wait) = mpsc::sync_channel(0); + let (release, released) = mpsc::channel(); + let peer = thread::spawn(move || { + let mut request = vec![0; OUTER_HEADER_LEN + CAPABILITY_LEN]; + server.read_exact(&mut request).unwrap(); + assert_eq!( + decode_outer_header(request[..OUTER_HEADER_LEN].try_into().unwrap()) + .unwrap() + .record_type, + OUTER_HOST_ATTACH + ); + waiting.send(()).unwrap(); + released.recv_timeout(Duration::from_secs(5)).unwrap(); + }); + let started = Instant::now(); + let result = super::super::run::attach_with_output( + move |cancellation| Session::attach(client, &CAPABILITY, 60.0, Some(cancellation)), + || { + wait.recv_timeout(Duration::from_secs(5)).unwrap(); + Err(Exit::error(ErrorCode::Io, "original forwarding failure")) + }, + ); + let elapsed = started.elapsed(); + release.send(()).unwrap(); + peer.join().unwrap(); + assert_eq!( + result.err().unwrap().message(), + Some("original forwarding failure") + ); + assert!(elapsed < Duration::from_secs(2), "{elapsed:?}"); +} + #[test] fn response_that_does_not_arrive_times_out() { let (mut session, worker) = session_with(|guest| { @@ -1041,6 +1122,7 @@ mod endpoints { checks += 1; checks == 2 }, + None, ) .err() .expect("unsupported peer verification must fail before authentication"); @@ -1057,6 +1139,52 @@ mod endpoints { check_waits_for_openvmm_that_exits(path.to_str().unwrap()); } + #[test] + #[cfg(target_os = "linux")] + fn forward_failure_cancels_the_connection_before_its_timeout() { + let directory = tempfile::tempdir().unwrap(); + check_forward_failure_during_connect( + directory.path().join("control.sock").to_str().unwrap(), + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn forward_failure_cancels_a_handshake_while_the_peer_remains_open() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("control.sock"); + let listener = UnixListener::bind(&path).unwrap(); + let client = platform::connect_endpoint( + path.to_str().unwrap(), + std::process::id(), + Duration::from_secs(5), + ) + .unwrap(); + let (server, _) = listener.accept().unwrap(); + check_forward_failure_during_handshake(client, server); + } + + #[test] + #[cfg(target_os = "linux")] + fn zero_console_polls_do_not_install_a_blocking_socket_timeout() { + let (stream, mut server) = UnixStream::pair().unwrap(); + let inspect = stream.try_clone().unwrap(); + let mut client = platform::transport_from_handle(stream.into()).unwrap(); + for _ in 0..3 { + assert_eq!( + client.read(&mut [0], Some(Duration::ZERO)).unwrap_err().kind(), + io::ErrorKind::TimedOut + ); + assert_eq!(inspect.read_timeout().unwrap(), None); + } + server.write_all(b"c").unwrap(); + let mut byte = [0]; + assert_eq!(client.read(&mut byte, Some(Duration::ZERO)).unwrap(), 1); + assert_eq!(&byte, b"c"); + drop(server); + assert_eq!(client.read(&mut byte, Some(Duration::ZERO)).unwrap(), 0); + } + #[test] #[cfg(target_os = "linux")] fn writes_to_a_slow_endpoint_end_by_their_deadline() { @@ -1227,6 +1355,55 @@ mod endpoints { check_waits_for_openvmm_that_exits(r"\\.\pipe\nvx-cli-session-test-exited"); } + #[test] + fn forward_failure_cancels_the_connection_before_its_timeout() { + check_forward_failure_during_connect(r"\\.\pipe\nvx-cli-session-test-cancelled"); + } + + #[test] + fn forward_failure_cancels_a_handshake_while_the_peer_remains_open() { + let (name, server) = create_pipe(); + let accepted = thread::spawn(move || { + accept(&server); + server + }); + let client = platform::connect_endpoint( + &name.replace('\\', "/"), + std::process::id(), + Duration::from_secs(5), + ) + .unwrap(); + check_forward_failure_during_handshake(client, accepted.join().unwrap()); + } + + #[test] + fn zero_console_polls_preserve_ready_bytes_and_peer_exit() { + let (name, server) = create_pipe(); + let accepted = thread::spawn(move || { + accept(&server); + server + }); + let mut client = platform::connect_endpoint( + &name.replace('\\', "/"), + std::process::id(), + Duration::from_secs(5), + ) + .unwrap(); + let mut server = accepted.join().unwrap(); + for _ in 0..3 { + assert_eq!( + client.read(&mut [0], Some(Duration::ZERO)).unwrap_err().kind(), + io::ErrorKind::TimedOut + ); + } + server.write_all(b"c").unwrap(); + let mut byte = [0]; + assert_eq!(client.read(&mut byte, Some(Duration::ZERO)).unwrap(), 1); + assert_eq!(&byte, b"c"); + drop(server); + assert_eq!(client.read(&mut byte, Some(Duration::ZERO)).unwrap(), 0); + } + #[test] fn writes_to_a_slow_endpoint_end_by_their_deadline() { let (name, server) = create_pipe(); diff --git a/nvx-rs/src/host/platform/linux/endpoint.rs b/nvx-rs/src/host/platform/linux/endpoint.rs index 1b63bd9b6..7b407462c 100644 --- a/nvx-rs/src/host/platform/linux/endpoint.rs +++ b/nvx-rs/src/host/platform/linux/endpoint.rs @@ -11,7 +11,7 @@ use std::thread; use std::time::{Duration, Instant}; use nix::poll::{PollFd, PollFlags, PollTimeout, poll}; -use nix::sys::socket::{getsockopt, sockopt::PeerPidfd}; +use nix::sys::socket::{MsgFlags, getsockopt, recv, sockopt::PeerPidfd}; use crate::host::platform::{Transport, TransportHandle, time_left}; use crate::host::sys; @@ -339,6 +339,12 @@ impl Transport for UnixTransport { } fn read(&mut self, buffer: &mut [u8], timeout: Option) -> io::Result { + if timeout == Some(Duration::ZERO) { + return match recv(self.stream.as_raw_fd(), buffer, MsgFlags::MSG_DONTWAIT) { + Err(nix::errno::Errno::EINTR) => Err(io::ErrorKind::TimedOut.into()), + result => result.map_err(io::Error::from).map_err(map_timeout), + }; + } self.stream.set_read_timeout(socket_timeout(timeout))?; loop { match self.stream.read(buffer) { diff --git a/nvx-rs/src/host/platform/windows/endpoint.rs b/nvx-rs/src/host/platform/windows/endpoint.rs index 25741febf..dae07c09b 100644 --- a/nvx-rs/src/host/platform/windows/endpoint.rs +++ b/nvx-rs/src/host/platform/windows/endpoint.rs @@ -348,7 +348,7 @@ fn overlapped( let wait = timeout.map_or(INFINITE, |timeout| { u32::try_from(timeout.as_millis()) .unwrap_or(INFINITE - 1) - .clamp(1, INFINITE - 1) + .min(INFINITE - 1) }); // SAFETY: the event belongs to the caller. let signaled = unsafe { WaitForSingleObject(overlapped.hEvent, wait) } == WAIT_OBJECT_0; diff --git a/nvx-rs/src/testing/fake_openvmm.rs b/nvx-rs/src/testing/fake_openvmm.rs index de777d797..9309d4f75 100644 --- a/nvx-rs/src/testing/fake_openvmm.rs +++ b/nvx-rs/src/testing/fake_openvmm.rs @@ -54,6 +54,7 @@ //! `fake_report_before_console=1` writes a run's report before console output or its boot delay. //! `fake_boot_diagnostics=1` emits console output and a binary log before control attachment. //! `fake_refuse_attach=1` refuses attachment after the endpoint has accepted its client. +//! `fake_foreground_gate=console|connect|handshake` synchronizes foreground wait regressions. //! `fake_guest_features=MASK` advertises the decimal feature mask `MASK` instead of the current //! guest's, `fake_legacy_guest=1` refuses the features request like a guest agent that //! predates it, and `fake_refuse_maps=1` fails every `MAPS` request like a guest that cannot @@ -395,6 +396,8 @@ struct Options { boot_diagnostics: bool, /// Whether the control handshake refuses even the correct credential. refuse_attach: bool, + /// The wait phase and private directory of a synchronized foreground-failure fixture. + foreground_gate: Option<(String, PathBuf)>, /// Artificial delay before the fake OpenVMM crashes, when requested. crash_after: Option, /// Whether stop requests are deliberately left unanswered. @@ -523,6 +526,10 @@ pub fn main() -> ExitCode { } else { None }; + if let Err(error) = foreground_gate(&options, "console") { + eprintln!("{program}: {error}"); + return ExitCode::from(1); + } let mut console = match boot_console(&options) { Ok(console) => console, Err(error) => { @@ -577,6 +584,23 @@ pub fn main() -> ExitCode { } }, }; + if let Some((_, directory)) = options.foreground_gate.clone() { + thread::spawn(move || { + while !directory.join("release").is_file() { + thread::sleep(Duration::from_millis(5)); + } + if let Err(error) = console + .write_all(b"gated foreground output\n") + .and_then(|()| console.flush()) + { + eprintln!("gated console output failed: {error}"); + } + }); + } + if let Err(error) = foreground_gate(&options, "connect") { + eprintln!("{program}: {error}"); + return ExitCode::from(1); + } thread::sleep(options.boot_delay); if let Some(after) = options.crash_after { thread::spawn(move || { @@ -593,6 +617,21 @@ pub fn main() -> ExitCode { } } +/// Holds one named fixture phase until the CLI's verified stop terminates this fake. +fn foreground_gate(options: &Options, phase: &str) -> io::Result<()> { + if let Some((requested, directory)) = &options.foreground_gate + && requested == phase + { + let record = serde_json::json!({"pid": std::process::id(), "phase": phase}); + fs::write(directory.join("phase.tmp"), serde_json::to_vec(&record)?)?; + fs::rename(directory.join("phase.tmp"), directory.join("phase.json"))?; + loop { + thread::park(); + } + } + Ok(()) +} + /// Returns `status` for [`main`] to return, or exits with it if `ExitCode` cannot hold it. /// /// Returning from `main`, rather than calling `std::process::exit`, lets the C runtime run its exit @@ -1779,6 +1818,16 @@ fn parse(program: &str, arguments: &[String]) -> Result { report_before_console: knob("fake_report_before_console") == Some("1"), boot_diagnostics: knob("fake_boot_diagnostics") == Some("1"), refuse_attach: knob("fake_refuse_attach") == Some("1"), + foreground_gate: knob("fake_foreground_gate") + .map(|phase| { + if !matches!(phase, "console" | "connect" | "handshake") { + return Err("fake_foreground_gate must be console, connect or handshake".to_owned()); + } + std::env::var_os("NVX_TEST_FOREGROUND_GATE_DIR") + .map(|directory| (phase.to_owned(), PathBuf::from(directory))) + .ok_or_else(|| "the foreground gate needs its private directory".to_owned()) + }) + .transpose()?, crash_after: millis("fake_crash_after_ms"), ignore_stop: knob("fake_ignore_stop") == Some("1"), exit_delay: millis("fake_exit_delay_ms").unwrap_or_default(), @@ -2869,6 +2918,10 @@ fn serve_client( let _ = stream.write_all(&control_record(OUTER_ERROR, &[0; 16], 0, &code)); return Flow::Continue; } + if let Err(error) = foreground_gate(options, "handshake") { + eprintln!("foreground handshake gate failed: {error}"); + return Flow::Continue; + } let mut instance = [0u8; 16]; while instance == [0; 16] { if getrandom::fill(&mut instance).is_err() { @@ -2968,7 +3021,7 @@ fn record_session(options: &Options) { } } -fn boot_console(options: &Options) -> io::Result> { +fn boot_console(options: &Options) -> io::Result> { let Some(endpoint) = &options.boot_console else { return Ok(Box::new(io::stdout())); }; diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index 56876f629..f402e583d 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -923,6 +923,179 @@ fn cold_terminal_runs_forward_raw_output_and_restore_the_callers_terminal() { } } +fn gated_native( + process: &mut std::process::Child, + directory: &std::path::Path, +) -> nvx::host::process::Process { + use std::time::{Duration, Instant}; + use nvx::host::process::{BootId, Opened, Process, ProcessRef}; + + let deadline = Instant::now() + Duration::from_secs(10); + let marker = directory.join("phase.json"); + while !marker.is_file() { + assert!(process.try_wait().unwrap().is_none(), "the CLI exited before its gate"); + if Instant::now() >= deadline { + process.kill().unwrap(); + process.wait().unwrap(); + panic!("the fake did not reach its foreground gate"); + } + std::thread::sleep(Duration::from_millis(5)); + } + let record: serde_json::Value = + serde_json::from_slice(&std::fs::read(marker).unwrap()).unwrap(); + let pid = record["pid"].as_u64().unwrap().try_into().unwrap(); + let reference = ProcessRef { + pid, + start_time: nvx::host::platform::process_start_time(pid).unwrap(), + boot: BootId::current().unwrap(), + }; + match Process::open(&reference).unwrap() { + Opened::Running(process) => process, + Opened::Exited(_) => panic!("the gated fake already exited"), + } +} + +fn foreground_exits_promptly( + process: &mut std::process::Child, + native: &nvx::host::process::Process, +) { + use std::time::{Duration, Instant}; + + let deadline = Instant::now() + Duration::from_secs(3); + while process.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + native.kill().unwrap(); + assert!(native.wait(Instant::now() + Duration::from_secs(2)).unwrap().is_some()); + process.kill().unwrap(); + process.wait().unwrap(); + panic!("foreground cancellation waited for an attachment timeout"); + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!(native.wait(Instant::now() + Duration::from_secs(1)).unwrap().is_some()); +} + +#[test] +fn forwarding_failure_stops_the_verified_vm_during_connect_and_handshake() { + use std::io::{BufRead, BufReader}; + use std::process::Stdio; + + for phase in ["connect", "handshake"] { + let root = Root::new(); + let gate = root.below("build/foreground-gate"); + std::fs::create_dir(&gate).unwrap(); + let temporary = tempfile::Builder::new().prefix("nf3-").tempdir().unwrap(); + let mut process = root.command(&[ + "run", "--cmdline", + &format!("nvx_mode=managed nvx_identity=1001:1001 fake_foreground_gate={phase}"), + ]) + .env("NVX_TEST_FOREGROUND_GATE_DIR", &gate) + .env("TMPDIR", temporary.path()) + .env("TMP", temporary.path()) + .env("TEMP", temporary.path()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn().unwrap(); + let mut stdout = BufReader::new(process.stdout.take().unwrap()); + let mut command = String::new(); + stdout.read_line(&mut command).unwrap(); + assert!(command.starts_with(">> "), "{command}"); + let native = gated_native(&mut process, &gate); + drop(stdout); + std::fs::write(gate.join("release"), b"go").unwrap(); + foreground_exits_promptly(&mut process, &native); + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("error[io]:"), "{stderr}"); + assert!(!stderr.contains("timed out"), "{stderr}"); + assert_eq!(std::fs::read_dir(temporary.path()).unwrap().count(), 0); + } +} + +#[cfg(target_os = "linux")] +#[test] +#[allow(unsafe_code)] +fn terminal_ctrl_c_cancels_boot_and_handshake_and_restores_the_terminal() { + use std::fs::File; + use std::io::Write; + use std::os::unix::process::CommandExt; + use std::process::Stdio; + use std::time::{Duration, Instant}; + use nix::pty::openpty; + use nix::sys::termios::{LocalFlags, tcgetattr}; + + for phase in ["console", "handshake"] { + let root = Root::new(); + let gate = root.below("build/foreground-gate"); + std::fs::create_dir(&gate).unwrap(); + let temporary = tempfile::Builder::new().prefix("nf3-").tempdir().unwrap(); + let terminal = openpty(None, None).unwrap(); + let original = tcgetattr(&terminal.slave).unwrap(); + let mut command = root.command(&[ + "run", "--cmdline", + &format!("nvx_mode=managed nvx_identity=1001:1001 fake_foreground_gate={phase}"), + ]); + command.env("NVX_TEST_FOREGROUND_GATE_DIR", &gate) + .env("TMPDIR", temporary.path()) + .stdin(Stdio::from(terminal.slave.try_clone().unwrap())) + .stdout(Stdio::from(terminal.slave.try_clone().unwrap())) + .stderr(Stdio::piped()); + // SAFETY: the isolated child's pre-exec hook uses only session/terminal syscalls. + unsafe { + command.pre_exec(|| { + nix::unistd::setsid()?; + if nix::libc::ioctl(0, nix::libc::TIOCSCTTY, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut process = command.spawn().unwrap(); + let native = gated_native(&mut process, &gate); + let deadline = Instant::now() + Duration::from_secs(2); + let raw = loop { + let mode = tcgetattr(&terminal.slave).unwrap(); + if !mode.local_flags.intersects(LocalFlags::ICANON | LocalFlags::ECHO) { + break mode; + } + assert!(Instant::now() < deadline, "the terminal never became raw"); + std::thread::sleep(Duration::from_millis(5)); + }; + let mut keyboard = File::from(terminal.master); + keyboard.write_all(b"\x03").unwrap(); + foreground_exits_promptly(&mut process, &native); + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(130), "{output:?}"); + assert!(raw.local_flags.contains(LocalFlags::ISIG)); + assert!(!String::from_utf8_lossy(&output.stderr).contains("error[io]:")); + let restored = tcgetattr(&terminal.slave).unwrap(); + assert_eq!(restored.input_flags, original.input_flags); + assert_eq!(restored.output_flags, original.output_flags); + assert_eq!(restored.local_flags, original.local_flags); + assert_eq!(restored.control_flags, original.control_flags); + assert_eq!(restored.control_chars, original.control_chars); + assert_eq!(std::fs::read_dir(temporary.path()).unwrap().count(), 0); + } +} + +#[test] +fn a_ctrl_c_byte_in_redirected_stdin_is_not_terminal_cancellation() { + use std::io::Write; + use std::process::Stdio; + + let root = Root::new(); + let mut process = root.command(&[ + "run", "--cmdline", "fake_boot_delay_ms=100 fake_exit_status=7", + ]) + .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()) + .spawn().unwrap(); + process.stdin.take().unwrap().write_all(b"\x03").unwrap(); + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(7), "{output:?}"); +} + #[test] fn cold_runs_refuse_existing_and_missing_parent_reports_before_launch() { let root = Root::new(); From 452ed07eef60f63c07f51d935bb7725cc20728f7 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 15:18:00 -0800 Subject: [PATCH 15/27] test: exercise raw-terminal foreground cancellation through private terminals Add actual ConPTY and PTY keyboard input, held-process cleanup receipts, and the explicitly approved default field in the compiled dependency example. Retain the separate public-spawn API integration blocker. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80075afd-7139-47a9-90c2-923b6b22bda5 --- nvx-rs/examples/external-launch.rs | 1 + nvx-rs/src/host/cli/run.rs | 3 +- nvx-rs/src/host/cli/session.rs | 28 ++- nvx-rs/src/host/cli/session/tests.rs | 64 +++--- nvx-rs/src/testing/fake_openvmm.rs | 4 +- nvx-rs/tests/cli/one_shot.rs | 291 +++++++++++++++++++++++++-- 6 files changed, 333 insertions(+), 58 deletions(-) diff --git a/nvx-rs/examples/external-launch.rs b/nvx-rs/examples/external-launch.rs index 634d5e548..12060e616 100644 --- a/nvx-rs/examples/external-launch.rs +++ b/nvx-rs/examples/external-launch.rs @@ -65,6 +65,7 @@ fn main() -> Result<(), Box> { console: ConsoleAttachment::Listener, boot: Some(Boot { command_line: "quiet loglevel=0".to_owned(), + memory_capacity_mib: None, capture: None, }), }; diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index cf5027707..e52dd30ea 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -772,8 +772,7 @@ impl TerminalGuard { #[allow(unsafe_code)] fn enable_interrupts() -> io::Result<()> { use windows_sys::Win32::System::Console::{ - ENABLE_PROCESSED_INPUT, GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE, - SetConsoleMode, + ENABLE_PROCESSED_INPUT, GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE, SetConsoleMode, }; let mut mode = 0; diff --git a/nvx-rs/src/host/cli/session.rs b/nvx-rs/src/host/cli/session.rs index ca9cd8f7a..e7950327d 100644 --- a/nvx-rs/src/host/cli/session.rs +++ b/nvx-rs/src/host/cli/session.rs @@ -88,7 +88,10 @@ impl AttachmentCancellation { #[cfg(any(target_os = "linux", windows))] { let handle = transport.try_clone_handle()?.ok_or_else(|| { - io::Error::new(io::ErrorKind::Unsupported, "attachment I/O cannot be cancelled") + io::Error::new( + io::ErrorKind::Unsupported, + "attachment I/O cannot be cancelled", + ) })?; *self.transport.lock().expect("attachment lock poisoned") = Some(handle); } @@ -105,8 +108,8 @@ impl AttachmentCancellation { if let Some(handle) = &*self.transport.lock().expect("attachment lock poisoned") { #[cfg(target_os = "linux")] { - use std::os::fd::AsRawFd; use nix::sys::socket::{Shutdown, shutdown}; + use std::os::fd::AsRawFd; match shutdown(handle.as_raw_fd(), Shutdown::Both) { Ok(()) | Err(nix::errno::Errno::ENOTCONN) => {} @@ -303,7 +306,11 @@ impl Session { pid, capability, timeout, - || launched.wait_timeout(Duration::ZERO).is_ok_and(|status| status.is_some()), + || { + launched + .wait_timeout(Duration::ZERO) + .is_ok_and(|status| status.is_some()) + }, None, ) } @@ -322,7 +329,11 @@ impl Session { pid, capability, timeout, - || launched.wait_timeout(Duration::ZERO).is_ok_and(|status| status.is_some()), + || { + launched + .wait_timeout(Duration::ZERO) + .is_ok_and(|status| status.is_some()) + }, Some(cancellation), ) } @@ -387,9 +398,14 @@ impl Session { cancellation: Option, ) -> Result { if let Some(cancellation) = &cancellation { - cancellation.register(transport.as_ref()).map_err(Exit::from_os_error)?; + cancellation + .register(transport.as_ref()) + .map_err(Exit::from_os_error)?; } - let probe = Arc::new(Probe { cancellation, ..Probe::default() }); + let probe = Arc::new(Probe { + cancellation, + ..Probe::default() + }); let transport = Box::new(ProbedTransport { inner: transport, probe: Arc::clone(&probe), diff --git a/nvx-rs/src/host/cli/session/tests.rs b/nvx-rs/src/host/cli/session/tests.rs index 1eedf831f..a892a36e7 100644 --- a/nvx-rs/src/host/cli/session/tests.rs +++ b/nvx-rs/src/host/cli/session/tests.rs @@ -736,20 +736,19 @@ fn check_writes_to_a_slow_endpoint( fn check_waits_for_openvmm_that_exits(endpoint: &str) { let mut checks = 0; let started = Instant::now(); - let failure = - Session::connect_unless_exited( - endpoint, - std::process::id(), - &CAPABILITY, - 60.0, - || { - checks += 1; - checks == 3 - }, - None, - ) - .err() - .unwrap(); + let failure = Session::connect_unless_exited( + endpoint, + std::process::id(), + &CAPABILITY, + 60.0, + || { + checks += 1; + checks == 3 + }, + None, + ) + .err() + .unwrap(); assert!( started.elapsed() < Duration::from_secs(30), "{:?}", @@ -759,20 +758,19 @@ fn check_waits_for_openvmm_that_exits(endpoint: &str) { assert_eq!(checks, 3); let mut checks = 0; let started = Instant::now(); - let failure = - Session::connect_unless_exited( - endpoint, - std::process::id(), - &CAPABILITY, - 0.2, - || { - checks += 1; - false - }, - None, - ) - .err() - .unwrap(); + let failure = Session::connect_unless_exited( + endpoint, + std::process::id(), + &CAPABILITY, + 0.2, + || { + checks += 1; + false + }, + None, + ) + .err() + .unwrap(); assert!(started.elapsed() >= Duration::from_millis(200)); assert_eq!( failure.message().unwrap(), @@ -1172,7 +1170,10 @@ mod endpoints { let mut client = platform::transport_from_handle(stream.into()).unwrap(); for _ in 0..3 { assert_eq!( - client.read(&mut [0], Some(Duration::ZERO)).unwrap_err().kind(), + client + .read(&mut [0], Some(Duration::ZERO)) + .unwrap_err() + .kind(), io::ErrorKind::TimedOut ); assert_eq!(inspect.read_timeout().unwrap(), None); @@ -1392,7 +1393,10 @@ mod endpoints { let mut server = accepted.join().unwrap(); for _ in 0..3 { assert_eq!( - client.read(&mut [0], Some(Duration::ZERO)).unwrap_err().kind(), + client + .read(&mut [0], Some(Duration::ZERO)) + .unwrap_err() + .kind(), io::ErrorKind::TimedOut ); } diff --git a/nvx-rs/src/testing/fake_openvmm.rs b/nvx-rs/src/testing/fake_openvmm.rs index 9309d4f75..d3e00d36e 100644 --- a/nvx-rs/src/testing/fake_openvmm.rs +++ b/nvx-rs/src/testing/fake_openvmm.rs @@ -1821,7 +1821,9 @@ fn parse(program: &str, arguments: &[String]) -> Result { foreground_gate: knob("fake_foreground_gate") .map(|phase| { if !matches!(phase, "console" | "connect" | "handshake") { - return Err("fake_foreground_gate must be console, connect or handshake".to_owned()); + return Err( + "fake_foreground_gate must be console, connect or handshake".to_owned() + ); } std::env::var_os("NVX_TEST_FOREGROUND_GATE_DIR") .map(|directory| (phase.to_owned(), PathBuf::from(directory))) diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index f402e583d..1663ee3c7 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -927,13 +927,16 @@ fn gated_native( process: &mut std::process::Child, directory: &std::path::Path, ) -> nvx::host::process::Process { - use std::time::{Duration, Instant}; use nvx::host::process::{BootId, Opened, Process, ProcessRef}; + use std::time::{Duration, Instant}; let deadline = Instant::now() + Duration::from_secs(10); let marker = directory.join("phase.json"); while !marker.is_file() { - assert!(process.try_wait().unwrap().is_none(), "the CLI exited before its gate"); + assert!( + process.try_wait().unwrap().is_none(), + "the CLI exited before its gate" + ); if Instant::now() >= deadline { process.kill().unwrap(); process.wait().unwrap(); @@ -965,14 +968,24 @@ fn foreground_exits_promptly( while process.try_wait().unwrap().is_none() { if Instant::now() >= deadline { native.kill().unwrap(); - assert!(native.wait(Instant::now() + Duration::from_secs(2)).unwrap().is_some()); + assert!( + native + .wait(Instant::now() + Duration::from_secs(2)) + .unwrap() + .is_some() + ); process.kill().unwrap(); process.wait().unwrap(); panic!("foreground cancellation waited for an attachment timeout"); } std::thread::sleep(Duration::from_millis(5)); } - assert!(native.wait(Instant::now() + Duration::from_secs(1)).unwrap().is_some()); + assert!( + native + .wait(Instant::now() + Duration::from_secs(1)) + .unwrap() + .is_some() + ); } #[test] @@ -985,10 +998,12 @@ fn forwarding_failure_stops_the_verified_vm_during_connect_and_handshake() { let gate = root.below("build/foreground-gate"); std::fs::create_dir(&gate).unwrap(); let temporary = tempfile::Builder::new().prefix("nf3-").tempdir().unwrap(); - let mut process = root.command(&[ - "run", "--cmdline", - &format!("nvx_mode=managed nvx_identity=1001:1001 fake_foreground_gate={phase}"), - ]) + let mut process = root + .command(&[ + "run", + "--cmdline", + &format!("nvx_mode=managed nvx_identity=1001:1001 fake_foreground_gate={phase}"), + ]) .env("NVX_TEST_FOREGROUND_GATE_DIR", &gate) .env("TMPDIR", temporary.path()) .env("TMP", temporary.path()) @@ -996,7 +1011,8 @@ fn forwarding_failure_stops_the_verified_vm_during_connect_and_handshake() { .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .spawn().unwrap(); + .spawn() + .unwrap(); let mut stdout = BufReader::new(process.stdout.take().unwrap()); let mut command = String::new(); stdout.read_line(&mut command).unwrap(); @@ -1011,6 +1027,11 @@ fn forwarding_failure_stops_the_verified_vm_during_connect_and_handshake() { assert!(stderr.contains("error[io]:"), "{stderr}"); assert!(!stderr.contains("timed out"), "{stderr}"); assert_eq!(std::fs::read_dir(temporary.path()).unwrap().count(), 0); + println!("NVX-FORWARD-FAILURE {}", serde_json::json!({ + "phase":phase, "native":native.reference(), "held_native_exited":true, + "status":1, "original_io_error":true, "private_directory_removed":true, + "completion_bound_seconds":3, + })); } } @@ -1018,13 +1039,13 @@ fn forwarding_failure_stops_the_verified_vm_during_connect_and_handshake() { #[test] #[allow(unsafe_code)] fn terminal_ctrl_c_cancels_boot_and_handshake_and_restores_the_terminal() { + use nix::pty::openpty; + use nix::sys::termios::{LocalFlags, tcgetattr}; use std::fs::File; use std::io::Write; use std::os::unix::process::CommandExt; use std::process::Stdio; use std::time::{Duration, Instant}; - use nix::pty::openpty; - use nix::sys::termios::{LocalFlags, tcgetattr}; for phase in ["console", "handshake"] { let root = Root::new(); @@ -1034,10 +1055,12 @@ fn terminal_ctrl_c_cancels_boot_and_handshake_and_restores_the_terminal() { let terminal = openpty(None, None).unwrap(); let original = tcgetattr(&terminal.slave).unwrap(); let mut command = root.command(&[ - "run", "--cmdline", + "run", + "--cmdline", &format!("nvx_mode=managed nvx_identity=1001:1001 fake_foreground_gate={phase}"), ]); - command.env("NVX_TEST_FOREGROUND_GATE_DIR", &gate) + command + .env("NVX_TEST_FOREGROUND_GATE_DIR", &gate) .env("TMPDIR", temporary.path()) .stdin(Stdio::from(terminal.slave.try_clone().unwrap())) .stdout(Stdio::from(terminal.slave.try_clone().unwrap())) @@ -1057,7 +1080,10 @@ fn terminal_ctrl_c_cancels_boot_and_handshake_and_restores_the_terminal() { let deadline = Instant::now() + Duration::from_secs(2); let raw = loop { let mode = tcgetattr(&terminal.slave).unwrap(); - if !mode.local_flags.intersects(LocalFlags::ICANON | LocalFlags::ECHO) { + if !mode + .local_flags + .intersects(LocalFlags::ICANON | LocalFlags::ECHO) + { break mode; } assert!(Instant::now() < deadline, "the terminal never became raw"); @@ -1077,6 +1103,11 @@ fn terminal_ctrl_c_cancels_boot_and_handshake_and_restores_the_terminal() { assert_eq!(restored.control_flags, original.control_flags); assert_eq!(restored.control_chars, original.control_chars); assert_eq!(std::fs::read_dir(temporary.path()).unwrap().count(), 0); + println!("NVX-TERMINAL-CTRL-C {}", serde_json::json!({ + "phase":phase, "native":native.reference(), "terminal_keyboard_byte":3, + "raw_isig":true, "terminal_restored":true, "status":130, + "held_native_exited":true, "private_directory_removed":true, + })); } } @@ -1086,16 +1117,238 @@ fn a_ctrl_c_byte_in_redirected_stdin_is_not_terminal_cancellation() { use std::process::Stdio; let root = Root::new(); - let mut process = root.command(&[ - "run", "--cmdline", "fake_boot_delay_ms=100 fake_exit_status=7", - ]) - .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()) - .spawn().unwrap(); + let mut process = root + .command(&[ + "run", + "--cmdline", + "fake_boot_delay_ms=100 fake_exit_status=7", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); process.stdin.take().unwrap().write_all(b"\x03").unwrap(); let output = process.wait_with_output().unwrap(); assert_eq!(output.status.code(), Some(7), "{output:?}"); } +#[cfg(windows)] +#[test] +#[allow(unsafe_code)] +fn terminal_ctrl_c_cancels_boot_and_handshake_and_restores_the_terminal() { + use std::fs::File; + use std::io::{Read, Write}; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::{FromRawHandle, OwnedHandle}; + use std::time::{Duration, Instant}; + use windows_sys::Win32::Foundation::{HANDLE, WAIT_OBJECT_0}; + use windows_sys::Win32::System::Console::{ + COORD, ClosePseudoConsole, CreatePseudoConsole, HPCON, + }; + use windows_sys::Win32::System::Pipes::CreatePipe; + use windows_sys::Win32::System::Threading::{ + CREATE_UNICODE_ENVIRONMENT, CreateProcessW, DeleteProcThreadAttributeList, + EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess, InitializeProcThreadAttributeList, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, PROCESS_INFORMATION, STARTUPINFOEXW, + UpdateProcThreadAttribute, WaitForSingleObject, + }; + + struct Console(HPCON); + impl Drop for Console { + fn drop(&mut self) { + // SAFETY: this test owns the pseudoconsole and continuously drains its output. + unsafe { ClosePseudoConsole(self.0) }; + } + } + + fn pipe() -> (OwnedHandle, OwnedHandle) { + let (mut read, mut write): (HANDLE, HANDLE) = + (std::ptr::null_mut(), std::ptr::null_mut()); + // SAFETY: the writable outputs receive two new, non-inheritable handles. + assert_ne!(unsafe { CreatePipe(&mut read, &mut write, std::ptr::null(), 0) }, 0); + // SAFETY: only these wrappers own the handles returned by CreatePipe. + unsafe { + (OwnedHandle::from_raw_handle(read.cast()), OwnedHandle::from_raw_handle(write.cast())) + } + } + + use std::os::windows::io::AsRawHandle; + let directory = tempfile::tempdir().unwrap(); + let (input, keyboard) = pipe(); + let (screen, output) = pipe(); + let mut console = 0; + // SAFETY: the pipe ends remain open throughout pseudoconsole creation. + assert_eq!(unsafe { + CreatePseudoConsole( + COORD { X: 100, Y: 40 }, + input.as_raw_handle().cast(), + output.as_raw_handle().cast(), + 0, + &mut console, + ) + }, 0); + let console = Console(console); + drop(input); + drop(output); + let reader = std::thread::spawn(move || { + let mut bytes = Vec::new(); + File::from(screen).read_to_end(&mut bytes).unwrap(); + bytes + }); + let mut bytes = 0; + // SAFETY: the first call obtains the allocation size for one process attribute. + unsafe { InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut bytes) }; + let mut attributes = vec![0usize; bytes.div_ceil(std::mem::size_of::())]; + let attributes = attributes.as_mut_ptr().cast(); + // SAFETY: the aligned allocation has the requested size and remains live through creation. + assert_ne!(unsafe { InitializeProcThreadAttributeList(attributes, 1, 0, &mut bytes) }, 0); + // SAFETY: the pseudoconsole remains owned until its child and all of its output end. + assert_ne!(unsafe { + UpdateProcThreadAttribute( + attributes, + 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE as usize, + console.0 as *const std::ffi::c_void, + std::mem::size_of::(), + std::ptr::null_mut(), + std::ptr::null(), + ) + }, 0); + let mut environment: Vec<_> = std::env::vars_os().collect(); + environment.push(("NVX_TEST_RAW_TERMINAL_DIR".into(), directory.path().as_os_str().to_owned())); + environment.sort_by_key(|(name, _)| name.to_string_lossy().to_ascii_uppercase()); + let mut block = Vec::new(); + for (name, value) in environment { + block.extend(name.encode_wide()); + block.push(u16::from(b'=')); + block.extend(value.encode_wide()); + block.push(0); + } + block.push(0); + let executable = std::env::current_exe().unwrap(); + let mut command: Vec = format!( + "\"{}\" --exact one_shot::windows_terminal_ctrl_c_helper --ignored --nocapture --test-threads=1", + executable.display() + ).encode_utf16().chain([0]).collect(); + // SAFETY: both Win32 structs are plain data with valid all-zero initial states. + let mut startup: STARTUPINFOEXW = unsafe { std::mem::zeroed() }; + let mut child: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + startup.StartupInfo.cb = std::mem::size_of::() as u32; + startup.lpAttributeList = attributes; + // SAFETY: every buffer and attribute remains valid until CreateProcessW returns. + let created = unsafe { + CreateProcessW( + std::ptr::null(), command.as_mut_ptr(), std::ptr::null(), std::ptr::null(), + 0, EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + block.as_ptr().cast(), std::ptr::null(), &startup.StartupInfo, &mut child, + ) + }; + let error = std::io::Error::last_os_error(); + // SAFETY: initialization succeeded and process creation has finished using the list. + unsafe { DeleteProcThreadAttributeList(attributes) }; + assert_ne!(created, 0, "{error}"); + // SAFETY: the successful call returned these two newly owned handles. + let process = unsafe { OwnedHandle::from_raw_handle(child.hProcess.cast()) }; + drop(unsafe { OwnedHandle::from_raw_handle(child.hThread.cast()) }); + let mut keyboard = File::from(keyboard); + for phase in ["console", "handshake"] { + let deadline = Instant::now() + Duration::from_secs(10); + while !directory.path().join(format!("{phase}.ready")).is_file() { + // SAFETY: the handle still owns exactly the helper that this test created. + assert_ne!(unsafe { WaitForSingleObject(child.hProcess, 0) }, WAIT_OBJECT_0, + "the private terminal helper exited before {phase}"); + assert!(Instant::now() < deadline, "the terminal helper never reached {phase}"); + std::thread::sleep(Duration::from_millis(5)); + } + keyboard.write_all(b"\x03").unwrap(); + } + // SAFETY: the helper itself bounds each CLI cancellation and confirms its owned fake's exit. + assert_eq!(unsafe { WaitForSingleObject(child.hProcess, 10_000) }, WAIT_OBJECT_0); + let mut status = 0; + // SAFETY: the process handle and writable exit-code output remain valid. + assert_ne!(unsafe { GetExitCodeProcess(child.hProcess, &mut status) }, 0); + drop(keyboard); + drop(console); + let transcript = reader.join().unwrap(); + assert_eq!(status, 0, "{}", String::from_utf8_lossy(&transcript)); + for phase in ["console", "handshake"] { + let receipt = std::fs::read_to_string(directory.path().join(format!("{phase}.json"))).unwrap(); + println!("NVX-TERMINAL-CTRL-C {receipt}"); + } + drop(process); +} + +#[cfg(windows)] +#[test] +#[ignore] +#[allow(unsafe_code)] +fn windows_terminal_ctrl_c_helper() { + use std::process::Stdio; + use std::time::{Duration, Instant}; + use windows_sys::Win32::System::Console::{ + CTRL_C_EVENT, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, + GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE, SetConsoleCtrlHandler, + }; + + unsafe extern "system" fn keep_helper(control: u32) -> windows_sys::core::BOOL { + i32::from(control == CTRL_C_EVENT) + } + + let directory = std::path::PathBuf::from( + std::env::var_os("NVX_TEST_RAW_TERMINAL_DIR").expect("private pseudoconsole required") + ); + // SAFETY: this callback affects only this helper, not its children or any other console. + assert_ne!(unsafe { SetConsoleCtrlHandler(Some(keep_helper), 1) }, 0); + for phase in ["console", "handshake"] { + let root = Root::new(); + let gate = root.below("build/foreground-gate"); + std::fs::create_dir(&gate).unwrap(); + let temporary = tempfile::Builder::new().prefix("nf3-").tempdir().unwrap(); + let mut original = 0; + // SAFETY: this helper's stdin belongs to the parent's private pseudoconsole. + let input = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; + assert_ne!(unsafe { GetConsoleMode(input, &mut original) }, 0); + let mut process = root.command(&[ + "run", "--cmdline", + &format!("nvx_mode=managed nvx_identity=1001:1001 fake_foreground_gate={phase}"), + ]) + .env("NVX_TEST_FOREGROUND_GATE_DIR", &gate) + .env("TMP", temporary.path()).env("TEMP", temporary.path()) + .stdin(Stdio::inherit()).stdout(Stdio::piped()).stderr(Stdio::piped()) + .spawn().unwrap(); + let native = gated_native(&mut process, &gate); + let deadline = Instant::now() + Duration::from_secs(2); + let raw = loop { + let mut mode = 0; + // SAFETY: the helper retains its own console input handle while the CLI runs. + assert_ne!(unsafe { GetConsoleMode(input, &mut mode) }, 0); + if mode & (ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT) == 0 { + break mode; + } + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(5)); + }; + std::fs::write(directory.join(format!("{phase}.ready")), b"raw").unwrap(); + foreground_exits_promptly(&mut process, &native); + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(130), "{output:?}"); + assert_ne!(raw & ENABLE_PROCESSED_INPUT, 0); + assert!(!String::from_utf8_lossy(&output.stderr).contains("error[io]:")); + let mut restored = 0; + // SAFETY: the helper survives Ctrl-C and reads the same pseudoconsole after CLI exit. + assert_ne!(unsafe { GetConsoleMode(input, &mut restored) }, 0); + assert_eq!(restored, original); + assert_eq!(std::fs::read_dir(temporary.path()).unwrap().count(), 0); + let receipt = serde_json::json!({ + "phase":phase, "terminal_keyboard_byte":3, "raw_mode":raw, + "original_mode":original, "restored_mode":restored, "status":130, + "held_native_exited":native.has_exited().unwrap(), "private_directory_removed":true, + }); + std::fs::write(directory.join(format!("{phase}.json")), receipt.to_string()).unwrap(); + } +} + #[test] fn cold_runs_refuse_existing_and_missing_parent_reports_before_launch() { let root = Root::new(); From 55c273e27393d0a06b9508a2c7f3a6a69287de21 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 15:21:34 -0800 Subject: [PATCH 16/27] test: retain exact terminal cancellation receipts and pipe EOF handling Keep the private ConPTY fixture isolated and retain explicit terminal/verified-cleanup observations without changing production scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80075afd-7139-47a9-90c2-923b6b22bda5 --- nvx-rs/tests/cli/one_shot.rs | 176 +++++++++++++++++++++++------------ 1 file changed, 119 insertions(+), 57 deletions(-) diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index 1663ee3c7..72207baae 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -1027,11 +1027,14 @@ fn forwarding_failure_stops_the_verified_vm_during_connect_and_handshake() { assert!(stderr.contains("error[io]:"), "{stderr}"); assert!(!stderr.contains("timed out"), "{stderr}"); assert_eq!(std::fs::read_dir(temporary.path()).unwrap().count(), 0); - println!("NVX-FORWARD-FAILURE {}", serde_json::json!({ - "phase":phase, "native":native.reference(), "held_native_exited":true, - "status":1, "original_io_error":true, "private_directory_removed":true, - "completion_bound_seconds":3, - })); + println!( + "NVX-FORWARD-FAILURE {}", + serde_json::json!({ + "phase":phase, "native":native.reference(), "held_native_exited":true, + "status":1, "original_io_error":true, "private_directory_removed":true, + "completion_bound_seconds":3, + }) + ); } } @@ -1103,11 +1106,14 @@ fn terminal_ctrl_c_cancels_boot_and_handshake_and_restores_the_terminal() { assert_eq!(restored.control_flags, original.control_flags); assert_eq!(restored.control_chars, original.control_chars); assert_eq!(std::fs::read_dir(temporary.path()).unwrap().count(), 0); - println!("NVX-TERMINAL-CTRL-C {}", serde_json::json!({ - "phase":phase, "native":native.reference(), "terminal_keyboard_byte":3, - "raw_isig":true, "terminal_restored":true, "status":130, - "held_native_exited":true, "private_directory_removed":true, - })); + println!( + "NVX-TERMINAL-CTRL-C {}", + serde_json::json!({ + "phase":phase, "native":native.reference(), "terminal_keyboard_byte":3, + "raw_isig":true, "terminal_restored":true, "status":130, + "held_native_exited":true, "private_directory_removed":true, + }) + ); } } @@ -1163,13 +1169,18 @@ fn terminal_ctrl_c_cancels_boot_and_handshake_and_restores_the_terminal() { } fn pipe() -> (OwnedHandle, OwnedHandle) { - let (mut read, mut write): (HANDLE, HANDLE) = - (std::ptr::null_mut(), std::ptr::null_mut()); + let (mut read, mut write): (HANDLE, HANDLE) = (std::ptr::null_mut(), std::ptr::null_mut()); // SAFETY: the writable outputs receive two new, non-inheritable handles. - assert_ne!(unsafe { CreatePipe(&mut read, &mut write, std::ptr::null(), 0) }, 0); + assert_ne!( + unsafe { CreatePipe(&mut read, &mut write, std::ptr::null(), 0) }, + 0 + ); // SAFETY: only these wrappers own the handles returned by CreatePipe. unsafe { - (OwnedHandle::from_raw_handle(read.cast()), OwnedHandle::from_raw_handle(write.cast())) + ( + OwnedHandle::from_raw_handle(read.cast()), + OwnedHandle::from_raw_handle(write.cast()), + ) } } @@ -1179,21 +1190,33 @@ fn terminal_ctrl_c_cancels_boot_and_handshake_and_restores_the_terminal() { let (screen, output) = pipe(); let mut console = 0; // SAFETY: the pipe ends remain open throughout pseudoconsole creation. - assert_eq!(unsafe { - CreatePseudoConsole( - COORD { X: 100, Y: 40 }, - input.as_raw_handle().cast(), - output.as_raw_handle().cast(), - 0, - &mut console, - ) - }, 0); + assert_eq!( + unsafe { + CreatePseudoConsole( + COORD { X: 100, Y: 40 }, + input.as_raw_handle().cast(), + output.as_raw_handle().cast(), + 0, + &mut console, + ) + }, + 0 + ); let console = Console(console); drop(input); drop(output); let reader = std::thread::spawn(move || { let mut bytes = Vec::new(); - File::from(screen).read_to_end(&mut bytes).unwrap(); + let mut screen = File::from(screen); + let mut buffer = [0; 8192]; + loop { + match screen.read(&mut buffer) { + Ok(0) => break, + Ok(count) => bytes.extend_from_slice(&buffer[..count]), + Err(error) if error.raw_os_error() == Some(109) => break, + Err(error) => panic!("cannot drain the private pseudoconsole: {error}"), + } + } bytes }); let mut bytes = 0; @@ -1202,21 +1225,34 @@ fn terminal_ctrl_c_cancels_boot_and_handshake_and_restores_the_terminal() { let mut attributes = vec![0usize; bytes.div_ceil(std::mem::size_of::())]; let attributes = attributes.as_mut_ptr().cast(); // SAFETY: the aligned allocation has the requested size and remains live through creation. - assert_ne!(unsafe { InitializeProcThreadAttributeList(attributes, 1, 0, &mut bytes) }, 0); + assert_ne!( + unsafe { InitializeProcThreadAttributeList(attributes, 1, 0, &mut bytes) }, + 0 + ); // SAFETY: the pseudoconsole remains owned until its child and all of its output end. - assert_ne!(unsafe { - UpdateProcThreadAttribute( - attributes, - 0, - PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE as usize, - console.0 as *const std::ffi::c_void, - std::mem::size_of::(), - std::ptr::null_mut(), - std::ptr::null(), - ) - }, 0); - let mut environment: Vec<_> = std::env::vars_os().collect(); - environment.push(("NVX_TEST_RAW_TERMINAL_DIR".into(), directory.path().as_os_str().to_owned())); + assert_ne!( + unsafe { + UpdateProcThreadAttribute( + attributes, + 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE as usize, + console.0 as *const std::ffi::c_void, + std::mem::size_of::(), + std::ptr::null_mut(), + std::ptr::null(), + ) + }, + 0 + ); + let mut environment: Vec<_> = std::env::vars_os() + .filter(|(name, _)| { + !name.to_string_lossy().eq_ignore_ascii_case("NVX_TEST_RAW_TERMINAL_DIR") + }) + .collect(); + environment.push(( + "NVX_TEST_RAW_TERMINAL_DIR".into(), + directory.path().as_os_str().to_owned(), + )); environment.sort_by_key(|(name, _)| name.to_string_lossy().to_ascii_uppercase()); let mut block = Vec::new(); for (name, value) in environment { @@ -1239,9 +1275,16 @@ fn terminal_ctrl_c_cancels_boot_and_handshake_and_restores_the_terminal() { // SAFETY: every buffer and attribute remains valid until CreateProcessW returns. let created = unsafe { CreateProcessW( - std::ptr::null(), command.as_mut_ptr(), std::ptr::null(), std::ptr::null(), - 0, EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, - block.as_ptr().cast(), std::ptr::null(), &startup.StartupInfo, &mut child, + std::ptr::null(), + command.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + 0, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + block.as_ptr().cast(), + std::ptr::null(), + &startup.StartupInfo, + &mut child, ) }; let error = std::io::Error::last_os_error(); @@ -1256,24 +1299,37 @@ fn terminal_ctrl_c_cancels_boot_and_handshake_and_restores_the_terminal() { let deadline = Instant::now() + Duration::from_secs(10); while !directory.path().join(format!("{phase}.ready")).is_file() { // SAFETY: the handle still owns exactly the helper that this test created. - assert_ne!(unsafe { WaitForSingleObject(child.hProcess, 0) }, WAIT_OBJECT_0, - "the private terminal helper exited before {phase}"); - assert!(Instant::now() < deadline, "the terminal helper never reached {phase}"); + assert_ne!( + unsafe { WaitForSingleObject(child.hProcess, 0) }, + WAIT_OBJECT_0, + "the private terminal helper exited before {phase}" + ); + assert!( + Instant::now() < deadline, + "the terminal helper never reached {phase}" + ); std::thread::sleep(Duration::from_millis(5)); } keyboard.write_all(b"\x03").unwrap(); } // SAFETY: the helper itself bounds each CLI cancellation and confirms its owned fake's exit. - assert_eq!(unsafe { WaitForSingleObject(child.hProcess, 10_000) }, WAIT_OBJECT_0); + assert_eq!( + unsafe { WaitForSingleObject(child.hProcess, 10_000) }, + WAIT_OBJECT_0 + ); let mut status = 0; // SAFETY: the process handle and writable exit-code output remain valid. - assert_ne!(unsafe { GetExitCodeProcess(child.hProcess, &mut status) }, 0); + assert_ne!( + unsafe { GetExitCodeProcess(child.hProcess, &mut status) }, + 0 + ); drop(keyboard); drop(console); let transcript = reader.join().unwrap(); assert_eq!(status, 0, "{}", String::from_utf8_lossy(&transcript)); for phase in ["console", "handshake"] { - let receipt = std::fs::read_to_string(directory.path().join(format!("{phase}.json"))).unwrap(); + let receipt = + std::fs::read_to_string(directory.path().join(format!("{phase}.json"))).unwrap(); println!("NVX-TERMINAL-CTRL-C {receipt}"); } drop(process); @@ -1287,8 +1343,8 @@ fn windows_terminal_ctrl_c_helper() { use std::process::Stdio; use std::time::{Duration, Instant}; use windows_sys::Win32::System::Console::{ - CTRL_C_EVENT, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, - GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE, SetConsoleCtrlHandler, + CTRL_C_EVENT, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, GetConsoleMode, + GetStdHandle, STD_INPUT_HANDLE, SetConsoleCtrlHandler, }; unsafe extern "system" fn keep_helper(control: u32) -> windows_sys::core::BOOL { @@ -1296,7 +1352,7 @@ fn windows_terminal_ctrl_c_helper() { } let directory = std::path::PathBuf::from( - std::env::var_os("NVX_TEST_RAW_TERMINAL_DIR").expect("private pseudoconsole required") + std::env::var_os("NVX_TEST_RAW_TERMINAL_DIR").expect("private pseudoconsole required"), ); // SAFETY: this callback affects only this helper, not its children or any other console. assert_ne!(unsafe { SetConsoleCtrlHandler(Some(keep_helper), 1) }, 0); @@ -1309,14 +1365,20 @@ fn windows_terminal_ctrl_c_helper() { // SAFETY: this helper's stdin belongs to the parent's private pseudoconsole. let input = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; assert_ne!(unsafe { GetConsoleMode(input, &mut original) }, 0); - let mut process = root.command(&[ - "run", "--cmdline", - &format!("nvx_mode=managed nvx_identity=1001:1001 fake_foreground_gate={phase}"), - ]) + let mut process = root + .command(&[ + "run", + "--cmdline", + &format!("nvx_mode=managed nvx_identity=1001:1001 fake_foreground_gate={phase}"), + ]) .env("NVX_TEST_FOREGROUND_GATE_DIR", &gate) - .env("TMP", temporary.path()).env("TEMP", temporary.path()) - .stdin(Stdio::inherit()).stdout(Stdio::piped()).stderr(Stdio::piped()) - .spawn().unwrap(); + .env("TMP", temporary.path()) + .env("TEMP", temporary.path()) + .stdin(Stdio::inherit()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); let native = gated_native(&mut process, &gate); let deadline = Instant::now() + Duration::from_secs(2); let raw = loop { From 86e766114c5289c9b9485ce1a8715dadae6e9b10 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 15:23:17 -0800 Subject: [PATCH 17/27] fix: preserve the public owned-launch API during foreground integration Keep the dependency public spawn_vm(command, capability, log) signature and detached default; route only the existing private VM caller through the shared private foreground-mode helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80075afd-7139-47a9-90c2-923b6b22bda5 --- nvx-rs/src/host/spawn.rs | 9 +++++++++ nvx-rs/src/host/vm.rs | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/nvx-rs/src/host/spawn.rs b/nvx-rs/src/host/spawn.rs index 1c6df3fd1..1fc1a3eb8 100644 --- a/nvx-rs/src/host/spawn.rs +++ b/nvx-rs/src/host/spawn.rs @@ -124,6 +124,15 @@ pub fn spawn_vm( command: &VmmCommand<'_>, capability: &[u8; CAPABILITY_LEN], log: File, +) -> io::Result { + spawn_vm_with_mode(command, capability, log, false) +} + +/// Shares the strict VM spawn with the CLI's private foreground mode. +pub(crate) fn spawn_vm_with_mode( + command: &VmmCommand<'_>, + capability: &[u8; CAPABILITY_LEN], + log: File, foreground: bool, ) -> io::Result { spawn_inner(command, capability, log, true, foreground) diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index 246341385..192d2c6bd 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -871,7 +871,7 @@ fn spawn_verified_with_cleanup( inherit: inherit.iter().map(Inherited::new).collect(), }; let child = - spawn::spawn_vm(&command, capability.as_bytes(), log, foreground).map_err(|error| { + spawn::spawn_vm_with_mode(&command, capability.as_bytes(), log, foreground).map_err(|error| { let unconfirmed = error .get_ref() .and_then(|source| source.downcast_ref::()) From a62057118156dc3bff96beb816730a9964d04ca6 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 15:29:21 -0800 Subject: [PATCH 18/27] fix: release scoped attachment cancellation handles after handshake Do not let the cancellation duplicate keep a failed established session open. Add a native transport regression on each supported host and retain generated formatting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80075afd-7139-47a9-90c2-923b6b22bda5 --- nvx-rs/src/host/cli/session.rs | 9 +++++ nvx-rs/src/host/cli/session/tests.rs | 58 ++++++++++++++++++++++++++++ nvx-rs/src/host/vm.rs | 4 +- nvx-rs/tests/cli/one_shot.rs | 4 +- 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/nvx-rs/src/host/cli/session.rs b/nvx-rs/src/host/cli/session.rs index e7950327d..e04236c1a 100644 --- a/nvx-rs/src/host/cli/session.rs +++ b/nvx-rs/src/host/cli/session.rs @@ -100,6 +100,12 @@ impl AttachmentCancellation { self.check() } + /// Releases the cancellation duplicate once attachment no longer has pending I/O. + fn attached(&self) { + #[cfg(any(target_os = "linux", windows))] + self.transport.lock().expect("attachment lock poisoned").take(); + } + /// Ends pending I/O; repeated calls also cover cancellation racing with an I/O submission. #[allow(unsafe_code)] pub(super) fn cancel(&self) -> io::Result<()> { @@ -416,6 +422,9 @@ impl Session { let attached = ControlSession::attach(transport, capability, deadline.instant()) .map_err(|error| session.failure(error))?; session.inner = Some(attached); + if let Some(cancellation) = &session.probe.cancellation { + cancellation.attached(); + } Ok(session) } diff --git a/nvx-rs/src/host/cli/session/tests.rs b/nvx-rs/src/host/cli/session/tests.rs index a892a36e7..2aadea163 100644 --- a/nvx-rs/src/host/cli/session/tests.rs +++ b/nvx-rs/src/host/cli/session/tests.rs @@ -846,6 +846,32 @@ fn check_forward_failure_during_handshake( assert!(elapsed < Duration::from_secs(2), "{elapsed:?}"); } +#[cfg(any(target_os = "linux", windows))] +fn check_cancellable_session_closes_failed_io( + client: Box, + server: impl Transport + 'static, +) { + let (closed, closure) = mpsc::channel(); + let peer = thread::spawn(move || { + let mut guest = Guest::new(server); + guest.accept(); + let _ = guest.request(); + closed.send(guest.record().is_none()).unwrap(); + }); + let cancellation = AttachmentCancellation::default(); + let mut session = + Session::attach(client, &CAPABILITY, 5.0, Some(cancellation.clone())).unwrap(); + assert_eq!( + message(session.ping(0.05)), + "managed control response timed out" + ); + let observed = closure.recv_timeout(Duration::from_secs(1)); + drop(session); + drop(cancellation); + peer.join().unwrap(); + assert_eq!(observed, Ok(true)); +} + #[test] fn response_that_does_not_arrive_times_out() { let (mut session, worker) = session_with(|guest| { @@ -1186,6 +1212,22 @@ mod endpoints { assert_eq!(client.read(&mut byte, Some(Duration::ZERO)).unwrap(), 0); } + #[test] + #[cfg(target_os = "linux")] + fn successful_attachment_does_not_keep_failed_session_io_open() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("control.sock"); + let listener = UnixListener::bind(&path).unwrap(); + let client = platform::connect_endpoint( + path.to_str().unwrap(), + std::process::id(), + Duration::from_secs(5), + ) + .unwrap(); + let (server, _) = listener.accept().unwrap(); + check_cancellable_session_closes_failed_io(client, Server(server)); + } + #[test] #[cfg(target_os = "linux")] fn writes_to_a_slow_endpoint_end_by_their_deadline() { @@ -1408,6 +1450,22 @@ mod endpoints { assert_eq!(client.read(&mut byte, Some(Duration::ZERO)).unwrap(), 0); } + #[test] + fn successful_attachment_does_not_keep_failed_session_io_open() { + let (name, server) = create_pipe(); + let accepted = thread::spawn(move || { + accept(&server); + server + }); + let client = platform::connect_endpoint( + &name.replace('\\', "/"), + std::process::id(), + Duration::from_secs(5), + ) + .unwrap(); + check_cancellable_session_closes_failed_io(client, Server(accepted.join().unwrap())); + } + #[test] fn writes_to_a_slow_endpoint_end_by_their_deadline() { let (name, server) = create_pipe(); diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index 192d2c6bd..b148774fc 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -870,8 +870,8 @@ fn spawn_verified_with_cleanup( breakaway_from_job: windows_breakaway, inherit: inherit.iter().map(Inherited::new).collect(), }; - let child = - spawn::spawn_vm_with_mode(&command, capability.as_bytes(), log, foreground).map_err(|error| { + let child = spawn::spawn_vm_with_mode(&command, capability.as_bytes(), log, foreground) + .map_err(|error| { let unconfirmed = error .get_ref() .and_then(|source| source.downcast_ref::()) diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index 72207baae..e75684fa4 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -1246,7 +1246,9 @@ fn terminal_ctrl_c_cancels_boot_and_handshake_and_restores_the_terminal() { ); let mut environment: Vec<_> = std::env::vars_os() .filter(|(name, _)| { - !name.to_string_lossy().eq_ignore_ascii_case("NVX_TEST_RAW_TERMINAL_DIR") + !name + .to_string_lossy() + .eq_ignore_ascii_case("NVX_TEST_RAW_TERMINAL_DIR") }) .collect(); environment.push(( From d876ee5a2490b0b389e54829bdbe001b86d01700 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 15:30:54 -0800 Subject: [PATCH 19/27] style: format scoped cancellation handle release Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80075afd-7139-47a9-90c2-923b6b22bda5 --- nvx-rs/src/host/cli/session.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nvx-rs/src/host/cli/session.rs b/nvx-rs/src/host/cli/session.rs index e04236c1a..fc6dddcef 100644 --- a/nvx-rs/src/host/cli/session.rs +++ b/nvx-rs/src/host/cli/session.rs @@ -103,7 +103,10 @@ impl AttachmentCancellation { /// Releases the cancellation duplicate once attachment no longer has pending I/O. fn attached(&self) { #[cfg(any(target_os = "linux", windows))] - self.transport.lock().expect("attachment lock poisoned").take(); + self.transport + .lock() + .expect("attachment lock poisoned") + .take(); } /// Ends pending I/O; repeated calls also cover cancellation racing with an I/O submission. From 44b0a1a76ef9ff4975445a4de5a5e74e996fa5cc Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 15:36:56 -0800 Subject: [PATCH 20/27] test: require the gated native process start time before holding it Handle the existing optional process-start lookup explicitly rather than constructing an invalid ProcessRef fixture. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80075afd-7139-47a9-90c2-923b6b22bda5 --- nvx-rs/tests/cli/one_shot.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index e75684fa4..1ddd797f5 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -949,7 +949,9 @@ fn gated_native( let pid = record["pid"].as_u64().unwrap().try_into().unwrap(); let reference = ProcessRef { pid, - start_time: nvx::host::platform::process_start_time(pid).unwrap(), + start_time: nvx::host::platform::process_start_time(pid) + .unwrap() + .expect("the gated fake must still be running"), boot: BootId::current().unwrap(), }; match Process::open(&reference).unwrap() { From f0c4e340b2942bcc8ec8656dc7001f85efe158d0 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 15:43:35 -0800 Subject: [PATCH 21/27] test: give the private ConPTY CLI its actual console input Do not mistake inherited redirected standard input for CONIN$. Verify all console members are owned before injecting a keyboard interrupt and retain the accepted nonexclusive native-stop qualification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80075afd-7139-47a9-90c2-923b6b22bda5 --- nvx-rs/tests/cli/one_shot.rs | 38 +++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index 1ddd797f5..ecbad6eeb 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -1344,11 +1344,12 @@ fn terminal_ctrl_c_cancels_boot_and_handshake_and_restores_the_terminal() { #[ignore] #[allow(unsafe_code)] fn windows_terminal_ctrl_c_helper() { + use std::os::windows::io::AsRawHandle; use std::process::Stdio; use std::time::{Duration, Instant}; use windows_sys::Win32::System::Console::{ CTRL_C_EVENT, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, GetConsoleMode, - GetStdHandle, STD_INPUT_HANDLE, SetConsoleCtrlHandler, + GetConsoleProcessList, SetConsoleCtrlHandler, }; unsafe extern "system" fn keep_helper(control: u32) -> windows_sys::core::BOOL { @@ -1360,15 +1361,30 @@ fn windows_terminal_ctrl_c_helper() { ); // SAFETY: this callback affects only this helper, not its children or any other console. assert_ne!(unsafe { SetConsoleCtrlHandler(Some(keep_helper), 1) }, 0); + let mut members = [0; 8]; + // SAFETY: no CLI or VM exists yet; only this helper may belong to its private pseudoconsole. + let count = unsafe { GetConsoleProcessList(members.as_mut_ptr(), members.len() as u32) }; + assert_eq!(count, 1); + assert_eq!(members[0], std::process::id()); + let console_input = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("CONIN$") + .unwrap(); + let input = console_input.as_raw_handle().cast(); for phase in ["console", "handshake"] { let root = Root::new(); let gate = root.below("build/foreground-gate"); std::fs::create_dir(&gate).unwrap(); let temporary = tempfile::Builder::new().prefix("nf3-").tempdir().unwrap(); let mut original = 0; - // SAFETY: this helper's stdin belongs to the parent's private pseudoconsole. - let input = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; - assert_ne!(unsafe { GetConsoleMode(input, &mut original) }, 0); + // SAFETY: this opened console input belongs to the parent's verified private pseudoconsole. + assert_ne!( + unsafe { GetConsoleMode(input, &mut original) }, + 0, + "{}", + std::io::Error::last_os_error() + ); let mut process = root .command(&[ "run", @@ -1378,7 +1394,7 @@ fn windows_terminal_ctrl_c_helper() { .env("NVX_TEST_FOREGROUND_GATE_DIR", &gate) .env("TMP", temporary.path()) .env("TEMP", temporary.path()) - .stdin(Stdio::inherit()) + .stdin(Stdio::from(console_input.try_clone().unwrap())) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() @@ -1395,6 +1411,16 @@ fn windows_terminal_ctrl_c_helper() { assert!(Instant::now() < deadline); std::thread::sleep(Duration::from_millis(5)); }; + let mut active_members = [0; 8]; + // SAFETY: admit a keyboard interrupt only for this helper and its two verified children. + let active = unsafe { + GetConsoleProcessList(active_members.as_mut_ptr(), active_members.len() as u32) + }; + assert_eq!(active, 3); + let mut expected = [std::process::id(), process.id(), native.reference().pid]; + expected.sort_unstable(); + active_members[..3].sort_unstable(); + assert_eq!(active_members[..3], expected); std::fs::write(directory.join(format!("{phase}.ready")), b"raw").unwrap(); foreground_exits_promptly(&mut process, &native); let output = process.wait_with_output().unwrap(); @@ -1410,6 +1436,8 @@ fn windows_terminal_ctrl_c_helper() { "phase":phase, "terminal_keyboard_byte":3, "raw_mode":raw, "original_mode":original, "restored_mode":restored, "status":130, "held_native_exited":native.has_exited().unwrap(), "private_directory_removed":true, + "console_members_before_launch":count, "cli_exclusive_native_stop_causality":false, + "verified_console_members_before_key":expected, }); std::fs::write(directory.join(format!("{phase}.json")), receipt.to_string()).unwrap(); } From dc9a7c6a7acf2ec404ae7f1c6b710b42f7668378 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 16:22:49 -0800 Subject: [PATCH 22/27] Keep quit and suspend keys in the raw guest console Leave only the terminal interrupt key as a host signal and restore all saved control characters. Exercise actual PTY input echoed by the held fake console, then Ctrl-C and verified cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80075afd-7139-47a9-90c2-923b6b22bda5 --- nvx-rs/src/host/cli/run.rs | 22 +++++- nvx-rs/src/testing/fake_openvmm.rs | 42 ++++++++++- nvx-rs/tests/cli/one_shot.rs | 113 ++++++++++++++++++++++++++++- 3 files changed, 169 insertions(+), 8 deletions(-) diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index e52dd30ea..0e1888231 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -759,11 +759,31 @@ impl TerminalGuard { #[cfg(unix)] fn enable_interrupts() -> io::Result<()> { - use nix::sys::termios::{LocalFlags, SetArg, tcgetattr, tcsetattr}; + use nix::sys::termios::{ + LocalFlags, SetArg, SpecialCharacterIndices, tcgetattr, tcsetattr, + }; let stdin = io::stdin(); let mut mode = tcgetattr(&stdin)?; mode.local_flags.insert(LocalFlags::ISIG); + // Only the interrupt key is a host signal; quit/suspend remain guest input. + for key in [ + SpecialCharacterIndices::VQUIT, + SpecialCharacterIndices::VSUSP, + #[cfg(any( + target_vendor = "apple", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "netbsd", + target_os = "openbsd", + target_os = "solaris", + target_os = "illumos", + target_os = "aix" + ))] + SpecialCharacterIndices::VDSUSP, + ] { + mode.control_chars[key as usize] = nix::libc::_POSIX_VDISABLE as _; + } tcsetattr(&stdin, SetArg::TCSANOW, &mode)?; Ok(()) } diff --git a/nvx-rs/src/testing/fake_openvmm.rs b/nvx-rs/src/testing/fake_openvmm.rs index d3e00d36e..cbd6e9ecc 100644 --- a/nvx-rs/src/testing/fake_openvmm.rs +++ b/nvx-rs/src/testing/fake_openvmm.rs @@ -54,7 +54,7 @@ //! `fake_report_before_console=1` writes a run's report before console output or its boot delay. //! `fake_boot_diagnostics=1` emits console output and a binary log before control attachment. //! `fake_refuse_attach=1` refuses attachment after the endpoint has accepted its client. -//! `fake_foreground_gate=console|connect|handshake` synchronizes foreground wait regressions. +//! `fake_foreground_gate=console|connect|handshake|keys` synchronizes foreground regressions. //! `fake_guest_features=MASK` advertises the decimal feature mask `MASK` instead of the current //! guest's, `fake_legacy_guest=1` refuses the features request like a guest agent that //! predates it, and `fake_refuse_maps=1` fails every `MAPS` request like a guest that cannot @@ -537,6 +537,10 @@ pub fn main() -> ExitCode { return ExitCode::from(1); } }; + if let Err(error) = foreground_gate(&options, "keys") { + eprintln!("{program}: {error}"); + return ExitCode::from(1); + } if options.boot_diagnostics { let written = console .write_all(b"boot-console before control\n") @@ -1820,9 +1824,9 @@ fn parse(program: &str, arguments: &[String]) -> Result { refuse_attach: knob("fake_refuse_attach") == Some("1"), foreground_gate: knob("fake_foreground_gate") .map(|phase| { - if !matches!(phase, "console" | "connect" | "handshake") { + if !matches!(phase, "console" | "connect" | "handshake" | "keys") { return Err( - "fake_foreground_gate must be console, connect or handshake".to_owned() + "fake_foreground_gate must be console, connect, handshake or keys".to_owned() ); } std::env::var_os("NVX_TEST_FOREGROUND_GATE_DIR") @@ -3034,7 +3038,10 @@ fn boot_console(options: &Options) -> io::Result> { let listener = UnixListener::bind(endpoint)?; fs::set_permissions(endpoint, fs::Permissions::from_mode(0o600))?; - Ok(Box::new(listener.accept()?.0)) + let console = listener.accept()?.0; + #[cfg(target_os = "linux")] + echo_signal_keys(options, &console)?; + Ok(Box::new(console)) } #[cfg(windows)] { @@ -3049,6 +3056,33 @@ fn boot_console(options: &Options) -> io::Result> { )) } +#[cfg(target_os = "linux")] +fn echo_signal_keys( + options: &Options, + console: &std::os::unix::net::UnixStream, +) -> io::Result<()> { + if options + .foreground_gate + .as_ref() + .is_some_and(|(phase, _)| phase == "keys") + { + let mut console = console.try_clone()?; + thread::Builder::new() + .name("fake-console-key-echo".to_owned()) + .spawn(move || { + let mut keys = [0; 2]; + if let Err(error) = console + .read_exact(&mut keys) + .and_then(|()| console.write_all(&keys)) + .and_then(|()| console.flush()) + { + eprintln!("gated console key echo failed: {error}"); + } + })?; + } + Ok(()) +} + #[cfg(windows)] struct PipeConsole(fs::File); diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index ecbad6eeb..095769f45 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -1085,9 +1085,10 @@ fn terminal_ctrl_c_cancels_boot_and_handshake_and_restores_the_terminal() { let deadline = Instant::now() + Duration::from_secs(2); let raw = loop { let mode = tcgetattr(&terminal.slave).unwrap(); - if !mode - .local_flags - .intersects(LocalFlags::ICANON | LocalFlags::ECHO) + if mode.local_flags.contains(LocalFlags::ISIG) + && !mode + .local_flags + .intersects(LocalFlags::ICANON | LocalFlags::ECHO) { break mode; } @@ -1119,6 +1120,112 @@ fn terminal_ctrl_c_cancels_boot_and_handshake_and_restores_the_terminal() { } } +#[cfg(target_os = "linux")] +#[test] +#[allow(unsafe_code)] +fn terminal_quit_and_suspend_keys_remain_guest_console_input() { + use nix::pty::openpty; + use nix::sys::termios::{LocalFlags, SpecialCharacterIndices, tcgetattr}; + use std::fs::File; + use std::io::{BufRead, BufReader, Read, Write}; + use std::os::unix::process::CommandExt; + use std::process::Stdio; + use std::time::{Duration, Instant}; + + let root = Root::new(); + let gate = root.below("build/foreground-gate"); + std::fs::create_dir(&gate).unwrap(); + let temporary = tempfile::Builder::new().prefix("nf3-").tempdir().unwrap(); + let terminal = openpty(None, None).unwrap(); + let original = tcgetattr(&terminal.slave).unwrap(); + let mut command = root.command(&["run", "--cmdline", "fake_foreground_gate=keys"]); + command + .env("NVX_TEST_FOREGROUND_GATE_DIR", &gate) + .env("TMPDIR", temporary.path()) + .stdin(Stdio::from(terminal.slave.try_clone().unwrap())) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + // SAFETY: the isolated child's pre-exec hook uses only session/terminal syscalls. + unsafe { + command.pre_exec(|| { + nix::unistd::setsid()?; + if nix::libc::ioctl(0, nix::libc::TIOCSCTTY, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut process = command.spawn().unwrap(); + let mut stdout = BufReader::new(process.stdout.take().unwrap()); + let mut invocation = String::new(); + stdout.read_line(&mut invocation).unwrap(); + assert!(invocation.starts_with(">> "), "{invocation}"); + let native = gated_native(&mut process, &gate); + let deadline = Instant::now() + Duration::from_secs(2); + let raw = loop { + let mode = tcgetattr(&terminal.slave).unwrap(); + if mode.local_flags.contains(LocalFlags::ISIG) + && !mode + .local_flags + .intersects(LocalFlags::ICANON | LocalFlags::ECHO) + { + break mode; + } + assert!(Instant::now() < deadline, "the terminal never became raw"); + std::thread::sleep(Duration::from_millis(5)); + }; + let (sent, received) = std::sync::mpsc::sync_channel(1); + let reader = std::thread::spawn(move || { + let mut keys = [0; 2]; + let result = stdout.read_exact(&mut keys).map(|()| keys); + sent.send(result).unwrap(); + stdout + }); + let mut keyboard = File::from(terminal.master); + keyboard.write_all(b"\x1c\x1a").unwrap(); + let echoed = received.recv_timeout(Duration::from_secs(2)); + if !matches!(echoed, Ok(Ok([0x1c, 0x1a]))) { + native.kill().unwrap(); + assert!( + native + .wait(Instant::now() + Duration::from_secs(2)) + .unwrap() + .is_some() + ); + if process.try_wait().unwrap().is_none() { + process.kill().unwrap(); + } + process.wait().unwrap(); + reader.join().unwrap(); + panic!("signal keys did not reach the guest console: {echoed:?}"); + } + let _stdout = reader.join().unwrap(); + assert!(process.try_wait().unwrap().is_none()); + keyboard.write_all(b"\x03").unwrap(); + foreground_exits_promptly(&mut process, &native); + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(130), "{output:?}"); + for key in [SpecialCharacterIndices::VQUIT, SpecialCharacterIndices::VSUSP] { + assert_eq!(raw.control_chars[key as usize], nix::libc::_POSIX_VDISABLE); + } + let restored = tcgetattr(&terminal.slave).unwrap(); + assert_eq!(restored.input_flags, original.input_flags); + assert_eq!(restored.output_flags, original.output_flags); + assert_eq!(restored.local_flags, original.local_flags); + assert_eq!(restored.control_flags, original.control_flags); + assert_eq!(restored.control_chars, original.control_chars); + assert_eq!(std::fs::read_dir(temporary.path()).unwrap().count(), 0); + println!( + "NVX-TERMINAL-SIGNAL-KEYS {}", + serde_json::json!({ + "native":native.reference(), "terminal_keyboard_bytes":[28,26], + "guest_console_echo":[28,26], "cli_kept_running":true, "raw_isig":true, + "terminal_restored":true, "ctrl_c_status":130, + "held_native_exited":true, "private_directory_removed":true, + }) + ); +} + #[test] fn a_ctrl_c_byte_in_redirected_stdin_is_not_terminal_cancellation() { use std::io::Write; From f5ebf5ade6a3efc14bf5710c3e1c23383a253e97 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 16:26:35 -0800 Subject: [PATCH 23/27] Apply formatter output to terminal input regression fixtures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80075afd-7139-47a9-90c2-923b6b22bda5 --- nvx-rs/src/testing/fake_openvmm.rs | 8 +++----- nvx-rs/tests/cli/one_shot.rs | 5 ++++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/nvx-rs/src/testing/fake_openvmm.rs b/nvx-rs/src/testing/fake_openvmm.rs index cbd6e9ecc..d7cc3b7de 100644 --- a/nvx-rs/src/testing/fake_openvmm.rs +++ b/nvx-rs/src/testing/fake_openvmm.rs @@ -1826,7 +1826,8 @@ fn parse(program: &str, arguments: &[String]) -> Result { .map(|phase| { if !matches!(phase, "console" | "connect" | "handshake" | "keys") { return Err( - "fake_foreground_gate must be console, connect, handshake or keys".to_owned() + "fake_foreground_gate must be console, connect, handshake or keys" + .to_owned(), ); } std::env::var_os("NVX_TEST_FOREGROUND_GATE_DIR") @@ -3057,10 +3058,7 @@ fn boot_console(options: &Options) -> io::Result> { } #[cfg(target_os = "linux")] -fn echo_signal_keys( - options: &Options, - console: &std::os::unix::net::UnixStream, -) -> io::Result<()> { +fn echo_signal_keys(options: &Options, console: &std::os::unix::net::UnixStream) -> io::Result<()> { if options .foreground_gate .as_ref() diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index 095769f45..554ff0013 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -1205,7 +1205,10 @@ fn terminal_quit_and_suspend_keys_remain_guest_console_input() { foreground_exits_promptly(&mut process, &native); let output = process.wait_with_output().unwrap(); assert_eq!(output.status.code(), Some(130), "{output:?}"); - for key in [SpecialCharacterIndices::VQUIT, SpecialCharacterIndices::VSUSP] { + for key in [ + SpecialCharacterIndices::VQUIT, + SpecialCharacterIndices::VSUSP, + ] { assert_eq!(raw.control_chars[key as usize], nix::libc::_POSIX_VDISABLE); } let restored = tcgetattr(&terminal.slave).unwrap(); From a08aa41158833082e55000f5e5fa1bc12964381a Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 16:55:37 -0800 Subject: [PATCH 24/27] Classify terminal dependencies in the existing notices inventory Use locked musl and MSVC normal CLI graphs, retaining the existing executed-procedural-macro policy. The required versioned notices already exist; runtime rustix/linux-raw-sys move out of the test-only classification and the Redox/GNU target packages remain unshipped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80075afd-7139-47a9-90c2-923b6b22bda5 --- scripts/test_nvx_tools.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/scripts/test_nvx_tools.py b/scripts/test_nvx_tools.py index db8e5dfc2..1c0cd68b0 100644 --- a/scripts/test_nvx_tools.py +++ b/scripts/test_nvx_tools.py @@ -14388,32 +14388,44 @@ def test_guest_binary_third_party_notices_cover_the_rust_runtime(self): def test_nvx_rs_third_party_notices_cover_every_linked_crate(self): # The crates of `cargo tree --locked --features cli -e normal` for the # release targets, x86_64-unknown-linux-musl and x86_64-pc-windows-msvc: - # what bin/nvx-rs[.exe] links, and serde_derive with the crates that it - # runs on while nvx-rs builds. + # what bin/nvx-rs[.exe] links, and serde_derive/document-features with + # the crates that they run on while nvx-rs builds. shipped = { "bitflags", "block-buffer", "cfg-if", "cpufeatures", + "crossterm", + "crossterm_winapi", "crypto-common", "digest", + "document-features", "generic-array", "getrandom", "itoa", "libc", + "linux-raw-sys", + "litrs", + "lock_api", "memchr", "memoffset", "nix", + "parking_lot", + "parking_lot_core", "proc-macro2", "quote", + "rustix", + "scopeguard", "serde", "serde_core", "serde_derive", "serde_json", "sha2", + "smallvec", "syn", "typenum", "unicode-ident", + "winapi", "windows-link", "windows-sys", "zmij", @@ -14427,7 +14439,6 @@ def test_nvx_rs_third_party_notices_cover_every_linked_crate(self): "core_detect", "errno", "fastrand", - "linux-raw-sys", "num-traits", "once_cell", "proptest", @@ -14435,11 +14446,13 @@ def test_nvx_rs_third_party_notices_cover_every_linked_crate(self): "rand", "rand_core", "rand_xorshift", + "redox_syscall", "regex-syntax", - "rustix", "tempfile", "unarray", "version_check", + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", } # Versions of shipped crates that only the tests use: proptest's # chacha20 takes cpufeatures 0.3, and sha2 the cpufeatures that ships. From 95dcce4d4a30b2d7309dc58893d31c661206d6e0 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Tue, 10 Nov 2026 18:26:15 -0800 Subject: [PATCH 25/27] test: report terminal readiness only after interrupts are enabled The private-console helper published readiness once line input and echo were off, which also matches the mode between enable_raw_mode, which clears processed input, and TerminalGuard::enable_interrupts, which sets it again. A Ctrl-C byte sent then reaches the guest instead of cancelling the CLI, or the later check of the captured mode fails. Wait for processed input as well. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/tests/cli/one_shot.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index 554ff0013..84031013f 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -1515,7 +1515,11 @@ fn windows_terminal_ctrl_c_helper() { let mut mode = 0; // SAFETY: the helper retains its own console input handle while the CLI runs. assert_ne!(unsafe { GetConsoleMode(input, &mut mode) }, 0); - if mode & (ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT) == 0 { + // Raw mode clears processed input before the CLI enables interrupts again, so only + // a raw mode with processed input proves that Ctrl-C cancels. + if mode & (ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT) == 0 + && mode & ENABLE_PROCESSED_INPUT != 0 + { break mode; } assert!(Instant::now() < deadline); From 4beb1ce3ab35d795f9a03f3cfb69042942b174e1 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Tue, 10 Nov 2026 18:27:17 -0800 Subject: [PATCH 26/27] cli: restore the console's entry input mode on Windows On Windows, crossterm's disable_raw_mode sets line input, echo and processed input instead of restoring their entry values, so a caller that ran nvx-rs with echo off got it back on. TerminalGuard now saves the console input mode before it enables raw mode, and restores exactly that mode when dropped, which also covers the error path after enable_interrupts. On Unix, crossterm already restores the termios that it saved. The private-console helper now enters each phase with echo and processed input off, so that its check that the CLI restores the entry mode fails for a guard that sets those flags instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/src/host/cli/run.rs | 70 ++++++++++++++++++++++++++++++------ nvx-rs/tests/cli/one_shot.rs | 22 +++++++++++- 2 files changed, 81 insertions(+), 11 deletions(-) diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index 0e1888231..571ef734b 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -742,21 +742,71 @@ fn connect_before_exit( } } -struct TerminalGuard(bool); +/// Keeps standard input, when it is a terminal, in raw mode with terminal interrupts, and restores +/// the terminal's entry mode when dropped, on success and error paths alike. +struct TerminalGuard(Option); + +/// The console input mode on entry, which [`TerminalGuard`] restores exactly: on Windows, +/// crossterm's `disable_raw_mode` sets line input, echo and processed input instead of restoring +/// their entry values. +#[cfg(windows)] +type EntryMode = windows_sys::Win32::System::Console::CONSOLE_MODE; + +/// Nothing: crossterm saves the terminal's entry mode, which its `disable_raw_mode` restores. +#[cfg(not(windows))] +struct EntryMode; impl TerminalGuard { fn new() -> Result { - let terminal = io::stdin().is_terminal(); - if terminal { - crossterm::terminal::enable_raw_mode().map_err(Exit::from_os_error)?; - } - let guard = Self(terminal); - if terminal { - Self::enable_interrupts().map_err(Exit::from_os_error)?; + if !io::stdin().is_terminal() { + return Ok(Self(None)); } + let entry = Self::entry_mode().map_err(Exit::from_os_error)?; + crossterm::terminal::enable_raw_mode().map_err(Exit::from_os_error)?; + let guard = Self(Some(entry)); + Self::enable_interrupts().map_err(Exit::from_os_error)?; Ok(guard) } + /// Reads the console input mode that the guard restores. + #[cfg(windows)] + #[allow(unsafe_code)] + fn entry_mode() -> io::Result { + use windows_sys::Win32::System::Console::{GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE}; + + let mut mode = 0; + // SAFETY: stdin is this process's console input, and `mode` outlives the call. + if unsafe { GetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), &mut mode) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(mode) + } + + /// Restores the console input mode that [`Self::entry_mode`] read. + #[cfg(windows)] + #[allow(unsafe_code)] + fn restore(entry: EntryMode) -> io::Result<()> { + use windows_sys::Win32::System::Console::{GetStdHandle, STD_INPUT_HANDLE, SetConsoleMode}; + + // SAFETY: stdin is this process's console input, whose entry mode the guard saved. + if unsafe { SetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), entry) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + + /// Reads nothing: crossterm saves the terminal's entry mode when it enables raw mode. + #[cfg(not(windows))] + fn entry_mode() -> io::Result { + Ok(EntryMode) + } + + /// Restores the terminal mode that crossterm saved when it enabled raw mode. + #[cfg(not(windows))] + fn restore(_entry: EntryMode) -> io::Result<()> { + crossterm::terminal::disable_raw_mode() + } + #[cfg(unix)] fn enable_interrupts() -> io::Result<()> { use nix::sys::termios::{ @@ -816,8 +866,8 @@ impl TerminalGuard { impl Drop for TerminalGuard { fn drop(&mut self) { - if self.0 - && let Err(error) = crossterm::terminal::disable_raw_mode() + if let Some(entry) = self.0.take() + && let Err(error) = Self::restore(entry) { output::print_stderr(&format!("cannot restore terminal mode: {error}")); } diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index 84031013f..808cb5e48 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -1459,7 +1459,7 @@ fn windows_terminal_ctrl_c_helper() { use std::time::{Duration, Instant}; use windows_sys::Win32::System::Console::{ CTRL_C_EVENT, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, GetConsoleMode, - GetConsoleProcessList, SetConsoleCtrlHandler, + GetConsoleProcessList, SetConsoleCtrlHandler, SetConsoleMode, }; unsafe extern "system" fn keep_helper(control: u32) -> windows_sys::core::BOOL { @@ -1487,6 +1487,24 @@ fn windows_terminal_ctrl_c_helper() { let gate = root.below("build/foreground-gate"); std::fs::create_dir(&gate).unwrap(); let temporary = tempfile::Builder::new().prefix("nf3-").tempdir().unwrap(); + let mut entry = 0; + // SAFETY: this opened console input belongs to the parent's verified private pseudoconsole. + assert_ne!( + unsafe { GetConsoleMode(input, &mut entry) }, + 0, + "{}", + std::io::Error::last_os_error() + ); + // Enter with echo and processed input off, so that only an exact restore of the entry + // mode, not one that sets the flags that raw mode clears, matches it after the CLI exits. + let disabled = ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT; + // SAFETY: the helper alone uses its private pseudoconsole before it starts the CLI. + assert_ne!( + unsafe { SetConsoleMode(input, (entry & !disabled) | ENABLE_LINE_INPUT) }, + 0, + "{}", + std::io::Error::last_os_error() + ); let mut original = 0; // SAFETY: this opened console input belongs to the parent's verified private pseudoconsole. assert_ne!( @@ -1495,6 +1513,8 @@ fn windows_terminal_ctrl_c_helper() { "{}", std::io::Error::last_os_error() ); + assert_eq!(original & disabled, 0); + assert_ne!(original & ENABLE_LINE_INPUT, 0); let mut process = root .command(&[ "run", From 231b618f4839291f585edd45853c83e5127c3f39 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Tue, 10 Nov 2026 19:06:52 -0800 Subject: [PATCH 27/27] test: restore Ctrl-C processing in the private console helpers A process created with CREATE_NEW_PROCESS_GROUP ignores Ctrl-C, and passes that on to every process that it starts, such as the test process of a CI runner's Windows step. The private-console helpers inherited it, so the console output helper never recorded the Ctrl-C that it generates, and the terminal Ctrl-C helper's CLI never cancelled. Each helper now restores normal Ctrl-C processing for itself, alone in its private console, and the CLI that it starts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/src/host/cli/run/tests.rs | 8 ++++++-- nvx-rs/tests/cli/one_shot.rs | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/nvx-rs/src/host/cli/run/tests.rs b/nvx-rs/src/host/cli/run/tests.rs index 8b9c81e05..59aa215e6 100644 --- a/nvx-rs/src/host/cli/run/tests.rs +++ b/nvx-rs/src/host/cli/run/tests.rs @@ -301,8 +301,8 @@ fn windows_console_output_helper() { use windows_sys::Win32::System::Console::{ CONSOLE_TEXTMODE_BUFFER, COORD, CTRL_C_EVENT, CreateConsoleScreenBuffer, GenerateConsoleCtrlEvent, GetConsoleOutputCP, GetConsoleProcessList, GetStdHandle, - ReadConsoleOutputCharacterW, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE, SetConsoleCursorPosition, - SetConsoleOutputCP, SetStdHandle, + ReadConsoleOutputCharacterW, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE, SetConsoleCtrlHandler, + SetConsoleCursorPosition, SetConsoleOutputCP, SetStdHandle, }; /// Restores the helper's private console and its original redirected standard streams. @@ -493,6 +493,10 @@ fn windows_console_output_helper() { assert_eq!(read as usize, expected.len()); assert_eq!(actual, expected); + // A parent that ignores Ctrl-C, as one created with CREATE_NEW_PROCESS_GROUP does, passes + // that on to this helper, whose handlers would then never see the event below. + // SAFETY: this changes only the Ctrl-C processing of this helper, alone in its console. + assert_ne!(unsafe { SetConsoleCtrlHandler(None, 0) }, 0); platform::watch_interrupts().unwrap(); // SAFETY: verify the event can reach only this helper in its private console. let count = unsafe { GetConsoleProcessList(members.as_mut_ptr(), members.len() as u32) }; diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index 808cb5e48..6d119b42d 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -1469,6 +1469,10 @@ fn windows_terminal_ctrl_c_helper() { let directory = std::path::PathBuf::from( std::env::var_os("NVX_TEST_RAW_TERMINAL_DIR").expect("private pseudoconsole required"), ); + // A parent that ignores Ctrl-C, as one created with CREATE_NEW_PROCESS_GROUP does, passes + // that on to this helper and to the CLI that it starts, which then never cancels. + // SAFETY: this changes only the Ctrl-C processing of this helper and its later children. + assert_ne!(unsafe { SetConsoleCtrlHandler(None, 0) }, 0); // SAFETY: this callback affects only this helper, not its children or any other console. assert_ne!(unsafe { SetConsoleCtrlHandler(Some(keep_helper), 1) }, 0); let mut members = [0; 8];