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/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/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/cli/run.rs b/nvx-rs/src/host/cli/run.rs index a7e8bc29c..571ef734b 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,57 @@ //! 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}; +use std::io::{self, IsTerminal, Read}; use std::path::Path; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; 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::{AttachmentCancellation, 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, ExportPlan, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, +}; +use crate::host::platform::{self, Transport}; +use crate::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; 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 +428,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_preflight(&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 +444,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 +471,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 +485,898 @@ 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)) +} + +/// 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( + 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() + .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" + }, + )); + } + 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()) + .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)?; + 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 { + 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 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()?; + if wait_status(Duration::ZERO) + .map_err(Exit::from_os_error)? + .is_some() + { + return Ok(None); + } + 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, + format!("VM console endpoint did not become available: {endpoint}"), + )); + } + thread::sleep(Duration::from_millis(10)); + } +} + +/// 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 { + 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::{ + 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(()) + } + + #[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()) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + if let Some(entry) = self.0.take() + && let Err(error) = Self::restore(entry) + { + 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", + )) + } +} + +struct ConsoleOutput { + write: fn(&[u8]) -> io::Result<()>, + #[cfg(windows)] + console: bool, + #[cfg(windows)] + pending: [u8; 3], + #[cfg(windows)] + pending_len: usize, +} + +impl ConsoleOutput { + fn new() -> Self { + Self { + write: output::write_stdout, + #[cfg(windows)] + console: io::stdout().is_terminal(), + #[cfg(windows)] + pending: [0; 3], + #[cfg(windows)] + pending_len: 0, + } + } + + fn stderr() -> Self { + Self { + write: output::write_stderr, + #[cfg(windows)] + console: io::stderr().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; + (self.write)(String::from_utf8_lossy(&bytes[..ready]).as_bytes())?; + self.pending[..incomplete].copy_from_slice(&bytes[ready..]); + self.pending_len = incomplete; + return Ok(()); + } + (self.write)(data) + } + + fn finish(&mut self) -> io::Result<()> { + #[cfg(windows)] + if self.pending_len != 0 { + (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 { + 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)?; + } + 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. +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, move || attach(pending)) + .map_err(Exit::from_os_error)?; + 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() + .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 !platform::interrupted() + && let Err(attach) = attached + { + attach.report(); + } + return Err(error); + } + } + } + } + attached + }) +} + +fn foreground( + launched: &mut vm::Launched, + output: &mut ForegroundOutput<'_>, + 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 input = if let Some(console) = connect_running(launched, &endpoint)? { + let (console, input) = console_input(console)?; + output.console = Some(console); + Some(input) + } else { + None + }; + let mut attach_closed = None; + if managed && output.console.is_some() { + let endpoint = launched.outputs.endpoint.clone(); + let capability = *launched.capability.as_bytes(); + let child = launched.child_mut().map_err(Exit::from_os_error)?; + match attach_with_output( + |cancellation| { + Session::connect_launched_cancellable( + &endpoint, + child, + &capability, + 60.0, + cancellation, + ) + }, + || output.forward(), + ) { + Ok(session) => *control = Some(session), + Err(Exit::EndpointClosed) => { + attach_closed = Some(Instant::now() + Duration::from_secs(60)); + } + Err(error) => return Err(error), + } + } + loop { check_interrupt()?; - output::print_stderr(&guidance); + let progressed = output.forward()?; + 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(status) = status { + output.finish()?; + return Ok(status); + } + if attach_closed.is_some_and(|deadline| Instant::now() >= deadline) { + return Err(Exit::EndpointClosed); + } + if !progressed { + thread::sleep(Duration::from_millis(10)); + } } - Err(Exit::Status(status)) +} + +/// 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, + 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 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: 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 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", + )); + } + let mut launched = vm::spawn(prepared).map_err(|error| { + cleanup = error.kind() != crate::ErrorKind::StillRunning; + error_with_cause(error) + })?; + cleanup = false; + 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 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( + &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:?}")); + } + if let Some(report) = &options.outcome_report + && let Err(error) = copy_openvmm_outcome(&launched.outputs.outcome, report) + { + error.report(); + } + let status = finish_foreground(&mut output, 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 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); + } + } + 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..59aa215e6 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,10 +46,513 @@ 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()); + } +} + +#[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:?}" + ); +} + +#[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( + move |_| { + 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); +} + +#[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"; + +#[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:?}"); + assert!( + output + .stdout + .windows(REDIRECTED_CONSOLE_BYTES.len()) + .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. +#[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}; + 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, CTRL_C_EVENT, CreateConsoleScreenBuffer, + GenerateConsoleCtrlEvent, GetConsoleOutputCP, GetConsoleProcessList, GetStdHandle, + ReadConsoleOutputCharacterW, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE, SetConsoleCtrlHandler, + SetConsoleCursorPosition, SetConsoleOutputCP, SetStdHandle, + }; + + /// Restores the helper's private console and its original redirected standard streams. + 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, + } + 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!(SetStdHandle(STD_ERROR_HANDLE, self.stderr), 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()) }; + 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) }, + 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!( + 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); + assert_ne!( + SetStdHandle(STD_OUTPUT_HANDLE, buffer.as_raw_handle().cast()), + 0 + ); + } + let mut console = ConsoleOutput::new(); + 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(), + ] { + 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}|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. + 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); + + 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); + + // 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) }; + 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! { /// 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, @@ -63,24 +563,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 +595,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 +655,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/cli/session.rs b/nvx-rs/src/host/cli/session.rs index 4e0a6aabb..fc6dddcef 100644 --- a/nvx-rs/src/host/cli/session.rs +++ b/nvx-rs/src/host/cli/session.rs @@ -63,6 +63,87 @@ 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() + } + + /// 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<()> { + 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 nix::sys::socket::{Shutdown, shutdown}; + use std::os::fd::AsRawFd; + + 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 +153,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 +194,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 +225,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 +294,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 +310,41 @@ 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 +357,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 +395,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 +404,17 @@ 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), @@ -293,6 +425,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 270f75a55..2aadea163 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)), @@ -736,13 +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, || { + let failure = Session::connect_unless_exited( + endpoint, + std::process::id(), + &CAPABILITY, + 60.0, + || { checks += 1; checks == 3 - }) - .err() - .unwrap(); + }, + None, + ) + .err() + .unwrap(); assert!( started.elapsed() < Duration::from_secs(30), "{:?}", @@ -752,13 +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, || { + let failure = Session::connect_unless_exited( + endpoint, + std::process::id(), + &CAPABILITY, + 0.2, + || { checks += 1; false - }) - .err() - .unwrap(); + }, + None, + ) + .err() + .unwrap(); assert!(started.elapsed() >= Duration::from_millis(200)); assert_eq!( failure.message().unwrap(), @@ -767,6 +779,99 @@ 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:?}"); +} + +#[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| { @@ -1041,6 +1146,7 @@ mod endpoints { checks += 1; checks == 2 }, + None, ) .err() .expect("unsupported peer verification must fail before authentication"); @@ -1057,6 +1163,71 @@ 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 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() { @@ -1227,6 +1398,74 @@ 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 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/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, }), }; 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..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; @@ -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. @@ -332,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/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..dae07c09b 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)?; @@ -343,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/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..bcaf571e3 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, @@ -481,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/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..1fc1a3eb8 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`]. @@ -125,7 +125,17 @@ pub fn spawn_vm( capability: &[u8; CAPABILITY_LEN], log: File, ) -> io::Result { - spawn_inner(command, capability, log, true) + 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) } /// Shares the legacy spawn mechanics without its optional Windows log-job fallback for a VM. @@ -134,6 +144,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 +161,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 +191,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..b148774fc 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,13 @@ 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; + self + } + /// Returns the credential to journal before a spawn or use when attaching a session. pub fn capability(&self) -> &Capability { &self.capability @@ -639,6 +650,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 +704,42 @@ 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); + +#[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 { + /// 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()? + .wait_timeout(timeout)? + .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 + .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. @@ -711,7 +759,8 @@ pub struct 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) } @@ -792,11 +841,21 @@ 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, working_dir, windows_breakaway, + foreground, capability, outputs, log, @@ -811,26 +870,53 @@ 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) - .map_err(|error| Error::io("cannot spawn the VM").with_source(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::()) + .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")] + 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..7eecb2efe 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 { @@ -1391,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() { @@ -1540,10 +1573,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 +1759,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..d7cc3b7de 100644 --- a/nvx-rs/src/testing/fake_openvmm.rs +++ b/nvx-rs/src/testing/fake_openvmm.rs @@ -50,6 +50,11 @@ //! `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_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|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 @@ -83,7 +88,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 +360,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 +386,18 @@ 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, + /// 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, + /// 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, + /// 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. @@ -494,24 +515,96 @@ 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 + }; + 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) => { + eprintln!("{program}: {error}"); + 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") + .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) + { + 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)), - 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); + } + }, }; + 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 || { @@ -528,6 +621,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 @@ -720,7 +828,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 +837,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 +878,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 +896,25 @@ 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 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}") + }; + 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 +1330,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 +1338,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 +1425,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 +1456,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 +1488,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 +1649,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 +1795,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 +1808,33 @@ 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"), + 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"), + 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" | "keys") { + return Err( + "fake_foreground_gate must be console, connect, handshake or keys" + .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(), @@ -2746,11 +2920,15 @@ 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; } + 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() { @@ -2850,6 +3028,73 @@ 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))?; + let console = listener.accept()?.0; + #[cfg(target_os = "linux")] + echo_signal_keys(options, &console)?; + Ok(Box::new(console)) + } + #[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(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); + +#[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 +3118,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 +3154,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 +3183,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..d7f67bc85 100644 --- a/nvx-rs/tests/cli/download.rs +++ b/nvx-rs/tests/cli/download.rs @@ -77,6 +77,32 @@ 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]); + 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"), + "{}", + outcome.describe() + ); + assert_no_staging(&root); + } +} + #[test] fn run_downloads_nothing_over_some_artifacts() { let root = checkout(&["build/vmlinux"]); @@ -120,6 +146,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/dry_run.rs b/nvx-rs/tests/cli/dry_run.rs index f8d796528..104482699 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,10 +420,13 @@ 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("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"]), @@ -721,7 +732,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 +790,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 +802,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 +896,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 +972,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 +1012,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 +1167,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 +1192,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 +1216,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 +1263,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 +1324,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 +1363,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 +1392,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 +1410,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/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index cef3c22a5..6d119b42d 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( @@ -652,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"), ] } @@ -678,8 +699,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 +748,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 +788,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 +803,1356 @@ 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:?}" + ); + } +} + +fn gated_native( + process: &mut std::process::Child, + directory: &std::path::Path, +) -> nvx::host::process::Process { + 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" + ); + 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() + .expect("the gated fake must still be running"), + 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); + 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, + }) + ); + } +} + +#[cfg(target_os = "linux")] +#[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}; + + 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.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 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); + 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, + }) + ); + } +} + +#[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; + 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:?}"); +} + +#[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(); + 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; + // 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() + .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 { + 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::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, + GetConsoleProcessList, SetConsoleCtrlHandler, SetConsoleMode, + }; + + 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"), + ); + // 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]; + // 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 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!( + unsafe { GetConsoleMode(input, &mut original) }, + 0, + "{}", + std::io::Error::last_os_error() + ); + assert_eq!(original & disabled, 0); + assert_ne!(original & ENABLE_LINE_INPUT, 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::from(console_input.try_clone().unwrap())) + .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); + // 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); + 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(); + 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, + "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(); + } +} + +#[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() + ); +} + +#[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}; + + 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() { + 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( 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..e4dc5e1e8 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,11 @@ 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): 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.