diff --git a/.github/actions/build-guest-artifacts/action.yml b/.github/actions/build-guest-artifacts/action.yml index c90dfeba2..1fb6039f2 100644 --- a/.github/actions/build-guest-artifacts/action.yml +++ b/.github/actions/build-guest-artifacts/action.yml @@ -73,21 +73,23 @@ runs: AZURELINUX_VERSION: ${{ steps.guest-versions.outputs.azurelinux-version }} KERNEL_INPUT_HASH: ${{ hashFiles('SOURCE-MANIFEST.json', 'kernel/config-microvm', 'kernel/patches/**', 'docker/Dockerfile', '.dockerignore', 'scripts/nvx_tools/build.py', 'scripts/nvx_tools/build_config.py', 'scripts/nvx_tools/build_constants.py') }} DEBUG_KERNEL_INPUT_HASH: ${{ hashFiles('SOURCE-MANIFEST.json', 'kernel/config-microvm', 'kernel/config-microvm-debug', 'kernel/patches/**', 'docker/Dockerfile', '.dockerignore', 'scripts/nvx_tools/build.py', 'scripts/nvx_tools/build_config.py', 'scripts/nvx_tools/build_constants.py') }} - ALPINE_INPUT_HASH: ${{ hashFiles('SOURCE-MANIFEST.json', 'guest/common/**', 'guest/alpine/**', 'docker/Dockerfile', '.dockerignore', 'scripts/nvx_tools/build.py', 'scripts/nvx_tools/build_config.py', 'scripts/nvx_tools/build_constants.py', 'scripts/nvx_tools/common.py', 'scripts/nvx_tools/guests.py') }} - UBUNTU_INPUT_HASH: ${{ hashFiles('SOURCE-MANIFEST.json', 'guest/common/**', 'guest/ubuntu/**', 'ubuntu/**', 'docker/Dockerfile', '.dockerignore', 'scripts/nvx_tools/build.py', 'scripts/nvx_tools/build_config.py', 'scripts/nvx_tools/build_constants.py', 'scripts/nvx_tools/common.py', 'scripts/nvx_tools/guests.py', 'scripts/nvx_tools/ubuntu.py') }} - AZURELINUX_INPUT_HASH: ${{ hashFiles('SOURCE-MANIFEST.json', 'guest/common/**', 'azurelinux/**', 'docker/Dockerfile', '.dockerignore', 'scripts/nvx_tools/azurelinux.py', 'scripts/nvx_tools/build.py', 'scripts/nvx_tools/build_config.py', 'scripts/nvx_tools/build_constants.py', 'scripts/nvx_tools/common.py', 'scripts/nvx_tools/guests.py') }} + ALPINE_INPUT_HASH: ${{ hashFiles('SOURCE-MANIFEST.json', 'guest/common/**', 'guest/alpine/**', 'nvx-rs/**', '!nvx-rs/target/**', 'rust-toolchain.toml', 'docker/Dockerfile', '.dockerignore', 'scripts/nvx_tools/build.py', 'scripts/nvx_tools/build_config.py', 'scripts/nvx_tools/build_constants.py', 'scripts/nvx_tools/common.py', 'scripts/nvx_tools/elf.py', 'scripts/nvx_tools/guest_binaries.py', 'scripts/nvx_tools/guests.py') }} + UBUNTU_INPUT_HASH: ${{ hashFiles('SOURCE-MANIFEST.json', 'guest/common/**', 'guest/ubuntu/**', 'ubuntu/**', 'nvx-rs/**', '!nvx-rs/target/**', 'rust-toolchain.toml', 'docker/Dockerfile', '.dockerignore', 'scripts/nvx_tools/build.py', 'scripts/nvx_tools/build_config.py', 'scripts/nvx_tools/build_constants.py', 'scripts/nvx_tools/common.py', 'scripts/nvx_tools/elf.py', 'scripts/nvx_tools/guest_binaries.py', 'scripts/nvx_tools/guests.py', 'scripts/nvx_tools/ubuntu.py') }} + AZURELINUX_INPUT_HASH: ${{ hashFiles('SOURCE-MANIFEST.json', 'guest/common/**', 'azurelinux/**', 'nvx-rs/**', '!nvx-rs/target/**', 'rust-toolchain.toml', 'docker/Dockerfile', '.dockerignore', 'scripts/nvx_tools/azurelinux.py', 'scripts/nvx_tools/build.py', 'scripts/nvx_tools/build_config.py', 'scripts/nvx_tools/build_constants.py', 'scripts/nvx_tools/common.py', 'scripts/nvx_tools/elf.py', 'scripts/nvx_tools/guest_binaries.py', 'scripts/nvx_tools/guests.py') }} run: | set -euo pipefail # hashFiles hashes contents only, so the kernel keys also name each patch - # path and its order. + # path and its order, and the guest image keys the path-aware digest of + # the Rust guest sources that their package manifests record. patch_identity=$(python3 scripts/nvx.py kernel-patch-identity) + rust_source_identity=$(python3 scripts/nvx.py guest-binary-source-identity) { echo "kernel=linux-kernel-v1-${KERNEL_VERSION}-${KERNEL_INPUT_HASH}-${patch_identity}" echo "kernel-debug=linux-kernel-debug-v1-${KERNEL_VERSION}-${DEBUG_KERNEL_INPUT_HASH}-${patch_identity}" - echo "alpine=alpine-image-v1-${ALPINE_VERSION}-${ALPINE_INPUT_HASH}" - echo "ubuntu=ubuntu-initramfs-v1-${UBUNTU_VERSION}-${UBUNTU_INPUT_HASH}" - echo "ubuntu-distro=ubuntu-distro-v1-${UBUNTU_VERSION}-${UBUNTU_INPUT_HASH}" - echo "azurelinux=azurelinux-initramfs-v1-${AZURELINUX_VERSION}-${AZURELINUX_INPUT_HASH}" + echo "alpine=alpine-image-v2-${ALPINE_VERSION}-${ALPINE_INPUT_HASH}-${rust_source_identity}" + echo "ubuntu=ubuntu-initramfs-v2-${UBUNTU_VERSION}-${UBUNTU_INPUT_HASH}-${rust_source_identity}" + echo "ubuntu-distro=ubuntu-distro-v2-${UBUNTU_VERSION}-${UBUNTU_INPUT_HASH}-${rust_source_identity}" + echo "azurelinux=azurelinux-initramfs-v2-${AZURELINUX_VERSION}-${AZURELINUX_INPUT_HASH}-${rust_source_identity}" } >> "${GITHUB_OUTPUT}" - name: Restore Linux kernel (zstd) diff --git a/.github/actions/validate-nvx/action.yml b/.github/actions/validate-nvx/action.yml index 68ad94130..a3cd1b88d 100644 --- a/.github/actions/validate-nvx/action.yml +++ b/.github/actions/validate-nvx/action.yml @@ -10,7 +10,7 @@ runs: run: | set -euo pipefail python3 -m compileall -q scripts - python3 -m unittest scripts/test_performance.py scripts/test_nvx_tools.py scripts/test_guest_binaries.py scripts/test_microvm_tests.py scripts/test_development_release.py scripts/test_egress_policy.py scripts/test_control_session.py scripts/test_managed_agent.py scripts/test_guest_time.py scripts/test_guest_ingress.py scripts/test_host_telemetry.py scripts/test_time_abi.py scripts/test_tool_versions.py scripts/test_markdown_links.py -v + python3 -m unittest scripts/test_performance.py scripts/test_nvx_tools.py scripts/test_guest_binaries.py scripts/test_microvm_tests.py scripts/test_development_release.py scripts/test_egress_policy.py scripts/test_control_session.py scripts/test_host_telemetry.py scripts/test_time_abi.py scripts/test_tool_versions.py scripts/test_markdown_links.py -v python3 scripts/test_adversarial.py -v python3 .github/skills/nvx-host-connect/scripts/test_hosts.py -v python3 scripts/nvx.py --help @@ -30,7 +30,7 @@ runs: $ErrorActionPreference = "Stop" python -m compileall -q scripts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - python -m unittest scripts/test_performance.py scripts/test_nvx_tools.py scripts/test_guest_binaries.py scripts/test_microvm_tests.py scripts/test_development_release.py scripts/test_egress_policy.py scripts/test_control_session.py scripts/test_managed_agent.py scripts/test_guest_time.py scripts/test_guest_ingress.py scripts/test_host_telemetry.py scripts/test_time_abi.py scripts/test_tool_versions.py scripts/test_markdown_links.py -v + python -m unittest scripts/test_performance.py scripts/test_nvx_tools.py scripts/test_guest_binaries.py scripts/test_microvm_tests.py scripts/test_development_release.py scripts/test_egress_policy.py scripts/test_control_session.py scripts/test_host_telemetry.py scripts/test_time_abi.py scripts/test_tool_versions.py scripts/test_markdown_links.py -v if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python scripts\test_adversarial.py -v if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c45fdd946..7b60f5fa1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -50,9 +50,10 @@ [copilot-setup-steps](./workflows/copilot-setup-steps.yml) prepares. It initializes `openvmm/`; installs `requirements-dev.txt` into a Python virtual environment on `PATH`, the Rust release that `rust-toolchain.toml` pins with - the OpenVMM test targets, the minimum supported Rust versions of the - `aci_edge_sandboxes` and `nvx-rs` crates, cargo-nextest, and the native guest build - prerequisites; grants `/dev/kvm` access; pulls the pinned shell linter images; + the OpenVMM test targets and the `x86_64-unknown-linux-musl` target used by + the Rust guest binaries, the minimum supported Rust versions of the + `aci_edge_sandboxes` and `nvx-rs` crates, cargo-nextest, and the remaining + native guest build prerequisites; grants `/dev/kvm` access; pulls the pinned shell linter images; and stages the pinned Linux and Ubuntu Base archives in `.cache/downloads`. When CI has cached them for the checked-out inputs, it also restores the guest artifacts into `build/` and the KVM OpenVMM binary into diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index d5962e5da..3ff091f8a 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -22,7 +22,7 @@ on: - .gitmodules - SOURCE-MANIFEST.json - aci_edge_sandboxes/Cargo.toml - - nvx-rs/Cargo.toml + - nvx-rs/** - openvmm - requirements-dev.txt - rust-toolchain.toml diff --git a/README.md b/README.md index 459cfc18a..d725e482c 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,11 @@ NVX was jointly developed by the [MSR Systems Research Group][msr-systems] and [msr-systems]: https://www.microsoft.com/en-us/research/group/systems-research-group-redmond/ [azure-systems]: https://www.microsoft.com/en-us/research/group/azure-research-systems/ -This repository includes version pins for Linux and OpenVMM, along with the patches, guest source -files, build tools, and benchmarks needed to use NVX. +This repository includes version pins for Linux, OpenVMM, and the Rust guest +toolchain, along with the patches, guest source files, build tools, and +benchmarks needed to use NVX. Alpine, Ubuntu, and Azure Linux ship the nine +static guest binaries from [`nvx-rs`](nvx-rs/README.md#guest-binaries) at +their common `/sbin/nvx-*` paths. ## Quick Start diff --git a/SOURCE-MANIFEST.json b/SOURCE-MANIFEST.json index a15f52395..86cb8a7d7 100644 --- a/SOURCE-MANIFEST.json +++ b/SOURCE-MANIFEST.json @@ -9,6 +9,10 @@ "control_session_protocol_version": 1, "control_contract_revision": "nvx-microvm-v2-control-v2" }, + "guest_rust": { + "toolchain": "1.95.0", + "target": "x86_64-unknown-linux-musl" + }, "linux": { "version": "6.18.38", "upstream_url": "https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.18.38.tar.xz", @@ -31,7 +35,9 @@ "minirootfs_sha256": "41f73e3cf5fa919b8aa5ca6b30dc48f0da2720776d7423e2a7748211456fe081", "guest_sources": [ "guest/common", - "guest/alpine" + "guest/alpine", + "nvx-rs", + "rust-toolchain.toml" ], "package_manifests": [ "build/initramfs.cpio.gz.packages.json" @@ -52,7 +58,9 @@ "guest_sources": [ "guest/common", "guest/ubuntu", - "ubuntu/packages.lock.json" + "ubuntu/packages.lock.json", + "nvx-rs", + "rust-toolchain.toml" ], "package_manifests": [ "build/initramfs-ubuntu.cpio.gz.packages.json", @@ -70,7 +78,9 @@ "package_lock_sha256": "f2014a858f46482778f138b374f80c36df389206ae677870d58a7e919bec182b", "guest_sources": [ "guest/common", - "azurelinux/packages.lock.json" + "azurelinux/packages.lock.json", + "nvx-rs", + "rust-toolchain.toml" ], "package_manifests": [ "build/initramfs-azurelinux.cpio.gz.packages.json" diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2990dad39..43e15f921 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -8,8 +8,16 @@ the distribution tooling. | --- | --- | --- | | Linux 6.18.38 | Generated under `.cache/linux` | GPL-2.0-only; the upstream archive and patch series are pinned in `SOURCE-MANIFEST.json` and `kernel/patches`. Each build exports its exact config, and `collect-sources` emits the complete patched tree. | | OpenVMM | `openvmm` | MIT; see `openvmm/LICENSE`. Source is a private Git submodule and is available only to authorized users. | -| NVX guest/build sources | `guest`, `ubuntu`, `kernel`, `scripts/nvx_tools` | MIT; see `LICENSE`. | -| musl libc (Debian 12 `musl`) | statically linked into `/sbin/nvx-time`, `/sbin/nvx-time-probe`, and `/sbin/nvx-exit` in generated initramfs images | MIT; Copyright © 2005-2020 Rich Felker, et al. The build image's `/usr/share/doc/musl/copyright` carries the full notice, and the source is Debian's `musl` source package. | +| NVX guest/build sources | `guest`, `nvx-rs`, `ubuntu`, `kernel`, `scripts/nvx_tools` | MIT; see `LICENSE`. | +| Rust core and standard libraries | linked into all nine Rust guest binaries listed in the musl row | MIT OR Apache-2.0; the exact release is pinned by `rust-toolchain.toml`. Source and license files are under [`rust-lang/rust/library`](https://github.com/rust-lang/rust/tree/master/library), with `LICENSE-MIT` and `LICENSE-APACHE` at the repository root. | +| Rust `compiler_builtins` | Rust target runtime routines used by the guest binaries | MIT OR Apache-2.0 for `rust-lang/compiler-builtins`; routines derived from LLVM compiler-rt are Apache-2.0 WITH LLVM-exception. Source is the revision bundled by the pinned Rust release under [`rust-lang/compiler-builtins`](https://github.com/rust-lang/compiler-builtins) and its [`llvm-project/compiler-rt`](https://github.com/llvm/llvm-project/tree/main/compiler-rt) sources. | +| Rust `libc` crate 0.2.189 | linked into the guest binaries 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 | 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` | 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 | 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` and `getrandom` dependency in the guest binaries | 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 | 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). | +| 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`, and `/sbin/nvx-time-probe` in every generated initramfs | 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/). | | Alpine Linux packages | generated initramfs images | Per-package licenses. Every build emits `*.packages.json`; retain that manifest and collect the corresponding aports recipes and upstream sources before distributing an image. | | Azure Linux 3.0 packages | generated Azure Linux initramfs | Per-package licenses, including GPLv2 BusyBox. The base image is digest-pinned in `SOURCE-MANIFEST.json`, every supplemental RPM is SHA-256 pinned in `azurelinux/packages.lock.json`, and every build emits `initramfs-azurelinux.cpio.gz.packages.json`. `collect-sources` does not collect this corresponding source yet, so `--include-source` packages omit the Azure Linux initramfs; publish the matching source before distributing a binary-only package that contains it. | | Ubuntu Base 26.04.1 LTS and supplemental packages | generated Ubuntu initramfs and EROFS images | Per-package licenses. The base archive and every supplemental `.deb` are SHA-256 pinned. Retain the generated Ubuntu manifests and publish the matching `.dsc` plus all referenced source members before distributing an image. Ubuntu artifacts contain Ubuntu userland with the NVX kernel, not the Ubuntu kernel. | diff --git a/doc/benchmarks.md b/doc/benchmarks.md index a7002fddd..d886326b3 100644 --- a/doc/benchmarks.md +++ b/doc/benchmarks.md @@ -214,8 +214,10 @@ MSHV, and WHP. They do not create TAP devices or require host firewall rules. Each workload directory includes `benchmark-metadata.json` with the platform, backend, ABI, processor count, host affinity set, memory sizes, artifact revisions, repository dirty state, warmups, measured run counts, and SHA-256 values for the VMM, kernel, initramfs, and coordinator. -Device metadata also records helper source and packaged-binary hashes. Collection rejects -mismatched lifecycle/workload metadata and duplicate topology rows. +Device metadata also records the aggregate Rust guest-source digest, pinned +toolchain and target, and the packaged `nvx-device-io` binary hash from the +initramfs manifest. Collection rejects mismatched lifecycle/workload metadata +and duplicate topology rows. Use a fixed affinity set containing one logical processor per physical core and at least `N+2` processors for an `N`-vCPU guest. The additional processors @@ -398,8 +400,9 @@ measures complete process wall time while host and guest exchange files through The dedicated suite always uses the canonical microVM and attaches the block backing object as the writable `scratch` role. There is no ABI selector or -legacy unroled `--virtio-blk` control. Every guest has 256 MiB RAM and runs the same static, dependency-free -x86-64 helper from the reproducible initramfs. Canonical runs execute five warmups followed by 30 +legacy unroled `--virtio-blk` control. Every guest has 256 MiB RAM and runs the +same static Rust x86-64 binary from the reproducible initramfs. +Canonical runs execute five warmups followed by 30 retained attempts for each device. Every operation window lasts ten seconds against a 512 MiB backing object. diff --git a/doc/build.md b/doc/build.md index ac30ffbca..8a94ce107 100644 --- a/doc/build.md +++ b/doc/build.md @@ -31,6 +31,22 @@ python3 scripts/nvx.py build-guest --native python3 scripts/nvx.py build-guest --native --guest ubuntu ``` +Both native image builds call the same guest-binary routine that Docker and +the `nvx-rs` CI job use. It can also be run directly: + +```bash +python3 scripts/nvx.py build-guest-binaries --output build/guest-binaries +``` + +The routine takes the exact toolchain from `rust-toolchain.toml`, the +`x86_64-unknown-linux-musl` target, and the `guest` profile and feature. It +uses `--locked`, a fresh target directory, disabled incremental compilation, +stable source and Cargo-home path remapping, and no linker build IDs. One +Cargo invocation builds all nine programs, and every result must pass +`check-static-elf`. The output's `manifest.json` binds the exact build pins, +one digest over the `nvx-rs` crate, the shared Python build recipe, and +`rust-toolchain.toml`, and each binary SHA-256. + The native build keeps the Linux archive, the Ubuntu Base archive, and the supplemental Ubuntu packages under `downloads/` in the cache directory, `.cache/` or `$NVX_CACHE_DIR`. It reuses a file there whose SHA-256 matches its @@ -95,8 +111,8 @@ recording its provenance. Host OS detection and backend validation live in Fixed build inputs and defaults live in [`build_constants.py`](../scripts/nvx_tools/build_constants.py). Its namespace -classes group kernel, OpenVMM, Alpine, Ubuntu, initramfs, Docker, cache-tool, -and release settings. For example, Python callers use +classes group kernel, OpenVMM, Rust guest-binary, Alpine, Ubuntu, initramfs, +Docker, cache-tool, and release settings. For example, Python callers use `KernelBuildConstants.VERSION` and `AlpineBuildConstants.MINIROOTFS_SHA256`; the previous module-level constants are not re-exported. Shared repository and artifact directories belong to `BuildConstants`. @@ -108,10 +124,10 @@ not frozen into the constants module. The provenance sidecars bind the kernel to its pinned archive, patch set, input configuration, generated configuration, and output hash; bind the -initramfs and package manifest to the pinned Alpine inputs and source files; -and bind OpenVMM to the exact clean gitlink revision and executable hash. -The Alpine source-file inputs include the constants module. Packaging rejects -missing, dirty, stale, or mismatched provenance. +initramfs and package manifest to the pinned Alpine inputs, Rust guest inputs, +and source files; and bind OpenVMM to the exact clean gitlink revision and +executable hash. Packaging rejects missing, dirty, stale, or mismatched +provenance. Ubuntu adds: @@ -164,12 +180,19 @@ Both commands need the standard guest build outputs above. The second writes complete per-scenario logs under `build/test-results/microvm` by default. -The Alpine initramfs includes the sandbox PID-1 bootstrap, its container namespace -helpers, the static `nvx-device-io` benchmark helper, the static `nvx-time-probe` -guest time probe, and the static `nvx-time` guest time component under `/sbin`. -The probe measures TSC and clock read latency, reports the guest's clock, -clocksource, TSC flags, and hypervisor identity, and runs the cross-vCPU warp -test that the +All three initramfses install the same nine Rust executables under `/sbin`: +`nvx-console-pending`, `nvx-device-io`, `nvx-exit`, `nvx-ingress`, +`nvx-managed-agent`, `nvx-mmio-write`, `nvx-reseed`, `nvx-time`, and +`nvx-time-probe`. Docker builds them once in a Rust +1.95.0 image pinned by digest and copies the identical bytes into Alpine, +Ubuntu, and Azure Linux; the kernel targets do not depend on that build. The +Ubuntu determinism target instead rebuilds them from fresh targets in both +attempts. Native image builds also rebuild them fresh. + +The Alpine initramfs additionally includes the sandbox PID-1 bootstrap and +its container namespace scripts. `nvx-time-probe` measures TSC and +clock read latency, reports the guest's clock, clocksource, TSC flags, and +hypervisor identity, and runs the cross-vCPU warp test that the [time ABI](design/3.2-clock-and-time.md#327-cross-processor-consistency) uses to enforce its 1 µs skew bound. `nvx-time` implements the time ABI's [guest obligations](design/3.2-clock-and-time.md#3210-guest-obligations): the conformance @@ -186,18 +209,18 @@ executable mapping. `nvx-time exhaustive` runs the CI-only exhaustive check boots never run it. The `--report-only` flag (or the `nvx_time_abi=report-only` kernel command-line token) reports violations without powering off, for runs against VMMs that -predate the ABI. Both time tools are linked statically against musl -(`musl-gcc`): with static glibc each would add about 700 KB to the initramfs, -and every unpacked kilobyte delays cold boot. The +predate the ABI. All nine executables link the musl supplied by the pinned +Rust target. Every package manifest records the shared Rust source digest and +each installed binary digest. The [kernel configuration checks](#kernel-configuration-checks) cover the kernel features that the sandbox bootstrap needs. The APK manifest records the -`blkid` and `util-linux` tools used by the bootstrap plus the device helper's -source and binary SHA-256 values. +`blkid` and `util-linux` tools used by the bootstrap along with every +guest-binary digest. The Ubuntu initramfs uses Ubuntu userland with the NVX kernel. It is not an Ubuntu-kernel or systemd VM. Its distribution-neutral package manifest records the Ubuntu Base and supplemental binary/source identities, license metadata, -rootfs SHA-256, and NVX helper provenance. +rootfs SHA-256, and NVX guest-binary provenance. The kernel build, `build-kernel`, which the Docker build also runs, caches the verified and patched source under `.cache/linux`, uses `O=build/linux`, runs @@ -309,7 +332,9 @@ complete output before replacing an existing `dist/` version, which requires `--force`. Its `SOURCE-MANIFEST.json` records the package version and exact hashes for OpenVMM, Linux, the generated kernel config, each packaged guest initramfs and its package manifest, and the Ubuntu distro layer and its -manifest. The OpenVMM section advertises microVM ABI 2, +manifest. Its `guest_rust` section pins Rust 1.95.0 and +`x86_64-unknown-linux-musl`, and each guest's `guest_sources` includes +`nvx-rs` and `rust-toolchain.toml`. The OpenVMM section advertises microVM ABI 2, control-session protocol 1, and contract `nvx-microvm-v2-control-v2`; product guest-agent metadata is intentionally not part of this platform manifest. @@ -342,6 +367,8 @@ SHA256SUMS Packages built with `--include-source` additionally contain `source/` and omit the Azure Linux guest artifacts, whose corresponding source is not collected. +The project source archive includes `nvx-rs` and excludes its generated +`target/` tree. `SHA256SUMS` has sorted `SHA256 relative/path` entries using POSIX separators for every packaged file, including `SOURCE-MANIFEST.json`, except `SHA256SUMS` itself. diff --git a/doc/ci.md b/doc/ci.md index a84061ff4..fe2ffb3d4 100644 --- a/doc/ci.md +++ b/doc/ci.md @@ -231,10 +231,15 @@ process exits, so delayed final output chunks cannot create a false failure. Shared guest artifacts are built with Docker on a GitHub-hosted Ubuntu runner. The kernel, Alpine initramfs, Ubuntu initramfs, and Ubuntu EROFS layer use -separate cache keys. Ubuntu keys include the Canonical archive pin, -supplemental package lock, common guest sources, shared download and guest -descriptor modules, converter implementation, Dockerfile, and `.dockerignore`. -Artifact upload +separate cache keys. Every guest key includes `nvx-rs/**` except its `target` +directory, `rust-toolchain.toml`, the shared binary-build modules, the +Dockerfile, and `.dockerignore`; the Rust builder image itself is pinned by +digest. Because `hashFiles` hashes contents only, every guest key also ends +with the path-aware Rust source digest that the guest package manifests +record, which `nvx.py guest-binary-source-identity` prints. Ubuntu keys also +include the Canonical archive pin, supplemental +package lock, common guest sources, shared download and guest descriptor +modules, and converter implementation. Artifact upload retains the Alpine filenames and adds the distinct Ubuntu filenames. Each backend also boots the Ubuntu initramfs and runs `/sbin/nvx-sandbox-smoke` from the Ubuntu EROFS layer as UID/GID 65534 over a @@ -590,8 +595,9 @@ the `guest` profile. The action delegates the musl lint and tests to path-remapped build, static-ELF check, and digest manifest to [`nvx.py build-guest-binaries`](usage.md#build-guest-binaries), and the prebuilt-binary integration run to -`nvx.py test-guest-binaries`. The guests ship the C programs in -`guest/common/` until their build switches to these binaries. +`nvx.py test-guest-binaries`. The same shared routine supplies every native +and Docker guest build, and Alpine, Ubuntu, and Azure Linux ship its nine +executables. The development release job depends on `aci-edge-sandboxes` and `nvx-rs` and requires their combined Linux/Windows results to be successful; failed, @@ -657,7 +663,8 @@ restore repeats the key, paths, and gzip compression of the cache entry that they differ, because a mismatch would otherwise only cause a silent cache miss. Because the agent firewall blocks `cdn.kernel.org` and `cdimage.ubuntu.com`, even inside containers, the workflow also installs the native guest build -prerequisites, allows the unprivileged user namespaces that Alpine's `apk` uses +prerequisites, including the pinned Rust toolchain's +`x86_64-unknown-linux-musl` target, allows the unprivileged user namespaces that Alpine's `apk` uses for package triggers, and stages the pinned Linux and Ubuntu Base archives, so agents can rebuild guest artifacts with `build-guest --native`. When a runner has a separate `/mnt` disk with more free space than `/`, it mounts the @@ -669,7 +676,7 @@ Copilot always runs the version on `dev`, even for sessions based on other branches, so changes reach agent sessions only after they merge. Pushes run it as a normal workflow for validation when they change the workflow or a file that its steps take versions, requirements, metadata, or code from: the -`build-guest-artifacts` action, the files that pin its Rust, cargo-nextest, +`build-guest-artifacts` action, all `nvx-rs/**` files, the files that pin its Rust, cargo-nextest, and shell linter versions, `requirements-dev.txt`, `SOURCE-MANIFEST.json`, `.gitmodules`, the OpenVMM submodule pin, and the NVX CLI and its modules. diff --git a/doc/contribute.md b/doc/contribute.md index 4aeb0243c..256ee3ce2 100644 --- a/doc/contribute.md +++ b/doc/contribute.md @@ -92,7 +92,7 @@ and snapshot test, which boots NVX's kernel and Alpine initramfs when [`test-openvmm`](usage.md#test-openvmm) runs it. NVX owns behavior that depends on its patched Linux kernel, its Alpine, -Ubuntu, and Azure Linux userspaces, guest helpers, SMP behavior, or virtio +Ubuntu, and Azure Linux userspaces, guest binaries and scripts, SMP behavior, or virtio devices. Add those scenarios to `scripts/nvx_tools/microvm_tests.py` and run them with `scripts/nvx.py test-microvm --backend BACKEND`. Keep guest workloads in @@ -104,7 +104,8 @@ registers them. Unit tests for the host tooling live in `scripts/test_*.py`. The `aci_edge_sandboxes` and `nvx-rs` crates keep unit tests alongside their Rust -source and integration tests in their `tests` directories. Tests of the NVX +source and integration tests in their `tests` directories. The latter also +runs `tests/guest_tools.rs` against the exact guest-profile binaries. Tests of the NVX mechanics, such as the golden fixtures of the control protocol, belong in `nvx-rs`, and tests of the ACI Edge Sandboxes contract in `aci_edge_sandboxes`. The `scripts/nvx.py test-aci-edge-sandboxes --backend diff --git a/doc/guests/azurelinux-guest.md b/doc/guests/azurelinux-guest.md index 111601a66..baa028f63 100644 --- a/doc/guests/azurelinux-guest.md +++ b/doc/guests/azurelinux-guest.md @@ -60,9 +60,9 @@ The build then: checks every RPM signature, installs the RPMs, and requires `ldd`, `setpriv`, and BusyBox to be present; 3. records the installed RPM inventory for the package manifest; -4. installs the common NVX `/init`, the guest scripts it needs, and - statically linked helpers built from `guest/common`, so the helpers do not - depend on Azure Linux libraries; +4. installs the common NVX `/init`, the guest scripts it needs, and the same + nine static Rust binaries as Alpine and Ubuntu, so they do not depend on + Azure Linux libraries; 5. links the BusyBox applets that the shared guest scripts expect, including `/bin/sh`; every other command resolves to the Azure Linux packages; 6. gives the `nobody` account, UID 65534, the empty home `/nonexistent` @@ -99,11 +99,12 @@ initramfs into a RAM filesystem capped at half of guest memory; see The package manifest `initramfs-azurelinux.cpio.gz.packages.json` records the release, architecture, base image, input digest, and artifact SHA-256. Each package entry names the RPM's version, release, architecture, license, and -source RPM. The manifest also records the source and binary digests of the -freestanding device-I/O helper. The input digest is a domain-separated SHA-256 -over the release, architecture, base image, and the checkout files that define -the build: the Dockerfile, the package lock, the build modules that download -the RPMs and compute the digest, and `guest/common`. +source RPM. The manifest also records the shared aggregate Rust source digest +and each of the nine installed guest-binary digests. The input digest is a +domain-separated SHA-256 over the release, architecture, base image, and the +checkout files that define the build: the Dockerfile, package lock, build +modules, guest scripts, complete `nvx-rs` guest input set, and +`rust-toolchain.toml`. Release staging rejects a manifest that does not match the artifact, the base image, or the current inputs. Only binary-only packages carry the two Azure diff --git a/doc/guests/ubuntu-guest.md b/doc/guests/ubuntu-guest.md index afebde1a5..169a8b474 100644 --- a/doc/guests/ubuntu-guest.md +++ b/doc/guests/ubuntu-guest.md @@ -85,7 +85,8 @@ The builder then: 6. requires one `root` account, a `nobody` account with UID 65534 and home `/nonexistent`, a `nogroup` group, and usr-merged `/bin`, `/sbin`, `/lib`, and `/lib64` links; and -7. installs the NVX guest files from `guest/common` and `guest/ubuntu`. +7. installs the NVX scripts from `guest/common` and `guest/ubuntu`, and the + nine Rust guest binaries from `nvx-rs`. The builder never executes a binary from the Ubuntu tree on the host. Both artifacts are packed with root ownership, fixed timestamps, and a stable file @@ -114,6 +115,12 @@ snapshots, including the guest obligations of the time ABI. The test harness rejects the `console-snapshot` scenario for Ubuntu instead of substituting Alpine. +It installs the same nine Rust guest binaries as Alpine and Azure Linux under +their `/sbin/nvx-*` names. Docker copies all nine from one digest-pinned Rust +builder, while native and determinism builds use the shared fresh-target +routine. The package manifest records one aggregate `nvx-rs` source digest +and each installed binary's digest. + ## Ubuntu sandbox layer [`build-distro-layer`](../usage.md#build-distro-layer) turns the prepared tree diff --git a/doc/project-structure.md b/doc/project-structure.md index 818ce554d..eccb8faa5 100644 --- a/doc/project-structure.md +++ b/doc/project-structure.md @@ -16,7 +16,7 @@ are build products or caches and are not part of the tracked source tree. The | `doc` | User and contributor documentation | | `docker` | Reproducible guest build environment | | `kernel` | Reproducible configs and complete Linux patch series | -| `guest` | Common guest sources plus Alpine-control and Ubuntu shell helpers | +| `guest` | Common and distribution-specific guest scripts | | `ubuntu` | Pinned Ubuntu supplemental binary-package lock | | `azurelinux` | Checksum-pinned Azure Linux supplemental RPM lock | | `aci_edge_sandboxes` | Rust crate `aci_edge_sandboxes`: state-aware sandbox API with an OpenVMM backend and an opt-in native-agent backend | @@ -52,9 +52,9 @@ nvx/ | |-- actionlint.yaml Self-hosted runner labels for actionlint | `-- copilot-instructions.md Repository-wide Copilot instructions |-- .vscode/settings.json Shared VS Code settings -|-- guest/ Guest-owned scripts and static helpers -| |-- common/ Shared init, agent, snapshot, time, and test helpers -| |-- alpine/ Alpine-control container entry helpers +|-- guest/ Guest-owned scripts +| |-- common/ Shared init, agent, snapshot, and probe scripts +| |-- alpine/ Alpine-control container entry scripts | `-- ubuntu/ Ubuntu interactive-shell startup policy |-- ubuntu/ | `-- packages.lock.json Exact supplemental Ubuntu binary package closure @@ -201,9 +201,10 @@ OpenVMM release. ### `guest/`, `ubuntu/`, and `azurelinux/` -`guest/common` contains scripts and static helper sources shared by the Alpine, -Ubuntu, and Azure Linux initramfs builds, plus the Azure Linux variants of the -container-entry helpers. `init` controls early boot and then starts an +`guest/common` contains scripts shared by the Alpine, Ubuntu, and Azure Linux +initramfs builds, plus the Azure Linux variants of the container-entry +scripts. The nine compiled `/sbin/nvx-*` programs live under `nvx-rs/src/guest` +and `nvx-rs/src/bin`. `init` controls early boot and then starts an initramfs program named by `nvx_exec=`, the Alpine-only `nvx-init-agent` sandbox profile, `nvx-managed-agent` for a managed workload, a virtio restore probe for benchmark profiling, or the interactive shell. On an interactive @@ -213,14 +214,16 @@ the container launch and entry scripts that only the Alpine initramfs installs; the entry script stages Alpine's musl loader and `setpriv` inside the workload's root. -The sandbox helpers resolve fixed virtio-blk roles through +The sandbox scripts resolve fixed virtio-blk roles through sysfs, assemble EROFS lower layers over ext4 scratch, place the workload in its cgroup before release, construct its mount/PID/UTS namespaces, enter its filesystem root after dropping capabilities, and retain the agent as the outer PID 1. In a managed sandbox, the agent supervises `nvx-managed-agent` and performs the ordered unmount teardown after a stop request. The remaining common helpers handle shutdown, virtio-fs mounting, snapshot preparation, and -the guest side of the time ABI, or provide device-access and diagnostic probes. +the guest side of the time ABI. The Rust guest binaries provide shutdown, +managed control, restore repair, device access, ingress announcements, and +diagnostic probes. `ubuntu/packages.lock.json` pins the complete supplemental `.deb` closure installed without maintainer-script execution. @@ -270,8 +273,8 @@ authenticated control protocol of the guest agent, artifact discovery and bundled-release staging, OpenVMM's command line, the lowering of network and filesystem policies, process and platform primitives for Linux and Windows hosts, the supervision of VM instances, and a fake OpenVMM for tests. Its -`guest` feature adds the programs that will replace the C programs of -`guest/common/`, built as static musl executables. It is a +`guest` feature builds the nine static executables that every guest image +installs at `/sbin/nvx-*`. It is a standalone crate with its own `Cargo.lock`, like `aci_edge_sandboxes`, and builds independently of the OpenVMM submodule. See its [README](../nvx-rs/README.md). @@ -326,7 +329,7 @@ see [Automated environment bootstrap](setup.md#automated-environment-bootstrap). | `pyproject.toml` | Strict Pyright policy plus Ruff lint and format settings | | `requirements-dev.txt` | Pinned Python tools used by contributors and CI | | `rust-toolchain.toml` | Rust release that builds OpenVMM, its test guests, and the repository's Rust code | -| `SOURCE-MANIFEST.json` | Distribution version, OpenVMM microVM ABI and control-protocol versions, and exact Linux, Alpine, Ubuntu, and Azure Linux input identities and output locations | +| `SOURCE-MANIFEST.json` | Distribution version, OpenVMM microVM ABI and control-protocol versions, Rust guest toolchain and source roots, and exact Linux, Alpine, Ubuntu, and Azure Linux input identities and output locations | | `VERSION` | Distribution version consumed by packaging tools | | `.gitmodules` | OpenVMM repository URL, path, and tracking branch | | `.gitignore` | Excludes build products, caches, virtual environments, logs, and platform metadata | diff --git a/doc/setup.md b/doc/setup.md index 40c15572d..aebe36311 100644 --- a/doc/setup.md +++ b/doc/setup.md @@ -52,11 +52,21 @@ For Debian/Ubuntu hosts: sudo apt-get update sudo apt-get install -y \ bc binutils bison build-essential ca-certificates cpio curl erofs-utils flex \ - gzip libelf-dev libssl-dev musl-tools python3 rsync tar xz-utils zstd + gzip libelf-dev libssl-dev python3 rsync tar xz-utils zstd ``` -`musl-tools` provides `musl-gcc`, which native initramfs builds use for the -guest time tools; Docker builds install it in the build image. +Native guest builds use the exact Rust release in `rust-toolchain.toml` and +its musl standard-library target: + +```bash +toolchain=$(sed -n 's/^channel = "\([^"]*\)".*/\1/p' rust-toolchain.toml) +rustup toolchain install "$toolchain" --profile minimal +rustup target add --toolchain "$toolchain" x86_64-unknown-linux-musl +``` + +The Linux runner setup scripts and Copilot setup install this target +automatically. `build-essential` remains necessary for the kernel and as +Rust's linker driver; no separate guest libc compiler is required. ### Linux / KVM diff --git a/doc/usage.md b/doc/usage.md index d25010063..142489838 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -37,8 +37,9 @@ python3 scripts/nvx.py performance gate --help | `setup-cross-os-cache` | Install GNU tar and zstd for GitHub Actions cross-OS caches. | | `classify-ci-changes` | Select the CI suites that a list of changed files runs. | | `kernel-patch-identity` | Print the digest of the kernel patch paths and contents that keys the kernel cache. | +| `guest-binary-source-identity` | Print the digest of the Rust guest binary source paths and contents that keys the guest image caches. | | `check-required-ci` | Validate required GitHub Actions job results. | -| `check-static-elf` | Check that executables are static ELF files without an interpreter. | +| `check-static-elf` | Check that executables are static ELF files without an interpreter or an executable stack. | | `test-openvmm-unit` | Run the OpenVMM workspace unit and documentation tests. | | `test-openvmm` | Run OpenVMM Petri VMM tests. | | `test-microvm` | Run NVX Linux and device correctness tests through OpenVMM. | @@ -97,6 +98,20 @@ order, together with its content. The guest build appends it to both kernel cache keys, because `hashFiles` hashes contents only: a renamed patch changes the key even when its bytes do not. +### `guest-binary-source-identity` + +```text +python3 scripts/nvx.py guest-binary-source-identity [--root PATH] +``` + +Prints the source digest that guest package manifests record for the Rust guest +binaries: a digest of the relative path and content of every file under +`nvx-rs/src/`, the crate's manifest, lockfile, and build script, +`rust-toolchain.toml`, and the `build_constants.py` and `guest_binaries.py` +build modules. The guest build appends it to the Alpine, Ubuntu, and Azure +Linux cache keys, because `hashFiles` hashes contents only: moving a module +into its own `mod.rs` changes the key even when no bytes change. + ### `classify-ci-changes` ```text @@ -147,9 +162,10 @@ python3 scripts/nvx.py check-static-elf PATH [PATH ...] Prints each executable's ELF type, size, and linkage, and fails unless every one is a 64-bit little-endian ELF executable without a program interpreter (`PT_INTERP`) or needed libraries (`DT_NEEDED`), so that it runs in a guest -without a dynamic loader. A static position-independent executable passes. The -`nvx-rs` CI job checks the [guest binaries](../nvx-rs/README.md#guest-binaries) -with it. +without a dynamic loader. A static position-independent executable passes. It +also fails for an executable stack: a `PT_GNU_STACK` program header with +`PF_X`. The `nvx-rs` CI job checks the +[guest binaries](../nvx-rs/README.md#guest-binaries) with it. ### `check-guest-binaries` diff --git a/docker/Dockerfile b/docker/Dockerfile index 030c3eb8d..8fdeac9a4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,10 +1,26 @@ # syntax=docker/dockerfile:1.7 ARG DEBIAN_IMAGE=debian:12-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 +ARG RUST_BUILDER_IMAGE=rust:1.95.0-slim-bookworm@sha256:d7482085ff5b415f84dba5647ae71606650bdef00db7aeb69f4b3d170c3e4082 ARG EROFS_UTILS_VERSION=1.5-1 ARG AZURELINUX_VERSION=3.0 ARG AZURELINUX_IMAGE=mcr.microsoft.com/azurelinux/base/core@sha256:c877612270d1ee2d6ab2bc1f64bfe38ab697ac50be325154ee5129fce89c17e4 +FROM ${RUST_BUILDER_IMAGE} AS guest-binaries +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + python3 \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /repo +COPY rust-toolchain.toml /repo/ +COPY nvx-rs/ /repo/nvx-rs/ +COPY scripts/ /repo/scripts/ +RUN set -eux; \ + toolchain="$(sed -n 's/^channel = "\([^"]*\)".*/\1/p' rust-toolchain.toml)"; \ + test -n "$toolchain"; \ + rustup target add --toolchain "$toolchain" x86_64-unknown-linux-musl; \ + python3 scripts/nvx.py build-guest-binaries --output /out/guest-binaries + FROM ${DEBIAN_IMAGE} AS base ARG EROFS_UTILS_VERSION RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -20,7 +36,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ gzip \ libelf-dev \ libssl-dev \ - musl-tools \ patch \ python3 \ rsync \ @@ -43,10 +58,18 @@ RUN python3 scripts/nvx.py build-kernel FROM base AS kernel-debug RUN python3 scripts/nvx.py build-kernel --debug -FROM base AS initramfs +# Only the guest images install the Rust guest binaries and record their sources, +# so the kernel and Linux source targets never build them. +FROM base AS guest-base +COPY nvx-rs/ /repo/nvx-rs/ +COPY rust-toolchain.toml /repo/ +COPY --from=guest-binaries /out/guest-binaries/ /repo/build/guest-binaries/ +ENV NVX_GUEST_BINARIES=/repo/build/guest-binaries + +FROM guest-base AS initramfs RUN python3 scripts/nvx.py build-initramfs --guest alpine -FROM base AS ubuntu-initramfs +FROM guest-base AS ubuntu-initramfs RUN python3 scripts/nvx.py build-initramfs --guest ubuntu FROM ubuntu-initramfs AS ubuntu-distro-layer @@ -54,26 +77,16 @@ RUN python3 scripts/nvx.py build-distro-layer \ --guest ubuntu \ --output build/ubuntu-distro.erofs -FROM base AS ubuntu-determinism -RUN python3 scripts/nvx.py verify-guest-determinism \ +FROM guest-base AS ubuntu-determinism +COPY --from=guest-binaries /usr/local/cargo/ /usr/local/cargo/ +COPY --from=guest-binaries /usr/local/rustup/ /usr/local/rustup/ +ENV CARGO_HOME=/usr/local/cargo +ENV RUSTUP_HOME=/usr/local/rustup +ENV PATH=/usr/local/cargo/bin:${PATH} +RUN NVX_GUEST_BINARIES= python3 scripts/nvx.py verify-guest-determinism \ --guest ubuntu \ --work-dir build/guest-determinism -FROM base AS azurelinux-helpers -RUN set -eux; \ - mkdir -p /out/helpers; \ - for helper in nvx-reseed nvx-mmio-write nvx-console-pending nvx-managed-agent; do \ - cc -static -Os -s -Wall -Wextra -Werror -o "/out/helpers/$helper" "/repo/guest/common/$helper.c"; \ - done; \ - for helper in nvx-time nvx-exit nvx-ingress; do \ - musl-gcc -static -Os -s -Wall -Wextra -Werror -o "/out/helpers/$helper" "/repo/guest/common/$helper.c"; \ - done; \ - musl-gcc -std=gnu11 -static -O2 -pthread -s -Wall -Wextra -Werror -o /out/helpers/nvx-time-probe \ - /repo/guest/common/nvx-time-probe.c; \ - cc -nostdlib -static -Os -fno-builtin -fno-pie -fno-stack-protector -no-pie \ - -Wl,--build-id=none -Wl,-z,noexecstack -s -o /out/helpers/nvx-device-io \ - /repo/guest/common/nvx-device-io.c - FROM base AS azurelinux-packages RUN python3 - <<'PY' import sys @@ -97,12 +110,11 @@ RUN rpm --import /etc/pki/rpm-gpg/MICROSOFT-RPM-GPG-KEY \ && rpm -qa --queryformat '%{NAME}\t%{VERSION}\t%{RELEASE}\t%{ARCH}\t%{LICENSE}\t%{SOURCERPM}\n' \ | sort > /tmp/azurelinux-rpm-packages.tsv -FROM base AS azurelinux-initramfs +FROM guest-base AS azurelinux-initramfs ARG AZURELINUX_IMAGE ARG AZURELINUX_VERSION COPY --from=azurelinux-rootfs / /rootfs/ COPY --from=azurelinux-rootfs /tmp/azurelinux-rpm-packages.tsv /tmp/azurelinux-rpm-packages.tsv -COPY --from=azurelinux-helpers /out/helpers/ /rootfs/sbin/ RUN <<'EOF' set -eux install -m 0755 /repo/guest/common/init /rootfs/init @@ -113,6 +125,22 @@ install -m 0755 /repo/guest/common/nvx-hostmount \ /repo/guest/common/nvx-container-enter-azurelinux /rootfs/sbin/ install -m 0755 /repo/guest/common/nvx-container-launch-azurelinux \ /rootfs/sbin/nvx-container-launch +python3 <<'PY' +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, '/repo/scripts') +from nvx_tools.build_constants import GuestRustBuildConstants +from nvx_tools.guest_binaries import inspect_guest_binaries + +source = Path('/repo/build/guest-binaries') +inspect_guest_binaries(source) +for name in GuestRustBuildConstants.BINARIES: + destination = Path('/rootfs/sbin') / name + shutil.copyfile(source / name, destination) + destination.chmod(0o755) +PY # Link the BusyBox applets that the shared guest scripts expect; every other # command resolves through PATH to the Azure Linux packages. ln -sf ../sbin/busybox /rootfs/usr/bin/busybox @@ -171,10 +199,23 @@ from pathlib import Path sys.path.insert(0, '/repo/scripts') from nvx_tools.azurelinux import input_sha256 +from nvx_tools.build_constants import GuestRustBuildConstants +from nvx_tools.guest_binaries import inspect_guest_binaries def sha256(path: str) -> str: return hashlib.sha256(Path(path).read_bytes()).hexdigest() +guest_build = inspect_guest_binaries(Path('/repo/build/guest-binaries')) +helpers = {} +for name in GuestRustBuildConstants.BINARIES: + binary_sha256 = sha256(f'/rootfs/sbin/{name}') + if binary_sha256 != guest_build.binaries[name]: + raise ValueError(f'installed Rust guest binary changed: {name}') + helpers[name] = { + 'source_sha256': guest_build.source_sha256, + 'binary_sha256': binary_sha256, + } + manifest = { 'format': 1, 'guest': 'azurelinux', @@ -190,12 +231,7 @@ manifest = { ), 'package_manifest_format': 'azurelinux-v1', 'packages': json.loads(Path('/tmp/azurelinux-packages.json').read_text(encoding='utf-8')), - 'helpers': { - 'nvx-device-io': { - 'source_sha256': sha256('/repo/guest/common/nvx-device-io.c'), - 'binary_sha256': sha256('/rootfs/sbin/nvx-device-io'), - }, - }, + 'helpers': helpers, } Path('/tmp/initramfs-azurelinux.cpio.gz.packages.json').write_text( json.dumps(manifest, indent=2, sort_keys=True) + '\n', diff --git a/guest/common/nvx-console-pending.c b/guest/common/nvx-console-pending.c deleted file mode 100644 index 1c556d3a6..000000000 --- a/guest/common/nvx-console-pending.c +++ /dev/null @@ -1,40 +0,0 @@ -#include -#include -#include -#include -#include -#include - -int main(int argc, char **argv) -{ - int descriptor; - int pending; - - if (argc != 2) { - fprintf(stderr, "usage: nvx-console-pending DEVICE\n"); - return 2; - } - - descriptor = open(argv[1], O_RDONLY | O_CLOEXEC | O_NOCTTY | O_NONBLOCK); - if (descriptor < 0) { - fprintf(stderr, "nvx-console-pending: open %s: %s\n", argv[1], - strerror(errno)); - return 1; - } - if (ioctl(descriptor, FIONREAD, &pending) != 0) { - fprintf(stderr, "nvx-console-pending: ioctl %s: %s\n", argv[1], - strerror(errno)); - close(descriptor); - return 1; - } - if (close(descriptor) != 0) { - fprintf(stderr, "nvx-console-pending: close %s: %s\n", argv[1], - strerror(errno)); - return 1; - } - if (printf("%d\n", pending) < 0) { - fprintf(stderr, "nvx-console-pending: write: %s\n", strerror(errno)); - return 1; - } - return 0; -} \ No newline at end of file diff --git a/guest/common/nvx-device-io.c b/guest/common/nvx-device-io.c deleted file mode 100644 index 752b613be..000000000 --- a/guest/common/nvx-device-io.c +++ /dev/null @@ -1,376 +0,0 @@ -typedef unsigned long u64; - -#define SYS_READ 0 -#define SYS_WRITE 1 -#define SYS_CLOSE 3 -#define SYS_POLL 7 -#define SYS_PREAD64 17 -#define SYS_PWRITE64 18 -#define SYS_SOCKET 41 -#define SYS_CONNECT 42 -#define SYS_FSYNC 74 -#define SYS_FTRUNCATE 77 -#define SYS_CLOCK_GETTIME 228 -#define SYS_OPENAT 257 -#define SYS_EXIT_GROUP 231 - -#define AT_FDCWD -100 -#define O_RDONLY 0 -#define O_RDWR 2 -#define O_CREAT 64 -#define O_DIRECT 16384 -#define O_CLOEXEC 524288 -#define AF_INET 2 -#define SOCK_DGRAM 2 -#define SOCK_CLOEXEC 524288 -#define POLLIN 1 -#define CLOCK_MONOTONIC 1 - -struct timespec { - long seconds; - long nanoseconds; -}; - -struct sockaddr_in { - unsigned short family; - unsigned short port; - unsigned int address; - unsigned char padding[8]; -}; - -struct pollfd { - int fd; - short events; - short returned_events; -}; - -static unsigned char io_buffer[4096] __attribute__((aligned(4096))); -static char output[384]; -static u64 output_length; - -static long syscall6( - long number, - long argument1, - long argument2, - long argument3, - long argument4, - long argument5, - long argument6 -) { - register long register10 __asm__("r10") = argument4; - register long register8 __asm__("r8") = argument5; - register long register9 __asm__("r9") = argument6; - long result; - __asm__ volatile( - "syscall" - : "=a"(result) - : "a"(number), "D"(argument1), "S"(argument2), "d"(argument3), - "r"(register10), "r"(register8), "r"(register9) - : "rcx", "r11", "memory" - ); - return result; -} - -static long syscall4(long number, long a1, long a2, long a3, long a4) { - return syscall6(number, a1, a2, a3, a4, 0, 0); -} - -static long syscall3(long number, long a1, long a2, long a3) { - return syscall6(number, a1, a2, a3, 0, 0, 0); -} - -static long syscall2(long number, long a1, long a2) { - return syscall6(number, a1, a2, 0, 0, 0, 0); -} - -static long syscall1(long number, long a1) { - return syscall6(number, a1, 0, 0, 0, 0, 0); -} - -static int strings_equal(const char *left, const char *right) { - while (*left && *left == *right) { - left++; - right++; - } - return *left == *right; -} - -static int parse_u64(const char *text, u64 *value) { - u64 parsed = 0; - if (!*text) { - return -1; - } - while (*text) { - if (*text < '0' || *text > '9') { - return -1; - } - parsed = parsed * 10 + (u64)(*text - '0'); - text++; - } - *value = parsed; - return 0; -} - -static int parse_ipv4(const char *text, unsigned int *address) { - unsigned int octets[4] = {0, 0, 0, 0}; - int index = 0; - int digits = 0; - while (*text) { - if (*text == '.') { - if (!digits || index == 3) { - return -1; - } - index++; - digits = 0; - } else if (*text >= '0' && *text <= '9') { - octets[index] = octets[index] * 10 + (unsigned int)(*text - '0'); - if (octets[index] > 255) { - return -1; - } - digits++; - } else { - return -1; - } - text++; - } - if (index != 3 || !digits) { - return -1; - } - *address = octets[0] | (octets[1] << 8) | (octets[2] << 16) | - (octets[3] << 24); - return 0; -} - -static unsigned short network_u16(unsigned short value) { - return (unsigned short)((value << 8) | (value >> 8)); -} - -static u64 monotonic_ns(void) { - struct timespec value; - if (syscall2(SYS_CLOCK_GETTIME, CLOCK_MONOTONIC, (long)&value) < 0) { - return 0; - } - return (u64)value.seconds * 1000000000UL + (u64)value.nanoseconds; -} - -static void append_text(const char *text) { - while (*text && output_length < sizeof(output)) { - output[output_length++] = *text++; - } -} - -static void append_u64(u64 value) { - char digits[24]; - int length = 0; - do { - digits[length++] = (char)('0' + value % 10); - value /= 10; - } while (value); - while (length && output_length < sizeof(output)) { - output[output_length++] = digits[--length]; - } -} - -static int write_all(int descriptor, const void *data, u64 length) { - const char *current = data; - while (length) { - long written = syscall3(SYS_WRITE, descriptor, (long)current, (long)length); - if (written == -4) { - continue; - } - if (written <= 0) { - return -1; - } - current += written; - length -= (u64)written; - } - return 0; -} - -static int emit_result( - const char *device, - const char *operation, - u64 operations, - u64 bytes_per_operation, - u64 elapsed_ns -) { - output_length = 0; - append_text("{\"device\":\""); - append_text(device); - append_text("\",\"operation\":\""); - append_text(operation); - append_text("\",\"operations\":"); - append_u64(operations); - append_text(",\"bytes_per_operation\":"); - append_u64(bytes_per_operation); - append_text(",\"elapsed_ns\":"); - append_u64(elapsed_ns); - append_text("}\n"); - return write_all(1, output, output_length); -} - -static int run_file(int argc, char **argv) { - u64 duration_ms; - u64 size_bytes; - int writing; - int direct; - int create; - int flags; - long descriptor; - u64 started; - u64 ended; - u64 operations = 0; - u64 random = 0x9e3779b97f4a7c15UL; - u64 block_count; - int index; - - if (argc != 8 || parse_u64(argv[4], &duration_ms) || - parse_u64(argv[5], &size_bytes) || duration_ms == 0 || - size_bytes < sizeof(io_buffer)) { - return 2; - } - writing = strings_equal(argv[3], "write"); - if (!writing && !strings_equal(argv[3], "read")) { - return 2; - } - direct = strings_equal(argv[6], "direct"); - create = strings_equal(argv[7], "create"); - if ((!direct && !strings_equal(argv[6], "buffered")) || - (!create && !strings_equal(argv[7], "existing"))) { - return 2; - } - flags = (writing ? O_RDWR : O_RDONLY) | O_CLOEXEC; - if (direct) { - flags |= O_DIRECT; - } - if (create) { - flags |= O_CREAT; - } - descriptor = syscall4(SYS_OPENAT, AT_FDCWD, (long)argv[2], flags, 0600); - if (descriptor < 0) { - return 3; - } - if (create && syscall2(SYS_FTRUNCATE, descriptor, (long)size_bytes) < 0) { - syscall1(SYS_CLOSE, descriptor); - return 3; - } - for (index = 0; index < (int)sizeof(io_buffer); index++) { - io_buffer[index] = (unsigned char)(index * 31 + 17); - } - block_count = size_bytes / sizeof(io_buffer); - started = monotonic_ns(); - if (!started) { - syscall1(SYS_CLOSE, descriptor); - return 3; - } - ended = started; - while (ended - started < duration_ms * 1000000UL) { - u64 offset; - long transferred; - random = random * 6364136223846793005UL + 1442695040888963407UL; - offset = (random % block_count) * sizeof(io_buffer); - transferred = writing - ? syscall4(SYS_PWRITE64, descriptor, (long)io_buffer, sizeof(io_buffer), offset) - : syscall4(SYS_PREAD64, descriptor, (long)io_buffer, sizeof(io_buffer), offset); - if (transferred != (long)sizeof(io_buffer)) { - syscall1(SYS_CLOSE, descriptor); - return 4; - } - operations++; - if ((operations & 63) == 0) { - ended = monotonic_ns(); - } - } - ended = monotonic_ns(); - if (writing) { - syscall1(SYS_FSYNC, descriptor); - } - syscall1(SYS_CLOSE, descriptor); - return emit_result("file", argv[3], operations, sizeof(io_buffer), ended - started); -} - -static int run_network(int argc, char **argv) { - u64 port; - u64 duration_ms; - unsigned int address; - struct sockaddr_in peer = {0}; - struct pollfd poll_descriptor; - long descriptor; - u64 started; - u64 ended; - u64 operations = 0; - int index; - - if (argc != 5 || parse_ipv4(argv[2], &address) || - parse_u64(argv[3], &port) || port == 0 || port > 65535 || - parse_u64(argv[4], &duration_ms) || duration_ms == 0) { - return 2; - } - descriptor = syscall3(SYS_SOCKET, AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); - if (descriptor < 0) { - return 3; - } - peer.family = AF_INET; - peer.port = network_u16((unsigned short)port); - peer.address = address; - if (syscall3(SYS_CONNECT, descriptor, (long)&peer, sizeof(peer)) < 0) { - syscall1(SYS_CLOSE, descriptor); - return 3; - } - for (index = 0; index < 64; index++) { - io_buffer[index] = (unsigned char)(index + 1); - } - poll_descriptor.fd = (int)descriptor; - poll_descriptor.events = POLLIN; - started = monotonic_ns(); - if (!started) { - syscall1(SYS_CLOSE, descriptor); - return 3; - } - ended = started; - while (ended - started < duration_ms * 1000000UL) { - long received; - if (syscall3(SYS_WRITE, descriptor, (long)io_buffer, 64) != 64) { - syscall1(SYS_CLOSE, descriptor); - return 4; - } - poll_descriptor.returned_events = 0; - if (syscall3(SYS_POLL, (long)&poll_descriptor, 1, 100) > 0 && - (poll_descriptor.returned_events & POLLIN)) { - received = syscall3(SYS_READ, descriptor, (long)io_buffer, 64); - if (received == 64) { - operations++; - } - } - ended = monotonic_ns(); - } - syscall1(SYS_CLOSE, descriptor); - return emit_result("network", "roundtrip", operations, 64, ended - started); -} - -static int benchmark_main(int argc, char **argv) { - if (argc > 1 && strings_equal(argv[1], "file")) { - return run_file(argc, argv); - } - if (argc > 1 && strings_equal(argv[1], "network")) { - return run_network(argc, argv); - } - return 2; -} - -void nvx_device_io_start(long *stack) { - int status = benchmark_main((int)stack[0], (char **)&stack[1]); - syscall1(SYS_EXIT_GROUP, status); - for (;;) { - } -} - -__asm__( - ".global _start\n" - ".type _start,@function\n" - "_start:\n" - "mov %rsp,%rdi\n" - "andq $-16,%rsp\n" - "call nvx_device_io_start\n" - "ud2\n" -); \ No newline at end of file diff --git a/guest/common/nvx-exit.c b/guest/common/nvx-exit.c deleted file mode 100644 index 6f581ad8a..000000000 --- a/guest/common/nvx-exit.c +++ /dev/null @@ -1,46 +0,0 @@ -/* - * nvx-exit [CODE]: stop the VM with an 8-bit exit code, by writing one byte - * to OpenVMM's control port 0x604 through /dev/port. CODE defaults to 1, and - * any CODE that isn't a decimal number from 0 to 255 becomes 1. It prints - * nothing; if the write fails, it exits with status 1. - * - * This is a single static exec rather than a shell script, because a guest's - * first command after a restore pays a cold first touch for every fork, exec - * and exit, and the script ran sh, a command substitution and a printf | dd - * pipeline. - */ -#include -#include -#include - -/* Tests build against a regular file; the guest always writes /dev/port. */ -#ifndef NVX_EXIT_DEVICE -#define NVX_EXIT_DEVICE "/dev/port" -#endif -#define NVX_EXIT_PORT 0x604 - -static unsigned char exit_code(const char *argument) -{ - unsigned int value = 0; - - if (argument == NULL || *argument == '\0') - return 1; - for (; *argument != '\0'; argument++) { - if (*argument < '0' || *argument > '9') - return 1; - value = value * 10 + (unsigned int)(*argument - '0'); - if (value > 255) - return 1; - } - return (unsigned char)value; -} - -int main(int argc, char **argv) -{ - unsigned char code = exit_code(argc > 1 ? argv[1] : NULL); - int port = open(NVX_EXIT_DEVICE, O_WRONLY); - - if (port < 0) - return 1; - return pwrite(port, &code, 1, NVX_EXIT_PORT) == 1 ? 0 : 1; -} diff --git a/guest/common/nvx-ingress.c b/guest/common/nvx-ingress.c deleted file mode 100644 index 2b021a0e2..000000000 --- a/guest/common/nvx-ingress.c +++ /dev/null @@ -1,661 +0,0 @@ -// nvx-ingress announces the guest's TCP and UDP listeners to the gateway of -// the portable network profile, which publishes them to the host's private -// networks or, for host-loopback ingress alone, on host loopback. The init -// system starts it, as root, when the kernel command line carries -// virtnet_ingress=allow or virtnet_ingress=host-loopback. -// -// Every 100 ms it reads the kernel's socket tables. A TCP socket in the LISTEN -// state is a listener, and so is an unconnected UDP socket that persists for a -// second, which skips short-lived client sockets, such as those of DNS queries. -// Each UDP socket settles on its own, identified by the kernel's inode for it, -// and stays announced while it exists, even once a server connects it to its -// client; another socket that reuses a port, such as a client's, settles anew, -// and a port announces the families of its announced sockets. Sockets bound to -// loopback addresses, or to IPv6 link-local ones, are unreachable through the -// gateway. A TCP listener on the unspecified IPv6 address also takes IPv4 -// connections unless the kernel's socket diagnostics report it IPv6-only; a UDP -// socket there, which the guest kernel's diagnostics do not cover, follows -// net.ipv6.bindv6only. It sends the canonical set of listeners to UDP port 1021 -// of the IPv4 gateway from a privileged port, which only root can bind, -// whenever the set changes and at least every 2 seconds. -// -// An announcement is the magic "NVXL", version 1, a flags byte whose bit 0 -// marks omitted listeners, a big-endian count, and that many entries in -// increasing order of protocol and port: the IP protocol number, a family -// mask (1 for IPv4, 2 for IPv6), and the big-endian port. -// -// "nvx-ingress test DIRECTORY" runs the numbered snapshots in DIRECTORY, from -// 0, as successive scans instead, and prints each scan's time and -// announcement in hexadecimal. A snapshot holds the socket tables under net/, -// optionally sys/net/ipv6/bindv6only, optionally a socket diagnostics dump in -// diag, and the scan time in milliseconds in time. - -#define _GNU_SOURCE - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define ANNOUNCEMENT_PORT 1021 -#define FIRST_SOURCE_PORT 1023 -#define LAST_SOURCE_PORT 512 -#define MAX_ANNOUNCED 64 -#define MAX_TRACKED 1024 -#define SCAN_INTERVAL_MS 100 -#define REFRESH_INTERVAL_MS 2000 -#define UDP_SETTLE_MS 1000 -#define FAMILY_IPV4 1 -#define FAMILY_IPV6 2 -#define PROTOCOL_TCP 6 -#define PROTOCOL_UDP 17 -#define TCP_LISTEN 0x0a -#define UDP_CONNECTED 0x01 -#define UDP_UNCONNECTED 0x07 -#define HEADER_LENGTH 8 -#define ENTRY_LENGTH 4 - -// The kernel's netlink socket diagnostics ABI (linux/netlink.h, -// linux/sock_diag.h, and linux/inet_diag.h), whose headers musl lacks. -#define NETLINK_SOCK_DIAG_PROTOCOL 4 -#define NETLINK_REQUEST 0x01 -#define NETLINK_DUMP 0x300 -#define NETLINK_ERROR 2 -#define NETLINK_DONE 3 -#define SOCK_DIAG_BY_FAMILY 20 -#define INET_DIAG_SKV6ONLY 11 - -struct netlink_header { - uint32_t length; - uint16_t type; - uint16_t flags; - uint32_t sequence; - uint32_t pid; -}; - -struct netlink_address { - sa_family_t family; - unsigned short pad; - uint32_t pid; - uint32_t groups; -}; - -struct netlink_attribute { - uint16_t length; - uint16_t type; -}; - -struct inet_diag_socket_id { - uint16_t source_port; - uint16_t destination_port; - uint32_t source[4]; - uint32_t destination[4]; - uint32_t interface; - uint32_t cookie[2]; -}; - -struct inet_diag_request { - uint8_t family; - uint8_t protocol; - uint8_t extensions; - uint8_t pad; - uint32_t states; - struct inet_diag_socket_id id; -}; - -struct inet_diag_message { - uint8_t family; - uint8_t state; - uint8_t timer; - uint8_t retransmits; - struct inet_diag_socket_id id; - uint32_t expires; - uint32_t receive_queue; - uint32_t write_queue; - uint32_t uid; - uint32_t inode; -}; - -// Whether the IPv6 TCP listeners on the unspecified address at one port take -// IPv4 connections, as the kernel's socket diagnostics report them. -struct wildcard_listener { - uint16_t port; - uint8_t dual_stack; - uint8_t ipv6_only; -}; - -struct listener { - uint8_t protocol; - uint8_t families; - uint16_t port; -}; - -struct table { - struct listener entries[MAX_TRACKED]; - size_t count; -}; - -// A reachable UDP socket, which the kernel's inode identifies while it exists. -struct udp_socket { - uint16_t port; - uint8_t families; - uint8_t connected; - unsigned long inode; -}; - -// A UDP socket that is settling or, once it has persisted unconnected for -// UDP_SETTLE_MS, announced. -struct udp_state { - uint16_t port; - uint8_t families; - uint8_t announced; - unsigned long inode; - uint64_t first_seen_ms; -}; - -static struct table found; -static struct udp_socket udp_sockets[MAX_TRACKED]; -static size_t udp_socket_count; -static struct udp_state udp_states[MAX_TRACKED]; -static size_t udp_state_count; -static struct udp_state next_udp_states[MAX_TRACKED]; -static uint8_t payload[HEADER_LENGTH + MAX_ANNOUNCED * ENTRY_LENGTH]; -static uint8_t last_payload[sizeof(payload)]; -static size_t last_length; -static int ipv6_dual_stack = 1; -static struct wildcard_listener wildcard_tcp[MAX_TRACKED]; -static size_t wildcard_tcp_count; -static uint8_t diag_buffer[32768] __attribute__((aligned(8))); -// The root of the socket tables and sysctls, which test mode replaces. -static const char *proc_root = "/proc"; -static int test_mode; - -static uint64_t now_ms(void) -{ - struct timespec now; - clock_gettime(CLOCK_MONOTONIC, &now); - return (uint64_t)now.tv_sec * 1000 + (uint64_t)now.tv_nsec / 1000000; -} - -static void sleep_ms(unsigned int milliseconds) -{ - struct timespec delay = { - .tv_sec = milliseconds / 1000, - .tv_nsec = (long)(milliseconds % 1000) * 1000000, - }; - while (nanosleep(&delay, &delay) != 0 && errno == EINTR) { - } -} - -// Records a listener, merging the address families of one protocol and port. -static void add(struct table *table, uint8_t protocol, uint16_t port, uint8_t families) -{ - for (size_t index = 0; index < table->count; ++index) { - struct listener *entry = &table->entries[index]; - if (entry->protocol == protocol && entry->port == port) { - entry->families |= families; - return; - } - } - if (table->count < MAX_TRACKED) { - table->entries[table->count++] = (struct listener){ - .protocol = protocol, - .families = families, - .port = port, - }; - } -} - -// Returns the tracked state of the UDP socket with `inode` at `port`, if any. -static const struct udp_state *find_udp_state(uint16_t port, unsigned long inode) -{ - for (size_t index = 0; index < udp_state_count; ++index) { - if (udp_states[index].port == port && udp_states[index].inode == inode) { - return &udp_states[index]; - } - } - return NULL; -} - -static int parse_hex_u32(const char *text, uint32_t *value) -{ - char digits[9]; - char *end; - memcpy(digits, text, 8); - digits[8] = '\0'; - errno = 0; - unsigned long parsed = strtoul(digits, &end, 16); - if (errno != 0 || *end != '\0' || end != digits + 8) { - return 0; - } - *value = (uint32_t)parsed; - return 1; -} - -// Records the IPV6_V6ONLY state of an IPv6 TCP listener on the unspecified -// address from one socket diagnostics message. -static void note_wildcard_tcp(const uint8_t *message, size_t length) -{ - struct inet_diag_message diag; - if (length < sizeof(diag)) { - return; - } - memcpy(&diag, message, sizeof(diag)); - if (diag.family != AF_INET6 || diag.state != TCP_LISTEN) { - return; - } - for (int word = 0; word < 4; ++word) { - if (diag.id.source[word] != 0) { - return; - } - } - uint16_t port = ntohs(diag.id.source_port); - for (size_t offset = sizeof(diag); offset + sizeof(struct netlink_attribute) <= length;) { - struct netlink_attribute attribute; - memcpy(&attribute, message + offset, sizeof(attribute)); - if (attribute.length < sizeof(attribute) || attribute.length > length - offset) { - return; - } - if (attribute.type == INET_DIAG_SKV6ONLY && attribute.length > sizeof(attribute)) { - uint8_t ipv6_only = message[offset + sizeof(attribute)] != 0; - struct wildcard_listener *entry = NULL; - for (size_t index = 0; index < wildcard_tcp_count; ++index) { - if (wildcard_tcp[index].port == port) { - entry = &wildcard_tcp[index]; - } - } - if (entry == NULL && wildcard_tcp_count < MAX_TRACKED) { - entry = &wildcard_tcp[wildcard_tcp_count++]; - *entry = (struct wildcard_listener){.port = port}; - } - if (entry != NULL) { - entry->dual_stack |= (uint8_t)!ipv6_only; - entry->ipv6_only |= ipv6_only; - } - return; - } - offset += (attribute.length + 3u) & ~3u; - } -} - -// Records the IPv6 TCP listeners on the unspecified address of one part of a -// socket diagnostics dump, returning 0 once the dump ends or is malformed. -static int parse_wildcard_tcp(const uint8_t *buffer, size_t length) -{ - for (size_t offset = 0; offset + sizeof(struct netlink_header) <= length;) { - struct netlink_header header; - memcpy(&header, buffer + offset, sizeof(header)); - if (header.length < sizeof(header) || header.length > length - offset || - header.type == NETLINK_DONE || header.type == NETLINK_ERROR) { - return 0; - } - if (header.type == SOCK_DIAG_BY_FAMILY) { - note_wildcard_tcp(buffer + offset + sizeof(header), header.length - sizeof(header)); - } - offset += (header.length + 3u) & ~3u; - } - return 1; -} - -// Asks the kernel's socket diagnostics, or in test mode the snapshot's dump, -// which IPv6 TCP listeners on the unspecified address are IPv6-only. Without -// an answer, none is known. -static void query_wildcard_tcp(void) -{ - wildcard_tcp_count = 0; - if (test_mode) { - char path[4096]; - if (snprintf(path, sizeof(path), "%s/diag", proc_root) >= (int)sizeof(path)) { - return; - } - FILE *file = fopen(path, "rbe"); - if (file != NULL) { - size_t length = fread(diag_buffer, 1, sizeof(diag_buffer), file); - fclose(file); - parse_wildcard_tcp(diag_buffer, length); - } - return; - } - int fd = socket(AF_NETLINK, SOCK_DGRAM | SOCK_CLOEXEC, NETLINK_SOCK_DIAG_PROTOCOL); - if (fd < 0) { - return; - } - // A dump that never ends must not stall the scans. - struct timeval timeout = {.tv_usec = SCAN_INTERVAL_MS * 1000}; - struct { - struct netlink_header header; - struct inet_diag_request request; - } query = { - .header = - { - .length = sizeof(query), - .type = SOCK_DIAG_BY_FAMILY, - .flags = NETLINK_REQUEST | NETLINK_DUMP, - }, - .request = - { - .family = AF_INET6, - .protocol = IPPROTO_TCP, - .states = 1u << TCP_LISTEN, - }, - }; - struct netlink_address kernel = {.family = AF_NETLINK}; - if (setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) != 0 || - sendto(fd, &query, sizeof(query), 0, (struct sockaddr *)&kernel, sizeof(kernel)) != - (ssize_t)sizeof(query)) { - close(fd); - return; - } - for (;;) { - ssize_t received = recv(fd, diag_buffer, sizeof(diag_buffer), 0); - if (received <= 0 || !parse_wildcard_tcp(diag_buffer, (size_t)received)) { - break; - } - } - close(fd); -} - -// Returns the families through which an IPv6 socket of `protocol` on the -// unspecified address at `port` is reachable. -static uint8_t wildcard_families(uint8_t protocol, uint16_t port) -{ - if (protocol == PROTOCOL_TCP) { - for (size_t index = 0; index < wildcard_tcp_count; ++index) { - const struct wildcard_listener *entry = &wildcard_tcp[index]; - if (entry->port == port && (entry->dual_stack || entry->ipv6_only)) { - return entry->dual_stack ? FAMILY_IPV4 | FAMILY_IPV6 : FAMILY_IPV6; - } - } - } - return ipv6_dual_stack ? FAMILY_IPV4 | FAMILY_IPV6 : FAMILY_IPV6; -} - -// Returns the families through which a socket of `protocol` at `port`, bound -// to the kernel's hexadecimal rendering of an address, is reachable from the -// gateway, or 0. The kernel prints each 32-bit word of the address as it lies -// in memory. -static uint8_t reachable_families(const char *address, int ipv6, uint8_t protocol, uint16_t port) -{ - if (!ipv6) { - struct in_addr ipv4; - if (strlen(address) != 8 || !parse_hex_u32(address, &ipv4.s_addr)) { - return 0; - } - return (ntohl(ipv4.s_addr) >> 24) == 127 ? 0 : FAMILY_IPV4; - } - struct in6_addr ipv6_address; - if (strlen(address) != 32) { - return 0; - } - for (int word = 0; word < 4; ++word) { - uint32_t value; - if (!parse_hex_u32(address + word * 8, &value)) { - return 0; - } - memcpy(&ipv6_address.s6_addr[word * 4], &value, sizeof(value)); - } - if (IN6_IS_ADDR_UNSPECIFIED(&ipv6_address)) { - return wildcard_families(protocol, port); - } - if (IN6_IS_ADDR_LOOPBACK(&ipv6_address) || IN6_IS_ADDR_LINKLOCAL(&ipv6_address)) { - return 0; - } - if (IN6_IS_ADDR_V4MAPPED(&ipv6_address)) { - return ipv6_address.s6_addr[12] == 127 ? 0 : FAMILY_IPV4; - } - return FAMILY_IPV6; -} - -// Adds the TCP listeners of one of the kernel's socket tables, `name` under -// net/, to `found`, and its reachable UDP sockets, connected or not, to -// `udp_sockets`. -static void scan(const char *name, uint8_t protocol, int ipv6, uint16_t own_port) -{ - char line[512]; - char path[4096]; - if (snprintf(path, sizeof(path), "%s/net/%s", proc_root, name) >= (int)sizeof(path)) { - return; - } - FILE *file = fopen(path, "re"); - if (file == NULL) { - return; - } - if (fgets(line, sizeof(line), file) == NULL) { - fclose(file); - return; - } - while (fgets(line, sizeof(line), file) != NULL) { - char local[64]; - char remote[64]; - unsigned int state; - unsigned long inode; - if (sscanf(line, " %*u: %63s %63s %x %*s %*s %*s %*u %*u %lu", local, remote, &state, - &inode) != 4) { - continue; - } - char *local_port = strrchr(local, ':'); - char *remote_port = strrchr(remote, ':'); - if (local_port == NULL || remote_port == NULL) { - continue; - } - *local_port++ = '\0'; - *remote_port++ = '\0'; - unsigned long port = strtoul(local_port, NULL, 16); - if (port == 0 || port > UINT16_MAX) { - continue; - } - uint8_t connected = 0; - if (protocol == PROTOCOL_TCP) { - if (state != TCP_LISTEN) { - continue; - } - } else if (state == UDP_CONNECTED) { - connected = 1; - } else if (state != UDP_UNCONNECTED || strtoul(remote_port, NULL, 16) != 0 || - strspn(remote, "0") != strlen(remote) || port == own_port) { - continue; - } - uint8_t families = reachable_families(local, ipv6, protocol, (uint16_t)port); - if (families == 0) { - continue; - } - if (protocol == PROTOCOL_TCP) { - add(&found, protocol, (uint16_t)port, families); - } else if (udp_socket_count < MAX_TRACKED) { - udp_sockets[udp_socket_count++] = (struct udp_socket){ - .port = (uint16_t)port, - .families = families, - .connected = connected, - .inode = inode, - }; - } - } - fclose(file); -} - -static int compare_listeners(const void *left, const void *right) -{ - const struct listener *a = left; - const struct listener *b = right; - if (a->protocol != b->protocol) { - return a->protocol < b->protocol ? -1 : 1; - } - return a->port < b->port ? -1 : a->port > b->port; -} - -// Scans the socket tables and encodes the announcement into `payload`, -// returning its length. -static size_t collect(uint16_t own_port, uint64_t now) -{ - found.count = 0; - udp_socket_count = 0; - query_wildcard_tcp(); - scan("tcp", PROTOCOL_TCP, 0, own_port); - scan("tcp6", PROTOCOL_TCP, 1, own_port); - scan("udp", PROTOCOL_UDP, 0, own_port); - scan("udp6", PROTOCOL_UDP, 1, own_port); - - // An unconnected UDP socket is announced once it persists for - // UDP_SETTLE_MS, and stays announced, even once a server connects it to a - // client, while it exists. Each port announces the families of its - // announced sockets. - size_t next_count = 0; - for (size_t index = 0; index < udp_socket_count; ++index) { - const struct udp_socket *socket = &udp_sockets[index]; - const struct udp_state *previous = find_udp_state(socket->port, socket->inode); - struct udp_state next = { - .port = socket->port, - .families = socket->families, - .inode = socket->inode, - .first_seen_ms = previous != NULL ? previous->first_seen_ms : now, - }; - if (previous != NULL && previous->announced) { - next.announced = 1; - } else if (socket->connected) { - continue; - } else { - next.announced = now - next.first_seen_ms >= UDP_SETTLE_MS; - } - next_udp_states[next_count++] = next; - if (next.announced) { - add(&found, PROTOCOL_UDP, next.port, next.families); - } - } - memcpy(udp_states, next_udp_states, next_count * sizeof(udp_states[0])); - udp_state_count = next_count; - size_t announced = found.count; - - qsort(found.entries, announced, sizeof(found.entries[0]), compare_listeners); - size_t count = announced < MAX_ANNOUNCED ? announced : MAX_ANNOUNCED; - memcpy(payload, "NVXL", 4); - payload[4] = 1; - payload[5] = announced > MAX_ANNOUNCED; - payload[6] = (uint8_t)(count >> 8); - payload[7] = (uint8_t)count; - for (size_t index = 0; index < count; ++index) { - uint8_t *entry = &payload[HEADER_LENGTH + index * ENTRY_LENGTH]; - entry[0] = found.entries[index].protocol; - entry[1] = found.entries[index].families; - entry[2] = (uint8_t)(found.entries[index].port >> 8); - entry[3] = (uint8_t)found.entries[index].port; - } - return HEADER_LENGTH + count * ENTRY_LENGTH; -} - -static void read_bindv6only(void) -{ - char path[4096]; - if (snprintf(path, sizeof(path), "%s/sys/net/ipv6/bindv6only", proc_root) >= - (int)sizeof(path)) { - return; - } - FILE *file = fopen(path, "re"); - if (file == NULL) { - return; - } - int value; - if (fscanf(file, "%d", &value) == 1) { - ipv6_dual_stack = value == 0; - } - fclose(file); -} - -// Runs the snapshots in `directory` as successive scans; see the top of the -// file. -static int run_test(const char *directory) -{ - static char root[4096]; - test_mode = 1; - for (unsigned int index = 0;; ++index) { - char path[4200]; - if (snprintf(root, sizeof(root), "%s/%u", directory, index) >= (int)sizeof(root) || - snprintf(path, sizeof(path), "%s/time", root) >= (int)sizeof(path)) { - return 1; - } - FILE *file = fopen(path, "re"); - if (file == NULL) { - return index == 0; - } - unsigned long long now; - int parsed = fscanf(file, "%llu", &now); - fclose(file); - if (parsed != 1) { - return 1; - } - proc_root = root; - ipv6_dual_stack = 1; - read_bindv6only(); - size_t length = collect(FIRST_SOURCE_PORT, now); - printf("%llu ", now); - for (size_t offset = 0; offset < length; ++offset) { - printf("%02x", payload[offset]); - } - printf("\n"); - } -} - -int main(int argc, char **argv) -{ - if (argc == 3 && strcmp(argv[1], "test") == 0) { - return run_test(argv[2]); - } - struct sockaddr_in gateway = { - .sin_family = AF_INET, - .sin_port = htons(ANNOUNCEMENT_PORT), - }; - if (argc != 2 || inet_pton(AF_INET, argv[1], &gateway.sin_addr) != 1) { - fprintf(stderr, "usage: nvx-ingress GATEWAY_IPV4\n"); - return 2; - } - signal(SIGINT, SIG_IGN); - signal(SIGHUP, SIG_IGN); - read_bindv6only(); - - int sock = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); - if (sock < 0) { - fprintf(stderr, "nvx-ingress: socket: %s\n", strerror(errno)); - return 1; - } - uint16_t own_port = 0; - for (int port = FIRST_SOURCE_PORT; port >= LAST_SOURCE_PORT && own_port == 0; --port) { - struct sockaddr_in source = { - .sin_family = AF_INET, - .sin_port = htons((uint16_t)port), - .sin_addr.s_addr = htonl(INADDR_ANY), - }; - if (bind(sock, (struct sockaddr *)&source, sizeof(source)) == 0) { - own_port = (uint16_t)port; - } else if (errno != EADDRINUSE) { - fprintf(stderr, "nvx-ingress: bind privileged port: %s\n", strerror(errno)); - return 1; - } - } - if (own_port == 0) { - fprintf(stderr, "nvx-ingress: no privileged source port is free\n"); - return 1; - } - - uint64_t last_sent = 0; - for (;;) { - uint64_t now = now_ms(); - size_t length = collect(own_port, now); - if (length != last_length || memcmp(payload, last_payload, length) != 0 || - now - last_sent >= REFRESH_INTERVAL_MS) { - if (sendto(sock, payload, length, 0, (struct sockaddr *)&gateway, - sizeof(gateway)) == (ssize_t)length) { - memcpy(last_payload, payload, length); - last_length = length; - last_sent = now; - } - } - sleep_ms(SCAN_INTERVAL_MS); - } -} diff --git a/guest/common/nvx-managed-agent.c b/guest/common/nvx-managed-agent.c deleted file mode 100644 index ac3b1e2b7..000000000 --- a/guest/common/nvx-managed-agent.c +++ /dev/null @@ -1,2431 +0,0 @@ -#define _GNU_SOURCE - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define OUTER_HEADER_LEN 44U -#define OUTER_MAX_PAYLOAD 65536U -#define OUTER_VERSION 1U -#define OUTER_GUEST_ATTACH 1U -#define OUTER_RESET 3U -#define OUTER_ACK 4U -#define OUTER_DATA 5U -#define OUTER_CREDIT 9U -#define OUTER_RECEIVE_WINDOW (1024U * 1024U) - -#define APP_HEADER_LEN 24U -#define APP_VERSION 1U -#define APP_PING 1U -#define APP_EXEC 2U -#define APP_STOP 3U -#define APP_CANCEL 4U -#define APP_FEATURES 5U -#define APP_MAPS 6U -#define APP_READY 0x81U -#define APP_STDOUT 0x82U -#define APP_STDERR 0x83U -#define APP_EXIT 0x84U -#define APP_STOPPED 0x85U -#define APP_ERROR 0xffU - -/* - * Control features that a host can rely on, advertised in the answer to - * FEATURES. The host refuses an image that lacks a feature it needs, so a - * behavior that a host depends on needs a bit here and in the host's copy - * (aci_edge_sandboxes, src/openvmm/protocol.rs). The init script of the image provides - * FEATURE_WORKLOAD_ACCOUNT; the agent speaks for it because both ship in one - * initramfs. An agent that predates FEATURES refuses the request as an - * unsupported operation, which the host reads as no features. Bit 1 announced - * bind mounts that nvx_map= kernel command-line tokens listed; it stays unused, - * so hosts that still send those tokens refuse this image. - */ -#define FEATURE_CANCEL (1U << 0) -#define FEATURE_WORKLOAD_ACCOUNT (1U << 2) -#define FEATURE_EXEC_CGROUP (1U << 3) -#define FEATURE_EXEC_ENVIRONMENT (1U << 4) -#define FEATURE_EXEC_CWD (1U << 5) -#define FEATURE_HOST_MAPPING_TABLE (1U << 6) - -#define MAX_ARGUMENTS 64U -#define MAX_ARGUMENT_LEN 4096U -#define MAX_ENVIRONMENT 256U -#define EXEC_EXTENDED 1U -#define EXEC_CWD_PRESENT 1U -#define EXEC_ENVIRONMENT_PRESENT 2U -#define EXEC_INHERIT_DEFAULT_ENV 4U -/* - * Set only in the configuration that a direct-mode child hands its launch - * helper, never on the wire: the child already entered the working directory, - * so the helper keeps it instead of looking the path up again. - */ -#define EXEC_CWD_ENTERED 0x8000U -#define MAX_OUTPUT_BYTES (1024U * 1024U) -#define OUTPUT_CHUNK_BYTES 32768U -#define CONTAINER_BARRIER_ATTEMPTS 500U -#define PORTB_CONSOLE 0xe9 -#define AGENT_STOPPED 1 -#ifndef CGROUP_ROOT -#define CGROUP_ROOT "/sys/fs/cgroup" -#endif -#define EXEC_CGROUP CGROUP_ROOT "/nvx-exec" -#ifndef HOSTFS_DIR -#define HOSTFS_DIR "/run/nvx/hostfs" -#endif -#define HOSTFS_ROOT HOSTFS_DIR "/root" -#define MAPS_TOKEN "nvx_maps=" -#define MAX_HOST_MAPPINGS 4096U -#define MAPS_HEADER_LEN 8U -#define MAP_ENTRY_HEADER_LEN 6U -#define MAP_READ_ONLY 1U -#define MAX_COMMAND_LINE 4096U -#define MAX_GUEST_PATH 4096U - -struct outer_record { - uint8_t type; - uint8_t instance_id[16]; - uint64_t epoch; - uint64_t sequence; - uint32_t payload_len; - uint8_t *payload; -}; - -struct control_session { - int fd; - uint8_t instance_id[16]; - uint64_t epoch; - uint64_t guest_sequence; - uint64_t host_sequence; -}; - -struct app_request { - uint8_t kind; - uint64_t request_id; - int32_t status; - uint32_t payload_len; - const uint8_t *payload; -}; - -struct agent_config { - const char *rootfs; - const char *hostname; - const char *uid; - const char *gid; - const char *user; - const char *home; - int direct; -}; - -struct exec_config { - char *cwd; - char **environment; - uint16_t environment_count; - int environment_present; - int inherit_default_env; -}; - -/* - * Host mappings that nvx_maps= announces and APP_MAPS requests deliver. Until - * every announced mapping is mounted, workloads are refused. - */ -struct host_mappings { - uint32_t expected; - uint32_t applied; - int failed; -}; - -#define LAUNCH_STAGE_CWD 1 -#define LAUNCH_STAGE_SETUP 2 - -/* What a direct-mode child reports when it fails before it executes setpriv. */ -struct launch_failure { - int32_t stage; - int32_t error; -}; - -static uint16_t read_u16(const uint8_t *bytes) -{ - return (uint16_t)bytes[0] | ((uint16_t)bytes[1] << 8); -} - -static uint32_t read_u32(const uint8_t *bytes) -{ - return (uint32_t)bytes[0] | ((uint32_t)bytes[1] << 8) | - ((uint32_t)bytes[2] << 16) | ((uint32_t)bytes[3] << 24); -} - -static uint64_t read_u64(const uint8_t *bytes) -{ - uint64_t value = 0; - unsigned int index; - - for (index = 0; index < 8; ++index) { - value |= (uint64_t)bytes[index] << (index * 8); - } - return value; -} - -static void write_u16(uint8_t *bytes, uint16_t value) -{ - bytes[0] = (uint8_t)value; - bytes[1] = (uint8_t)(value >> 8); -} - -static void write_u32(uint8_t *bytes, uint32_t value) -{ - bytes[0] = (uint8_t)value; - bytes[1] = (uint8_t)(value >> 8); - bytes[2] = (uint8_t)(value >> 16); - bytes[3] = (uint8_t)(value >> 24); -} - -static void write_u64(uint8_t *bytes, uint64_t value) -{ - unsigned int index; - - for (index = 0; index < 8; ++index) { - bytes[index] = (uint8_t)(value >> (index * 8)); - } -} - -static int read_exact(int fd, void *buffer, size_t length) -{ - uint8_t *bytes = buffer; - size_t offset = 0; - - while (offset < length) { - ssize_t count = read(fd, bytes + offset, length - offset); - if (count > 0) { - offset += (size_t)count; - } else if (count == 0) { - return -1; - } else if (errno != EINTR) { - return -1; - } - } - return 0; -} - -static int write_all(int fd, const void *buffer, size_t length) -{ - const uint8_t *bytes = buffer; - size_t offset = 0; - - while (offset < length) { - ssize_t count = write(fd, bytes + offset, length - offset); - if (count > 0) { - offset += (size_t)count; - } else if (count < 0 && errno == EINTR) { - continue; - } else { - return -1; - } - } - return 0; -} - -static void portb_log(const char *message) -{ - int fd = open("/dev/port", O_WRONLY | O_CLOEXEC); - - if (fd < 0) { - return; - } - while (*message != '\0') { - if (pwrite(fd, message, 1, PORTB_CONSOLE) != 1) { - break; - } - ++message; - } - close(fd); -} - -static void portb_error(const char *stage, int status) -{ - char message[128]; - int length = snprintf( - message, - sizeof(message), - "NVX-MANAGED-ERROR: stage=%s status=%d\n", - stage, - status); - - if (length > 0 && (size_t)length < sizeof(message)) { - portb_log(message); - } -} - -static void free_outer_record(struct outer_record *record) -{ - free(record->payload); - record->payload = NULL; - record->payload_len = 0; -} - -static int read_outer_record(int fd, struct outer_record *record) -{ - uint8_t header[OUTER_HEADER_LEN]; - - memset(record, 0, sizeof(*record)); - if (read_exact(fd, header, sizeof(header)) != 0) { - return -1; - } - if (memcmp(header, "NVXS", 4) != 0 || read_u16(header + 4) != OUTER_VERSION || - header[7] != 0) { - return -1; - } - record->type = header[6]; - memcpy(record->instance_id, header + 8, sizeof(record->instance_id)); - record->epoch = read_u64(header + 24); - record->sequence = read_u64(header + 32); - record->payload_len = read_u32(header + 40); - if (record->payload_len > OUTER_MAX_PAYLOAD) { - return -1; - } - if (record->payload_len != 0) { - record->payload = malloc(record->payload_len); - if (record->payload == NULL || - read_exact(fd, record->payload, record->payload_len) != 0) { - free_outer_record(record); - return -1; - } - } - return 0; -} - -static int write_outer_record( - int fd, - uint8_t type, - const uint8_t instance_id[16], - uint64_t epoch, - uint64_t sequence, - const void *payload, - uint32_t payload_len) -{ - uint8_t header[OUTER_HEADER_LEN] = {0}; - - if (payload_len > OUTER_MAX_PAYLOAD) { - return -1; - } - memcpy(header, "NVXS", 4); - write_u16(header + 4, OUTER_VERSION); - header[6] = type; - memcpy(header + 8, instance_id, 16); - write_u64(header + 24, epoch); - write_u64(header + 32, sequence); - write_u32(header + 40, payload_len); - if (write_all(fd, header, sizeof(header)) != 0) { - return -1; - } - return payload_len == 0 || write_all(fd, payload, payload_len) == 0 ? 0 : -1; -} - -/* Accepts a /-separated path without empty, "." (unless alone), or ".." components. */ -static int safe_path(const char *path, int absolute) -{ - const char *component = path; - - if (absolute) { - if (path[0] != '/') { - return 0; - } - component = path + 1; - if (*component == '\0') { - return 0; - } - } else if (strcmp(path, ".") == 0) { - return 1; - } - while (*component != '\0') { - const char *end = strchr(component, '/'); - size_t length = end == NULL ? strlen(component) : (size_t)(end - component); - - if (length == 0 || (length == 1 && component[0] == '.') || - (length == 2 && component[0] == '.' && component[1] == '.')) { - return 0; - } - component += length; - if (*component == '/') { - ++component; - if (*component == '\0') { - return 0; - } - } - } - return 1; -} - -/* Creates every missing directory of `path` up to, and optionally including, its last component. */ -static int make_directories(const char *path, int include_last) -{ - char buffer[MAX_GUEST_PATH]; - size_t index; - size_t length = strlen(path); - - if (length >= sizeof(buffer)) { - return -1; - } - memcpy(buffer, path, length + 1); - for (index = 1; index <= length; ++index) { - if (buffer[index] == '/' || (buffer[index] == '\0' && include_last)) { - char saved = buffer[index]; - - buffer[index] = '\0'; - if (mkdir(buffer, 0755) != 0 && errno != EEXIST) { - return -1; - } - buffer[index] = saved; - } - } - return 0; -} - -/* - * Reads the number of host mappings that nvx_maps=COUNT announces into - * `count`, or 0 without the token. Tokens are split in place. Older hosts - * listed their mappings in nvx_map= tokens, which are ignored. - */ -static int parse_host_mapping_count(char *command_line, uint32_t *count) -{ - char *token; - char *cursor; - int found = 0; - - *count = 0; - for (token = strtok_r(command_line, " \t\n", &cursor); token != NULL; - token = strtok_r(NULL, " \t\n", &cursor)) { - const char *digit = token + strlen(MAPS_TOKEN); - uint32_t value = 0; - - if (strncmp(token, MAPS_TOKEN, strlen(MAPS_TOKEN)) != 0) { - continue; - } - if (found || *digit == '\0') { - errno = EINVAL; - return -1; - } - for (; *digit != '\0'; ++digit) { - if (*digit < '0' || *digit > '9' || value > MAX_HOST_MAPPINGS) { - errno = EINVAL; - return -1; - } - value = value * 10U + (uint32_t)(*digit - '0'); - } - if (value == 0 || value > MAX_HOST_MAPPINGS) { - errno = EINVAL; - return -1; - } - *count = value; - found = 1; - } - return 0; -} - -/* - * Reads the number of announced host mappings and, while the host's export is - * mounted at HOSTFS_ROOT, makes its parent a directory that only root may - * enter, so workloads reach mapped paths only through the bind mounts. The - * announced mappings need that export. - */ -static int prepare_host_mappings(struct host_mappings *mappings) -{ - char command_line[MAX_COMMAND_LINE]; - struct stat parent; - struct stat export; - ssize_t count; - int mounted; - int fd = open("/proc/cmdline", O_RDONLY | O_CLOEXEC); - - if (fd < 0) { - return -1; - } - count = read(fd, command_line, sizeof(command_line) - 1); - close(fd); - if (count < 0) { - return -1; - } - command_line[count] = '\0'; - if (parse_host_mapping_count(command_line, &mappings->expected) != 0) { - return -1; - } - mounted = stat(HOSTFS_DIR, &parent) == 0 && stat(HOSTFS_ROOT, &export) == 0 && - parent.st_dev != export.st_dev; - if (mounted && (chown(HOSTFS_DIR, 0, 0) != 0 || chmod(HOSTFS_DIR, 0700) != 0)) { - return -1; - } - if (mappings->expected != 0 && !mounted) { - errno = ENOENT; - return -1; - } - return 0; -} - -/* - * Bind-mounts SOURCE, a path relative to the export, at the guest path TARGET, - * read-only or read-write, without set-user-ID programs or device nodes. - */ -static int apply_host_mapping(const char *source, const char *target, int read_only) -{ - char host[MAX_GUEST_PATH + sizeof(HOSTFS_ROOT) + 1]; - struct stat status; - unsigned long flags = MS_BIND | MS_REMOUNT | MS_NOSUID | MS_NODEV; - - if (read_only) { - flags |= MS_RDONLY; - } - snprintf(host, sizeof(host), "%s/%s", HOSTFS_ROOT, source); - if (lstat(host, &status) != 0) { - return -1; - } - if (S_ISDIR(status.st_mode)) { - if (make_directories(target, 1) != 0) { - return -1; - } - } else if (S_ISREG(status.st_mode)) { - struct stat existing; - - if (make_directories(target, 0) != 0) { - return -1; - } - /* - * An existing target may lie inside an earlier read-only bind, where - * even O_CREAT with write access fails, so only a missing one is made. - */ - if (lstat(target, &existing) != 0) { - int file; - - if (errno != ENOENT) { - return -1; - } - file = open(target, O_RDONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0644); - if (file < 0) { - return -1; - } - close(file); - } else if (!S_ISREG(existing.st_mode)) { - errno = EINVAL; - return -1; - } - } else { - errno = EINVAL; - return -1; - } - if (mount(host, target, NULL, MS_BIND, NULL) != 0 || - mount(NULL, target, NULL, flags, NULL) != 0) { - return -1; - } - return 0; -} - -/* - * Decodes the APP_MAPS entry at `*offset` and advances past it: 16-bit flags, - * source length, and target length, then the source, a relative path of the - * export, and the target, an absolute guest path. - */ -static int decode_host_mapping( - const uint8_t *payload, - uint32_t payload_len, - uint32_t *offset, - char *source, - char *target, - int *read_only) -{ - uint16_t flags; - uint16_t source_len; - uint16_t target_len; - - if (payload_len - *offset < MAP_ENTRY_HEADER_LEN) { - return -1; - } - flags = read_u16(payload + *offset); - source_len = read_u16(payload + *offset + 2); - target_len = read_u16(payload + *offset + 4); - *offset += MAP_ENTRY_HEADER_LEN; - if ((flags & ~MAP_READ_ONLY) != 0 || source_len == 0 || target_len == 0 || - source_len >= MAX_GUEST_PATH || target_len >= MAX_GUEST_PATH || - payload_len - *offset < (uint32_t)source_len + target_len) { - return -1; - } - memcpy(source, payload + *offset, source_len); - source[source_len] = '\0'; - *offset += source_len; - memcpy(target, payload + *offset, target_len); - target[target_len] = '\0'; - *offset += target_len; - if (strlen(source) != source_len || strlen(target) != target_len || - strcmp(source, ".") == 0 || !safe_path(source, 0) || !safe_path(target, 1)) { - return -1; - } - *read_only = (flags & MAP_READ_ONLY) != 0; - return 0; -} - -static int send_guest_attach(int fd) -{ - const uint8_t zero_instance[16] = {0}; - - return write_outer_record( - fd, OUTER_GUEST_ATTACH, zero_instance, 0, 0, NULL, 0); -} - -static int acknowledge_reset( - struct control_session *session, - const struct outer_record *record) -{ - uint8_t credit[4]; - - if (record->type != OUTER_RESET || record->payload_len != 0 || - record->epoch == 0) { - return -1; - } - memcpy(session->instance_id, record->instance_id, 16); - session->epoch = record->epoch; - session->guest_sequence = 0; - session->host_sequence = record->sequence + 1; - write_u32(credit, OUTER_RECEIVE_WINDOW); - if (write_outer_record( - session->fd, - OUTER_ACK, - session->instance_id, - session->epoch, - session->guest_sequence, - credit, - sizeof(credit)) != 0) { - return -1; - } - session->guest_sequence = 1; - return 0; -} - -static int send_guest_record( - struct control_session *session, - uint8_t type, - const void *payload, - uint32_t payload_len) -{ - if (write_outer_record( - session->fd, - type, - session->instance_id, - session->epoch, - session->guest_sequence, - payload, - payload_len) != 0) { - return -1; - } - ++session->guest_sequence; - return 0; -} - -static int send_credit(struct control_session *session, uint32_t bytes) -{ - uint8_t payload[4]; - - if (bytes == 0) { - return 0; - } - write_u32(payload, bytes); - return send_guest_record(session, OUTER_CREDIT, payload, sizeof(payload)); -} - -static int send_app_frame( - struct control_session *session, - uint8_t kind, - uint64_t request_id, - int32_t status, - const void *payload, - uint32_t payload_len) -{ - uint8_t *frame; - uint32_t frame_len; - int result; - - if (payload_len > OUTER_MAX_PAYLOAD - APP_HEADER_LEN) { - return -1; - } - frame_len = APP_HEADER_LEN + payload_len; - frame = malloc(frame_len); - if (frame == NULL) { - return -1; - } - memset(frame, 0, APP_HEADER_LEN); - memcpy(frame, "NVXC", 4); - frame[4] = APP_VERSION; - frame[5] = kind; - write_u64(frame + 8, request_id); - write_u32(frame + 16, (uint32_t)status); - write_u32(frame + 20, payload_len); - if (payload_len != 0) { - memcpy(frame + APP_HEADER_LEN, payload, payload_len); - } - result = send_guest_record(session, OUTER_DATA, frame, frame_len); - free(frame); - return result; -} - -static int parse_app_request( - const uint8_t *payload, - uint32_t payload_len, - struct app_request *request) -{ - uint32_t declared_len; - - if (payload_len < APP_HEADER_LEN || memcmp(payload, "NVXC", 4) != 0 || - payload[4] != APP_VERSION || read_u16(payload + 6) != 0) { - return -1; - } - declared_len = read_u32(payload + 20); - if (declared_len != payload_len - APP_HEADER_LEN) { - return -1; - } - request->kind = payload[5]; - request->request_id = read_u64(payload + 8); - request->status = (int32_t)read_u32(payload + 16); - request->payload_len = declared_len; - request->payload = payload + APP_HEADER_LEN; - return request->request_id != 0 && request->status == 0 ? 0 : -1; -} - -static int send_app_error( - struct control_session *session, - uint64_t request_id, - int32_t status, - const char *category) -{ - return send_app_frame( - session, - APP_ERROR, - request_id, - status, - category, - (uint32_t)strlen(category)); -} - -static uint64_t monotonic_milliseconds(void) -{ - struct timespec value; - - if (clock_gettime(CLOCK_MONOTONIC, &value) != 0) { - return 0; - } - return (uint64_t)value.tv_sec * 1000U + (uint64_t)value.tv_nsec / 1000000U; -} - -static int open_control_tty(const char *path) -{ - const struct timespec delay = { - .tv_sec = 0, - .tv_nsec = 50U * 1000U * 1000U, - }; - unsigned int attempt; - - for (attempt = 0; attempt < 600; ++attempt) { - int fd = open(path, O_RDWR | O_CLOEXEC); - if (fd >= 0) { - return fd; - } - if (errno != ENOENT && errno != ENXIO && errno != ENODEV && errno != EIO) { - return -1; - } - nanosleep(&delay, NULL); - } - errno = ETIMEDOUT; - return -1; -} - -static int configure_control_tty(int fd) -{ - struct termios settings; - - if (tcgetattr(fd, &settings) != 0) { - return -1; - } - cfmakeraw(&settings); - settings.c_cflag |= CLOCAL; - return tcsetattr(fd, TCSANOW, &settings); -} - -static int make_nonblocking(int fd) -{ - int flags = fcntl(fd, F_GETFL); - - return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0 ? 0 : -1; -} - -static int write_pid_to_cgroup(pid_t pid) -{ - char buffer[32]; - int fd; - int length; - - fd = open("/sys/fs/cgroup/container/cgroup.procs", O_WRONLY | O_CLOEXEC); - if (fd < 0) { - return -1; - } - length = snprintf(buffer, sizeof(buffer), "%ld\n", (long)pid); - if (length <= 0 || (size_t)length >= sizeof(buffer) || - write_all(fd, buffer, (size_t)length) != 0) { - close(fd); - return -1; - } - return close(fd); -} - -static int write_container_barrier(int fd) -{ - struct sigaction ignored = {0}; - struct sigaction previous; - int result; - int status; - - ignored.sa_handler = SIG_IGN; - if (sigemptyset(&ignored.sa_mask) != 0 || - sigaction(SIGPIPE, &ignored, &previous) != 0) { - return -1; - } - result = write_all(fd, "start\n", 6); - status = errno; - if (sigaction(SIGPIPE, &previous, NULL) != 0) { - return -1; - } - if (result != 0) { - errno = status; - } - return result; -} - -/* Returns 0 after writing, 1 for an unreaped child exit, or -1 on failure. */ -static int release_container_barrier(const char *path, pid_t child) -{ - unsigned int attempt; - - for (attempt = 0; attempt < CONTAINER_BARRIER_ATTEMPTS; ++attempt) { - int fd = open(path, O_WRONLY | O_CLOEXEC | O_NONBLOCK); - - if (fd >= 0) { - int result = write_container_barrier(fd); - - close(fd); - return result; - } - if (errno != ENXIO && errno != EINTR) { - return -1; - } - if (child > 0) { - siginfo_t information = {0}; - - if (waitid( - P_PID, - (id_t)child, - &information, - WEXITED | WNOHANG | WNOWAIT) != 0) { - if (errno == EINTR) { - continue; - } - return -1; - } - if (information.si_pid == child) { - return 1; - } - } - { - const struct timespec delay = {.tv_nsec = 10000000L}; - - nanosleep(&delay, NULL); - } - } - errno = ETIMEDOUT; - return -1; -} - -static int enable_pids_controller(void) -{ - int fd = open(CGROUP_ROOT "/cgroup.subtree_control", O_WRONLY | O_CLOEXEC); - int result; - int status; - - if (fd < 0) { - return -1; - } - result = write_all(fd, "+pids", 5); - status = errno; - if (close(fd) != 0 && result == 0) { - return -1; - } - errno = status; - return result; -} - -/* - * Direct-mode workloads run in the guest's own root file system, contained by - * a cgroup that every process of the workload inherits, whatever session or - * process group it moves to. Killing the cgroup ends all of them, and its pids - * controller counts each of them until PID 1 reaps it. - */ -static int prepare_exec_cgroup(void) -{ - struct stat status; - - if (stat(CGROUP_ROOT "/cgroup.procs", &status) != 0) { - if (mkdir(CGROUP_ROOT, 0755) != 0 && errno != EEXIST) { - return -1; - } - if (mount("cgroup2", CGROUP_ROOT, "cgroup2", MS_NOSUID | MS_NODEV | MS_NOEXEC, NULL) != - 0) { - return -1; - } - } - if (mkdir(EXEC_CGROUP, 0755) != 0 && errno != EEXIST) { - return -1; - } - if (stat(EXEC_CGROUP "/pids.current", &status) == 0) { - return 0; - } - return errno == ENOENT ? enable_pids_controller() : -1; -} - -static int join_exec_cgroup(void) -{ - char buffer[32]; - int fd = open(EXEC_CGROUP "/cgroup.procs", O_WRONLY | O_CLOEXEC); - int length; - int result; - - if (fd < 0) { - return -1; - } - length = snprintf(buffer, sizeof(buffer), "%ld\n", (long)getpid()); - result = length > 0 && (size_t)length < sizeof(buffer) - ? write_all(fd, buffer, (size_t)length) - : -1; - close(fd); - return result; -} - -static int kill_exec_cgroup(void) -{ - int fd = open(EXEC_CGROUP "/cgroup.kill", O_WRONLY | O_CLOEXEC); - int result; - int status; - - if (fd < 0) { - goto error; - } - result = write_all(fd, "1", 1); - status = errno; - if (close(fd) != 0 && result == 0) { - goto error; - } - if (result == 0) { - return 0; - } - errno = status; -error: - status = errno; - portb_error("exec-cgroup-kill", status); - errno = status; - return -1; -} - -/* - * Returns 1 if the pids controller still charges a task to the exec cgroup, - * 0 if it charges none, or -1. A task stays charged until it is reaped, while - * cgroup.events stops counting a killed process as populated before the - * process even becomes a zombie. - */ -static int exec_cgroup_has_tasks(void) -{ - char buffer[32]; - size_t length = 0; - size_t index; - int fd = open(EXEC_CGROUP "/pids.current", O_RDONLY | O_CLOEXEC); - - if (fd < 0) { - return -1; - } - for (;;) { - ssize_t count = read(fd, buffer + length, sizeof(buffer) - length); - - if (count > 0) { - length += (size_t)count; - if (length < sizeof(buffer)) { - continue; - } - errno = EOVERFLOW; - } else if (count == 0) { - break; - } else if (errno == EINTR) { - continue; - } - { - int status = errno; - - close(fd); - errno = status; - return -1; - } - } - if (close(fd) != 0) { - return -1; - } - /* A decimal count without sign or leading zeros, then a newline. */ - if (length < 2 || buffer[length - 1] != '\n' || (buffer[0] == '0' && length != 2)) { - errno = EPROTO; - return -1; - } - for (index = 0; index + 1 < length; ++index) { - if (buffer[index] < '0' || buffer[index] > '9') { - errno = EPROTO; - return -1; - } - } - return buffer[0] != '0'; -} - -/* - * Reaps exited children without blocking. The agent runs as PID 1, so besides - * the workload's direct child it inherits every orphaned workload process. - */ -static void reap_children(pid_t child, int *child_exited, int *wait_status) -{ - for (;;) { - int status; - pid_t pid = waitpid(-1, &status, WNOHANG); - - if (pid < 0 && errno == EINTR) { - continue; - } - if (pid <= 0) { - return; - } - if (pid == child && !*child_exited) { - *child_exited = 1; - *wait_status = status; - } - } -} - -/* - * Reaps what the workload left to PID 1, then returns 0 if no task of the - * exec cgroup remains, 1 if one does, or -1 on failure. - */ -static int reap_exec_cgroup(void) -{ - int exited = 1; - int status = 0; - - reap_children(0, &exited, &status); - return exec_cgroup_has_tasks(); -} - -/* - * Kills what is left of a direct-mode workload and waits until it is gone: - * every process of the workload has exited and been reaped, so the next - * workload cannot find any of them in /proc. - */ -static int settle_exec_cgroup(void) -{ - const struct timespec delay = { - .tv_sec = 0, - .tv_nsec = 10U * 1000U * 1000U, - }; - unsigned int attempt; - - if (kill_exec_cgroup() != 0) { - return -1; - } - for (attempt = 0; attempt <= 200; ++attempt) { - int remaining = reap_exec_cgroup(); - - if (remaining < 0) { - return -1; - } - if (remaining == 0) { - return 0; - } - if (attempt == 200) { - break; - } - nanosleep(&delay, NULL); - } - errno = ETIMEDOUT; - return -1; -} - -/* Kills the workload: its process group, and in direct mode its whole cgroup. */ -static void terminate_workload(const struct agent_config *config, pid_t child) -{ - kill(-child, SIGKILL); - if (config->direct) { - (void)kill_exec_cgroup(); - } -} - -/* Parses a workload user or group ID: decimal, neither root nor the "unchanged" value -1. */ -static int parse_workload_id(const char *text, uint32_t *id) -{ - char *end = NULL; - unsigned long value; - - if (text[0] < '0' || text[0] > '9') { - errno = EINVAL; - return -1; - } - errno = 0; - value = strtoul(text, &end, 10); - if (errno != 0 || *end != '\0' || value == 0 || value >= UINT32_MAX) { - errno = EINVAL; - return -1; - } - *id = (uint32_t)value; - return 0; -} - -/* Tells the agent why a direct-mode child cannot launch its workload, then exits. */ -static void report_launch_failure(int launch_fd, int32_t stage, int error) -{ - struct launch_failure failure = {.stage = stage, .error = error}; - - (void)write_all(launch_fd, &failure, sizeof(failure)); - _exit(125); -} - -/* - * Enters the working directory with the workload's IDs, no supplementary - * groups, and no effective capabilities, as setpriv will run the workload, so - * a directory that the workload cannot search is refused before anything - * runs, instead of failing in the launch helper with an exit status that the - * workload could also return. The real and saved IDs stay root, so the child - * regains root for setpriv afterwards. setpriv and the launch helper inherit - * the directory instead of looking its path up again, so the workload starts - * in the directory that was checked even if the path is renamed, replaced, or - * retargeted in the meantime. - */ -static void enter_workload_directory( - const struct agent_config *config, - const char *directory, - int launch_fd) -{ - uint32_t uid; - uint32_t gid; - - if (parse_workload_id(config->uid, &uid) != 0 || - parse_workload_id(config->gid, &gid) != 0 || setgroups(0, NULL) != 0 || - setegid((gid_t)gid) != 0 || seteuid((uid_t)uid) != 0) { - report_launch_failure(launch_fd, LAUNCH_STAGE_SETUP, errno); - } - if (chdir(directory) != 0) { - report_launch_failure(launch_fd, LAUNCH_STAGE_CWD, errno); - } - if (seteuid(0) != 0 || setegid(0) != 0) { - report_launch_failure(launch_fd, LAUNCH_STAGE_SETUP, errno); - } -} - -/* - * Points PWD at the working directory, so a shell reports it as the caller - * named it, symbolic links included. A path with empty, ".", or ".." - * components is replaced by the directory's physical path. - */ -static int export_working_directory(const char *directory) -{ - char physical[MAX_GUEST_PATH]; - - if (strcmp(directory, "/") == 0 || safe_path(directory, 1)) { - return setenv("PWD", directory, 1); - } - if (getcwd(physical, sizeof(physical)) == NULL) { - return -1; - } - return setenv("PWD", physical, 1); -} - -static void exec_direct( - const struct agent_config *config, - const char *config_fd, - char *const workload_argv[]) -{ - char *arguments[MAX_ARGUMENTS + 20]; - size_t index = 0; - size_t workload_index = 0; - - if (join_exec_cgroup() != 0) { - _exit(125); - } - if (setenv("HOME", config->home, 1) != 0 || - setenv("USER", config->user, 1) != 0 || - setenv("LOGNAME", config->user, 1) != 0) { - dprintf( - STDERR_FILENO, - "nvx-managed-agent: cannot configure workload environment: %s\n", - strerror(errno)); - _exit(125); - } - arguments[index++] = "setpriv"; - arguments[index++] = "--reuid"; - arguments[index++] = (char *)config->uid; - arguments[index++] = "--regid"; - arguments[index++] = (char *)config->gid; - arguments[index++] = "--clear-groups"; - arguments[index++] = "--no-new-privs"; - arguments[index++] = "--bounding-set=-all"; - arguments[index++] = "--inh-caps=-all"; - arguments[index++] = "--ambient-caps=-all"; - arguments[index++] = "/sbin/nvx-managed-agent"; - arguments[index++] = "--exec-config-fd"; - arguments[index++] = (char *)config_fd; - arguments[index++] = "--"; - while (workload_argv[workload_index] != NULL && index + 1 < MAX_ARGUMENTS + 20) { - arguments[index++] = workload_argv[workload_index++]; - } - arguments[index] = NULL; - execvp("setpriv", arguments); - _exit(125); -} - -static void exec_sandbox( - const struct agent_config *config, - const char *barrier, - const char *config_fd, - char *const workload_argv[]) -{ - char *arguments[MAX_ARGUMENTS + 10]; - size_t index = 0; - size_t workload_index = 0; - - arguments[index++] = "/sbin/nvx-container-launch"; - arguments[index++] = (char *)barrier; - arguments[index++] = (char *)config->rootfs; - arguments[index++] = (char *)config->hostname; - arguments[index++] = (char *)config->uid; - arguments[index++] = (char *)config->gid; - arguments[index++] = (char *)config->user; - arguments[index++] = (char *)config->home; - if (setenv("NVX_EXEC_CONFIG_FD", config_fd, 1) != 0) { - dprintf( - STDERR_FILENO, - "nvx-managed-agent: cannot export execution configuration: %s\n", - strerror(errno)); - _exit(125); - } - while (workload_argv[workload_index] != NULL && index + 1 < MAX_ARGUMENTS + 10) { - arguments[index++] = workload_argv[workload_index++]; - } - arguments[index] = NULL; - execv(arguments[0], arguments); - _exit(125); -} - -static int decode_exec_payload( - const uint8_t *payload, - uint32_t payload_len, - uint32_t *timeout_ms, - char ***workload_argv, - struct exec_config *config) -{ - uint16_t argc; - uint16_t extension; - uint16_t flags = 0; - uint16_t environment_count = 0; - uint32_t cwd_len = 0; - uint32_t offset = 8; - char **arguments; - uint16_t index; - - memset(config, 0, sizeof(*config)); - if (payload_len < 8) { - return -1; - } - *timeout_ms = read_u32(payload); - argc = read_u16(payload + 4); - extension = read_u16(payload + 6); - if (argc == 0 || argc > MAX_ARGUMENTS) { - return -1; - } - if (extension == EXEC_EXTENDED) { - if (payload_len < 16) { - return -1; - } - flags = read_u16(payload + 8); - environment_count = read_u16(payload + 10); - cwd_len = read_u32(payload + 12); - offset = 16; - if ((flags & - ~(EXEC_CWD_PRESENT | EXEC_ENVIRONMENT_PRESENT | - EXEC_INHERIT_DEFAULT_ENV)) != 0 || - ((flags & EXEC_CWD_PRESENT) == 0 && cwd_len != 0) || - ((flags & EXEC_CWD_PRESENT) != 0 && - (cwd_len == 0 || cwd_len > MAX_ARGUMENT_LEN)) || - ((flags & EXEC_ENVIRONMENT_PRESENT) == 0 && - (environment_count != 0 || - (flags & EXEC_INHERIT_DEFAULT_ENV) != 0)) || - environment_count > MAX_ENVIRONMENT) { - return -1; - } - } else if (extension != 0) { - return -1; - } - arguments = calloc((size_t)argc + 1, sizeof(*arguments)); - if (arguments == NULL) { - return -1; - } - for (index = 0; index < argc; ++index) { - uint32_t length; - - if (offset > payload_len || payload_len - offset < 4) { - goto fail; - } - length = read_u32(payload + offset); - offset += 4; - if (length == 0 || length > MAX_ARGUMENT_LEN || length > payload_len - offset || - memchr(payload + offset, '\0', length) != NULL) { - goto fail; - } - arguments[index] = malloc((size_t)length + 1); - if (arguments[index] == NULL) { - goto fail; - } - memcpy(arguments[index], payload + offset, length); - arguments[index][length] = '\0'; - offset += length; - } - if (arguments[0][0] != '/' || - (extension == 0 && offset != payload_len)) { - goto fail; - } - if (extension == EXEC_EXTENDED) { - if (cwd_len != 0) { - if (cwd_len > payload_len - offset || - memchr(payload + offset, '\0', cwd_len) != NULL || - payload[offset] != '/') { - goto fail; - } - config->cwd = malloc((size_t)cwd_len + 1); - if (config->cwd == NULL) { - goto fail; - } - memcpy(config->cwd, payload + offset, cwd_len); - config->cwd[cwd_len] = '\0'; - offset += cwd_len; - } - if ((flags & EXEC_ENVIRONMENT_PRESENT) != 0) { - config->environment = calloc( - (size_t)environment_count + 1, sizeof(*config->environment)); - if (config->environment == NULL) { - goto fail; - } - config->environment_present = 1; - config->inherit_default_env = - (flags & EXEC_INHERIT_DEFAULT_ENV) != 0; - config->environment_count = environment_count; - for (index = 0; index < environment_count; ++index) { - uint32_t length; - const uint8_t *equals; - - if (offset > payload_len || payload_len - offset < 4) { - goto fail; - } - length = read_u32(payload + offset); - offset += 4; - if (length == 0 || length > MAX_ARGUMENT_LEN || - length > payload_len - offset || - memchr(payload + offset, '\0', length) != NULL) { - goto fail; - } - equals = memchr(payload + offset, '=', length); - if (equals == NULL || equals == payload + offset) { - goto fail; - } - for (uint16_t previous = 0; previous < index; ++previous) { - size_t name_length = (size_t)(equals - (payload + offset)); - const char *prior = config->environment[previous]; - if (strcspn(prior, "=") == name_length && - memcmp(prior, payload + offset, name_length) == 0) { - goto fail; - } - } - config->environment[index] = malloc((size_t)length + 1); - if (config->environment[index] == NULL) { - goto fail; - } - memcpy(config->environment[index], payload + offset, length); - config->environment[index][length] = '\0'; - offset += length; - } - } - if (offset != payload_len) { - goto fail; - } - } - *workload_argv = arguments; - return 0; - -fail: - for (index = 0; index < argc; ++index) { - free(arguments[index]); - } - free(arguments); - free(config->cwd); - config->cwd = NULL; - if (config->environment != NULL) { - for (index = 0; index < config->environment_count; ++index) { - free(config->environment[index]); - } - free(config->environment); - config->environment = NULL; - } - return -1; -} - -static void free_arguments(char **arguments) -{ - size_t index; - - if (arguments == NULL) { - return; - } - for (index = 0; arguments[index] != NULL; ++index) { - free(arguments[index]); - } - free(arguments); -} - -static void free_exec_config(struct exec_config *config) -{ - uint16_t index; - - free(config->cwd); - for (index = 0; index < config->environment_count; ++index) { - free(config->environment[index]); - } - free(config->environment); - memset(config, 0, sizeof(*config)); -} - -/* - * Writes the launch helper's configuration. When the caller has entered the - * working directory already, the configuration says so instead of naming it. - */ -static int write_exec_config( - int fd, - const struct exec_config *config, - int cwd_entered) -{ - uint8_t header[8]; - uint16_t flags = 0; - const char *cwd = cwd_entered ? NULL : config->cwd; - uint16_t index; - - if (cwd_entered) { - flags |= EXEC_CWD_ENTERED; - } else if (cwd != NULL) { - flags |= EXEC_CWD_PRESENT; - } - if (config->environment_present) { - flags |= EXEC_ENVIRONMENT_PRESENT; - } - if (config->inherit_default_env) { - flags |= EXEC_INHERIT_DEFAULT_ENV; - } - write_u16(header, flags); - write_u16(header + 2, config->environment_count); - write_u32(header + 4, cwd == NULL ? 0 : (uint32_t)strlen(cwd)); - if (write_all(fd, header, sizeof(header)) != 0 || - (cwd != NULL && write_all(fd, cwd, strlen(cwd)) != 0)) { - return -1; - } - for (index = 0; index < config->environment_count; ++index) { - uint32_t length = (uint32_t)strlen(config->environment[index]); - uint8_t encoded_length[4]; - - write_u32(encoded_length, length); - if (write_all(fd, encoded_length, sizeof(encoded_length)) != 0 || - write_all(fd, config->environment[index], length) != 0) { - return -1; - } - } - return 0; -} - -static int create_exec_config_fd(const struct exec_config *config, int cwd_entered) -{ - int fd = memfd_create("nvx-exec-config", MFD_CLOEXEC | MFD_ALLOW_SEALING); - - if (fd < 0) { - return -1; - } - /* A sealed, bounded anonymous file avoids depending on pipe capacity. */ - if (write_exec_config(fd, config, cwd_entered) != 0 || - lseek(fd, 0, SEEK_SET) < 0 || - fcntl(fd, F_ADD_SEALS, - F_SEAL_WRITE | F_SEAL_GROW | F_SEAL_SHRINK | F_SEAL_SEAL) != 0) { - close(fd); - return -1; - } - return fd; -} - -static int launch_workload(int argc, char **argv) -{ - char *end = NULL; - long descriptor; - uint8_t header[8]; - uint16_t flags; - uint16_t environment_count; - uint32_t cwd_len; - char *cwd = NULL; - char **environment = NULL; - uint16_t index; - - if (argc < 5 || strcmp(argv[1], "--exec-config-fd") != 0 || - strcmp(argv[3], "--") != 0 || argv[4][0] != '/') { - return 125; - } - errno = 0; - descriptor = strtol(argv[2], &end, 10); - if (errno != 0 || end == argv[2] || *end != '\0' || - descriptor < 0 || descriptor > INT32_MAX || - read_exact((int)descriptor, header, sizeof(header)) != 0) { - return 125; - } - flags = read_u16(header); - environment_count = read_u16(header + 2); - cwd_len = read_u32(header + 4); - if ((flags & - ~(EXEC_CWD_PRESENT | EXEC_ENVIRONMENT_PRESENT | - EXEC_INHERIT_DEFAULT_ENV | EXEC_CWD_ENTERED)) != 0 || - ((flags & EXEC_CWD_ENTERED) != 0 && (flags & EXEC_CWD_PRESENT) != 0) || - environment_count > MAX_ENVIRONMENT || - ((flags & EXEC_CWD_PRESENT) == 0 && cwd_len != 0) || - ((flags & EXEC_CWD_PRESENT) != 0 && - (cwd_len == 0 || cwd_len > MAX_ARGUMENT_LEN)) || - ((flags & EXEC_ENVIRONMENT_PRESENT) == 0 && - (environment_count != 0 || - (flags & EXEC_INHERIT_DEFAULT_ENV) != 0))) { - return 125; - } - if (cwd_len != 0) { - cwd = malloc((size_t)cwd_len + 1); - if (cwd == NULL || read_exact((int)descriptor, cwd, cwd_len) != 0) { - free(cwd); - return 125; - } - cwd[cwd_len] = '\0'; - } - environment = calloc( - (size_t)environment_count + 1, sizeof(*environment)); - if (environment == NULL) { - free(cwd); - return 125; - } - for (index = 0; index < environment_count; ++index) { - uint8_t encoded_length[4]; - uint32_t length; - - if (read_exact((int)descriptor, encoded_length, 4) != 0) { - goto fail; - } - length = read_u32(encoded_length); - if (length == 0 || length > MAX_ARGUMENT_LEN) { - goto fail; - } - environment[index] = malloc((size_t)length + 1); - if (environment[index] == NULL || - read_exact((int)descriptor, environment[index], length) != 0) { - goto fail; - } - if (memchr(environment[index], '\0', length) != NULL || - environment[index][0] == '=' || - memchr(environment[index], '=', length) == NULL) { - goto fail; - } - environment[index][length] = '\0'; - } - close((int)descriptor); - if (cwd != NULL && - (cwd[0] != '/' || memchr(cwd, '\0', cwd_len) != NULL)) { - dprintf(STDERR_FILENO, "nvx-managed-agent: invalid working directory\n"); - goto fail; - } - if (unsetenv("NVX_EXEC_CONFIG_FD") != 0) { - goto fail; - } - if ((flags & EXEC_CWD_ENTERED) == 0) { - const char *directory = cwd == NULL ? "/" : cwd; - - if (chdir(directory) != 0) { - dprintf( - STDERR_FILENO, - "nvx-managed-agent: cannot use working directory %s: %s\n", - directory, - strerror(errno)); - goto fail; - } - if (((flags & EXEC_ENVIRONMENT_PRESENT) == 0 || - (flags & EXEC_INHERIT_DEFAULT_ENV) != 0) && - export_working_directory(directory) != 0) { - dprintf( - STDERR_FILENO, - "nvx-managed-agent: cannot configure working directory " - "environment: %s\n", - strerror(errno)); - goto fail; - } - } - if ((flags & EXEC_ENVIRONMENT_PRESENT) != 0) { - if ((flags & EXEC_INHERIT_DEFAULT_ENV) == 0 && clearenv() != 0) { - goto fail; - } - for (index = 0; index < environment_count; ++index) { - if (putenv(environment[index]) != 0) { - goto fail; - } - environment[index] = NULL; - } - } - execv(argv[4], &argv[4]); - dprintf( - STDERR_FILENO, - "nvx-managed-agent: cannot execute workload: %s\n", - strerror(errno)); - -fail: - free(cwd); - for (index = 0; index < environment_count; ++index) { - free(environment[index]); - } - free(environment); - return 125; -} - -static int stream_output( - struct control_session *session, - uint64_t request_id, - int fd, - uint8_t kind, - size_t *output_bytes) -{ - uint8_t buffer[OUTPUT_CHUNK_BYTES]; - ssize_t count; - - for (;;) { - count = read(fd, buffer, sizeof(buffer)); - if (count > 0) { - if (*output_bytes > MAX_OUTPUT_BYTES - (size_t)count) { - return -2; - } - *output_bytes += (size_t)count; - if (send_app_frame( - session, - kind, - request_id, - 0, - buffer, - (uint32_t)count) != 0) { - return -1; - } - } else if (count == 0) { - return 1; - } else if (errno == EINTR) { - continue; - } else if (errno == EAGAIN || errno == EWOULDBLOCK) { - return 0; - } else { - return -1; - } - } -} - -/* Outcome of control traffic observed while a workload runs. */ -enum exec_control { - EXEC_CONTROL_NONE, - EXEC_CONTROL_CANCEL, - EXEC_CONTROL_SESSION_LOST, - EXEC_CONTROL_FAILED, -}; - -/* - * Handles one control record that arrives while the workload with request ID - * `exec_id` runs. CANCEL for that workload asks for its termination. A RESET - * means the host that could observe the workload is gone: the broker has - * started a new epoch and drops anything sent for the old one. Other requests - * are refused, because executions are sequential. - */ -static enum exec_control handle_exec_control( - struct control_session *session, - uint64_t exec_id) -{ - struct outer_record record; - struct app_request request; - enum exec_control result = EXEC_CONTROL_NONE; - - if (read_outer_record(session->fd, &record) != 0) { - portb_error("exec-outer-read", errno); - return EXEC_CONTROL_FAILED; - } - if (record.type == OUTER_RESET) { - result = acknowledge_reset(session, &record) == 0 - ? EXEC_CONTROL_SESSION_LOST - : EXEC_CONTROL_FAILED; - free_outer_record(&record); - return result; - } - if (record.type != OUTER_DATA || record.sequence != session->host_sequence || - record.epoch != session->epoch || - memcmp(record.instance_id, session->instance_id, 16) != 0 || - parse_app_request(record.payload, record.payload_len, &request) != 0) { - portb_error("exec-record", record.type); - free_outer_record(&record); - return EXEC_CONTROL_FAILED; - } - ++session->host_sequence; - if (send_credit(session, record.payload_len) != 0) { - free_outer_record(&record); - return EXEC_CONTROL_FAILED; - } - if (request.kind == APP_CANCEL) { - if (request.payload_len == 0 && request.request_id == exec_id) { - result = EXEC_CONTROL_CANCEL; - } - } else if (send_app_error(session, request.request_id, 16, "busy") != 0) { - result = EXEC_CONTROL_FAILED; - } - free_outer_record(&record); - return result; -} - -/* Discards output of a workload whose host is gone; returns 1 at end-of-file. */ -static int drain_output(int fd) -{ - uint8_t buffer[OUTPUT_CHUNK_BYTES]; - ssize_t count; - - for (;;) { - count = read(fd, buffer, sizeof(buffer)); - if (count > 0) { - continue; - } - if (count == 0) { - return 1; - } - if (errno == EINTR) { - continue; - } - return errno == EAGAIN || errno == EWOULDBLOCK ? 0 : -1; - } -} - -/* - * Waits until a direct-mode child either executes setpriv, which closes the - * close-on-exec launch pipe, or reports why it cannot launch the workload. - * Returns 0 once setpriv runs, 1 with `failure` filled in, or -1 on error. - */ -static int read_launch_failure(int fd, struct launch_failure *failure) -{ - uint8_t buffer[sizeof(*failure)]; - size_t length = 0; - - while (length < sizeof(buffer)) { - ssize_t count = read(fd, buffer + length, sizeof(buffer) - length); - - if (count > 0) { - length += (size_t)count; - } else if (count == 0) { - break; - } else if (errno != EINTR) { - return -1; - } - } - if (length == 0) { - return 0; - } - if (length != sizeof(buffer)) { - errno = EPROTO; - return -1; - } - memcpy(failure, buffer, sizeof(*failure)); - return 1; -} - -/* - * Refuses a workload whose working directory it cannot enter. Nothing of the - * workload ran; a diagnostic on its standard error names the directory, and - * the refusal carries the error number. - */ -static int refuse_working_directory( - struct control_session *session, - uint64_t request_id, - const char *directory, - int error) -{ - char message[MAX_GUEST_PATH + 128]; - int length = snprintf( - message, - sizeof(message), - "nvx-managed-agent: cannot enter working directory %s: %s\n", - directory, - strerror(error)); - - if (length > 0 && (size_t)length < sizeof(message) && - send_app_frame( - session, APP_STDERR, request_id, 0, message, (uint32_t)length) != 0) { - return -1; - } - return send_app_error(session, request_id, error, "cwd-failed"); -} - -static void close_pipe(int pipe_fds[2]) -{ - unsigned int index; - - for (index = 0; index < 2; ++index) { - if (pipe_fds[index] >= 0) { - close(pipe_fds[index]); - pipe_fds[index] = -1; - } - } -} - -static int run_exec( - struct control_session *session, - const struct agent_config *config, - uint64_t request_id, - uint32_t timeout_ms, - char **workload_argv, - const struct exec_config *exec_config) -{ - const char *barrier = "/run/nvx/managed-container-start"; - int stdout_pipe[2] = {-1, -1}; - int stderr_pipe[2] = {-1, -1}; - int exec_config_fd = -1; - int launch_pipe[2] = {-1, -1}; - struct launch_failure launch_failure = {0}; - int launch_report = 0; - pid_t child; - uint64_t started; - size_t output_bytes = 0; - int stdout_open = 1; - int stderr_open = 1; - int timed_out = 0; - int output_limited = 0; - int cancelled = 0; - int session_lost = 0; - int stragglers_killed = 0; - int wait_status = 0; - int child_exited = 0; - int launch_failed = 0; - - if (config->direct && prepare_exec_cgroup() != 0) { - portb_error("exec-cgroup", errno); - return send_app_error(session, request_id, 125, "launch-failed"); - } - if (config->direct) { - /* A workload whose settlement timed out may have exited since. */ - int remaining = reap_exec_cgroup(); - - if (remaining != 0) { - portb_error("exec-cgroup-verify", remaining < 0 ? errno : EBUSY); - return send_app_error(session, request_id, 125, "containment-failed"); - } - } - if (!config->direct) { - unlink(barrier); - if (mkfifo(barrier, 0600) != 0) { - return send_app_error(session, request_id, 125, "launch-failed"); - } - } - if (pipe2(stdout_pipe, O_CLOEXEC) != 0 || - pipe2(stderr_pipe, O_CLOEXEC) != 0 || - (exec_config_fd = create_exec_config_fd(exec_config, config->direct)) < 0 || - (config->direct && pipe2(launch_pipe, O_CLOEXEC) != 0)) { - unlink(barrier); - if (stdout_pipe[0] >= 0) { - close(stdout_pipe[0]); - close(stdout_pipe[1]); - } - if (stderr_pipe[0] >= 0) { - close(stderr_pipe[0]); - close(stderr_pipe[1]); - } - if (exec_config_fd >= 0) { - close(exec_config_fd); - } - return send_app_error(session, request_id, 125, "launch-failed"); - } - - child = fork(); - if (child < 0) { - close(stdout_pipe[0]); - close(stdout_pipe[1]); - close(stderr_pipe[0]); - close(stderr_pipe[1]); - close(exec_config_fd); - close_pipe(launch_pipe); - unlink(barrier); - return send_app_error(session, request_id, 125, "launch-failed"); - } - if (child == 0) { - int null_fd; - char config_fd[32]; - - setpgid(0, 0); - if (fcntl(exec_config_fd, F_SETFD, 0) != 0 || - snprintf( - config_fd, sizeof(config_fd), "%d", exec_config_fd) <= 0) { - _exit(125); - } - close(stdout_pipe[0]); - close(stderr_pipe[0]); - null_fd = open("/dev/null", O_RDONLY); - if (null_fd >= 0) { - dup2(null_fd, STDIN_FILENO); - close(null_fd); - } - dup2(stdout_pipe[1], STDOUT_FILENO); - dup2(stderr_pipe[1], STDERR_FILENO); - close(stdout_pipe[1]); - close(stderr_pipe[1]); - if (config->direct) { - const char *directory = - exec_config->cwd == NULL ? "/" : exec_config->cwd; - - close(launch_pipe[0]); - enter_workload_directory(config, directory, launch_pipe[1]); - /* - * A request that replaces the environment controls PWD itself; - * one that layers entries over the default keeps this PWD unless - * an entry names it. - */ - if ((!exec_config->environment_present || - exec_config->inherit_default_env) && - export_working_directory(directory) != 0) { - report_launch_failure(launch_pipe[1], LAUNCH_STAGE_SETUP, errno); - } - exec_direct(config, config_fd, workload_argv); - } - exec_sandbox(config, barrier, config_fd, workload_argv); - } - - setpgid(child, child); - close(exec_config_fd); - close(stdout_pipe[1]); - close(stderr_pipe[1]); - /* - * Only the child writes to the launch pipe, and it closes its end when it - * executes setpriv. Its report is read once it has exited, so the agent - * keeps serving control traffic while the child enters the directory. - */ - if (config->direct) { - close(launch_pipe[1]); - launch_pipe[1] = -1; - } - launch_failed = make_nonblocking(stdout_pipe[0]) != 0 || - make_nonblocking(stderr_pipe[0]) != 0; - if (!launch_failed && !config->direct) { - int barrier_result; - - if (write_pid_to_cgroup(child) != 0) { - launch_failed = 1; - } else { - barrier_result = release_container_barrier(barrier, child); - launch_failed = barrier_result < 0; - } - } - if (launch_failed) { - terminate_workload(config, child); - waitpid(child, NULL, 0); - close(stdout_pipe[0]); - close(stderr_pipe[0]); - close_pipe(launch_pipe); - unlink(barrier); - return send_app_error(session, request_id, 125, "launch-failed"); - } - unlink(barrier); - started = monotonic_milliseconds(); - - while (!child_exited || stdout_open || stderr_open) { - struct pollfd descriptors[3]; - int poll_result; - int stdout_result = 0; - int stderr_result = 0; - - descriptors[0].fd = stdout_open ? stdout_pipe[0] : -1; - descriptors[0].events = POLLIN | POLLHUP; - descriptors[0].revents = 0; - descriptors[1].fd = stderr_open ? stderr_pipe[0] : -1; - descriptors[1].events = POLLIN | POLLHUP; - descriptors[1].revents = 0; - /* After a reset the old session is gone; later records belong to the main loop. */ - descriptors[2].fd = session_lost ? -1 : session->fd; - descriptors[2].events = POLLIN; - descriptors[2].revents = 0; - poll_result = poll(descriptors, 3, 25); - if (poll_result < 0 && errno != EINTR) { - terminate_workload(config, child); - } - if (!session_lost && descriptors[2].revents != 0) { - switch (handle_exec_control(session, request_id)) { - case EXEC_CONTROL_NONE: - break; - case EXEC_CONTROL_CANCEL: - if (!child_exited && !cancelled) { - cancelled = 1; - terminate_workload(config, child); - } - break; - case EXEC_CONTROL_SESSION_LOST: - session_lost = 1; - terminate_workload(config, child); - break; - case EXEC_CONTROL_FAILED: - terminate_workload(config, child); - waitpid(child, NULL, 0); - if (stdout_open) { - close(stdout_pipe[0]); - } - if (stderr_open) { - close(stderr_pipe[0]); - } - close_pipe(launch_pipe); - return -1; - } - } - if (stdout_open && descriptors[0].revents != 0) { - stdout_result = session_lost - ? drain_output(stdout_pipe[0]) - : stream_output( - session, - request_id, - stdout_pipe[0], - APP_STDOUT, - &output_bytes); - } - if (stderr_open && descriptors[1].revents != 0) { - stderr_result = session_lost - ? drain_output(stderr_pipe[0]) - : stream_output( - session, - request_id, - stderr_pipe[0], - APP_STDERR, - &output_bytes); - } - if (stdout_result == 1) { - close(stdout_pipe[0]); - stdout_open = 0; - } - if (stderr_result == 1) { - close(stderr_pipe[0]); - stderr_open = 0; - } - if (stdout_result < 0 || stderr_result < 0) { - output_limited = stdout_result == -2 || stderr_result == -2; - terminate_workload(config, child); - } - reap_children(child, &child_exited, &wait_status); - /* Processes the workload left behind must not hold its output open. */ - if (child_exited && config->direct && !stragglers_killed && - (stdout_open || stderr_open)) { - stragglers_killed = 1; - (void)kill_exec_cgroup(); - } - if (!child_exited && timeout_ms != 0 && - monotonic_milliseconds() - started >= timeout_ms) { - timed_out = 1; - terminate_workload(config, child); - } - if ((timed_out || output_limited || cancelled || session_lost) && !child_exited) { - if (waitpid(child, &wait_status, 0) == child) { - child_exited = 1; - } - } - } - - /* The child has exited, so its launch report, if any, is complete. */ - if (config->direct) { - launch_report = read_launch_failure(launch_pipe[0], &launch_failure); - if (launch_report < 0) { - portb_error("exec-launch-report", errno); - } - close_pipe(launch_pipe); - } - if (config->direct && settle_exec_cgroup() != 0) { - portb_error("exec-cgroup-settle", errno); - return session_lost - ? 0 - : send_app_error(session, request_id, 125, "containment-failed"); - } - if (session_lost) { - /* Nothing may be sent for the abandoned exec once the new epoch is acknowledged. */ - return 0; - } - /* A child that reported a launch failure exited without running anything. */ - if (launch_report > 0 && launch_failure.stage == LAUNCH_STAGE_CWD) { - return refuse_working_directory( - session, - request_id, - exec_config->cwd == NULL ? "/" : exec_config->cwd, - launch_failure.error); - } - if (launch_report > 0) { - portb_error("exec-launch", launch_failure.error); - return send_app_error(session, request_id, 125, "launch-failed"); - } - if (cancelled) { - return send_app_frame( - session, APP_EXIT, request_id, 128 + SIGKILL, "cancelled", 9); - } - if (timed_out) { - return send_app_frame( - session, APP_EXIT, request_id, 124, "timeout", 7); - } - if (output_limited) { - return send_app_frame( - session, APP_EXIT, request_id, 125, "output-limit", 12); - } - if (WIFEXITED(wait_status)) { - return send_app_frame( - session, - APP_EXIT, - request_id, - WEXITSTATUS(wait_status), - "exit", - 4); - } - if (WIFSIGNALED(wait_status)) { - int status = 128 + WTERMSIG(wait_status); - return send_app_frame( - session, APP_EXIT, request_id, status, "signal", 6); - } - return send_app_frame( - session, APP_EXIT, request_id, 125, "failed", 6); -} - -/* - * Direct mode alone sets up host mappings, gives each workload a cgroup, and - * refuses a working directory that the workload cannot enter as cwd-failed. - */ -static uint32_t agent_features(const struct agent_config *config) -{ - uint32_t features = - FEATURE_CANCEL | FEATURE_WORKLOAD_ACCOUNT | FEATURE_EXEC_ENVIRONMENT; - - if (config->direct) { - features |= FEATURE_HOST_MAPPING_TABLE | FEATURE_EXEC_CGROUP | FEATURE_EXEC_CWD; - } - return features; -} - -/* - * Mounts the entries FIRST to FIRST + COUNT - 1 of the host mapping table that - * an APP_MAPS request carries after its 32-bit FIRST and COUNT. Entries arrive - * in order and only up to the number that nvx_maps= announced, before any - * workload runs. The whole request is checked before anything is mounted, and - * a mapping that fails to mount fails every later workload. - */ -static int handle_host_mappings( - struct control_session *session, - struct host_mappings *mappings, - const struct app_request *request) -{ - char source[MAX_GUEST_PATH]; - char target[MAX_GUEST_PATH]; - uint32_t first; - uint32_t count; - uint32_t index; - uint32_t offset = MAPS_HEADER_LEN; - int read_only; - - if (mappings->failed || request->payload_len < MAPS_HEADER_LEN) { - return send_app_error(session, request->request_id, 22, "invalid-request"); - } - first = read_u32(request->payload); - count = read_u32(request->payload + 4); - if (first != mappings->applied || count == 0 || - count > mappings->expected - mappings->applied) { - return send_app_error(session, request->request_id, 22, "invalid-request"); - } - for (index = 0; index < count; ++index) { - if (decode_host_mapping( - request->payload, request->payload_len, &offset, source, target, - &read_only) != 0) { - return send_app_error(session, request->request_id, 22, "invalid-request"); - } - } - if (offset != request->payload_len) { - return send_app_error(session, request->request_id, 22, "invalid-request"); - } - offset = MAPS_HEADER_LEN; - for (index = 0; index < count; ++index) { - decode_host_mapping( - request->payload, request->payload_len, &offset, source, target, &read_only); - if (apply_host_mapping(source, target, read_only) != 0) { - int status = errno; - - mappings->failed = 1; - portb_error("host-mapping", status); - dprintf( - STDERR_FILENO, - "NVX-MANAGED-ERROR: stage=host-mapping status=%d entry=%u\n", - status, - (unsigned)(first + index)); - return send_app_error(session, request->request_id, status, "mapping-failed"); - } - ++mappings->applied; - } - return send_app_frame(session, APP_READY, request->request_id, 0, NULL, 0); -} - -/* Serves one application request of the authenticated host. */ -static int handle_app_request( - struct control_session *session, - const struct agent_config *config, - struct host_mappings *mappings, - const struct app_request *request) -{ - char **workload_argv = NULL; - uint8_t features[4]; - struct exec_config exec_config = {0}; - uint32_t timeout_ms = 0; - int result; - - switch (request->kind) { - case APP_PING: - if (request->payload_len != 0) { - portb_error("ping-payload", (int)request->payload_len); - return send_app_error( - session, request->request_id, 22, "invalid-request"); - } - result = send_app_frame( - session, APP_READY, request->request_id, 0, NULL, 0); - if (result != 0) { - portb_error("ping-ready", errno); - } - return result; - case APP_EXEC: - /* Workloads must not run without the host paths that they expect. */ - if (mappings->applied != mappings->expected) { - return send_app_error( - session, request->request_id, EAGAIN, "mappings-incomplete"); - } - if (decode_exec_payload( - request->payload, - request->payload_len, - &timeout_ms, - &workload_argv, - &exec_config) != 0) { - return send_app_error( - session, request->request_id, 22, "invalid-request"); - } - result = run_exec( - session, - config, - request->request_id, - timeout_ms, - workload_argv, - &exec_config); - free_arguments(workload_argv); - free_exec_config(&exec_config); - return result; - case APP_CANCEL: - /* The targeted workload already finished and reported its outcome. */ - return 0; - case APP_FEATURES: - if (request->payload_len != 0) { - return send_app_error( - session, request->request_id, 22, "invalid-request"); - } - write_u32(features, agent_features(config)); - return send_app_frame( - session, - APP_READY, - request->request_id, - 0, - features, - sizeof(features)); - case APP_MAPS: - return handle_host_mappings(session, mappings, request); - case APP_STOP: - if (request->payload_len != 0 || - send_app_frame( - session, APP_STOPPED, request->request_id, 0, NULL, 0) != 0) { - return -1; - } - tcdrain(session->fd); - return AGENT_STOPPED; - default: - return send_app_error( - session, request->request_id, 95, "unsupported-operation"); - } -} - -static int handle_data_record( - struct control_session *session, - const struct agent_config *config, - struct host_mappings *mappings, - const struct outer_record *record) -{ - struct app_request request; - - if (record->sequence != session->host_sequence) { - portb_error("data-sequence", (int)record->sequence); - return -1; - } - if (record->epoch != session->epoch || - memcmp(record->instance_id, session->instance_id, 16) != 0) { - portb_error("data-identity", (int)record->epoch); - return -1; - } - if (parse_app_request(record->payload, record->payload_len, &request) != 0) { - portb_error("data-application", (int)record->payload_len); - return -1; - } - ++session->host_sequence; - if (send_credit(session, record->payload_len) != 0) { - portb_error("data-credit", (int)record->payload_len); - return -1; - } - return handle_app_request(session, config, mappings, &request); -} - -static int run_agent( - struct control_session *session, - const struct agent_config *config, - struct host_mappings *mappings) -{ - struct outer_record record; - - if (send_guest_attach(session->fd) != 0) { - return -1; - } - for (;;) { - if (read_outer_record(session->fd, &record) != 0) { - portb_error("outer-read", errno); - return -1; - } - if (record.type == OUTER_RESET) { - if (acknowledge_reset(session, &record) != 0) { - portb_error("reset-ack", errno); - free_outer_record(&record); - return -1; - } - } else if (record.type == OUTER_DATA) { - int result = handle_data_record(session, config, mappings, &record); - - if (result == AGENT_STOPPED) { - free_outer_record(&record); - return AGENT_STOPPED; - } - if (result != 0) { - portb_error("data", errno); - free_outer_record(&record); - return -1; - } - } else { - portb_error("outer-type", record.type); - free_outer_record(&record); - return -1; - } - free_outer_record(&record); - } -} - -/* - * Direct mode runs as PID 1 and powers the VM off itself. In sandbox mode the - * init agent supervises this process and unmounts the live share, overlay, - * layers, and scratch before it powers the VM off. - */ -static int finish_agent(const struct agent_config *config, int status) -{ - char code[12]; - - if (config->direct) { - snprintf(code, sizeof(code), "%d", status); - execl("/sbin/nvx-exit", "nvx-exit", code, (char *)NULL); - } - return status; -} - -int main(int argc, char **argv) -{ - struct control_session session = {0}; - struct host_mappings mappings = {0}; - struct agent_config config; - int result; - - if (argc >= 2 && strcmp(argv[1], "--exec-config-fd") == 0) { - return launch_workload(argc, argv); - } - if (argc != 8) { - return 125; - } - config.rootfs = argv[2]; - config.hostname = argv[3]; - config.uid = argv[4]; - config.gid = argv[5]; - config.user = argv[6]; - config.home = argv[7]; - config.direct = strcmp(config.rootfs, "-") == 0; - session.fd = open_control_tty(argv[1]); - if (session.fd < 0) { - int status = errno; - portb_error("control-open", status); - dprintf( - STDERR_FILENO, - "NVX-MANAGED-ERROR: stage=control-open status=%d\n", - status); - return finish_agent(&config, 125); - } - if (config.direct && prepare_host_mappings(&mappings) != 0) { - int status = errno; - portb_error("host-mappings", status); - dprintf( - STDERR_FILENO, - "NVX-MANAGED-ERROR: stage=host-mappings status=%d\n", - status); - close(session.fd); - return finish_agent(&config, 125); - } - if (configure_control_tty(session.fd) != 0) { - int status = errno; - portb_error("control-tty", status); - dprintf( - STDERR_FILENO, - "NVX-MANAGED-ERROR: stage=control-tty status=%d\n", - status); - close(session.fd); - return finish_agent(&config, 125); - } - result = run_agent(&session, &config, &mappings); - if (result == AGENT_STOPPED) { - close(session.fd); - return finish_agent(&config, 0); - } - if (result != 0) { - int status = errno; - portb_error("control-session", status); - dprintf( - STDERR_FILENO, - "NVX-MANAGED-ERROR: stage=control-session status=%d\n", - status); - if (session.fd >= 0) { - close(session.fd); - } - return finish_agent(&config, 125); - } - return 0; -} diff --git a/guest/common/nvx-mmio-write.c b/guest/common/nvx-mmio-write.c deleted file mode 100644 index e1749729c..000000000 --- a/guest/common/nvx-mmio-write.c +++ /dev/null @@ -1,72 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include - -static int parse_u64(const char *text, uint64_t *value) -{ - char *end; - - errno = 0; - *value = strtoull(text, &end, 0); - return errno == 0 && end != text && *end == '\0'; -} - -int main(int argc, char **argv) -{ - uint64_t address; - uint64_t value; - long page_size; - uint64_t page_base; - size_t page_offset; - void *mapping; - int memory; - - if (argc != 3 || !parse_u64(argv[1], &address) || - !parse_u64(argv[2], &value) || value > UINT32_MAX) { - fprintf(stderr, "usage: nvx-mmio-write ADDRESS VALUE\n"); - return 2; - } - - page_size = sysconf(_SC_PAGESIZE); - if (page_size <= 0 || (page_size & (page_size - 1)) != 0) { - fprintf(stderr, "invalid page size\n"); - return 1; - } - page_base = address & ~((uint64_t)page_size - 1); - page_offset = (size_t)(address - page_base); - if (page_offset > (size_t)page_size - sizeof(uint32_t)) { - fprintf(stderr, "MMIO write crosses a page boundary\n"); - return 1; - } - - memory = open("/dev/mem", O_RDWR | O_SYNC); - if (memory < 0) { - perror("open /dev/mem"); - return 1; - } - mapping = mmap(NULL, (size_t)page_size, PROT_READ | PROT_WRITE, MAP_SHARED, - memory, (off_t)page_base); - if (mapping == MAP_FAILED) { - perror("mmap /dev/mem"); - close(memory); - return 1; - } - - *(volatile uint32_t *)((unsigned char *)mapping + page_offset) = - (uint32_t)value; - - if (munmap(mapping, (size_t)page_size) != 0) { - perror("munmap /dev/mem"); - close(memory); - return 1; - } - if (close(memory) != 0) { - perror("close /dev/mem"); - return 1; - } - return 0; -} diff --git a/guest/common/nvx-reseed.c b/guest/common/nvx-reseed.c deleted file mode 100644 index 3d1142aab..000000000 --- a/guest/common/nvx-reseed.c +++ /dev/null @@ -1,178 +0,0 @@ -#define _GNU_SOURCE - -#include -#include -#include -#include -#include -#include -#include -#include - -#define SEED_BYTES 64 -#define GENERATION_ID_BYTES 16 -#define RANDOM_SAMPLE_BYTES 32 - -struct seed_payload { - int entropy_count; - int buf_size; - unsigned char bytes[SEED_BYTES]; -}; - -static int read_seed(const char *path, unsigned char *bytes) -{ - int fd = open(path, O_RDONLY | O_CLOEXEC); - if (fd < 0) { - fprintf(stderr, "nvx-reseed: open %s: %s\n", path, strerror(errno)); - return -1; - } - - size_t offset = 0; - while (offset < SEED_BYTES) { - ssize_t count = read(fd, bytes + offset, SEED_BYTES - offset); - if (count <= 0) { - fprintf(stderr, "nvx-reseed: read %s: %s\n", path, - count == 0 ? "unexpected end of file" : strerror(errno)); - close(fd); - return -1; - } - offset += (size_t)count; - } - close(fd); - return 0; -} - -static int print_hex(const unsigned char *bytes, size_t size) -{ - for (size_t index = 0; index < size; ++index) { - if (printf("%02x", bytes[index]) < 0) { - fprintf(stderr, "nvx-reseed: write output failed\n"); - return -1; - } - } - if (putchar('\n') == EOF) { - fprintf(stderr, "nvx-reseed: write output failed\n"); - return -1; - } - return 0; -} - -static int hex_value(char digit) -{ - if (digit >= '0' && digit <= '9') { - return digit - '0'; - } - if (digit >= 'a' && digit <= 'f') { - return digit - 'a' + 10; - } - if (digit >= 'A' && digit <= 'F') { - return digit - 'A' + 10; - } - return -1; -} - -static int validate_generation_change(const char *previous, - const unsigned char *generation_id) -{ - if (strlen(previous) != GENERATION_ID_BYTES * 2) { - fprintf(stderr, - "nvx-reseed: previous VM generation ID is not 32 hex digits\n"); - return -1; - } - unsigned char previous_bytes[GENERATION_ID_BYTES]; - for (size_t index = 0; index < sizeof(previous_bytes); ++index) { - int high = hex_value(previous[index * 2]); - int low = hex_value(previous[index * 2 + 1]); - if (high < 0 || low < 0) { - fprintf(stderr, - "nvx-reseed: previous VM generation ID is not hexadecimal\n"); - return -1; - } - previous_bytes[index] = (unsigned char)((high << 4) | low); - } - if (memcmp(previous_bytes, generation_id, sizeof(previous_bytes)) == 0) { - fprintf(stderr, "nvx-reseed: VM generation ID did not change\n"); - return -1; - } - return 0; -} - -static int print_random_sample(void) -{ - unsigned char sample[RANDOM_SAMPLE_BYTES]; - size_t offset = 0; - while (offset < sizeof(sample)) { - ssize_t count = - getrandom(sample + offset, sizeof(sample) - offset, 0); - if (count > 0) { - offset += (size_t)count; - continue; - } - if (count < 0 && errno == EINTR) { - continue; - } - fprintf(stderr, "nvx-reseed: getrandom: %s\n", - count == 0 ? "no bytes returned" : strerror(errno)); - return -1; - } - return print_hex(sample, sizeof(sample)); -} - -int main(int argc, char **argv) -{ - if (argc == 2 && strcmp(argv[1], "--sample") == 0) { - return print_random_sample() == 0 ? 0 : 1; - } - - int generation_only = 0; - const char *seed_path; - const char *previous_generation_id = NULL; - if (argc == 2 || argc == 3) { - seed_path = argv[1]; - if (argc == 3) { - previous_generation_id = argv[2]; - } - } else if (argc == 4 && strcmp(argv[1], "--generation-only") == 0) { - generation_only = 1; - seed_path = argv[2]; - previous_generation_id = argv[3]; - } else { - fprintf(stderr, - "usage: nvx-reseed --sample | [--generation-only] SEED_FILE " - "[PREVIOUS_GENERATION_ID]\n"); - return 2; - } - - struct seed_payload seed = { - .entropy_count = SEED_BYTES * 8, - .buf_size = SEED_BYTES, - }; - if (read_seed(seed_path, seed.bytes) != 0) { - return 1; - } - if (previous_generation_id != NULL && - validate_generation_change(previous_generation_id, seed.bytes) != 0) { - return 1; - } - if (generation_only) { - return print_hex(seed.bytes, GENERATION_ID_BYTES) == 0 ? 0 : 1; - } - - int random = open("/dev/random", O_WRONLY | O_CLOEXEC); - if (random < 0) { - fprintf(stderr, "nvx-reseed: open /dev/random: %s\n", strerror(errno)); - return 1; - } - if (ioctl(random, RNDADDENTROPY, &seed) != 0 || - ioctl(random, RNDRESEEDCRNG, 0) != 0) { - fprintf(stderr, "nvx-reseed: random ioctl: %s\n", strerror(errno)); - close(random); - return 1; - } - close(random); - if (previous_generation_id != NULL && - print_hex(seed.bytes, GENERATION_ID_BYTES) != 0) { - return 1; - } - return 0; -} diff --git a/guest/common/nvx-time-probe.c b/guest/common/nvx-time-probe.c deleted file mode 100644 index f77f9b254..000000000 --- a/guest/common/nvx-time-probe.c +++ /dev/null @@ -1,1763 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// nvx-time-probe: guest-side time diagnostics for the NVX time ABI. -// -// latency ns/op of TSC reads and clock_gettime(), p50/p99. -// warp pairwise cross-CPU TSC warp and offset checks. -// clock clocks, clocksources, TSC flags, hypervisor identity, and -// per-CPU clock event devices. -// -// Build (static, x86-64): cc -O2 -static -pthread -o nvx-time-probe -// nvx-time-probe.c - -#define _GNU_SOURCE - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define PROBE_VERSION "1.1" -#define EXIT_FAIL 1 -#define EXIT_USAGE 2 -#define EXIT_ERROR 3 -#define MAX_CPUS 1024 -#define KMSG_RECORD_MAX 8192 -#define SYSLOG_ACTION_READ_ALL 3 -#define SYSLOG_ACTION_SIZE_BUFFER 10 -#define CLOCKSOURCE_DIR "/sys/devices/system/clocksource/clocksource0" - -struct tsc_rate { - double hz; - const char *source; -}; - -static struct tsc_rate g_tsc; -static volatile uint64_t g_sink; - -// --------------------------------------------------------------------------- -// CPU primitives -// --------------------------------------------------------------------------- - -static inline uint64_t rdtsc_plain(void) -{ - uint32_t lo, hi; - - __asm__ __volatile__("rdtsc" : "=a"(lo), "=d"(hi)); - return ((uint64_t)hi << 32) | lo; -} - -static inline uint64_t rdtscp_plain(void) -{ - uint32_t lo, hi, aux; - - __asm__ __volatile__("rdtscp" : "=a"(lo), "=d"(hi), "=c"(aux)); - return ((uint64_t)hi << 32) | lo; -} - -// Linux rdtsc_ordered(): the read cannot pass earlier loads. -static inline uint64_t rdtsc_lfence(void) -{ - uint32_t lo, hi; - - __asm__ __volatile__("lfence\n\trdtsc" : "=a"(lo), "=d"(hi)::"memory"); - return ((uint64_t)hi << 32) | lo; -} - -// Fully fenced read for timing brackets: later work cannot start before it. -static inline uint64_t rdtsc_fenced(void) -{ - uint32_t lo, hi; - - __asm__ __volatile__("lfence\n\trdtsc\n\tlfence" - : "=a"(lo), "=d"(hi)::"memory"); - return ((uint64_t)hi << 32) | lo; -} - -static inline void cpu_pause(void) -{ - __asm__ __volatile__("pause" ::: "memory"); -} - -static void cpuid(uint32_t leaf, uint32_t subleaf, uint32_t regs[4]) -{ - __asm__ __volatile__("cpuid" - : "=a"(regs[0]), "=b"(regs[1]), "=c"(regs[2]), - "=d"(regs[3]) - : "a"(leaf), "c"(subleaf)); -} - -static bool cpu_has_rdtscp(void) -{ - uint32_t regs[4]; - - cpuid(0x80000000u, 0, regs); - if (regs[0] < 0x80000001u) - return false; - cpuid(0x80000001u, 0, regs); - return (regs[3] >> 27) & 1; -} - -static int pin_self(int cpu) -{ - cpu_set_t set; - - CPU_ZERO(&set); - CPU_SET(cpu, &set); - if (sched_setaffinity(0, sizeof(set), &set) != 0) - return -1; - if (sched_getcpu() != cpu) { - errno = EINVAL; - return -1; - } - return 0; -} - -// --------------------------------------------------------------------------- -// Small helpers -// --------------------------------------------------------------------------- - -static double cycles_to_ns(double cycles) -{ - return g_tsc.hz > 0 ? cycles * 1e9 / g_tsc.hz : 0; -} - -static long long round_ll(double value) -{ - return (long long)(value >= 0 ? value + 0.5 : value - 0.5); -} - -static int read_first_line(const char *path, char *buf, size_t size) -{ - FILE *file = fopen(path, "r"); - - if (!file) - return -1; - if (!fgets(buf, (int)size, file)) { - fclose(file); - return -1; - } - fclose(file); - buf[strcspn(buf, "\n")] = '\0'; - return 0; -} - -static int cmp_double(const void *a, const void *b) -{ - double x = *(const double *)a; - double y = *(const double *)b; - - return (x > y) - (x < y); -} - -static int cmp_i64(const void *a, const void *b) -{ - int64_t x = *(const int64_t *)a; - int64_t y = *(const int64_t *)b; - - return (x > y) - (x < y); -} - -// Nearest-rank percentile over a sorted array, as the NVX harness reports. -static double nearest_rank(const double *sorted, size_t count, double pct) -{ - double exact = pct * (double)count; - size_t rank = (size_t)exact; - - if ((double)rank < exact) - rank++; - if (rank < 1) - rank = 1; - if (rank > count) - rank = count; - return sorted[rank - 1]; -} - -static int parse_u64_arg(const char *text, uint64_t *value) -{ - char *end; - - errno = 0; - *value = strtoull(text, &end, 0); - return errno == 0 && end != text && *end == '\0' ? 0 : -1; -} - -static int parse_cpu_list(const char *text, int *cpus, int max) -{ - int count = 0; - const char *p = text; - - while (*p) { - char *end; - long first = strtol(p, &end, 10); - long last = first; - - if (end == p || first < 0 || first >= MAX_CPUS) - return -1; - p = end; - if (*p == '-') { - last = strtol(p + 1, &end, 10); - if (end == p + 1 || last < first || last >= MAX_CPUS) - return -1; - p = end; - } - for (long cpu = first; cpu <= last; cpu++) { - for (int i = 0; i < count; i++) - if (cpus[i] == cpu) - return -1; - if (count >= max) - return -1; - cpus[count++] = (int)cpu; - } - if (*p == ',') - p++; - else if (*p) - return -1; - } - return count; -} - -static int affinity_cpus(int *cpus, int max) -{ - cpu_set_t set; - int count = 0; - - if (sched_getaffinity(0, sizeof(set), &set) != 0) - return -1; - for (int cpu = 0; cpu < CPU_SETSIZE && count < max; cpu++) - if (CPU_ISSET(cpu, &set)) - cpus[count++] = cpu; - return count; -} - -// Rejects CPUs outside the affinity mask (offline or excluded) before any -// measurement starts. -static int check_cpus_allowed(const char *command, const int *cpus, int count) -{ - cpu_set_t allowed; - - if (sched_getaffinity(0, sizeof(allowed), &allowed) != 0) { - fprintf(stderr, "nvx-time-probe %s: sched_getaffinity: %s\n", command, - strerror(errno)); - return -1; - } - for (int i = 0; i < count; i++) { - if (cpus[i] >= CPU_SETSIZE || !CPU_ISSET(cpus[i], &allowed)) { - fprintf(stderr, - "nvx-time-probe %s: cpu %d is not in the affinity mask" - " (offline or excluded)\n", - command, cpus[i]); - return -1; - } - } - return 0; -} - -static void format_cpu_list(const int *cpus, int count, char *buf, size_t size) -{ - size_t used = 0; - - buf[0] = '\0'; - for (int i = 0; i < count && used < size; i++) { - int first = cpus[i]; - int last = first; - - while (i + 1 < count && cpus[i + 1] == last + 1) - last = cpus[++i]; - used += (size_t)snprintf(buf + used, size - used, "%s%d", used ? "," : "", - first); - if (last != first && used < size) - used += (size_t)snprintf(buf + used, size - used, "-%d", last); - } -} - -// --------------------------------------------------------------------------- -// Kernel log access (/dev/kmsg, falling back to syslog(2)) -// --------------------------------------------------------------------------- - -typedef void (*kmsg_fn)(uint64_t usec, const char *text, void *ctx); - -static int kmsg_from_devkmsg(kmsg_fn fn, void *ctx) -{ - char record[KMSG_RECORD_MAX]; - int count = 0; - int fd = open("/dev/kmsg", O_RDONLY | O_NONBLOCK | O_CLOEXEC); - - if (fd < 0) - return -1; - for (;;) { - ssize_t n = read(fd, record, sizeof(record) - 1); - char *semi; - char *p; - int field = 0; - - if (n < 0 && (errno == EINTR || errno == EPIPE)) - continue; - if (n <= 0) - break; - record[n] = '\0'; - // Record format: "prio,seq,usec,flags[,...];message\n[ KEY=VALUE\n]". - semi = strchr(record, ';'); - if (!semi) - continue; - for (p = record; p < semi && field < 2; p++) - if (*p == ',') - field++; - record[strcspn(record, "\n")] = '\0'; - fn(strtoull(p, NULL, 10), semi + 1, ctx); - count++; - } - close(fd); - return count > 0 ? 0 : -1; -} - -static int kmsg_from_syslog(kmsg_fn fn, void *ctx) -{ - int size = klogctl(SYSLOG_ACTION_SIZE_BUFFER, NULL, 0); - char *buf; - char *line; - int n; - - if (size <= 0) - size = 1 << 20; - buf = malloc((size_t)size + 1); - if (!buf) - return -1; - n = klogctl(SYSLOG_ACTION_READ_ALL, buf, size); - if (n < 0) { - free(buf); - return -1; - } - buf[n] = '\0'; - for (line = buf; line && *line;) { - char *next = strchr(line, '\n'); - char *p = line; - uint64_t usec = 0; - - if (next) - *next++ = '\0'; - if (*p == '<') { - char *close_angle = strchr(p, '>'); - - if (close_angle) - p = close_angle + 1; - } - if (*p == '[') { - char *close_bracket = strchr(p, ']'); - - if (close_bracket) { - usec = (uint64_t)(strtod(p + 1, NULL) * 1e6 + 0.5); - p = close_bracket + 1; - if (*p == ' ') - p++; - } - } - fn(usec, p, ctx); - line = next; - } - free(buf); - return 0; -} - -static int kmsg_foreach(kmsg_fn fn, void *ctx) -{ - if (kmsg_from_devkmsg(fn, ctx) == 0) - return 0; - return kmsg_from_syslog(fn, ctx); -} - -// --------------------------------------------------------------------------- -// /proc/cpuinfo and TSC rate discovery -// --------------------------------------------------------------------------- - -static bool cpuinfo_has_flag(const char *flag) -{ - static char flags[8192]; - static bool loaded; - size_t len = strlen(flag); - const char *p; - - if (!loaded) { - char line[8192]; - FILE *file = fopen("/proc/cpuinfo", "r"); - - loaded = true; - while (file && fgets(line, sizeof(line), file)) { - if (strncmp(line, "flags", 5) == 0 && strchr(line, ':')) { - snprintf(flags, sizeof(flags), " %s", strchr(line, ':') + 1); - flags[strcspn(flags, "\n")] = '\0'; - strncat(flags, " ", sizeof(flags) - strlen(flags) - 1); - break; - } - } - if (file) - fclose(file); - } - for (p = flags; (p = strstr(p, flag)) != NULL; p += len) - if (p[-1] == ' ' && p[len] == ' ') - return true; - return false; -} - -static double cpuinfo_mhz(void) -{ - char line[512]; - double mhz = 0; - FILE *file = fopen("/proc/cpuinfo", "r"); - - if (!file) - return 0; - while (fgets(line, sizeof(line), file)) { - if (strncmp(line, "cpu MHz", 7) == 0 && strchr(line, ':')) { - mhz = strtod(strchr(line, ':') + 1, NULL); - break; - } - } - fclose(file); - return mhz; -} - -struct kmsg_tsc { - double refined_mhz; - double detected_tsc_mhz; - double detected_cpu_mhz; -}; - -static double mhz_after(const char *text, const char *prefix, const char *suffix) -{ - const char *p = strstr(text, prefix); - char *end; - double mhz; - - if (!p) - return 0; - p += strlen(prefix); - mhz = strtod(p, &end); - if (end == p || strncmp(end, suffix, strlen(suffix)) != 0) - return 0; - return mhz; -} - -static void kmsg_tsc_cb(uint64_t usec, const char *text, void *ctx) -{ - struct kmsg_tsc *k = ctx; - double mhz; - - (void)usec; - if ((mhz = mhz_after(text, "Refined TSC clocksource calibration: ", " MHz")) > 0) - k->refined_mhz = mhz; - else if ((mhz = mhz_after(text, "tsc: Detected ", " MHz TSC")) > 0) - k->detected_tsc_mhz = mhz; - else if ((mhz = mhz_after(text, "tsc: Detected ", " MHz processor")) > 0) - k->detected_cpu_mhz = mhz; -} - -// The kernel prints kHz precision as "NNNN.NNN MHz". -static double mhz_to_hz(double mhz) -{ - return (double)(uint64_t)(mhz * 1000.0 + 0.5) * 1000.0; -} - -static double measure_tsc_hz(unsigned ms) -{ - struct timespec a, b; - struct timespec pause = {ms / 1000, (long)(ms % 1000) * 1000000L}; - uint64_t t0, t1; - double ns; - - clock_gettime(CLOCK_MONOTONIC_RAW, &a); - t0 = rdtsc_fenced(); - nanosleep(&pause, NULL); - clock_gettime(CLOCK_MONOTONIC_RAW, &b); - t1 = rdtsc_fenced(); - ns = (double)(b.tv_sec - a.tv_sec) * 1e9 + (double)(b.tv_nsec - a.tv_nsec); - return ns > 0 ? (double)(t1 - t0) * 1e9 / ns : 0; -} - -// Precedence: option, sysfs tsc_freq_khz, kernel log (refined, then detected -// TSC, then detected processor), "cpu MHz" without APERF/MPERF, measurement. -static void detect_tsc_rate(double override_hz) -{ - struct kmsg_tsc k = {0}; - char line[64]; - - if (override_hz > 0) { - g_tsc = (struct tsc_rate){override_hz, "option"}; - return; - } - if (read_first_line("/sys/devices/system/cpu/cpu0/tsc_freq_khz", line, - sizeof(line)) == 0 && - strtoull(line, NULL, 10) > 0) { - g_tsc = (struct tsc_rate){(double)strtoull(line, NULL, 10) * 1000.0, - "sysfs-tsc_freq_khz"}; - return; - } - kmsg_foreach(kmsg_tsc_cb, &k); - if (k.refined_mhz > 0) { - g_tsc = (struct tsc_rate){mhz_to_hz(k.refined_mhz), "kmsg-refined"}; - return; - } - if (k.detected_tsc_mhz > 0) { - g_tsc = (struct tsc_rate){mhz_to_hz(k.detected_tsc_mhz), - "kmsg-detected-tsc"}; - return; - } - if (k.detected_cpu_mhz > 0) { - g_tsc = (struct tsc_rate){mhz_to_hz(k.detected_cpu_mhz), - "kmsg-detected-processor"}; - return; - } - // Without APERF/MPERF, "cpu MHz" reports cpu_khz, which equals tsc_khz. - if (!cpuinfo_has_flag("aperfmperf") && cpuinfo_mhz() > 0) { - g_tsc = (struct tsc_rate){mhz_to_hz(cpuinfo_mhz()), "cpuinfo-cpu-mhz"}; - return; - } - g_tsc = (struct tsc_rate){measure_tsc_hz(200), "measured-monotonic-raw"}; -} - -static void current_clocksource(char *buf, size_t size) -{ - if (read_first_line(CLOCKSOURCE_DIR "/current_clocksource", buf, size) != 0) - snprintf(buf, size, "unknown"); -} - -// --------------------------------------------------------------------------- -// Option parsing -// --------------------------------------------------------------------------- - -// Matches "--name value" or "--name=value" at argv[*index]. -static const char *option_value(int argc, char **argv, int *index, const char *name) -{ - const char *arg = argv[*index]; - size_t len = strlen(name); - - if (strncmp(arg, name, len) != 0) - return NULL; - if (arg[len] == '=') - return arg + len + 1; - if (arg[len] == '\0' && *index + 1 < argc) - return argv[++*index]; - return NULL; -} - -static int parse_hz_arg(const char *text, double *hz) -{ - char *end; - - errno = 0; - *hz = strtod(text, &end); - return errno == 0 && end != text && *end == '\0' && *hz > 0 ? 0 : -1; -} - -static int bad_option(const char *command, const char *arg) -{ - fprintf(stderr, "nvx-time-probe %s: invalid option or value: %s\n", command, arg); - return EXIT_USAGE; -} - -// --------------------------------------------------------------------------- -// latency -// --------------------------------------------------------------------------- - -enum method { - M_RDTSC, - M_RDTSCP, - M_LFENCE_RDTSC, - M_CLOCK_MONOTONIC, - M_CLOCK_REALTIME, - M_SYSCALL_MONOTONIC, - M_SYSCALL_GETPID, - M_SCHED_YIELD, - M_COUNT, -}; - -static const char *const method_names[M_COUNT] = { - "rdtsc", - "rdtscp", - "lfence_rdtsc", - "clock_gettime_monotonic", - "clock_gettime_realtime", - "syscall_clock_gettime_monotonic", - "syscall_getpid", - "sched_yield", -}; - -struct latency_result { - double p50; - double p99; - double min; - double mean; - size_t samples; -}; - -static uint64_t run_batch(enum method method, unsigned batch) -{ - struct timespec ts; - uint64_t sink = 0; - unsigned i; - - switch (method) { - case M_RDTSC: - for (i = 0; i < batch; i++) - sink += rdtsc_plain(); - break; - case M_RDTSCP: - for (i = 0; i < batch; i++) - sink += rdtscp_plain(); - break; - case M_LFENCE_RDTSC: - for (i = 0; i < batch; i++) - sink += rdtsc_lfence(); - break; - case M_CLOCK_MONOTONIC: - for (i = 0; i < batch; i++) { - clock_gettime(CLOCK_MONOTONIC, &ts); - sink += (uint64_t)ts.tv_nsec; - } - break; - case M_CLOCK_REALTIME: - for (i = 0; i < batch; i++) { - clock_gettime(CLOCK_REALTIME, &ts); - sink += (uint64_t)ts.tv_nsec; - } - break; - case M_SYSCALL_MONOTONIC: - for (i = 0; i < batch; i++) { - syscall(SYS_clock_gettime, CLOCK_MONOTONIC, &ts); - sink += (uint64_t)ts.tv_nsec; - } - break; - case M_SYSCALL_GETPID: - for (i = 0; i < batch; i++) - sink += (uint64_t)syscall(SYS_getpid); - break; - case M_SCHED_YIELD: - for (i = 0; i < batch; i++) - sink += (uint64_t)sched_yield(); - break; - default: - break; - } - return sink; -} - -// Median cost of an empty fenced timing bracket, subtracted from samples. -static double bracket_overhead_cycles(size_t iterations) -{ - double *samples = malloc(iterations * sizeof(*samples)); - double median; - - if (!samples) - return 0; - for (size_t i = 0; i < iterations; i++) { - uint64_t t0 = rdtsc_fenced(); - uint64_t t1 = rdtsc_fenced(); - - samples[i] = (double)(t1 - t0); - } - qsort(samples, iterations, sizeof(*samples), cmp_double); - median = nearest_rank(samples, iterations, 0.50); - free(samples); - return median; -} - -static int measure_method(enum method method, size_t iterations, unsigned batch, - double overhead_cycles, struct latency_result *out) -{ - double *per_op = malloc(iterations * sizeof(*per_op)); - double sum = 0; - - if (!per_op) - return -1; - g_sink += run_batch(method, batch); - for (size_t i = 0; i < iterations; i++) { - uint64_t t0 = rdtsc_fenced(); - uint64_t sink = run_batch(method, batch); - uint64_t t1 = rdtsc_fenced(); - double cycles = (double)(t1 - t0) - overhead_cycles; - - g_sink += sink; - if (cycles < 0) - cycles = 0; - per_op[i] = cycles_to_ns(cycles) / batch; - sum += per_op[i]; - } - qsort(per_op, iterations, sizeof(*per_op), cmp_double); - out->p50 = nearest_rank(per_op, iterations, 0.50); - out->p99 = nearest_rank(per_op, iterations, 0.99); - out->min = per_op[0]; - out->mean = sum / (double)iterations; - out->samples = iterations; - free(per_op); - return 0; -} - -static int cmd_latency(int argc, char **argv) -{ - uint64_t iterations = 2000; - uint64_t batch = 50; - uint64_t cpu_arg = UINT64_MAX; - double tsc_hz = 0; - struct latency_result results[M_COUNT] = {0}; - char clocksource[64]; - bool has_rdtscp = cpu_has_rdtscp(); - bool has_vdso = getauxval(AT_SYSINFO_EHDR) != 0; - double overhead; - int cpu; - - for (int i = 2; i < argc; i++) { - const char *value; - - if ((value = option_value(argc, argv, &i, "--iterations"))) { - if (parse_u64_arg(value, &iterations) || iterations == 0 || - iterations > 10000000) - return bad_option("latency", argv[i]); - } else if ((value = option_value(argc, argv, &i, "--batch"))) { - if (parse_u64_arg(value, &batch) || batch == 0 || batch > 1000000) - return bad_option("latency", argv[i]); - } else if ((value = option_value(argc, argv, &i, "--cpu"))) { - if (parse_u64_arg(value, &cpu_arg) || cpu_arg >= MAX_CPUS) - return bad_option("latency", argv[i]); - } else if ((value = option_value(argc, argv, &i, "--tsc-hz"))) { - if (parse_hz_arg(value, &tsc_hz)) - return bad_option("latency", argv[i]); - } else { - return bad_option("latency", argv[i]); - } - } - - cpu = cpu_arg == UINT64_MAX ? sched_getcpu() : (int)cpu_arg; - if (cpu >= 0 && check_cpus_allowed("latency", &cpu, 1) != 0) - return EXIT_USAGE; - if (cpu < 0 || pin_self(cpu) != 0) { - fprintf(stderr, "nvx-time-probe latency: cannot pin to cpu %d: %s\n", cpu, - strerror(errno)); - return EXIT_ERROR; - } - detect_tsc_rate(tsc_hz); - if (g_tsc.hz <= 0) { - fprintf(stderr, "nvx-time-probe latency: unknown TSC rate; pass --tsc-hz\n"); - return EXIT_ERROR; - } - current_clocksource(clocksource, sizeof(clocksource)); - overhead = bracket_overhead_cycles((size_t)iterations); - - printf("nvx-time-probe %s latency: cpu=%d iterations=%" PRIu64 " batch=%" PRIu64 - " tsc_hz=%.0f tsc_hz_source=%s clocksource=%s vdso=%d" - " bracket_overhead_ns=%.1f\n", - PROBE_VERSION, cpu, iterations, batch, g_tsc.hz, g_tsc.source, - clocksource, has_vdso, cycles_to_ns(overhead)); - printf("%-32s %10s %10s %10s %10s\n", "method", "p50_ns", "p99_ns", "min_ns", - "mean_ns"); - for (int m = 0; m < M_COUNT; m++) { - if (m == M_RDTSCP && !has_rdtscp) { - printf("%-32s unsupported (CPUID.80000001H:EDX[27] clear)\n", - method_names[m]); - continue; - } - if (measure_method((enum method)m, (size_t)iterations, (unsigned)batch, - overhead, &results[m]) != 0) { - fprintf(stderr, "nvx-time-probe latency: out of memory\n"); - return EXIT_ERROR; - } - printf("%-32s %10.1f %10.1f %10.1f %10.1f\n", method_names[m], - results[m].p50, results[m].p99, results[m].min, results[m].mean); - fflush(stdout); - } - for (int m = 0; m < M_COUNT; m++) { - if (!results[m].samples) { - printf("NVX-TIME-PROBE latency method=%s unsupported=1\n", method_names[m]); - continue; - } - printf("NVX-TIME-PROBE latency method=%s p50_ns=%.1f p99_ns=%.1f min_ns=%.1f" - " mean_ns=%.1f samples=%zu batch=%" PRIu64 " cpu=%d clocksource=%s\n", - method_names[m], results[m].p50, results[m].p99, results[m].min, - results[m].mean, results[m].samples, batch, cpu, clocksource); - } - return 0; -} - -// --------------------------------------------------------------------------- -// warp -// --------------------------------------------------------------------------- - -#define PP_STOP UINT64_MAX -#define PP_MAX_SAMPLES (1u << 18) -#define STALL_SECONDS 2.0 -#define SPIN_CHECK_MASK 0xfffffu - -// (a) Linux check_tsc_warp(): both CPUs take one spinlock, read the TSC, and -// compare it with the last value either CPU stored. A backward step proves a -// skew larger than the lock handoff latency, so it is a lower bound. -static struct { - _Alignas(64) atomic_int lock; - uint64_t last_tsc; - uint64_t max_warp; - uint64_t warps; - _Alignas(64) atomic_int ready; - atomic_int abort; - atomic_int stop; -} g_warp; - -// (b) Ping-pong: cpu_a reads t1 and signals, cpu_b reads t2 and answers, and -// cpu_a reads t3. The offset of cpu_b relative to cpu_a lies in [t2-t3, t2-t1]; -// the point estimate is t2-(t1+t3)/2 with uncertainty (t3-t1)/2. -static struct { - _Alignas(64) _Atomic uint64_t seq; - _Atomic uint64_t t2; - _Alignas(64) atomic_int ready; - atomic_int abort; -} g_pp; - -struct pair_result { - int cpu_a; - int cpu_b; - uint64_t warp_iterations; - uint64_t warps; - uint64_t max_warp_cycles; - uint64_t rounds; - uint64_t min_rtt_cycles; - int64_t min_rtt_offset_x2; - int64_t lo_cycles; - int64_t hi_cycles; - double median_offset_cycles; - double offset_cycles; - double uncertainty_cycles; - bool consistent; - bool stalled; -}; - -struct thread_arg { - int cpu; - bool leader; - uint64_t budget_cycles; - uint64_t stall_cycles; - uint64_t iterations; - int pin_errno; - struct pair_result *result; - int64_t *offsets_x2; -}; - -static bool pair_barrier(atomic_int *ready, atomic_int *abort_flag, bool failed) -{ - if (failed) - atomic_store(abort_flag, 1); - atomic_fetch_add(ready, 1); - while (atomic_load(ready) < 2) - cpu_pause(); - return !atomic_load(abort_flag); -} - -static uint64_t coarse_ns(void) -{ - struct timespec ts; - - clock_gettime(CLOCK_MONOTONIC_COARSE, &ts); - return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; -} - -static void *warp_thread(void *p) -{ - struct thread_arg *arg = p; - bool pinned = pin_self(arg->cpu) == 0; - uint64_t end; - uint64_t deadline_ns; - uint64_t i; - - if (!pinned) - arg->pin_errno = errno; - if (!pair_barrier(&g_warp.ready, &g_warp.abort, !pinned)) - return NULL; - end = rdtsc_lfence() + arg->budget_cycles; - // Guard against a TSC that never reaches the deadline. - deadline_ns = coarse_ns() + - (uint64_t)(cycles_to_ns((double)arg->budget_cycles) + STALL_SECONDS * 1e9); - for (i = 0;; i++) { - uint64_t prev, now; - - while (atomic_exchange_explicit(&g_warp.lock, 1, memory_order_acquire)) - while (atomic_load_explicit(&g_warp.lock, memory_order_relaxed)) - cpu_pause(); - prev = g_warp.last_tsc; - now = rdtsc_lfence(); - g_warp.last_tsc = now; - if (prev > now) { - if (prev - now > g_warp.max_warp) - g_warp.max_warp = prev - now; - g_warp.warps++; - } - atomic_store_explicit(&g_warp.lock, 0, memory_order_release); - if (!(i & 7)) { - if (arg->leader && - (rdtsc_plain() > end || (!(i & 4095) && coarse_ns() > deadline_ns))) - atomic_store_explicit(&g_warp.stop, 1, memory_order_relaxed); - if (atomic_load_explicit(&g_warp.stop, memory_order_relaxed)) - break; - } - } - arg->iterations = i + 1; - return NULL; -} - -static void *pp_leader(void *p) -{ - struct thread_arg *arg = p; - struct pair_result *r = arg->result; - bool pinned = pin_self(arg->cpu) == 0; - uint64_t stored = 0; - uint64_t end; - uint64_t k; - - if (!pinned) - arg->pin_errno = errno; - if (!pair_barrier(&g_pp.ready, &g_pp.abort, !pinned)) - return NULL; - end = rdtsc_lfence() + arg->budget_cycles; - for (k = 1;; k++) { - uint64_t spins = 0; - uint64_t t1, t2, t3, rtt; - int64_t lo, hi; - - t1 = rdtsc_fenced(); - atomic_store_explicit(&g_pp.seq, 2 * k - 1, memory_order_release); - while (atomic_load_explicit(&g_pp.seq, memory_order_acquire) != 2 * k) { - if ((++spins & SPIN_CHECK_MASK) == 0 && - rdtsc_plain() - t1 > arg->stall_cycles) { - r->stalled = true; - goto done; - } - } - t3 = rdtsc_lfence(); - t2 = atomic_load_explicit(&g_pp.t2, memory_order_relaxed); - rtt = t3 - t1; - lo = (int64_t)(t2 - t3); - hi = (int64_t)(t2 - t1); - if (rtt < r->min_rtt_cycles) { - r->min_rtt_cycles = rtt; - r->min_rtt_offset_x2 = lo + hi; - } - if (lo > r->lo_cycles) - r->lo_cycles = lo; - if (hi < r->hi_cycles) - r->hi_cycles = hi; - if (stored < PP_MAX_SAMPLES) - arg->offsets_x2[stored++] = lo + hi; - r->rounds = k; - if ((k & 63) == 0 && rdtsc_plain() > end) - break; - } -done: - atomic_store_explicit(&g_pp.seq, PP_STOP, memory_order_release); - arg->iterations = stored; - return NULL; -} - -static void *pp_follower(void *p) -{ - struct thread_arg *arg = p; - bool pinned = pin_self(arg->cpu) == 0; - - if (!pinned) - arg->pin_errno = errno; - if (!pair_barrier(&g_pp.ready, &g_pp.abort, !pinned)) - return NULL; - for (uint64_t k = 1;; k++) { - uint64_t spins = 0; - uint64_t wait_start = 0; - uint64_t value; - - while ((value = atomic_load_explicit(&g_pp.seq, memory_order_acquire)) != - 2 * k - 1) { - if (value == PP_STOP) - return NULL; - if ((++spins & SPIN_CHECK_MASK) == 0) { - uint64_t now = rdtsc_plain(); - - if (!wait_start) - wait_start = now; - else if (now - wait_start > arg->stall_cycles) - return NULL; - } - } - atomic_store_explicit(&g_pp.t2, rdtsc_fenced(), memory_order_relaxed); - atomic_store_explicit(&g_pp.seq, 2 * k, memory_order_release); - } -} - -static int run_threads(void *(*fn_a)(void *), void *(*fn_b)(void *), - struct thread_arg *a, struct thread_arg *b) -{ - pthread_t ta, tb; - int err; - - if ((err = pthread_create(&ta, NULL, fn_a, a)) != 0) { - errno = err; - return -1; - } - if ((err = pthread_create(&tb, NULL, fn_b, b)) != 0) { - // Release the first thread from its barrier before joining it. - atomic_store(&g_warp.abort, 1); - atomic_store(&g_pp.abort, 1); - atomic_fetch_add(&g_warp.ready, 1); - atomic_fetch_add(&g_pp.ready, 1); - pthread_join(ta, NULL); - errno = err; - return -1; - } - pthread_join(ta, NULL); - pthread_join(tb, NULL); - if (a->pin_errno || b->pin_errno) { - errno = a->pin_errno ? a->pin_errno : b->pin_errno; - return -1; - } - return 0; -} - -static int run_pair(int cpu_a, int cpu_b, uint64_t budget_cycles, - uint64_t stall_cycles, int64_t *offsets_x2, - struct pair_result *r) -{ - struct thread_arg a = {cpu_a, true, budget_cycles, stall_cycles, 0, 0, r, offsets_x2}; - struct thread_arg b = {cpu_b, false, budget_cycles, stall_cycles, 0, 0, r, NULL}; - - memset(r, 0, sizeof(*r)); - r->cpu_a = cpu_a; - r->cpu_b = cpu_b; - - atomic_store(&g_warp.lock, 0); - atomic_store(&g_warp.ready, 0); - atomic_store(&g_warp.abort, 0); - atomic_store(&g_warp.stop, 0); - g_warp.last_tsc = 0; - g_warp.max_warp = 0; - g_warp.warps = 0; - if (run_threads(warp_thread, warp_thread, &a, &b) != 0) - return -1; - r->warp_iterations = a.iterations + b.iterations; - r->warps = g_warp.warps; - r->max_warp_cycles = g_warp.max_warp; - - atomic_store(&g_pp.seq, 0); - atomic_store(&g_pp.t2, 0); - atomic_store(&g_pp.ready, 0); - atomic_store(&g_pp.abort, 0); - r->min_rtt_cycles = UINT64_MAX; - r->lo_cycles = INT64_MIN; - r->hi_cycles = INT64_MAX; - a.iterations = 0; - if (run_threads(pp_leader, pp_follower, &a, &b) != 0) - return -1; - if (a.iterations == 0) { - r->stalled = true; - return 0; - } - qsort(offsets_x2, (size_t)a.iterations, sizeof(*offsets_x2), cmp_i64); - r->median_offset_cycles = (double)offsets_x2[(a.iterations - 1) / 2] / 2.0; - r->consistent = r->lo_cycles <= r->hi_cycles; - if (r->consistent) { - r->offset_cycles = ((double)r->lo_cycles + (double)r->hi_cycles) / 2.0; - r->uncertainty_cycles = ((double)r->hi_cycles - (double)r->lo_cycles) / 2.0; - } else { - r->offset_cycles = (double)r->min_rtt_offset_x2 / 2.0; - r->uncertainty_cycles = (double)r->min_rtt_cycles / 2.0; - } - return 0; -} - -static double abs_double(double value) -{ - return value < 0 ? -value : value; -} - -static int cmd_warp(int argc, char **argv) -{ - static int cpus[MAX_CPUS]; - uint64_t duration_ms = 100; - uint64_t bound_ns = 1000; - double tsc_hz = 0; - int ncpus = -1; - int pairs = 0; - int inconsistent = 0; - int stalled = 0; - uint64_t total_warps = 0; - uint64_t max_backward = 0; - double max_abs_offset = 0; - double max_uncertainty = 0; - double max_skew_bound = 0; - uint64_t budget_cycles; - uint64_t stall_cycles; - int64_t *offsets_x2; - char cpu_text[256]; - bool pass; - bool conclusive; - - for (int i = 2; i < argc; i++) { - const char *value; - - if ((value = option_value(argc, argv, &i, "--duration-ms"))) { - if (parse_u64_arg(value, &duration_ms) || duration_ms == 0 || - duration_ms > 600000) - return bad_option("warp", argv[i]); - } else if ((value = option_value(argc, argv, &i, "--cpus"))) { - if ((ncpus = parse_cpu_list(value, cpus, MAX_CPUS)) <= 0) - return bad_option("warp", argv[i]); - } else if ((value = option_value(argc, argv, &i, "--bound-ns"))) { - if (parse_u64_arg(value, &bound_ns)) - return bad_option("warp", argv[i]); - } else if ((value = option_value(argc, argv, &i, "--tsc-hz"))) { - if (parse_hz_arg(value, &tsc_hz)) - return bad_option("warp", argv[i]); - } else { - return bad_option("warp", argv[i]); - } - } - if (ncpus < 0 && (ncpus = affinity_cpus(cpus, MAX_CPUS)) <= 0) { - fprintf(stderr, "nvx-time-probe warp: sched_getaffinity: %s\n", strerror(errno)); - return EXIT_ERROR; - } - if (check_cpus_allowed("warp", cpus, ncpus) != 0) - return EXIT_USAGE; - detect_tsc_rate(tsc_hz); - if (g_tsc.hz <= 0) { - fprintf(stderr, "nvx-time-probe warp: unknown TSC rate; pass --tsc-hz\n"); - return EXIT_ERROR; - } - budget_cycles = (uint64_t)((double)duration_ms * g_tsc.hz / 1000.0); - stall_cycles = (uint64_t)(STALL_SECONDS * g_tsc.hz) + budget_cycles; - offsets_x2 = malloc(PP_MAX_SAMPLES * sizeof(*offsets_x2)); - if (!offsets_x2) { - fprintf(stderr, "nvx-time-probe warp: out of memory\n"); - return EXIT_ERROR; - } - format_cpu_list(cpus, ncpus, cpu_text, sizeof(cpu_text)); - printf("nvx-time-probe %s warp: cpus=%s pairs=%d duration_ms=%" PRIu64 - " (per test per pair) tsc_hz=%.0f tsc_hz_source=%s bound_ns=%" PRIu64 "\n", - PROBE_VERSION, cpu_text, ncpus * (ncpus - 1) / 2, duration_ms, g_tsc.hz, - g_tsc.source, bound_ns); - fflush(stdout); - - for (int i = 0; i < ncpus; i++) { - for (int j = i + 1; j < ncpus; j++) { - struct pair_result r; - double skew_bound; - - if (run_pair(cpus[i], cpus[j], budget_cycles, stall_cycles, offsets_x2, - &r) != 0) { - fprintf(stderr, "nvx-time-probe warp: pair %d-%d: %s\n", cpus[i], - cpus[j], strerror(errno)); - free(offsets_x2); - return EXIT_ERROR; - } - pairs++; - total_warps += r.warps; - if (r.max_warp_cycles > max_backward) - max_backward = r.max_warp_cycles; - if (r.stalled) { - stalled++; - printf("pair %d-%d: warp_iterations=%" PRIu64 " warps=%" PRIu64 - " max_backward_cycles=%" PRIu64 " ping_pong=stalled\n", - r.cpu_a, r.cpu_b, r.warp_iterations, r.warps, r.max_warp_cycles); - continue; - } - if (!r.consistent) - inconsistent++; - skew_bound = r.consistent - ? (abs_double((double)r.lo_cycles) > abs_double((double)r.hi_cycles) - ? abs_double((double)r.lo_cycles) - : abs_double((double)r.hi_cycles)) - : abs_double(r.offset_cycles) + r.uncertainty_cycles; - if (abs_double(r.offset_cycles) > max_abs_offset) - max_abs_offset = abs_double(r.offset_cycles); - if (r.uncertainty_cycles > max_uncertainty) - max_uncertainty = r.uncertainty_cycles; - if (skew_bound > max_skew_bound) - max_skew_bound = skew_bound; - printf("pair %d-%d: warp_iterations=%" PRIu64 " warps=%" PRIu64 - " max_backward_cycles=%" PRIu64 " max_backward_ns=%lld rounds=%" PRIu64 - " min_rtt_ns=%lld offset_cycles=%.1f offset_ns=%lld uncertainty_ns=%lld" - " bounds_ns=[%lld,%lld] median_offset_ns=%lld consistent=%d\n", - r.cpu_a, r.cpu_b, r.warp_iterations, r.warps, r.max_warp_cycles, - round_ll(cycles_to_ns((double)r.max_warp_cycles)), r.rounds, - round_ll(cycles_to_ns((double)r.min_rtt_cycles)), r.offset_cycles, - round_ll(cycles_to_ns(r.offset_cycles)), - round_ll(cycles_to_ns(r.uncertainty_cycles)), - round_ll(cycles_to_ns((double)r.lo_cycles)), - round_ll(cycles_to_ns((double)r.hi_cycles)), - round_ll(cycles_to_ns(r.median_offset_cycles)), r.consistent); - fflush(stdout); - } - } - free(offsets_x2); - if (pairs == 0) - printf("single CPU: no pairs to compare\n"); - - pass = stalled == 0 && cycles_to_ns((double)max_backward) <= (double)bound_ns && - cycles_to_ns(max_abs_offset) <= (double)bound_ns; - // The offset estimate decides nothing when its uncertainty reaches the bound - // (for example, under trapped RDTSC, where every read exits to the VMM). - conclusive = pairs == 0 || (stalled == 0 && cycles_to_ns(max_uncertainty) < (double)bound_ns); - if (!conclusive) - printf("warning: offset uncertainty (+/-%lld ns) is not below the bound (%" PRIu64 - " ns); the verdict is not conclusive (trapped RDTSC or a descheduled vCPU?)\n", - round_ll(cycles_to_ns(max_uncertainty)), bound_ns); - printf("NVX-TIME-PROBE warp max_backward_cycles=%" PRIu64 " max_backward_ns=%lld" - " max_abs_offset_ns=%lld pairs=%d verdict=%s bound_ns=%" PRIu64 "\n", - max_backward, round_ll(cycles_to_ns((double)max_backward)), - round_ll(cycles_to_ns(max_abs_offset)), pairs, pass ? "PASS" : "FAIL", - bound_ns); - printf("NVX-TIME-PROBE warp-detail max_abs_offset_cycles=%.1f max_uncertainty_ns=%lld" - " max_skew_bound_ns=%lld total_warps=%" PRIu64 " inconsistent_pairs=%d" - " stalled_pairs=%d cpus=%s duration_ms=%" PRIu64 " tsc_hz=%.0f" - " tsc_hz_source=%s conclusive=%d\n", - max_abs_offset, round_ll(cycles_to_ns(max_uncertainty)), - round_ll(cycles_to_ns(max_skew_bound)), total_warps, inconsistent, stalled, - cpu_text, duration_ms, g_tsc.hz, g_tsc.source, conclusive); - return pass ? 0 : EXIT_FAIL; -} - -// --------------------------------------------------------------------------- -// clock -// --------------------------------------------------------------------------- - -static const char *const tsc_flag_names[] = { - "tsc", "rdtscp", "constant_tsc", "nonstop_tsc", "nonstop_tsc_s3", - "tsc_known_freq", "tsc_reliable", "tsc_deadline_timer", "tsc_adjust", "art", - "hypervisor", "arat", "aperfmperf", "hwp", "x2apic", - NULL, -}; - -static const char *const hypervisor_patterns[] = { - "Hypervisor detected", "Hyper-V", "Booting paravirtualized kernel", "kvm-guest", - NULL, -}; - -static const char *const time_patterns[] = { - "tsc", "TSC", "clocksource", "clockevent", "kvm-clock", - "APIC timer", "LAPIC", "lapic", "calibrat", "Calibrat", - "unchecked MSR", "hpet", "HPET", "timekeeping", "sched_clock", - "detected stall", "watchdog", "lockup", "hung_task", "jiffies", - "rtc", "RTC", NULL, -}; - -static const char *const param_files[] = { - "/sys/module/rcupdate/parameters/rcu_cpu_stall_suppress", - "/sys/module/rcupdate/parameters/rcu_cpu_stall_timeout", - "/proc/sys/kernel/watchdog", - "/proc/sys/kernel/soft_watchdog", - "/proc/sys/kernel/nmi_watchdog", - "/proc/sys/kernel/watchdog_thresh", - "/proc/sys/kernel/hung_task_timeout_secs", - NULL, -}; - -struct kmsg_filter { - const char *const *patterns; - int matches; - int tsc_unstable; - int unchecked_msr; -}; - -struct tick_info { - bool seen; - int tick_mode; - int state; - int hres_active; - int nohz; - char device[64]; - char handler[64]; -}; - -static void add_unique(char *list, size_t size, const char *item) -{ - size_t len = strlen(item); - - for (const char *p = list; (p = strstr(p, item)) != NULL; p += len) - if ((p == list || p[-1] == ',') && (p[len] == ',' || p[len] == '\0')) - return; - if (list[0]) - strncat(list, ",", size - strlen(list) - 1); - strncat(list, item, size - strlen(list) - 1); -} - -static void sanitize_token(char *text) -{ - for (char *p = text; *p; p++) - if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || - (*p >= '0' && *p <= '9') || *p == '#' || *p == '-' || *p == '.')) - *p = '_'; -} - -static void print_clock(const char *name, clockid_t id, struct timespec *out) -{ - struct timespec ts; - - if (clock_gettime(id, &ts) != 0) { - printf("%-20s unavailable (%s)\n", name, strerror(errno)); - return; - } - if (out) - *out = ts; - printf("%-20s %lld.%09ld", name, (long long)ts.tv_sec, ts.tv_nsec); - if (id == CLOCK_REALTIME) { - struct tm tm; - char iso[32]; - time_t seconds = ts.tv_sec; - - gmtime_r(&seconds, &tm); - strftime(iso, sizeof(iso), "%Y-%m-%dT%H:%M:%S", &tm); - printf(" (%s.%09ldZ)", iso, ts.tv_nsec); - } - printf("\n"); -} - -static void kmsg_filter_cb(uint64_t usec, const char *text, void *ctx) -{ - struct kmsg_filter *filter = ctx; - - if (strstr(text, "Marking TSC unstable") || strstr(text, "TSC unstable")) - filter->tsc_unstable++; - if (strstr(text, "unchecked MSR access")) - filter->unchecked_msr++; - for (const char *const *pattern = filter->patterns; *pattern; pattern++) { - if (strstr(text, *pattern)) { - printf("[%5llu.%06llu] %s\n", (unsigned long long)(usec / 1000000), - (unsigned long long)(usec % 1000000), text); - filter->matches++; - return; - } - } -} - -static const char *tick_mode_name(int mode) -{ - return mode == 0 ? "periodic" : mode == 1 ? "oneshot" : "unknown"; -} - -static const char *clockevent_state_name(int state) -{ - static const char *const names[] = {"detached", "shutdown", "periodic", "oneshot", - "oneshot_stopped"}; - - return state >= 0 && state <= 4 ? names[state] : "unknown"; -} - -static void print_timer_list(char *summary, size_t size) -{ - static struct tick_info cpus[MAX_CPUS]; - struct tick_info broadcast = {false, -1, -1, -1, -1, "", ""}; - struct tick_info *cur = NULL; - char devices[256] = ""; - char modes[64] = ""; - char states[128] = ""; - char line[512]; - int hr_cpu = -1; - int tick_mode = -1; - int count = 0; - FILE *file = fopen("/proc/timer_list", "r"); - - if (!file) { - printf("timer_list: unavailable (%s)\n", strerror(errno)); - snprintf(summary, size, "clockevents=unavailable"); - return; - } - for (int i = 0; i < MAX_CPUS; i++) - cpus[i] = (struct tick_info){false, -1, -1, -1, -1, "", ""}; - while (fgets(line, sizeof(line), file)) { - char name[64]; - int value; - - line[strcspn(line, "\n")] = '\0'; - if (strncmp(line, "cpu: ", 5) == 0) { - hr_cpu = atoi(line + 5); - cur = NULL; - } else if (strncmp(line, "Tick Device: mode:", 18) == 0) { - tick_mode = atoi(line + 18); - hr_cpu = -1; - cur = NULL; - } else if (strncmp(line, "Broadcast device", 16) == 0) { - cur = &broadcast; - cur->seen = true; - cur->tick_mode = tick_mode; - } else if (sscanf(line, "Per CPU device: %d", &value) == 1) { - cur = value >= 0 && value < MAX_CPUS ? &cpus[value] : NULL; - if (cur) { - cur->seen = true; - cur->tick_mode = tick_mode; - } - } else if (cur && sscanf(line, "Clock Event Device: %63s", name) == 1) { - snprintf(cur->device, sizeof(cur->device), "%s", name); - } else if (cur && sscanf(line, " mode: %d", &value) == 1) { - cur->state = value; - } else if (cur && sscanf(line, " event_handler: %63s", name) == 1) { - snprintf(cur->handler, sizeof(cur->handler), "%s", name); - } else if (hr_cpu >= 0 && hr_cpu < MAX_CPUS) { - if (sscanf(line, " .hres_active : %d", &value) == 1) - cpus[hr_cpu].hres_active = value; - else if (sscanf(line, " .nohz_mode : %d", &value) == 1 || - sscanf(line, " .nohz : %d", &value) == 1) - cpus[hr_cpu].nohz = value; - } - } - fclose(file); - - if (broadcast.seen) - printf("broadcast: device=%s state=%s tick_mode=%s handler=%s\n", - broadcast.device[0] ? broadcast.device : "none", - clockevent_state_name(broadcast.state), tick_mode_name(broadcast.tick_mode), - broadcast.handler[0] ? broadcast.handler : "none"); - for (int i = 0; i < MAX_CPUS; i++) { - if (!cpus[i].seen) - continue; - count++; - printf("cpu%d: device=%s state=%s tick_mode=%s handler=%s hres_active=%d nohz=%d\n", - i, cpus[i].device[0] ? cpus[i].device : "none", - clockevent_state_name(cpus[i].state), tick_mode_name(cpus[i].tick_mode), - cpus[i].handler[0] ? cpus[i].handler : "none", cpus[i].hres_active, - cpus[i].nohz); - add_unique(devices, sizeof(devices), cpus[i].device[0] ? cpus[i].device : "none"); - add_unique(modes, sizeof(modes), tick_mode_name(cpus[i].tick_mode)); - add_unique(states, sizeof(states), clockevent_state_name(cpus[i].state)); - } - if (!broadcast.seen && count == 0) - printf("timer_list: no tick devices found (file empty or masked)\n"); - snprintf(summary, size, - "clockevent_cpus=%d clockevent_devices=%s tick_modes=%s clockevent_states=%s" - " broadcast=%s:%s", - count, devices[0] ? devices : "none", modes[0] ? modes : "none", - states[0] ? states : "none", - broadcast.seen && broadcast.device[0] ? broadcast.device : "none", - broadcast.seen ? clockevent_state_name(broadcast.state) : "none"); -} - -struct cpuid_summary { - char vendor[16]; - uint32_t max_leaf; - uint32_t features_eax; - uint32_t features_edx; - bool hypervisor; -}; - -static void print_cpuid(struct cpuid_summary *s) -{ - uint32_t r[4]; - uint32_t max_basic, max_ext, family, model; - - memset(s, 0, sizeof(*s)); - cpuid(0, 0, r); - max_basic = r[0]; - cpuid(0x80000000u, 0, r); - max_ext = r[0]; - cpuid(1, 0, r); - family = (r[0] >> 8) & 0xf; - model = (r[0] >> 4) & 0xf; - if (family == 0x6 || family == 0xf) - model |= (r[0] >> 12) & 0xf0; - if (family == 0xf) - family += (r[0] >> 20) & 0xff; - s->hypervisor = (r[2] >> 31) & 1; - printf("cpuid.1: family=%u model=%u stepping=%u edx.tsc=%u ecx.tsc_deadline=%u" - " ecx.hypervisor=%u\n", - family, model, r[0] & 0xf, (r[3] >> 4) & 1, (r[2] >> 24) & 1, s->hypervisor); - if (max_basic >= 6) { - cpuid(6, 0, r); - printf("cpuid.6: eax.arat=%u eax.hwp=%u ecx.aperfmperf=%u\n", (r[0] >> 2) & 1, - (r[0] >> 7) & 1, r[2] & 1); - } - if (max_basic >= 7) { - cpuid(7, 0, r); - printf("cpuid.7.0: ebx.tsc_adjust=%u\n", (r[1] >> 1) & 1); - } - if (max_basic >= 0xa) { - cpuid(0xa, 0, r); - printf("cpuid.0xa: eax=%#010x (pmu_version=%u)\n", r[0], r[0] & 0xff); - } - if (max_basic >= 0x15) { - cpuid(0x15, 0, r); - printf("cpuid.0x15: eax.denominator=%u ebx.numerator=%u ecx.crystal_hz=%u", r[0], - r[1], r[2]); - if (r[0] && r[1] && r[2]) - printf(" (tsc_hz=%.0f)", (double)r[2] * r[1] / r[0]); - printf("\n"); - } - if (max_basic >= 0x16) { - cpuid(0x16, 0, r); - printf("cpuid.0x16: eax.base_mhz=%u ebx.max_mhz=%u ecx.bus_mhz=%u\n", - r[0] & 0xffff, r[1] & 0xffff, r[2] & 0xffff); - } - if (max_ext >= 0x80000001u) { - cpuid(0x80000001u, 0, r); - printf("cpuid.0x80000001: edx.rdtscp=%u\n", (r[3] >> 27) & 1); - } - if (max_ext >= 0x80000007u) { - cpuid(0x80000007u, 0, r); - printf("cpuid.0x80000007: edx.invariant_tsc=%u\n", (r[3] >> 8) & 1); - } - if (!s->hypervisor) { - printf("cpuid.0x40000000: not queried (hypervisor bit clear)\n"); - snprintf(s->vendor, sizeof(s->vendor), "none"); - return; - } - cpuid(0x40000000u, 0, r); - s->max_leaf = r[0]; - memcpy(s->vendor, &r[1], 4); - memcpy(s->vendor + 4, &r[2], 4); - memcpy(s->vendor + 8, &r[3], 4); - s->vendor[12] = '\0'; - for (int i = 0; i < 12 && s->vendor[i]; i++) - if (s->vendor[i] < 0x20 || s->vendor[i] > 0x7e) - s->vendor[i] = '.'; - printf("cpuid.0x40000000: eax.max_leaf=%#x vendor=\"%s\"\n", s->max_leaf, s->vendor); - for (uint32_t leaf = 0x40000001u; leaf <= s->max_leaf && leaf <= 0x40000010u; leaf++) { - cpuid(leaf, 0, r); - printf("cpuid.%#x: eax=%#010x ebx=%#010x ecx=%#010x edx=%#010x\n", leaf, r[0], - r[1], r[2], r[3]); - if (leaf == 0x40000003u) { - s->features_eax = r[0]; - s->features_edx = r[3]; - } - } - if (strcmp(s->vendor, "Microsoft Hv") == 0 && s->max_leaf >= 0x40000003u) - printf("hyperv.features: hypercall=%u vp_index=%u access_frequency_msrs=%u" - " access_tsc_invariant=%u frequency_msrs_available=%u\n", - (s->features_eax >> 5) & 1, (s->features_eax >> 6) & 1, - (s->features_eax >> 11) & 1, (s->features_eax >> 15) & 1, - (s->features_edx >> 8) & 1); -} - -// Opt-in: reads go through the msr driver's rdmsr_safe(), so a #GP returns -// EIO instead of faulting in the guest. -static void print_msrs(void) -{ - static const struct { - uint32_t index; - const char *name; - } msrs[] = { - {0x10, "IA32_TSC"}, - {0x3b, "IA32_TSC_ADJUST"}, - {0x40000002u, "HV_X64_MSR_VP_INDEX"}, - {0x40000022u, "HV_X64_MSR_TSC_FREQUENCY"}, - {0x40000023u, "HV_X64_MSR_APIC_FREQUENCY"}, - {0x40000118u, "HV_X64_MSR_TSC_INVARIANT_CONTROL"}, - {0x4b564d01u, "MSR_KVM_SYSTEM_TIME_NEW"}, - }; - int fd = open("/dev/cpu/0/msr", O_RDONLY | O_CLOEXEC); - - if (fd < 0) { - printf("msr: /dev/cpu/0/msr unavailable (%s)\n", strerror(errno)); - return; - } - for (size_t i = 0; i < sizeof(msrs) / sizeof(msrs[0]); i++) { - uint64_t value; - - if (pread(fd, &value, sizeof(value), (off_t)msrs[i].index) == sizeof(value)) - printf("msr %#010x %-34s = %#" PRIx64 " (%" PRIu64 ")\n", msrs[i].index, - msrs[i].name, value, value); - else - printf("msr %#010x %-34s : %s\n", msrs[i].index, msrs[i].name, - errno == EIO ? "read faulted (#GP)" : strerror(errno)); - } - close(fd); -} - -static int cmd_clock(int argc, char **argv) -{ - uint64_t measure_ms = 200; - double tsc_hz = 0; - bool read_msrs = false; - struct timespec mono = {0}; - struct kmsg_filter hv_filter = {hypervisor_patterns, 0, 0, 0}; - struct kmsg_filter time_filter = {time_patterns, 0, 0, 0}; - struct cpuid_summary ids; - char clocksource[64]; - char available[256] = ""; - char present[256] = ""; - char absent[256] = ""; - char timer_summary[512]; - char line[4096]; - uint64_t tsc; - int cpu; - - for (int i = 2; i < argc; i++) { - const char *value; - - if ((value = option_value(argc, argv, &i, "--measure-ms"))) { - if (parse_u64_arg(value, &measure_ms) || measure_ms > 60000) - return bad_option("clock", argv[i]); - } else if ((value = option_value(argc, argv, &i, "--tsc-hz"))) { - if (parse_hz_arg(value, &tsc_hz)) - return bad_option("clock", argv[i]); - } else if (strcmp(argv[i], "--msr") == 0) { - read_msrs = true; - } else { - return bad_option("clock", argv[i]); - } - } - detect_tsc_rate(tsc_hz); - - printf("nvx-time-probe %s clock\n== clocks\n", PROBE_VERSION); - cpu = sched_getcpu(); - tsc = rdtsc_fenced(); - print_clock("CLOCK_REALTIME", CLOCK_REALTIME, NULL); - print_clock("CLOCK_MONOTONIC", CLOCK_MONOTONIC, &mono); - print_clock("CLOCK_MONOTONIC_RAW", CLOCK_MONOTONIC_RAW, NULL); - print_clock("CLOCK_BOOTTIME", CLOCK_BOOTTIME, NULL); - printf("%-20s %" PRIu64 " on cpu %d", "TSC", tsc, cpu); - if (g_tsc.hz > 0) - printf(" (%.9f s; tsc_minus_monotonic_ns=%lld)", (double)tsc / g_tsc.hz, - round_ll((double)tsc * 1e9 / g_tsc.hz - - ((double)mono.tv_sec * 1e9 + (double)mono.tv_nsec))); - printf("\ntsc_hz=%.0f tsc_hz_source=%s\n", g_tsc.hz, g_tsc.source); - if (measure_ms > 0) - printf("tsc_hz_measured=%.0f (over %" PRIu64 " ms against CLOCK_MONOTONIC_RAW)\n", - measure_tsc_hz((unsigned)measure_ms), measure_ms); - - printf("== clocksource\n"); - current_clocksource(clocksource, sizeof(clocksource)); - if (read_first_line(CLOCKSOURCE_DIR "/available_clocksource", line, sizeof(line)) == 0) - for (char *token = strtok(line, " "); token; token = strtok(NULL, " ")) - add_unique(available, sizeof(available), token); - printf("current: %s\navailable: %s\n", clocksource, available[0] ? available : "unknown"); - - printf("== cpuinfo\n"); - { - FILE *file = fopen("/proc/cpuinfo", "r"); - int processors = 0; - bool model_printed = false; - char mhz[512] = ""; - - while (file && fgets(line, sizeof(line), file)) { - char *colon = strchr(line, ':'); - - line[strcspn(line, "\n")] = '\0'; - if (strncmp(line, "processor", 9) == 0) - processors++; - else if (!model_printed && strncmp(line, "model name", 10) == 0 && colon) { - printf("model name:%s\n", colon + 1); - model_printed = true; - } else if (strncmp(line, "cpu MHz", 7) == 0 && colon) - add_unique(mhz, sizeof(mhz), colon + 2); - } - if (file) - fclose(file); - printf("processors: %d\ncpu MHz: %s\n", processors, mhz[0] ? mhz : "unknown"); - } - for (const char *const *flag = tsc_flag_names; *flag; flag++) - add_unique(cpuinfo_has_flag(*flag) ? present : absent, - cpuinfo_has_flag(*flag) ? sizeof(present) : sizeof(absent), *flag); - printf("time flags present: %s\ntime flags absent: %s\n", present[0] ? present : "none", - absent[0] ? absent : "none"); - - printf("== cpuid\n"); - print_cpuid(&ids); - if (read_msrs) { - printf("== msr (cpu 0)\n"); - print_msrs(); - } - - printf("== kernel\n"); - if (read_first_line("/proc/cmdline", line, sizeof(line)) == 0) - printf("cmdline: %s\n", line); - if (read_first_line("/sys/devices/system/cpu/online", line, sizeof(line)) == 0) - printf("online cpus: %s\n", line); - for (const char *const *path = param_files; *path; path++) { - if (read_first_line(*path, line, sizeof(line)) == 0) - printf("%s = %s\n", *path, line); - else - printf("%s: absent\n", *path); - } - - printf("== kmsg: hypervisor\n"); - if (kmsg_foreach(kmsg_filter_cb, &hv_filter) != 0) - printf("(kernel log unavailable: %s)\n", strerror(errno)); - else if (!hv_filter.matches) - printf("(none)\n"); - printf("== kmsg: time\n"); - if (kmsg_foreach(kmsg_filter_cb, &time_filter) != 0) - printf("(kernel log unavailable: %s)\n", strerror(errno)); - else if (!time_filter.matches) - printf("(none)\n"); - - printf("== timer_list\n"); - print_timer_list(timer_summary, sizeof(timer_summary)); - - sanitize_token(ids.vendor); - printf("NVX-TIME-PROBE clock clocksource=%s available=%s tsc_hz=%.0f tsc_hz_source=%s" - " hv_vendor=%s hv_max_leaf=%#x hv_features_eax=%#x hv_features_edx=%#x" - " flags=%s missing_flags=%s tsc_unstable_msgs=%d unchecked_msr_msgs=%d %s\n", - clocksource, available[0] ? available : "unknown", g_tsc.hz, g_tsc.source, - ids.vendor, ids.max_leaf, ids.features_eax, ids.features_edx, - present[0] ? present : "none", absent[0] ? absent : "none", - time_filter.tsc_unstable, time_filter.unchecked_msr, timer_summary); - return 0; -} - -// --------------------------------------------------------------------------- -// main -// --------------------------------------------------------------------------- - -static void usage(FILE *out) -{ - fprintf(out, - "usage: nvx-time-probe COMMAND [OPTIONS]\n" - "\n" - "Commands:\n" - " latency [--iterations N] [--batch B] [--cpu C] [--tsc-hz HZ]\n" - " Per-operation cost (p50/p99/min/mean ns) of rdtsc, rdtscp,\n" - " lfence;rdtsc, clock_gettime(MONOTONIC/REALTIME) through the vDSO,\n" - " and syscall controls. Default: 2000 timed batches of 50 operations\n" - " on the current CPU.\n" - " warp [--duration-ms N] [--cpus LIST] [--bound-ns N] [--tsc-hz HZ]\n" - " Pairwise cross-CPU TSC checks: a check_tsc_warp-style spinlock test\n" - " and a ping-pong offset estimate, each running N ms per pair\n" - " (default 100). LIST looks like 0-3,6 (default: every CPU in the\n" - " affinity mask). The bound defaults to 1000 ns.\n" - " clock [--measure-ms N] [--tsc-hz HZ] [--msr]\n" - " Clocks, clocksources, TSC flags, CPUID time leaves, hypervisor and\n" - " time kernel messages, and the /proc/timer_list clock event summary.\n" - " --msr also reads the TSC, Hyper-V time, and kvmclock MSRs on CPU 0\n" - " through /dev/cpu/0/msr (a #GP is reported, not raised).\n" - " version\n" - "\n" - "The TSC rate comes from --tsc-hz, sysfs tsc_freq_khz, the kernel log, or\n" - "/proc/cpuinfo, in that order, and is measured as a last resort.\n" - "Exit status: 0 success, 1 warp FAIL, 2 usage error, 3 runtime error.\n"); -} - -int main(int argc, char **argv) -{ - setvbuf(stdout, NULL, _IOLBF, 0); - if (argc < 2) { - usage(stderr); - return EXIT_USAGE; - } - if (strcmp(argv[1], "latency") == 0) - return cmd_latency(argc, argv); - if (strcmp(argv[1], "warp") == 0) - return cmd_warp(argc, argv); - if (strcmp(argv[1], "clock") == 0) - return cmd_clock(argc, argv); - if (strcmp(argv[1], "version") == 0 || strcmp(argv[1], "--version") == 0) { - printf("nvx-time-probe %s\n", PROBE_VERSION); - return 0; - } - if (strcmp(argv[1], "help") == 0 || strcmp(argv[1], "--help") == 0 || - strcmp(argv[1], "-h") == 0) { - usage(stdout); - return 0; - } - usage(stderr); - return EXIT_USAGE; -} diff --git a/guest/common/nvx-time.c b/guest/common/nvx-time.c deleted file mode 100644 index ce75ee163..000000000 --- a/guest/common/nvx-time.c +++ /dev/null @@ -1,5076 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// nvx-time: the guest time component of NVX time ABI v1. -// -// doc/design/3.2-clock-and-time.md ("Guest Obligations") is the contract this -// helper enforces. It runs the conformance checks, the violation watcher and -// the wall-clock discipline (one daemon), and the snapshot agent's time steps: -// the guest preparation steps of doc/design/5.3-snapshot-protocol.md and the -// guest repair steps of doc/design/5.5-restore-protocol.md, which the comments -// below cite by number. It prints nothing on success: checks record their -// result in the state file, and only violation events reach the console. This -// file defines the check IDs, the guest codes, and the formats of its output -// lines and the state file. -// -// boot initial clock step (C12), then the other boot -// checks asynchronously, which start the daemon -// check --phase PHASE [--new-cpus LIST] -// capture or restore checks, for diagnosis -// pre-capture capture checks and watchdog suppression -// capture REQUEST GENERATION_ID ENTROPY_FILE [--finish] -// capture request, then restore repair -// cancel-capture undo pre-capture after a rejected capture -// restore-finish [--ack] [--new-cpus LIST] -// acknowledgement, then the daemon's restore -// checks and release -// status print every recorded check and the runtime -// state, after waiting for pending checks -// sample print one host time sample -// generation-id print the VM generation ID -// exhaustive the CI-only exhaustive check -// test NAME ARGUMENTS... pure decoders and checks, for unit tests -// -// --report-only, or nvx_time_abi=report-only on the kernel command line, -// reports failures without powering the VM off and prints NVX-TIME-REPORT -// instead of NVX-TIME-ABI, so that its output never passes for a conformant -// run. Every portb transfer uses four-byte reads: a restore packet without -// memory ranges costs 24 data-port exits. - -#define _GNU_SOURCE - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#ifndef SYS_pidfd_open -#define SYS_pidfd_open 434 -#endif - -#define ARRAY_SIZE(array) (sizeof(array) / sizeof((array)[0])) - -#define NSEC_PER_SEC INT64_C(1000000000) -#define NSEC_PER_MSEC INT64_C(1000000) -#define NSEC_PER_USEC INT64_C(1000) -#define MAX_CPUS 64 - -#define STATUS_CONFORMANCE 193 -#define STATUS_VIOLATION 194 -#define STATUS_REPAIR 195 - -#define PORTB_DATA 0xe9 -#define PORTB_STATUS 0xea -#define PORTB_WINDOW 0xeb -#define PORT_SHUTDOWN 0x604 -#define PORT_SNAPSHOT 0x605 -#define SELECT_PACKET 0xa5 -#define SELECT_GENERATION_ID 0xa6 -#define SELECT_TIME_SAMPLE 0xa7 -#define PORTB_PACKET_AVAILABLE 0x02 -#define PORTB_GENERATION_ID_AVAILABLE 0x20 -#define PORTB_TIME_WINDOW 0x40 -#define SNAPSHOT_ACKNOWLEDGE 2 - -#define PACKET_HEADER_SIZE 32 -#define PACKET_RANGE_SIZE 16 -#define PACKET_ENTROPY_SIZE 64 -#define PACKET_MAX_RANGES 255 -#define PACKET_MAX_SIZE \ - (PACKET_HEADER_SIZE + PACKET_MAX_RANGES * PACKET_RANGE_SIZE + \ - PACKET_ENTROPY_SIZE) -#define PACKET_DOWNTIME_UTC 0x01 -#define PACKET_MEMORY_TARGET 0x02 -#define PACKET_ACK_REQUIRED 0x04 -#define PACKET_TEST_HOOKS 0x08 -#define PACKET_KNOWN_FLAGS 0x0f -#define SAMPLE_SIZE 16 -#define SAMPLE_TEST_HOOKS 0x08 -#define GENERATION_ID_SIZE 16 - -#define RATE_DEVIATION_LIMIT INT64_C(16384000) -#define FREQUENCY_LIMIT (INT64_C(500) << 16) -#define DOWNTIME_LIMIT_NS (INT64_C(2592000) * NSEC_PER_SEC) -#define REPAIR_BOUND_NS NSEC_PER_MSEC -#define BOOT_BOUND_NS NSEC_PER_MSEC -#define POLL_BOUND_NS (50 * NSEC_PER_USEC) -#define SAMPLE_ATTEMPTS 3 -#define STEP_THRESHOLD_NS (128 * NSEC_PER_MSEC) -#define SYNCHRONIZED_AGE_NS (128 * NSEC_PER_SEC) -#define FAST_PERIOD_S 16 -#define SLOW_PERIOD_S 64 -#define FAST_POLLS 4 -#define FAST_TIME_CONSTANT 4 -#define SLOW_TIME_CONSTANT 6 -#define TIMER_LIST_WAIT_NS (200 * NSEC_PER_MSEC) -#define DEFAULT_STALL_TIMEOUT_S 21 -// The asynchronous boot checks start 150 ms after the time ABI boot step, -// which init runs shortly before shell-ready, and repair step 7 150 ms -// after the acknowledgement: earlier, they contend with the readiness path -// while the VMM still demand-faults guest memory, and with the workload's own -// first activity after readiness. Both start at SCHED_IDLE and continue at -// normal priority 100 ms after they start, which bounds the fail-fast window -// under a CPU-bound workload. -#define DEFERRED_START_NS (150 * NSEC_PER_MSEC) -#define IDLE_BOUND_NS (100 * NSEC_PER_MSEC) -// nvx-time status and pre-capture wait at most 30 s for pending checks. -#define STATUS_WAIT_NS (30 * NSEC_PER_SEC) -#define STATUS_POLL_NS (10 * NSEC_PER_MSEC) -#define EVENT_MAX 512 -#define DETAIL_MAX 384 -#define TEXT_MAX 65536 - -#define RUN_DIR "/run/nvx" -#define TIME_DIR RUN_DIR "/time" -#define STATE_PATH TIME_DIR "/state" -#define STATE_TEMP_PATH TIME_DIR "/.state" -#define STATE_LOCK_PATH TIME_DIR "/state.lock" -#define DAEMON_PID_PATH TIME_DIR "/daemon.pid" -#define SUPPRESSION_PATH TIME_DIR "/suppression" -#define SUPPRESSION_LOCK_PATH TIME_DIR "/suppression.lock" -#define RESTORE_PATH TIME_DIR "/restore" -#define PORTB_LOCK_PATH RUN_DIR "/portb.lock" -#define RCU_SYNC_PATH RUN_DIR "/rcu-sync" - -#define RCU_STALL_SUPPRESS "/sys/module/rcupdate/parameters/rcu_cpu_stall_suppress" -#define RCU_STALL_TIMEOUT "/sys/module/rcupdate/parameters/rcu_cpu_stall_timeout" -#define RCU_NORMAL "/sys/kernel/rcu_normal" -#define RCU_STALL_COUNT "/sys/kernel/rcu_stall_count" -#define SOFT_WATCHDOG "/proc/sys/kernel/soft_watchdog" -#define HUNG_TASK_TIMEOUT "/proc/sys/kernel/hung_task_timeout_secs" -#define CLOCKSOURCE_DIR "/sys/devices/system/clocksource/clocksource0" - -enum phase { PHASE_BOOT, PHASE_CAPTURE, PHASE_RESTORE, PHASE_RUNTIME }; - -static const char *const k_phase_names[] = {"boot", "capture", "restore", - "runtime"}; - -static bool g_report_only; -static bool g_test; -static uint32_t g_generation; -static bool g_ports; -// Set in the daemon. Its lines can arrive while a shell or workload is in the -// middle of a console line, so each one starts with a newline. -static bool g_async_output; - -// --------------------------------------------------------------------------- -// Small utilities -// --------------------------------------------------------------------------- - -static int64_t clock_ns(clockid_t clock) -{ - struct timespec now; - - clock_gettime(clock, &now); - return (int64_t)now.tv_sec * NSEC_PER_SEC + now.tv_nsec; -} - -// The CPU time of this process, every thread's, exited threads' included: -// what a check's cpu_us reports. -static int64_t cpu_time_ns(void) -{ - return clock_ns(CLOCK_PROCESS_CPUTIME_ID); -} - -static int write_all(int fd, const void *data, size_t size) -{ - const char *bytes = data; - - while (size > 0) { - ssize_t count = write(fd, bytes, size); - - if (count < 0 && errno == EINTR) - continue; - if (count <= 0) - return -1; - bytes += count; - size -= (size_t)count; - } - return 0; -} - -static ssize_t read_bytes(const char *path, void *buffer, size_t size) -{ - int fd = open(path, O_RDONLY | O_CLOEXEC); - size_t total = 0; - - if (fd < 0) - return -1; - while (total < size) { - ssize_t count = read(fd, (char *)buffer + total, size - total); - - if (count < 0 && errno == EINTR) - continue; - if (count < 0) { - int error = errno; - - close(fd); - errno = error; - return -1; - } - if (count == 0) - break; - total += (size_t)count; - } - close(fd); - return (ssize_t)total; -} - -static ssize_t read_text(const char *path, char *buffer, size_t size) -{ - ssize_t count = read_bytes(path, buffer, size - 1); - - if (count < 0) - return -1; - buffer[count] = '\0'; - return count; -} - -typedef void (*line_consumer)(void *context, char *line); - -// Passes each line of TEXT to CONSUME, splitting TEXT in place. -static void for_each_text_line(char *text, line_consumer consume, - void *context) -{ - for (char *line = text; line != NULL && *line != '\0';) { - char *next = strchr(line, '\n'); - - if (next != NULL) - *next++ = '\0'; - consume(context, line); - line = next; - } -} - -// Streams the file at PATH into CONSUME one line at a time, so files whose -// size depends on the workload (/proc/timer_list) or on the CPU count -// (/proc/cpuinfo) are never truncated. Longer lines than the line buffer are -// cut. Returns -1 with errno set on failure. -static int for_each_file_line(const char *path, line_consumer consume, - void *context) -{ - char chunk[16384]; - char line[8192]; - size_t length = 0; - int fd = open(path, O_RDONLY | O_CLOEXEC); - - if (fd < 0) - return -1; - for (;;) { - ssize_t count = read(fd, chunk, sizeof(chunk)); - - if (count < 0 && errno == EINTR) - continue; - if (count < 0) { - int error = errno; - - close(fd); - errno = error; - return -1; - } - if (count == 0) - break; - for (ssize_t index = 0; index < count; index++) { - if (chunk[index] == '\n') { - line[length] = '\0'; - consume(context, line); - length = 0; - } else if (length < sizeof(line) - 1) { - line[length++] = chunk[index]; - } - } - } - close(fd); - if (length > 0) { - line[length] = '\0'; - consume(context, line); - } - return 0; -} - -static int parse_int64(const char *text, int64_t *value) -{ - char *end; - - errno = 0; - *value = strtoll(text, &end, 10); - if (errno != 0 || end == text) - return -1; - while (*end == '\n' || *end == ' ') - end++; - return *end == '\0' ? 0 : -1; -} - -static int read_int64(const char *path, int64_t *value) -{ - char text[64]; - - if (read_text(path, text, sizeof(text)) < 0) - return -1; - return parse_int64(text, value); -} - -static int write_text(const char *path, const char *text) -{ - int fd = open(path, O_WRONLY | O_CLOEXEC); - int result; - - if (fd < 0) - return -1; - result = write_all(fd, text, strlen(text)); - if (close(fd) != 0) - result = -1; - return result; -} - -static int write_int64(const char *path, int64_t value) -{ - char text[32]; - - snprintf(text, sizeof(text), "%" PRId64 "\n", value); - return write_text(path, text); -} - -// Atomically replaces PATH with TEXT through a temporary file in its directory. -static int replace_file(const char *path, const char *temporary, - const char *text) -{ - int fd = open(temporary, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); - int result; - - if (fd < 0) - return -1; - result = write_all(fd, text, strlen(text)); - if (close(fd) != 0) - result = -1; - if (result == 0 && rename(temporary, path) != 0) - result = -1; - if (result != 0) - unlink(temporary); - return result; -} - -static void make_runtime_directories(void) -{ - (void)mkdir(RUN_DIR, 0755); - (void)mkdir(TIME_DIR, 0755); -} - -static bool ends_with(const char *text, const char *suffix) -{ - size_t length = strlen(text); - size_t suffix_length = strlen(suffix); - - return length >= suffix_length && - strcmp(text + length - suffix_length, suffix) == 0; -} - -static uint32_t read_le32(const uint8_t *bytes) -{ - return (uint32_t)bytes[0] | (uint32_t)bytes[1] << 8 | - (uint32_t)bytes[2] << 16 | (uint32_t)bytes[3] << 24; -} - -static uint64_t read_le64(const uint8_t *bytes) -{ - return (uint64_t)read_le32(bytes) | (uint64_t)read_le32(bytes + 4) << 32; -} - -static void format_hex(const uint8_t *bytes, size_t size, char *text) -{ - static const char digits[] = "0123456789abcdef"; - - for (size_t index = 0; index < size; index++) { - text[index * 2] = digits[bytes[index] >> 4]; - text[index * 2 + 1] = digits[bytes[index] & 0x0f]; - } - text[size * 2] = '\0'; -} - -static int parse_hex(const char *text, uint8_t *bytes, size_t size) -{ - if (strlen(text) != size * 2) - return -1; - for (size_t index = 0; index < size * 2; index++) { - char c = text[index]; - int value; - - if (c >= '0' && c <= '9') - value = c - '0'; - else if (c >= 'a' && c <= 'f') - value = c - 'a' + 10; - else if (c >= 'A' && c <= 'F') - value = c - 'A' + 10; - else - return -1; - if (index % 2 == 0) - bytes[index / 2] = (uint8_t)(value << 4); - else - bytes[index / 2] |= (uint8_t)value; - } - return 0; -} - -// Parses a CPU list such as "0-3,5". Returns the number of CPUs in it. -static int parse_cpu_list(const char *text, bool *cpus) -{ - const char *cursor = text; - int count = 0; - - memset(cpus, 0, sizeof(bool) * MAX_CPUS); - while (*cursor != '\0' && *cursor != '\n') { - char *end; - long first = strtol(cursor, &end, 10); - long last = first; - - if (end == cursor || first < 0) - return -1; - cursor = end; - if (*cursor == '-') { - last = strtol(cursor + 1, &end, 10); - if (end == cursor + 1 || last < first) - return -1; - cursor = end; - } - if (last >= MAX_CPUS) - return -1; - for (long cpu = first; cpu <= last; cpu++) { - if (!cpus[cpu]) { - cpus[cpu] = true; - count++; - } - } - if (*cursor == ',') - cursor++; - else if (*cursor != '\0' && *cursor != '\n') - return -1; - } - return count; -} - -// --------------------------------------------------------------------------- -// Console output, events, and power-off -// --------------------------------------------------------------------------- - -static const char *abi_prefix(void) -{ - return g_report_only ? "NVX-TIME-REPORT" : "NVX-TIME-ABI"; -} - -// Escapes TEXT into OUT, which holds LIMIT characters plus a terminator. An -// escape sequence that would not fit is dropped whole, never split. -static void escape_text(const char *text, char *out, size_t limit) -{ - size_t length = 0; - - for (const unsigned char *c = (const unsigned char *)text; *c != '\0'; - c++) { - char piece[5]; - size_t size; - - if (*c == '"' || *c == '\\') { - piece[0] = '\\'; - piece[1] = (char)*c; - size = 2; - } else if (*c < 0x20 || *c > 0x7e) { - snprintf(piece, sizeof(piece), "\\x%02x", *c); - size = 4; - } else { - piece[0] = (char)*c; - size = 1; - } - if (length + size > limit) - break; - memcpy(out + length, piece, size); - length += size; - } - out[length] = '\0'; -} - -static void console_write(const char *line) -{ - char buffer[EVENT_MAX + 128]; - int fd = g_test ? -1 : open("/dev/console", O_WRONLY | O_NOCTTY | O_CLOEXEC); - - if (g_async_output) { - snprintf(buffer, sizeof(buffer), "\n%s", line); - line = buffer; - } - if (fd < 0) { - (void)write_all(g_test ? STDOUT_FILENO : STDERR_FILENO, line, - strlen(line)); - return; - } - (void)write_all(fd, line, strlen(line)); - close(fd); -} - -static int enable_ports(void) -{ - if (g_ports) - return 0; - if (g_test) { - errno = EPERM; - return -1; - } - // A four-byte read at port P needs permission for P to P + 3, so the - // reads at 0xe9 and 0xeb cover 0xe9 to 0xee. - if (ioperm(PORTB_DATA, PORTB_WINDOW + 4 - PORTB_DATA, 1) != 0 || - ioperm(PORT_SHUTDOWN, 2, 1) != 0) - return -1; - g_ports = true; - return 0; -} - -// Reads SIZE bytes from PORT four bytes per exit; the device zero-fills -// past the end of the selected record. A string read (rep insl) would take one -// exit, but OpenVMM's MSHV and WHP emulation raises #GP for it in user mode. -static void portb_read(uint16_t port, uint8_t *buffer, size_t size) -{ - for (size_t offset = 0; offset < size; offset += 4) { - uint32_t value = inl(port); - size_t count = size - offset < 4 ? size - offset : 4; - - memcpy(buffer + offset, &value, count); - } -} - -static int lock_file(const char *path) -{ - int fd; - - make_runtime_directories(); - fd = open(path, O_RDWR | O_CREAT | O_CLOEXEC, 0600); - if (fd < 0) - return -1; - while (flock(fd, LOCK_EX) != 0) { - if (errno != EINTR) { - close(fd); - return -1; - } - } - return fd; -} - -static void power_off(int status) -{ - fflush(NULL); - if (enable_ports() == 0) { - outb((unsigned char)status, PORT_SHUTDOWN); - } else { - int fd = open("/dev/port", O_WRONLY | O_CLOEXEC); - unsigned char byte = (unsigned char)status; - - if (fd >= 0) { - ssize_t written = pwrite(fd, &byte, 1, PORT_SHUTDOWN); - - (void)written; - close(fd); - } - } - for (;;) - pause(); -} - -static void format_event(char *line, const char *code, const char *source, - enum phase phase, uint32_t generation, - int64_t boottime_ns, const char *detail) -{ - int prefix = snprintf(line, EVENT_MAX + 1, - "%s-VIOLATION: v=1 code=%s source=%s phase=%s " - "generation=%" PRIu32 " boottime_ns=%" PRId64 - " detail=\"", - abi_prefix(), code, source, k_phase_names[phase], - generation, boottime_ns); - - escape_text(detail, line + prefix, EVENT_MAX - (size_t)prefix - 2); - strcat(line, "\"\n"); -} - -static void count_violation(void); - -// Writes a violation event to the kernel log, the portb data port, and the -// console. Consumers treat identical lines as one event. -static void emit_event(const char *code, const char *source, enum phase phase, - const char *detail) -{ - char line[EVENT_MAX + 1]; - char record[EVENT_MAX + 8]; - int fd; - - format_event(line, code, source, phase, g_generation, - clock_ns(CLOCK_BOOTTIME), detail); - if (g_test) { - console_write(line); - return; - } - fd = open("/dev/kmsg", O_WRONLY | O_CLOEXEC); - if (fd >= 0) { - snprintf(record, sizeof(record), "<2>%s", line); - (void)write_all(fd, record, strlen(record)); - close(fd); - } - if (enable_ports() == 0) { - if (g_async_output) - outb('\n', PORTB_DATA); - for (const char *c = line; *c != '\0'; c++) - outb((unsigned char)*c, PORTB_DATA); - } - console_write(line); - count_violation(); -} - -// Reports a fatal time-ABI failure. In report-only mode it returns, and the -// caller abandons the failed step. -static void fail_fatal(int status, const char *code, const char *source, - enum phase phase, const char *format, ...) - __attribute__((format(printf, 5, 6))); - -static void fail_fatal(int status, const char *code, const char *source, - enum phase phase, const char *format, ...) -{ - char detail[DETAIL_MAX]; - va_list arguments; - - va_start(arguments, format); - vsnprintf(detail, sizeof(detail), format, arguments); - va_end(arguments); - emit_event(code, source, phase, detail); - if (!g_report_only) - power_off(status); -} - -// --------------------------------------------------------------------------- -// Published state (/run/nvx/time/state) and private records -// --------------------------------------------------------------------------- - -// A conformance check's record: STATUS is empty until the phase first runs, -// then "pending" while its check runs, then "ok" (a failed check powers the -// guest off; report-only mode records "fail" instead). ELAPSED_US is the -// check's wall time and CPU_US its CPU time over every process that ran it. -// GENERATION is g when the check ran; the boot check's is 0. FAILURES is the -// check's failure count, kept in report-only mode only, and -1 otherwise. -struct check_state { - char status[16]; - uint64_t cpus; - int64_t elapsed_us; - int64_t cpu_us; - uint64_t generation; - int64_t failures; -}; - -// The phases with a check record, indexed by enum phase. -#define CHECK_PHASES 3 - -struct time_state { - uint64_t generation; - struct check_state checks[CHECK_PHASES]; - uint64_t tsc_hz; - uint64_t lapic_hz; - uint64_t discontinuities; - char last_discontinuity[16]; - int64_t last_step_ns; - int64_t last_step_realtime_ns; - uint64_t last_downtime_ns; - char last_downtime_source[16]; - uint64_t synchronized; - int64_t offset_ns; - int64_t uncertainty_ns; - int64_t frequency_ppb; - uint64_t samples; - uint64_t rejected_samples; - char last_sample_error[24]; - uint64_t violations; -}; - -// A fresh state is published at the start of the boot checks, which are then -// pending; a state without a boot record also counts as pending. -static void state_init(struct time_state *state) -{ - memset(state, 0, sizeof(*state)); - for (int phase = 0; phase < CHECK_PHASES; phase++) - state->checks[phase].failures = -1; - snprintf(state->checks[PHASE_BOOT].status, - sizeof(state->checks[PHASE_BOOT].status), "pending"); - snprintf(state->last_discontinuity, sizeof(state->last_discontinuity), - "none"); - snprintf(state->last_downtime_source, sizeof(state->last_downtime_source), - "none"); - snprintf(state->last_sample_error, sizeof(state->last_sample_error), - "none"); -} - -static void copy_word(char *destination, size_t size, const char *value) -{ - snprintf(destination, size, "%.*s", (int)strcspn(value, "\n"), value); -} - -// Parses key=value lines. Unknown keys are ignored; a malformed number in a -// known numeric key fails the parse. -static int state_parse(char *text, struct time_state *state) -{ - struct check_state *boot = &state->checks[PHASE_BOOT]; - struct check_state *capture = &state->checks[PHASE_CAPTURE]; - struct check_state *restore = &state->checks[PHASE_RESTORE]; - struct { - const char *key; - uint64_t *unsigned_value; - int64_t *signed_value; - } const numbers[] = { - {"generation", &state->generation, NULL}, - {"boot_cpus", &boot->cpus, NULL}, - {"capture_cpus", &capture->cpus, NULL}, - {"restore_cpus", &restore->cpus, NULL}, - {"boot_elapsed_us", NULL, &boot->elapsed_us}, - {"capture_elapsed_us", NULL, &capture->elapsed_us}, - {"restore_elapsed_us", NULL, &restore->elapsed_us}, - {"boot_cpu_us", NULL, &boot->cpu_us}, - {"capture_cpu_us", NULL, &capture->cpu_us}, - {"restore_cpu_us", NULL, &restore->cpu_us}, - {"capture_generation", &capture->generation, NULL}, - {"restore_generation", &restore->generation, NULL}, - {"boot_failures", NULL, &boot->failures}, - {"capture_failures", NULL, &capture->failures}, - {"restore_failures", NULL, &restore->failures}, - {"tsc_hz", &state->tsc_hz, NULL}, - {"lapic_hz", &state->lapic_hz, NULL}, - {"discontinuities", &state->discontinuities, NULL}, - {"last_step_ns", NULL, &state->last_step_ns}, - {"last_step_realtime_ns", NULL, &state->last_step_realtime_ns}, - {"last_downtime_ns", &state->last_downtime_ns, NULL}, - {"synchronized", &state->synchronized, NULL}, - {"offset_ns", NULL, &state->offset_ns}, - {"uncertainty_ns", NULL, &state->uncertainty_ns}, - {"frequency_ppb", NULL, &state->frequency_ppb}, - {"samples", &state->samples, NULL}, - {"rejected_samples", &state->rejected_samples, NULL}, - {"violations", &state->violations, NULL}, - }; - struct { - const char *key; - char *value; - size_t size; - } const words[] = { - {"boot_status", boot->status, sizeof(boot->status)}, - {"capture_status", capture->status, sizeof(capture->status)}, - {"restore_status", restore->status, sizeof(restore->status)}, - {"last_discontinuity", state->last_discontinuity, - sizeof(state->last_discontinuity)}, - {"last_downtime_source", state->last_downtime_source, - sizeof(state->last_downtime_source)}, - {"last_sample_error", state->last_sample_error, - sizeof(state->last_sample_error)}, - }; - - state_init(state); - for (char *line = text; line != NULL && *line != '\0';) { - char *next = strchr(line, '\n'); - char *value = strchr(line, '='); - - if (next != NULL) - *next++ = '\0'; - if (value != NULL) { - *value++ = '\0'; - for (size_t index = 0; index < ARRAY_SIZE(words); index++) { - if (strcmp(line, words[index].key) == 0) - copy_word(words[index].value, words[index].size, value); - } - for (size_t index = 0; index < ARRAY_SIZE(numbers); index++) { - int64_t number; - - if (strcmp(line, numbers[index].key) != 0) - continue; - if (parse_int64(value, &number) != 0) - return -1; - if (numbers[index].unsigned_value != NULL) - *numbers[index].unsigned_value = (uint64_t)number; - else - *numbers[index].signed_value = number; - } - } - line = next; - } - return 0; -} - -// Appends a formatted line to TEXT at *LENGTH, if it fits. -static void append_text(char *text, size_t size, size_t *length, - const char *format, ...) - __attribute__((format(printf, 4, 5))); - -static void append_text(char *text, size_t size, size_t *length, - const char *format, ...) -{ - va_list arguments; - int written; - - if (*length >= size) - return; - va_start(arguments, format); - written = vsnprintf(text + *length, size - *length, format, arguments); - va_end(arguments); - if (written > 0) - *length += (size_t)written; -} - -// Formats the state file's key=value lines in their fixed order; the keys of a -// phase whose check never ran are absent. -static void state_format(const struct time_state *state, char *text, - size_t size) -{ - size_t length = 0; - - append_text(text, size, &length, "version=1\ngeneration=%" PRIu64 "\n", - state->generation); - for (int phase = 0; phase < CHECK_PHASES; phase++) { - if (state->checks[phase].status[0] != '\0') - append_text(text, size, &length, "%s_status=%s\n", - k_phase_names[phase], state->checks[phase].status); - } - for (int phase = 0; phase < CHECK_PHASES; phase++) { - if (state->checks[phase].status[0] != '\0') - append_text(text, size, &length, "%s_cpus=%" PRIu64 "\n", - k_phase_names[phase], state->checks[phase].cpus); - } - for (int phase = 0; phase < CHECK_PHASES; phase++) { - if (state->checks[phase].status[0] != '\0') - append_text(text, size, &length, "%s_elapsed_us=%" PRId64 "\n", - k_phase_names[phase], state->checks[phase].elapsed_us); - } - for (int phase = 0; phase < CHECK_PHASES; phase++) { - if (state->checks[phase].status[0] != '\0') - append_text(text, size, &length, "%s_cpu_us=%" PRId64 "\n", - k_phase_names[phase], state->checks[phase].cpu_us); - } - for (int phase = PHASE_CAPTURE; phase < CHECK_PHASES; phase++) { - if (state->checks[phase].status[0] != '\0') - append_text(text, size, &length, "%s_generation=%" PRIu64 "\n", - k_phase_names[phase], state->checks[phase].generation); - } - for (int phase = 0; phase < CHECK_PHASES; phase++) { - if (state->checks[phase].status[0] != '\0' && - state->checks[phase].failures >= 0) - append_text(text, size, &length, "%s_failures=%" PRId64 "\n", - k_phase_names[phase], state->checks[phase].failures); - } - append_text(text, size, &length, - "tsc_hz=%" PRIu64 "\n" - "lapic_hz=%" PRIu64 "\n" - "discontinuities=%" PRIu64 "\n" - "last_discontinuity=%s\n" - "last_step_ns=%" PRId64 "\n" - "last_step_realtime_ns=%" PRId64 "\n" - "last_downtime_ns=%" PRIu64 "\n" - "last_downtime_source=%s\n" - "synchronized=%" PRIu64 "\n" - "offset_ns=%" PRId64 "\n" - "uncertainty_ns=%" PRId64 "\n" - "frequency_ppb=%" PRId64 "\n" - "samples=%" PRIu64 "\n" - "rejected_samples=%" PRIu64 "\n" - "last_sample_error=%s\n" - "violations=%" PRIu64 "\n", - state->tsc_hz, state->lapic_hz, state->discontinuities, - state->last_discontinuity, state->last_step_ns, - state->last_step_realtime_ns, state->last_downtime_ns, - state->last_downtime_source, state->synchronized, - state->offset_ns, state->uncertainty_ns, state->frequency_ppb, - state->samples, state->rejected_samples, - state->last_sample_error, state->violations); -} - -static int state_load(struct time_state *state) -{ - char text[2048]; - - if (read_text(STATE_PATH, text, sizeof(text)) < 0) - return -1; - return state_parse(text, state); -} - -static int state_store(const struct time_state *state) -{ - char text[2048]; - - state_format(state, text, sizeof(text)); - return replace_file(STATE_PATH, STATE_TEMP_PATH, text); -} - -typedef void (*state_update)(struct time_state *state, const void *context); - -// Applies UPDATE to the published state under its lock. The helpers and the -// daemon all write the state this way, so updates never interleave. -static int state_apply(state_update update, const void *context) -{ - struct time_state state; - int lock = lock_file(STATE_LOCK_PATH); - int result; - - if (lock < 0) - return -1; - if (state_load(&state) != 0) - state_init(&state); - update(&state, context); - result = state_store(&state); - close(lock); - return result; -} - -static void add_violation(struct time_state *state, const void *context) -{ - (void)context; - state->violations++; -} - -static void count_violation(void) -{ - if (!g_test && access(STATE_PATH, F_OK) == 0) - (void)state_apply(add_violation, NULL); -} - -static int64_t frequency_to_ppb(int64_t frequency) -{ - int64_t scaled = frequency * 1000; - - return (scaled + (scaled >= 0 ? 32768 : -32768)) / 65536; -} - -// F and L are published in the state file by the boot check. -static int rates_load(uint64_t *tsc_hz, uint64_t *lapic_hz) -{ - struct time_state state; - - if (state_load(&state) != 0 || state.tsc_hz == 0 || state.lapic_hz == 0) - return -1; - *tsc_hz = state.tsc_hz; - *lapic_hz = state.lapic_hz; - return 0; -} - -struct check_record { - enum phase phase; - const char *status; - int cpus; - int64_t elapsed_us; - int64_t cpu_us; - uint64_t tsc_hz; - uint64_t lapic_hz; - int failures; -}; - -static void apply_check(struct time_state *state, const void *context) -{ - const struct check_record *record = context; - struct check_state *check = &state->checks[record->phase]; - - snprintf(check->status, sizeof(check->status), "%s", record->status); - check->cpus = (uint64_t)record->cpus; - check->elapsed_us = record->elapsed_us; - check->cpu_us = record->cpu_us; - check->generation = record->phase == PHASE_BOOT ? 0 : state->generation; - check->failures = -1; - // Report-only mode keeps the failures in the state file, in keys that only - // it writes. - if (g_report_only) { - check->failures = record->failures; - if (record->failures > 0 && strcmp(record->status, "ok") == 0) - snprintf(check->status, sizeof(check->status), "fail"); - } - if (record->tsc_hz != 0) - state->tsc_hz = record->tsc_hz; - if (record->lapic_hz != 0) - state->lapic_hz = record->lapic_hz; -} - -// Records a phase's conformance check in the state file: "pending" while it -// runs, then "ok" (a failed check powers the guest off instead), with the -// generation it ran in and its wall and CPU times. Rates of 0 keep the -// published ones. Report-only mode records "fail" and the failure count of a -// check that failed. -static int record_check(enum phase phase, const char *status, int cpus, - int64_t elapsed_us, int64_t cpu_us, uint64_t tsc_hz, - uint64_t lapic_hz, int failures) -{ - struct check_record record = {phase, status, cpus, elapsed_us, - cpu_us, tsc_hz, lapic_hz, failures}; - - return state_apply(apply_check, &record); -} - -// The watchdog settings saved before a capture, in the order they are -// written. Missing debug-kernel sysctls are skipped. -static const char *const k_suppressed[] = {RCU_STALL_SUPPRESS, SOFT_WATCHDOG, - HUNG_TASK_TIMEOUT}; -static const int64_t k_suppressed_values[] = {1, 0, 0}; - -static int suppression_save(void) -{ - char text[512] = ""; - size_t length = 0; - int fd; - - for (size_t index = 0; index < ARRAY_SIZE(k_suppressed); index++) { - int64_t value; - - if (access(k_suppressed[index], F_OK) != 0) { - if (index == 0) - return -1; - continue; - } - if (read_int64(k_suppressed[index], &value) != 0) - return -1; - length += (size_t)snprintf(text + length, sizeof(text) - length, - "%s=%" PRId64 "\n", k_suppressed[index], - value); - } - fd = open(SUPPRESSION_PATH, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600); - if (fd < 0) - return -1; - if (write_all(fd, text, length) != 0) { - close(fd); - unlink(SUPPRESSION_PATH); - return -1; - } - close(fd); - for (size_t index = 0; index < ARRAY_SIZE(k_suppressed); index++) { - if (access(k_suppressed[index], F_OK) == 0 && - write_int64(k_suppressed[index], k_suppressed_values[index]) != 0) - return -1; - } - return 0; -} - -// Writes back every value saved by suppression_save, then forgets them. A -// lock serializes the daemon's release at the stall timeout with the restore -// worker's; with MISSING_OK, values already written back count as restored. -static int suppression_restore(char *detail, size_t size, bool missing_ok) -{ - char text[512]; - char *line = text; - int lock = lock_file(SUPPRESSION_LOCK_PATH); - int result = -1; - - if (lock < 0) { - snprintf(detail, size, "lock %s: %s", SUPPRESSION_LOCK_PATH, - strerror(errno)); - return -1; - } - if (read_text(SUPPRESSION_PATH, text, sizeof(text)) < 0) { - if (missing_ok && errno == ENOENT) - result = 0; - else - snprintf(detail, size, "read %s: %s", SUPPRESSION_PATH, - strerror(errno)); - close(lock); - return result; - } - while (*line != '\0') { - char *next = strchr(line, '\n'); - char *value = strchr(line, '='); - - if (next != NULL) - *next++ = '\0'; - if (value == NULL) - break; - *value++ = '\0'; - if (write_text(line, value) != 0) { - snprintf(detail, size, "restore %.200s: %s", line, - strerror(errno)); - close(lock); - return -1; - } - line = next != NULL ? next : line + strlen(line); - } - if (unlink(SUPPRESSION_PATH) != 0) - snprintf(detail, size, "remove %s: %s", SUPPRESSION_PATH, - strerror(errno)); - else - result = 0; - close(lock); - return result; -} - -// --------------------------------------------------------------------------- -// Decoders and decisions (pure; exercised by `nvx-time test`) -// --------------------------------------------------------------------------- - -struct restore_packet { - uint8_t flags; - uint8_t online_vp_count; - uint8_t range_count; - uint32_t generation; - int32_t rate_deviation; - uint64_t downtime_ns; - uint64_t utc_ns; - uint64_t ranges[PACKET_MAX_RANGES][2]; - uint8_t entropy[PACKET_ENTROPY_SIZE]; -}; - -static int parse_packet_header(const uint8_t *bytes, - struct restore_packet *packet, char *detail, - size_t size) -{ - uint8_t online; - - memset(packet, 0, sizeof(*packet)); - if (memcmp(bytes, "OVR", 3) != 0) { - snprintf(detail, size, "restore packet magic is %02x%02x%02x", - bytes[0], bytes[1], bytes[2]); - return -1; - } - if (bytes[3] != 4) { - snprintf(detail, size, "restore packet version is %u", bytes[3]); - return -1; - } - packet->flags = bytes[4]; - online = bytes[5]; - packet->online_vp_count = online; - packet->range_count = bytes[6]; - packet->generation = read_le32(bytes + 8); - packet->rate_deviation = (int32_t)read_le32(bytes + 12); - packet->downtime_ns = read_le64(bytes + 16); - packet->utc_ns = read_le64(bytes + 24); - if ((packet->flags & ~PACKET_KNOWN_FLAGS) != 0) { - snprintf(detail, size, "restore packet flags 0x%02x set reserved bits", - packet->flags); - return -1; - } - if (online != 0 && online != 1 && online != 2 && online != 4 && - online != 8) { - snprintf(detail, size, "restore packet online VP count is %u", online); - return -1; - } - if ((packet->flags & PACKET_MEMORY_TARGET) == 0 && packet->range_count != 0) { - snprintf(detail, size, - "restore packet has %u memory ranges without a memory target", - packet->range_count); - return -1; - } - if (bytes[7] != 0) { - snprintf(detail, size, "restore packet reserved byte is %u", bytes[7]); - return -1; - } - if (packet->generation == 0) { - snprintf(detail, size, "restore packet generation is 0"); - return -1; - } - if (packet->rate_deviation > RATE_DEVIATION_LIMIT || - packet->rate_deviation < -RATE_DEVIATION_LIMIT) { - snprintf(detail, size, - "restore packet rate deviation %" PRId32 - " exceeds the 250 ppm tolerance", - packet->rate_deviation); - return -1; - } - if (packet->downtime_ns > (uint64_t)DOWNTIME_LIMIT_NS) { - snprintf(detail, size, - "restore packet downtime %" PRIu64 " ns exceeds 30 days", - packet->downtime_ns); - return -1; - } - if (packet->utc_ns == 0 || packet->utc_ns > (uint64_t)INT64_MAX) { - snprintf(detail, size, "restore packet UTC %" PRIu64 " is invalid", - packet->utc_ns); - return -1; - } - return 0; -} - -static size_t packet_body_size(const struct restore_packet *packet) -{ - return (size_t)packet->range_count * PACKET_RANGE_SIZE + PACKET_ENTROPY_SIZE; -} - -static int parse_packet_body(const uint8_t *bytes, - struct restore_packet *packet, char *detail, - size_t size) -{ - for (unsigned index = 0; index < packet->range_count; index++) { - uint64_t start = read_le64(bytes + index * PACKET_RANGE_SIZE); - uint64_t length = read_le64(bytes + index * PACKET_RANGE_SIZE + 8); - - if (length == 0 || start + length < start) { - snprintf(detail, size, "restore memory range %u is invalid", index); - return -1; - } - packet->ranges[index][0] = start; - packet->ranges[index][1] = length; - } - memcpy(packet->entropy, bytes + packet->range_count * PACKET_RANGE_SIZE, - PACKET_ENTROPY_SIZE); - return 0; -} - -struct time_sample { - uint8_t flags; - uint32_t generation; - uint64_t utc_ns; - int64_t t0; - int64_t t1; -}; - -static int parse_sample(const uint8_t *bytes, struct time_sample *sample, - char *detail, size_t size) -{ - uint16_t reserved = (uint16_t)(bytes[2] | bytes[3] << 8); - - sample->flags = bytes[1]; - sample->generation = read_le32(bytes + 4); - sample->utc_ns = read_le64(bytes + 8); - if (bytes[0] != 1) { - snprintf(detail, size, "time sample version is %u", bytes[0]); - return -1; - } - if ((sample->flags & ~SAMPLE_TEST_HOOKS) != 0 || reserved != 0) { - snprintf(detail, size, "time sample flags 0x%02x or reserved 0x%04x", - sample->flags, reserved); - return -1; - } - if (sample->utc_ns == 0 || sample->utc_ns > (uint64_t)INT64_MAX) { - snprintf(detail, size, "time sample UTC %" PRIu64 " is invalid", - sample->utc_ns); - return -1; - } - return 0; -} - -// The host latched UTC between the guest's CLOCK_REALTIME reads T0 and T1. -static void pair_clocks(int64_t t0, int64_t t1, uint64_t utc_ns, - int64_t *theta, int64_t *epsilon) -{ - *epsilon = (t1 - t0) / 2; - *theta = (int64_t)utc_ns - (t0 + (t1 - t0) / 2); -} - -static void offset_to_timeval(int64_t offset_ns, struct timeval *time) -{ - time->tv_sec = (time_t)(offset_ns / NSEC_PER_SEC); - time->tv_usec = (suseconds_t)(offset_ns % NSEC_PER_SEC); - if (time->tv_usec < 0) { - time->tv_sec -= 1; - time->tv_usec += NSEC_PER_SEC; - } -} - -static int64_t restore_frequency(int32_t rate_deviation) -{ - int64_t frequency = -(int64_t)rate_deviation; - - if (frequency > FREQUENCY_LIMIT) - return FREQUENCY_LIMIT; - if (frequency < -FREQUENCY_LIMIT) - return -FREQUENCY_LIMIT; - return frequency; -} - -static int64_t ceil_us(int64_t nanoseconds) -{ - return (nanoseconds + NSEC_PER_USEC - 1) / NSEC_PER_USEC; -} - -// Builds the discipline's correction for an accepted sample: a step at or -// beyond 128 ms, otherwise a PLL slew. Returns true for a step. -static bool plan_correction(int64_t theta, int64_t epsilon, bool fast, - struct timex *tx) -{ - int64_t magnitude = theta < 0 ? -theta : theta; - - memset(tx, 0, sizeof(*tx)); - if (magnitude >= STEP_THRESHOLD_NS) { - tx->modes = ADJ_SETOFFSET | ADJ_NANO; - offset_to_timeval(theta, &tx->time); - return true; - } - tx->modes = ADJ_OFFSET | ADJ_STATUS | ADJ_NANO | ADJ_TIMECONST | - ADJ_MAXERROR | ADJ_ESTERROR; - tx->offset = (long)theta; - tx->status = STA_PLL | STA_NANO; - tx->constant = fast ? FAST_TIME_CONSTANT : SLOW_TIME_CONSTANT; - tx->maxerror = (long)ceil_us(magnitude + epsilon); - tx->esterror = (long)ceil_us(epsilon); - return false; -} - -struct watch_rule { - const char *code; - const char *all[2]; - const char *any[4]; -}; - -static const struct watch_rule k_watch_rules[] = { - {"G_TSC_UNSTABLE", {"Marking TSC unstable due to ", NULL}, {NULL}}, - {"G_CLOCKSOURCE_UNSTABLE", - {"timekeeping watchdog on CPU", "as unstable because the skew is too large"}, - {NULL}}, - {"G_CLOCKSOURCE_SWITCH", {"clocksource: Switched to clocksource ", NULL}, - {NULL}}, - {"G_CLOCKSOURCE_SKEW", - {"clocksource: ", NULL}, - {" ahead of CPU ", " behind CPU ", NULL}}, - {"G_TSC_WARP", - {NULL, NULL}, - {"TSC synchronization [CPU#", "cycles TSC warp between CPUs", - "TSC warped randomly between CPUs", NULL}}, - {"G_TSC_ADJUST", {"TSC ADJUST", NULL}, {NULL}}, - {"G_RCU_STALL", - {"rcu: INFO: ", NULL}, - {" detected stalls on CPUs/tasks", " self-detected stall on CPU", - " detected expedited stalls", NULL}}, - {"G_RCU_STARVED", - {"rcu: ", NULL}, - {" kthread starved for ", " kthread timer wakeup didn't happen for ", - NULL}}, - {"G_SOFT_LOCKUP", {"watchdog: BUG: soft lockup - CPU#", NULL}, {NULL}}, - {"G_HARD_LOCKUP", {"Watchdog detected hard LOCKUP", NULL}, {NULL}}, - {"G_HUNG_TASK", {"INFO: task ", " blocked for more than "}, {NULL}}, - {"G_UNCHECKED_MSR", {"unchecked MSR access error", NULL}, {NULL}}, - {"G_APIC_ID_MISMATCH", {"APIC ID mismatch", NULL}, {NULL}}, -}; - -// Returns the code of the first rule whose substrings all appear in MESSAGE. -// The guest's own NVX-TIME lines quote kernel text and are never matched. -static const char *match_watch_rule(const char *message) -{ - if (strncmp(message, "NVX-TIME-", 9) == 0) - return NULL; - for (size_t index = 0; index < ARRAY_SIZE(k_watch_rules); index++) { - const struct watch_rule *rule = &k_watch_rules[index]; - bool all = true; - bool any = rule->any[0] == NULL; - - for (size_t part = 0; part < ARRAY_SIZE(rule->all) && rule->all[part]; - part++) - all = all && strstr(message, rule->all[part]) != NULL; - for (size_t part = 0; part < ARRAY_SIZE(rule->any) && rule->any[part]; - part++) - any = any || strstr(message, rule->any[part]) != NULL; - if (all && any) - return rule->code; - } - return NULL; -} - -struct boot_log { - bool hypervisor; - bool privileges; - bool lapic; - char last_switch[48]; - char problem[DETAIL_MAX]; - char wx[DETAIL_MAX]; -}; - -// CONFIG_DEBUG_WX audits the kernel page tables before init runs and logs -// this for a mapping that is both writable and executable. -static const char k_wx_record[] = "Found insecure W+X mapping"; - -static void boot_log_add(struct boot_log *log, const char *message) -{ - static const char *const forbidden[] = { - "Fast TSC calibration", "Refined TSC clocksource calibration", - "kvm-clock", "APIC timer: using supplied frequency"}; - static const char switched[] = "clocksource: Switched to clocksource "; - const char *name = strstr(message, switched); - const char *code; - - if (log->wx[0] == '\0' && strncmp(message, "NVX-TIME-", 9) != 0 && - strstr(message, k_wx_record) != NULL) - snprintf(log->wx, sizeof(log->wx), "%.300s", message); - if (strstr(message, "Hypervisor detected: Microsoft Hyper-V") != NULL) - log->hypervisor = true; - if (strstr(message, "Hyper-V: privilege flags low 0x8860,") != NULL) - log->privileges = true; - if (ends_with(message, "Hyper-V: LAPIC Timer Frequency: 0x989680") || - ends_with(message, "Hyper-V: LAPIC Timer Frequency: 0x1e8480")) - log->lapic = true; - if (name != NULL) { - name += sizeof(switched) - 1; - snprintf(log->last_switch, sizeof(log->last_switch), "%.*s", - (int)strcspn(name, " \n"), name); - } - if (log->problem[0] != '\0') - return; - code = match_watch_rule(message); - if (code != NULL && strcmp(code, "G_CLOCKSOURCE_SWITCH") != 0) { - snprintf(log->problem, sizeof(log->problem), "%s: %.300s", code, - message); - return; - } - for (size_t index = 0; index < ARRAY_SIZE(forbidden); index++) { - if (strncmp(message, "NVX-TIME-", 9) != 0 && - strstr(message, forbidden[index]) != NULL) { - snprintf(log->problem, sizeof(log->problem), - "forbidden record: %.300s", message); - return; - } - } -} - -static bool boot_log_verdict(const struct boot_log *log, char *detail, - size_t size) -{ - if (log->problem[0] != '\0') - snprintf(detail, size, "%s", log->problem); - else if (!log->hypervisor) - snprintf(detail, size, "no 'Hypervisor detected: Microsoft Hyper-V'"); - else if (!log->privileges) - snprintf(detail, size, "no 'Hyper-V: privilege flags low 0x8860,'"); - else if (!log->lapic) - snprintf(detail, size, "no 'Hyper-V: LAPIC Timer Frequency' of 1 GHz " - "or 200 MHz"); - else if (strcmp(log->last_switch, "tsc") != 0) - snprintf(detail, size, "last clocksource switch is to '%s', not tsc", - log->last_switch); - else - return true; - return false; -} - -// --------------------------------------------------------------------------- -// Kernel log -// --------------------------------------------------------------------------- - -typedef void (*kmsg_consumer)(void *context, const char *message); - -// Reads every available /dev/kmsg record from the non-blocking FD and passes -// each message text to CONSUME. Returns 0 when drained, -EPIPE when records -// were overwritten before they were read, or another negative errno. -static int kmsg_drain(int fd, kmsg_consumer consume, void *context) -{ - char record[8192]; - - for (;;) { - ssize_t count = read(fd, record, sizeof(record) - 1); - char *message; - - if (count < 0 && errno == EINTR) - continue; - if (count < 0) - return errno == EAGAIN ? 0 : -errno; - if (count == 0) - return 0; - record[count] = '\0'; - message = strchr(record, ';'); - if (message == NULL) - continue; - message++; - message[strcspn(message, "\n")] = '\0'; - consume(context, message); - } -} - -#define SYSLOG_ACTION_READ_ALL 3 -#define SYSLOG_ACTION_SIZE_BUFFER 10 - -// Returns the message text of one SYSLOG_ACTION_READ_ALL line, without its -// "" prefix and its printk timestamp ("[ 1.234567] "). -static const char *syslog_message(const char *line) -{ - const char *cursor = line; - const char *end = strchr(line, '>'); - const char *scan; - - if (*cursor == '<' && end != NULL) - cursor = end + 1; - if (*cursor != '[') - return cursor; - scan = cursor + 1; - while (*scan == ' ') - scan++; - while (*scan >= '0' && *scan <= '9') - scan++; - if (*scan++ != '.') - return cursor; - while (*scan >= '0' && *scan <= '9') - scan++; - if (*scan != ']') - return cursor; - return scan[1] == ' ' ? scan + 2 : scan + 1; -} - -static void boot_log_line(void *context, char *line) -{ - boot_log_add(context, syslog_message(line)); -} - -struct line_starts { - const char **starts; - size_t count; - size_t capacity; -}; - -// Records the start of every line of BUFFER that contains NEEDLE. -static bool mark_lines(const char *buffer, const char *needle, - struct line_starts *lines) -{ - for (const char *hit = strstr(buffer, needle); hit != NULL; - hit = strstr(hit + 1, needle)) { - const char *start = hit; - - while (start > buffer && start[-1] != '\n') - start--; - if (lines->count == lines->capacity) { - size_t capacity = lines->capacity * 2; - const char **grown = - realloc(lines->starts, capacity * sizeof(*lines->starts)); - - if (grown == NULL) - return false; - lines->starts = grown; - lines->capacity = capacity; - } - lines->starts[lines->count++] = start; - } - return true; -} - -static int compare_starts(const void *left, const void *right) -{ - const char *a = *(const char *const *)left; - const char *b = *(const char *const *)right; - - return a < b ? -1 : a > b; -} - -// C4 and K1 over the whole SYSLOG_ACTION_READ_ALL text. Only lines that -// contain a required record, a forbidden record, the W+X record, or the first -// substring of a watcher row can change the verdict, so a few whole-buffer -// searches find them, and boot_log_add() sees just those lines, in log order. -// Matching every rule against every line cost about a millisecond on a -// guest's boot log. -static void boot_log_scan(char *buffer, struct boot_log *log) -{ - static const char *const anchors[] = { - "Hypervisor detected: Microsoft Hyper-V", - "Hyper-V: privilege flags low 0x8860,", - "Hyper-V: LAPIC Timer Frequency: ", - "Fast TSC calibration", - "Refined TSC clocksource calibration", - "kvm-clock", - "APIC timer: using supplied frequency", - k_wx_record}; - struct line_starts lines = {malloc(256 * sizeof(const char *)), 0, 256}; - bool ok = lines.starts != NULL; - - for (size_t index = 0; ok && index < ARRAY_SIZE(anchors); index++) - ok = mark_lines(buffer, anchors[index], &lines); - for (size_t index = 0; ok && index < ARRAY_SIZE(k_watch_rules); index++) { - const struct watch_rule *rule = &k_watch_rules[index]; - - if (rule->all[0] != NULL) { - ok = mark_lines(buffer, rule->all[0], &lines); - continue; - } - for (size_t part = 0; - ok && part < ARRAY_SIZE(rule->any) && rule->any[part]; part++) - ok = mark_lines(buffer, rule->any[part], &lines); - } - if (!ok) { - free(lines.starts); - for_each_text_line(buffer, boot_log_line, log); - return; - } - qsort(lines.starts, lines.count, sizeof(*lines.starts), compare_starts); - for (size_t index = 0; index < lines.count; index++) { - char *start = (char *)lines.starts[index]; - char *end = strchr(start, '\n'); - - if (index > 0 && lines.starts[index - 1] == start) - continue; - if (end != NULL) - *end = '\0'; - boot_log_add(log, syslog_message(start)); - if (end != NULL) - *end = '\n'; - } - free(lines.starts); -} - -// Reads the whole kernel log for C4 with one SYSLOG_ACTION_READ_ALL. The -// caller has already positioned the daemon's /dev/kmsg descriptor at the end -// of the log, so records logged in between are seen twice, never missed. -// BUFFER holds SIZE + 1 bytes, the size SYSLOG_ACTION_SIZE_BUFFER reported. -static int read_boot_log_into(struct boot_log *log, char *buffer, int size) -{ - int count = klogctl(SYSLOG_ACTION_READ_ALL, buffer, size); - int error = errno; - - if (count >= 0) { - buffer[count] = '\0'; - boot_log_scan(buffer, log); - } - errno = error; - return count < 0 ? -1 : 0; -} - -// --------------------------------------------------------------------------- -// Conformance checks -// --------------------------------------------------------------------------- - -struct checks { - enum phase phase; - int failures; - int possible; - int online_count; - bool online[MAX_CPUS]; - uint64_t cpu_khz[MAX_CPUS]; - bool cpu_khz_known[MAX_CPUS]; - uint64_t tsc_hz; - uint64_t lapic_hz; -}; - -static bool check_failed_code(struct checks *checks, const char *code, - const char *format, ...) - __attribute__((format(printf, 3, 4))); - -// Serializes failure reports from the boot check's per-CPU threads, which -// share the console and the violation count. -static pthread_mutex_t g_report_lock = PTHREAD_MUTEX_INITIALIZER; - -// Emits CODE and powers off with status 193; a failed check prints no -// marker. In report-only mode it returns false and the run continues. -static bool check_failed_code(struct checks *checks, const char *code, - const char *format, ...) -{ - char detail[DETAIL_MAX]; - va_list arguments; - - va_start(arguments, format); - vsnprintf(detail, sizeof(detail), format, arguments); - va_end(arguments); - pthread_mutex_lock(&g_report_lock); - checks->failures++; - emit_event(code, "conformance", checks->phase, detail); - if (!g_report_only) - power_off(STATUS_CONFORMANCE); - pthread_mutex_unlock(&g_report_lock); - return false; -} - -static bool check_failed(struct checks *checks, const char *id, - const char *format, ...) - __attribute__((format(printf, 3, 4))); - -// check_failed_code() with the conformance code G_CONFORMANCE_. -static bool check_failed(struct checks *checks, const char *id, - const char *format, ...) -{ - char detail[DETAIL_MAX]; - char code[32]; - va_list arguments; - - va_start(arguments, format); - vsnprintf(detail, sizeof(detail), format, arguments); - va_end(arguments); - snprintf(code, sizeof(code), "G_CONFORMANCE_%s", id); - return check_failed_code(checks, code, "%s", detail); -} - -static int checks_init(struct checks *checks, enum phase phase) -{ - char text[256]; - bool possible[MAX_CPUS]; - - memset(checks, 0, sizeof(*checks)); - checks->phase = phase; - if (read_text("/sys/devices/system/cpu/possible", text, sizeof(text)) < 0) - return -1; - checks->possible = parse_cpu_list(text, possible); - if (read_text("/sys/devices/system/cpu/online", text, sizeof(text)) < 0) - return -1; - checks->online_count = parse_cpu_list(text, checks->online); - return checks->possible > 0 && checks->online_count > 0 ? 0 : -1; -} - -static void cpuid(uint32_t leaf, uint32_t subleaf, uint32_t registers[4]) -{ - __asm__ __volatile__("cpuid" - : "=a"(registers[0]), "=b"(registers[1]), - "=c"(registers[2]), "=d"(registers[3]) - : "a"(leaf), "c"(subleaf)); -} - -// Runs the calling thread on CPU only, so inline CPUID and the msr driver -// read that CPU without inter-processor calls. -static int pin_to_cpu(int cpu) -{ - cpu_set_t set; - - CPU_ZERO(&set); - CPU_SET(cpu, &set); - if (sched_setaffinity(0, sizeof(set), &set) != 0) - return -1; - return sched_getcpu() == cpu ? 0 : -1; -} - -static bool all_zero(const uint32_t value[4]) -{ - return (value[0] | value[1] | value[2] | value[3]) == 0; -} - -// C1: the identity leaves and the explicit zero leaves. The absence of a -// hypervisor signature at other bases is a static backend property that the -// CI exhaustive check covers, so the boot check skips that 255-leaf scan. -static bool check_identity(struct checks *checks, int cpu) -{ - const uint32_t capacity = (uint32_t)checks->possible; - const uint32_t expected[6][4] = { - {0x40000005, 0x7263694d, 0x666f736f, 0x76482074}, - {0x31237648, 0, 0, 0}, - {0x0058564e, 0x00010000, 0, 0}, - {0x00008860, 0, 0, 0x00000100}, - {0, 0xffffffff, 0, 0}, - {capacity, capacity, 0, 0}, - }; - static const uint32_t zero_leaves[][2] = {{0x40000006, 0x4000000f}, - {0x40000080, 0x40000082}}; - uint32_t value[4]; - - for (uint32_t index = 0; index < 6; index++) { - cpuid(0x40000000 + index, 0, value); - if (memcmp(value, expected[index], sizeof(value)) != 0) - return check_failed( - checks, "C1", - "cpu %d leaf 0x%08" PRIx32 " is %08" PRIx32 ":%08" PRIx32 - ":%08" PRIx32 ":%08" PRIx32 ", expected %08" PRIx32 - ":%08" PRIx32 ":%08" PRIx32 ":%08" PRIx32, - cpu, 0x40000000 + index, value[0], value[1], value[2], - value[3], expected[index][0], expected[index][1], - expected[index][2], expected[index][3]); - } - for (size_t range = 0; range < ARRAY_SIZE(zero_leaves); range++) { - for (uint32_t leaf = zero_leaves[range][0]; leaf <= zero_leaves[range][1]; - leaf++) { - cpuid(leaf, 0, value); - if (!all_zero(value)) - return check_failed( - checks, "C1", - "cpu %d leaf 0x%08" PRIx32 " is %08" PRIx32 ":%08" PRIx32 - ":%08" PRIx32 ":%08" PRIx32 ", expected zero", - cpu, leaf, value[0], value[1], value[2], value[3]); - } - } - return true; -} - -static bool check_bit(struct checks *checks, int cpu, const char *name, - uint32_t reg, unsigned bit, bool expected) -{ - if (((reg >> bit) & 1) == (expected ? 1u : 0u)) - return true; - return check_failed(checks, "C2", "cpu %d %s is %s", cpu, name, - expected ? "clear" : "set"); -} - -static bool check_leaf(struct checks *checks, int cpu, uint32_t leaf, - const uint32_t expected[4]) -{ - uint32_t value[4]; - - cpuid(leaf, 0, value); - if (memcmp(value, expected, sizeof(value)) == 0) - return true; - return check_failed(checks, "C2", - "cpu %d leaf 0x%08" PRIx32 " is %08" PRIx32 - ":%08" PRIx32 ":%08" PRIx32 ":%08" PRIx32 - ", expected %08" PRIx32 ":%08" PRIx32 ":%08" PRIx32 - ":%08" PRIx32, - cpu, leaf, value[0], value[1], value[2], value[3], - expected[0], expected[1], expected[2], expected[3]); -} - -// C2: the CPU time bits every profile fixes. -static bool check_time_bits(struct checks *checks, int cpu) -{ - static const uint32_t arat_only[4] = {4, 0, 0, 0}; - static const uint32_t zero[4] = {0, 0, 0, 0}; - static const uint32_t invariant_tsc[4] = {0, 0, 0, 0x100}; - uint32_t value[4]; - uint32_t max_basic; - bool ok; - - cpuid(0, 0, value); - max_basic = value[0]; - cpuid(1, 0, value); - ok = check_bit(checks, cpu, "CPUID.1:ECX[31] (hypervisor)", value[2], 31, - true) && - check_bit(checks, cpu, "CPUID.1:ECX[24] (TSC-deadline)", value[2], - 24, false) && - check_bit(checks, cpu, "CPUID.1:ECX[15] (PDCM)", value[2], 15, - false) && - check_bit(checks, cpu, "CPUID.1:EDX[4] (TSC)", value[3], 4, true); - if (!ok) - return false; - if (max_basic < 6) - return check_failed(checks, "C2", "cpu %d lacks leaf 0x6 (ARAT)", cpu); - if (!check_leaf(checks, cpu, 6, arat_only)) - return false; - if (max_basic >= 7) { - cpuid(7, 0, value); - if (!check_bit(checks, cpu, "CPUID.7.0:EBX[1] (TSC_ADJUST)", value[1], - 1, false)) - return false; - } - if (max_basic >= 0xa && !check_leaf(checks, cpu, 0xa, zero)) - return false; - if (max_basic >= 0x15 && !check_leaf(checks, cpu, 0x15, zero)) - return false; - if (max_basic >= 0x16 && !check_leaf(checks, cpu, 0x16, zero)) - return false; - cpuid(0x80000000, 0, value); - if (value[0] < 0x80000007) - return check_failed(checks, "C2", "cpu %d lacks leaf 0x80000007", cpu); - cpuid(0x80000001, 0, value); - if (!check_bit(checks, cpu, "CPUID.80000001:EDX[27] (RDTSCP)", value[3], - 27, true)) - return false; - return check_leaf(checks, cpu, 0x80000007, invariant_tsc); -} - -static int msr_read(int fd, uint32_t msr, uint64_t *value) -{ - ssize_t count = pread(fd, value, sizeof(*value), (off_t)msr); - - if (count == (ssize_t)sizeof(*value)) - return 0; - return count < 0 ? -errno : -EIO; -} - -// C3: the synthetic MSRs. At boot (FULL), the VP index on every CPU and the -// partition-wide MSRs on CPU 0 only; at restore, CPU 0's declared TSC rate. -static bool check_msrs(struct checks *checks, int cpu, bool full) -{ - static const uint32_t faulting[] = {0x40000000, 0x40000001, 0x40000020}; - char path[64]; - uint64_t value = 0; - bool ok = true; - int error = 0; - int fd; - - snprintf(path, sizeof(path), "/dev/cpu/%d/msr", cpu); - fd = open(path, O_RDONLY | O_CLOEXEC); - if (fd < 0) - return check_failed(checks, "C3", "cpu %d: open %s: %s", cpu, path, - strerror(errno)); - if (full && ((error = msr_read(fd, 0x40000002, &value)) != 0 || - value != (uint64_t)cpu)) - ok = check_failed(checks, "C3", - "cpu %d: MSR 0x40000002 (VP index) is %" PRIu64 - " (error %d)", - cpu, value, error); - if (cpu != 0) { - close(fd); - return ok; - } - if (ok && (error = msr_read(fd, 0x40000022, &value)) != 0) - ok = check_failed(checks, "C3", "cpu %d: MSR 0x40000022 read: %s", - cpu, strerror(-error)); - if (ok && checks->tsc_hz != 0 && value != checks->tsc_hz) - ok = check_failed(checks, "C3", - "cpu %d: MSR 0x40000022 is %" PRIu64 - " Hz, expected %" PRIu64 " Hz", - cpu, value, checks->tsc_hz); - if (ok && checks->tsc_hz == 0) - checks->tsc_hz = value; - if (ok && checks->cpu_khz_known[cpu] && - value / 1000 != checks->cpu_khz[cpu]) - ok = check_failed(checks, "C3", - "cpu %d: floor(F / 1000) is %" PRIu64 - " kHz but cpu MHz is %" PRIu64 " kHz", - cpu, value / 1000, checks->cpu_khz[cpu]); - if (ok && full) { - error = msr_read(fd, 0x40000023, &value); - if (error != 0 || (value != 1000000000 && value != 200000000) || - (checks->lapic_hz != 0 && value != checks->lapic_hz)) - ok = check_failed(checks, "C3", - "cpu %d: MSR 0x40000023 (LAPIC rate) is %" PRIu64 - " (error %d)", - cpu, value, error); - else - checks->lapic_hz = value; - } - if (ok && full && - ((error = msr_read(fd, 0x40000118, &value)) != 0 || value != 1)) - ok = check_failed(checks, "C3", - "cpu %d: MSR 0x40000118 is %" PRIu64 " (error %d)", - cpu, value, error); - for (size_t index = 0; ok && full && index < ARRAY_SIZE(faulting); - index++) { - error = msr_read(fd, faulting[index], &value); - if (error != -EIO) - ok = check_failed(checks, "C3", - "cpu %d: MSR 0x%08" PRIx32 - " read did not fail with EIO (error %d)", - cpu, faulting[index], error); - } - close(fd); - return ok; -} - -static bool has_word(const char *list, const char *word) -{ - size_t length = strlen(word); - - for (const char *cursor = list; (cursor = strstr(cursor, word)) != NULL; - cursor += length) { - bool starts = cursor == list || cursor[-1] == ' ' || cursor[-1] == '\t'; - char after = cursor[length]; - - if (starts && (after == '\0' || after == ' ' || after == '\n')) - return true; - } - return false; -} - -// Parses /proc/cpuinfo line by line: records each CPU's `cpu MHz` in kHz and, -// when FLAGS is set, checks C5 on the CPUs in SELECTED. The first C5 failure -// ends the check. -struct cpuinfo_parse { - struct checks *checks; - const bool *selected; - bool flags; - bool failed; - int cpu; - bool seen[MAX_CPUS]; -}; - -static void cpuinfo_init(struct cpuinfo_parse *parse, struct checks *checks, - const bool *selected, bool flags) -{ - memset(parse, 0, sizeof(*parse)); - parse->checks = checks; - parse->selected = selected; - parse->flags = flags; - parse->cpu = -1; -} - -static void cpuinfo_line(void *context, char *line) -{ - static const char *const required[] = { - "tsc", "constant_tsc", "nonstop_tsc", "tsc_known_freq", - "tsc_reliable", "rdtscp", "hypervisor", "arat"}; - static const char *const forbidden[] = {"tsc_deadline_timer", "tsc_adjust"}; - struct cpuinfo_parse *parse = context; - char *colon = strchr(line, ':'); - int cpu = parse->cpu; - - if (parse->failed || colon == NULL) - return; - if (strncmp(line, "processor", 9) == 0) { - cpu = atoi(colon + 1); - parse->cpu = cpu >= 0 && cpu < MAX_CPUS ? cpu : -1; - if (parse->cpu >= 0) - parse->seen[cpu] = true; - } else if (cpu >= 0 && strncmp(line, "cpu MHz", 7) == 0) { - char fraction[4] = "000"; - const char *dot = strchr(colon, '.'); - unsigned long long whole = strtoull(colon + 1, NULL, 10); - - for (int digit = 0; dot != NULL && digit < 3 && - dot[1 + digit] >= '0' && dot[1 + digit] <= '9'; - digit++) - fraction[digit] = dot[1 + digit]; - parse->checks->cpu_khz[cpu] = whole * 1000 + strtoull(fraction, NULL, 10); - parse->checks->cpu_khz_known[cpu] = true; - } else if (cpu >= 0 && parse->flags && parse->selected[cpu] && - strncmp(line, "flags", 5) == 0) { - for (size_t index = 0; index < ARRAY_SIZE(required); index++) { - if (!has_word(colon + 1, required[index])) { - parse->failed = true; - check_failed(parse->checks, "C5", "cpu %d lacks the %s flag", - cpu, required[index]); - return; - } - } - for (size_t index = 0; index < ARRAY_SIZE(forbidden); index++) { - if (has_word(colon + 1, forbidden[index])) { - parse->failed = true; - check_failed(parse->checks, "C5", "cpu %d has the %s flag", cpu, - forbidden[index]); - return; - } - } - } -} - -static bool cpuinfo_result(const struct cpuinfo_parse *parse) -{ - if (parse->failed) - return false; - for (int index = 0; parse->flags && index < MAX_CPUS; index++) { - if (parse->selected[index] && !parse->seen[index]) - return check_failed(parse->checks, "C5", - "cpu %d is not in /proc/cpuinfo", index); - } - return true; -} - -static bool check_cpuinfo_text(struct checks *checks, char *text, - const bool *selected, bool flags) -{ - struct cpuinfo_parse parse; - - cpuinfo_init(&parse, checks, selected, flags); - for_each_text_line(text, cpuinfo_line, &parse); - return cpuinfo_result(&parse); -} - -static bool check_cpuinfo(struct checks *checks, const bool *selected, - bool flags) -{ - struct cpuinfo_parse parse; - - cpuinfo_init(&parse, checks, selected, flags); - if (for_each_file_line("/proc/cpuinfo", cpuinfo_line, &parse) != 0) - return check_failed(checks, "C5", "read /proc/cpuinfo: %s", - strerror(errno)); - return cpuinfo_result(&parse); -} - -// --------------------------------------------------------------------------- -// Exhaustive check (CI only) -// --------------------------------------------------------------------------- - -static int msr_write(int fd, uint32_t msr, uint64_t value) -{ - ssize_t count = pwrite(fd, &value, sizeof(value), (off_t)msr); - - if (count == (ssize_t)sizeof(value)) - return 0; - return count < 0 ? -errno : -EIO; -} - -// The leaves that OpenVMM programs as explicit zeros. -static bool explicit_zero_leaf(uint32_t leaf) -{ - return (leaf >= 0x40000006 && leaf <= 0x4000000f) || - (leaf >= 0x40000080 && leaf <= 0x40000082); -} - -// X1 and X2 for one CPUID result: all zeros or, except for an explicit zero -// leaf, the Intel out-of-range result (the highest basic leaf's result for -// the same subleaf). -static bool exhaustive_leaf_ok(uint32_t leaf, const uint32_t value[4], - const uint32_t out_of_range[4], char *detail, - size_t size) -{ - char signature[13]; - - if (all_zero(value) || - (!explicit_zero_leaf(leaf) && - memcmp(value, out_of_range, 4 * sizeof(uint32_t)) == 0)) - return true; - memcpy(signature, &value[1], 4); - memcpy(signature + 4, &value[2], 4); - memcpy(signature + 8, &value[3], 4); - signature[12] = '\0'; - for (int index = 0; index < 12; index++) { - if (signature[index] < 0x20 || signature[index] > 0x7e) - signature[index] = '.'; - } - snprintf(detail, size, - "leaf 0x%08" PRIx32 " is %08" PRIx32 " %08" PRIx32 " %08" PRIx32 - " %08" PRIx32 " (signature \"%s\"), expected zero%s", - leaf, value[0], value[1], value[2], value[3], signature, - explicit_zero_leaf(leaf) ? "" : " or the highest basic leaf"); - return false; -} - -struct exhaustive { - int failures; - uint64_t tsc_hz; - uint64_t lapic_hz; - struct checks cpuinfo; -}; - -static void exhaustive_report(struct exhaustive *state, const char *id, int cpu, - bool passed, const char *detail) -{ - char escaped[DETAIL_MAX]; - - escape_text(detail, escaped, sizeof(escaped) - 1); - printf("NVX-TIME-ABI-EXHAUSTIVE: v=1 check=%s cpu=%d status=%s " - "detail=\"%s\"\n", - id, cpu, passed ? "pass" : "fail", escaped); - if (!passed) - state->failures++; -} - -static int exhaustive_summary(const struct exhaustive *state, int online) -{ - printf("NVX-TIME-ABI-EXHAUSTIVE: v=1 status=%s cpus=%d failures=%d\n", - state->failures == 0 ? "ok" : "fail", online, state->failures); - return state->failures == 0 ? 0 : 1; -} - -// X1 (every leaf 0x40000006..=0x400000ff) when STEP is 1, or X2 (every base -// 0x40000100..=0x4000ff00) when STEP is 0x100, on the current CPU. -static void exhaustive_leaves(struct exhaustive *state, const char *id, int cpu, - uint32_t first, uint32_t last, uint32_t step, - const uint32_t out_of_range[4]) -{ - char detail[DETAIL_MAX] = ""; - int failing = 0; - int count = 0; - - for (uint32_t leaf = first; leaf <= last; leaf += step, count++) { - char reason[DETAIL_MAX]; - uint32_t value[4]; - - cpuid(leaf, 0, value); - if (!exhaustive_leaf_ok(leaf, value, out_of_range, reason, - sizeof(reason)) && - failing++ == 0) - snprintf(detail, sizeof(detail), "%s", reason); - } - if (failing == 0) { - snprintf(detail, sizeof(detail), "%d %s read zero or the highest basic leaf", - count, step == 1 ? "leaves" : "bases"); - } else if (failing > 1) { - size_t length = strlen(detail); - - snprintf(detail + length, sizeof(detail) - length, "; %d of %d fail", - failing, count); - } - exhaustive_report(state, id, cpu, failing == 0, detail); -} - -// X3: every C3 property on this CPU. -static void exhaustive_x3(struct exhaustive *state, int fd, int cpu) -{ - static const uint32_t faulting[] = {0x40000000, 0x40000001, 0x40000020}; - char detail[DETAIL_MAX]; - uint64_t index = 0; - uint64_t tsc = 0; - uint64_t lapic = 0; - uint64_t control = 0; - uint64_t value; - bool ok = false; - int error; - - if ((error = msr_read(fd, 0x40000002, &index)) != 0 || - index != (uint64_t)cpu) - snprintf(detail, sizeof(detail), - "MSR 0x40000002 (VP index) is %" PRIu64 " (error %d)", index, - error); - else if ((error = msr_read(fd, 0x40000022, &tsc)) != 0 || tsc == 0 || - (state->tsc_hz != 0 && tsc != state->tsc_hz)) - snprintf(detail, sizeof(detail), - "MSR 0x40000022 (TSC rate) is %" PRIu64 - " (error %d; first CPU %" PRIu64 ")", - tsc, error, state->tsc_hz); - else if (state->cpuinfo.cpu_khz_known[cpu] && - tsc / 1000 != state->cpuinfo.cpu_khz[cpu]) - snprintf(detail, sizeof(detail), - "floor(F / 1000) is %" PRIu64 " kHz but cpu MHz is %" PRIu64 - " kHz", - tsc / 1000, state->cpuinfo.cpu_khz[cpu]); - else if ((error = msr_read(fd, 0x40000023, &lapic)) != 0 || - (lapic != 1000000000 && lapic != 200000000) || - (state->lapic_hz != 0 && lapic != state->lapic_hz)) - snprintf(detail, sizeof(detail), - "MSR 0x40000023 (LAPIC rate) is %" PRIu64 - " (error %d; first CPU %" PRIu64 ")", - lapic, error, state->lapic_hz); - else if ((error = msr_read(fd, 0x40000118, &control)) != 0 || control != 1) - snprintf(detail, sizeof(detail), - "MSR 0x40000118 is %" PRIu64 " (error %d)", control, error); - else - ok = true; - for (size_t item = 0; ok && item < ARRAY_SIZE(faulting); item++) { - error = msr_read(fd, faulting[item], &value); - if (error != -EIO) { - ok = false; - snprintf(detail, sizeof(detail), - "MSR 0x%08" PRIx32 " read did not fail with EIO (error %d)", - faulting[item], error); - } - } - if (ok) { - snprintf(detail, sizeof(detail), - "vp_index=%d tsc_hz=%" PRIu64 " lapic_hz=%" PRIu64 - " invariant_control=1", - cpu, tsc, lapic); - if (state->tsc_hz == 0) { - state->tsc_hz = tsc; - state->lapic_hz = lapic; - } - } - exhaustive_report(state, "X3", cpu, ok, detail); -} - -// X4: MSR 0x40000118 accepts 0 and 1, each read back, and rejects 2. It is -// left at 1, the value Linux wrote at boot, whatever the outcome. -static void exhaustive_x4(struct exhaustive *state, int fd, int cpu) -{ - char detail[DETAIL_MAX] = "0 and 1 accepted, 2 rejected, left at 1"; - uint64_t value = 0; - bool ok = true; - int error = 0; - - for (uint64_t written = 0; ok && written <= 1; written++) { - if ((error = msr_write(fd, 0x40000118, written)) != 0 || - (error = msr_read(fd, 0x40000118, &value)) != 0 || - value != written) { - ok = false; - snprintf(detail, sizeof(detail), - "after writing %" PRIu64 ", MSR 0x40000118 is %" PRIu64 - " (error %d)", - written, value, error); - } - } - if (ok && (error = msr_write(fd, 0x40000118, 2)) != -EIO) { - ok = false; - snprintf(detail, sizeof(detail), - "write of 2 to MSR 0x40000118 did not fail with EIO (error %d)", - error); - } - (void)msr_write(fd, 0x40000118, 1); - if (ok && (msr_read(fd, 0x40000118, &value) != 0 || value != 1)) { - ok = false; - snprintf(detail, sizeof(detail), - "MSR 0x40000118 is %" PRIu64 " after the check", value); - } - exhaustive_report(state, "X4", cpu, ok, detail); -} - -// X5: writes to the identity MSRs that are read-only or absent fail. X6: -// reads of IA32_TSC_ADJUST and IA32_TSC_DEADLINE fail. -static void exhaustive_faults(struct exhaustive *state, int fd, int cpu, - bool writes) -{ - static const uint32_t read_only[] = {0x40000000, 0x40000001, 0x40000002, - 0x40000020, 0x40000022, 0x40000023}; - static const uint32_t absent[] = {0x3b, 0x6e0}; - const uint32_t *list = writes ? read_only : absent; - size_t length = writes ? ARRAY_SIZE(read_only) : ARRAY_SIZE(absent); - char detail[DETAIL_MAX]; - bool ok = true; - - snprintf(detail, sizeof(detail), "%zu %s fail with EIO", length, - writes ? "writes" : "reads"); - for (size_t item = 0; ok && item < length; item++) { - uint64_t value = 0; - int error = writes ? msr_write(fd, list[item], 0) - : msr_read(fd, list[item], &value); - - if (error != -EIO) { - ok = false; - snprintf(detail, sizeof(detail), - "%s MSR 0x%" PRIx32 " did not fail with EIO (error %d)", - writes ? "write of 0 to" : "read of", list[item], error); - } - } - exhaustive_report(state, writes ? "X5" : "X6", cpu, ok, detail); -} - -// The CI-only exhaustive check: X1 to X6 on every online CPU. It reports and -// exits, never powers off, and production boots never run it. -static int exhaustive_run(void) -{ - static const char *const ids[] = {"X1", "X2", "X3", "X4", "X5", "X6"}; - struct cpuinfo_parse parse; - struct exhaustive state; - bool cpus[MAX_CPUS]; - cpu_set_t original; - char text[256]; - int online; - - memset(&state, 0, sizeof(state)); - if (read_text("/sys/devices/system/cpu/online", text, sizeof(text)) < 0 || - (online = parse_cpu_list(text, cpus)) <= 0) { - state.failures = 1; - return exhaustive_summary(&state, 0); - } - cpuinfo_init(&parse, &state.cpuinfo, cpus, false); - (void)for_each_file_line("/proc/cpuinfo", cpuinfo_line, &parse); - if (sched_getaffinity(0, sizeof(original), &original) != 0) - CPU_ZERO(&original); - for (int cpu = 0; cpu < MAX_CPUS; cpu++) { - char detail[DETAIL_MAX]; - uint32_t basic[4]; - uint32_t out_of_range[4]; - char path[64]; - int fd; - - if (!cpus[cpu]) - continue; - if (pin_to_cpu(cpu) != 0) { - for (size_t item = 0; item < ARRAY_SIZE(ids); item++) - exhaustive_report(&state, ids[item], cpu, false, - "cannot run on this CPU"); - continue; - } - cpuid(0, 0, basic); - cpuid(basic[0], 0, out_of_range); - exhaustive_leaves(&state, "X1", cpu, 0x40000006, 0x400000ff, 1, - out_of_range); - exhaustive_leaves(&state, "X2", cpu, 0x40000100, 0x4000ff00, 0x100, - out_of_range); - snprintf(path, sizeof(path), "/dev/cpu/%d/msr", cpu); - fd = open(path, O_RDWR | O_CLOEXEC); - if (fd < 0) { - snprintf(detail, sizeof(detail), "open %s: %s", path, - strerror(errno)); - for (size_t item = 2; item < ARRAY_SIZE(ids); item++) - exhaustive_report(&state, ids[item], cpu, false, detail); - continue; - } - exhaustive_x3(&state, fd, cpu); - exhaustive_x4(&state, fd, cpu); - exhaustive_faults(&state, fd, cpu, true); - exhaustive_faults(&state, fd, cpu, false); - close(fd); - } - if (CPU_COUNT(&original) > 0) - (void)sched_setaffinity(0, sizeof(original), &original); - return exhaustive_summary(&state, online); -} - -// C6: `tsc` is current, and only tsc, refined-jiffies, or jiffies is -// available. -static bool check_clocksource_values(struct checks *checks, const char *current, - char *available) -{ - bool tsc = false; - - if (strncmp(current, "tsc", 3) != 0 || - (current[3] != '\0' && current[3] != '\n')) - return check_failed(checks, "C6", "current clocksource is %.*s", - (int)strcspn(current, "\n"), current); - for (char *token = strtok(available, " \n"); token != NULL; - token = strtok(NULL, " \n")) { - if (strcmp(token, "tsc") == 0) - tsc = true; - else if (strcmp(token, "refined-jiffies") != 0 && - strcmp(token, "jiffies") != 0) - return check_failed(checks, "C6", "clocksource %.64s is available", - token); - } - if (!tsc) - return check_failed(checks, "C6", "tsc is not an available clocksource"); - return true; -} - -static bool check_clocksource(struct checks *checks) -{ - char current[64]; - char available[256]; - - if (read_text(CLOCKSOURCE_DIR "/current_clocksource", current, - sizeof(current)) < 0 || - read_text(CLOCKSOURCE_DIR "/available_clocksource", available, - sizeof(available)) < 0) - return check_failed(checks, "C6", "clocksource sysfs: %s", - strerror(errno)); - return check_clocksource_values(checks, current, available); -} - -struct tick_section { - bool seen; - int mode; - int state; - char device[48]; - char handler[48]; -}; - -// Line-by-line /proc/timer_list parser. The file lists every active hrtimer -// before the tick devices, so its size depends on the workload. -struct timer_list_parse { - struct tick_section cpus[MAX_CPUS]; - struct tick_section broadcast; - struct tick_section *current; - int tick_mode; -}; - -static void timer_list_init(struct timer_list_parse *parse) -{ - static const struct tick_section empty = {false, -1, -1, "", ""}; - - for (int index = 0; index < MAX_CPUS; index++) - parse->cpus[index] = empty; - parse->broadcast = empty; - parse->current = NULL; - parse->tick_mode = -1; -} - -static void timer_list_line(void *context, char *line) -{ - struct timer_list_parse *parse = context; - struct tick_section *current = parse->current; - char name[48]; - int value; - - if (sscanf(line, "Tick Device: mode: %d", &value) == 1) { - parse->tick_mode = value; - parse->current = NULL; - } else if (strncmp(line, "Broadcast device", 16) == 0) { - parse->current = &parse->broadcast; - parse->current->seen = true; - parse->current->mode = parse->tick_mode; - } else if (sscanf(line, "Per CPU device: %d", &value) == 1) { - parse->current = - value >= 0 && value < MAX_CPUS ? &parse->cpus[value] : NULL; - if (parse->current != NULL) { - parse->current->seen = true; - parse->current->mode = parse->tick_mode; - } - } else if (current != NULL && - sscanf(line, "Clock Event Device: %47s", name) == 1) { - snprintf(current->device, sizeof(current->device), "%s", name); - } else if (current != NULL && sscanf(line, " mode: %d", &value) == 1) { - current->state = value; - } else if (current != NULL && - sscanf(line, " event_handler: %47s", name) == 1) { - snprintf(current->handler, sizeof(current->handler), "%s", name); - } -} - -// C7 verdict: every CPU in SELECTED ticks with the LAPIC in one-shot mode -// through hrtimer_interrupt, and no broadcast device exists. -static bool timer_list_result(const struct timer_list_parse *parse, - const bool *selected, char *detail, size_t size) -{ - if (parse->broadcast.seen && - strcmp(parse->broadcast.device, "") != 0) { - snprintf(detail, size, "broadcast clock event device is %s", - parse->broadcast.device); - return false; - } - for (int cpu = 0; cpu < MAX_CPUS; cpu++) { - const struct tick_section *section = &parse->cpus[cpu]; - - if (!selected[cpu]) - continue; - if (!section->seen || strcmp(section->device, "lapic") != 0 || - strcmp(section->handler, "hrtimer_interrupt") != 0 || - section->mode != 1 || (section->state != 3 && section->state != 4)) { - snprintf(detail, size, - "cpu %d tick device %s handler %s tick mode %d state %d", - cpu, section->seen ? section->device : "missing", - section->handler[0] != '\0' ? section->handler : "none", - section->mode, section->state); - return false; - } - } - return true; -} - -static bool timer_list_verdict(char *text, const bool *selected, char *detail, - size_t size) -{ - static struct timer_list_parse parse; - - timer_list_init(&parse); - for_each_text_line(text, timer_list_line, &parse); - return timer_list_result(&parse, selected, detail, size); -} - -// Polls C7 for at most WAIT_NS, because a CPU switches to one-shot mode at -// its first tick. A zero wait checks once. -static bool check_timer_list(struct checks *checks, int64_t wait_ns) -{ - static struct timer_list_parse parse; - char detail[DETAIL_MAX] = ""; - int64_t deadline = clock_ns(CLOCK_MONOTONIC) + wait_ns; - - for (;;) { - timer_list_init(&parse); - if (for_each_file_line("/proc/timer_list", timer_list_line, &parse) != - 0) - return check_failed(checks, "C7", "read /proc/timer_list: %s", - strerror(errno)); - if (timer_list_result(&parse, checks->online, detail, sizeof(detail))) - return true; - if (clock_ns(CLOCK_MONOTONIC) >= deadline) - return check_failed(checks, "C7", "%s", detail); - nanosleep(&(struct timespec){0, 10 * NSEC_PER_MSEC}, NULL); - } -} - -// C8: no VMBus, no cpufreq, and the production RCU stall settings. -static bool check_platform(struct checks *checks) -{ - struct stat info; - int64_t value = -1; - - if (stat("/sys/bus/vmbus", &info) == 0) - return check_failed(checks, "C8", "/sys/bus/vmbus exists"); - if (stat("/sys/devices/system/cpu/cpufreq/policy0", &info) == 0) - return check_failed(checks, "C8", "cpufreq policy0 exists"); - if (read_int64(RCU_STALL_SUPPRESS, &value) != 0 || value != 0) - return check_failed(checks, "C8", "rcu_cpu_stall_suppress is %" PRId64, - value); - if (read_int64(RCU_STALL_TIMEOUT, &value) != 0 || value != 21) - return check_failed(checks, "C8", "rcu_cpu_stall_timeout is %" PRId64, - value); - return true; -} - -// C9: no clock workaround on the kernel command line. -static bool check_cmdline_text(struct checks *checks, char *cmdline) -{ - static const char *const prefixes[] = {"tsc_early_khz=", "lapic_timer_hz="}; - static const char *const tokens[] = {"notsc", "nolapic", "nolapic_timer", - "tsc=unstable", "hpet=force"}; - - for (char *token = strtok(cmdline, " \t\n"); token != NULL; - token = strtok(NULL, " \t\n")) { - bool bad = strncmp(token, "clocksource=", 12) == 0 && - strcmp(token + 12, "tsc") != 0; - - for (size_t index = 0; index < ARRAY_SIZE(prefixes); index++) - bad = bad || strncmp(token, prefixes[index], - strlen(prefixes[index])) == 0; - for (size_t index = 0; index < ARRAY_SIZE(tokens); index++) - bad = bad || strcmp(token, tokens[index]) == 0; - if (bad) - return check_failed(checks, "C9", "command line contains %.120s", - token); - } - return true; -} - -static bool check_cmdline(struct checks *checks) -{ - char text[4096]; - - if (read_text("/proc/cmdline", text, sizeof(text)) < 0) - return check_failed(checks, "C9", "read /proc/cmdline: %s", - strerror(errno)); - return check_cmdline_text(checks, text); -} - -static pid_t daemon_pid(void) -{ - char path[64]; - char comm[32]; - int64_t pid; - - if (read_int64(DAEMON_PID_PATH, &pid) != 0 || pid <= 1 || pid > INT_MAX || - kill((pid_t)pid, 0) != 0) - return -1; - snprintf(path, sizeof(path), "/proc/%" PRId64 "/comm", pid); - if (read_text(path, comm, sizeof(comm)) < 0 || - strcmp(comm, "nvx-time\n") != 0) - return -1; - return (pid_t)pid; -} - -// The asynchronous boot checks and repair step 7 start at idle priority, so -// that on few vCPUs they never take a CPU from the workload, and continue at -// normal priority after IDLE_BOUND_NS; the watcher and the discipline keep -// normal priority. musl does not implement sched_setscheduler(), so this uses -// the per-thread system call: PID 0 is the calling thread. -static void set_idle_priority(pid_t pid, bool idle) -{ - struct sched_param param = {0}; - - (void)syscall(SYS_sched_setscheduler, pid, idle ? SCHED_IDLE : SCHED_OTHER, - ¶m); -} - -// Raises every thread of this process to normal priority, twice, so that a -// thread an idle thread created during the first pass is raised too. -static void promote_threads(void) -{ - for (int pass = 0; pass < 2; pass++) { - DIR *tasks = opendir("/proc/self/task"); - struct dirent *entry; - - if (tasks == NULL) - return; - while ((entry = readdir(tasks)) != NULL) { - long tid = strtol(entry->d_name, NULL, 10); - - if (tid > 0) - set_idle_priority((pid_t)tid, false); - } - closedir(tasks); - } -} - -// A normal-priority thread that raises the boot checker's idle threads when -// the checks still run IDLE_BOUND_NS after they started. -struct promoter { - pthread_t thread; - pthread_mutex_t lock; - pthread_cond_t stop; - struct timespec deadline; - bool stopped; -}; - -static void *promoter_run(void *argument) -{ - struct promoter *promoter = argument; - bool stopped; - int result = 0; - - pthread_mutex_lock(&promoter->lock); - while (!promoter->stopped && result == 0) - result = pthread_cond_timedwait(&promoter->stop, &promoter->lock, - &promoter->deadline); - stopped = promoter->stopped; - pthread_mutex_unlock(&promoter->lock); - if (!stopped) - promote_threads(); - return NULL; -} - -// Starts PROMOTER, at the caller's priority, for checks that started at -// START_NS. Returns true when it runs. -static bool promoter_start(struct promoter *promoter, int64_t start_ns) -{ - int64_t deadline = start_ns + IDLE_BOUND_NS; - pthread_condattr_t attributes; - bool ready; - - promoter->stopped = false; - promoter->deadline.tv_sec = deadline / NSEC_PER_SEC; - promoter->deadline.tv_nsec = deadline % NSEC_PER_SEC; - if (pthread_condattr_init(&attributes) != 0) - return false; - ready = pthread_condattr_setclock(&attributes, CLOCK_MONOTONIC) == 0 && - pthread_cond_init(&promoter->stop, &attributes) == 0; - pthread_condattr_destroy(&attributes); - return ready && pthread_mutex_init(&promoter->lock, NULL) == 0 && - pthread_create(&promoter->thread, NULL, promoter_run, promoter) == - 0; -} - -static void promoter_stop(struct promoter *promoter) -{ - pthread_mutex_lock(&promoter->lock); - promoter->stopped = true; - pthread_cond_signal(&promoter->stop); - pthread_mutex_unlock(&promoter->lock); - pthread_join(promoter->thread, NULL); -} - -// Repair steps 6 and 7 on the readiness path: the acknowledgement when the -// host gated its input, then hand step 7 to DAEMON. The sigqueue() value is -// the readiness path's wall time since START_NS (CLOCK_MONOTONIC), in -// microseconds clamped to 32 bits, read after the acknowledgement so that the -// read does not delay it. The readiness path's CPU time is not reported: the -// restore latency gate measures that path. Returns -1, without acknowledging, -// when no daemon runs; 1 when the daemon vanished after the acknowledgement. -// The caller has enabled the ports when ACK is set. -static int signal_restore(pid_t daemon, bool ack, int64_t start_ns) -{ - union sigval value; - int64_t elapsed_us; - - if (daemon <= 0 || kill(daemon, 0) != 0) - return -1; - if (ack) - outb(SNAPSHOT_ACKNOWLEDGE, PORT_SNAPSHOT); - elapsed_us = (clock_ns(CLOCK_MONOTONIC) - start_ns) / NSEC_PER_USEC; - if (elapsed_us > UINT32_MAX) - elapsed_us = UINT32_MAX; - value.sival_ptr = (void *)(uintptr_t)(elapsed_us < 0 ? 0 : elapsed_us); - return sigqueue(daemon, SIGUSR1, value) == 0 ? 0 : 1; -} - -// C10: the daemon runs, has recorded no violation, and no stall was counted. -static bool check_daemon(struct checks *checks) -{ - struct time_state state; - int64_t stalls = -1; - - if (daemon_pid() < 0) - return check_failed(checks, "C10", "the time daemon is not running"); - if (state_load(&state) != 0) - return check_failed(checks, "C10", "time state is unreadable"); - if (state.violations != 0) - return check_failed(checks, "C10", - "the time daemon recorded %" PRIu64 " violations", - state.violations); - if (read_int64(RCU_STALL_COUNT, &stalls) != 0 || stalls != 0) - return check_failed(checks, "C10", "rcu_stall_count is %" PRId64, - stalls); - return true; -} - -// C11: on the CI debug kernel, the soft-lockup and hung-task detectors run. -static bool check_debug_watchdogs(struct checks *checks) -{ - bool soft = access(SOFT_WATCHDOG, F_OK) == 0; - bool hung = access(HUNG_TASK_TIMEOUT, F_OK) == 0; - int64_t value = -1; - - if (!soft && !hung) - return true; - if (!soft || read_int64(SOFT_WATCHDOG, &value) != 0 || value != 1) - return check_failed(checks, "C11", "soft_watchdog is %" PRId64, value); - if (!hung || read_int64(HUNG_TASK_TIMEOUT, &value) != 0 || value == 0) - return check_failed(checks, "C11", - "hung_task_timeout_secs is %" PRId64, value); - return true; -} - -// C1, C2, and C3 on every CPU in SELECTED, pinned to each in turn. The -// three checks are independent, so a report-only run shows each failure; -// the loop stops at the first CPU that fails. -static bool check_cpus(struct checks *checks, const bool *selected, - bool identity, bool msrs) -{ - cpu_set_t original; - bool ok = true; - - if (sched_getaffinity(0, sizeof(original), &original) != 0) - return check_failed(checks, "C1", "sched_getaffinity: %s", - strerror(errno)); - for (int cpu = 0; ok && cpu < MAX_CPUS; cpu++) { - if (!selected[cpu]) - continue; - if (pin_to_cpu(cpu) != 0) { - ok = check_failed(checks, "C1", "cannot run on cpu %d: %s", cpu, - strerror(errno)); - break; - } - if (identity) { - if (!check_identity(checks, cpu)) - ok = false; - if (!check_time_bits(checks, cpu)) - ok = false; - } - if (msrs && !check_msrs(checks, cpu, true)) - ok = false; - } - (void)sched_setaffinity(0, sizeof(original), &original); - return ok; -} - -struct cpu_job { - pthread_t thread; - struct checks checks; - bool identity; - bool msrs; - bool running; - int cpu; -}; - -static void *cpu_job_run(void *argument) -{ - struct cpu_job *job = argument; - - if (pin_to_cpu(job->cpu) != 0) { - check_failed(&job->checks, "C1", "cannot run on cpu %d: %s", job->cpu, - strerror(errno)); - return NULL; - } - if (job->identity) { - check_identity(&job->checks, job->cpu); - check_time_bits(&job->checks, job->cpu); - } - if (job->msrs) - check_msrs(&job->checks, job->cpu, true); - return NULL; -} - -// A boot check that runs in its own thread beside the per-CPU checks, on a -// private copy of the checks. -struct side_job { - pthread_t thread; - struct checks checks; - void (*run)(struct checks *checks, void *context); - void *context; - bool running; -}; - -static void *side_job_run(void *argument) -{ - struct side_job *job = argument; - - job->run(&job->checks, job->context); - return NULL; -} - -// check_cpus() with every CPU in its own thread pinned to it, beside the -// SIDES checks, each in a thread of its own, while the caller runs FOREGROUND -// (when set). Running them in parallel keeps the cost from growing with the -// CPU count and leaves the caller on its warm CPU. The threads share one -// stack mapping, so creating one maps nothing. Each job works on a copy of -// CHECKS whose failures are added back; a job whose thread cannot start runs -// in the caller afterwards. -static void check_cpus_parallel(struct checks *checks, const bool *selected, - bool identity, bool msrs, - struct side_job *sides, int side_count, - void (*foreground)(struct checks *, void *), - void *foreground_context) -{ - enum { STACK_SIZE = 128 * 1024 }; - static struct cpu_job jobs[MAX_CPUS]; - bool serial[MAX_CPUS] = {false}; - bool any_serial = false; - pthread_attr_t attributes; - pthread_attr_t *attr = NULL; - size_t threads = (size_t)side_count; - size_t next = 0; - uint8_t *stacks; - - for (int cpu = 0; cpu < MAX_CPUS; cpu++) - threads += selected[cpu] ? 1 : 0; - // With one CPU, threads only add their own cost. - if (threads <= (size_t)side_count + 1) { - if (foreground != NULL) - foreground(checks, foreground_context); - for (int index = 0; index < side_count; index++) { - struct side_job *side = &sides[index]; - - side->checks = *checks; - side->checks.failures = 0; - side->run(&side->checks, side->context); - checks->failures += side->checks.failures; - } - check_cpus(checks, selected, identity, msrs); - return; - } - stacks = mmap(NULL, threads * STACK_SIZE, PROT_READ | PROT_WRITE, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (stacks != MAP_FAILED && pthread_attr_init(&attributes) == 0) - attr = &attributes; - for (int index = 0; index < side_count; index++) { - struct side_job *side = &sides[index]; - - side->checks = *checks; - side->checks.failures = 0; - if (attr != NULL) - pthread_attr_setstack(attr, stacks + STACK_SIZE * next++, - STACK_SIZE); - side->running = - pthread_create(&side->thread, attr, side_job_run, side) == 0; - } - for (int cpu = 0; cpu < MAX_CPUS; cpu++) { - struct cpu_job *job = &jobs[cpu]; - - job->running = false; - if (!selected[cpu]) - continue; - job->checks = *checks; - job->checks.failures = 0; - job->identity = identity; - job->msrs = msrs; - job->cpu = cpu; - if (attr != NULL) - pthread_attr_setstack(attr, stacks + STACK_SIZE * next++, - STACK_SIZE); - if (pthread_create(&job->thread, attr, cpu_job_run, job) == 0) - job->running = true; - else - serial[cpu] = any_serial = true; - } - if (foreground != NULL) - foreground(checks, foreground_context); - for (int index = 0; index < side_count; index++) { - struct side_job *side = &sides[index]; - - if (side->running) - pthread_join(side->thread, NULL); - else - side->run(&side->checks, side->context); - checks->failures += side->checks.failures; - } - for (int cpu = 0; cpu < MAX_CPUS; cpu++) { - struct cpu_job *job = &jobs[cpu]; - - if (!job->running) - continue; - pthread_join(job->thread, NULL); - checks->failures += job->checks.failures; - // C3 reads the partition-wide rates on CPU 0 only. - if (cpu == 0 && msrs) { - checks->tsc_hz = job->checks.tsc_hz; - checks->lapic_hz = job->checks.lapic_hz; - } - } - if (attr != NULL) - pthread_attr_destroy(attr); - if (stacks != MAP_FAILED) - munmap(stacks, threads * STACK_SIZE); - if (any_serial) - check_cpus(checks, serial, identity, msrs); -} -// --------------------------------------------------------------------------- -// Host time samples and clock adjustment -// --------------------------------------------------------------------------- - -// Latches a sample with selector 0xa7 and reads it from 0xeb. CLOCK_REALTIME -// brackets the selector write, so the host instant lies in [t0, t1]. The -// caller holds the portb lock. -static int take_sample(struct time_sample *sample, char *detail, size_t size) -{ - uint8_t bytes[SAMPLE_SIZE]; - - sample->t0 = clock_ns(CLOCK_REALTIME); - outb(SELECT_TIME_SAMPLE, PORTB_STATUS); - sample->t1 = clock_ns(CLOCK_REALTIME); - portb_read(PORTB_WINDOW, bytes, sizeof(bytes)); - return parse_sample(bytes, sample, detail, size); -} - -static int lock_portb(char *detail, size_t size) -{ - int lock = lock_file(PORTB_LOCK_PATH); - - if (lock < 0) - snprintf(detail, size, "portb lock: %s", strerror(errno)); - return lock; -} - -// Takes up to ATTEMPTS samples of generation G and keeps the first whose -// uncertainty is within BOUND_NS. Returns 0 when one is, 1 when only wider -// samples were seen (the narrowest is returned), and -1 when none was valid -// or the VMM has no time-sample window. A sample from another generation -// stops the search with -2. The caller holds the portb lock. -static int best_sample(int attempts, int64_t bound_ns, uint32_t generation, - struct time_sample *best, int64_t *theta, - int64_t *epsilon, char *detail, size_t size) -{ - unsigned status; - int result = -1; - - *epsilon = INT64_MAX; - if (enable_ports() != 0) { - snprintf(detail, size, "ioperm: %s", strerror(errno)); - return -1; - } - status = inb(PORTB_STATUS); - if ((status & PORTB_TIME_WINDOW) == 0) { - snprintf(detail, size, "portb status 0x%02x has no time-sample window", - status); - return -1; - } - for (int attempt = 0; attempt < attempts; attempt++) { - struct time_sample sample; - int64_t sample_theta; - int64_t sample_epsilon; - - if (take_sample(&sample, detail, size) != 0) - continue; - if (sample.generation != generation) { - snprintf(detail, size, - "time sample generation %" PRIu32 ", expected %" PRIu32, - sample.generation, generation); - return -2; - } - pair_clocks(sample.t0, sample.t1, sample.utc_ns, &sample_theta, - &sample_epsilon); - if (sample_epsilon < *epsilon) { - *best = sample; - *theta = sample_theta; - *epsilon = sample_epsilon; - result = 1; - } - if (sample_epsilon <= bound_ns) - return 0; - snprintf(detail, size, - "time sample uncertainty %" PRId64 " ns exceeds %" PRId64 - " ns", - sample_epsilon, bound_ns); - } - return result; -} - -static int step_clock(int64_t offset_ns) -{ - struct timex tx; - - memset(&tx, 0, sizeof(tx)); - tx.modes = ADJ_SETOFFSET | ADJ_NANO; - offset_to_timeval(offset_ns, &tx.time); - return adjtimex(&tx) < 0 ? -1 : 0; -} - -static int set_frequency(int64_t frequency) -{ - struct timex tx; - - memset(&tx, 0, sizeof(tx)); - tx.modes = ADJ_FREQUENCY | ADJ_STATUS; - tx.freq = (long)frequency; - tx.status = STA_PLL | STA_NANO; - return adjtimex(&tx) < 0 ? -1 : 0; -} - -static int64_t current_frequency(void) -{ - struct timex tx; - - memset(&tx, 0, sizeof(tx)); - return adjtimex(&tx) < 0 ? 0 : (int64_t)tx.freq; -} - -// C12: one sample within 1 ms (three attempts), then a clock step. The boot -// step is not a discontinuity. -static bool check_initial_sample(struct checks *checks, int64_t *theta, - int64_t *epsilon) -{ - struct time_sample sample; - char detail[DETAIL_MAX] = "no valid time sample"; - int result; - int lock; - - lock = lock_portb(detail, sizeof(detail)); - if (lock < 0) - return check_failed(checks, "C12", "%s", detail); - result = best_sample(SAMPLE_ATTEMPTS, BOOT_BOUND_NS, g_generation, &sample, - theta, epsilon, detail, sizeof(detail)); - close(lock); - if (result != 0) - return check_failed(checks, "C12", "%s", detail); - if (step_clock(*theta) != 0) - return check_failed(checks, "C12", "adjtimex step: %s", - strerror(errno)); - return true; -} - -// --------------------------------------------------------------------------- -// Daemon: violation watcher, wall-clock discipline, restore completion -// --------------------------------------------------------------------------- - -struct daemon { - int kmsg; - int signals; - int timer; - int polls; - int64_t last_accepted_ns; - pid_t worker_pid; - int64_t worker_promote_ns; - int64_t worker_deadline_ns; - bool suppression_released; - bool restore_pending; - int64_t restore_due_ns; - int64_t readiness_us; -}; - -static void watcher_consume(void *context, const char *message) -{ - const char *code = match_watch_rule(message); - - (void)context; - if (code != NULL) - fail_fatal(STATUS_VIOLATION, code, "watcher", PHASE_RUNTIME, "%.400s", - message); -} - -static void watch_kernel_log(struct daemon *daemon) -{ - if (kmsg_drain(daemon->kmsg, watcher_consume, daemon) == -EPIPE) - fail_fatal(STATUS_VIOLATION, "G_KMSG_OVERRUN", "watcher", - PHASE_RUNTIME, "kernel log records were lost (EPIPE)"); -} - -static int arm_timer(struct daemon *daemon) -{ - struct itimerspec period; - - memset(&period, 0, sizeof(period)); - period.it_value.tv_sec = - daemon->polls < FAST_POLLS ? FAST_PERIOD_S : SLOW_PERIOD_S; - return timerfd_settime(daemon->timer, 0, &period, NULL); -} - -struct poll_outcome { - bool accepted; - bool stepped; - bool synchronized; - int64_t theta; - int64_t epsilon; - int64_t step_realtime_ns; - int64_t frequency; -}; - -static void apply_poll(struct time_state *state, const void *context) -{ - const struct poll_outcome *outcome = context; - - if (outcome->accepted) { - state->samples++; - state->offset_ns = outcome->theta; - state->uncertainty_ns = outcome->epsilon; - state->frequency_ppb = frequency_to_ppb(outcome->frequency); - snprintf(state->last_sample_error, sizeof(state->last_sample_error), - "none"); - if (outcome->stepped) { - state->discontinuities++; - snprintf(state->last_discontinuity, - sizeof(state->last_discontinuity), "step"); - state->last_step_ns = outcome->theta; - state->last_step_realtime_ns = outcome->step_realtime_ns; - } - } else { - state->rejected_samples++; - snprintf(state->last_sample_error, sizeof(state->last_sample_error), - "G_SAMPLE_UNCERTAIN"); - } - state->synchronized = outcome->synchronized; -} - -// One discipline poll: the RCU stall backstop, then up to three samples. A -// step reapplies the frequency and status that the step's NTP reset clears. -// The poll holds the portb lock from its first sample to the published state, -// as a capture does from its request to the restored clock, so a snapshot -// never contains a half-applied poll: a poll blocked behind a capture samples -// after the restore, sees the new generation, and is discarded. -static void discipline_poll(struct daemon *daemon) -{ - struct poll_outcome outcome; - struct time_sample sample; - char detail[DETAIL_MAX] = ""; - bool fast = daemon->polls < FAST_POLLS; - int64_t stalls = 0; - int result; - int lock; - - memset(&outcome, 0, sizeof(outcome)); - if (read_int64(RCU_STALL_COUNT, &stalls) == 0 && stalls != 0) - fail_fatal(STATUS_VIOLATION, "G_RCU_STALL", "watcher", PHASE_RUNTIME, - "rcu_stall_count is %" PRId64, stalls); - daemon->polls++; - lock = lock_portb(detail, sizeof(detail)); - result = lock < 0 ? -1 - : best_sample(SAMPLE_ATTEMPTS, POLL_BOUND_NS, - g_generation, &sample, &outcome.theta, - &outcome.epsilon, detail, sizeof(detail)); - if (result == -2) { - close(lock); - return; - } - if (result == 0) { - struct timex tx; - int64_t frequency = current_frequency(); - - outcome.stepped = plan_correction(outcome.theta, outcome.epsilon, fast, - &tx); - outcome.accepted = adjtimex(&tx) >= 0 && - (!outcome.stepped || set_frequency(frequency) == 0); - outcome.step_realtime_ns = clock_ns(CLOCK_REALTIME); - outcome.frequency = current_frequency(); - if (outcome.accepted) - daemon->last_accepted_ns = clock_ns(CLOCK_MONOTONIC); - } - outcome.synchronized = - daemon->last_accepted_ns != 0 && - clock_ns(CLOCK_MONOTONIC) - daemon->last_accepted_ns <= - SYNCHRONIZED_AGE_NS; - (void)state_apply(apply_poll, &outcome); - if (lock >= 0) - close(lock); -} - -// Waits for a full normal RCU grace period: with rcu_normal set, the -// synchronize_rcu_expedited() in every unmount waits for one that starts -// after the call, on any number of CPUs. The private mount namespace makes the -// caller a process of its own: the restore worker, or a child. Returns 0 when -// the release completed. -static int grace_period_release(void) -{ - int64_t normal = 0; - bool saved = read_int64(RCU_NORMAL, &normal) == 0 && - write_int64(RCU_NORMAL, 1) == 0; - int status = 1; - - if (saved && unshare(CLONE_NEWNS) == 0 && - mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL) == 0) { - (void)mkdir(RCU_SYNC_PATH, 0700); - if (mount("nvx-rcu-sync", RCU_SYNC_PATH, "tmpfs", - MS_NOSUID | MS_NODEV | MS_NOEXEC, "size=4k") == 0 && - umount2(RCU_SYNC_PATH, 0) == 0) - status = 0; - (void)rmdir(RCU_SYNC_PATH); - } - if (saved && write_int64(RCU_NORMAL, normal) != 0) - status = 1; - return status; -} - -// Runs the grace-period release in a child and waits at most TIMEOUT_S -// seconds for it, for callers without the daemon's event loop. A release -// still pending at the deadline counts as done: the grace period is stuck, -// and the kernel then reports the real stall. -static bool release_with_deadline(int64_t timeout_s) -{ - int64_t deadline = clock_ns(CLOCK_MONOTONIC) + timeout_s * NSEC_PER_SEC; - sigset_t mask; - sigset_t previous; - bool released = false; - pid_t pid; - - sigemptyset(&mask); - sigaddset(&mask, SIGCHLD); - sigprocmask(SIG_BLOCK, &mask, &previous); - pid = fork(); - if (pid == 0) { - sigprocmask(SIG_SETMASK, &previous, NULL); - _exit(grace_period_release()); - } - while (pid > 0) { - int status; - pid_t done = waitpid(pid, &status, WNOHANG); - int64_t remaining = deadline - clock_ns(CLOCK_MONOTONIC); - - if (done == pid) { - released = WIFEXITED(status) && WEXITSTATUS(status) == 0; - break; - } - if (done < 0 && errno != EINTR) - break; - if (remaining <= 0) { - released = true; - break; - } - (void)sigtimedwait(&mask, NULL, - &(struct timespec){remaining / NSEC_PER_SEC, - remaining % NSEC_PER_SEC}); - } - sigprocmask(SIG_SETMASK, &previous, NULL); - return released; -} - -static void run_restore_checks(struct checks *checks, const bool *new_cpus, - int new_count); - -static int parse_new_cpus(const char *text, bool *cpus) -{ - if (strcmp(text, "") == 0 || strcmp(text, "none") == 0) { - memset(cpus, 0, sizeof(bool) * MAX_CPUS); - return 0; - } - return parse_cpu_list(text, cpus); -} - -// Parses the restore record that restore-finish writes for the daemon: the -// generation it finished and the CPUs that activation brought online. -// Returns the number of those CPUs, or -1 when the record is malformed. -static int parse_restore_record(const char *text, unsigned long long *generation, - bool *new_cpus) -{ - char cpus[128] = "none"; - - if (sscanf(text, "generation=%llu new_cpus=%127s", generation, cpus) < 1) - return -1; - return parse_new_cpus(cpus, new_cpus); -} - -// The times that restore_work records. The wall time is READINESS_US, the -// readiness path's, plus the checks' since START_NS, when they were due. The -// CPU time is repair step 7's alone: this process's since CPU_BASE_NS, plus -// the daemon's preparation, PRIOR_CPU_NS up to the fork and the fork's own -// share, which the daemon writes to FORK_FD (-1 when there is none) once the -// fork returns. -struct restore_timing { - int64_t start_ns; - int64_t readiness_us; - int64_t prior_cpu_ns; - int64_t cpu_base_ns; - int fork_fd; -}; - -// Repair step 7's CPU time so far, in a process that TIMING describes. The -// read waits for the daemon's fork share if the daemon has not yet written it. -static int64_t deferred_restore_cpu_ns(const struct restore_timing *timing) -{ - int64_t cpu = timing->prior_cpu_ns + cpu_time_ns() - timing->cpu_base_ns; - int64_t fork_share = 0; - - if (timing->fork_fd >= 0 && - read(timing->fork_fd, &fork_share, sizeof(fork_share)) == - sizeof(fork_share) && - fork_share > 0) - cpu += fork_share; - return cpu; -} - -// Forks the repair step 7 worker at SCHED_IDLE for a daemon whose preparation -// started at CPU_START_NS, its CPU clock, and completes TIMING's CPU fields -// for the child. The parent returns to normal priority as soon as the fork -// does. Its fork costs CPU time after the child's copy of TIMING was taken, so -// it writes that share to a pipe that the child reads. Returns fork()'s -// result. -static pid_t fork_deferred_restore(int64_t cpu_start_ns, - struct restore_timing *timing) -{ - int fds[2] = {-1, -1}; - int64_t fork_share; - pid_t pid; - - if (pipe2(fds, O_CLOEXEC) != 0) - fds[0] = fds[1] = -1; - timing->fork_fd = fds[0]; - timing->cpu_base_ns = 0; - set_idle_priority(0, true); - timing->prior_cpu_ns = cpu_time_ns() - cpu_start_ns; - pid = fork(); - if (pid == 0) { - if (fds[1] >= 0) - close(fds[1]); - return 0; - } - set_idle_priority(0, false); - if (fds[0] >= 0) - close(fds[0]); - timing->fork_fd = -1; - if (fds[1] >= 0) { - fork_share = cpu_time_ns() - cpu_start_ns - timing->prior_cpu_ns; - if (pid > 0) - (void)write_all(fds[1], &fork_share, sizeof(fork_share)); - close(fds[1]); - } - return pid; -} - -// Repair step 7, fail-fast: the restore checks, the grace-period release, the -// saved watchdog settings, the deferred C7, and the restore check's record. A -// child of the daemon runs it at SCHED_IDLE from DEFERRED_START_NS after the -// acknowledgement; the daemon raises it to normal priority IDLE_BOUND_NS later -// and releases the suppression itself at the stall timeout. restore-finish -// runs it inline, with its own deadline (WITH_DEADLINE), in report-only mode -// when no daemon runs. Neither recorded time counts the grace period wait or -// C7. Returns 0, or 1 when a report-only release failed. -static int restore_work(const bool *new_cpus, int new_count, bool with_deadline, - const struct restore_timing *timing) -{ - int64_t timeout_s = DEFAULT_STALL_TIMEOUT_S; - char detail[DETAIL_MAX]; - struct checks checks; - int64_t elapsed; - int64_t cpu; - bool released; - - if (checks_init(&checks, PHASE_RESTORE) != 0) - check_failed(&checks, "C6", "cannot read the CPU sets"); - else - run_restore_checks(&checks, new_cpus, new_count); - elapsed = clock_ns(CLOCK_MONOTONIC) - timing->start_ns; - cpu = deferred_restore_cpu_ns(timing); - if (with_deadline) { - (void)read_int64(RCU_STALL_TIMEOUT, &timeout_s); - released = release_with_deadline(timeout_s); - } else { - released = grace_period_release() == 0; - } - if (!released) - fail_fatal(STATUS_REPAIR, "G_REPAIR_SUPPRESSION", "repair", - PHASE_RESTORE, "the RCU grace-period release failed"); - if (suppression_restore(detail, sizeof(detail), true) != 0) - fail_fatal(STATUS_REPAIR, "G_REPAIR_SUPPRESSION", "repair", - PHASE_RESTORE, "%s", detail); - check_timer_list(&checks, TIMER_LIST_WAIT_NS); - if (record_check(PHASE_RESTORE, "ok", checks.online_count, - timing->readiness_us + elapsed / NSEC_PER_USEC, - cpu / NSEC_PER_USEC, 0, 0, checks.failures) != 0) - check_failed(&checks, "C10", "cannot record the restore check: %s", - strerror(errno)); - return released ? 0 : 1; -} - -// Starts repair step 7 when it is due, DEFERRED_START_NS after the -// acknowledgement, in a child at SCHED_IDLE, so the watcher and the -// discipline keep normal priority; the fork itself also runs at SCHED_IDLE. -// The readiness path's wall time came with the signal. -static void start_restore(struct daemon *daemon) -{ - int64_t cpu_start = cpu_time_ns(); - bool new_cpus[MAX_CPUS] = {false}; - struct restore_timing timing; - struct time_state state; - unsigned long long generation; - int64_t timeout_s = DEFAULT_STALL_TIMEOUT_S; - char text[256]; - int new_count = 0; - pid_t pid; - - if (daemon->worker_pid > 0) - return; - if (state_load(&state) != 0) { - fail_fatal(STATUS_REPAIR, "G_REPAIR_SUPPRESSION", "repair", - PHASE_RESTORE, "the time state is unreadable"); - return; - } - g_generation = (uint32_t)state.generation; - if (read_text(RESTORE_PATH, text, sizeof(text)) >= 0) { - new_count = parse_restore_record(text, &generation, new_cpus); - if (new_count < 0) { - fail_fatal(STATUS_REPAIR, "G_REPAIR_SUPPRESSION", "repair", - PHASE_RESTORE, "the restore record is malformed"); - return; - } - if (generation != state.generation) { - memset(new_cpus, 0, sizeof(new_cpus)); - new_count = 0; - } - } - (void)read_int64(RCU_STALL_TIMEOUT, &timeout_s); - // The worker's wall time runs from when step 7 was due. Its CPU time is - // step 7's: this preparation, the fork, and the worker's own. - timing.start_ns = daemon->restore_due_ns; - timing.readiness_us = daemon->readiness_us; - pid = fork_deferred_restore(cpu_start, &timing); - if (pid == 0) - _exit(restore_work(new_cpus, new_count, false, &timing)); - if (pid < 0) { - fail_fatal(STATUS_REPAIR, "G_REPAIR_SUPPRESSION", "repair", - PHASE_RESTORE, "cannot start the restore work: %s", - strerror(errno)); - return; - } - daemon->worker_pid = pid; - daemon->worker_promote_ns = daemon->restore_due_ns + IDLE_BOUND_NS; - daemon->worker_deadline_ns = - clock_ns(CLOCK_MONOTONIC) + timeout_s * NSEC_PER_SEC; - daemon->suppression_released = false; -} - -// Releases the stall suppression for a worker whose grace period is still -// pending at the stall timeout: the grace period is stuck, and the kernel -// then reports the real stall. -static void release_suppression(struct daemon *daemon) -{ - char detail[DETAIL_MAX]; - - daemon->suppression_released = true; - if (suppression_restore(detail, sizeof(detail), true) != 0) - fail_fatal(STATUS_REPAIR, "G_REPAIR_SUPPRESSION", "repair", - PHASE_RESTORE, "%s", detail); -} - -static void reap_children(struct daemon *daemon) -{ - int status; - pid_t pid; - - while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { - if (pid != daemon->worker_pid) - continue; - daemon->worker_pid = 0; - daemon->worker_promote_ns = 0; - // A failing worker powered the guest off itself, or reported in - // report-only mode; a crash leaves the suppression to the daemon. - if (!WIFEXITED(status)) { - release_suppression(daemon); - fail_fatal(STATUS_REPAIR, "G_REPAIR_SUPPRESSION", "repair", - PHASE_RESTORE, "the restore work ended with signal %d", - WTERMSIG(status)); - } - // Repair step 7 ends by restarting the discipline at the fast cadence. - daemon->polls = 0; - (void)arm_timer(daemon); - } -} - -static void handle_signals(struct daemon *daemon) -{ - struct signalfd_siginfo info; - - while (read(daemon->signals, &info, sizeof(info)) == sizeof(info)) { - if (info.ssi_signo == SIGTERM) - _exit(0); - // sigqueue() carries the readiness path's wall time. Step 7 is due - // DEFERRED_START_NS later; the repair paired the clock with the - // packet's UTC now. - if (info.ssi_signo == SIGUSR1) { - daemon->restore_pending = true; - daemon->last_accepted_ns = clock_ns(CLOCK_MONOTONIC); - daemon->restore_due_ns = - daemon->last_accepted_ns + DEFERRED_START_NS; - daemon->readiness_us = info.ssi_code == SI_QUEUE - ? (int64_t)(info.ssi_ptr & UINT32_MAX) - : 0; - } - } - // A worker that ended, if any: the daemon loop starts a due restore only - // once the previous worker is reaped. - reap_children(daemon); -} - -// Sets up the daemon's signal and timer descriptors and its OOM exemption. -// Returns 0 or an errno value. -static int daemon_setup(struct daemon *daemon) -{ - sigset_t mask; - - sigemptyset(&mask); - sigaddset(&mask, SIGUSR1); - sigaddset(&mask, SIGTERM); - sigaddset(&mask, SIGCHLD); - if (sigprocmask(SIG_BLOCK, &mask, NULL) != 0) - return errno; - signal(SIGHUP, SIG_IGN); - signal(SIGPIPE, SIG_IGN); - daemon->signals = signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK); - if (daemon->signals < 0) - return errno; - daemon->timer = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK); - if (daemon->timer < 0) - return errno; - if (write_text("/proc/self/oom_score_adj", "-1000") != 0 || - arm_timer(daemon) != 0) - return errno; - return 0; -} - -// Returns the poll timeout in milliseconds until DEADLINE_NS, or -1 when -// DEADLINE_NS is 0, for none. -static int poll_timeout_until(int64_t deadline_ns) -{ - int64_t remaining; - - if (deadline_ns == 0) - return -1; - remaining = deadline_ns - clock_ns(CLOCK_MONOTONIC); - return remaining <= 0 - ? 0 - : (int)((remaining + NSEC_PER_MSEC - 1) / NSEC_PER_MSEC); -} - -// Returns the earlier of two poll timeouts, where -1 is none. -static int earlier_timeout(int first, int second) -{ - if (first < 0) - return second; - if (second < 0) - return first; - return first < second ? first : second; -} - -static void daemon_loop(struct daemon *daemon) -{ - for (;;) { - struct pollfd fds[3] = {{daemon->kmsg, POLLIN, 0}, - {daemon->signals, POLLIN, 0}, - {daemon->timer, POLLIN, 0}}; - bool waiting = daemon->worker_pid > 0 && !daemon->suppression_released; - bool idle = daemon->worker_pid > 0 && daemon->worker_promote_ns != 0; - bool due = daemon->restore_pending && daemon->worker_pid == 0; - int timeout = earlier_timeout( - earlier_timeout( - poll_timeout_until(idle ? daemon->worker_promote_ns : 0), - poll_timeout_until(waiting ? daemon->worker_deadline_ns : 0)), - poll_timeout_until(due ? daemon->restore_due_ns : 0)); - int64_t now; - - if (poll(fds, ARRAY_SIZE(fds), timeout) < 0 && errno != EINTR) { - sleep(1); - continue; - } - if ((fds[0].revents & (POLLIN | POLLERR)) != 0) - watch_kernel_log(daemon); - if ((fds[1].revents & POLLIN) != 0) - handle_signals(daemon); - if ((fds[2].revents & POLLIN) != 0) { - uint64_t expirations; - ssize_t count = read(daemon->timer, &expirations, - sizeof(expirations)); - - (void)count; - discipline_poll(daemon); - arm_timer(daemon); - } - now = clock_ns(CLOCK_MONOTONIC); - // Step 7 starts DEFERRED_START_NS after the ack, and continues at - // normal priority IDLE_BOUND_NS after it starts. - if (daemon->restore_pending && daemon->worker_pid == 0 && - now >= daemon->restore_due_ns) { - daemon->restore_pending = false; - start_restore(daemon); - now = clock_ns(CLOCK_MONOTONIC); - } - if (daemon->worker_pid > 0 && daemon->worker_promote_ns != 0 && - now >= daemon->worker_promote_ns) { - daemon->worker_promote_ns = 0; - set_idle_priority(daemon->worker_pid, false); - } - if (daemon->worker_pid > 0 && !daemon->suppression_released && - now >= daemon->worker_deadline_ns) - release_suppression(daemon); - } -} - -// Turns the boot checker into the time daemon, at normal priority. It keeps -// reading the kernel log from where the boot check positioned KMSG and -// publishes its PID once its descriptors are ready. Returns 0 or -1. -static int become_daemon(struct daemon *daemon, int kmsg, - int64_t last_accepted_ns) -{ - char text[32]; - int error; - - memset(daemon, 0, sizeof(*daemon)); - daemon->kmsg = kmsg; - daemon->last_accepted_ns = last_accepted_ns; - set_idle_priority(0, false); - error = daemon_setup(daemon); - if (error != 0) { - errno = error; - return -1; - } - snprintf(text, sizeof(text), "%d\n", (int)getpid()); - return replace_file(DAEMON_PID_PATH, DAEMON_PID_PATH ".tmp", text); -} - -// --------------------------------------------------------------------------- -// Commands -// --------------------------------------------------------------------------- - -static void load_generation(void) -{ - struct time_state state; - - if (state_load(&state) == 0) - g_generation = (uint32_t)state.generation; -} - -struct boot_log_job { - struct boot_log *log; - char *buffer; - int size; - bool read_log; -}; - -// C4 and K1 over the kernel log, beside the per-CPU checks. The caller -// allocates the buffer before the threads start: freeing a large allocation -// unmaps it, and an unmap in a process with running threads waits for every -// CPU running one of them. -static void boot_kernel_log(struct checks *checks, void *context) -{ - struct boot_log_job *job = context; - char detail[DETAIL_MAX]; - - if (!job->read_log) - return; - if (job->buffer == NULL) { - check_failed(checks, "C4", "no buffer for the %d-byte kernel log", - job->size); - return; - } - if (read_boot_log_into(job->log, job->buffer, job->size) != 0) { - check_failed(checks, "C4", "read the kernel log: %s", strerror(errno)); - return; - } - if (!boot_log_verdict(job->log, detail, sizeof(detail))) - check_failed(checks, "C4", "%s", detail); - // K1 is kernel hardening, not time: a writable and executable kernel - // mapping, such as unprotected ITS thunk pages. - if (job->log->wx[0] != '\0') - check_failed_code(checks, "G_KERNEL_WX", "%s", job->log->wx); -} - -// C7 beside the per-CPU checks. -static void boot_timer_list(struct checks *checks, void *context) -{ - (void)context; - check_timer_list(checks, TIMER_LIST_WAIT_NS); -} - -// C5 beside the per-CPU checks. -static void boot_cpuinfo(struct checks *checks, void *context) -{ - (void)context; - check_cpuinfo(checks, checks->online, true); -} - -struct boot_sample { - int64_t theta; - int64_t epsilon; - int64_t accepted_ns; -}; - -// The boot checks that need no CPU of their own run in the caller while the -// parallel jobs run. -static void boot_foreground(struct checks *checks, void *context) -{ - (void)context; - check_cmdline(checks); - check_platform(checks); - check_clocksource(checks); - check_debug_watchdogs(checks); -} - -// Sleeps until DEADLINE_NS on CLOCK_MONOTONIC. -static void sleep_until(int64_t deadline_ns) -{ - struct timespec when = {(time_t)(deadline_ns / NSEC_PER_SEC), - (long)(deadline_ns % NSEC_PER_SEC)}; - - while (clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &when, NULL) == - EINTR) - ; -} - -// The asynchronous boot checks: every boot check but C12, fail-fast, from -// DEFERRED_START_NS after the boot step at SCHED_IDLE, and at normal priority -// from IDLE_BOUND_NS after they start. When they pass, the checker becomes the -// daemon and records the boot check, whose wall and CPU times add C12's -// (C12_NS and C12_CPU_NS) and the daemon's start, but not the delay. It never -// returns. -static void boot_checker(const struct boot_sample *sample, int64_t c12_ns, - int64_t c12_cpu_ns, int c12_failures) -{ - // The delay counts from this fork in the time ABI boot step, the one - // point that every guest mode shares: init runs it before the rest of its - // setup, so the checks start somewhat less than DEFERRED_START_NS after - // shell-ready. - int64_t start = clock_ns(CLOCK_MONOTONIC) + DEFERRED_START_NS; - int64_t cpu_start = cpu_time_ns(); - int null = open("/dev/null", O_RDWR | O_CLOEXEC); - struct boot_log_job log_job; - struct promoter promoter; - struct side_job sides[3]; - struct daemon daemon; - struct checks checks; - struct boot_log log; - bool started = false; - bool promoting; - int kmsg; - - g_async_output = true; - (void)setsid(); - if (null >= 0) { - (void)dup2(null, STDIN_FILENO); - (void)dup2(null, STDOUT_FILENO); - (void)dup2(null, STDERR_FILENO); - close(null); - } - if (chdir("/") != 0) - _exit(1); - promoting = promoter_start(&promoter, start); - set_idle_priority(0, true); - sleep_until(start); - if (checks_init(&checks, PHASE_BOOT) != 0) { - check_failed(&checks, "C1", "cannot read the CPU sets"); - _exit(1); - } - checks.failures = c12_failures; - memset(&log, 0, sizeof(log)); - log_job.log = &log; - log_job.read_log = false; - log_job.size = klogctl(SYSLOG_ACTION_SIZE_BUFFER, NULL, 0); - log_job.buffer = log_job.size > 0 ? malloc((size_t)log_job.size + 1) : NULL; - kmsg = open("/dev/kmsg", O_RDONLY | O_NONBLOCK | O_CLOEXEC); - if (kmsg < 0 || lseek(kmsg, 0, SEEK_END) < 0) { - check_failed(&checks, "C4", "open /dev/kmsg: %s", strerror(errno)); - if (kmsg >= 0) - close(kmsg); - kmsg = -1; - } else { - log_job.read_log = true; - } - // The kernel log, the tick mode, and cpuinfo run beside the per-CPU - // checks, longest first; the other checks run in the caller meanwhile. - sides[0] = (struct side_job){.run = boot_kernel_log, .context = &log_job}; - sides[1] = (struct side_job){.run = boot_timer_list}; - sides[2] = (struct side_job){.run = boot_cpuinfo}; - check_cpus_parallel(&checks, checks.online, true, true, sides, - ARRAY_SIZE(sides), boot_foreground, NULL); - free(log_job.buffer); - memcpy(checks.cpu_khz, sides[2].checks.cpu_khz, sizeof(checks.cpu_khz)); - memcpy(checks.cpu_khz_known, sides[2].checks.cpu_khz_known, - sizeof(checks.cpu_khz_known)); - // C3 compares CPU 0's declared rate with cpuinfo once both have run. - if (checks.tsc_hz != 0 && checks.cpu_khz_known[0] && - checks.tsc_hz / 1000 != checks.cpu_khz[0]) - check_failed(&checks, "C3", - "cpu 0: floor(F / 1000) is %" PRIu64 - " kHz but cpu MHz is %" PRIu64 " kHz", - checks.tsc_hz / 1000, checks.cpu_khz[0]); - // The daemon blocks its signals in its only thread. - if (promoting) - promoter_stop(&promoter); - if (kmsg >= 0) { - started = become_daemon(&daemon, kmsg, sample->accepted_ns) == 0; - if (!started) - check_failed(&checks, "C10", "cannot start the time daemon: %s", - strerror(errno)); - } - if (record_check(PHASE_BOOT, "ok", checks.online_count, - (c12_ns + clock_ns(CLOCK_MONOTONIC) - start) / - NSEC_PER_USEC, - (c12_cpu_ns + cpu_time_ns() - cpu_start) / NSEC_PER_USEC, - checks.tsc_hz, checks.lapic_hz, checks.failures) != 0) - check_failed(&checks, "C10", "cannot publish the time state: %s", - strerror(errno)); - if (!started) - _exit(1); - daemon_loop(&daemon); - _exit(0); -} - -// Before shell-ready, only C12: the initial time sample and clock step, then -// the published state. Every other boot check runs asynchronously in a child, -// which becomes the daemon. -static int cmd_boot(void) -{ - int64_t start = clock_ns(CLOCK_MONOTONIC); - int64_t cpu_start = cpu_time_ns(); - struct boot_sample sample = {0, 0, 0}; - struct time_state state; - struct checks checks; - int64_t c12_cpu; - pid_t pid; - - g_generation = 0; - make_runtime_directories(); - memset(&checks, 0, sizeof(checks)); - checks.phase = PHASE_BOOT; - if (check_initial_sample(&checks, &sample.theta, &sample.epsilon)) - sample.accepted_ns = clock_ns(CLOCK_MONOTONIC); - state_init(&state); - state.synchronized = sample.accepted_ns != 0; - state.samples = sample.accepted_ns != 0; - state.offset_ns = sample.accepted_ns != 0 ? sample.theta : 0; - state.uncertainty_ns = sample.accepted_ns != 0 ? sample.epsilon : 0; - state.frequency_ppb = frequency_to_ppb(current_frequency()); - if (state_store(&state) != 0) { - check_failed(&checks, "C10", "cannot publish the time state: %s", - strerror(errno)); - return 1; - } - fflush(NULL); - // The child cannot read this process's CPU clock. - c12_cpu = cpu_time_ns() - cpu_start; - pid = fork(); - if (pid == 0) - boot_checker(&sample, clock_ns(CLOCK_MONOTONIC) - start, c12_cpu, - checks.failures); - if (pid < 0) { - check_failed(&checks, "C10", "cannot start the boot checks: %s", - strerror(errno)); - return 1; - } - return 0; -} - -// Capture checks (C6, C7, and C10); they never wait. -static void run_capture_checks(struct checks *checks) -{ - (void)rates_load(&checks->tsc_hz, &checks->lapic_hz); - check_clocksource(checks); - check_timer_list(checks, 0); - check_daemon(checks); -} - -// Restore checks: C1, C2, and C5 on newly onlined CPUs, C3 for CPU 0, C6, -// and C10. They never wait. -static void run_restore_checks(struct checks *checks, const bool *new_cpus, - int new_count) -{ - uint64_t tsc_hz = 0; - uint64_t lapic_hz = 0; - - if (rates_load(&tsc_hz, &lapic_hz) != 0) { - check_failed(checks, "C3", "the boot rates are unavailable"); - } else { - checks->tsc_hz = tsc_hz; - checks->lapic_hz = lapic_hz; - } - if (new_count > 0) { - check_cpus(checks, new_cpus, true, false); - check_cpuinfo(checks, new_cpus, true); - } - check_msrs(checks, 0, false); - check_clocksource(checks); - check_daemon(checks); -} - -static int cmd_check(int argc, char **argv) -{ - int64_t start = clock_ns(CLOCK_MONOTONIC); - int64_t cpu_start = cpu_time_ns(); - bool new_cpus[MAX_CPUS] = {false}; - int new_count = 0; - enum phase phase = PHASE_RUNTIME; - struct checks checks; - - for (int index = 0; index < argc; index++) { - if (strcmp(argv[index], "--phase") == 0 && index + 1 < argc) { - const char *name = argv[++index]; - - phase = strcmp(name, "capture") == 0 ? PHASE_CAPTURE - : strcmp(name, "restore") == 0 ? PHASE_RESTORE - : PHASE_RUNTIME; - } else if (strcmp(argv[index], "--new-cpus") == 0 && index + 1 < argc) { - new_count = parse_new_cpus(argv[++index], new_cpus); - } else { - new_count = -1; - } - } - if (phase == PHASE_RUNTIME || new_count < 0) { - fprintf(stderr, "usage: nvx-time check --phase capture|restore " - "[--new-cpus LIST]\n"); - return 2; - } - load_generation(); - if (checks_init(&checks, phase) != 0) { - check_failed(&checks, "C6", "cannot read the CPU sets"); - return 1; - } - if (phase == PHASE_CAPTURE) - run_capture_checks(&checks); - else - run_restore_checks(&checks, new_cpus, new_count); - (void)record_check(phase, "ok", checks.online_count, - (clock_ns(CLOCK_MONOTONIC) - start) / NSEC_PER_USEC, - (cpu_time_ns() - cpu_start) / NSEC_PER_USEC, 0, 0, - checks.failures); - return checks.failures == 0 ? 0 : 1; -} - -// Whether a recorded check of STATE is still pending: the boot check only -// with BOOT_ONLY, or any. A state without a boot record parses as a pending -// boot check. -static bool checks_pending(const struct time_state *state, bool boot_only) -{ - for (int phase = 0; phase < (boot_only ? 1 : CHECK_PHASES); phase++) { - if (strcmp(state->checks[phase].status, "pending") == 0) - return true; - } - return false; -} - -// Waits until no recorded check (with BOOT_ONLY, no boot check) is pending, -// for at most STATUS_WAIT_NS, and loads the state into STATE; a missing -// state file counts as a pending boot check. Returns 0, or 1 when a check is -// still pending. -static int wait_for_checks(struct time_state *state, bool boot_only) -{ - int64_t deadline = clock_ns(CLOCK_MONOTONIC) + STATUS_WAIT_NS; - - for (;;) { - if (state_load(state) != 0) - state_init(state); - else if (!checks_pending(state, boot_only)) - return 0; - if (clock_ns(CLOCK_MONOTONIC) >= deadline) - return 1; - (void)nanosleep(&(struct timespec){0, STATUS_POLL_NS}, NULL); - } -} - -static int cmd_pre_capture(void) -{ - struct time_state state; - struct checks checks; - int64_t cpu_start; - int64_t elapsed; - int64_t start; - int64_t cpu; - - // Preparation step 1: the asynchronous boot checks start the daemon that - // C10 requires. - if (wait_for_checks(&state, true) != 0) { - memset(&checks, 0, sizeof(checks)); - checks.phase = PHASE_CAPTURE; - check_failed(&checks, "C10", "the boot checks are still pending"); - return 1; - } - start = clock_ns(CLOCK_MONOTONIC); - cpu_start = cpu_time_ns(); - g_generation = (uint32_t)state.generation; - if (checks_init(&checks, PHASE_CAPTURE) != 0) { - check_failed(&checks, "C6", "cannot read the CPU sets"); - return 1; - } - run_capture_checks(&checks); - elapsed = clock_ns(CLOCK_MONOTONIC) - start; - cpu = cpu_time_ns() - cpu_start; - if (suppression_save() != 0) { - char detail[DETAIL_MAX]; - int error = errno; - - if (error != EEXIST) - (void)suppression_restore(detail, sizeof(detail), false); - fprintf(stderr, "nvx-time: cannot suppress the stall detectors: %s\n", - error == EEXIST ? "a capture is already pending" - : strerror(error)); - return 1; - } - // Step 1's record travels in the snapshot. - if (record_check(PHASE_CAPTURE, "ok", checks.online_count, - elapsed / NSEC_PER_USEC, cpu / NSEC_PER_USEC, 0, 0, - checks.failures) != 0) - check_failed(&checks, "C10", "cannot record the capture check: %s", - strerror(errno)); - return 0; -} - -static int cmd_cancel_capture(void) -{ - char detail[DETAIL_MAX]; - - if (suppression_restore(detail, sizeof(detail), false) != 0) { - fprintf(stderr, "nvx-time: %s\n", detail); - return 1; - } - return 0; -} - -struct repair_record { - const struct restore_packet *packet; - int64_t theta; - int64_t epsilon; - int64_t step_realtime_ns; - int64_t frequency; - int64_t elapsed_us; -}; - -static void apply_repair(struct time_state *state, const void *context) -{ - const struct repair_record *repair = context; - - state->generation = repair->packet->generation; - state->discontinuities++; - snprintf(state->last_discontinuity, sizeof(state->last_discontinuity), - "restore"); - state->last_step_ns = repair->theta; - state->last_step_realtime_ns = repair->step_realtime_ns; - state->last_downtime_ns = repair->packet->downtime_ns; - snprintf(state->last_downtime_source, sizeof(state->last_downtime_source), - "%s", - (repair->packet->flags & PACKET_DOWNTIME_UTC) != 0 ? "utc" - : "monotonic"); - state->synchronized = 1; - state->offset_ns = repair->theta; - state->uncertainty_ns = repair->epsilon; - state->frequency_ppb = frequency_to_ppb(repair->frequency); - state->samples++; - snprintf(state->last_sample_error, sizeof(state->last_sample_error), - "none"); - // Repair step 3 marks this restore's check pending, so nvx-time status - // waits for the daemon's step 7. Until then, restore_elapsed_us is the - // readiness path up to the clock step, at last_step_realtime_ns; - // restore_cpu_us counts only step 7, which records it. - snprintf(state->checks[PHASE_RESTORE].status, - sizeof(state->checks[PHASE_RESTORE].status), "pending"); - state->checks[PHASE_RESTORE].cpus = 0; - state->checks[PHASE_RESTORE].elapsed_us = repair->elapsed_us; - state->checks[PHASE_RESTORE].cpu_us = 0; - state->checks[PHASE_RESTORE].generation = state->generation; - state->checks[PHASE_RESTORE].failures = -1; -} - -static int repair_failed(const char *code, const char *detail) -{ - fail_fatal(STATUS_REPAIR, code, "repair", PHASE_RESTORE, "%s", detail); - return 1; -} - -// Repair steps 2 and 3: read the packet with four-byte reads, validate it, -// and set the wall clock from its bracketed UTC, then write the entropy to -// ENTROPY_FD, the caller's file at ENTROPY_PATH. START_NS is when the capture -// request returned. The caller holds the portb lock. With FINISH (untiered) -// and a DAEMON, a restore with no processors or memory to activate needs no -// shell work before the acknowledgement, so steps 6 and 7 start here too -// and no second helper process starts; the metadata then begins with 2 -// instead of 1. -static int repair_restore(const uint8_t *previous_id, const char *entropy_path, - int entropy_fd, int64_t start_ns, bool finish, - pid_t daemon) -{ - static uint8_t body[PACKET_MAX_SIZE]; - static struct restore_packet packet; - uint8_t header[PACKET_HEADER_SIZE]; - struct repair_record repair; - struct time_sample sample; - char detail[DETAIL_MAX]; - char id[GENERATION_ID_SIZE * 2 + 1]; - int64_t t0; - int64_t t1; - int64_t sample_theta; - int64_t sample_epsilon; - bool finishing; - int finished = -1; - - t0 = clock_ns(CLOCK_REALTIME); - outb(SELECT_PACKET, PORTB_STATUS); - t1 = clock_ns(CLOCK_REALTIME); - portb_read(PORTB_DATA, header, sizeof(header)); - if (parse_packet_header(header, &packet, detail, sizeof(detail)) != 0) - return repair_failed("G_REPAIR_PACKET", detail); - if (packet.generation == g_generation) { - close(entropy_fd); - unlink(entropy_path); - puts("0"); - return 0; - } - if ((uint64_t)packet.generation != (uint64_t)g_generation + 1) { - snprintf(detail, sizeof(detail), - "restore packet generation %" PRIu32 - " does not follow recorded generation %" PRIu32, - packet.generation, g_generation); - return repair_failed("G_REPAIR_GENERATION", detail); - } - portb_read(PORTB_DATA, body, packet_body_size(&packet)); - if (parse_packet_body(body, &packet, detail, sizeof(detail)) != 0) - return repair_failed("G_REPAIR_PACKET", detail); - if (memcmp(packet.entropy, previous_id, GENERATION_ID_SIZE) == 0) - return repair_failed("G_REPAIR_GENERATION", - "the VM generation ID did not change"); - g_generation = packet.generation; - pair_clocks(t0, t1, packet.utc_ns, &repair.theta, &repair.epsilon); - if (repair.epsilon > REPAIR_BOUND_NS) { - int result = best_sample(SAMPLE_ATTEMPTS - 1, REPAIR_BOUND_NS, - packet.generation, &sample, &sample_theta, - &sample_epsilon, detail, sizeof(detail)); - - if (result == -2) - return repair_failed("G_REPAIR_GENERATION", detail); - if (result >= 0 && sample_epsilon < repair.epsilon) { - repair.theta = sample_theta; - repair.epsilon = sample_epsilon; - } - if (repair.epsilon > REPAIR_BOUND_NS) { - snprintf(detail, sizeof(detail), - "UTC pairing uncertainty %" PRId64 " ns exceeds 1 ms", - repair.epsilon); - return repair_failed("G_REPAIR_SAMPLE", detail); - } - } - repair.frequency = restore_frequency(packet.rate_deviation); - if (step_clock(repair.theta) != 0 || set_frequency(repair.frequency) != 0) { - snprintf(detail, sizeof(detail), "adjtimex: %s", strerror(errno)); - return repair_failed("G_REPAIR_CLOCK", detail); - } - repair.step_realtime_ns = clock_ns(CLOCK_REALTIME); - repair.elapsed_us = (clock_ns(CLOCK_MONOTONIC) - start_ns) / NSEC_PER_USEC; - repair.packet = &packet; - // A plain untiered restore needs no shell work: this helper also - // acknowledges and hands step 7 to the daemon. - finishing = finish && daemon > 0 && packet.online_vp_count == 0 && - packet.range_count == 0 && - (packet.flags & PACKET_MEMORY_TARGET) == 0; - // The entropy goes to the caller in every case, an untiered restore's - // too; the file already exists, empty, so only its bytes are written. - if (pwrite(entropy_fd, packet.entropy, PACKET_ENTROPY_SIZE, 0) != - PACKET_ENTROPY_SIZE || - close(entropy_fd) != 0) { - snprintf(detail, sizeof(detail), "write %.200s: %s", entropy_path, - strerror(errno)); - return repair_failed("G_REPAIR_PACKET", detail); - } - if (state_apply(apply_repair, &repair) != 0) { - snprintf(detail, sizeof(detail), "record the restore: %s", - strerror(errno)); - return repair_failed("G_REPAIR_CLOCK", detail); - } - format_hex(packet.entropy, GENERATION_ID_SIZE, id); - if (finishing) { - finished = signal_restore(daemon, - (packet.flags & PACKET_ACK_REQUIRED) != 0, - start_ns); - if (finished > 0) - fail_fatal(STATUS_CONFORMANCE, "G_CONFORMANCE_C10", "conformance", - PHASE_RESTORE, "the time daemon is not running"); - } - printf("%d %u %u %u %s", finished >= 0 ? 2 : 1, packet.flags, - packet.online_vp_count, packet.range_count, id); - for (unsigned index = 0; index < packet.range_count; index++) - printf(" %" PRIu64 " %" PRIu64, packet.ranges[index][0], - packet.ranges[index][1]); - putchar('\n'); - return 0; -} - -// Writes the capture request to 0x605. In a restored process the write -// returns with a restore packet available, and repair steps 1 to 3 run here -// before any other guest work. Prints 0 when no restore happened. The portb -// lock is held from the request to the restored clock, so the snapshot never -// contains a discipline poll that would apply a pre-capture sample after the -// restore. -static int cmd_capture(int argc, char **argv) -{ - uint8_t previous_id[GENERATION_ID_SIZE]; - char detail[DETAIL_MAX]; - char *end; - long request; - int64_t start; - unsigned status; - pid_t daemon = -1; - int entropy_fd; - int result; - int lock; - - if ((argc != 3 && (argc != 4 || strcmp(argv[3], "--finish") != 0)) || - (request = strtol(argv[0], &end, 10), *end != '\0') || - (request != 0 && request != 1) || - parse_hex(argv[1], previous_id, sizeof(previous_id)) != 0) { - fprintf(stderr, "usage: nvx-time capture 0|1 GENERATION_ID " - "ENTROPY_FILE [--finish]\n"); - return 2; - } - if (state_load(&(struct time_state){0}) != 0) { - fprintf(stderr, "nvx-time: the time state is unavailable\n"); - return 1; - } - load_generation(); - if (enable_ports() != 0) { - fprintf(stderr, "nvx-time: ioperm: %s\n", strerror(errno)); - return 1; - } - // The daemon keeps its PID in the restored guest; looking it up now keeps - // the file reads off the restore path. - if (argc == 4) - daemon = daemon_pid(); - // The entropy file is created before the capture, empty, so that a - // restored helper only writes its bytes: creating a file right after a - // restore faults in the filesystem's metadata, 4 to 9 ms on WHP. - entropy_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | - O_CLOEXEC, - 0600); - if (entropy_fd < 0) { - fprintf(stderr, "nvx-time: open %s: %s\n", argv[2], strerror(errno)); - return 1; - } - lock = lock_portb(detail, sizeof(detail)); - if (lock < 0) { - fprintf(stderr, "nvx-time: %s\n", detail); - close(entropy_fd); - unlink(argv[2]); - return 1; - } - outb((unsigned char)request, PORT_SNAPSHOT); - start = clock_ns(CLOCK_MONOTONIC); - status = inb(PORTB_STATUS); - if ((status & PORTB_PACKET_AVAILABLE) == 0) { - close(lock); - close(entropy_fd); - unlink(argv[2]); - puts("0"); - return 0; - } - result = repair_restore(previous_id, argv[2], entropy_fd, start, argc == 4, - daemon); - close(lock); - return result; -} - -// Repair steps 6 and 7 after the shell's activation and identity work: the -// acknowledgement when the packet asked for one, then the daemon runs step 7 -// asynchronously. The record tells it which CPUs activation onlined. The -// readiness path started restore_elapsed_us before the helper's clock step, -// at last_step_realtime_ns. -static int cmd_restore_finish(int argc, char **argv) -{ - bool new_cpus[MAX_CPUS] = {false}; - const char *new_list = "none"; - struct restore_timing timing; - struct time_state state; - struct checks checks; - char text[256]; - int64_t start; - bool ack = false; - int new_count = 0; - int signaled; - - for (int index = 0; index < argc; index++) { - if (strcmp(argv[index], "--ack") == 0) { - ack = true; - } else if (strcmp(argv[index], "--new-cpus") == 0 && index + 1 < argc) { - new_list = argv[++index]; - new_count = parse_new_cpus(new_list, new_cpus); - } else { - new_count = -1; - } - } - if (new_count < 0 || strlen(new_list) > 127) { - fprintf(stderr, "usage: nvx-time restore-finish [--ack] " - "[--new-cpus LIST]\n"); - return 2; - } - if (state_load(&state) != 0) - return repair_failed("G_REPAIR_CLOCK", "the time state is unreadable"); - g_generation = (uint32_t)state.generation; - snprintf(text, sizeof(text), "generation=%" PRIu64 "\nnew_cpus=%s\n", - state.generation, new_count > 0 ? new_list : "none"); - if (replace_file(RESTORE_PATH, RESTORE_PATH ".tmp", text) != 0) { - snprintf(text, sizeof(text), "record the restore: %s", - strerror(errno)); - return repair_failed("G_REPAIR_CLOCK", text); - } - if (ack && enable_ports() != 0) { - fprintf(stderr, "nvx-time: ioperm: %s\n", strerror(errno)); - return 1; - } - start = clock_ns(CLOCK_MONOTONIC) - - (clock_ns(CLOCK_REALTIME) - state.last_step_realtime_ns) - - state.checks[PHASE_RESTORE].elapsed_us * NSEC_PER_USEC; - signaled = signal_restore(daemon_pid(), ack, start); - if (signaled == 0) - return 0; - if (signaled > 0 || !g_report_only) { - memset(&checks, 0, sizeof(checks)); - checks.phase = PHASE_RESTORE; - check_failed(&checks, "C10", "the time daemon is not running"); - return 1; - } - // Report-only runs may lack the daemon; finish the restore here, where - // step 7's CPU time is restore_work's own. - if (ack) - outb(SNAPSHOT_ACKNOWLEDGE, PORT_SNAPSHOT); - timing.start_ns = clock_ns(CLOCK_MONOTONIC); - timing.readiness_us = (timing.start_ns - start) / NSEC_PER_USEC; - timing.prior_cpu_ns = 0; - timing.cpu_base_ns = cpu_time_ns(); - timing.fork_fd = -1; - (void)restore_work(new_cpus, new_count, true, &timing); - return 0; -} - -// Formats nvx-time status's lines from STATE: one per recorded phase, in the -// order boot, capture, and restore, with status=pending and neither -// elapsed_us nor cpu_us while its check runs, then the runtime line. -// Report-only mode appends each check's failure count, and a failed check's -// status is fail. Returns whether every phase line reports ok. -static bool format_status(const struct time_state *state, char *text, - size_t size) -{ - size_t length = 0; - bool ok = true; - - text[0] = '\0'; - for (int phase = 0; phase < CHECK_PHASES; phase++) { - const struct check_state *check = &state->checks[phase]; - const char *status = check->status; - char times[64] = ""; - char suffix[40] = ""; - - if (status[0] == '\0') - continue; - if (strcmp(status, "pending") != 0) - snprintf(times, sizeof(times), - " elapsed_us=%" PRId64 " cpu_us=%" PRId64, - check->elapsed_us, check->cpu_us); - if (g_report_only) - snprintf(suffix, sizeof(suffix), " failures=%" PRId64, - check->failures > 0 ? check->failures : 0); - ok = ok && strcmp(status, "ok") == 0; - append_text(text, size, &length, - "%s: v=1 phase=%s status=%s cpus=%" PRIu64 - " tsc_hz=%" PRIu64 " lapic_hz=%" PRIu64 - " generation=%" PRIu64 "%s%s\n", - abi_prefix(), k_phase_names[phase], status, check->cpus, - state->tsc_hz, state->lapic_hz, check->generation, times, - suffix); - } - append_text(text, size, &length, - "%s: v=1 phase=runtime status=%s generation=%" PRIu64 - " discontinuities=%" PRIu64 " offset_ns=%" PRId64 - " uncertainty_ns=%" PRId64 " rejected_samples=%" PRIu64 - " last_sample_error=%s\n", - abi_prefix(), - state->synchronized != 0 ? "synchronized" : "unsynchronized", - state->generation, state->discontinuities, state->offset_ns, - state->uncertainty_ns, state->rejected_samples, - state->last_sample_error); - return ok; -} - -// Prints the time state on demand, after waiting for pending checks: CI and -// the matrix driver run it over the console; production paths never do. -// Exits with 0 only when every phase line reports ok. -static int cmd_status(void) -{ - struct time_state state; - char text[1024]; - int waited = wait_for_checks(&state, false); - bool ok = format_status(&state, text, sizeof(text)); - - fputs(text, stdout); - fflush(stdout); - return waited == 0 && ok ? 0 : 1; -} - -static int cmd_sample(void) -{ - struct time_sample sample; - char detail[DETAIL_MAX] = ""; - int64_t theta; - int64_t epsilon; - unsigned status; - int result; - int lock; - - if (enable_ports() != 0) { - fprintf(stderr, "nvx-time: ioperm: %s\n", strerror(errno)); - return 1; - } - status = inb(PORTB_STATUS); - if ((status & PORTB_TIME_WINDOW) == 0) { - fprintf(stderr, "nvx-time: portb status 0x%02x has no time window\n", - status); - return 1; - } - lock = lock_portb(detail, sizeof(detail)); - result = lock < 0 ? -1 : take_sample(&sample, detail, sizeof(detail)); - if (lock >= 0) - close(lock); - if (result != 0) { - fprintf(stderr, "nvx-time: %s\n", detail); - return 1; - } - pair_clocks(sample.t0, sample.t1, sample.utc_ns, &theta, &epsilon); - printf("flags=0x%02x generation=%" PRIu32 " utc_ns=%" PRIu64 - " t0=%" PRId64 " t1=%" PRId64 " theta_ns=%" PRId64 - " epsilon_ns=%" PRId64 "\n", - sample.flags, sample.generation, sample.utc_ns, sample.t0, - sample.t1, theta, epsilon); - return 0; -} - -static int cmd_generation_id(void) -{ - uint8_t id[GENERATION_ID_SIZE]; - char text[GENERATION_ID_SIZE * 2 + 1]; - unsigned status; - int lock; - - if (enable_ports() != 0) { - fprintf(stderr, "nvx-time: ioperm: %s\n", strerror(errno)); - return 1; - } - status = inb(PORTB_STATUS); - if ((status & PORTB_GENERATION_ID_AVAILABLE) == 0) { - fprintf(stderr, "nvx-time: the VM generation ID is unavailable\n"); - return 1; - } - lock = lock_file(PORTB_LOCK_PATH); - if (lock < 0) { - fprintf(stderr, "nvx-time: portb lock: %s\n", strerror(errno)); - return 1; - } - outb(SELECT_GENERATION_ID, PORTB_STATUS); - portb_read(PORTB_DATA, id, sizeof(id)); - close(lock); - format_hex(id, sizeof(id), text); - puts(text); - return 0; -} - -// --------------------------------------------------------------------------- -// Unit-test entry points: pure decoders and decisions over fixture files -// --------------------------------------------------------------------------- - -static int test_bytes(const char *path, uint8_t *buffer, size_t size) -{ - memset(buffer, 0, size); - return read_bytes(path, buffer, size) < 0 ? -1 : 0; -} - -static int test_text(const char *path, char *buffer, size_t size) -{ - return read_text(path, buffer, size) < 0 ? -1 : 0; -} - -static int test_packet(int argc, char **argv) -{ - static uint8_t bytes[PACKET_MAX_SIZE]; - static struct restore_packet packet; - uint8_t previous[GENERATION_ID_SIZE]; - char detail[DETAIL_MAX]; - char id[GENERATION_ID_SIZE * 2 + 1]; - uint64_t recorded; - - if (argc != 3 || test_bytes(argv[0], bytes, sizeof(bytes)) != 0 || - parse_hex(argv[2], previous, sizeof(previous)) != 0) - return 2; - recorded = strtoull(argv[1], NULL, 10); - if (parse_packet_header(bytes, &packet, detail, sizeof(detail)) != 0) { - printf("error G_REPAIR_PACKET %s\n", detail); - return 0; - } - if (packet.generation == recorded) { - printf("stale\n"); - return 0; - } - if ((uint64_t)packet.generation != recorded + 1) { - printf("error G_REPAIR_GENERATION generation %" PRIu32 "\n", - packet.generation); - return 0; - } - if (parse_packet_body(bytes + PACKET_HEADER_SIZE, &packet, detail, - sizeof(detail)) != 0) { - printf("error G_REPAIR_PACKET %s\n", detail); - return 0; - } - if (memcmp(packet.entropy, previous, GENERATION_ID_SIZE) == 0) { - printf("error G_REPAIR_GENERATION generation ID unchanged\n"); - return 0; - } - format_hex(packet.entropy, GENERATION_ID_SIZE, id); - printf("ok flags=%u online=%u ranges=%u generation=%" PRIu32 - " rate_deviation=%" PRId32 " frequency=%" PRId64 - " downtime_ns=%" PRIu64 " utc_ns=%" PRIu64 " generation_id=%s", - packet.flags, packet.online_vp_count, packet.range_count, - packet.generation, packet.rate_deviation, - restore_frequency(packet.rate_deviation), packet.downtime_ns, - packet.utc_ns, id); - for (unsigned index = 0; index < packet.range_count; index++) - printf(" range=%" PRIu64 ":%" PRIu64, packet.ranges[index][0], - packet.ranges[index][1]); - putchar('\n'); - return 0; -} - -static int test_sample(int argc, char **argv) -{ - uint8_t bytes[SAMPLE_SIZE]; - struct time_sample sample; - char detail[DETAIL_MAX]; - - if (argc != 2 || test_bytes(argv[0], bytes, sizeof(bytes)) != 0) - return 2; - if (parse_sample(bytes, &sample, detail, sizeof(detail)) != 0) - printf("error %s\n", detail); - else if (sample.generation != strtoul(argv[1], NULL, 10)) - printf("error generation %" PRIu32 "\n", sample.generation); - else - printf("ok flags=%u generation=%" PRIu32 " utc_ns=%" PRIu64 "\n", - sample.flags, sample.generation, sample.utc_ns); - return 0; -} - -// Watcher rules per line (KMSG), or the C4 verdict over message lines -// (BOOT_LOG) or SYSLOG_ACTION_READ_ALL text (SYSLOG). -enum line_test { LINES_KMSG, LINES_BOOT_LOG, LINES_SYSLOG }; - -static int test_lines(int argc, char **argv, enum line_test mode) -{ - static char text[TEXT_MAX]; - struct boot_log log; - char detail[DETAIL_MAX]; - bool boot = mode != LINES_KMSG; - - if (argc != 1 || test_text(argv[0], text, sizeof(text)) != 0) - return 2; - memset(&log, 0, sizeof(log)); - if (mode == LINES_SYSLOG) { - boot_log_scan(text, &log); - } else { - for (char *line = strtok(text, "\n"); line != NULL; - line = strtok(NULL, "\n")) { - const char *code = match_watch_rule(line); - - if (boot) - boot_log_add(&log, line); - else - printf("%s\n", code != NULL ? code : "none"); - } - } - if (boot) { - if (boot_log_verdict(&log, detail, sizeof(detail))) - printf("ok\n"); - else - printf("fail %s\n", detail); - if (log.wx[0] != '\0') - printf("wx %s\n", log.wx); - } - return 0; -} - -static int test_timer_list(int argc, char **argv) -{ - static char text[TEXT_MAX]; - bool cpus[MAX_CPUS]; - char detail[DETAIL_MAX]; - - if (argc != 2 || test_text(argv[0], text, sizeof(text)) != 0 || - parse_cpu_list(argv[1], cpus) <= 0) - return 2; - if (timer_list_verdict(text, cpus, detail, sizeof(detail))) - printf("ok\n"); - else - printf("fail %s\n", detail); - return 0; -} - -static int test_checks(const char *name, int argc, char **argv) -{ - static char text[TEXT_MAX]; - struct checks checks; - bool cpus[MAX_CPUS]; - - memset(&checks, 0, sizeof(checks)); - checks.phase = PHASE_BOOT; - if (strcmp(name, "cpuinfo") == 0) { - if (argc != 2 || test_text(argv[0], text, sizeof(text)) != 0 || - parse_cpu_list(argv[1], cpus) <= 0) - return 2; - if (check_cpuinfo_text(&checks, text, cpus, true)) { - printf("ok"); - for (int cpu = 0; cpu < MAX_CPUS; cpu++) { - if (checks.cpu_khz_known[cpu]) - printf(" cpu%d_khz=%" PRIu64, cpu, checks.cpu_khz[cpu]); - } - putchar('\n'); - } - } else if (strcmp(name, "cmdline") == 0) { - if (argc != 1) - return 2; - snprintf(text, sizeof(text), "%s", argv[0]); - if (check_cmdline_text(&checks, text)) - printf("ok\n"); - } else if (strcmp(name, "clocksource") == 0) { - if (argc != 2) - return 2; - snprintf(text, sizeof(text), "%s", argv[1]); - if (check_clocksource_values(&checks, argv[0], text)) - printf("ok\n"); - } else { - return 2; - } - return 0; -} - -static int test_correction(int argc, char **argv) -{ - struct timex tx; - bool step; - - if (argc != 3) - return 2; - step = plan_correction(strtoll(argv[0], NULL, 10), strtoll(argv[1], NULL, 10), - strcmp(argv[2], "fast") == 0, &tx); - if (step) - printf("step modes=0x%x sec=%ld nsec=%ld\n", tx.modes, - (long)tx.time.tv_sec, (long)tx.time.tv_usec); - else - printf("slew modes=0x%x offset=%ld status=0x%x constant=%ld " - "maxerror=%ld esterror=%ld\n", - tx.modes, tx.offset, (unsigned)tx.status, tx.constant, - tx.maxerror, tx.esterror); - return 0; -} - -static int test_state(int argc, char **argv) -{ - static char text[4096]; - struct time_state state; - - if (argc != 1 || test_text(argv[0], text, sizeof(text)) != 0) - return 2; - if (state_parse(text, &state) != 0) { - printf("error\n"); - return 0; - } - state_format(&state, text, sizeof(text)); - fputs(text, stdout); - return 0; -} - -static int test_restore_record(int argc, char **argv) -{ - static char text[4096]; - unsigned long long generation; - bool cpus[MAX_CPUS]; - const char *separator = ""; - int count; - - if (argc != 1 || test_text(argv[0], text, sizeof(text)) != 0) - return 2; - count = parse_restore_record(text, &generation, cpus); - if (count < 0) { - printf("error\n"); - return 0; - } - printf("generation=%llu new_cpus=", generation); - for (int cpu = 0; cpu < MAX_CPUS; cpu++) { - if (cpus[cpu]) { - printf("%s%d", separator, cpu); - separator = ","; - } - } - printf("%s count=%d\n", count == 0 ? "none" : "", count); - return 0; -} - -// A side job and a foreground that fail a known number of times, so the test -// sees whether check_cpus_parallel() merges every job's failures. -static void test_failing_job(struct checks *checks, void *context) -{ - const int *failures = context; - - for (int index = 0; index < *failures; index++) - check_failed(checks, "C4", "side job failure %d", index + 1); -} - -static void test_failing_foreground(struct checks *checks, void *context) -{ - (void)context; - check_failed(checks, "C9", "foreground failure"); -} - -static int test_parallel(int argc, char **argv) -{ - static int failures[3] = {1, 2, 3}; - struct side_job sides[ARRAY_SIZE(failures)]; - struct checks checks; - bool cpus[MAX_CPUS]; - - if (argc != 1 || parse_cpu_list(argv[0], cpus) <= 0) - return 2; - memset(&checks, 0, sizeof(checks)); - checks.phase = PHASE_BOOT; - for (size_t index = 0; index < ARRAY_SIZE(sides); index++) - sides[index] = (struct side_job){.run = test_failing_job, - .context = &failures[index]}; - check_cpus_parallel(&checks, cpus, false, false, sides, ARRAY_SIZE(sides), - test_failing_foreground, NULL); - printf("failures=%d\n", checks.failures); - return 0; -} - -// A thread that an idle thread starts, so it is idle too; it waits until the -// test ends. -struct test_thread { - pthread_mutex_t lock; - pthread_cond_t changed; - pid_t tid; - bool done; -}; - -static void *test_thread_run(void *argument) -{ - struct test_thread *thread = argument; - - pthread_mutex_lock(&thread->lock); - thread->tid = (pid_t)syscall(SYS_gettid); - pthread_cond_broadcast(&thread->changed); - while (!thread->done) - pthread_cond_wait(&thread->changed, &thread->lock); - pthread_mutex_unlock(&thread->lock); - return NULL; -} - -// Prints the policies of an idle caller and of the idle thread it started -// once a promoter fires 50 ms later, then the caller's policy after a -// promoter stopped before its 10 s bound, and how long stopping took. -static int test_promote(void) -{ - struct test_thread thread = {.lock = PTHREAD_MUTEX_INITIALIZER, - .changed = PTHREAD_COND_INITIALIZER}; - struct promoter promoter; - pthread_t handle; - long caller; - long started; - int64_t start; - - if (!promoter_start(&promoter, clock_ns(CLOCK_MONOTONIC) - IDLE_BOUND_NS + - 50 * NSEC_PER_MSEC)) - return 1; - set_idle_priority(0, true); - if (pthread_create(&handle, NULL, test_thread_run, &thread) != 0) - return 1; - pthread_mutex_lock(&thread.lock); - while (thread.tid == 0) - pthread_cond_wait(&thread.changed, &thread.lock); - pthread_mutex_unlock(&thread.lock); - pthread_join(promoter.thread, NULL); - caller = syscall(SYS_sched_getscheduler, 0); - started = syscall(SYS_sched_getscheduler, thread.tid); - pthread_mutex_lock(&thread.lock); - thread.done = true; - pthread_cond_broadcast(&thread.changed); - pthread_mutex_unlock(&thread.lock); - pthread_join(handle, NULL); - printf("promoted caller=%ld thread=%ld\n", caller, started); - - start = clock_ns(CLOCK_MONOTONIC); - if (!promoter_start(&promoter, - start - IDLE_BOUND_NS + 10 * NSEC_PER_SEC)) - return 1; - set_idle_priority(0, true); - promoter_stop(&promoter); - printf("stopped caller=%ld stop_ms=%" PRId64 "\n", - syscall(SYS_sched_getscheduler, 0), - (clock_ns(CLOCK_MONOTONIC) - start) / NSEC_PER_MSEC); - return 0; -} - -static int64_t g_test_fork_burn_ms; - -// Spends MS milliseconds of this process's CPU time. -static void burn_cpu(int64_t ms) -{ - int64_t until = cpu_time_ns() + ms * NSEC_PER_MSEC; - - while (cpu_time_ns() < until) - ; -} - -static void test_fork_burn(void) -{ - burn_cpu(g_test_fork_burn_ms); -} - -// Forks a repair step 7 worker as start_restore does, after PRE_MS of -// preparation, with a fork that costs the parent FORK_MS; the worker spends -// CHILD_MS and prints its preparation share and step 7's CPU time. -static int test_deferred_restore_cpu(int argc, char **argv) -{ - struct restore_timing timing = {0}; - int64_t start = cpu_time_ns(); - int status; - pid_t pid; - - if (argc != 3) - return 2; - g_test_fork_burn_ms = strtoll(argv[1], NULL, 10); - if (pthread_atfork(NULL, test_fork_burn, NULL) != 0) - return 1; - burn_cpu(strtoll(argv[0], NULL, 10)); - pid = fork_deferred_restore(start, &timing); - if (pid == 0) { - burn_cpu(strtoll(argv[2], NULL, 10)); - printf("prior_ms=%" PRId64 " total_ms=%" PRId64 "\n", - timing.prior_cpu_ns / NSEC_PER_MSEC, - deferred_restore_cpu_ns(&timing) / NSEC_PER_MSEC); - fflush(stdout); - _exit(0); - } - if (pid < 0 || waitpid(pid, &status, 0) != pid) - return 1; - return WIFEXITED(status) ? WEXITSTATUS(status) : 1; -} - -static int cmd_test(int argc, char **argv) -{ - const char *name = argc > 0 ? argv[0] : ""; - - g_test = true; - g_report_only = true; - argc--; - argv++; - if (strcmp(name, "packet") == 0) - return test_packet(argc, argv); - if (strcmp(name, "sample") == 0) - return test_sample(argc, argv); - if (strcmp(name, "pair") == 0 && argc == 3) { - int64_t theta; - int64_t epsilon; - - pair_clocks(strtoll(argv[0], NULL, 10), strtoll(argv[1], NULL, 10), - strtoull(argv[2], NULL, 10), &theta, &epsilon); - printf("theta_ns=%" PRId64 " epsilon_ns=%" PRId64 "\n", theta, epsilon); - return 0; - } - if (strcmp(name, "kmsg") == 0) - return test_lines(argc, argv, LINES_KMSG); - if (strcmp(name, "console") == 0 && argc == 2) { - g_async_output = strcmp(argv[1], "async") == 0; - console_write(argv[0]); - return 0; - } - if (strcmp(name, "exhaustive-leaf") == 0 && argc == 9) { - uint32_t value[4]; - uint32_t out_of_range[4]; - char detail[DETAIL_MAX]; - - for (int index = 0; index < 4; index++) { - value[index] = (uint32_t)strtoul(argv[1 + index], NULL, 0); - out_of_range[index] = (uint32_t)strtoul(argv[5 + index], NULL, 0); - } - if (exhaustive_leaf_ok((uint32_t)strtoul(argv[0], NULL, 0), value, - out_of_range, detail, sizeof(detail))) - printf("pass\n"); - else - printf("fail %s\n", detail); - return 0; - } - if (strcmp(name, "exhaustive-report") == 0 && argc == 4) { - struct exhaustive state; - - memset(&state, 0, sizeof(state)); - exhaustive_report(&state, argv[0], atoi(argv[1]), - strcmp(argv[2], "pass") == 0, argv[3]); - printf("failures=%d\n", state.failures); - return 0; - } - if (strcmp(name, "exhaustive-summary") == 0 && argc == 2) { - struct exhaustive state; - int status; - - memset(&state, 0, sizeof(state)); - state.failures = atoi(argv[1]); - status = exhaustive_summary(&state, atoi(argv[0])); - printf("exit=%d\n", status); - return 0; - } - if (strcmp(name, "boot-log") == 0) - return test_lines(argc, argv, LINES_BOOT_LOG); - if (strcmp(name, "syslog") == 0) - return test_lines(argc, argv, LINES_SYSLOG); - if (strcmp(name, "timer-list") == 0) - return test_timer_list(argc, argv); - if (strcmp(name, "correction") == 0) - return test_correction(argc, argv); - if (strcmp(name, "frequency") == 0 && argc == 1) { - int64_t frequency = restore_frequency((int32_t)strtol(argv[0], NULL, 10)); - - printf("frequency=%" PRId64 " ppb=%" PRId64 "\n", frequency, - frequency_to_ppb(frequency)); - return 0; - } - if (strcmp(name, "event") == 0 && argc == 6) { - char line[EVENT_MAX + 1]; - enum phase phase = PHASE_RUNTIME; - - for (size_t index = 0; index < ARRAY_SIZE(k_phase_names); index++) { - if (strcmp(argv[2], k_phase_names[index]) == 0) - phase = (enum phase)index; - } - g_report_only = false; - format_event(line, argv[0], argv[1], phase, - (uint32_t)strtoul(argv[3], NULL, 10), - strtoll(argv[4], NULL, 10), argv[5]); - fputs(line, stdout); - return 0; - } - if (strcmp(name, "state") == 0) - return test_state(argc, argv); - if (strcmp(name, "status") == 0 && argc == 2) { - static char text[4096]; - struct time_state state; - char out[1024]; - bool ok; - - if (test_text(argv[0], text, sizeof(text)) != 0 || - state_parse(text, &state) != 0) - return 2; - g_report_only = strcmp(argv[1], "report-only") == 0; - ok = format_status(&state, out, sizeof(out)); - fputs(out, stdout); - printf("ok=%d\n", ok ? 1 : 0); - return 0; - } - if (strcmp(name, "restore-record") == 0) - return test_restore_record(argc, argv); - if (strcmp(name, "deferred-restore-cpu") == 0) - return test_deferred_restore_cpu(argc, argv); - if (strcmp(name, "deferral") == 0 && argc == 0) { - printf("deferred_start_ms=%" PRId64 " idle_bound_ms=%" PRId64 "\n", - (int64_t)(DEFERRED_START_NS / NSEC_PER_MSEC), - (int64_t)(IDLE_BOUND_NS / NSEC_PER_MSEC)); - return 0; - } - if (strcmp(name, "parallel") == 0) - return test_parallel(argc, argv); - if (strcmp(name, "promote") == 0 && argc == 0) - return test_promote(); - if (strcmp(name, "idle-priority") == 0 && argc == 0) { - long idle; - - set_idle_priority(0, true); - idle = syscall(SYS_sched_getscheduler, 0); - set_idle_priority(0, false); - printf("idle=%ld normal=%ld\n", idle, - syscall(SYS_sched_getscheduler, 0)); - return 0; - } - return test_checks(name, argc, argv); -} - -// --------------------------------------------------------------------------- -// Entry point -// --------------------------------------------------------------------------- - -static bool cmdline_requests_report_only(void) -{ - char text[4096]; - - if (read_text("/proc/cmdline", text, sizeof(text)) < 0) - return false; - for (char *token = strtok(text, " \t\n"); token != NULL; - token = strtok(NULL, " \t\n")) { - if (strcmp(token, "nvx_time_abi=report-only") == 0) - return true; - } - return false; -} - -static int usage(void) -{ - fprintf(stderr, - "usage: nvx-time [--report-only] COMMAND\n" - " boot\n" - " check --phase capture|restore [--new-cpus LIST]\n" - " pre-capture\n" - " capture 0|1 GENERATION_ID ENTROPY_FILE [--finish]\n" - " cancel-capture\n" - " restore-finish [--ack] [--new-cpus LIST]\n" - " status\n" - " sample\n" - " generation-id\n" - " exhaustive\n" - " test NAME ARGUMENTS...\n"); - return 2; -} - -int main(int argc, char **argv) -{ - char *arguments[32]; - int count = 0; - const char *command; - - for (int index = 1; index < argc; index++) { - if (strcmp(argv[index], "--report-only") == 0) - g_report_only = true; - else if (count < (int)ARRAY_SIZE(arguments)) - arguments[count++] = argv[index]; - else - return usage(); - } - if (count == 0) - return usage(); - command = arguments[0]; - if (strcmp(command, "test") == 0) - return cmd_test(count - 1, arguments + 1); - // An inherited SIG_IGN would make the kernel reap the release child and - // the daemon before waitpid() sees them. - signal(SIGCHLD, SIG_DFL); - if (cmdline_requests_report_only()) - g_report_only = true; - if (strcmp(command, "boot") == 0 && count == 1) - return cmd_boot(); - if (strcmp(command, "check") == 0) - return cmd_check(count - 1, arguments + 1); - if (strcmp(command, "pre-capture") == 0 && count == 1) - return cmd_pre_capture(); - if (strcmp(command, "capture") == 0) - return cmd_capture(count - 1, arguments + 1); - if (strcmp(command, "cancel-capture") == 0 && count == 1) - return cmd_cancel_capture(); - if (strcmp(command, "restore-finish") == 0) - return cmd_restore_finish(count - 1, arguments + 1); - if (strcmp(command, "status") == 0 && count == 1) - return cmd_status(); - if (strcmp(command, "sample") == 0 && count == 1) - return cmd_sample(); - if (strcmp(command, "generation-id") == 0 && count == 1) - return cmd_generation_id(); - if (strcmp(command, "exhaustive") == 0 && count == 1) - return exhaustive_run(); - return usage(); -} diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 242a03c91..196a08a59 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -155,8 +155,8 @@ as `nvx/`. ## Guest binaries The `guest` feature adds the programs that run in NVX's Linux guests: the -`nvx::guest` module and one binary per program. Guest images install these -executables as `/sbin/nvx-*`. +`nvx::guest` module and one binary per program. The Alpine, Ubuntu, and Azure +Linux images ship all nine at their established `/sbin/nvx-*` paths. | Binary | Program | | --- | --- | @@ -181,6 +181,10 @@ python3 scripts/nvx.py build-guest-binaries \ --output build/nvx-rs-guest-binaries ``` +Native guest builds, the digest-pinned Docker Rust stage, and CI all call this +routine. Docker builds the set once and installs identical bytes in all three +guest images; Ubuntu determinism checks rebuild from fresh targets. + ## Testing ```bash @@ -189,12 +193,11 @@ NVX_BUNDLE=skip cargo test --all-features # unit, golden-fixture, and fake-Ope ``` The tests need no hypervisor. The fake-OpenVMM test drives `Instance` end to end -against `nvx-fake-openvmm`, and the protocol tests compare the wire constants -with the guest agent's source in `guest/common/nvx-managed-agent.c`. On Linux, -a loopback test drives the host `ControlSession` through a broker shim into the -real managed agent's session, dispatch, and execution engine, and `nvx.py` also -lints and tests the guest binaries, and runs their integration tests against a -build of them: +against `nvx-fake-openvmm`, and shared protocol definitions plus golden byte +fixtures cover both sides of every record. On Linux, a loopback test drives the +host `ControlSession` through a broker shim into the real managed agent's +session, dispatch, and execution engine, and `nvx.py` also lints and tests the +guest binaries, and runs their integration tests against a build of them: ```bash python3 ../scripts/nvx.py check-guest-binaries diff --git a/nvx-rs/src/guest/managed_agent/dispatch.rs b/nvx-rs/src/guest/managed_agent/dispatch.rs index ccd83d505..48b2f555e 100644 --- a/nvx-rs/src/guest/managed_agent/dispatch.rs +++ b/nvx-rs/src/guest/managed_agent/dispatch.rs @@ -151,21 +151,24 @@ fn handle_data( mod tests { use std::ffi::CString; use std::io::{Read, Write}; + use std::os::unix::ffi::OsStrExt; use std::os::unix::net::UnixStream; use std::thread; use crate::protocol::{ - APP_ERROR, OUTER_ACK, OUTER_CREDIT, OUTER_GUEST_ATTACH, OUTER_HEADER_LEN, - OUTER_RECEIVE_WINDOW, OuterHeader, decode_ack_payload, decode_app, decode_credit_payload, - decode_outer_header, encode_app, encode_outer, + APP_ERROR, APP_EXIT, APP_STDERR, OUTER_ACK, OUTER_CREDIT, OUTER_GUEST_ATTACH, + OUTER_HEADER_LEN, OUTER_RECEIVE_WINDOW, OuterHeader, Workload, WorkloadEnvironment, + decode_ack_payload, decode_app, decode_credit_payload, decode_outer_header, encode_app, + encode_exec_payload, encode_outer, }; use super::super::seam::{Paths, TestSystem}; use super::*; - use crate::guest::sys::FileDescriptor; + use crate::guest::sys::{FileDescriptor, MountFlags}; const INSTANCE: [u8; 16] = [7; 16]; + #[derive(Debug, PartialEq, Eq)] struct Response { kind: u8, request_id: u64, @@ -182,10 +185,13 @@ mod tests { impl Harness { fn new(direct: bool) -> Self { + Self::with_system(direct, TestSystem::new(Paths::default()), 0) + } + + fn with_system(direct: bool, mut system: TestSystem, expected_mappings: u32) -> Self { let (host, guest) = UnixStream::pair().unwrap(); let descriptor = FileDescriptor::from_owned(guest.into()); let thread = thread::spawn(move || { - let mut system = TestSystem::new(Paths::default()); let config = AgentConfig { rootfs: CString::new(if direct { "-" } else { "/rootfs" }).unwrap(), hostname: c"test".to_owned(), @@ -196,7 +202,10 @@ mod tests { direct, }; let mut session = Session::new(descriptor); - let mut mappings = HostMappings::default(); + let mut mappings = HostMappings { + expected: expected_mappings, + ..HostMappings::default() + }; let outcome = run(&mut system, &mut session, &config, &mut mappings); (outcome, system) }); @@ -220,7 +229,13 @@ mod tests { } fn request(&mut self, kind: u8, payload: &[u8]) -> Response { - let frame = encode_app(kind, 42, 0, payload); + let mut responses = self.request_all(42, kind, payload); + assert_eq!(responses.len(), 1, "{responses:?}"); + responses.pop().unwrap() + } + + fn request_all(&mut self, request_id: u64, kind: u8, payload: &[u8]) -> Vec { + let frame = encode_app(kind, request_id, 0, payload); self.host .write_all( &encode_outer(OUTER_DATA, &INSTANCE, 1, self.host_sequence, &frame).unwrap(), @@ -234,18 +249,25 @@ mod tests { ); assert_eq!(decode_credit_payload(&payload).unwrap(), frame.len() as u32); self.guest_sequence += 1; - let (data, frame) = self.read_outer(); - assert_eq!( - (data.record_type, data.sequence), - (OUTER_DATA, self.guest_sequence) - ); - self.guest_sequence += 1; - let frame = decode_app(&frame).unwrap(); - Response { - kind: frame.kind, - request_id: frame.request_id, - status: frame.status, - payload: frame.payload.to_vec(), + let mut responses = Vec::new(); + loop { + let (data, frame) = self.read_outer(); + assert_eq!( + (data.record_type, data.sequence), + (OUTER_DATA, self.guest_sequence) + ); + self.guest_sequence += 1; + let frame = decode_app(&frame).unwrap(); + let terminal = matches!(frame.kind, APP_READY | APP_ERROR | APP_EXIT | APP_STOPPED); + responses.push(Response { + kind: frame.kind, + request_id: frame.request_id, + status: frame.status, + payload: frame.payload.to_vec(), + }); + if terminal { + return responses; + } } } @@ -282,6 +304,86 @@ mod tests { } } + fn response(kind: u8, request_id: u64, status: i32, payload: &[u8]) -> Response { + Response { + kind, + request_id, + status, + payload: payload.to_vec(), + } + } + + fn exec_payload(cwd: Option<&str>) -> Vec { + encode_exec_payload(&Workload { + argv: vec!["/bin/true".to_owned()], + timeout_ms: 0, + cwd, + environment: WorkloadEnvironment::Default, + }) + .unwrap() + } + + fn direct_system() -> (tempfile::TempDir, TestSystem) { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("cgroup"); + let execution = root.join("nvx-exec"); + std::fs::create_dir_all(&execution).unwrap(); + std::fs::write(root.join("cgroup.procs"), b"").unwrap(); + std::fs::write(root.join("cgroup.subtree_control"), b"").unwrap(); + std::fs::write(execution.join("pids.current"), b"0\n").unwrap(); + std::fs::write(execution.join("cgroup.kill"), b"").unwrap(); + std::fs::write(execution.join("cgroup.procs"), b"").unwrap(); + let paths = Paths { + cgroup_root: root, + ..Paths::default() + }; + (temporary, TestSystem::new(paths)) + } + + fn mapping_system() -> (tempfile::TempDir, TestSystem) { + let temporary = tempfile::tempdir().unwrap(); + let hostfs = temporary.path().join("hostfs"); + std::fs::create_dir_all(hostfs.join("root/0/src")).unwrap(); + std::fs::write(hostfs.join("root/0/notes.txt"), b"notes").unwrap(); + std::fs::create_dir(hostfs.join("root/1")).unwrap(); + let paths = Paths { + hostfs_dir: hostfs, + ..Paths::default() + }; + (temporary, TestSystem::new(paths)) + } + + fn maps_payload(first: u32, entries: &[(&[u8], &[u8], u16)], count: Option) -> Vec { + let mut payload = Vec::new(); + payload.extend_from_slice(&first.to_le_bytes()); + payload.extend_from_slice(&count.unwrap_or(entries.len() as u32).to_le_bytes()); + for (source, target, flags) in entries { + payload.extend_from_slice(&flags.to_le_bytes()); + payload.extend_from_slice(&(source.len() as u16).to_le_bytes()); + payload.extend_from_slice(&(target.len() as u16).to_le_bytes()); + payload.extend_from_slice(source); + payload.extend_from_slice(target); + } + payload + } + + fn assert_environment_failure(mut harness: Harness, diagnostic_prefix: &[u8]) { + let replies = harness.request_all(42, APP_EXEC, &exec_payload(None)); + assert_eq!(replies.len(), 2, "{replies:?}"); + assert_eq!( + (replies[0].kind, replies[0].request_id, replies[0].status), + (APP_STDERR, 42, 0) + ); + assert!( + replies[0].payload.starts_with(diagnostic_prefix), + "{:?}", + replies[0].payload + ); + assert_eq!(replies[1], response(APP_EXIT, 42, 125, b"exit")); + let system = harness.stop(); + assert_eq!(system.spawn_calls, 0); + } + #[test] fn agent_advertises_the_control_features_of_its_mode_and_stops() { for (direct, expected) in [ @@ -317,6 +419,273 @@ mod tests { harness.stop(); } + #[test] + fn containment_failures_refuse_consecutive_execs_until_verified_empty() { + if super::super::isolate_process_test( + "NVX_DISPATCH_CONTAINMENT_TEST_CHILD", + "guest::managed_agent::dispatch::tests::containment_failures_refuse_consecutive_execs_until_verified_empty", + ) { + return; + } + let _process = super::super::PROCESS_TEST_LOCK.lock().unwrap(); + let payload = exec_payload(None); + for state in ["missing", "directory", "malformed", "charged"] { + let (_temporary, system) = direct_system(); + let tasks = system.paths.cgroup_root.join("nvx-exec/pids.current"); + match state { + "missing" => std::fs::remove_file(&tasks).unwrap(), + "directory" => { + std::fs::remove_file(&tasks).unwrap(); + std::fs::create_dir(&tasks).unwrap(); + } + "malformed" => std::fs::write(&tasks, b"max\n").unwrap(), + "charged" => std::fs::write(&tasks, b"1\n").unwrap(), + _ => unreachable!(), + } + let mut harness = Harness::with_system(true, system, 0); + for request_id in [42, 43] { + assert_eq!( + harness.request_all(request_id, APP_EXEC, &payload), + vec![response(APP_ERROR, request_id, 125, b"containment-failed")], + "{state}" + ); + } + + if state == "directory" { + std::fs::remove_dir(&tasks).unwrap(); + } + std::fs::write(&tasks, b"0\n").unwrap(); + assert_eq!( + harness.request_all(44, APP_EXEC, &payload), + vec![response( + APP_ERROR, + 44, + 125, + protocol::LAUNCH_FAILED.as_bytes() + )], + "{state}" + ); + let system = harness.stop(); + assert_eq!(system.spawn_calls, 1, "{state}"); + } + } + + #[test] + fn mappings_mount_in_order_and_unblock_workloads_through_dispatch() { + let (temporary, system) = mapping_system(); + let export = system.paths.hostfs_dir.join("root"); + let guest = temporary.path().join("guest"); + let source = guest.join("src"); + let notes = guest.join("src/notes.txt"); + let out = guest.join("out"); + let first = maps_payload( + 0, + &[ + ( + b"0/src", + source.as_os_str().as_bytes(), + protocol::MAP_READ_ONLY, + ), + (b"0/notes.txt", notes.as_os_str().as_bytes(), 0), + ], + None, + ); + let second = maps_payload(2, &[(b"1", out.as_os_str().as_bytes(), 0)], None); + let mut harness = Harness::with_system(true, system, 3); + let mut replies = Vec::new(); + replies.extend(harness.request_all(1, APP_EXEC, &[])); + replies.extend(harness.request_all(2, APP_MAPS, &first)); + replies.extend(harness.request_all(3, APP_EXEC, &[])); + replies.extend(harness.request_all(4, APP_MAPS, &second)); + replies.extend(harness.request_all(5, APP_EXEC, &[])); + assert_eq!( + replies, + vec![ + response(APP_ERROR, 1, Errno::EAGAIN as i32, b"mappings-incomplete"), + response(APP_READY, 2, 0, b""), + response(APP_ERROR, 3, Errno::EAGAIN as i32, b"mappings-incomplete"), + response(APP_READY, 4, 0, b""), + response(APP_ERROR, 5, Errno::EINVAL as i32, b"invalid-request"), + ] + ); + + let system = harness.stop(); + let remount = MountFlags::MS_BIND + | MountFlags::MS_REMOUNT + | MountFlags::MS_NOSUID + | MountFlags::MS_NODEV; + assert_eq!( + system.mounts, + vec![ + ( + Some(export.join("0/src").as_os_str().as_bytes().to_vec()), + source.as_os_str().as_bytes().to_vec(), + MountFlags::MS_BIND, + ), + ( + None, + source.as_os_str().as_bytes().to_vec(), + remount | MountFlags::MS_RDONLY, + ), + ( + Some(export.join("0/notes.txt").as_os_str().as_bytes().to_vec()), + notes.as_os_str().as_bytes().to_vec(), + MountFlags::MS_BIND, + ), + (None, notes.as_os_str().as_bytes().to_vec(), remount,), + ( + Some(export.join("1").as_os_str().as_bytes().to_vec()), + out.as_os_str().as_bytes().to_vec(), + MountFlags::MS_BIND, + ), + (None, out.as_os_str().as_bytes().to_vec(), remount), + ] + ); + assert!(source.is_dir()); + assert!(notes.is_file()); + assert!(out.is_dir()); + assert_eq!(system.spawn_calls, 0); + } + + #[test] + fn mapping_sequence_errors_are_reported_through_dispatch() { + let (temporary, system) = mapping_system(); + let target = temporary.path().join("guest/src"); + let target = target.as_os_str().as_bytes(); + let entry = [(b"0/src".as_slice(), target, 0)]; + let mut harness = Harness::with_system(true, system, 1); + let mut replies = Vec::new(); + replies.extend(harness.request_all(1, APP_MAPS, &maps_payload(1, &entry, None))); + replies.extend(harness.request_all( + 2, + APP_MAPS, + &maps_payload(0, &[(b"0/src", target, 0), (b"0/src", target, 0)], None), + )); + replies.extend(harness.request_all(3, APP_MAPS, &maps_payload(0, &entry, None))); + replies.extend(harness.request_all(4, APP_MAPS, &maps_payload(1, &entry, None))); + assert_eq!( + replies, + vec![ + response(APP_ERROR, 1, Errno::EINVAL as i32, b"invalid-request"), + response(APP_ERROR, 2, Errno::EINVAL as i32, b"invalid-request"), + response(APP_READY, 3, 0, b""), + response(APP_ERROR, 4, Errno::EINVAL as i32, b"invalid-request"), + ] + ); + let system = harness.stop(); + assert_eq!(system.mounts.len(), 2); + assert_eq!(system.spawn_calls, 0); + } + + #[test] + fn a_failed_mapping_keeps_refusing_workloads_through_dispatch() { + let (temporary, system) = mapping_system(); + let target = temporary.path().join("guest/missing"); + let missing = maps_payload(0, &[(b"0/missing", target.as_os_str().as_bytes(), 0)], None); + let valid = maps_payload(0, &[(b"0/src", target.as_os_str().as_bytes(), 0)], None); + let mut harness = Harness::with_system(true, system, 2); + let mut replies = Vec::new(); + replies.extend(harness.request_all(1, APP_MAPS, &missing)); + replies.extend(harness.request_all(2, APP_EXEC, &[])); + replies.extend(harness.request_all(3, APP_MAPS, &valid)); + assert_eq!( + replies, + vec![ + response(APP_ERROR, 1, Errno::ENOENT as i32, b"mapping-failed"), + response(APP_ERROR, 2, Errno::EAGAIN as i32, b"mappings-incomplete"), + response(APP_ERROR, 3, Errno::EINVAL as i32, b"invalid-request"), + ] + ); + let system = harness.stop(); + assert!(system.mounts.is_empty()); + assert_eq!(system.spawn_calls, 0); + } + + #[test] + fn unannounced_mapping_tables_are_refused_through_dispatch() { + let (temporary, system) = mapping_system(); + let target = temporary.path().join("guest/src"); + let mapping = maps_payload(0, &[(b"0/src", target.as_os_str().as_bytes(), 0)], None); + let mut harness = Harness::with_system(true, system, 0); + assert_eq!( + harness.request_all(1, APP_MAPS, &mapping), + vec![response( + APP_ERROR, + 1, + Errno::EINVAL as i32, + b"invalid-request" + )] + ); + assert_eq!( + harness.request_all(2, APP_EXEC, &[]), + vec![response( + APP_ERROR, + 2, + Errno::EINVAL as i32, + b"invalid-request" + )] + ); + let system = harness.stop(); + assert!(system.mounts.is_empty()); + assert_eq!(system.spawn_calls, 0); + } + + #[test] + fn sandbox_does_not_launch_when_descriptor_export_fails() { + let mut system = TestSystem::new(Paths::default()); + system.fail_environment = Some(b"NVX_EXEC_CONFIG_FD".to_vec()); + assert_environment_failure( + Harness::with_system(false, system, 0), + b"nvx-managed-agent: cannot export execution configuration: ", + ); + } + + #[test] + fn direct_does_not_launch_when_identity_export_fails() { + // Direct mode verifies the exec cgroup first, which reaps any child of this process. + if super::super::isolate_process_test( + "NVX_DISPATCH_HOME_EXPORT_TEST_CHILD", + "guest::managed_agent::dispatch::tests::direct_does_not_launch_when_identity_export_fails", + ) { + return; + } + let _process = super::super::PROCESS_TEST_LOCK.lock().unwrap(); + let (_temporary, mut system) = direct_system(); + system.fail_environment = Some(b"HOME".to_vec()); + assert_environment_failure( + Harness::with_system(true, system, 0), + b"nvx-managed-agent: cannot configure workload environment: ", + ); + } + + #[test] + fn pwd_export_failures_refuse_launch_without_spawning() { + // Direct mode verifies the exec cgroup first, which reaps any child of this process. + if super::super::isolate_process_test( + "NVX_DISPATCH_PWD_EXPORT_TEST_CHILD", + "guest::managed_agent::dispatch::tests::pwd_export_failures_refuse_launch_without_spawning", + ) { + return; + } + let _process = super::super::PROCESS_TEST_LOCK.lock().unwrap(); + let (temporary, mut system) = direct_system(); + system.fail_environment = Some(b"PWD".to_vec()); + let mut harness = Harness::with_system(true, system, 0); + for (request_id, cwd) in [(42, None), (43, Some(temporary.path().to_str().unwrap()))] { + assert_eq!( + harness.request_all(request_id, APP_EXEC, &exec_payload(cwd)), + vec![response( + APP_ERROR, + request_id, + 125, + protocol::LAUNCH_FAILED.as_bytes() + )] + ); + } + let system = harness.stop(); + assert_eq!(system.spawn_calls, 0); + } + #[test] fn agent_refuses_malformed_working_directories() { let mut harness = Harness::new(true); diff --git a/nvx-rs/src/guest/managed_agent/mappings.rs b/nvx-rs/src/guest/managed_agent/mappings.rs index 4574f3306..1979a6ff2 100644 --- a/nvx-rs/src/guest/managed_agent/mappings.rs +++ b/nvx-rs/src/guest/managed_agent/mappings.rs @@ -283,11 +283,19 @@ mod tests { &[good, (b"0//src", target.as_os_str().as_bytes(), 0)], None, ), + payload( + 0, + &[good, (b"0/s\0rc", target.as_os_str().as_bytes(), 0)], + None, + ), payload( 0, &[good, (b"0/src", target.as_os_str().as_bytes(), 2)], None, ), + payload(0, &[good, (b"", target.as_os_str().as_bytes(), 0)], None), + [payload(0, &[good], None), vec![0]].concat(), + payload(0, &[good], Some(2)), payload(0, &[], Some(0)), vec![0, 0, 0], ]; diff --git a/nvx-rs/src/guest/managed_agent/seam.rs b/nvx-rs/src/guest/managed_agent/seam.rs index f149d16b2..30880c0eb 100644 --- a/nvx-rs/src/guest/managed_agent/seam.rs +++ b/nvx-rs/src/guest/managed_agent/seam.rs @@ -193,6 +193,7 @@ pub(crate) struct TestSystem { pub(crate) diagnostics: Vec, pub(crate) fail_environment: Option>, pub(crate) execs: Vec, + pub(crate) spawn_calls: usize, pub(crate) tty_configured: usize, pub(crate) tty_drained: usize, pub(crate) close_barrier_reader: Option, @@ -211,6 +212,7 @@ impl TestSystem { diagnostics: Vec::new(), fail_environment: None, execs: Vec::new(), + spawn_calls: 0, tty_configured: 0, tty_drained: 0, close_barrier_reader: None, @@ -326,6 +328,7 @@ impl System for TestSystem { } fn spawn(&mut self, _request: SpawnRequest<'_>) -> Result { + self.spawn_calls += 1; Err(Errno::ENOSYS) } } diff --git a/nvx-rs/src/guest/reseed.rs b/nvx-rs/src/guest/reseed.rs index 877825329..1328f15d8 100644 --- a/nvx-rs/src/guest/reseed.rs +++ b/nvx-rs/src/guest/reseed.rs @@ -22,7 +22,7 @@ const SEED_BYTES: usize = 64; const GENERATION_ID_BYTES: usize = 16; /// Number of random bytes printed by `--sample`. const RANDOM_SAMPLE_BYTES: usize = 32; -/// Exact usage diagnostic inherited from the C program. +/// Exact public usage diagnostic. const USAGE: &[u8] = b"usage: nvx-reseed --sample | [--generation-only] SEED_FILE [PREVIOUS_GENERATION_ID]\n"; @@ -52,8 +52,8 @@ fn run( { return print_random_sample(out, err); } - // As in the C program, a first argument of `--generation-only` names the seed file unless - // three arguments follow the program name. + // A first argument of `--generation-only` names the seed file unless three arguments follow + // the program name. let (generation_only, seed_path, previous) = match args { [_, seed] => (false, seed, None), [_, seed, previous] => (false, seed, Some(previous)), @@ -107,8 +107,7 @@ fn reseed(random: &CStr, seed: &[u8; SEED_BYTES], err: &mut dyn Write) -> Result }) } -/// Reads exactly `bytes.len()` bytes from the file at `path`. Like the C program, it does not -/// retry after `EINTR`. +/// Reads exactly `bytes.len()` bytes from the file at `path`, without retrying after `EINTR`. fn read_seed(path: &CStr, bytes: &mut [u8], err: &mut dyn Write) -> Result<(), CommandError> { let fail = |err: &mut dyn Write, operation: &[u8], reason: &[u8]| { let path = path.to_bytes(); diff --git a/nvx-rs/src/guest/time/decoders.rs b/nvx-rs/src/guest/time/decoders.rs index 43ce8f292..c2a48a13d 100644 --- a/nvx-rs/src/guest/time/decoders.rs +++ b/nvx-rs/src/guest/time/decoders.rs @@ -1054,6 +1054,105 @@ mod tests { ); } + #[test] + fn watcher_rules_match_linux_6_18_messages() { + let messages = [ + ( + "tsc: Marking TSC unstable due to clocksource watchdog", + Some(ViolationCode::TscUnstable), + ), + ( + "clocksource: timekeeping watchdog on CPU0: Marking clocksource \ + 'tsc-early' as unstable because the skew is too large:", + Some(ViolationCode::ClocksourceUnstable), + ), + ( + "clocksource: Switched to clocksource refined-jiffies", + Some(ViolationCode::ClocksourceSwitch), + ), + ( + "clocksource: CPUs 1 ahead of CPU 0 for clocksource tsc.", + Some(ViolationCode::ClocksourceSkew), + ), + ( + "clocksource: CPUs 2-3 behind CPU 0 for clocksource tsc.", + Some(ViolationCode::ClocksourceSkew), + ), + ( + "TSC synchronization [CPU#0 -> CPU#1]:", + Some(ViolationCode::TscWarp), + ), + ( + "Measured 45 cycles TSC warp between CPUs, turning off TSC clock.", + Some(ViolationCode::TscWarp), + ), + ( + "TSC warped randomly between CPUs", + Some(ViolationCode::TscWarp), + ), + ( + "[Firmware Bug]: TSC ADJUST differs: CPU1 0 --> 100. Restoring", + Some(ViolationCode::TscAdjust), + ), + ( + "rcu: INFO: rcu_preempt detected stalls on CPUs/tasks:", + Some(ViolationCode::RcuStall), + ), + ( + "rcu: INFO: rcu_preempt self-detected stall on CPU", + Some(ViolationCode::RcuStall), + ), + ( + "rcu: INFO: rcu_preempt detected expedited stalls on CPUs/tasks: { 1-... }", + Some(ViolationCode::RcuStall), + ), + ( + "rcu: rcu_preempt kthread starved for 2603 jiffies! g345 f0x0", + Some(ViolationCode::RcuStarved), + ), + ( + "rcu: rcu_preempt kthread timer wakeup didn't happen for 2602 jiffies! \ + g345 f0x0", + Some(ViolationCode::RcuStarved), + ), + ( + "watchdog: BUG: soft lockup - CPU#0 stuck for 22s! [sh:123]", + Some(ViolationCode::SoftLockup), + ), + ( + "watchdog: Watchdog detected hard LOCKUP on cpu 1", + Some(ViolationCode::HardLockup), + ), + ( + "INFO: task kworker/0:1:12 blocked for more than 120 seconds.", + Some(ViolationCode::HungTask), + ), + ( + "unchecked MSR access error: RDMSR from 0x1ad at rIP: \ + 0xffffffff816f11ee (__rdmsr_on_cpu+0x1e/0x30)", + Some(ViolationCode::UncheckedMsr), + ), + ( + "[Firmware Bug]: CPU 1: APIC ID mismatch. CPUID: 0x0000 APIC: 0x0001", + Some(ViolationCode::ApicIdMismatch), + ), + ( + "clocksource: tsc: mask: 0xffffffffffffffff max_cycles: 0x1fa3", + None, + ), + ("Hyper-V: LAPIC Timer Frequency: 0x1e8480", None), + ("rcu: Hierarchical RCU implementation.", None), + ( + "NVX-TIME-ABI-VIOLATION: v=1 code=G_UNCHECKED_MSR \ + detail=\"unchecked MSR access error\"", + None, + ), + ]; + for (message, expected) in messages { + assert_eq!(match_watch_rule(message.as_bytes()), expected, "{message}"); + } + } + #[test] fn boot_log_reads_syslog_lines() { let log = scan_boot_log( @@ -1076,6 +1175,149 @@ mod tests { ); } + #[test] + fn boot_log_requires_the_identity_and_a_final_switch_to_tsc() { + const BOOT_LOG: &str = "Hypervisor detected: Microsoft Hyper-V\n\ + Hyper-V: privilege flags low 0x8860, high 0x0, ext 0x0, hints 0x0, misc 0x100\n\ + Hyper-V: LAPIC Timer Frequency: 0x1e8480\n\ + tsc: Detected 2194.843 MHz processor\n\ + clocksource: Switched to clocksource tsc-early\n\ + clocksource: Switched to clocksource tsc\n"; + assert_eq!(scan_boot_log(BOOT_LOG.as_bytes(), false).verdict(), Ok(())); + assert_eq!( + scan_boot_log(BOOT_LOG.replace("0x1e8480", "0x989680").as_bytes(), false).verdict(), + Ok(()) + ); + for (name, log) in [ + ( + "missing identity", + BOOT_LOG.lines().skip(1).collect::>().join("\n"), + ), + ("wrong LAPIC rate", BOOT_LOG.replace("0x1e8480", "0x1e8481")), + ( + "last switch", + format!("{BOOT_LOG}clocksource: Switched to clocksource refined-jiffies\n"), + ), + ( + "kvm-clock", + format!("{BOOT_LOG}kvm-clock: Using msrs 4b564d01\n"), + ), + ( + "refinement", + format!("{BOOT_LOG}tsc: Refined TSC clocksource calibration: 2194.843 MHz\n"), + ), + ( + "lapic token", + format!("{BOOT_LOG}APIC timer: using supplied frequency 200000000 Hz\n"), + ), + ( + "watcher record", + format!("{BOOT_LOG}tsc: Marking TSC unstable due to clocksource watchdog\n"), + ), + ] { + assert!( + scan_boot_log(log.as_bytes(), false).verdict().is_err(), + "{name}" + ); + } + } + + #[test] + fn boot_log_reads_syslog_read_all_lines() { + let lines = concat!( + "<6>[ 0.000000] Hypervisor detected: Microsoft Hyper-V\n", + "<6>[ 0.123456] [drm] a bracketed message is kept\n", + "<6>[ 1.007919] Hyper-V: privilege flags low 0x8860, high 0x0, ", + "ext 0x0, hints 0x0, misc 0x100\n", + "<6>[ 2.015838] Hyper-V: LAPIC Timer Frequency: 0x1e8480\n", + "<6>[ 3.023757] tsc: Detected 2194.843 MHz processor\n", + "<6>[ 4.031676] clocksource: Switched to clocksource tsc-early\n", + "<6>[ 5.039595] clocksource: Switched to clocksource tsc\n" + ); + assert_eq!(scan_boot_log(lines.as_bytes(), true).verdict(), Ok(())); + let own = concat!( + "<2>[ 3.000000] NVX-TIME-REPORT-VIOLATION: v=1 ", + "code=G_CONFORMANCE_C4 detail=\"forbidden record: kvm-clock: Using msrs\"\n" + ); + assert_eq!( + scan_boot_log(format!("{lines}{own}").as_bytes(), true).verdict(), + Ok(()) + ); + assert!( + scan_boot_log( + format!("{lines}<6>[ 4.000000] kvm-clock: Using msrs\n").as_bytes(), + true + ) + .verdict() + .unwrap_err() + .starts_with(b"forbidden record: kvm-clock") + ); + assert!( + scan_boot_log( + lines + .lines() + .skip(2) + .collect::>() + .join("\n") + .as_bytes(), + true + ) + .verdict() + .unwrap_err() + .starts_with(b"no 'Hypervisor") + ); + } + + #[test] + fn boot_log_reports_a_kernel_wx_mapping_apart_from_c4() { + const BOOT_LOG: &str = "<6>[ 0.100000] Hypervisor detected: Microsoft Hyper-V\n\ + <6>[ 0.100000] Hyper-V: privilege flags low 0x8860, high 0x0, ext 0x0\n\ + <6>[ 0.100000] Hyper-V: LAPIC Timer Frequency: 0x1e8480\n\ + <6>[ 0.100000] clocksource: Switched to clocksource tsc\n"; + const WX: &str = "<4>[ 0.412345] ------------[ cut here ]------------\n\ + <4>[ 0.412346] x86/mm: Found insecure W+X mapping at address 0xffffffffc0000000\n\ + <4>[ 0.412400] WARNING: CPU: 0 PID: 1 at \ + arch/x86/mm/dump_pagetables.c:246 note_wx+0x5a/0x70\n\ + <6>[ 0.412500] x86/mm: Checked W+X mappings: FAILED, 4 W+X pages found.\n"; + let log = scan_boot_log(format!("{WX}{BOOT_LOG}").as_bytes(), true); + assert_eq!(log.verdict(), Ok(())); + assert_eq!( + log.wx.as_deref(), + Some(b"x86/mm: Found insecure W+X mapping at address 0xffffffffc0000000".as_slice()) + ); + let passed = scan_boot_log( + format!( + "{BOOT_LOG}<6>[ 0.412500] x86/mm: Checked W+X mappings: passed, \ + no W+X pages found.\n" + ) + .as_bytes(), + true, + ); + assert_eq!((passed.verdict(), passed.wx), (Ok(()), None)); + let missing = scan_boot_log( + format!( + "{WX}<6>[ 0.100000] Hyper-V: privilege flags low 0x8860, high 0x0\n\ + <6>[ 0.100000] Hyper-V: LAPIC Timer Frequency: 0x1e8480\n\ + <6>[ 0.100000] clocksource: Switched to clocksource tsc\n" + ) + .as_bytes(), + true, + ); + assert!( + missing + .verdict() + .unwrap_err() + .starts_with(b"no 'Hypervisor") + ); + assert!(missing.wx.is_some()); + let own = concat!( + "<2>[ 3.000000] NVX-TIME-REPORT-VIOLATION: v=1 code=G_KERNEL_WX ", + "detail=\"x86/mm: Found insecure W+X mapping at address 0x0\"\n" + ); + let ignored = scan_boot_log(format!("{BOOT_LOG}{own}").as_bytes(), true); + assert_eq!((ignored.verdict(), ignored.wx), (Ok(()), None)); + } + #[test] fn timer_list_requires_one_shot_lapic_ticks() { let list = b"Tick Device: mode: 1\n\ @@ -1094,6 +1336,83 @@ mod tests { ); } + #[test] + fn timer_list_covers_two_cpu_linux_6_18_variants() { + fn cpu(cpu: usize, device: &str, state: i32, handler: &str, mode: i32) -> String { + format!( + "Tick Device: mode: {mode}\n\ + Per CPU device: {cpu}\n\ + Clock Event Device: {device}\n\ + max_delta_ns: 1374389534634\n\ + min_delta_ns: 1000\n\ + mult: 6710886\n\ + shift: 26\n\ + mode: {state}\n\ + next_event: 1342000000 nsecs\n\ + set_next_event: lapic_next_event\n\ + shutdown: lapic_timer_shutdown\n\ + periodic: lapic_timer_set_periodic\n\ + oneshot: lapic_timer_set_oneshot\n\ + oneshot stopped: lapic_timer_shutdown\n\ + event_handler: {handler}\n\ + retries: 0\n\n" + ) + } + fn list(broadcast: &str, second: Option<(&str, i32, &str, i32)>) -> String { + let mut text = format!( + "Tick Device: mode: 1\n\ + Broadcast device\n\ + Clock Event Device: {broadcast}\n\ + tick_broadcast_mask: 00\n\ + tick_broadcast_oneshot_mask: 00\n\n" + ); + text.push_str(&cpu(0, "lapic", 3, "hrtimer_interrupt", 1)); + if let Some((device, state, handler, mode)) = second { + text.push_str(&cpu(1, device, state, handler, mode)); + } + text + } + let selected = CpuMask::EMPTY.with(0).with(1); + assert_eq!( + timer_list_verdict( + list("", Some(("lapic", 4, "hrtimer_interrupt", 1))).as_bytes(), + selected + ), + Ok(()) + ); + for (name, text) in [ + ( + "deadline", + list( + "", + Some(("lapic-deadline", 3, "hrtimer_interrupt", 1)), + ), + ), + ( + "periodic tick", + list("", Some(("lapic", 3, "hrtimer_interrupt", 0))), + ), + ( + "periodic state", + list("", Some(("lapic", 2, "hrtimer_interrupt", 1))), + ), + ( + "handler", + list("", Some(("lapic", 3, "tick_handle_periodic", 1))), + ), + ("missing cpu", list("", None)), + ( + "broadcast", + list("pit", Some(("lapic", 4, "hrtimer_interrupt", 1))), + ), + ] { + assert!( + timer_list_verdict(text.as_bytes(), selected).is_err(), + "{name}" + ); + } + } + #[test] fn cpuinfo_rates_round_down_to_khz() { assert_eq!(parse_khz("2194.8439"), Some(2_194_843)); @@ -1107,6 +1426,48 @@ mod tests { ); assert_eq!((info.khz[0], info.khz[1]), (None, Some(2_194_843))); assert_eq!(verdict, Err("cpu 0 is not in /proc/cpuinfo".to_owned())); + + const FLAGS: &str = "tsc msr pae constant_tsc rdtscp nonstop_tsc tsc_known_freq \ + tsc_reliable hypervisor arat x2apic"; + let fixture = |flags: &str, cpus: usize| { + (0..cpus) + .map(|cpu| { + format!( + "processor\t: {cpu}\n\ + vendor_id\t: GenuineIntel\n\ + cpu family\t: 6\n\ + model\t\t: 85\n\ + cpu MHz\t\t: 2194.843\n\ + flags\t\t: fpu vme {flags}\n\ + bugs\t\t: spectre_v1\n\n" + ) + }) + .collect::() + }; + let selected = CpuMask::EMPTY.with(0).with(1); + let (info, verdict) = check_cpuinfo(fixture(FLAGS, 2).as_bytes(), selected, true); + assert_eq!(verdict, Ok(())); + assert_eq!( + (info.khz[0], info.khz[1]), + (Some(2_194_843), Some(2_194_843)) + ); + for (name, flags) in [ + ("tsc_reliable", FLAGS.replace("tsc_reliable ", "")), + ("deadline", format!("{FLAGS} tsc_deadline_timer")), + ("adjust", format!("{FLAGS} tsc_adjust")), + ] { + assert!( + check_cpuinfo(fixture(&flags, 2).as_bytes(), selected, true) + .1 + .is_err(), + "{name}" + ); + } + assert!( + check_cpuinfo(fixture(FLAGS, 1).as_bytes(), selected, true) + .1 + .is_err() + ); } #[test] @@ -1125,8 +1486,10 @@ mod tests { Some((3, CpuMask::EMPTY)) ); for record in [ + &b""[..], &b"new_cpus=4\ngeneration=3\n"[..], b"generation=3 new_cpus=1", + b"generation=3\nnew_cpus=7-4\n", b"generation=3\nnew_cpus= 1-2\n", b"generation=3\nnew_cpus=64\n", b"generation=3\nnew_cpus=\ntrailing=value\n", diff --git a/nvx-rs/src/guest/time/mod.rs b/nvx-rs/src/guest/time/mod.rs index 8ab087643..2ec1b791c 100644 --- a/nvx-rs/src/guest/time/mod.rs +++ b/nvx-rs/src/guest/time/mod.rs @@ -1,12 +1,12 @@ -//! `nvx-time`, the guest side of NVX time ABI v1, which replaces `guest/common/nvx-time.c`. +//! `nvx-time`, the guest side of NVX time ABI v1. //! //! It runs the conformance checks, the violation watcher and the wall-clock discipline in one //! daemon, and the snapshot agent's time steps: the guest preparation steps of the architecture //! guide's section 5.3.2 and the guest repair steps of its section 5.5.7, which the comments cite //! by number. Section 3.2.10 is the contract that it enforces. It prints nothing on success: //! checks record their results in the state file, and only violation events reach the console. -//! The C program defines the check IDs, the guest codes, and the formats of the output lines and -//! the state file, which this program keeps. +//! It keeps the check IDs, the guest codes, and the formats of the output lines and the state +//! file of the C program that it replaced. //! //! `nvx-time test NAME ARGUMENTS...` runs one of the C program's test hooks, which exercise its //! decoders, formats, and process structure without the hypervisor. diff --git a/nvx-rs/src/guest/time/state.rs b/nvx-rs/src/guest/time/state.rs index a375022ca..f71f9a9f2 100644 --- a/nvx-rs/src/guest/time/state.rs +++ b/nvx-rs/src/guest/time/state.rs @@ -391,6 +391,75 @@ impl fmt::Display for TimeState { mod tests { use super::*; + const ROUND_TRIP_STATE: &str = concat!( + "version=1\n", + "generation=2\n", + "boot_status=ok\n", + "capture_status=ok\n", + "restore_status=pending\n", + "boot_cpus=8\n", + "capture_cpus=8\n", + "restore_cpus=0\n", + "boot_elapsed_us=2400\n", + "capture_elapsed_us=310\n", + "restore_elapsed_us=1830\n", + "boot_cpu_us=1900\n", + "capture_cpu_us=290\n", + "restore_cpu_us=640\n", + "capture_generation=1\n", + "restore_generation=2\n", + "tsc_hz=2194843000\n", + "lapic_hz=200000000\n", + "discontinuities=3\n", + "last_discontinuity=step\n", + "last_step_ns=-200000000\n", + "last_step_realtime_ns=1790841600000000000\n", + "last_downtime_ns=31000000000\n", + "last_downtime_source=utc\n", + "synchronized=1\n", + "offset_ns=-42\n", + "uncertainty_ns=1800\n", + "frequency_ppb=-1200\n", + "samples=17\n", + "rejected_samples=1\n", + "last_sample_error=none\n", + "violations=0\n" + ); + + const STATUS_STATE: &str = concat!( + "version=1\n", + "generation=2\n", + "boot_status=ok\n", + "capture_status=ok\n", + "restore_status=ok\n", + "boot_cpus=8\n", + "capture_cpus=8\n", + "restore_cpus=4\n", + "boot_elapsed_us=2400\n", + "capture_elapsed_us=310\n", + "restore_elapsed_us=1830\n", + "boot_cpu_us=1900\n", + "capture_cpu_us=290\n", + "restore_cpu_us=640\n", + "capture_generation=1\n", + "restore_generation=2\n", + "tsc_hz=2194843000\n", + "lapic_hz=200000000\n", + "discontinuities=2\n", + "synchronized=1\n", + "offset_ns=-42\n", + "uncertainty_ns=1800\n", + "rejected_samples=2\n", + "last_sample_error=G_SAMPLE_UNCERTAIN\n" + ); + + fn status(text: &str, report_only: bool) -> String { + let state = TimeState::parse(text.as_bytes()).unwrap(); + let mut output = state.status_markers(report_only); + output.push_str(&format!("ok={}\n", u8::from(state.checks_ok()))); + output + } + #[test] fn fresh_state_has_only_a_pending_boot_check() { let state = TimeState::parse(b"version=1\n").unwrap(); @@ -399,6 +468,10 @@ mod tests { .to_string() .starts_with("version=1\ngeneration=0\nboot_status=pending\nboot_cpus=0\n") ); + assert!(state.to_string().starts_with( + "version=1\ngeneration=0\nboot_status=pending\nboot_cpus=0\n\ + boot_elapsed_us=0\nboot_cpu_us=0\ntsc_hz=0\nlapic_hz=0\n" + )); assert_eq!(state.checks[1].status, None); assert!(state.pending(true) && state.pending(false) && !state.checks_ok()); } @@ -439,4 +512,89 @@ mod tests { ) ); } + + #[test] + fn enforcing_and_report_only_states_round_trip_exactly() { + let state = TimeState::parse(ROUND_TRIP_STATE.as_bytes()).unwrap(); + assert_eq!(state.to_string(), ROUND_TRIP_STATE); + assert!(state.pending(false) && !state.checks_ok()); + + let report = ROUND_TRIP_STATE + .replace("restore_status=pending", "restore_status=fail") + .replace( + "restore_generation=2\n", + "restore_generation=2\nboot_failures=0\ncapture_failures=0\n\ + restore_failures=3\n", + ); + assert_eq!( + TimeState::parse(report.as_bytes()).unwrap().to_string(), + report + ); + } + + #[test] + fn status_prints_every_recorded_check_and_the_runtime_line() { + const RATES: &str = "tsc_hz=2194843000 lapic_hz=200000000"; + assert_eq!( + status(STATUS_STATE, false), + format!( + "NVX-TIME-ABI: v=1 phase=boot status=ok cpus=8 {RATES} \ + generation=0 elapsed_us=2400 cpu_us=1900\n\ + NVX-TIME-ABI: v=1 phase=capture status=ok cpus=8 {RATES} \ + generation=1 elapsed_us=310 cpu_us=290\n\ + NVX-TIME-ABI: v=1 phase=restore status=ok cpus=4 {RATES} \ + generation=2 elapsed_us=1830 cpu_us=640\n\ + NVX-TIME-ABI: v=1 phase=runtime status=synchronized generation=2 \ + discontinuities=2 offset_ns=-42 uncertainty_ns=1800 rejected_samples=2 \ + last_sample_error=G_SAMPLE_UNCERTAIN\n\ + ok=1\n" + ) + ); + + let pending = status( + &STATUS_STATE.replace("restore_status=ok", "restore_status=pending"), + false, + ); + assert!(pending.contains(&format!( + "NVX-TIME-ABI: v=1 phase=restore status=pending cpus=4 {RATES} generation=2\n" + ))); + assert!(pending.ends_with("ok=0\n")); + + let fresh = concat!( + "NVX-TIME-ABI: v=1 phase=boot status=pending cpus=0 tsc_hz=0 ", + "lapic_hz=0 generation=0\n", + "NVX-TIME-ABI: v=1 phase=runtime status=unsynchronized generation=0 ", + "discontinuities=0 offset_ns=0 uncertainty_ns=0 rejected_samples=0 ", + "last_sample_error=none\n", + "ok=0\n" + ); + for text in [ + "version=1\ngeneration=0\nboot_status=pending\n", + "version=1\n", + ] { + assert_eq!(status(text, false), fresh, "{text:?}"); + } + + let report_state = STATUS_STATE + .replace("restore_status=ok", "restore_status=fail") + .replace( + "restore_generation=2\n", + "restore_generation=2\nboot_failures=0\nrestore_failures=3\n", + ); + assert_eq!( + status(&report_state, true), + format!( + "NVX-TIME-REPORT: v=1 phase=boot status=ok cpus=8 {RATES} \ + generation=0 elapsed_us=2400 cpu_us=1900 failures=0\n\ + NVX-TIME-REPORT: v=1 phase=capture status=ok cpus=8 {RATES} \ + generation=1 elapsed_us=310 cpu_us=290 failures=0\n\ + NVX-TIME-REPORT: v=1 phase=restore status=fail cpus=4 {RATES} \ + generation=2 elapsed_us=1830 cpu_us=640 failures=3\n\ + NVX-TIME-REPORT: v=1 phase=runtime status=synchronized generation=2 \ + discontinuities=2 offset_ns=-42 uncertainty_ns=1800 rejected_samples=2 \ + last_sample_error=G_SAMPLE_UNCERTAIN\n\ + ok=0\n" + ) + ); + } } diff --git a/nvx-rs/src/guest/time/test_mode.rs b/nvx-rs/src/guest/time/test_mode.rs index 481081adb..5652a344f 100644 --- a/nvx-rs/src/guest/time/test_mode.rs +++ b/nvx-rs/src/guest/time/test_mode.rs @@ -684,6 +684,12 @@ mod tests { use super::*; + fn hook(arguments: &[&[u8]]) -> Vec { + let mut out = Vec::new(); + run(arguments, &mut out).unwrap(); + out + } + #[test] fn cpuid_values_are_32_bit_decimal_or_hexadecimal() { assert_eq!(cpuid_value(b"0x40000006"), Ok(0x4000_0006)); @@ -741,6 +747,114 @@ mod tests { assert_eq!(out, [b"\n".as_slice(), &line].concat()); } + #[test] + fn console_lines_preserve_synchronous_and_asynchronous_boundaries() { + let line = b"NVX-TIME-ABI-VIOLATION: v=1 code=G_RCU_STALL source=watcher\n"; + assert_eq!(hook(&[b"console", line, b"sync"]), line); + assert_eq!( + hook(&[b"console", line, b"async"]), + [b"\n".as_slice(), line].concat() + ); + } + + #[test] + fn exhaustive_hooks_preserve_leaf_rules_and_report_formats() { + let out_of_range: [&[u8]; 4] = [b"0x00000d0b", b"0x00000001", b"0x00000002", b"0x00000003"]; + let zero: [&[u8]; 4] = [b"0", b"0", b"0", b"0"]; + let leaf = |number: &[u8], value: [&[u8]; 4]| { + hook(&[ + b"exhaustive-leaf", + number, + value[0], + value[1], + value[2], + value[3], + out_of_range[0], + out_of_range[1], + out_of_range[2], + out_of_range[3], + ]) + }; + assert_eq!(leaf(b"0x40000006", zero), b"pass\n"); + assert_eq!(leaf(b"0x40000010", out_of_range), b"pass\n"); + assert_eq!(leaf(b"0x40000100", zero), b"pass\n"); + assert_eq!(leaf(b"0x40000100", out_of_range), b"pass\n"); + + let explicit = String::from_utf8(leaf(b"0x40000081", out_of_range)).unwrap(); + assert!( + explicit.starts_with("fail leaf 0x40000081 is "), + "{explicit}" + ); + assert!(explicit.ends_with("expected zero\n"), "{explicit}"); + let kvm = [ + b"0x40000101".as_slice(), + b"0x4b4d564b", + b"0x564b4d56", + b"0x0000004d", + ]; + let signature = String::from_utf8(leaf(b"0x40000100", kvm)).unwrap(); + assert!( + signature.contains("(signature \"KVMKVMKVM...\")"), + "{signature}" + ); + assert!( + signature.ends_with("or the highest basic leaf\n"), + "{signature}" + ); + + assert_eq!( + hook(&[b"exhaustive-report", b"X3", b"2", b"pass", b"ok"]), + concat!( + "NVX-TIME-ABI-EXHAUSTIVE: v=1 check=X3 cpu=2 status=pass detail=\"ok\"\n", + "failures=0\n" + ) + .as_bytes() + ); + assert_eq!( + hook(&[ + b"exhaustive-report", + b"X2", + b"7", + b"fail", + b"base 0x40000100 \"KVMKVMKVM\" \\ x\ty", + ]), + concat!( + "NVX-TIME-ABI-EXHAUSTIVE: v=1 check=X2 cpu=7 status=fail ", + "detail=\"base 0x40000100 \\\"KVMKVMKVM\\\" \\\\ x\\x09y\"\n", + "failures=1\n" + ) + .as_bytes() + ); + assert_eq!( + hook(&[b"exhaustive-summary", b"4", b"0"]), + concat!( + "NVX-TIME-ABI-EXHAUSTIVE: v=1 status=ok cpus=4 failures=0\n", + "exit=0\n" + ) + .as_bytes() + ); + assert_eq!( + hook(&[b"exhaustive-summary", b"8", b"3"]), + concat!( + "NVX-TIME-ABI-EXHAUSTIVE: v=1 status=fail cpus=8 failures=3\n", + "exit=1\n" + ) + .as_bytes() + ); + } + + #[test] + fn accepted_cmdline_and_clocksource_hooks_print_ok() { + assert_eq!( + hook(&[ + b"cmdline", + b"earlycon=xe9 console=hvc0 quiet clocksource=tsc tsc=reliable", + ]), + b"ok\n" + ); + assert_eq!(hook(&[b"clocksource", b"tsc\n", b"tsc \n"]), b"ok\n"); + } + #[test] fn corrections_print_the_kernel_modes() { let mut out = Vec::new(); @@ -752,4 +866,39 @@ mod tests { slew modes=0x203d offset=-5 status=0x2001 constant=6 maxerror=1 esterror=1\n" ); } + + #[test] + fn ported_corrections_print_exact_results() { + let mut out = Vec::new(); + run(&[b"correction", b"127999999", b"10002", b"fast"], &mut out).unwrap(); + run(&[b"correction", b"-500", b"20", b"slow"], &mut out).unwrap(); + run(&[b"correction", b"128000000", b"10", b"fast"], &mut out).unwrap(); + run(&[b"correction", b"-1128000000", b"10", b"slow"], &mut out).unwrap(); + assert_eq!( + String::from_utf8(out).unwrap(), + "slew modes=0x203d offset=127999999 status=0x2001 constant=4 \ + maxerror=128011 esterror=11\n\ + slew modes=0x203d offset=-500 status=0x2001 constant=6 maxerror=1 esterror=1\n\ + step modes=0x2100 sec=0 nsec=128000000\n\ + step modes=0x2100 sec=-2 nsec=872000000\n" + ); + } + + #[test] + fn pairing_and_frequency_hooks_preserve_signed_arithmetic() { + assert_eq!( + hook(&[b"pair", b"10000", b"10400", b"9000"]), + b"theta_ns=-1200 epsilon_ns=200\n" + ); + for (deviation, expected) in [ + ( + b"13107200".as_slice(), + b"frequency=-13107200 ppb=-200000\n".as_slice(), + ), + (b"-40000000", b"frequency=32768000 ppb=500000\n"), + (b"-65536", b"frequency=65536 ppb=1000\n"), + ] { + assert_eq!(hook(&[b"frequency", deviation]), expected); + } + } } diff --git a/nvx-rs/src/guest/time_probe/mod.rs b/nvx-rs/src/guest/time_probe/mod.rs index bf5b27750..4ca4ddc4a 100644 --- a/nvx-rs/src/guest/time_probe/mod.rs +++ b/nvx-rs/src/guest/time_probe/mod.rs @@ -1,12 +1,11 @@ -//! `nvx-time-probe`, the guest-side diagnostics for the NVX time ABI, which replaces -//! `guest/common/nvx-time-probe.c`. +//! `nvx-time-probe`, the guest-side diagnostics for the NVX time ABI. //! -//! It keeps the C program's commands, output, and exit statuses: 0 on success, 1 for a warp -//! check that fails, 2 for an invalid command line, and 3 for a measurement that could not -//! run. Its numeric options are plain decimal, or `0x`-prefixed hexadecimal for the integers, -//! and its rate options decimal floating point, as the other guest programs read their -//! arguments. It reads the kernel's numbers, such as `/proc/cpuinfo`'s rates, only in the form -//! in which the kernel writes them. +//! It keeps the commands, output, and exit statuses of the C program that it replaced: 0 on +//! success, 1 for a warp check that fails, 2 for an invalid command line, and 3 for a +//! measurement that could not run. Its numeric options are plain decimal, or `0x`-prefixed +//! hexadecimal for the integers, and its rate options decimal floating point, as the other guest +//! programs read their arguments. It reads the kernel's numbers, such as `/proc/cpuinfo`'s +//! rates, only in the form in which the kernel writes them. mod clock; mod format; diff --git a/nvx-rs/src/protocol/features.rs b/nvx-rs/src/protocol/features.rs index 2252b2239..41e13b29d 100644 --- a/nvx-rs/src/protocol/features.rs +++ b/nvx-rs/src/protocol/features.rs @@ -7,10 +7,10 @@ use super::{ProtocolError, array, violation}; /// Control features of a guest image, advertised in the response to /// [`APP_FEATURES`](super::APP_FEATURES). /// -/// The bits are defined by the guest agent in `guest/common/nvx-managed-agent.c`, which also -/// speaks for the features of its image's init script. A host drives a sandbox only when its -/// guest provides every feature in [`GuestFeatures::REQUIRED`]; an older image would silently -/// ignore the policy or the request that depends on the missing feature. +/// The Rust guest agent uses these shared definitions and also speaks for the features of its +/// image's init script. A host drives a sandbox only when its guest provides every feature in +/// [`GuestFeatures::REQUIRED`]; an older image would silently ignore the policy or the request +/// that depends on the missing feature. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct GuestFeatures(u32); diff --git a/nvx-rs/src/protocol/mod.rs b/nvx-rs/src/protocol/mod.rs index 776c66075..a003e866d 100644 --- a/nvx-rs/src/protocol/mod.rs +++ b/nvx-rs/src/protocol/mod.rs @@ -1,8 +1,8 @@ //! Codec of the OpenVMM control-console protocol. //! //! OpenVMM brokers an authenticated stream of outer records (magic `NVXS`) between one host -//! client and the guest agent. Data records carry application frames (magic `NVXC`) that belong -//! to the guest agent in `guest/common/nvx-managed-agent.c`. All integers are little-endian. +//! client and the Rust guest agent. Data records carry application frames (magic `NVXC`) that +//! the host and guest share from this module. All integers are little-endian. //! //! - Outer records: [`encode_outer`] and [`decode_outer_header`], and the `OUTER_*` record types. //! - Application frames: [`encode_app`] and [`decode_app`], the `APP_*` request and response @@ -17,8 +17,7 @@ //! such as [`MAPS_TOKEN`]. //! //! The module depends on nothing outside the standard library, so the host, the guest, and test -//! doubles share it. Its wire constants are those of the guest agent, which tests compare with -//! the agent's source, and golden byte fixtures pin every record that both sides exchange. +//! doubles share it. Tests pin its wire constants as literals and its records as golden bytes. use std::fmt; diff --git a/nvx-rs/src/protocol/tests.rs b/nvx-rs/src/protocol/tests.rs index 506e3799b..8f8dc190d 100644 --- a/nvx-rs/src/protocol/tests.rs +++ b/nvx-rs/src/protocol/tests.rs @@ -854,136 +854,81 @@ fn maps_decoding_refuses_what_the_guest_agent_refuses() { } } -fn guest_agent_source() -> Option { - let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent()? - .join("guest") - .join("common") - .join("nvx-managed-agent.c"); - std::fs::read_to_string(source).ok() +// Hosts and guests share the wire constants, so changing one passes every round trip between +// them yet breaks compatibility with released peers. These tests pin each value literally. + +#[test] +fn record_definitions_keep_their_wire_values() { + assert_eq!(OUTER_HEADER_LEN, 44); + assert_eq!(OUTER_MAX_PAYLOAD, 65_536); + assert_eq!(OUTER_VERSION, 1); + assert_eq!(OUTER_GUEST_ATTACH, 1); + assert_eq!(OUTER_RESET, 3); + assert_eq!(OUTER_ACK, 4); + assert_eq!(OUTER_DATA, 5); + assert_eq!(OUTER_CREDIT, 9); + assert_eq!(OUTER_RECEIVE_WINDOW, 1024 * 1024); + assert_eq!(APP_HEADER_LEN, 24); + assert_eq!(APP_VERSION, 1); + assert_eq!(APP_PING, 1); + assert_eq!(APP_EXEC, 2); + assert_eq!(APP_STOP, 3); + assert_eq!(APP_CANCEL, 4); + assert_eq!(APP_READY, 0x81); + assert_eq!(APP_STDOUT, 0x82); + assert_eq!(APP_STDERR, 0x83); + assert_eq!(APP_EXIT, 0x84); + assert_eq!(APP_STOPPED, 0x85); + assert_eq!(APP_ERROR, 0xff); } #[test] -fn feature_definitions_match_the_guest_agent_source() { - let Some(source) = guest_agent_source() else { - return; - }; - let mut definitions = vec![ - format!("#define APP_FEATURES {APP_FEATURES}U"), - format!("#define APP_MAPS {APP_MAPS}U"), - ]; - for (name, feature) in [ - ("CANCEL", GuestFeatures::CANCEL), - ("WORKLOAD_ACCOUNT", GuestFeatures::WORKLOAD_ACCOUNT), - ("EXEC_CGROUP", GuestFeatures::EXEC_CGROUP), - ("EXEC_ENVIRONMENT", GuestFeatures::EXEC_ENVIRONMENT), - ("EXEC_CWD", GuestFeatures::EXEC_CWD), - ("HOST_MAPPING_TABLE", GuestFeatures::HOST_MAPPING_TABLE), - ] { - let bit = feature.bits().trailing_zeros(); - definitions.push(format!("#define FEATURE_{name} (1U << {bit})")); - } - for definition in definitions { - assert!( - source.contains(&definition), - "the guest agent does not define `{definition}`" - ); - } - // The retired bit stays unused, so hosts that need it refuse the image. - assert!(!source.contains("(1U << 1)")); +fn feature_definitions_keep_their_wire_values() { + assert_eq!(APP_FEATURES, 5); + assert_eq!(APP_MAPS, 6); + assert_eq!(GuestFeatures::CANCEL.bits(), 0x01); + // Bit 1 is retired and stays unused, so hosts that still need it refuse the image. + assert_eq!(GuestFeatures::WORKLOAD_ACCOUNT.bits(), 0x04); + assert_eq!(GuestFeatures::EXEC_CGROUP.bits(), 0x08); + assert_eq!(GuestFeatures::EXEC_ENVIRONMENT.bits(), 0x10); + assert_eq!(GuestFeatures::EXEC_CWD.bits(), 0x20); + assert_eq!(GuestFeatures::HOST_MAPPING_TABLE.bits(), 0x40); } #[test] -fn host_mapping_table_matches_the_guest_agent_source() { - let Some(source) = guest_agent_source() else { - return; - }; - for expected in [ - format!("#define MAX_HOST_MAPPINGS {MAX_HOST_MAPPINGS}U"), - format!("#define MAX_GUEST_PATH {}U", MAX_MAP_PATH_BYTES + 1), - format!("#define MAPS_HEADER_LEN {MAPS_HEADER_LEN}U"), - format!("#define MAP_ENTRY_HEADER_LEN {MAP_ENTRY_HEADER_LEN}U"), - format!("#define MAP_READ_ONLY {MAP_READ_ONLY}U"), - format!("#define MAPS_TOKEN \"{MAPS_TOKEN}\""), - format!("\"{MAPPINGS_INCOMPLETE}\""), - format!("\"{MAPPING_FAILED}\""), - ] { - assert!( - source.contains(&expected), - "the guest agent does not contain `{expected}`" - ); - } +fn host_mapping_table_keeps_its_wire_values() { + assert_eq!(MAX_HOST_MAPPINGS, 4096); + assert_eq!(MAX_MAP_PATH_BYTES, 4095); + assert_eq!(MAPS_HEADER_LEN, 8); + assert_eq!(MAP_ENTRY_HEADER_LEN, 6); + assert_eq!(MAP_READ_ONLY, 1); + assert_eq!(MAPS_TOKEN, "nvx_maps="); + assert_eq!(MAPPINGS_INCOMPLETE, "mappings-incomplete"); + assert_eq!(MAPPING_FAILED, "mapping-failed"); } #[test] -fn exec_extension_matches_the_guest_agent_source() { - // The agent accepts a working directory as long as one argument, more than any directory - // that a workload can enter. - const { assert!(MAX_CWD_BYTES < MAX_ARGUMENT_BYTES) }; - let Some(source) = guest_agent_source() else { - return; - }; - for expected in [ - format!("#define EXEC_EXTENDED {EXEC_EXTENDED}U"), - format!("#define EXEC_CWD_PRESENT {EXEC_CWD_PRESENT}U"), - format!("#define EXEC_ENVIRONMENT_PRESENT {EXEC_ENVIRONMENT_PRESENT}U"), - format!("#define EXEC_INHERIT_DEFAULT_ENV {EXEC_INHERIT_DEFAULT_ENV}U"), - format!("#define MAX_ARGUMENTS {MAX_ARGUMENTS}U"), - format!("#define MAX_ARGUMENT_LEN {MAX_ARGUMENT_BYTES}U"), - format!("#define MAX_ENVIRONMENT {MAX_ENVIRONMENT}U"), - format!( - "#define MAX_OUTPUT_BYTES ({}U * 1024U)", - MAX_OUTPUT_BYTES / 1024 - ), - format!("\"{CWD_FAILED}\""), - format!("\"{LAUNCH_FAILED}\""), - format!("\"{INVALID_REQUEST}\""), - format!("\"{BUSY}\""), - format!("\"{}\"", String::from_utf8_lossy(UNSUPPORTED_OPERATION)), - ] { - assert!( - source.contains(&expected), - "the guest agent does not contain `{expected}`" - ); - } +fn exec_extension_keeps_its_wire_values() { + assert_eq!(EXEC_EXTENDED, 1); + assert_eq!(EXEC_CWD_PRESENT, 1); + assert_eq!(EXEC_ENVIRONMENT_PRESENT, 2); + assert_eq!(EXEC_INHERIT_DEFAULT_ENV, 4); + assert_eq!(MAX_ARGUMENTS, 64); + assert_eq!(MAX_ARGUMENT_BYTES, 4096); + assert_eq!(MAX_ENVIRONMENT, 256); + assert_eq!(MAX_OUTPUT_BYTES, 1024 * 1024); + assert_eq!(CWD_FAILED, "cwd-failed"); + assert_eq!(LAUNCH_FAILED, "launch-failed"); + assert_eq!(INVALID_REQUEST, "invalid-request"); + assert_eq!(BUSY, "busy"); + assert_eq!(UNSUPPORTED_OPERATION, b"unsupported-operation"); } #[test] -fn record_definitions_match_the_guest_agent_source() { - let Some(source) = guest_agent_source() else { - return; - }; - for expected in [ - format!("#define OUTER_HEADER_LEN {OUTER_HEADER_LEN}U"), - format!("#define OUTER_MAX_PAYLOAD {OUTER_MAX_PAYLOAD}U"), - format!("#define OUTER_VERSION {OUTER_VERSION}U"), - format!("#define OUTER_GUEST_ATTACH {OUTER_GUEST_ATTACH}U"), - format!("#define OUTER_RESET {OUTER_RESET}U"), - format!("#define OUTER_ACK {OUTER_ACK}U"), - format!("#define OUTER_DATA {OUTER_DATA}U"), - format!("#define OUTER_CREDIT {OUTER_CREDIT}U"), - format!( - "#define OUTER_RECEIVE_WINDOW ({}U * 1024U)", - OUTER_RECEIVE_WINDOW / 1024 - ), - format!("#define APP_HEADER_LEN {APP_HEADER_LEN}U"), - format!("#define APP_VERSION {APP_VERSION}U"), - format!("#define APP_PING {APP_PING}U"), - format!("#define APP_EXEC {APP_EXEC}U"), - format!("#define APP_STOP {APP_STOP}U"), - format!("#define APP_CANCEL {APP_CANCEL}U"), - format!("#define APP_READY {APP_READY:#x}U"), - format!("#define APP_STDOUT {APP_STDOUT:#x}U"), - format!("#define APP_STDERR {APP_STDERR:#x}U"), - format!("#define APP_EXIT {APP_EXIT:#x}U"), - format!("#define APP_STOPPED {APP_STOPPED:#x}U"), - format!("#define APP_ERROR {APP_ERROR:#x}U"), - ] { - assert!( - source.contains(&expected), - "the guest agent does not contain `{expected}`" - ); - } +fn exec_limits_support_linux_paths() { + // A working directory may be as long as one argument, and Linux cannot + // enter a directory longer than that argument limit. + const { assert!(MAX_CWD_BYTES < MAX_ARGUMENT_BYTES) }; } #[test] @@ -1168,6 +1113,152 @@ fn guest_exec_decoder_accepts_linux_bytes_and_the_c_cwd_boundary() { assert_eq!(decoded.environment, Some(vec![b"K=\xff".to_vec()])); } +/// Encodes an extended exec request field by field, so that its flags, environment count, and +/// working-directory length may contradict what follows them. +fn raw_extended( + flags: u16, + environment_count: u16, + cwd: &[u8], + argv: &[&[u8]], + environment: &[&[u8]], +) -> Vec { + let length = |bytes: &[u8]| u32::try_from(bytes.len()).unwrap().to_le_bytes(); + let mut payload = 0u32.to_le_bytes().to_vec(); + payload.extend_from_slice(&u16::try_from(argv.len()).unwrap().to_le_bytes()); + payload.extend_from_slice(&EXEC_EXTENDED.to_le_bytes()); + payload.extend_from_slice(&flags.to_le_bytes()); + payload.extend_from_slice(&environment_count.to_le_bytes()); + payload.extend_from_slice(&length(cwd)); + for argument in argv { + payload.extend_from_slice(&length(argument)); + payload.extend_from_slice(argument); + } + payload.extend_from_slice(cwd); + for entry in environment { + payload.extend_from_slice(&length(entry)); + payload.extend_from_slice(entry); + } + payload +} + +#[test] +fn guest_exec_decoder_accepts_what_the_c_agent_accepted() { + fn with_environment(flags: u16, entries: &[&[u8]]) -> GuestExecPayload { + let count = u16::try_from(entries.len()).unwrap(); + decode_guest_exec_payload(&raw_extended(flags, count, b"", &[b"/bin/true"], entries)) + .unwrap() + } + + let empty = with_environment(EXEC_ENVIRONMENT_PRESENT, &[]); + assert_eq!(empty.environment, Some(Vec::new())); + assert!(!empty.inherit_default_env); + let exact = with_environment( + EXEC_ENVIRONMENT_PRESENT, + &[b"EMPTY=", b"VALUE=space = value"], + ); + assert_eq!( + exact.environment, + Some(vec![b"EMPTY=".to_vec(), b"VALUE=space = value".to_vec()]) + ); + assert!(!exact.inherit_default_env); + let layered = with_environment( + EXEC_ENVIRONMENT_PRESENT | EXEC_INHERIT_DEFAULT_ENV, + &[b"VALUE=layered"], + ); + assert_eq!(layered.environment, Some(vec![b"VALUE=layered".to_vec()])); + assert!(layered.inherit_default_env); + + for timeout_ms in [0, 3_600_001, 86_400_000, u32::MAX] { + let mut payload = raw_extended(EXEC_ENVIRONMENT_PRESENT, 0, b"", &[b"/bin/true"], &[]); + payload[..4].copy_from_slice(&timeout_ms.to_le_bytes()); + assert_eq!( + decode_guest_exec_payload(&payload).unwrap().timeout_ms, + timeout_ms + ); + } +} + +#[test] +fn guest_exec_decoder_refuses_what_the_c_agent_refused() { + // The agent decodes with its own byte-oriented decoder, so each refusal that the host + // decoder's table pins is pinned here too, with the cases that the C agent refused. + const PROGRAM: &[u8] = b"/bin/true"; + fn with_environment(entries: &[&[u8]]) -> Vec { + let count = u16::try_from(entries.len()).unwrap(); + raw_extended(EXEC_ENVIRONMENT_PRESENT, count, b"", &[PROGRAM], entries) + } + fn with_cwd(flags: u16, environment_count: u16, cwd: &[u8]) -> Vec { + raw_extended(flags, environment_count, cwd, &[PROGRAM], &[]) + } + + let mut unknown_extension = 0u32.to_le_bytes().to_vec(); + unknown_extension.extend_from_slice(&1u16.to_le_bytes()); + unknown_extension.extend_from_slice(&2u16.to_le_bytes()); + unknown_extension.extend_from_slice(&9u32.to_le_bytes()); + unknown_extension.extend_from_slice(PROGRAM); + let mut truncated_header = 0u32.to_le_bytes().to_vec(); + truncated_header.extend_from_slice(&1u16.to_le_bytes()); + truncated_header.extend_from_slice(&EXEC_EXTENDED.to_le_bytes()); + truncated_header.extend_from_slice(&EXEC_CWD_PRESENT.to_le_bytes()); + let longest_cwd = [b"/".as_slice(), &[b'd'; MAX_GUEST_CWD_BYTES]].concat(); + let mut truncated_cwd = with_cwd(EXEC_CWD_PRESENT, 0, b"/work"); + truncated_cwd.pop(); + let mut trailing = with_cwd(EXEC_CWD_PRESENT, 0, b"/work"); + trailing.push(b'x'); + + for (case, payload) in [ + ( + "repeated name", + with_environment(&[b"VALUE=one", b"VALUE=two"]), + ), + ("entry without a value", with_environment(&[b"NO_EQUALS"])), + ("entry without a name", with_environment(&[b"=empty-key"])), + ("NUL in an entry", with_environment(&[b"KEY=embedded\0nul"])), + ("unknown extension", unknown_extension), + ("truncated extended header", truncated_header), + ( + "unknown flag", + with_cwd(EXEC_CWD_PRESENT | 0x08, 0, b"/work"), + ), + // Only the agent tells its launch helper that it entered the directory. + ( + "launch-helper flag", + with_cwd(EXEC_CWD_PRESENT | 0x8000, 0, b"/work"), + ), + ( + "layering without an environment", + with_cwd(EXEC_CWD_PRESENT | EXEC_INHERIT_DEFAULT_ENV, 0, b"/work"), + ), + ( + "entries without the environment flag", + with_cwd(EXEC_CWD_PRESENT, 1, b"/work"), + ), + ( + "flag without a directory", + with_cwd(EXEC_CWD_PRESENT, 0, b""), + ), + ("directory without the flag", with_cwd(0, 0, b"/work")), + ( + "directory over the limit", + with_cwd(EXEC_CWD_PRESENT, 0, &longest_cwd), + ), + ("relative directory", with_cwd(EXEC_CWD_PRESENT, 0, b"work")), + ( + "NUL in the directory", + with_cwd(EXEC_CWD_PRESENT, 0, b"/wo\0rk"), + ), + ("truncated directory", truncated_cwd), + ("trailing bytes", trailing), + ("no arguments", raw_extended(0, 0, b"", &[], &[])), + ( + "relative program", + raw_extended(0, 0, b"", &[b"bin/true"], &[]), + ), + ] { + assert!(decode_guest_exec_payload(&payload).is_err(), "{case}"); + } +} + #[test] fn host_maps_encoding_round_trips_through_the_guest_decoder() { let maps = [ diff --git a/nvx-rs/tests/guest_tools.rs b/nvx-rs/tests/guest_tools.rs index 872c8eb44..907fab7a4 100644 --- a/nvx-rs/tests/guest_tools.rs +++ b/nvx-rs/tests/guest_tools.rs @@ -1002,6 +1002,15 @@ fn first_allowed_cpu() -> usize { .unwrap() } +#[cfg(target_arch = "x86_64")] +/// Returns the CPUs in the time ABI's mask that this process may run on. +fn allowed_time_cpus() -> Vec { + let affinity = nix::sched::sched_getaffinity(nix::unistd::Pid::from_raw(0)).unwrap(); + (0..64) + .filter(|&cpu| affinity.is_set(cpu).unwrap()) + .collect() +} + #[cfg(target_arch = "x86_64")] /// Returns the CPUs that this process may not run on, of the 64 that the time ABI covers. fn unusable_cpus() -> Vec { @@ -1011,6 +1020,27 @@ fn unusable_cpus() -> Vec { .collect() } +#[cfg(target_arch = "x86_64")] +/// Returns whether this process may switch itself from `SCHED_IDLE` to `SCHED_OTHER`. +fn can_leave_idle_priority() -> bool { + let status = fs::read_to_string("/proc/self/status").unwrap(); + let capabilities = status + .lines() + .find_map(|line| line.strip_prefix("CapEff:")) + .and_then(|value| u64::from_str_radix(value.trim(), 16).ok()) + .unwrap_or(0); + if capabilities & (1 << 23) != 0 { + return true; + } + let limits = fs::read_to_string("/proc/self/limits").unwrap(); + let nice = limits.lines().find_map(|line| { + line.strip_prefix("Max nice priority")? + .split_ascii_whitespace() + .next() + }); + nice.is_some_and(|value| value == "unlimited" || value.parse::().is_ok_and(|n| n >= 20)) +} + #[cfg(target_arch = "x86_64")] const TIME_UTC_NS: u64 = 1_790_841_600_123_456_789; @@ -1107,6 +1137,28 @@ fn time_test_mode_decodes_packets_samples_and_arithmetic() { .1 .ends_with(b" range=2147483648:134217728 range=4294967296:268435456\n") ); + let no_ranges = time_fixture( + directory.path(), + "no-ranges", + time_packet(0x02, 0, &[], 1, 0, 0), + ); + let no_ranges = run( + "nvx-time", + [ + OsStr::new("test"), + OsStr::new("packet"), + no_ranges.as_os_str(), + OsStr::new("0"), + OsStr::new("ffffffffffffffffffffffffffffffff"), + ], + ); + assert_eq!(no_ranges.0, Some(0)); + assert!( + no_ranges + .1 + .windows(b" ranges=0 ".len()) + .any(|window| window == b" ranges=0 ") + ); let mut sample = Vec::new(); sample.extend_from_slice(&[1, 8]); @@ -1257,6 +1309,22 @@ fn time_test_mode_checks_logs_timer_state_and_status() { ) .as_bytes() ); + let malformed_state = time_fixture( + directory.path(), + "malformed-state", + b"unknown=x\nsamples=bad\n", + ); + assert_eq!( + run( + "nvx-time", + [ + OsStr::new("test"), + OsStr::new("state"), + malformed_state.as_os_str() + ] + ), + (Some(0), b"error\n".to_vec(), Vec::new()) + ); let status = run( "nvx-time", [ @@ -1275,6 +1343,158 @@ fn time_test_mode_checks_logs_timer_state_and_status() { assert!(status.1.ends_with(b"last_sample_error=none\nok=1\n")); } +#[test] +#[cfg(target_arch = "x86_64")] +fn time_test_mode_preserves_boot_log_and_syslog_results() { + let directory = tempfile::tempdir().unwrap(); + let run_log = |name: &str, mode: &str, text: &str| { + let fixture = time_fixture(directory.path(), name, text); + run( + "nvx-time", + [OsStr::new("test"), OsStr::new(mode), fixture.as_os_str()], + ) + }; + let boot = concat!( + "Hypervisor detected: Microsoft Hyper-V\n", + "Hyper-V: privilege flags low 0x8860, high 0x0, ext 0x0, hints 0x0, misc 0x100\n", + "Hyper-V: LAPIC Timer Frequency: 0x1e8480\n", + "tsc: Detected 2194.843 MHz processor\n", + "clocksource: Switched to clocksource tsc-early\n", + "clocksource: Switched to clocksource tsc\n" + ); + assert_eq!( + run_log( + "boot-kvm-rate", + "boot-log", + &boot.replace("0x1e8480", "0x989680") + ), + (Some(0), b"ok\n".to_vec(), Vec::new()) + ); + + let mut syslog = boot + .lines() + .enumerate() + .map(|(index, line)| format!("<6>[{index:5}.{:06}] {line}", index * 7919 % 1_000_000)) + .collect::>(); + syslog.insert( + 1, + "<6>[ 0.123456] [drm] a bracketed message is kept".to_owned(), + ); + let syslog = syslog.join("\n") + "\n"; + assert_eq!( + run_log("syslog-good", "syslog", &syslog), + (Some(0), b"ok\n".to_vec(), Vec::new()) + ); + let own_c4 = concat!( + "<2>[ 3.000000] NVX-TIME-REPORT-VIOLATION: v=1 code=G_CONFORMANCE_C4 ", + "detail=\"forbidden record: kvm-clock: Using msrs\"\n" + ); + assert_eq!( + run_log("syslog-own-c4", "syslog", &format!("{syslog}{own_c4}")), + (Some(0), b"ok\n".to_vec(), Vec::new()) + ); + let kvm = run_log( + "syslog-kvm", + "syslog", + &format!("{syslog}<6>[ 4.000000] kvm-clock: Using msrs\n"), + ); + assert_eq!(kvm.0, Some(0)); + assert!(kvm.2.is_empty()); + assert!( + kvm.1.starts_with(b"fail forbidden record: kvm-clock"), + "{}", + String::from_utf8_lossy(&kvm.1) + ); + let missing = syslog.lines().skip(2).collect::>().join("\n") + "\n"; + let missing = run_log("syslog-missing", "syslog", &missing); + assert_eq!(missing.0, Some(0)); + assert!(missing.2.is_empty()); + assert!( + missing.1.starts_with(b"fail no 'Hypervisor"), + "{}", + String::from_utf8_lossy(&missing.1) + ); + + let lines = boot + .lines() + .map(|line| format!("<6>[ 0.100000] {line}")) + .collect::>(); + let warning = [ + "<4>[ 0.412345] ------------[ cut here ]------------", + "<4>[ 0.412346] x86/mm: Found insecure W+X mapping at address \ + 0xffffffffc0000000", + "<4>[ 0.412400] WARNING: CPU: 0 PID: 1 at \ + arch/x86/mm/dump_pagetables.c:246 note_wx+0x5a/0x70", + "<6>[ 0.412500] x86/mm: Checked W+X mappings: FAILED, 4 W+X pages found.", + ]; + let with_warning = lines[..2] + .iter() + .map(String::as_str) + .chain(warning.iter().copied()) + .chain(lines[2..].iter().map(String::as_str)) + .collect::>() + .join("\n") + + "\n"; + assert_eq!( + run_log("syslog-wx", "syslog", &with_warning), + ( + Some(0), + concat!( + "ok\n", + "wx x86/mm: Found insecure W+X mapping at address 0xffffffffc0000000\n" + ) + .as_bytes() + .to_vec(), + Vec::new() + ) + ); + let passed = lines + .iter() + .map(String::as_str) + .chain(std::iter::once( + "<6>[ 0.412500] x86/mm: Checked W+X mappings: passed, no W+X pages found.", + )) + .collect::>() + .join("\n") + + "\n"; + assert_eq!( + run_log("syslog-wx-passed", "syslog", &passed), + (Some(0), b"ok\n".to_vec(), Vec::new()) + ); + let missing_with_warning = warning + .iter() + .copied() + .chain(lines[1..].iter().map(String::as_str)) + .collect::>() + .join("\n") + + "\n"; + assert_eq!( + run_log("syslog-wx-missing", "syslog", &missing_with_warning), + ( + Some(0), + concat!( + "fail no 'Hypervisor detected: Microsoft Hyper-V'\n", + "wx x86/mm: Found insecure W+X mapping at address 0xffffffffc0000000\n" + ) + .as_bytes() + .to_vec(), + Vec::new() + ) + ); + let own_wx = concat!( + "<2>[ 3.000000] NVX-TIME-REPORT-VIOLATION: v=1 code=G_KERNEL_WX ", + "detail=\"x86/mm: Found insecure W+X mapping at address 0x0\"\n" + ); + assert_eq!( + run_log( + "syslog-own-wx", + "syslog", + &(lines.join("\n") + "\n" + own_wx) + ), + (Some(0), b"ok\n".to_vec(), Vec::new()) + ); +} + #[test] #[cfg(target_arch = "x86_64")] fn time_test_mode_preserves_parser_and_usage_failures() { @@ -1425,6 +1645,12 @@ fn time_test_mode_checks_packet_and_sample_boundaries() { .1 .starts_with(b"error G_REPAIR_GENERATION ") ); + let forward_gap = time_packet(0, 0, &[], 7, 0, 0); + assert!( + run_time_packet(directory.path(), "forward-gap", &forward_gap, "5", previous) + .1 + .starts_with(b"error G_REPAIR_GENERATION ") + ); assert!( run_time_packet( directory.path(), @@ -1512,6 +1738,46 @@ fn time_test_mode_covers_checks_events_records_and_workers() { .1, b"ok cpu0_khz=2194843 cpu1_khz=2194843\n" ); + let cpuinfo_text = |flags: &str, cpus: usize| { + (0..cpus) + .map(|cpu| format!("processor : {cpu}\ncpu MHz : 2194.843\nflags : {flags}\n\n")) + .collect::() + }; + for (name, flags, cpus) in [ + ( + "cpuinfo-no-reliable", + good_flags.replace("tsc_reliable ", ""), + 2, + ), + ( + "cpuinfo-deadline", + format!("{good_flags} tsc_deadline_timer"), + 2, + ), + ("cpuinfo-adjust", format!("{good_flags} tsc_adjust"), 2), + ("cpuinfo-missing", good_flags.to_owned(), 1), + ] { + let fixture = time_fixture(directory.path(), name, cpuinfo_text(&flags, cpus)); + let output = run( + "nvx-time", + [ + OsStr::new("test"), + OsStr::new("cpuinfo"), + fixture.as_os_str(), + OsStr::new("0-1"), + ], + ); + assert_eq!(output.0, Some(0)); + assert!(output.2.is_empty()); + assert!( + output + .1 + .windows(b"code=G_CONFORMANCE_C5".len()) + .any(|window| window == b"code=G_CONFORMANCE_C5"), + "{name}: {}", + String::from_utf8_lossy(&output.1) + ); + } for token in [ "tsc_early_khz=2194843", "lapic_timer_hz=200000000", @@ -1806,6 +2072,29 @@ fn time_test_mode_covers_remaining_events_records_and_workers() { expected.as_bytes() ); } + for (index, record) in [ + "", + "new_cpus=4\ngeneration=3\n", + "generation=3\nnew_cpus=7-4\n", + "generation=3\nnew_cpus=64\n", + ] + .into_iter() + .enumerate() + { + let record = time_fixture(directory.path(), &format!("restore-error-{index}"), record); + assert_eq!( + run( + "nvx-time", + [ + OsStr::new("test"), + OsStr::new("restore-record"), + record.as_os_str() + ] + ), + (Some(0), b"error\n".to_vec(), Vec::new()), + "case {index}" + ); + } // These exercise the same thread promotion, affinity merge, and at-fork CPU accounting // paths as the production deferred boot and restore workers. @@ -1814,7 +2103,50 @@ fn time_test_mode_covers_remaining_events_records_and_workers() { let output = run("nvx-time", ["test", "parallel", &cpu.to_string()]); assert_eq!(output.0, Some(0)); assert!(output.1.ends_with(b"failures=7\n")); + } + let allowed = allowed_time_cpus(); + if let Some(&cpu) = allowed.first() { + let mut sizes = vec![1, allowed.len().min(2), allowed.len().min(4)]; + sizes.sort_unstable(); + sizes.dedup(); + for size in sizes { + let selected = allowed[..size] + .iter() + .map(usize::to_string) + .collect::>() + .join(","); + let (status, output, stderr) = run("nvx-time", ["test", "parallel", &selected]); + assert_eq!(status, Some(0)); + assert!(stderr.is_empty()); + let output = String::from_utf8(output).unwrap(); + assert!(output.ends_with("failures=7\n"), "{output}"); + assert_eq!( + output.matches("code=G_CONFORMANCE_C4").count(), + 6, + "{output}" + ); + assert_eq!( + output.matches("code=G_CONFORMANCE_C9").count(), + 1, + "{output}" + ); + } + // Each CPU job that cannot run on its CPU fails C1, and every job's failures count. + if let Some(&unusable) = unusable_cpus().first() { + let (status, output, stderr) = run( + "nvx-time", + ["test", "parallel", &format!("{cpu},{unusable}")], + ); + assert_eq!(status, Some(0)); + assert!(stderr.is_empty()); + let output = String::from_utf8(output).unwrap(); + assert!(output.ends_with("failures=8\n"), "{output}"); + assert!( + output.contains(&format!("cannot run on cpu {unusable}")), + "{output}" + ); + } if let [first, second, ..] = unusable_cpus()[..] { let (status, output, _) = run( "nvx-time", @@ -1829,6 +2161,7 @@ fn time_test_mode_covers_remaining_events_records_and_workers() { } } } + let normal_policy = if can_leave_idle_priority() { 0 } else { 5 }; let promoted = run("nvx-time", ["test", "promote"]); assert_eq!(promoted.0, Some(0)); assert!(promoted.1.starts_with(b"promoted caller=")); @@ -1838,9 +2171,32 @@ fn time_test_mode_covers_remaining_events_records_and_workers() { .windows(b"\nstopped caller=".len()) .any(|window| window == b"\nstopped caller=") ); + assert!(promoted.2.is_empty()); + let promoted = String::from_utf8(promoted.1).unwrap(); + let mut promoted_lines = promoted.lines(); + assert_eq!( + promoted_lines.next(), + Some(format!("promoted caller={normal_policy} thread={normal_policy}").as_str()) + ); + let stopped = promoted_lines.next().unwrap(); + let stop_ms = stopped + .strip_prefix("stopped caller=5 stop_ms=") + .and_then(|value| value.parse::().ok()) + .unwrap_or_else(|| panic!("unexpected stop line: {stopped}")); + assert!(stop_ms < 1000, "{stopped}"); + assert_eq!(promoted_lines.next(), None); + let idle = run("nvx-time", ["test", "idle-priority"]); assert_eq!(idle.0, Some(0)); assert!(idle.1.starts_with(b"idle=5 normal=")); + assert_eq!( + idle, + ( + Some(0), + format!("idle=5 normal={normal_policy}\n").into_bytes(), + Vec::new() + ) + ); let cpu = run( "nvx-time", ["test", "deferred-restore-cpu", "20", "30", "10"], @@ -1857,6 +2213,10 @@ fn time_test_mode_covers_remaining_events_records_and_workers() { matches!(fields[..], [("prior_ms", 20..30), ("total_ms", 60..)]), "{fields:?}" ); + assert!( + matches!(fields[..], [("prior_ms", 20..30), ("total_ms", 60..90)]), + "{fields:?}" + ); } #[test] diff --git a/scripts/nvx.py b/scripts/nvx.py index 0fb1c94ef..013393a25 100644 --- a/scripts/nvx.py +++ b/scripts/nvx.py @@ -87,6 +87,7 @@ from nvx_tools.guest_binaries import ( build_guest_binaries, check_guest_binaries, + guest_binary_source_sha256, test_guest_binaries, ) from nvx_tools.guests import GUEST_NAMES, guest_descriptor @@ -393,6 +394,10 @@ def command_kernel_patch_identity(args: argparse.Namespace) -> None: print(kernel_patch_identity(args.root)) +def command_guest_binary_source_identity(args: argparse.Namespace) -> None: + print(guest_binary_source_sha256(args.root)) + + def command_check_required_ci(args: argparse.Namespace) -> None: results = { job: os.environ.get(environment, "") @@ -1123,6 +1128,20 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: ) patch_identity.set_defaults(handler=command_kernel_patch_identity) + source_identity = subparsers.add_parser( + "guest-binary-source-identity", + help=( + "print the digest of the Rust guest binary source paths and contents " + "that keys the guest image caches" + ), + ) + source_identity.add_argument( + "--root", + type=Path, + default=BuildConstants.REPO_ROOT, + ) + source_identity.set_defaults(handler=command_guest_binary_source_identity) + required_ci = subparsers.add_parser( "check-required-ci", help="validate required GitHub Actions job results", @@ -1151,7 +1170,10 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: static_elf = subparsers.add_parser( "check-static-elf", - help="check that executables are static ELF files without an interpreter", + help=( + "check that executables are static ELF files without an interpreter " + "or an executable stack" + ), ) static_elf.add_argument("paths", nargs="+", type=Path, metavar="PATH") static_elf.set_defaults(handler=command_check_static_elf) diff --git a/scripts/nvx_tools/archive.py b/scripts/nvx_tools/archive.py index 18163800b..da08cfe9d 100644 --- a/scripts/nvx_tools/archive.py +++ b/scripts/nvx_tools/archive.py @@ -22,6 +22,13 @@ def _normalize_member(member: tarfile.TarInfo) -> tarfile.TarInfo | None: path = PurePosixPath(member.name) if "__pycache__" in path.parts or path.suffix in (".pyc", ".pyo"): return None + for excluded in ReleaseBuildConstants.PROJECT_SOURCE_EXCLUDES: + parts = PurePosixPath(excluded.as_posix()).parts + if any( + path.parts[index : index + len(parts)] == parts + for index in range(len(path.parts) - len(parts) + 1) + ): + return None member.uid = 0 member.gid = 0 member.uname = "" diff --git a/scripts/nvx_tools/azurelinux.py b/scripts/nvx_tools/azurelinux.py index 81e247eb9..549528f33 100644 --- a/scripts/nvx_tools/azurelinux.py +++ b/scripts/nvx_tools/azurelinux.py @@ -17,8 +17,8 @@ ScriptError, download_verified, normalized_sha256_file, - sha256_file, ) +from .guest_binaries import guest_binary_source_files _RPM_FIELD = re.compile(r"[A-Za-z0-9][A-Za-z0-9._+~^-]*") _SHA256 = re.compile(r"[0-9a-f]{64}") @@ -129,8 +129,15 @@ def input_files() -> tuple[Path, ...]: # this digest, so changes to them must also rebuild the artifact. *( root / "scripts" / "nvx_tools" / name - for name in ("azurelinux.py", "build_constants.py", "common.py") + for name in ( + "azurelinux.py", + "build_constants.py", + "common.py", + "elf.py", + "guest_binaries.py", + ) ), + *guest_binary_source_files(), ] for directory in AzureLinuxBuildConstants.GUEST_SOURCE_DIRECTORIES: files.extend(path for path in (root / directory).rglob("*") if path.is_file()) @@ -153,7 +160,7 @@ def input_sha256( "files": [ { "path": path.relative_to(root).as_posix(), - "sha256": sha256_file(path), + "sha256": normalized_sha256_file(path), } for path in input_files() ], diff --git a/scripts/nvx_tools/benchmark.py b/scripts/nvx_tools/benchmark.py index 3c7bcf310..1d801cea1 100644 --- a/scripts/nvx_tools/benchmark.py +++ b/scripts/nvx_tools/benchmark.py @@ -38,10 +38,12 @@ from .build_constants import ( AlpineBuildConstants, BuildConstants, + GuestRustBuildConstants, KernelBuildConstants, OpenVMMBuildConstants, ) from .common import bytes_to_mib, sha256_file +from .guest_binaries import guest_binary_source_sha256, pinned_guest_rust_toolchain from .time_abi import TimeAbiFailure, TimeAbiMonitor, status_script BOOT_MARKER = b"ALPINE-MICROVM-BOOT-OK" @@ -4440,10 +4442,8 @@ def _git_status(repository: Path) -> list[str] | None: def _device_io_helper_provenance( args: argparse.Namespace, initrd: Path ) -> dict[str, str]: - source = require_file( - args.nvx_dir.resolve() / "guest" / "common" / "nvx-device-io.c", - "device I/O helper source", - ) + checkout = args.nvx_dir.resolve() + source = checkout / "nvx-rs" manifest_path = require_file( initrd.with_name(f"{initrd.name}{BuildConstants.PACKAGE_MANIFEST_SUFFIX}"), "initramfs package manifest", @@ -4457,10 +4457,11 @@ def _device_io_helper_provenance( raise ValueError( f"invalid nvx-device-io provenance in {manifest_path}" ) from error - actual_source_sha256 = sha256_file(source) + toolchain = pinned_guest_rust_toolchain(checkout) + actual_source_sha256 = guest_binary_source_sha256(checkout) if source_sha256 != actual_source_sha256: raise ValueError( - "initramfs device I/O helper source does not match the current checkout" + "initramfs Rust guest binary sources do not match the current checkout" ) if not isinstance(binary_sha256, str) or not re.fullmatch( r"[0-9a-f]{64}", binary_sha256 @@ -4470,6 +4471,8 @@ def _device_io_helper_provenance( "source": str(source), "source_sha256": actual_source_sha256, "binary_sha256": binary_sha256, + "toolchain": toolchain, + "target": GuestRustBuildConstants.TARGET, } diff --git a/scripts/nvx_tools/build.py b/scripts/nvx_tools/build.py index 36dbc6238..6dfd9cb72 100644 --- a/scripts/nvx_tools/build.py +++ b/scripts/nvx_tools/build.py @@ -29,6 +29,7 @@ AzureLinuxBuildConstants, BuildConstants, DockerBuildConstants, + GuestRustBuildConstants, InitramfsBuildConstants, KernelBuildConstants, OpenVMMBuildConstants, @@ -39,6 +40,7 @@ artifact_path, download_verified, format_size, + normalized_sha256_file, openvmm_git_state, require_file, require_success, @@ -47,6 +49,11 @@ run_checked, sha256_file, ) +from .guest_binaries import ( + build_guest_binaries, + guest_binary_source_files, + inspect_guest_binaries, +) from .guests import GuestDescriptor, guest_descriptor @@ -347,20 +354,23 @@ def merge_kernel_config_fragment(base: str, fragment: str) -> str: def _initramfs_source_files() -> tuple[Path, ...]: - sources = [ + sources = { BuildConstants.REPO_ROOT / DockerBuildConstants.DOCKERFILE, BuildConstants.REPO_ROOT / "scripts" / "nvx_tools" / "build.py", BuildConstants.REPO_ROOT / "scripts" / "nvx_tools" / "build_config.py", BuildConstants.REPO_ROOT / "scripts" / "nvx_tools" / "build_constants.py", BuildConstants.REPO_ROOT / "scripts" / "nvx_tools" / "common.py", + BuildConstants.REPO_ROOT / "scripts" / "nvx_tools" / "elf.py", + BuildConstants.REPO_ROOT / "scripts" / "nvx_tools" / "guest_binaries.py", BuildConstants.REPO_ROOT / "scripts" / "nvx_tools" / "guests.py", + *guest_binary_source_files(), *( path for directory in AlpineBuildConstants.GUEST_SOURCE_DIRECTORIES for path in (BuildConstants.REPO_ROOT / directory).rglob("*") if path.is_file() ), - ] + } return tuple( sorted( (require_file(path, "initramfs provenance input") for path in sources), @@ -380,7 +390,7 @@ def initramfs_provenance_inputs() -> dict[str, object]: "source_files": [ { "path": path.relative_to(BuildConstants.REPO_ROOT).as_posix(), - "sha256": sha256_file(path), + "sha256": normalized_sha256_file(path), } for path in _initramfs_source_files() ], @@ -600,53 +610,6 @@ def _install(source: Path, destination: Path) -> dict[str, str]: } -def _build_static_helper( - work: Path, - source: Path, - destination: Path, - cflags: tuple[str, ...] = InitramfsBuildConstants.STATIC_HELPER_CFLAGS, - compiler_name: str = "cc", -) -> dict[str, str]: - compiler = require_tool(compiler_name) - output = work / source.stem - run_checked( - [ - compiler, - *cflags, - "-o", - output, - source, - ] - ) - shutil.copyfile(output, destination) - destination.chmod(0o755) - return { - "source_sha256": sha256_file(source), - "binary_sha256": sha256_file(output), - } - - -def _build_device_io_helper(work: Path, destination: Path) -> dict[str, str]: - compiler = require_tool("cc") - source = BuildConstants.REPO_ROOT / "guest" / "common" / "nvx-device-io.c" - output = work / "nvx-device-io" - run_checked( - [ - compiler, - *InitramfsBuildConstants.DEVICE_IO_CFLAGS, - "-o", - output, - source, - ] - ) - shutil.copyfile(output, destination) - destination.chmod(0o755) - return { - "source_sha256": sha256_file(source), - "binary_sha256": sha256_file(output), - } - - def _apk_add(root: Path, *packages: str) -> None: loader = root / "lib" / "ld-musl-x86_64.so.1" environment = os.environ.copy() @@ -863,31 +826,25 @@ def _install_guest_files( if name == "nvx-bashrc": destination.chmod(0o644) - for name in InitramfsBuildConstants.STATIC_HELPERS: - helpers[name] = _build_static_helper( - config.work, - common / f"{name}.c", - root / "sbin" / name, - ) - for name in InitramfsBuildConstants.MUSL_STATIC_HELPERS: - helpers[name] = _build_static_helper( - config.work, - common / f"{name}.c", - root / "sbin" / name, - compiler_name=InitramfsBuildConstants.MUSL_COMPILER, - ) - probe = InitramfsBuildConstants.TIME_PROBE_NAME - helpers[probe] = _build_static_helper( - config.work, - common / f"{probe}.c", - root / "sbin" / probe, - InitramfsBuildConstants.TIME_PROBE_CFLAGS, - compiler_name=InitramfsBuildConstants.MUSL_COMPILER, - ) - helpers["nvx-device-io"] = _build_device_io_helper( - config.work, - root / "sbin" / "nvx-device-io", - ) + prebuilt = os.environ.get(GuestRustBuildConstants.BINARIES_VARIABLE) + if prebuilt: + binary_directory = Path(prebuilt) + result = inspect_guest_binaries(binary_directory) + else: + binary_directory = config.work / GuestRustBuildConstants.OUTPUT_DIRECTORY_NAME + result = build_guest_binaries(binary_directory) + for name in GuestRustBuildConstants.BINARIES: + source = binary_directory / name + destination = root / "sbin" / name + shutil.copyfile(source, destination) + destination.chmod(0o755) + binary_sha256 = sha256_file(destination) + if binary_sha256 != result.binaries[name]: + raise ScriptError(f"installed Rust guest binary changed: {name}") + helpers[name] = { + "source_sha256": result.source_sha256, + "binary_sha256": binary_sha256, + } return helpers @@ -942,7 +899,7 @@ def _guest_customization_files(descriptor: GuestDescriptor) -> tuple[Path, ...]: ) if path.is_file() ) - return (*common, *alpine) + return (*common, *alpine, *guest_binary_source_files()) def build_initramfs(config: InitramfsBuildConfig) -> None: diff --git a/scripts/nvx_tools/build_constants.py b/scripts/nvx_tools/build_constants.py index 13841b9cb..7fcaf660a 100644 --- a/scripts/nvx_tools/build_constants.py +++ b/scripts/nvx_tools/build_constants.py @@ -157,14 +157,17 @@ class GuestRustBuildConstants: """Pinned inputs and outputs of the Rust guest-binary build.""" DIRECTORY: Final = BuildConstants.REPO_ROOT / "nvx-rs" - MANIFEST: Final = DIRECTORY / "Cargo.toml" - LOCKFILE: Final = DIRECTORY / "Cargo.lock" - BUILD_SCRIPT: Final = DIRECTORY / "build.rs" - SOURCE_DIRECTORIES: Final = (DIRECTORY / "src",) - BUILD_RECIPE_FILES: Final = ( - BuildConstants.REPO_ROOT / "scripts" / "nvx_tools" / "build_constants.py", - BuildConstants.REPO_ROOT / "scripts" / "nvx_tools" / "guest_binaries.py", - ) + # The inputs of every guest executable, relative to a checkout's root: these + # files and every file under SOURCE_DIRECTORY outside a `target` tree. + SOURCE_FILES: Final = ( + Path("nvx-rs") / "Cargo.toml", + Path("nvx-rs") / "Cargo.lock", + Path("nvx-rs") / "build.rs", + Path("rust-toolchain.toml"), + Path("scripts") / "nvx_tools" / "build_constants.py", + Path("scripts") / "nvx_tools" / "guest_binaries.py", + ) + SOURCE_DIRECTORY: Final = Path("nvx-rs") / "src" TOOLCHAIN_FILE: Final = BuildConstants.REPO_ROOT / "rust-toolchain.toml" TOOLCHAIN: Final = OpenVMMBuildConstants.RUST_TOOLCHAIN TARGET: Final = OpenVMMBuildConstants.MUSL_RUST_TARGET @@ -176,6 +179,8 @@ class GuestRustBuildConstants: BUILD_MANIFEST_NAME: Final = "manifest.json" BUILD_MANIFEST_FORMAT: Final = 1 BUILD_MANIFEST_DOMAIN: Final = "nvx-rust-guest-binary-build" + OUTPUT_DIRECTORY_NAME: Final = "guest-binaries" + SOURCE_PATHS: Final = ("nvx-rs", "rust-toolchain.toml") BINARIES: Final = ( "nvx-console-pending", "nvx-mmio-write", @@ -358,53 +363,14 @@ class InitramfsBuildConstants: TIMESTAMP: Final = 0 CPIO_FORMAT: Final = "newc" GZIP_COMPRESSION_LEVEL: Final = 9 - STATIC_HELPER_CFLAGS: Final = ( - "-static", - "-Os", - "-s", - "-Wall", - "-Wextra", - "-Werror", - ) - DEVICE_IO_CFLAGS: Final = ( - "-nostdlib", - "-static", - "-Os", - "-fno-builtin", - "-fno-pie", - "-fno-stack-protector", - "-no-pie", - "-Wl,--build-id=none", - "-Wl,-z,noexecstack", - "-s", - ) - STATIC_HELPERS: Final = ( - "nvx-reseed", - "nvx-mmio-write", - "nvx-console-pending", - "nvx-managed-agent", - ) - # Time tools, nvx-exit, and nvx-ingress are linked against musl: a static - # glibc binary adds about 700 KB to the initramfs, and every unpacked - # kilobyte delays cold boot. nvx-exit is also a restored guest's usual - # first exec. - MUSL_COMPILER: Final = "musl-gcc" - MUSL_STATIC_HELPERS: Final = ("nvx-time", "nvx-exit", "nvx-ingress") - TIME_PROBE_NAME: Final = "nvx-time-probe" - TIME_PROBE_CFLAGS: Final = ( - "-std=gnu11", - "-static", - "-O2", - "-pthread", - "-s", - "-Wall", - "-Wextra", - "-Werror", - ) class DockerBuildConstants: DOCKERFILE: Final = Path("docker") / "Dockerfile" + RUST_BUILDER_IMAGE: Final = ( + f"rust:{GuestRustBuildConstants.TOOLCHAIN}-slim-bookworm@" + "sha256:d7482085ff5b415f84dba5647ae71606650bdef00db7aeb69f4b3d170c3e4082" + ) ALPINE_TARGET: Final = "artifacts" UBUNTU_TARGET: Final = "ubuntu-guest-artifacts" AZURELINUX_TARGET: Final = "azurelinux-guest-artifacts" @@ -450,6 +416,7 @@ class ReleaseBuildConstants: PROJECT_SOURCE_PATHS: Final = ( ".github/agents/nvx-adversary.md", "guest", + "nvx-rs", "ubuntu", "azurelinux", "data/linux-kvm-virtual-machine.csv", @@ -471,6 +438,7 @@ class ReleaseBuildConstants: "requirements-dev.txt", "rust-toolchain.toml", ) + PROJECT_SOURCE_EXCLUDES: Final = (Path("nvx-rs") / "target",) GUEST_ARTIFACT_NAMES: Final = ( KernelBuildConstants.BINARY_NAME, KernelBuildConstants.CONFIG_NAME, diff --git a/scripts/nvx_tools/ci.py b/scripts/nvx_tools/ci.py index c354a6cf1..0b89a29e3 100644 --- a/scripts/nvx_tools/ci.py +++ b/scripts/nvx_tools/ci.py @@ -131,13 +131,14 @@ # The OpenVMM VMM tests also boot the NVX kernel and Alpine initramfs, so they # add the inputs of those artifacts' cache keys and of the job that builds them. OPENVMM_VMM_TEST_INPUT_PATHS = re.compile( - r"^(openvmm($|/)|rust-toolchain\.toml$|scripts/setup/|" + r"^(openvmm($|/)|nvx-rs/|rust-toolchain\.toml$|scripts/setup/|" r"\.github/workflows/ci\.yml$|" r"\.github/actions/(build-guest-artifacts|checkout-openvmm|sccache|" r"setup-curl|validate-runner)/|" r"scripts/nvx\.py$|" r"scripts/nvx_tools/" - r"(__init__|build|build_config|build_constants|ci|common|guests)\.py$|" + r"(__init__|build|build_config|build_constants|ci|common|elf|" + r"guest_binaries|guests)\.py$|" r"SOURCE-MANIFEST\.json$|docker/Dockerfile$|\.dockerignore$|kernel/|guest/(alpine|common)/)" ) diff --git a/scripts/nvx_tools/elf.py b/scripts/nvx_tools/elf.py index 1835f39a3..ac7e5a52b 100644 --- a/scripts/nvx_tools/elf.py +++ b/scripts/nvx_tools/elf.py @@ -4,7 +4,8 @@ must be a 64-bit little-endian ELF executable without a ``PT_INTERP`` program header and without ``DT_NEEDED`` entries. A static position-independent executable has a ``PT_DYNAMIC`` segment for its own relocations, which is -allowed. +allowed. Each must also keep its stack non-executable, which a +``PT_GNU_STACK`` program header with ``PF_X`` would override. """ from __future__ import annotations @@ -22,6 +23,8 @@ _EXECUTABLE_TYPES = {2: "EXEC", 3: "DYN"} _PT_DYNAMIC = 2 _PT_INTERP = 3 +_PT_GNU_STACK = 0x6474E551 +_PF_X = 0x1 _PN_XNUM = 0xFFFF _DT_NULL = 0 _DT_NEEDED = 1 @@ -40,6 +43,7 @@ class ElfExecutable: interpreter: str | None needed: int dynamic: bool + executable_stack: bool @property def static(self) -> bool: @@ -55,7 +59,8 @@ def describe(self) -> str: if self.needed: details.append(f"{self.needed} needed libraries") linkage = "dynamic: " + ", ".join(details) - return f"{self.path}: ELF {self.elf_type}, {self.size} bytes, {linkage}" + stack = ", executable stack" if self.executable_stack else "" + return f"{self.path}: ELF {self.elf_type}, {self.size} bytes, {linkage}{stack}" def read_elf_executable(path: Path) -> ElfExecutable: @@ -87,9 +92,12 @@ def _parse(path: Path, data: bytes) -> ElfExecutable: interpreter = None needed = 0 dynamic = False + executable_stack = False for index in range(program_count): - segment_type, _, offset, _, _, file_size, _, _ = _PROGRAM_HEADER.unpack_from( - data, program_offset + index * program_entry_size + segment_type, flags, offset, _, _, file_size, _, _ = ( + _PROGRAM_HEADER.unpack_from( + data, program_offset + index * program_entry_size + ) ) segment = data[offset : offset + file_size] if len(segment) != file_size: @@ -105,6 +113,8 @@ def _parse(path: Path, data: bytes) -> ElfExecutable: break if tag == _DT_NEEDED: needed += 1 + elif segment_type == _PT_GNU_STACK: + executable_stack = bool(flags & _PF_X) return ElfExecutable( path=path, size=len(data), @@ -112,11 +122,13 @@ def _parse(path: Path, data: bytes) -> ElfExecutable: interpreter=interpreter, needed=needed, dynamic=dynamic, + executable_stack=executable_stack, ) def check_static_executables(paths: Sequence[Path]) -> list[ElfExecutable]: - """Describe each executable, and fail unless every one is static.""" + """Describe each executable, and fail unless every one is static with a + non-executable stack.""" executables = [read_elf_executable(path) for path in paths] for executable in executables: print(executable.describe()) @@ -125,4 +137,13 @@ def check_static_executables(paths: Sequence[Path]) -> list[ElfExecutable]: ] if dynamic: raise ScriptError("not static executables: " + ", ".join(dynamic)) + executable_stacks = [ + str(executable.path) + for executable in executables + if executable.executable_stack + ] + if executable_stacks: + raise ScriptError( + "executables with an executable stack: " + ", ".join(executable_stacks) + ) return executables diff --git a/scripts/nvx_tools/guest_binaries.py b/scripts/nvx_tools/guest_binaries.py index b06c4919d..9dfeb53ff 100644 --- a/scripts/nvx_tools/guest_binaries.py +++ b/scripts/nvx_tools/guest_binaries.py @@ -14,7 +14,14 @@ from typing import cast from .build_constants import BuildConstants, GuestRustBuildConstants -from .common import ScriptError, require_file, require_tool, run_checked, sha256_file +from .common import ( + ScriptError, + normalized_sha256_file, + require_file, + require_tool, + run_checked, + sha256_file, +) from .elf import check_static_executables @@ -26,58 +33,53 @@ class GuestBinaryBuildResult: binaries: dict[str, str] -def guest_binary_source_files() -> tuple[Path, ...]: - """Return the complete ordered input set of the PR 1 guest executables.""" - files = { - GuestRustBuildConstants.MANIFEST, - GuestRustBuildConstants.LOCKFILE, - GuestRustBuildConstants.BUILD_SCRIPT, - GuestRustBuildConstants.TOOLCHAIN_FILE, - *GuestRustBuildConstants.BUILD_RECIPE_FILES, - } - for directory in GuestRustBuildConstants.SOURCE_DIRECTORIES: - if not directory.is_dir(): - raise ScriptError(f"nvx-rs source directory not found: {directory}") - files.update( - path - for path in directory.rglob("*") - if path.is_file() and "target" not in path.relative_to(directory).parts - ) +def guest_binary_source_files(root: Path | None = None) -> tuple[Path, ...]: + """Return the complete ordered input set of the guest executables under ``root``.""" + root = Path(root or BuildConstants.REPO_ROOT) + files = {root / path for path in GuestRustBuildConstants.SOURCE_FILES} + source = root / GuestRustBuildConstants.SOURCE_DIRECTORY + if not source.is_dir(): + raise ScriptError(f"nvx-rs source directory not found: {source}") + files.update( + path + for path in source.rglob("*") + if path.is_file() and "target" not in path.relative_to(source).parts + ) return tuple( sorted( (require_file(path, "Rust guest binary source input") for path in files), - key=lambda path: path.relative_to(BuildConstants.REPO_ROOT).as_posix(), + key=lambda path: path.relative_to(root).as_posix(), ) ) -def guest_binary_source_sha256() -> str: +def guest_binary_source_sha256(root: Path | None = None) -> str: """Hash the paths and contents that determine every Rust guest executable.""" + root = Path(root or BuildConstants.REPO_ROOT) inventory = { "domain": GuestRustBuildConstants.SOURCE_DIGEST_DOMAIN, "format": GuestRustBuildConstants.SOURCE_DIGEST_FORMAT, "files": [ { - "path": path.relative_to(BuildConstants.REPO_ROOT).as_posix(), - "sha256": sha256_file(path), + "path": path.relative_to(root).as_posix(), + "sha256": normalized_sha256_file(path), } - for path in guest_binary_source_files() + for path in guest_binary_source_files(root) ], } encoded = json.dumps(inventory, sort_keys=True, separators=(",", ":")).encode() return hashlib.sha256(encoded).hexdigest() -def pinned_guest_rust_toolchain() -> str: +def pinned_guest_rust_toolchain(root: Path | None = None) -> str: """Read and validate the repository's exact Rust release pin.""" - contents = require_file( - GuestRustBuildConstants.TOOLCHAIN_FILE, "Rust toolchain pin" - ).read_text(encoding="utf-8") + toolchain_file = (root or BuildConstants.REPO_ROOT) / "rust-toolchain.toml" + contents = require_file(toolchain_file, "Rust toolchain pin").read_text( + encoding="utf-8" + ) matches = re.findall(r'^channel = "([^"]+)"$', contents, re.MULTILINE) if len(matches) != 1: - raise ScriptError( - f"{GuestRustBuildConstants.TOOLCHAIN_FILE} must define one toolchain channel" - ) + raise ScriptError(f"{toolchain_file} must define one toolchain channel") [toolchain] = matches if toolchain != GuestRustBuildConstants.TOOLCHAIN: raise ScriptError( @@ -289,6 +291,44 @@ def check_guest_binaries(toolchain: str | None = None) -> None: ) +def inspect_guest_binaries(directory: Path) -> GuestBinaryBuildResult: + """Validate and identify a current, previously staged guest build.""" + directory = directory.resolve() + retired = _validate_owned_output(directory) + missing = [ + binary + for binary in GuestRustBuildConstants.BINARIES + if not (directory / binary).is_file() + ] + if retired or missing: + raise ScriptError( + f"guest binary output is not a current build of every executable: " + f"{directory}" + ) + paths = [directory / binary for binary in GuestRustBuildConstants.BINARIES] + check_static_executables(paths) + result = GuestBinaryBuildResult( + source_sha256=guest_binary_source_sha256(), + binaries={ + binary: sha256_file(path) + for binary, path in zip( + GuestRustBuildConstants.BINARIES, paths, strict=True + ) + }, + ) + manifest_path = directory / GuestRustBuildConstants.BUILD_MANIFEST_NAME + manifest = cast( + dict[str, object], + json.loads(manifest_path.read_text(encoding="utf-8")), + ) + if manifest != _manifest(result): + raise ScriptError( + "Rust guest binary build manifest does not match the current " + "sources, build pins, and executables" + ) + return result + + def build_guest_binaries(output: Path) -> GuestBinaryBuildResult: """Build, statically check, digest, and stage a fresh set of executables.""" if sys.platform != "linux": diff --git a/scripts/nvx_tools/release.py b/scripts/nvx_tools/release.py index 26095eebe..4024260cb 100644 --- a/scripts/nvx_tools/release.py +++ b/scripts/nvx_tools/release.py @@ -36,6 +36,7 @@ AlpineBuildConstants, AzureLinuxBuildConstants, BuildConstants, + GuestRustBuildConstants, InitramfsBuildConstants, KernelBuildConstants, OpenVMMBuildConstants, @@ -59,6 +60,10 @@ write_sha256_sums, ) from .development_release import development_release_tag +from .guest_binaries import ( + guest_binary_source_files, + pinned_guest_rust_toolchain, +) from .ubuntu import converter_input_sha256, customization_files, package_lock_sha256 GITHUB_API_VERSION = "2022-11-28" @@ -1231,6 +1236,7 @@ def _validate_source_manifest_metadata( alpine_value = manifest.get("alpine") ubuntu_value = manifest.get("ubuntu") azurelinux_value = manifest.get("azurelinux") + guest_rust_value = manifest.get("guest_rust") source_value = kernel_inputs.get("source") config_value = kernel_inputs.get("input_config") if not all( @@ -1240,6 +1246,7 @@ def _validate_source_manifest_metadata( alpine_value, ubuntu_value, azurelinux_value, + guest_rust_value, source_value, config_value, ) @@ -1249,8 +1256,17 @@ def _validate_source_manifest_metadata( alpine = cast(dict[str, object], alpine_value) ubuntu = cast(dict[str, object], ubuntu_value) azurelinux = cast(dict[str, object], azurelinux_value) + guest_rust = cast(dict[str, object], guest_rust_value) source = cast(dict[str, object], source_value) input_config = cast(dict[str, object], config_value) + expected_guest_rust = { + "toolchain": pinned_guest_rust_toolchain(), + "target": GuestRustBuildConstants.TARGET, + } + if guest_rust != expected_guest_rust: + raise ScriptError( + "SOURCE-MANIFEST.json Rust guest toolchain does not match the build pin" + ) source_patches = source.get("patches") if not isinstance(source_patches, list): raise ScriptError("kernel provenance source patches must be a list") @@ -1295,7 +1311,11 @@ def _validate_source_manifest_metadata( "minirootfs_url": AlpineBuildConstants.MINIROOTFS_URL, "minirootfs_sha256": AlpineBuildConstants.MINIROOTFS_SHA256, "guest_sources": [ - path.as_posix() for path in AlpineBuildConstants.GUEST_SOURCE_DIRECTORIES + *( + path.as_posix() + for path in AlpineBuildConstants.GUEST_SOURCE_DIRECTORIES + ), + *GuestRustBuildConstants.SOURCE_PATHS, ], "package_manifests": [ ( @@ -1329,6 +1349,7 @@ def _validate_source_manifest_metadata( for path in UbuntuBuildConstants.GUEST_SOURCE_DIRECTORIES ), UbuntuBuildConstants.PACKAGE_LOCK_RELATIVE_PATH.as_posix(), + *GuestRustBuildConstants.SOURCE_PATHS, ], "package_manifests": [ ( @@ -1363,6 +1384,7 @@ def _validate_source_manifest_metadata( for path in AzureLinuxBuildConstants.GUEST_SOURCE_DIRECTORIES ), AzureLinuxBuildConstants.PACKAGE_LOCK_RELATIVE_PATH.as_posix(), + *GuestRustBuildConstants.SOURCE_PATHS, ], "package_manifests": [ ( @@ -1699,6 +1721,8 @@ def verify_source_tree() -> None: manifest, kernel_provenance_inputs(), ) + guest_binary_source_files() + pinned_guest_rust_toolchain() required = { BuildConstants.REPO_ROOT / "kernel" diff --git a/scripts/nvx_tools/ubuntu.py b/scripts/nvx_tools/ubuntu.py index 8c3a53cdb..5b9cbe6a6 100644 --- a/scripts/nvx_tools/ubuntu.py +++ b/scripts/nvx_tools/ubuntu.py @@ -33,6 +33,7 @@ require_tool, sha256_file, ) +from .guest_binaries import guest_binary_source_files _SHA256 = re.compile(r"[0-9a-f]{64}") _PACKAGE_NAME = re.compile(r"[a-z0-9][a-z0-9+.-]*") @@ -1009,6 +1010,7 @@ def customization_files() -> tuple[Path, ...]: if path.is_file() ), UbuntuBuildConstants.PACKAGE_LOCK, + *guest_binary_source_files(), ) @@ -1026,7 +1028,7 @@ def converter_input_sha256(customization_files: Sequence[Path]) -> str: "customization_files": [ { "path": path.relative_to(BuildConstants.REPO_ROOT).as_posix(), - "sha256": sha256_file(path), + "sha256": normalized_sha256_file(path), } for path in sorted(customization_files) ], diff --git a/scripts/setup/setup-linux-mshv.sh b/scripts/setup/setup-linux-mshv.sh index b3cef1ad1..15c95b60c 100644 --- a/scripts/setup/setup-linux-mshv.sh +++ b/scripts/setup/setup-linux-mshv.sh @@ -227,9 +227,14 @@ install_rust_tools() { fi rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal + rustup target add --toolchain "$RUST_TOOLCHAIN" \ + x86_64-unknown-linux-musl rust_version=$(RUSTUP_TOOLCHAIN=$RUST_TOOLCHAIN rustc --version | awk '{print $2}') [ "$rust_version" = "$RUST_TOOLCHAIN" ] || die "Rust ${RUST_TOOLCHAIN} is required, found ${rust_version}" + rustup target list --installed --toolchain "$RUST_TOOLCHAIN" | + grep -Fxq x86_64-unknown-linux-musl || + die "Rust target x86_64-unknown-linux-musl is not installed" if ! command -v cargo-nextest >/dev/null 2>&1 || ! cargo nextest --version | grep -Fq "cargo-nextest ${CARGO_NEXTEST_VERSION}"; then cargo +"$RUST_TOOLCHAIN" install --locked cargo-nextest \ diff --git a/scripts/setup/setup-linux-runner.sh b/scripts/setup/setup-linux-runner.sh index a55a00b2e..4080c150e 100644 --- a/scripts/setup/setup-linux-runner.sh +++ b/scripts/setup/setup-linux-runner.sh @@ -596,8 +596,7 @@ install_rust_tools() { run_as_root env \ CARGO_HOME="$trusted_cargo_home" \ RUSTUP_HOME="$trusted_rustup_home" \ - RUSTUP_TOOLCHAIN="$RUST_TOOLCHAIN" \ - "$rustup" target add \ + "$rustup" target add --toolchain "$RUST_TOOLCHAIN" \ x86_64-unknown-none \ x86_64-unknown-linux-musl \ x86_64-unknown-uefi diff --git a/scripts/test_guest_binaries.py b/scripts/test_guest_binaries.py index dc71815c8..a93b1634c 100644 --- a/scripts/test_guest_binaries.py +++ b/scripts/test_guest_binaries.py @@ -85,6 +85,11 @@ def test_source_identity_covers_the_crate_and_the_toolchain(self): path.relative_to(BuildConstants.REPO_ROOT).as_posix() for path in guest_binaries.guest_binary_source_files() } + # The build constants list the inputs that the inventory reads. + self.assertLessEqual( + {path.as_posix() for path in GuestRustBuildConstants.SOURCE_FILES}, + relative, + ) self.assertIn("rust-toolchain.toml", relative) self.assertIn("nvx-rs/Cargo.toml", relative) self.assertIn("nvx-rs/Cargo.lock", relative) @@ -100,6 +105,89 @@ def test_source_identity_covers_the_crate_and_the_toolchain(self): GuestRustBuildConstants.TOOLCHAIN, ) + def test_toolchain_pin_must_match_the_guest_build_constants(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "rust-toolchain.toml").write_text( + '[toolchain]\nchannel = "9.9.9"\n', + encoding="utf-8", + ) + with self.assertRaisesRegex( + guest_binaries.ScriptError, + "Rust guest build toolchain mismatch", + ): + guest_binaries.pinned_guest_rust_toolchain(root) + + def test_source_inventory_preserves_the_callers_root_spelling(self): + with tempfile.TemporaryDirectory( + prefix=".test-guest-binary-root-", + dir=BuildConstants.REPO_ROOT, + ) as temporary: + root = Path(temporary) + relative_root = Path(os.path.relpath(root, Path.cwd())) + crate = root / "nvx-rs" + source = crate / "src" + tools = root / "scripts" / "nvx_tools" + source.mkdir(parents=True) + tools.mkdir(parents=True) + for path in ( + crate / "Cargo.toml", + crate / "Cargo.lock", + crate / "build.rs", + source / "lib.rs", + tools / "build_constants.py", + tools / "guest_binaries.py", + root / "rust-toolchain.toml", + ): + path.write_text(f"{path.name}\n", encoding="utf-8") + + inventory = guest_binaries.guest_binary_source_files(relative_root) + + self.assertTrue(all(not path.is_absolute() for path in inventory)) + self.assertEqual( + guest_binaries.guest_binary_source_sha256(relative_root), + guest_binaries.guest_binary_source_sha256(root), + ) + + def test_source_identity_follows_a_content_preserving_module_move(self): + # The guest image cache keys append this identity because hashFiles + # hashes contents only: moving a module into its own mod.rs keeps every + # file's bytes but changes the source that guest provenance records. + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + crate = root / "nvx-rs" + module = crate / "src" / "guest" / "ingress" + tools = root / "scripts" / "nvx_tools" + module.mkdir(parents=True) + tools.mkdir(parents=True) + for path in ( + crate / "Cargo.toml", + crate / "Cargo.lock", + crate / "build.rs", + crate / "src" / "lib.rs", + module / "encode.rs", + tools / "build_constants.py", + tools / "guest_binaries.py", + root / "rust-toolchain.toml", + ): + path.write_text(f"{path.name}\n", encoding="utf-8") + + def contents() -> list[bytes]: + return [ + path.read_bytes() + for path in guest_binaries.guest_binary_source_files(root) + ] + + original_contents = contents() + original_identity = guest_binaries.guest_binary_source_sha256(root) + (module / "encode").mkdir() + (module / "encode.rs").rename(module / "encode" / "mod.rs") + + self.assertEqual(contents(), original_contents) + self.assertNotEqual( + guest_binaries.guest_binary_source_sha256(root), original_identity + ) + def test_build_uses_fresh_remapped_target_and_writes_manifest(self): with tempfile.TemporaryDirectory( prefix=".test-guest-binaries-", @@ -163,21 +251,6 @@ def run_cargo( patch.object(guest_binaries.sys, "platform", "linux"), patch.object(BuildConstants, "REPO_ROOT", root), patch.object(GuestRustBuildConstants, "DIRECTORY", crate), - patch.object(GuestRustBuildConstants, "MANIFEST", crate / "Cargo.toml"), - patch.object(GuestRustBuildConstants, "LOCKFILE", crate / "Cargo.lock"), - patch.object( - GuestRustBuildConstants, "BUILD_SCRIPT", crate / "build.rs" - ), - patch.object( - GuestRustBuildConstants, - "SOURCE_DIRECTORIES", - (source,), - ), - patch.object( - GuestRustBuildConstants, - "BUILD_RECIPE_FILES", - (recipe_constants, recipe_builder), - ), patch.object( GuestRustBuildConstants, "TOOLCHAIN_FILE", @@ -207,9 +280,16 @@ def run_cargo( guest_binaries.guest_binary_source_sha256(), first_source ) result = guest_binaries.build_guest_binaries(output) + inspected = guest_binaries.inspect_guest_binaries(output) + (source / "lib.rs").write_bytes(b"pub fn fixture() {}\r\n") + self.assertEqual( + guest_binaries.guest_binary_source_sha256(), + result.source_sha256, + ) # One Cargo build produces every binary from the canonical recipe. self.assertEqual(len(commands), 1) + self.assertEqual(inspected, result) [command] = commands self.assertEqual(command[:3], ["cargo", "+1.95.0", "build"]) self.assertEqual( @@ -246,6 +326,7 @@ def run_cargo( [path.name for path in check_static.call_args.args[0]], list(GuestRustBuildConstants.BINARIES), ) + self.assertEqual(check_static.call_count, 2) self.assertEqual( set(result.binaries), set(GuestRustBuildConstants.BINARIES) ) @@ -437,6 +518,37 @@ def test_rebuild_refuses_unrecorded_or_unrelated_files(self): cargo.assert_not_called() self.assertEqual(_contents(output), before) + def test_inspection_refuses_an_earlier_build(self): + # A build upgrades these outputs in place, but none is a current build. + earlier = tuple( + binary + for binary in GuestRustBuildConstants.BINARIES + if binary not in GuestRustBuildConstants.ADDED_BINARIES + ) + retired = (*GuestRustBuildConstants.BINARIES, "nvx-port-io") + for case, binaries in ( + ("build without the added executables", earlier), + ("build from before nvx-port-io retired", retired), + ): + with ( + self.subTest(case), + tempfile.TemporaryDirectory( + prefix=".test-guest-binary-inspect-", + dir=BuildConstants.REPO_ROOT, + ) as temporary, + ): + output = Path(temporary) / "output" + _write_build_output(output, binaries, recorded=binaries) + before = _contents(output) + with ( + _fake_guest_build(), + self.assertRaisesRegex( + guest_binaries.ScriptError, "not a current build" + ), + ): + guest_binaries.inspect_guest_binaries(output) + self.assertEqual(_contents(output), before) + def test_initialization_failure_removes_allocated_work_directory(self): with tempfile.TemporaryDirectory( prefix=".test-guest-binary-initialization-", @@ -608,6 +720,29 @@ def test_test_command_uses_the_selected_directory(self): args.handler(args) test.assert_called_once_with(Path("build/guest-binaries")) + def test_source_identity_command_prints_the_recorded_source_digest(self): + # The guest image cache keys end with this output, so it must be the + # digest that a build records as the binaries' source. + for argv, root in ( + (["guest-binary-source-identity"], BuildConstants.REPO_ROOT), + ( + ["guest-binary-source-identity", "--root", "checkout"], + Path("checkout"), + ), + ): + with self.subTest(argv=argv): + args = nvx.parse_args(argv) + stdout = io.StringIO() + with ( + patch.object( + nvx, "guest_binary_source_sha256", return_value="c" * 64 + ) as identity, + contextlib.redirect_stdout(stdout), + ): + args.handler(args) + identity.assert_called_once_with(root) + self.assertEqual(stdout.getvalue(), "c" * 64 + "\n") + if __name__ == "__main__": unittest.main() diff --git a/scripts/test_guest_ingress.py b/scripts/test_guest_ingress.py deleted file mode 100644 index 0ddd33d5d..000000000 --- a/scripts/test_guest_ingress.py +++ /dev/null @@ -1,416 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for the guest listener announcer, guest/common/nvx-ingress.c. - -The helper's test mode runs synthetic snapshots of the kernel's socket tables -and socket diagnostics through the scans that a guest runs, so these tests -check what it announces without a guest or privileges. -""" - -from __future__ import annotations - -import ipaddress -import shutil -import struct -import subprocess -import sys -import tempfile -import unittest -from dataclasses import dataclass -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] -SOURCE = REPO_ROOT / "guest" / "common" / "nvx-ingress.c" - -TCP = 6 -UDP = 17 -IPV4 = 1 -IPV6 = 2 -BOTH = IPV4 | IPV6 -TCP_LISTEN = 0x0A -TCP_ESTABLISHED = 0x01 -UDP_CONNECTED = 0x01 -UDP_UNCONNECTED = 0x07 -UDP_SETTLE_MS = 1000 -# The helper's first source port, which its test mode excludes like its own. -OWN_PORT = 1023 -TABLE_HEADER = ( - " sl local_address rem_address st tx_queue rx_queue tr tm->when " - "retrnsmt uid timeout inode\n" -) -NETLINK_HEADER = struct.Struct("=IHHII") -SOCK_DIAG_BY_FAMILY = 20 -NETLINK_DONE = 3 -INET_DIAG_SKV6ONLY = 11 -AF_INET6 = 10 - - -def kernel_address(address: str) -> str: - """Render an address as the kernel's socket tables print it on x86.""" - packed = ipaddress.ip_address(address).packed - return "".join( - f"{int.from_bytes(packed[offset : offset + 4], 'little'):08X}" - for offset in range(0, len(packed), 4) - ) - - -@dataclass(frozen=True) -class Socket: - protocol: int - address: str - port: int - inode: int - state: int - peer: tuple[str, int] | None = None - - @property - def ipv6(self) -> bool: - return ipaddress.ip_address(self.address).version == 6 - - @property - def table(self) -> str: - return ("tcp" if self.protocol == TCP else "udp") + ("6" if self.ipv6 else "") - - def line(self, index: int) -> str: - remote, remote_port = self.peer or ("::" if self.ipv6 else "0.0.0.0", 0) - return ( - f"{index:4}: {kernel_address(self.address)}:{self.port:04X} " - f"{kernel_address(remote)}:{remote_port:04X} {self.state:02X} " - "00000000:00000000 00:00000000 00000000 0 0 " - f"{self.inode} 2 0000000000000000\n" - ) - - -def tcp_listener(address: str, port: int, inode: int = 1) -> Socket: - return Socket(TCP, address, port, inode, TCP_LISTEN) - - -def udp_socket( - address: str, port: int, inode: int, peer: tuple[str, int] | None = None -) -> Socket: - state = UDP_UNCONNECTED if peer is None else UDP_CONNECTED - return Socket(UDP, address, port, inode, state, peer) - - -def diag_listener(port: int, ipv6_only: bool, address: str = "::") -> bytes: - """Encode a socket diagnostics message for an IPv6 TCP listener.""" - message = struct.pack("=BBBB", AF_INET6, TCP_LISTEN, 0, 0) - message += struct.pack("!HH", port, 0) - message += ipaddress.IPv6Address(address).packed + bytes(16) - message += struct.pack("=III", 0, 0, 0) + struct.pack("=IIIII", 0, 0, 0, 0, 0) - message += struct.pack("=HH", 5, INET_DIAG_SKV6ONLY) + bytes( - (int(ipv6_only), 0, 0, 0) - ) - return ( - NETLINK_HEADER.pack( - NETLINK_HEADER.size + len(message), SOCK_DIAG_BY_FAMILY, 2, 1, 0 - ) - + message - ) - - -def diag_done() -> bytes: - return NETLINK_HEADER.pack(NETLINK_HEADER.size + 4, NETLINK_DONE, 2, 1, 0) + bytes( - 4 - ) - - -@dataclass(frozen=True) -class Snapshot: - time_ms: int - sockets: tuple[Socket, ...] = () - bindv6only: int | None = None - diag: bytes | None = None - - def write(self, root: Path) -> None: - (root / "net").mkdir(parents=True) - for table in ("tcp", "tcp6", "udp", "udp6"): - sockets = [socket for socket in self.sockets if socket.table == table] - (root / "net" / table).write_text( - TABLE_HEADER - + "".join(socket.line(index) for index, socket in enumerate(sockets)), - encoding="utf-8", - ) - if self.bindv6only is not None: - sysctl = root / "sys" / "net" / "ipv6" - sysctl.mkdir(parents=True) - (sysctl / "bindv6only").write_text(f"{self.bindv6only}\n", encoding="utf-8") - if self.diag is not None: - (root / "diag").write_bytes(self.diag) - (root / "time").write_text(f"{self.time_ms}\n", encoding="utf-8") - - -@dataclass(frozen=True) -class Announcement: - payload: bytes - truncated: bool - listeners: dict[tuple[int, int], int] - order: tuple[tuple[int, int], ...] - - @classmethod - def decode(cls, payload: bytes) -> Announcement: - magic, version, flags, count = struct.unpack("!4sBBH", payload[:8]) - if magic != b"NVXL" or version != 1 or len(payload) != 8 + 4 * count: - raise AssertionError(f"malformed announcement {payload.hex()}") - entries = [ - struct.unpack("!BBH", payload[8 + 4 * index : 12 + 4 * index]) - for index in range(count) - ] - return cls( - payload, - bool(flags & 1), - {(protocol, port): families for protocol, families, port in entries}, - tuple((protocol, port) for protocol, _families, port in entries), - ) - - -@unittest.skipUnless(sys.platform.startswith("linux"), "nvx-ingress builds on Linux") -class GuestIngressTests(unittest.TestCase): - binary: Path - directory: tempfile.TemporaryDirectory[str] - - @classmethod - def setUpClass(cls) -> None: - compiler = shutil.which("cc") or shutil.which("gcc") - if compiler is None: - raise unittest.SkipTest("a C compiler is unavailable") - cls.directory = tempfile.TemporaryDirectory() - cls.binary = Path(cls.directory.name) / "nvx-ingress" - subprocess.run( - [ - compiler, - "-O1", - "-Wall", - "-Wextra", - "-Werror", - "-o", - str(cls.binary), - str(SOURCE), - ], - check=True, - capture_output=True, - timeout=120, - ) - - @classmethod - def tearDownClass(cls) -> None: - cls.directory.cleanup() - - def announce(self, *snapshots: Snapshot) -> list[Announcement]: - with tempfile.TemporaryDirectory() as temporary: - for index, snapshot in enumerate(snapshots): - snapshot.write(Path(temporary) / str(index)) - result = subprocess.run( - [str(self.binary), "test", temporary], - capture_output=True, - text=True, - timeout=30, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stderr) - lines = result.stdout.splitlines() - self.assertEqual( - [int(line.split()[0]) for line in lines], - [snapshot.time_ms for snapshot in snapshots], - ) - return [Announcement.decode(bytes.fromhex(line.split()[1])) for line in lines] - - def test_announcement_golden_vector(self): - (announcement,) = self.announce( - Snapshot( - 0, - (tcp_listener("0.0.0.0", 8080), tcp_listener("fd00::a00:2", 8443)), - ) - ) - self.assertEqual(announcement.payload.hex(), "4e56584c0100000206011f90060220fb") - - def test_only_listeners_reachable_through_the_gateway_are_announced(self): - sockets = ( - tcp_listener("0.0.0.0", 8080), - tcp_listener("192.0.2.2", 8081), - tcp_listener("127.0.0.1", 8082), - tcp_listener("127.0.0.53", 8083), - tcp_listener("::", 8084), - tcp_listener("fd00::a00:2", 8085), - tcp_listener("::1", 8086), - tcp_listener("fe80::1", 8087), - tcp_listener("::ffff:127.0.0.1", 8088), - tcp_listener("::ffff:192.0.2.2", 8089), - # One port's IPv4 and IPv6 listeners merge their families. - tcp_listener("0.0.0.0", 8090), - tcp_listener("fd00::a00:2", 8090, inode=2), - # Established connections are not listeners. - Socket(TCP, "192.0.2.2", 8091, 3, TCP_ESTABLISHED, ("192.0.2.1", 40000)), - ) - (announcement,) = self.announce(Snapshot(0, sockets)) - self.assertEqual( - announcement.listeners, - { - (TCP, 8080): IPV4, - (TCP, 8081): IPV4, - (TCP, 8084): BOTH, - (TCP, 8085): IPV6, - (TCP, 8089): IPV4, - (TCP, 8090): BOTH, - }, - ) - self.assertFalse(announcement.truncated) - - def test_ipv6_only_tcp_listeners_follow_socket_diagnostics(self): - sockets = tuple( - tcp_listener("::", port, inode=port) for port in range(7001, 7006) - ) - diag = ( - diag_listener(7001, ipv6_only=True) - + diag_listener(7002, ipv6_only=False) - # A port that IPv6-only and dual-stack sockets share takes IPv4. - + diag_listener(7003, ipv6_only=True) - + diag_listener(7003, ipv6_only=False) - # Only listeners on the unspecified address are classified. - + diag_listener(7004, ipv6_only=True, address="fd00::a00:2") - + diag_done() - # Messages after the end of the dump are ignored. - + diag_listener(7005, ipv6_only=True) - ) - default, ipv6_only_default = self.announce( - Snapshot(0, sockets, diag=diag), - Snapshot(100, sockets, bindv6only=1, diag=diag_listener(7002, False)), - ) - self.assertEqual( - default.listeners, - { - (TCP, 7001): IPV6, - (TCP, 7002): BOTH, - (TCP, 7003): BOTH, - (TCP, 7004): BOTH, - (TCP, 7005): BOTH, - }, - ) - # Without diagnostics for a socket, net.ipv6.bindv6only decides. - self.assertEqual( - ipv6_only_default.listeners, - { - (TCP, 7001): IPV6, - (TCP, 7002): BOTH, - (TCP, 7003): IPV6, - (TCP, 7004): IPV6, - (TCP, 7005): IPV6, - }, - ) - - def test_malformed_socket_diagnostics_are_ignored(self): - sockets = (tcp_listener("::", 7101),) - valid = diag_listener(7101, ipv6_only=True) - short_attribute = bytearray(valid) - struct.pack_into("=H", short_attribute, NETLINK_HEADER.size + 72, 2) - long_attribute = bytearray(valid) - struct.pack_into("=H", long_attribute, NETLINK_HEADER.size + 72, 200) - for name, diag in ( - ("garbage", bytes(range(37))), - ("short header", NETLINK_HEADER.pack(8, SOCK_DIAG_BY_FAMILY, 0, 0, 0)), - ( - "overlong message", - NETLINK_HEADER.pack(4096, SOCK_DIAG_BY_FAMILY, 0, 0, 0), - ), - ("truncated message", valid[:40]), - ("short attribute", bytes(short_attribute)), - ("overlong attribute", bytes(long_attribute)), - ): - with self.subTest(name): - (announcement,) = self.announce(Snapshot(0, sockets, diag=diag)) - self.assertEqual(announcement.listeners, {(TCP, 7101): BOTH}) - - def test_udp_sockets_settle_before_they_are_announced(self): - sockets = ( - udp_socket("0.0.0.0", 7201, 10), - udp_socket("::", 7202, 11), - # The helper's own source port is never announced. - udp_socket("0.0.0.0", OWN_PORT, 12), - # Nor is a UDP socket bound to loopback. - udp_socket("127.0.0.1", 7203, 13), - ) - announcements = self.announce( - Snapshot(0, sockets), - Snapshot(UDP_SETTLE_MS - 1, sockets), - Snapshot(UDP_SETTLE_MS, sockets), - Snapshot(UDP_SETTLE_MS + 100, sockets, bindv6only=1), - ) - self.assertEqual(announcements[0].listeners, {}) - self.assertEqual(announcements[1].listeners, {}) - self.assertEqual( - announcements[2].listeners, {(UDP, 7201): IPV4, (UDP, 7202): BOTH} - ) - self.assertEqual( - announcements[3].listeners, {(UDP, 7201): IPV4, (UDP, 7202): IPV6} - ) - - def test_udp_ports_follow_the_identity_of_their_sockets(self): - server = udp_socket("0.0.0.0", 7301, 20) - replacement = udp_socket("0.0.0.0", 7301, 21) - connected = udp_socket("192.0.2.2", 7301, 21, ("198.51.100.7", 5353)) - client = udp_socket("192.0.2.2", 7301, 22, ("192.0.2.1", 53)) - timeline = ( - (0, (server,), None), - (UDP_SETTLE_MS, (server,), IPV4), - # Another socket on the port settles anew. - (UDP_SETTLE_MS + 100, (replacement,), None), - (2 * UDP_SETTLE_MS + 99, (replacement,), None), - (2 * UDP_SETTLE_MS + 100, (replacement,), IPV4), - # A server that connects its announced socket keeps the port. - (2 * UDP_SETTLE_MS + 200, (connected,), IPV4), - (5 * UDP_SETTLE_MS, (connected,), IPV4), - # A connected client that reuses the port never takes it over. - (5 * UDP_SETTLE_MS + 100, (client,), None), - (9 * UDP_SETTLE_MS, (client,), None), - ) - announcements = self.announce( - *(Snapshot(time_ms, sockets) for time_ms, sockets, _ in timeline) - ) - for (time_ms, _sockets, families), announcement in zip( - timeline, announcements, strict=True - ): - with self.subTest(time_ms=time_ms): - expected = {} if families is None else {(UDP, 7301): families} - self.assertEqual(announcement.listeners, expected) - - def test_udp_ports_announce_the_families_of_their_sockets(self): - ipv4 = udp_socket("0.0.0.0", 7401, 30) - ipv6 = udp_socket("::", 7401, 31) - ipv4_connected = udp_socket("192.0.2.2", 7401, 30, ("198.51.100.7", 4000)) - ipv6_connected = udp_socket("fd00::a00:2", 7401, 31, ("fd12::7", 4000)) - timeline = ( - (0, (ipv4, ipv6), None), - (UDP_SETTLE_MS, (ipv4, ipv6), BOTH), - (UDP_SETTLE_MS + 100, (ipv4_connected, ipv6), BOTH), - (UDP_SETTLE_MS + 200, (ipv4_connected, ipv6_connected), BOTH), - (UDP_SETTLE_MS + 300, (ipv6_connected,), IPV6), - (UDP_SETTLE_MS + 400, (), None), - ) - announcements = self.announce( - *( - Snapshot(time_ms, sockets, bindv6only=1) - for time_ms, sockets, _ in timeline - ) - ) - for (time_ms, _sockets, families), announcement in zip( - timeline, announcements, strict=True - ): - with self.subTest(time_ms=time_ms): - expected = {} if families is None else {(UDP, 7401): families} - self.assertEqual(announcement.listeners, expected) - - def test_announcements_carry_the_lowest_64_listeners(self): - sockets = tuple( - tcp_listener("0.0.0.0", port, inode=port) for port in range(2069, 1999, -1) - ) - (announcement,) = self.announce(Snapshot(0, sockets)) - self.assertTrue(announcement.truncated) - self.assertEqual( - announcement.order, tuple((TCP, port) for port in range(2000, 2064)) - ) - (complete,) = self.announce(Snapshot(0, sockets[-64:])) - self.assertFalse(complete.truncated) - self.assertEqual(len(complete.order), 64) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_guest_time.py b/scripts/test_guest_time.py deleted file mode 100644 index 56ef24a53..000000000 --- a/scripts/test_guest_time.py +++ /dev/null @@ -1,943 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for the guest time component, guest/common/nvx-time.c. - -The fixtures encode the guest-visible formats independently of the C decoders. -The restore packet (doc/design/5.5-restore-protocol.md) and the time sample -(doc/design/3.2-clock-and-time.md) follow the guide; the other fixtures, such -as the exhaustive check's lines and the state file, pin the formats that -nvx-time.c defines. -""" - -from __future__ import annotations - -import os -import re -import shutil -import struct -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] -SOURCE = REPO_ROOT / "guest" / "common" / "nvx-time.c" - -DOWNTIME_UTC = 0x01 -MEMORY_TARGET = 0x02 -ACK_REQUIRED = 0x04 -TEST_HOOKS = 0x08 -UTC_NS = 1_790_841_600_123_456_789 -ENTROPY = bytes(range(64)) -PREVIOUS_ID = "ff" * 16 -THIRTY_DAYS_NS = 2_592_000 * 1_000_000_000 - - -def encode_packet( - *, - flags: int = 0, - online: int = 0, - ranges: tuple[tuple[int, int], ...] = (), - range_count: int | None = None, - generation: int = 1, - rate_deviation: int = 0, - downtime_ns: int = 0, - utc_ns: int = UTC_NS, - magic: bytes = b"OVR", - version: int = 4, - reserved: int = 0, - entropy: bytes = ENTROPY, -) -> bytes: - """Encode a restore packet v4 (guide: 5.5.5 "Restore Packet").""" - count = len(ranges) if range_count is None else range_count - header = magic + bytes((version, flags, online, count, reserved)) - header += struct.pack(" bytes: - """Encode a time sample (guide: 3.2.9 "Time Samples").""" - return struct.pack(" bool: - """Whether this process may switch from SCHED_IDLE back to SCHED_OTHER. - - That needs CAP_SYS_NICE, which the guest has, or an RLIMIT_NICE of at - least 20; containers and CI runners usually have neither. - """ - status = Path("/proc/self/status").read_text(encoding="utf-8") - capabilities = re.search(r"^CapEff:\s*([0-9a-f]+)$", status, re.MULTILINE) - if capabilities is not None and int(capabilities.group(1), 16) & (1 << 23): - return True - limits = Path("/proc/self/limits").read_text(encoding="utf-8") - nice = re.search(r"^Max nice priority\s+(\S+)", limits, re.MULTILINE) - return nice is not None and ( - nice.group(1) == "unlimited" or int(nice.group(1)) >= 20 - ) - - -TIMER_LIST_CPU = """\ -Tick Device: mode: 1 -Per CPU device: {cpu} -Clock Event Device: {device} - max_delta_ns: 1374389534634 - min_delta_ns: 1000 - mult: 6710886 - shift: 26 - mode: {state} - next_event: 1342000000 nsecs - set_next_event: lapic_next_event - shutdown: lapic_timer_shutdown - periodic: lapic_timer_set_periodic - oneshot: lapic_timer_set_oneshot - oneshot stopped: lapic_timer_shutdown - event_handler: {handler} - retries: 0 - -""" - -TIMER_LIST_BROADCAST = """\ -Tick Device: mode: 1 -Broadcast device -Clock Event Device: {device} -tick_broadcast_mask: 00 -tick_broadcast_oneshot_mask: 00 - -""" - -CPUINFO_CPU = """\ -processor\t: {cpu} -vendor_id\t: GenuineIntel -cpu family\t: 6 -model\t\t: 85 -cpu MHz\t\t: 2194.843 -flags\t\t: fpu vme {flags} -bugs\t\t: spectre_v1 - -""" - -GOOD_FLAGS = ( - "tsc msr pae constant_tsc rdtscp nonstop_tsc tsc_known_freq tsc_reliable " - "hypervisor arat x2apic" -) - - -@unittest.skipUnless(sys.platform.startswith("linux"), "nvx-time builds on Linux") -class GuestTimeTests(unittest.TestCase): - binary: Path - directory: tempfile.TemporaryDirectory[str] - - @classmethod - def setUpClass(cls) -> None: - compiler = shutil.which("cc") or shutil.which("gcc") - if compiler is None: - raise unittest.SkipTest("a C compiler is unavailable") - cls.directory = tempfile.TemporaryDirectory() - cls.binary = Path(cls.directory.name) / "nvx-time" - subprocess.run( - [ - compiler, - "-O1", - "-Wall", - "-Wextra", - "-Werror", - "-o", - str(cls.binary), - str(SOURCE), - ], - check=True, - capture_output=True, - timeout=120, - ) - - @classmethod - def tearDownClass(cls) -> None: - cls.directory.cleanup() - - def run_test(self, *arguments: str) -> str: - result = subprocess.run( - [str(self.binary), "test", *arguments], - capture_output=True, - text=True, - timeout=10, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stderr) - return result.stdout - - def fixture(self, name: str, content: bytes | str) -> str: - path = Path(self.directory.name) / name - if isinstance(content, str): - path.write_text(content, encoding="utf-8") - else: - path.write_bytes(content) - return str(path) - - def packet(self, packet: bytes, recorded: int = 0, previous: str = PREVIOUS_ID): - return self.run_test( - "packet", self.fixture("packet.bin", packet), str(recorded), previous - ).strip() - - def test_packet_v4_golden_vector(self): - packet = encode_packet( - flags=DOWNTIME_UTC | ACK_REQUIRED | TEST_HOOKS, - online=4, - generation=3, - rate_deviation=-13_107_200, - downtime_ns=30_000_000_000, - ) - - self.assertEqual(len(packet), 96) - self.assertEqual( - self.packet(packet, recorded=2), - "ok flags=13 online=4 ranges=0 generation=3 " - "rate_deviation=-13107200 frequency=13107200 " - "downtime_ns=30000000000 " - f"utc_ns={UTC_NS} generation_id={ENTROPY[:16].hex()}", - ) - - def test_musl_build_matches_the_reference_build(self): - musl = shutil.which("musl-gcc") - if musl is None: - self.skipTest("musl-gcc is unavailable") - binary = Path(self.directory.name) / "nvx-time-musl" - subprocess.run( - [musl, "-static", "-Os", "-s", "-Wall", "-Wextra", "-Werror"] - + ["-o", str(binary), str(SOURCE)], - check=True, - capture_output=True, - timeout=120, - ) - packet = self.fixture( - "musl-packet.bin", encode_packet(flags=ACK_REQUIRED, generation=2) - ) - arguments = ["test", "packet", packet, "1", PREVIOUS_ID] - result = subprocess.run( - [str(binary), *arguments], - capture_output=True, - text=True, - timeout=10, - check=False, - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout, self.run_test(*arguments[1:])) - # musl has no sched_setscheduler(); the daemon must still reach - # SCHED_IDLE after a restore and leave it after the bound. - policy = 0 if can_leave_idle_priority() else 5 - for name, expected in ( - ("idle-priority", f"idle=5 normal={policy}\n"), - ("promote", f"promoted caller={policy} thread={policy}\n"), - ): - result = subprocess.run( - [str(binary), "test", name], - capture_output=True, - text=True, - timeout=10, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertTrue(result.stdout.startswith(expected), result.stdout) - self.assertTrue(self.run_test(name).startswith(expected)) - - def test_packet_memory_ranges_follow_the_header(self): - packet = encode_packet( - flags=MEMORY_TARGET, - ranges=((0x8000_0000, 0x0800_0000), (0x1_0000_0000, 0x1000_0000)), - ) - - self.assertEqual(len(packet), 32 + 2 * 16 + 64) - self.assertTrue( - self.packet(packet).endswith( - " range=2147483648:134217728 range=4294967296:268435456" - ) - ) - self.assertIn(" ranges=0 ", self.packet(encode_packet(flags=MEMORY_TARGET))) - - def test_packet_rejections_name_the_repair_code(self): - cases = { - "magic": encode_packet(magic=b"OVX"), - "version 3": encode_packet(version=3), - "reserved flag": encode_packet(flags=0x10), - "reserved byte": encode_packet(reserved=1), - "online count": encode_packet(online=3), - "ranges without target": encode_packet(range_count=1), - "generation zero": encode_packet(generation=0), - "rate above": encode_packet(rate_deviation=16_384_001), - "rate below": encode_packet(rate_deviation=-16_384_001), - "downtime": encode_packet(downtime_ns=THIRTY_DAYS_NS + 1), - "utc zero": encode_packet(utc_ns=0), - "empty range": encode_packet(flags=MEMORY_TARGET, ranges=((0, 0),)), - "range overflow": encode_packet( - flags=MEMORY_TARGET, ranges=((2**64 - 4096, 8192),) - ), - } - for name, packet in cases.items(): - with self.subTest(case=name): - self.assertTrue( - self.packet(packet).startswith("error G_REPAIR_PACKET "), - name, - ) - - def test_packet_bounds_are_inclusive(self): - for packet in ( - encode_packet(rate_deviation=16_384_000), - encode_packet(rate_deviation=-16_384_000), - encode_packet(downtime_ns=THIRTY_DAYS_NS), - encode_packet(online=8), - ): - with self.subTest(packet=packet[:32].hex()): - self.assertTrue(self.packet(packet).startswith("ok ")) - - def test_packet_generation_must_follow_the_recorded_generation(self): - self.assertEqual(self.packet(encode_packet(generation=5), recorded=5), "stale") - self.assertTrue( - self.packet(encode_packet(generation=7), recorded=5).startswith( - "error G_REPAIR_GENERATION " - ) - ) - self.assertTrue( - self.packet( - encode_packet(generation=6), recorded=5, previous=ENTROPY[:16].hex() - ).startswith("error G_REPAIR_GENERATION ") - ) - - def sample(self, sample: bytes, generation: int = 0) -> str: - return self.run_test( - "sample", self.fixture("sample.bin", sample), str(generation) - ).strip() - - def test_time_sample_layout(self): - self.assertEqual(len(encode_sample()), 16) - self.assertEqual( - self.sample(encode_sample(generation=4, flags=TEST_HOOKS), 4), - f"ok flags=8 generation=4 utc_ns={UTC_NS}", - ) - for name, sample in { - "version": encode_sample(version=2), - "flags": encode_sample(flags=0x01), - "reserved": encode_sample(reserved=1), - "utc": encode_sample(utc_ns=0), - "generation": encode_sample(generation=3), - }.items(): - with self.subTest(case=name): - self.assertTrue(self.sample(sample).startswith("error "), name) - - def test_utc_pairing_uses_the_bracket_midpoint(self): - self.assertEqual( - self.run_test("pair", "1000", "3000", "5000").strip(), - "theta_ns=3000 epsilon_ns=1000", - ) - self.assertEqual( - self.run_test("pair", "10000", "10400", "9000").strip(), - "theta_ns=-1200 epsilon_ns=200", - ) - - def test_restore_frequency_compensates_and_clamps(self): - self.assertEqual( - self.run_test("frequency", "13107200").strip(), - "frequency=-13107200 ppb=-200000", - ) - self.assertEqual( - self.run_test("frequency", "-40000000").strip(), - "frequency=32768000 ppb=500000", - ) - self.assertEqual( - self.run_test("frequency", "-65536").strip(), - "frequency=65536 ppb=1000", - ) - - def test_discipline_steps_at_128_ms_and_slews_below(self): - self.assertEqual( - self.run_test("correction", "127999999", "10002", "fast").strip(), - "slew modes=0x203d offset=127999999 status=0x2001 constant=4 " - "maxerror=128011 esterror=11", - ) - self.assertEqual( - self.run_test("correction", "-500", "20", "slow").strip(), - "slew modes=0x203d offset=-500 status=0x2001 constant=6 " - "maxerror=1 esterror=1", - ) - self.assertEqual( - self.run_test("correction", "128000000", "10", "fast").strip(), - "step modes=0x2100 sec=0 nsec=128000000", - ) - self.assertEqual( - self.run_test("correction", "-1128000000", "10", "slow").strip(), - "step modes=0x2100 sec=-2 nsec=872000000", - ) - - def test_watcher_rules_match_linux_6_18_messages(self): - messages = { - "tsc: Marking TSC unstable due to clocksource watchdog": ("G_TSC_UNSTABLE"), - "clocksource: timekeeping watchdog on CPU0: Marking clocksource " - "'tsc-early' as unstable because the skew is too large:": ( - "G_CLOCKSOURCE_UNSTABLE" - ), - "clocksource: Switched to clocksource refined-jiffies": ( - "G_CLOCKSOURCE_SWITCH" - ), - "clocksource: CPUs 1 ahead of CPU 0 for clocksource tsc.": ( - "G_CLOCKSOURCE_SKEW" - ), - "clocksource: CPUs 2-3 behind CPU 0 for clocksource tsc.": ( - "G_CLOCKSOURCE_SKEW" - ), - "TSC synchronization [CPU#0 -> CPU#1]:": "G_TSC_WARP", - "Measured 45 cycles TSC warp between CPUs, turning off TSC clock.": ( - "G_TSC_WARP" - ), - "TSC warped randomly between CPUs": "G_TSC_WARP", - "[Firmware Bug]: TSC ADJUST differs: CPU1 0 --> 100. Restoring": ( - "G_TSC_ADJUST" - ), - "rcu: INFO: rcu_preempt detected stalls on CPUs/tasks:": "G_RCU_STALL", - "rcu: INFO: rcu_preempt self-detected stall on CPU": "G_RCU_STALL", - "rcu: INFO: rcu_preempt detected expedited stalls on CPUs/tasks: " - "{ 1-... }": "G_RCU_STALL", - "rcu: rcu_preempt kthread starved for 2603 jiffies! g345 f0x0": ( - "G_RCU_STARVED" - ), - "rcu: rcu_preempt kthread timer wakeup didn't happen for 2602 " - "jiffies! g345 f0x0": "G_RCU_STARVED", - "watchdog: BUG: soft lockup - CPU#0 stuck for 22s! [sh:123]": ( - "G_SOFT_LOCKUP" - ), - "watchdog: Watchdog detected hard LOCKUP on cpu 1": "G_HARD_LOCKUP", - "INFO: task kworker/0:1:12 blocked for more than 120 seconds.": ( - "G_HUNG_TASK" - ), - "unchecked MSR access error: RDMSR from 0x1ad at rIP: " - "0xffffffff816f11ee (__rdmsr_on_cpu+0x1e/0x30)": "G_UNCHECKED_MSR", - "[Firmware Bug]: CPU 1: APIC ID mismatch. CPUID: 0x0000 " - "APIC: 0x0001": "G_APIC_ID_MISMATCH", - "clocksource: tsc: mask: 0xffffffffffffffff max_cycles: 0x1fa3": "none", - "Hyper-V: LAPIC Timer Frequency: 0x1e8480": "none", - "rcu: Hierarchical RCU implementation.": "none", - 'NVX-TIME-ABI-VIOLATION: v=1 code=G_UNCHECKED_MSR detail="unchecked ' - 'MSR access error"': "none", - } - output = self.run_test( - "kmsg", self.fixture("kmsg.txt", "\n".join(messages) + "\n") - ) - self.assertEqual(output.splitlines(), list(messages.values())) - - def boot_log(self, lines: list[str]) -> str: - return self.run_test( - "boot-log", self.fixture("boot.txt", "\n".join(lines) + "\n") - ).strip() - - BOOT_LOG = [ - "Hypervisor detected: Microsoft Hyper-V", - "Hyper-V: privilege flags low 0x8860, high 0x0, ext 0x0, hints 0x0, misc 0x100", - "Hyper-V: LAPIC Timer Frequency: 0x1e8480", - "tsc: Detected 2194.843 MHz processor", - "clocksource: Switched to clocksource tsc-early", - "clocksource: Switched to clocksource tsc", - ] - - def test_boot_log_requires_the_identity_and_a_final_switch_to_tsc(self): - self.assertEqual(self.boot_log(self.BOOT_LOG), "ok") - kvm_rate = [line.replace("0x1e8480", "0x989680") for line in self.BOOT_LOG] - self.assertEqual(self.boot_log(kvm_rate), "ok") - failures = { - "missing identity": self.BOOT_LOG[1:], - "wrong LAPIC rate": [ - line.replace("0x1e8480", "0x1e8481") for line in self.BOOT_LOG - ], - "last switch": [ - *self.BOOT_LOG, - "clocksource: Switched to clocksource refined-jiffies", - ], - "kvm-clock": [*self.BOOT_LOG, "kvm-clock: Using msrs 4b564d01"], - "refinement": [ - *self.BOOT_LOG, - "tsc: Refined TSC clocksource calibration: 2194.843 MHz", - ], - "lapic token": [ - *self.BOOT_LOG, - "APIC timer: using supplied frequency 200000000 Hz", - ], - "watcher record": [ - *self.BOOT_LOG, - "tsc: Marking TSC unstable due to clocksource watchdog", - ], - } - for name, lines in failures.items(): - with self.subTest(case=name): - self.assertTrue(self.boot_log(lines).startswith("fail "), name) - - def test_boot_log_reads_syslog_read_all_lines(self): - # SYSLOG_ACTION_READ_ALL prefixes every line with "" and, with - # printk timestamps, "[seconds.micros] ". - lines = [ - f"<6>[{index:5d}.{index * 7919 % 1000000:06d}] {line}" - for index, line in enumerate(self.BOOT_LOG) - ] - lines.insert(1, "<6>[ 0.123456] [drm] a bracketed message is kept") - own_event = ( - "<2>[ 3.000000] NVX-TIME-REPORT-VIOLATION: v=1 code=G_CONFORMANCE_C4 " - 'detail="forbidden record: kvm-clock: Using msrs"' - ) - - def syslog(text_lines: list[str]) -> str: - return self.run_test( - "syslog", self.fixture("syslog.txt", "\n".join(text_lines) + "\n") - ).strip() - - self.assertEqual(syslog(lines), "ok") - self.assertEqual(syslog([*lines, own_event]), "ok") - self.assertTrue( - syslog([*lines, "<6>[ 4.000000] kvm-clock: Using msrs"]).startswith( - "fail forbidden record: kvm-clock" - ) - ) - self.assertTrue(syslog(lines[2:]).startswith("fail no 'Hypervisor")) - - def test_boot_log_reports_a_kernel_wx_mapping_apart_from_c4(self): - # Linux 6.18's CONFIG_DEBUG_WX audit, as logged when the ITS - # mitigation's thunk pages are left writable and executable. - warning = [ - "<4>[ 0.412345] ------------[ cut here ]------------", - "<4>[ 0.412346] x86/mm: Found insecure W+X mapping at address " - "0xffffffffc0000000", - "<4>[ 0.412400] WARNING: CPU: 0 PID: 1 at " - "arch/x86/mm/dump_pagetables.c:246 note_wx+0x5a/0x70", - "<6>[ 0.412500] x86/mm: Checked W+X mappings: FAILED, 4 W+X pages " - "found.", - ] - lines = [f"<6>[ 0.100000] {line}" for line in self.BOOT_LOG] - - def syslog(text_lines: list[str]) -> list[str]: - return ( - self.run_test( - "syslog", - self.fixture("syslog.txt", "\n".join(text_lines) + "\n"), - ) - .strip() - .splitlines() - ) - - self.assertEqual( - syslog([*lines[:2], *warning, *lines[2:]]), - [ - "ok", - "wx x86/mm: Found insecure W+X mapping at address 0xffffffffc0000000", - ], - ) - self.assertEqual( - syslog( - [ - *lines, - "<6>[ 0.412500] x86/mm: Checked W+X mappings: passed, " - "no W+X pages found.", - ] - ), - ["ok"], - ) - failed = syslog([*warning, *lines[1:]]) - self.assertEqual(len(failed), 2) - self.assertTrue(failed[0].startswith("fail no 'Hypervisor")) - self.assertTrue(failed[1].startswith("wx x86/mm: Found insecure W+X")) - own_event = ( - "<2>[ 3.000000] NVX-TIME-REPORT-VIOLATION: v=1 code=G_KERNEL_WX " - 'detail="x86/mm: Found insecure W+X mapping at address 0x0"' - ) - self.assertEqual(syslog([*lines, own_event]), ["ok"]) - - def test_daemon_lines_start_on_a_fresh_console_line(self): - # The daemon prints violation events while a shell may be in the - # middle of a line (its prompt, for example). - line = "NVX-TIME-ABI-VIOLATION: v=1 code=G_RCU_STALL source=watcher\n" - self.assertEqual(self.run_test("console", line, "sync"), line) - self.assertEqual(self.run_test("console", line, "async"), "\n" + line) - - def test_exhaustive_leaf_rules(self): - # X1 and X2: a leaf reads zero or the highest basic leaf's result, - # except the explicit zero leaves, which read zero. - out_of_range = ("0x00000d0b", "0x00000001", "0x00000002", "0x00000003") - zero = ("0", "0", "0", "0") - - def leaf(number: str, value: tuple[str, ...]) -> str: - return self.run_test( - "exhaustive-leaf", number, *value, *out_of_range - ).strip() - - self.assertEqual(leaf("0x40000006", zero), "pass") - self.assertEqual(leaf("0x40000010", out_of_range), "pass") - self.assertEqual(leaf("0x40000100", zero), "pass") - self.assertEqual(leaf("0x40000100", out_of_range), "pass") - explicit = leaf("0x40000081", out_of_range) - self.assertTrue(explicit.startswith("fail leaf 0x40000081 is "), explicit) - self.assertTrue(explicit.endswith("expected zero"), explicit) - # KVM's signature leaf moved to a higher base. - kvm = ("0x40000101", "0x4b4d564b", "0x564b4d56", "0x0000004d") - signature = leaf("0x40000100", kvm) - self.assertIn('(signature "KVMKVMKVM...")', signature) - self.assertTrue(signature.endswith("or the highest basic leaf"), signature) - - def test_exhaustive_report_format(self): - # The exhaustive check prints one line per check and CPU, with an - # escaped detail, then a summary. - line = re.compile( - r"^NVX-TIME-ABI-EXHAUSTIVE: v=1 check=X[1-6] cpu=\d+ " - r'status=(pass|fail) detail="(?:[^"\\]|\\.)*"$' - ) - passed = self.run_test( - "exhaustive-report", "X3", "2", "pass", "ok" - ).splitlines() - self.assertEqual( - passed, - [ - 'NVX-TIME-ABI-EXHAUSTIVE: v=1 check=X3 cpu=2 status=pass detail="ok"', - "failures=0", - ], - ) - failed = self.run_test( - "exhaustive-report", - "X2", - "7", - "fail", - 'base 0x40000100 "KVMKVMKVM" \\ x\ty', - ).splitlines() - self.assertRegex(failed[0], line) - self.assertIn(r'detail="base 0x40000100 \"KVMKVMKVM\" \\ x\x09y"', failed[0]) - self.assertEqual(failed[1], "failures=1") - self.assertEqual( - self.run_test("exhaustive-summary", "4", "0").splitlines(), - ["NVX-TIME-ABI-EXHAUSTIVE: v=1 status=ok cpus=4 failures=0", "exit=0"], - ) - self.assertEqual( - self.run_test("exhaustive-summary", "8", "3").splitlines(), - ["NVX-TIME-ABI-EXHAUSTIVE: v=1 status=fail cpus=8 failures=3", "exit=1"], - ) - - def timer_list( - self, - sections: list[tuple[int, str, int, str, int]], - broadcast: str = "", - cpus: str = "0-1", - ) -> str: - text = TIMER_LIST_BROADCAST.format(device=broadcast) - for cpu, device, state, handler, mode in sections: - text += TIMER_LIST_CPU.format( - cpu=cpu, device=device, state=state, handler=handler - ).replace("Tick Device: mode: 1", f"Tick Device: mode: {mode}") - return self.run_test( - "timer-list", self.fixture("timer_list.txt", text), cpus - ).strip() - - def test_timer_list_requires_one_shot_lapic_ticks(self): - good = [ - (0, "lapic", 3, "hrtimer_interrupt", 1), - (1, "lapic", 4, "hrtimer_interrupt", 1), - ] - self.assertEqual(self.timer_list(good), "ok") - failures = { - "deadline": [good[0], (1, "lapic-deadline", 3, "hrtimer_interrupt", 1)], - "periodic tick": [good[0], (1, "lapic", 3, "hrtimer_interrupt", 0)], - "periodic state": [good[0], (1, "lapic", 2, "hrtimer_interrupt", 1)], - "handler": [good[0], (1, "lapic", 3, "tick_handle_periodic", 1)], - "missing cpu": [good[0]], - } - for name, sections in failures.items(): - with self.subTest(case=name): - self.assertTrue(self.timer_list(sections).startswith("fail ")) - self.assertTrue(self.timer_list(good, broadcast="pit").startswith("fail ")) - - def test_cpuinfo_flags_and_rate(self): - def cpuinfo(flags: str, cpus: int = 2) -> str: - text = "".join( - CPUINFO_CPU.format(cpu=cpu, flags=flags) for cpu in range(cpus) - ) - return self.run_test( - "cpuinfo", self.fixture("cpuinfo.txt", text), "0-1" - ).strip() - - self.assertEqual(cpuinfo(GOOD_FLAGS), "ok cpu0_khz=2194843 cpu1_khz=2194843") - for name, flags in { - "tsc_reliable": GOOD_FLAGS.replace("tsc_reliable ", ""), - "deadline": GOOD_FLAGS + " tsc_deadline_timer", - "adjust": GOOD_FLAGS + " tsc_adjust", - }.items(): - with self.subTest(case=name): - self.assertIn("code=G_CONFORMANCE_C5", cpuinfo(flags)) - self.assertIn("code=G_CONFORMANCE_C5", cpuinfo(GOOD_FLAGS, cpus=1)) - - def test_command_line_rejects_clock_workarounds(self): - accepted = "earlycon=xe9 console=hvc0 quiet clocksource=tsc tsc=reliable" - self.assertEqual(self.run_test("cmdline", accepted).strip(), "ok") - for token in ( - "tsc_early_khz=2194843", - "lapic_timer_hz=200000000", - "notsc", - "nolapic", - "nolapic_timer", - "tsc=unstable", - "hpet=force", - "clocksource=kvm-clock", - ): - with self.subTest(token=token): - self.assertIn( - "code=G_CONFORMANCE_C9", - self.run_test("cmdline", f"{accepted} {token}"), - ) - - def test_clocksource_must_be_tsc_with_only_jiffies_fallbacks(self): - self.assertEqual(self.run_test("clocksource", "tsc\n", "tsc \n").strip(), "ok") - self.assertEqual( - self.run_test( - "clocksource", "tsc\n", "tsc refined-jiffies jiffies\n" - ).strip(), - "ok", - ) - for current, available in ( - ("tsc-early\n", "tsc-early \n"), - ("tsc\n", "tsc kvm-clock \n"), - ("refined-jiffies\n", "refined-jiffies \n"), - ): - with self.subTest(current=current, available=available): - self.assertIn( - "code=G_CONFORMANCE_C6", - self.run_test("clocksource", current, available), - ) - - def test_violation_event_format_escaping_and_limit(self): - line = self.run_test( - "event", - "G_UNCHECKED_MSR", - "watcher", - "runtime", - "3", - "123456789", - 'unchecked "MSR"\\ access\x01', - ) - self.assertEqual( - line, - "NVX-TIME-ABI-VIOLATION: v=1 code=G_UNCHECKED_MSR source=watcher " - "phase=runtime generation=3 boottime_ns=123456789 " - 'detail="unchecked \\"MSR\\"\\\\ access\\x01"\n', - ) - long_line = self.run_test( - "event", "G_HUNG_TASK", "watcher", "runtime", "0", "1", "\x02" * 400 - ) - self.assertLessEqual(len(long_line), 512) - self.assertTrue(long_line.endswith('\\x02"\n')) - - def test_state_file_round_trip(self): - # The fixture lists every key, in the order that nvx-time writes them. - text = ( - "version=1\ngeneration=2\nboot_status=ok\ncapture_status=ok\n" - "restore_status=pending\nboot_cpus=8\ncapture_cpus=8\nrestore_cpus=0\n" - "boot_elapsed_us=2400\ncapture_elapsed_us=310\nrestore_elapsed_us=1830\n" - "boot_cpu_us=1900\ncapture_cpu_us=290\nrestore_cpu_us=640\n" - "capture_generation=1\nrestore_generation=2\ntsc_hz=2194843000\n" - "lapic_hz=200000000\ndiscontinuities=3\nlast_discontinuity=step\n" - "last_step_ns=-200000000\nlast_step_realtime_ns=1790841600000000000\n" - "last_downtime_ns=31000000000\nlast_downtime_source=utc\n" - "synchronized=1\noffset_ns=-42\nuncertainty_ns=1800\n" - "frequency_ppb=-1200\nsamples=17\nrejected_samples=1\n" - "last_sample_error=none\nviolations=0\n" - ) - self.assertEqual(self.run_test("state", self.fixture("state.txt", text)), text) - # Report-only mode adds the failure counts, reserved for it, after the - # generations, and a failed check's status is fail. - report = text.replace("restore_status=pending", "restore_status=fail").replace( - "restore_generation=2\n", - "restore_generation=2\nboot_failures=0\ncapture_failures=0\n" - "restore_failures=3\n", - ) - self.assertEqual( - self.run_test("state", self.fixture("state.txt", report)), report - ) - # A fresh state is the pending boot check; the capture and restore keys - # are absent until those checks first run. - self.assertTrue( - self.run_test("state", self.fixture("state.txt", "version=1\n")).startswith( - "version=1\ngeneration=0\nboot_status=pending\nboot_cpus=0\n" - "boot_elapsed_us=0\nboot_cpu_us=0\ntsc_hz=0\nlapic_hz=0\n" - ) - ) - self.assertEqual( - self.run_test( - "state", self.fixture("state.txt", "unknown=x\nsamples=bad\n") - ).strip(), - "error", - ) - - def status(self, text: str, mode: str = "enforcing") -> list[str]: - return self.run_test( - "status", self.fixture("state.txt", text), mode - ).splitlines() - - def test_status_prints_every_recorded_check_and_the_runtime_line(self): - state = ( - "version=1\ngeneration=2\nboot_status=ok\ncapture_status=ok\n" - "restore_status=ok\nboot_cpus=8\ncapture_cpus=8\nrestore_cpus=4\n" - "boot_elapsed_us=2400\ncapture_elapsed_us=310\nrestore_elapsed_us=1830\n" - "boot_cpu_us=1900\ncapture_cpu_us=290\nrestore_cpu_us=640\n" - "capture_generation=1\nrestore_generation=2\ntsc_hz=2194843000\n" - "lapic_hz=200000000\ndiscontinuities=2\nsynchronized=1\n" - "offset_ns=-42\nuncertainty_ns=1800\nrejected_samples=2\n" - "last_sample_error=G_SAMPLE_UNCERTAIN\n" - ) - rates = "tsc_hz=2194843000 lapic_hz=200000000" - # One line per recorded phase, each with the generation its check ran - # in and its wall and CPU times, then the runtime line; the hook adds - # whether status exits 0. - self.assertEqual( - self.status(state), - [ - f"NVX-TIME-ABI: v=1 phase=boot status=ok cpus=8 {rates} " - "generation=0 elapsed_us=2400 cpu_us=1900", - f"NVX-TIME-ABI: v=1 phase=capture status=ok cpus=8 {rates} " - "generation=1 elapsed_us=310 cpu_us=290", - f"NVX-TIME-ABI: v=1 phase=restore status=ok cpus=4 {rates} " - "generation=2 elapsed_us=1830 cpu_us=640", - "NVX-TIME-ABI: v=1 phase=runtime status=synchronized generation=2 " - "discontinuities=2 offset_ns=-42 uncertainty_ns=1800 " - "rejected_samples=2 last_sample_error=G_SAMPLE_UNCERTAIN", - "ok=1", - ], - ) - # A check still pending after the wait has neither elapsed_us nor - # cpu_us and fails the exit status. A cold boot has a boot line only, - # and a state without a boot record counts as a pending boot check. - pending = state.replace("restore_status=ok", "restore_status=pending") - self.assertEqual( - self.status(pending)[2::2], - [ - f"NVX-TIME-ABI: v=1 phase=restore status=pending cpus=4 {rates} " - "generation=2", - "ok=0", - ], - ) - boot_pending = ( - "NVX-TIME-ABI: v=1 phase=boot status=pending cpus=0 tsc_hz=0 " - "lapic_hz=0 generation=0" - ) - for text in ("version=1\ngeneration=0\nboot_status=pending\n", "version=1\n"): - lines = self.status(text) - self.assertEqual(lines[0], boot_pending, text) - self.assertTrue(lines[1].startswith("NVX-TIME-ABI: v=1 phase=runtime ")) - self.assertEqual(lines[2:], ["ok=0"], text) - # Report-only mode marks every line and appends each check's failure - # count from the state file, where a failed check's status is fail. - report = self.status( - state.replace("restore_status=ok", "restore_status=fail").replace( - "restore_generation=2\n", - "restore_generation=2\nboot_failures=0\nrestore_failures=3\n", - ), - "report-only", - ) - self.assertEqual( - report[0], - f"NVX-TIME-REPORT: v=1 phase=boot status=ok cpus=8 {rates} " - "generation=0 elapsed_us=2400 cpu_us=1900 failures=0", - ) - self.assertEqual( - report[2], - f"NVX-TIME-REPORT: v=1 phase=restore status=fail cpus=4 {rates} " - "generation=2 elapsed_us=1830 cpu_us=640 failures=3", - ) - self.assertTrue(report[3].startswith("NVX-TIME-REPORT: v=1 phase=runtime ")) - self.assertEqual(report[4], "ok=0") - - def test_deferred_work_starts_150_ms_late_and_is_promoted_100_ms_later(self): - # Guest repair step 7 and the asynchronous boot checks start 150 ms - # after the acknowledgement or the boot step and leave SCHED_IDLE - # 100 ms after they start. - self.assertEqual( - self.run_test("deferral"), "deferred_start_ms=150 idle_bound_ms=100\n" - ) - - def test_restore_cpu_time_is_the_deferred_work_with_the_daemons_fork(self): - # The worker's repair step 7 CPU time adds the daemon's preparation - # before the fork, the fork's cost to the daemon, which arrives on a - # pipe after the worker's copy of the timing was taken, and its own. - fields = dict( - field.split("=") - for field in self.run_test("deferred-restore-cpu", "20", "30", "10").split() - ) - self.assertGreaterEqual(int(fields["prior_ms"]), 20) - self.assertLess(int(fields["prior_ms"]), 30) - self.assertGreaterEqual(int(fields["total_ms"]), 60) - self.assertLess(int(fields["total_ms"]), 90) - - def restore_record(self, text: str) -> str: - return self.run_test( - "restore-record", self.fixture("restore.txt", text) - ).strip() - - def test_restore_record_names_the_cpus_that_restore_finish_onlined(self): - # restore-finish records the CPUs that activation brought online. - self.assertEqual( - self.restore_record("generation=3\nnew_cpus=4-6,9\n"), - "generation=3 new_cpus=4,5,6,9 count=4", - ) - self.assertEqual( - self.restore_record("generation=3\nnew_cpus=none\n"), - "generation=3 new_cpus=none count=0", - ) - self.assertEqual( - self.restore_record("generation=3\n"), "generation=3 new_cpus=none count=0" - ) - for text in ( - "", - "new_cpus=4\ngeneration=3\n", - "generation=3\nnew_cpus=7-4\n", - "generation=3\nnew_cpus=64\n", - ): - self.assertEqual(self.restore_record(text), "error", text) - - def test_parallel_checks_merge_every_failure(self): - if sys.platform != "linux": - self.skipTest("CPU affinity is Linux-only") - allowed: list[int] = sorted(os.sched_getaffinity(0)) - # Three side jobs fail 1, 2, and 3 times and the foreground once, - # whether one CPU runs them in the caller or each has a thread. - for size in sorted({1, min(2, len(allowed)), min(4, len(allowed))}): - output = self.run_test("parallel", ",".join(map(str, allowed[:size]))) - self.assertTrue(output.endswith("failures=7\n"), output) - self.assertEqual(output.count("code=G_CONFORMANCE_C4"), 6, output) - self.assertEqual(output.count("code=G_CONFORMANCE_C9"), 1, output) - # A CPU the checks cannot run on fails C1 in its job, and each such - # job's failure counts. - unusable = [cpu for cpu in range(64) if cpu not in allowed] - if not unusable: - self.skipTest("every CPU is usable") - output = self.run_test("parallel", f"{allowed[0]},{unusable[0]}") - self.assertTrue(output.endswith("failures=8\n"), output) - self.assertIn(f"cannot run on cpu {unusable[0]}", output) - if len(unusable) < 2: - self.skipTest("only one CPU is unusable") - cpus = ",".join(map(str, [allowed[0], *unusable[:2]])) - output = self.run_test("parallel", cpus) - self.assertTrue(output.endswith("failures=9\n"), output) - for cpu in unusable[:2]: - self.assertIn(f"cannot run on cpu {cpu}: ", output) - - def test_idle_work_continues_at_normal_priority_after_the_bound(self): - # The asynchronous checks leave SCHED_IDLE (5) for SCHED_OTHER (0) - # 100 ms after they start, every thread included; checks that end - # first stop the promoter without waiting for the bound. - policy = 0 if can_leave_idle_priority() else 5 - promoted, stopped = self.run_test("promote").splitlines() - self.assertEqual(promoted, f"promoted caller={policy} thread={policy}") - fields = dict(field.split("=") for field in stopped.split()[1:]) - self.assertEqual(fields["caller"], "5") - self.assertLess(int(fields["stop_ms"]), 1000) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_managed_agent.py b/scripts/test_managed_agent.py deleted file mode 100644 index 794ff2ffd..000000000 --- a/scripts/test_managed_agent.py +++ /dev/null @@ -1,795 +0,0 @@ -#!/usr/bin/env python3 -"""Compile the existing guest decoder on Linux and test real wire inputs.""" - -import shutil -import struct -import subprocess -import sys -import tempfile -import unittest -from collections.abc import Sequence -from pathlib import Path - - -@unittest.skipUnless(sys.platform.startswith("linux"), "guest decoder requires Linux") -class ManagedAgentDecoderTests(unittest.TestCase): - @classmethod - def setUpClass(cls): - compiler = shutil.which("cc") - if compiler is None: - raise unittest.SkipTest("guest decoder requires the guest build C compiler") - cls.temporary = tempfile.TemporaryDirectory(prefix="nvx-agent-decoder-") - cls.addClassCleanup(cls.temporary.cleanup) - cls.root = Path(cls.temporary.name) - cls.executable = cls.root / "decoder" - cls.fault_executable = cls.root / "fault-injection" - harness = cls.root / "decoder.c" - harness.write_text( - """ -#define main nvx_agent_main -#include "nvx-managed-agent.c" -#undef main -int main(int argc, char **argv) { - unsigned char bytes[65536]; - uint32_t timeout_ms = 0; - char **arguments = NULL; - struct exec_config config; - memset(&config, 0xa5, sizeof(config)); - if (argc > 1 && strcmp(argv[1], "--exec-config-fd") == 0) - return launch_workload(argc, argv); - int launch = argc == 3 && strcmp(argv[1], "--launch") == 0; - if (argc != 2 && !launch) return 2; - FILE *file = fopen(argv[launch ? 2 : 1], "rb"); - if (file == NULL) return 2; - size_t length = fread(bytes, 1, sizeof(bytes), file); - if (ferror(file)) return 2; - fclose(file); - int result = decode_exec_payload( - bytes, (uint32_t)length, &timeout_ms, &arguments, &config); - if (result == 0 && launch) { - int fd = create_exec_config_fd(&config, 0); - if (fd < 0) return 2; - int seals = F_SEAL_WRITE | F_SEAL_GROW | F_SEAL_SHRINK | F_SEAL_SEAL; - if ((fcntl(fd, F_GET_SEALS) & seals) != seals) return 2; - char fd_text[32]; - snprintf(fd_text, sizeof(fd_text), "%d", fd); - char *helper_arguments[MAX_ARGUMENTS + 5] = { - argv[0], "--exec-config-fd", fd_text, "--" - }; - int count = 0; - while (arguments[count] != NULL) { - helper_arguments[count + 4] = arguments[count]; - count++; - } - helper_arguments[count + 4] = NULL; - return launch_workload(count + 4, helper_arguments); - } - if (result == 0) { - free_arguments(arguments); - free_exec_config(&config); - } - return result == 0 ? 0 : 1; -} -""", - encoding="utf-8", - ) - guest = Path(__file__).resolve().parent.parent / "guest" / "common" - subprocess.run( - [ - compiler, - "-D_GNU_SOURCE", - "-std=c11", - "-I", - str(guest), - str(harness), - "-o", - str(cls.executable), - ], - check=True, - capture_output=True, - timeout=30, - ) - fault_harness = cls.root / "fault-injection.c" - fault_cgroup_root = cls.root / "cgroup" - fault_exec_cgroup = fault_cgroup_root / "nvx-exec" - fault_exec_cgroup.mkdir(parents=True) - (fault_exec_cgroup / "cgroup.procs").write_text("", encoding="utf-8") - fault_harness.write_text( - """ -#define main nvx_agent_main -#include "nvx-managed-agent.c" -#undef main - -static int barrier_reader = -1; - -ssize_t __real_write(int, const void *, size_t); -ssize_t __wrap_write(int fd, const void *buffer, size_t length) { - if (barrier_reader >= 0 && length == 6 && - memcmp(buffer, "start\\n", length) == 0) { - close(barrier_reader); - barrier_reader = -1; - } - return __real_write(fd, buffer, length); -} - -int __real_setenv(const char *, const char *, int); -int __wrap_setenv(const char *name, const char *value, int overwrite) { - const char *failure = getenv("NVX_TEST_FAIL_SETENV"); - if (failure != NULL && strcmp(name, failure) == 0) { - errno = ENOMEM; - return -1; - } - return __real_setenv(name, value, overwrite); -} - -int __wrap_execv(const char *path, char *const argv[]) { - const char *marker = getenv("NVX_TEST_EXEC_MARKER"); - (void)path; - (void)argv; - if (marker != NULL) { - int fd = open(marker, O_WRONLY | O_CREAT | O_TRUNC, 0600); - if (fd >= 0) close(fd); - } - errno = ENOENT; - return -1; -} - -int __wrap_execvp(const char *file, char *const argv[]) { - return __wrap_execv(file, argv); -} - -int main(int argc, char **argv) { - if (argc == 3 && strcmp(argv[1], "--release-closing-barrier") == 0) { - barrier_reader = open(argv[2], O_RDONLY | O_CLOEXEC | O_NONBLOCK); - if (barrier_reader < 0) return 2; - errno = 0; - int result = release_container_barrier(argv[2], -1); - int status = errno; - if (barrier_reader >= 0) close(barrier_reader); - return result == -1 && status == EPIPE ? 0 : 1; - } - if (argc == 3 && strcmp(argv[1], "--release-orphaned-barrier") == 0) { - pid_t child = fork(); - if (child < 0) return 2; - if (child == 0) _exit(125); - int result = release_container_barrier(argv[2], child); - int status = 0; - if (waitpid(child, &status, 0) != child) return 2; - return result == 1 && WIFEXITED(status) ? 0 : 1; - } - if (argc == 3 && strcmp(argv[1], "--release-live-barrier") == 0) { - pid_t child = fork(); - if (child < 0) return 2; - if (child == 0) { - char message[6]; - int fd = open(argv[2], O_RDONLY | O_CLOEXEC); - if (fd < 0 || read(fd, message, sizeof(message)) != sizeof(message)) - _exit(125); - close(fd); - _exit(memcmp(message, "start\\n", sizeof(message)) == 0 ? 0 : 125); - } - int result = release_container_barrier(argv[2], child); - int status = 0; - if (waitpid(child, &status, 0) != child) return 2; - return result == 0 && WIFEXITED(status) && WEXITSTATUS(status) == 0 ? 0 : 1; - } - if (argc != 3) return 2; - struct agent_config config = { - .rootfs = "/rootfs", - .hostname = "sandbox", - .uid = "1000", - .gid = "1000", - .user = "workload", - .home = "/home/workload", - .direct = 0, - }; - char *workload[] = {"/bin/true", NULL}; - if (strcmp(argv[1], "--sandbox") == 0) - exec_sandbox(&config, "/unused", argv[2], workload); - if (strcmp(argv[1], "--direct") == 0) - exec_direct(&config, argv[2], workload); - return 2; -} -""", - encoding="utf-8", - ) - subprocess.run( - [ - compiler, - f'-DCGROUP_ROOT="{fault_cgroup_root}"', - "-D_GNU_SOURCE", - "-std=c11", - "-I", - str(guest), - str(fault_harness), - "-Wl,--wrap=write", - "-Wl,--wrap=setenv", - "-Wl,--wrap=execv", - "-Wl,--wrap=execvp", - "-o", - str(cls.fault_executable), - ], - check=True, - capture_output=True, - timeout=30, - ) - - def decode( - self, - environment: tuple[bytes, ...], - *, - timeout: int = 0, - inherit: bool = False, - ) -> int: - argument = b"/bin/true" - flags = 2 | (4 if inherit else 0) - payload = struct.pack(" subprocess.CompletedProcess[bytes]: - entries = () if environment is None else environment - flags = 1 | (2 if environment is not None else 0) | (4 if inherit else 0) - payload = struct.pack( - " length) return 2; - if (handle_app_request(&session, &config, &mappings, &request) < 0) return 4; - } - printf("%u %u %d\\n", mappings.expected, mappings.applied, mappings.failed); - return 0; -} -""", - encoding="utf-8", - ) - guest = Path(__file__).resolve().parent.parent / "guest" / "common" - subprocess.run( - [ - compiler, - f'-DHOSTFS_DIR="{cls.root / "hostfs"}"', - "-D_GNU_SOURCE", - "-std=c11", - "-I", - str(guest), - str(harness), - "-Wl,--wrap=mount", - "-o", - str(cls.executable), - ], - check=True, - capture_output=True, - timeout=30, - ) - - def setUp(self): - self.case = Path(tempfile.mkdtemp(prefix="case-", dir=self.root)) - export = self.root / "hostfs" / "root" - shutil.rmtree(export, ignore_errors=True) - (export / "0" / "src").mkdir(parents=True) - (export / "0" / "notes.txt").write_text("notes", encoding="utf-8") - (export / "1").mkdir() - self.log = self.case / "mounts.log" - - @staticmethod - def maps( - first: int, - entries: Sequence[tuple[str | bytes, str | bytes, int]], - count: int | None = None, - ) -> bytes: - payload = struct.pack(" tuple[subprocess.CompletedProcess[bytes], list[tuple[int, int, int, bytes]]]: - source = self.case / "requests.bin" - source.write_bytes( - b"".join( - struct.pack(" list[tuple[str, str, int]]: - if not self.log.exists(): - return [] - return [ - (source, target, int(flags)) - for source, target, flags in ( - line.split("|") for line in self.log.read_text().splitlines() - ) - ] - - def test_mappings_mount_in_order_and_unblock_workloads(self): - export = self.root / "hostfs" / "root" - src = str(self.case / "guest" / "src") - notes = str(self.case / "guest" / "src" / "notes.txt") - out = str(self.case / "guest" / "out") - result, answers = self.run_agent( - # A stale token of an older host is ignored. - "quiet nvx_map=0,/stale,rw nvx_maps=3", - [ - (APP_EXEC, b""), - ( - APP_MAPS, - self.maps(0, [("0/src", src, 1), ("0/notes.txt", notes, 0)]), - ), - (APP_EXEC, b""), - (APP_MAPS, self.maps(2, [("1", out, 0)])), - # An empty request is malformed, so it fails only once the mappings are complete. - (APP_EXEC, b""), - ], - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout, b"3 3 0\n") - self.assertEqual( - [(kind, status, payload) for kind, _, status, payload in answers], - [ - (APP_ERROR, 11, b"mappings-incomplete"), - (APP_READY, 0, b""), - (APP_ERROR, 11, b"mappings-incomplete"), - (APP_READY, 0, b""), - (APP_ERROR, 22, b"invalid-request"), - ], - ) - self.assertEqual( - self.mounts(), - [ - (str(export / "0" / "src"), src, MS_BIND), - ("-", src, REMOUNT | MS_RDONLY), - (str(export / "0" / "notes.txt"), notes, MS_BIND), - ("-", notes, REMOUNT), - (str(export / "1"), out, MS_BIND), - ("-", out, REMOUNT), - ], - ) - self.assertTrue(Path(src).is_dir()) - self.assertTrue(Path(notes).is_file()) - self.assertTrue(Path(out).is_dir()) - - def test_entries_beyond_the_announcement_or_out_of_order_are_refused(self): - target = str(self.case / "guest" / "src") - entry = [("0/src", target, 0)] - result, answers = self.run_agent( - "nvx_maps=1", - [ - (APP_MAPS, self.maps(1, entry)), - (APP_MAPS, self.maps(0, entry * 2)), - (APP_MAPS, self.maps(0, entry)), - (APP_MAPS, self.maps(1, entry)), - ], - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout, b"1 1 0\n") - self.assertEqual( - [(kind, status) for kind, _, status, _ in answers], - [(APP_ERROR, 22), (APP_ERROR, 22), (APP_READY, 0), (APP_ERROR, 22)], - ) - self.assertEqual(len(self.mounts()), 2) - - def test_malformed_tables_mount_nothing(self): - target = str(self.case / "guest" / "x") - good = ("0/src", target, 0) - malformed = [ - self.maps(0, [good, ("0/../1", target, 0)]), - self.maps(0, [good, ("/0/src", target, 0)]), - self.maps(0, [good, ("0/src", "relative", 0)]), - self.maps(0, [good, (".", target, 0)]), - self.maps(0, [good, ("0//src", target, 0)]), - self.maps(0, [good, (b"0/s\0rc", target, 0)]), - self.maps(0, [good, ("0/src", target, 2)]), - self.maps(0, [good, ("", target, 0)]), - self.maps(0, [good]) + b"\0", - self.maps(0, [good], count=2), - self.maps(0, [], count=0), - b"\0\0\0", - ] - result, answers = self.run_agent( - "nvx_maps=2", [(APP_MAPS, payload) for payload in malformed] - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout, b"2 0 0\n") - self.assertEqual( - [(kind, status, payload) for kind, _, status, payload in answers], - [(APP_ERROR, 22, b"invalid-request")] * len(malformed), - ) - self.assertEqual(self.mounts(), []) - - def test_without_an_announcement_tables_are_refused(self): - target = str(self.case / "guest" / "src") - result, answers = self.run_agent( - "nvx_map=0,/w,rw", - [(APP_MAPS, self.maps(0, [("0/src", target, 0)])), (APP_EXEC, b"")], - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout, b"0 0 0\n") - self.assertEqual( - [(kind, status, payload) for kind, _, status, payload in answers], - [(APP_ERROR, 22, b"invalid-request"), (APP_ERROR, 22, b"invalid-request")], - ) - self.assertEqual(self.mounts(), []) - - def test_a_failed_mount_keeps_refusing_workloads(self): - target = str(self.case / "guest" / "missing") - result, answers = self.run_agent( - "nvx_maps=2", - [ - (APP_MAPS, self.maps(0, [("0/missing", target, 0)])), - (APP_EXEC, b""), - (APP_MAPS, self.maps(0, [("0/src", target, 0)])), - ], - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout, b"2 0 1\n") - self.assertEqual( - [(kind, status, payload) for kind, _, status, payload in answers], - [ - (APP_ERROR, 2, b"mapping-failed"), - (APP_ERROR, 11, b"mappings-incomplete"), - (APP_ERROR, 22, b"invalid-request"), - ], - ) - - def test_mapping_counts_are_validated(self): - for command_line in [ - "nvx_maps=0", - "nvx_maps=", - "nvx_maps=4097", - "nvx_maps=99999999999", - "nvx_maps=1x", - "nvx_maps=1 nvx_maps=1", - ]: - result, _ = self.run_agent(command_line, []) - self.assertEqual(result.returncode, 3, command_line) - result, _ = self.run_agent("nvx_maps=4096", []) - self.assertEqual(result.stdout, b"4096 0 0\n") - - def test_features_announce_the_mapping_table_but_not_retired_bit_one(self): - _, answers = self.run_agent("", [(APP_FEATURES, b"")]) - self.assertEqual(len(answers), 1) - kind, _, status, payload = answers[0] - self.assertEqual((kind, status), (APP_READY, 0)) - (features,) = struct.unpack(" set[str]: match.group(1): { path.replace("**", "example").replace("*", "example") for path in re.findall(r"'([^']+)'", match.group(2)) + if not path.startswith("!") } for match in re.finditer( r"^\s+([A-Z_]+)_INPUT_HASH: \$\{\{ hashFiles\((.+)\) \}\}$", @@ -5316,6 +5328,7 @@ def test_ci_takes_rust_toolchain_from_repository_pin(self): setup = (workflows / "copilot-setup-steps.yml").read_text(encoding="utf-8") self.assertIn(" - rust-toolchain.toml\n", setup) + self.assertIn(" - nvx-rs/**\n", setup) self.assertIn( "rust-toolchain.toml", _workflow_step( @@ -5324,6 +5337,7 @@ def test_ci_takes_rust_toolchain_from_repository_pin(self): ) rust = _workflow_step(setup, "copilot-setup-steps", "Install Rust toolchains") self.assertIn('rustup default "${RUST_TOOLCHAIN}"', rust) + self.assertIn("x86_64-unknown-linux-musl", rust) self.assertNotIn("stable", rust) # A binary built by another compiler must not satisfy the cache. @@ -6430,6 +6444,20 @@ def test_guest_descriptors_and_source_manifest_share_build_pins(self): OpenVMMBuildConstants.CONTROL_CONTRACT_REVISION, manifest["openvmm"]["control_contract_revision"], ) + self.assertEqual( + manifest["guest_rust"], + { + "toolchain": GuestRustBuildConstants.TOOLCHAIN, + "target": GuestRustBuildConstants.TARGET, + }, + ) + for guest in ("alpine", "ubuntu", "azurelinux"): + with self.subTest(guest=guest): + self.assertTrue( + set(GuestRustBuildConstants.SOURCE_PATHS).issubset( + manifest[guest]["guest_sources"] + ) + ) def test_cache_defaults_are_resolved_for_each_configuration(self): with tempfile.TemporaryDirectory() as temporary: @@ -6469,13 +6497,13 @@ def test_initramfs_provenance_tracks_constants_source(self): records = cast(list[dict[str, str]], original["source_files"]) self.assertEqual( [record for record in records if record["path"] == relative], - [{"path": relative, "sha256": common.sha256_file(source)}], + [{"path": relative, "sha256": common.normalized_sha256_file(source)}], ) def changed_sha256(path: Path) -> str: - return "f" * 64 if path == source else common.sha256_file(path) + return "f" * 64 if path == source else common.normalized_sha256_file(path) - with patch.object(build, "sha256_file", side_effect=changed_sha256): + with patch.object(build, "normalized_sha256_file", side_effect=changed_sha256): changed = build.initramfs_provenance_inputs() self.assertNotEqual(original, changed) self.assertEqual(original["alpine"], changed["alpine"]) @@ -6486,6 +6514,11 @@ def test_constants_are_in_docker_and_release_source_inputs(self): ) self.assertIn("COPY scripts/ /repo/scripts/", dockerfile) self.assertIn("scripts", ReleaseBuildConstants.PROJECT_SOURCE_PATHS) + self.assertIn("nvx-rs", ReleaseBuildConstants.PROJECT_SOURCE_PATHS) + self.assertIn( + Path("nvx-rs") / "target", + ReleaseBuildConstants.PROJECT_SOURCE_EXCLUDES, + ) self.assertTrue( ( BuildConstants.REPO_ROOT @@ -6508,6 +6541,69 @@ def test_project_source_paths_exist_and_are_tracked(self): ) self.assertEqual(tracked.returncode, 0, tracked.stderr) + def test_no_c_or_assembler_sources_remain_outside_openvmm(self): + tracked = subprocess.run( + ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], + cwd=BuildConstants.REPO_ROOT, + capture_output=True, + check=True, + ).stdout.split(b"\0") + forbidden = sorted( + path.decode("utf-8") + for path in tracked + if path + and (BuildConstants.REPO_ROOT / path.decode("utf-8")).is_file() + and not path.startswith(b"openvmm/") + and PurePosixPath(path.decode("utf-8")).suffix.lower() in {".c", ".h", ".s"} + ) + self.assertEqual(forbidden, []) + + def test_guest_binary_third_party_notices_cover_the_rust_runtime(self): + notices = (BuildConstants.REPO_ROOT / "THIRD_PARTY_NOTICES.md").read_text( + encoding="utf-8" + ) + musl = next( + line for line in notices.splitlines() if line.startswith("| musl libc |") + ) + self.assertEqual( + set(re.findall(r"`/sbin/(nvx-[a-z-]+)`", musl)), + set(GuestRustBuildConstants.BINARIES), + ) + for component in ( + "Rust core and standard libraries", + "Rust `compiler_builtins`", + "Rust `libc` crate", + "Rust `nix` crate 0.31.3", + "Rust `getrandom` crate 0.4.3", + "Rust `bitflags` crate 2.13.2", + "Rust `cfg-if` crate 1.0.5", + "Rust `memoffset` crate 0.9.1", + "Apache-2.0 WITH LLVM-exception", + ): + with self.subTest(component=component): + self.assertIn(component, notices) + + def test_project_source_archive_excludes_the_nvx_rs_target(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + crate = root / "nvx-rs" + (crate / "src").mkdir(parents=True) + (crate / "src" / "lib.rs").write_text("source\n", encoding="utf-8") + (crate / "target" / "debug").mkdir(parents=True) + (crate / "target" / "debug" / "nvx-rs").write_text( + "build output\n", encoding="utf-8" + ) + output = root / "source.tar.gz" + archive.create_reproducible_tar_gz( + output, + [(crate, "nvx-project-source/nvx-rs")], + ) + with tarfile.open(output, "r:gz") as source_archive: + names = source_archive.getnames() + + self.assertIn("nvx-project-source/nvx-rs/src/lib.rs", names) + self.assertFalse(any("/target" in name for name in names)) + def test_constants_consumers_import_in_either_order(self): modules = ( "nvx_tools.build_constants", @@ -7802,6 +7898,57 @@ def test_manifest_tracks_every_kernel_patch(self): "control_contract_revision": "nvx-microvm-v2-control-v2", }, ) + self.assertEqual( + manifest["guest_rust"], + { + "toolchain": GuestRustBuildConstants.TOOLCHAIN, + "target": GuestRustBuildConstants.TARGET, + }, + ) + + def test_guest_artifact_inputs_cover_every_rust_binary_source(self): + rust_sources = set(guest_binaries.guest_binary_source_files()) + inputs = { + "alpine provenance": set(build._initramfs_source_files()), + "alpine customization": set( + build._guest_customization_files(guests.ALPINE_GUEST) + ), + "ubuntu customization": set(ubuntu.customization_files()), + "azure linux": set(azurelinux.input_files()), + } + for name, files in inputs.items(): + with self.subTest(inputs=name): + self.assertTrue(rust_sources.issubset(files)) + self.assertIn( + BuildConstants.REPO_ROOT / "scripts" / "nvx_tools" / "guest_binaries.py", + inputs["alpine provenance"], + ) + + def test_guest_artifact_identities_normalize_source_line_endings(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "nvx-rs" / "src" / "lib.rs" + source.parent.mkdir(parents=True) + source.write_bytes(b"pub fn fixture() {}\n") + with ( + patch.object(BuildConstants, "REPO_ROOT", root), + patch.object(build, "_initramfs_source_files", return_value=(source,)), + patch.object(ubuntu, "load_package_lock", return_value=()), + patch.object(azurelinux, "input_files", return_value=(source,)), + ): + lf = { + "alpine": build.initramfs_provenance_inputs(), + "ubuntu": ubuntu.converter_input_sha256((source,)), + "azurelinux": azurelinux.input_sha256(), + } + source.write_bytes(b"pub fn fixture() {}\r\n") + crlf = { + "alpine": build.initramfs_provenance_inputs(), + "ubuntu": ubuntu.converter_input_sha256((source,)), + "azurelinux": azurelinux.input_sha256(), + } + + self.assertEqual(crlf, lf) def test_guest_descriptors_preserve_alpine_and_select_guest_outputs(self): alpine = guests.guest_descriptor("alpine") @@ -8635,6 +8782,29 @@ def test_ci_guest_kernel_keys_include_the_patch_identity(self): action, ) + def test_ci_guest_image_keys_include_the_rust_source_identity(self): + # hashFiles hashes contents only, so a module move that kept every + # file's bytes reused images whose provenance named the old paths. + action = ( + BuildConstants.REPO_ROOT + / ".github" + / "actions" + / "build-guest-artifacts" + / "action.yml" + ).read_text(encoding="utf-8") + self.assertIn( + "rust_source_identity=$(python3 scripts/nvx.py guest-binary-source-identity)", + action, + ) + for key in ( + "alpine=alpine-image-v2-${ALPINE_VERSION}-${ALPINE_INPUT_HASH}", + "ubuntu=ubuntu-initramfs-v2-${UBUNTU_VERSION}-${UBUNTU_INPUT_HASH}", + "ubuntu-distro=ubuntu-distro-v2-${UBUNTU_VERSION}-${UBUNTU_INPUT_HASH}", + "azurelinux=azurelinux-initramfs-v2-${AZURELINUX_VERSION}-${AZURELINUX_INPUT_HASH}", + ): + with self.subTest(key=key.split("=", 1)[0]): + self.assertIn(f'echo "{key}-${{rust_source_identity}}"', action) + def test_ci_guest_cache_keys_include_build_configuration(self): action = ( BuildConstants.REPO_ROOT @@ -8788,10 +8958,28 @@ def test_ci_guest_cache_keys_include_shared_build_modules(self): / "build-guest-artifacts" / "action.yml" ).read_text(encoding="utf-8") - for variable in ("ALPINE_INPUT_HASH", "UBUNTU_INPUT_HASH"): + for variable in ( + "ALPINE_INPUT_HASH", + "UBUNTU_INPUT_HASH", + "AZURELINUX_INPUT_HASH", + ): assignment = next(line for line in action.splitlines() if variable in line) self.assertIn("'scripts/nvx_tools/common.py'", assignment) self.assertIn("'scripts/nvx_tools/guests.py'", assignment) + self.assertIn("'scripts/nvx_tools/elf.py'", assignment) + self.assertIn("'scripts/nvx_tools/guest_binaries.py'", assignment) + self.assertIn("'nvx-rs/**'", assignment) + self.assertIn("'!nvx-rs/target/**'", assignment) + self.assertIn("'rust-toolchain.toml'", assignment) + for prefix in ( + "alpine-image", + "ubuntu-initramfs", + "ubuntu-distro", + "azurelinux-initramfs", + ): + with self.subTest(cache=prefix): + self.assertIn(f"{prefix}-v2-", action) + self.assertNotIn(f"{prefix}-v1-", action) def test_quality_checks_include_shared_identity_probe(self): action = ( @@ -8816,13 +9004,132 @@ def test_docker_guest_builder_pins_erofs_toolchain(self): self.assertIn("ARG EROFS_UTILS_VERSION=1.5-1", dockerfile) self.assertIn("erofs-utils=${EROFS_UTILS_VERSION}", dockerfile) + def test_docker_builds_one_shared_rust_guest_binary_set(self): + dockerfile = (BuildConstants.REPO_ROOT / "docker" / "Dockerfile").read_text( + encoding="utf-8" + ) + self.assertIn( + f"ARG RUST_BUILDER_IMAGE={DockerBuildConstants.RUST_BUILDER_IMAGE}", + dockerfile, + ) + builder = dockerfile.split("FROM ${RUST_BUILDER_IMAGE} AS guest-binaries", 1)[ + 1 + ].split("\nFROM ", 1)[0] + self.assertIn("COPY rust-toolchain.toml /repo/", builder) + self.assertIn("COPY nvx-rs/ /repo/nvx-rs/", builder) + self.assertIn( + 'rustup target add --toolchain "$toolchain" x86_64-unknown-linux-musl', + builder, + ) + self.assertIn( + "python3 scripts/nvx.py build-guest-binaries --output /out/guest-binaries", + builder, + ) + self.assertEqual(dockerfile.count("build-guest-binaries"), 1) + + base = dockerfile.split("FROM ${DEBIAN_IMAGE} AS base", 1)[1].split( + "\nFROM ", 1 + )[0] + self.assertIn(" build-essential \\\n", base) + self.assertNotIn("musl-tools", base) + self.assertNotIn("nvx-rs", base) + self.assertNotIn("rust-toolchain.toml", base) + self.assertNotIn("guest-binaries", base) + + guest_base = dockerfile.split("FROM base AS guest-base", 1)[1].split( + "\nFROM ", 1 + )[0] + self.assertIn("COPY nvx-rs/ /repo/nvx-rs/", guest_base) + self.assertIn("COPY rust-toolchain.toml /repo/", guest_base) + self.assertIn( + "COPY --from=guest-binaries /out/guest-binaries/ " + "/repo/build/guest-binaries/", + guest_base, + ) + self.assertIn( + "ENV NVX_GUEST_BINARIES=/repo/build/guest-binaries", + guest_base, + ) + + determinism = dockerfile.split("FROM guest-base AS ubuntu-determinism", 1)[ + 1 + ].split("\nFROM ", 1)[0] + self.assertIn( + "COPY --from=guest-binaries /usr/local/cargo/ /usr/local/cargo/", + determinism, + ) + self.assertIn( + "COPY --from=guest-binaries /usr/local/rustup/ /usr/local/rustup/", + determinism, + ) + self.assertIn( + "RUN NVX_GUEST_BINARIES= python3 scripts/nvx.py verify-guest-determinism", + determinism, + ) + + azure = dockerfile.split("FROM guest-base AS azurelinux-initramfs", 1)[1].split( + "\nFROM ", 1 + )[0] + self.assertIn( + "source = Path('/repo/build/guest-binaries')", + azure, + ) + self.assertIn("inspect_guest_binaries", azure) + self.assertIn("for name in GuestRustBuildConstants.BINARIES:", azure) + self.assertIn("'helpers': helpers", azure) + self.assertNotIn("azurelinux-helpers", dockerfile) + self.assertNotIn("musl-gcc", dockerfile) + self.assertNotIn("cc -static", dockerfile) + + def test_docker_kernel_targets_do_not_build_the_guest_binaries(self): + # BuildKit builds every stage that a target inherits or copies from, so + # a kernel-only build must not compile the Rust guest binaries. + dockerfile = (BuildConstants.REPO_ROOT / "docker" / "Dockerfile").read_text( + encoding="utf-8" + ) + dependencies: dict[str, set[str]] = {} + stage = "" + for line in dockerfile.splitlines(): + if match := re.fullmatch(r"FROM (\S+) AS (\S+)", line): + stage = match.group(2) + dependencies[stage] = {match.group(1)} + elif match := re.match(r"COPY --from=(\S+) ", line): + dependencies[stage].add(match.group(1)) + + def stages_built_for(target: str) -> set[str]: + built: set[str] = set() + pending = [target] + while pending: + for dependency in dependencies.get(pending.pop(), set()): + if dependency not in built: + built.add(dependency) + pending.append(dependency) + return built + + for target in ( + "kernel-artifacts", + DockerBuildConstants.DEBUG_KERNEL_TARGET, + DockerBuildConstants.LINUX_SOURCE_TARGET, + ): + with self.subTest(target=target): + self.assertIn("base", stages_built_for(target)) + self.assertNotIn("guest-binaries", stages_built_for(target)) + for target in ( + DockerBuildConstants.ALPINE_TARGET, + DockerBuildConstants.UBUNTU_TARGET, + DockerBuildConstants.AZURELINUX_TARGET, + "ubuntu-determinism", + ): + with self.subTest(target=target): + self.assertIn("guest-binaries", stages_built_for(target)) + def test_azurelinux_initramfs_uses_locked_busybox_applets(self): dockerfile = (BuildConstants.REPO_ROOT / "docker" / "Dockerfile").read_text( encoding="utf-8" ) base_stage = dockerfile.split("FROM base AS kernel", 1)[0] self.assertNotIn("busybox", base_stage) - azure_stage = dockerfile.split("FROM base AS azurelinux-initramfs", 1)[1] + azure_stage = dockerfile.split("FROM guest-base AS azurelinux-initramfs", 1)[1] self.assertIn("ln -sf ../sbin/busybox /rootfs/usr/bin/busybox", azure_stage) applets = ( azure_stage.split("for utility in ", 1)[1] @@ -8850,7 +9157,7 @@ def test_azurelinux_initramfs_gives_nobody_a_home(self): dockerfile = (BuildConstants.REPO_ROOT / "docker" / "Dockerfile").read_text( encoding="utf-8" ) - azure_stage = dockerfile.split("FROM base AS azurelinux-initramfs", 1)[1] + azure_stage = dockerfile.split("FROM guest-base AS azurelinux-initramfs", 1)[1] self.assertIn( "sed -i 's#^\\(nobody:[^:]*:65534:65534:[^:]*:\\)/dev/null:" "#\\1/nonexistent:#'", @@ -8897,7 +9204,7 @@ def test_azurelinux_initramfs_packs_normalized_rootfs(self): dockerfile = (BuildConstants.REPO_ROOT / "docker" / "Dockerfile").read_text( encoding="utf-8" ) - azure_stage = dockerfile.split("FROM base AS azurelinux-initramfs", 1)[1] + azure_stage = dockerfile.split("FROM guest-base AS azurelinux-initramfs", 1)[1] cleanup = azure_stage.index( "rm -rf /rootfs/usr/lib/sysimage/tdnf /rootfs/var/cache/tdnf \\\n" " /rootfs/var/cache/ldconfig /rootfs/var/lib/rpm" @@ -8914,7 +9221,7 @@ def test_azurelinux_manifest_records_shared_input_digest(self): dockerfile = (BuildConstants.REPO_ROOT / "docker" / "Dockerfile").read_text( encoding="utf-8" ) - azure_stage = dockerfile.split("FROM base AS azurelinux-initramfs", 1)[1] + azure_stage = dockerfile.split("FROM guest-base AS azurelinux-initramfs", 1)[1] self.assertIn("from nvx_tools.azurelinux import input_sha256", azure_stage) self.assertIn( "'input_sha256': input_sha256(\n" @@ -8947,6 +9254,9 @@ def test_azurelinux_input_digest_tracks_guest_sources_and_dockerfile(self): "scripts/nvx_tools/azurelinux.py", "scripts/nvx_tools/build_constants.py", "scripts/nvx_tools/common.py", + "scripts/nvx_tools/guest_binaries.py", + "nvx-rs/src/lib.rs", + "rust-toolchain.toml", ): with self.subTest(path=relative): path = root / relative @@ -8967,23 +9277,39 @@ def install(_source: Path, destination: Path) -> dict[str, str]: installed.append(destination) return {} - root = Path("rootfs") - with ( - patch.object(build, "_install", side_effect=install), - patch.object(build, "_build_static_helper", return_value={}), - patch.object(build, "_build_device_io_helper", return_value={}), - ): - build._install_guest_files( - build_config.InitramfsBuildConfig(work=Path("work")), - root, - guests.ALPINE_GUEST, + def stage_binaries(output: Path) -> guest_binaries.GuestBinaryBuildResult: + output.mkdir() + binaries: dict[str, str] = {} + for name in GuestRustBuildConstants.BINARIES: + path = output / name + path.write_bytes(name.encode("ascii")) + binaries[name] = common.sha256_file(path) + return guest_binaries.GuestBinaryBuildResult( + source_sha256="a" * 64, + binaries=binaries, ) - self.assertIn(root / "sbin" / "nvx-hostmount", installed) + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "rootfs" + work = Path(temporary) / "work" + (root / "sbin").mkdir(parents=True) + (root / "etc").mkdir() + work.mkdir() + with ( + patch.object(build, "_install", side_effect=install), + patch.object(build, "build_guest_binaries", side_effect=stage_binaries), + ): + build._install_guest_files( + build_config.InitramfsBuildConfig(work=work), + root, + guests.ALPINE_GUEST, + ) + self.assertIn(root / "sbin" / "nvx-hostmount", installed) dockerfile = (BuildConstants.REPO_ROOT / "docker" / "Dockerfile").read_text( encoding="utf-8" ) - azure_stage = dockerfile.split("FROM base AS azurelinux-initramfs", 1)[1] + azure_stage = dockerfile.split("FROM guest-base AS azurelinux-initramfs", 1)[1] azure_sbin_scripts = azure_stage.split( "install -m 0755 /repo/guest/common/init /rootfs/init\n", 1 )[1].split("/rootfs/sbin/", 1)[0] @@ -9036,43 +9362,31 @@ def test_apk_add_uses_host_ca_bundle_without_overriding_configuration(self): environment = run.call_args.kwargs["env"] self.assertEqual(environment["SSL_CERT_FILE"], "/etc/configured-ca.pem") - def test_device_io_helper_is_static_with_nonexecutable_stack(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - work = root / "work" - destination = root / "root" / "sbin" / "nvx-device-io" - work.mkdir() - destination.parent.mkdir(parents=True) - - def compile_helper( - command: list[str | os.PathLike[str]], **_kwargs: object - ) -> None: - output = Path(command[command.index("-o") + 1]) - output.write_bytes(b"static-elf") - - with ( - patch.object(build, "require_tool", return_value="cc"), - patch.object(build, "run_checked", side_effect=compile_helper) as run, - ): - provenance = build._build_device_io_helper(work, destination) - - command = run.call_args.args[0] - self.assertIn("-nostdlib", command) - self.assertIn("-static", command) - self.assertIn("-Wl,-z,noexecstack", command) - self.assertEqual(destination.read_bytes(), b"static-elf") - self.assertEqual( - provenance["binary_sha256"], common.sha256_file(destination) - ) - self.assertEqual(len(provenance["source_sha256"]), 64) - - def test_device_io_provenance_uses_shared_helper_source(self): + def test_device_io_provenance_uses_shared_rust_source_digest(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) checkout = root / "nvx" - source = checkout / "guest" / "common" / "nvx-device-io.c" - source.parent.mkdir(parents=True) - source.write_bytes(b"shared helper source") + crate = checkout / "nvx-rs" + (crate / "src").mkdir(parents=True) + sources = { + crate / "Cargo.toml": "[package]\nname = 'fixture'\n", + crate / "Cargo.lock": "# lock\n", + crate / "build.rs": "fn main() {}\n", + crate / "src" / "lib.rs": "pub fn fixture() {}\n", + checkout / "rust-toolchain.toml": ( + f'[toolchain]\nchannel = "{GuestRustBuildConstants.TOOLCHAIN}"\n' + ), + checkout / "scripts" / "nvx_tools" / "build_constants.py": ( + "PROFILE = 'guest'\n" + ), + checkout / "scripts" / "nvx_tools" / "guest_binaries.py": ( + "def build(): pass\n" + ), + } + for path, contents in sources.items(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + source_sha256 = guest_binaries.guest_binary_source_sha256(checkout) initrd = root / "initramfs.cpio.gz" manifest = initrd.with_name(f"{initrd.name}.packages.json") manifest.write_text( @@ -9080,7 +9394,7 @@ def test_device_io_provenance_uses_shared_helper_source(self): { "helpers": { "nvx-device-io": { - "source_sha256": common.sha256_file(source), + "source_sha256": source_sha256, "binary_sha256": "1" * 64, } } @@ -9094,11 +9408,32 @@ def test_device_io_provenance_uses_shared_helper_source(self): initrd, ) - self.assertEqual(provenance["source"], str(source.resolve())) - self.assertEqual( - provenance["source_sha256"], - common.sha256_file(source), - ) + self.assertEqual(provenance["source"], str(crate.resolve())) + self.assertEqual(provenance["source_sha256"], source_sha256) + self.assertEqual(provenance["toolchain"], GuestRustBuildConstants.TOOLCHAIN) + self.assertEqual(provenance["target"], GuestRustBuildConstants.TARGET) + + with ( + patch.object(GuestRustBuildConstants, "TOOLCHAIN", "9.9.9"), + self.assertRaisesRegex( + common.ScriptError, + "Rust guest build toolchain mismatch", + ), + ): + benchmark._device_io_helper_provenance( + argparse.Namespace(nvx_dir=checkout), + initrd, + ) + + (crate / "src" / "lib.rs").write_text("changed\n", encoding="utf-8") + with self.assertRaisesRegex( + ValueError, + "Rust guest binary sources do not match", + ): + benchmark._device_io_helper_provenance( + argparse.Namespace(nvx_dir=checkout), + initrd, + ) def test_sandbox_kernel_config_requires_every_feature(self): with tempfile.TemporaryDirectory() as temporary: @@ -9496,36 +9831,89 @@ def test_guest_build_adds_debug_kernel_only_when_requested(self): debug.work.name, KernelBuildConstants.DEBUG_WORK_DIRECTORY_NAME ) - def test_guest_files_build_time_probe_with_its_own_flags(self): - source = BuildConstants.REPO_ROOT / "guest" / "common" / "nvx-time-probe.c" - header = source.read_text(encoding="utf-8").splitlines()[:2] - self.assertEqual( - header, - [ - "// Copyright (c) Microsoft Corporation.", - "// Licensed under the MIT License.", - ], - ) + def test_guest_files_install_one_checked_rust_build(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) / "root" work = Path(temporary) / "work" for directory in (root / "sbin", root / "etc", work): directory.mkdir(parents=True) - commands: list[list[str]] = [] + payloads = { + name: f"rust-guest-binary {name}".encode() + for name in GuestRustBuildConstants.BINARIES + } + result = guest_binaries.GuestBinaryBuildResult( + source_sha256="a" * 64, + binaries={ + name: hashlib.sha256(payload).hexdigest() + for name, payload in payloads.items() + }, + ) - def compile_helper( - command: list[str | os.PathLike[str]], **_kwargs: object - ) -> None: - commands.append([os.fspath(part) for part in command]) - output = Path(command[command.index("-o") + 1]) - output.write_bytes(f"static-elf {output.name}".encode()) + def stage_binaries(output: Path) -> guest_binaries.GuestBinaryBuildResult: + output.mkdir() + for name, payload in payloads.items(): + (output / name).write_bytes(payload) + return result - def find_tool(name: str) -> str: - return name + with patch.object( + build, "build_guest_binaries", side_effect=stage_binaries + ) as rust_build: + helpers = build._install_guest_files( + build_config.InitramfsBuildConfig(work=work), + root, + guests.guest_descriptor("alpine"), + ) + + rust_build.assert_called_once_with( + work / GuestRustBuildConstants.OUTPUT_DIRECTORY_NAME + ) + for name, payload in payloads.items(): + with self.subTest(helper=name): + installed = root / "sbin" / name + self.assertEqual(installed.read_bytes(), payload) + if os.name != "nt": + self.assertEqual(installed.stat().st_mode & 0o777, 0o755) + self.assertEqual( + helpers[name], + { + "source_sha256": result.source_sha256, + "binary_sha256": result.binaries[name], + }, + ) + for name in ( + "STATIC_HELPER_CFLAGS", + "DEVICE_IO_CFLAGS", + "STATIC_HELPERS", + "MUSL_COMPILER", + "MUSL_STATIC_HELPERS", + "TIME_PROBE_CFLAGS", + ): + with self.subTest(helper=name): + self.assertFalse(hasattr(InitramfsBuildConstants, name)) + def test_guest_files_reuse_the_configured_prebuilt_rust_binaries(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "root" + work = Path(temporary) / "work" + prebuilt = Path(temporary) / "prebuilt" + for directory in (root / "sbin", root / "etc", work, prebuilt): + directory.mkdir(parents=True) + binaries: dict[str, str] = {} + for name in GuestRustBuildConstants.BINARIES: + path = prebuilt / name + path.write_bytes(name.encode("ascii")) + binaries[name] = common.sha256_file(path) + result = guest_binaries.GuestBinaryBuildResult( + source_sha256="a" * 64, + binaries=binaries, + ) + environment = GuestRustBuildConstants.BINARIES_VARIABLE with ( - patch.object(build, "require_tool", side_effect=find_tool), - patch.object(build, "run_checked", side_effect=compile_helper), + patch.dict(os.environ, {environment: str(prebuilt)}), + patch.object( + build, "inspect_guest_binaries", return_value=result + ) as inspect, + patch.object(build, "build_guest_binaries") as rust_build, ): helpers = build._install_guest_files( build_config.InitramfsBuildConfig(work=work), @@ -9533,49 +9921,15 @@ def find_tool(name: str) -> str: guests.guest_descriptor("alpine"), ) - probe = InitramfsBuildConstants.TIME_PROBE_NAME - flags_by_source = { - Path(command[-1]).stem: command[1 : command.index("-o")] - for command in commands - } - compiler_by_source = { - Path(command[-1]).stem: command[0] for command in commands - } - self.assertEqual( - flags_by_source[probe], list(InitramfsBuildConstants.TIME_PROBE_CFLAGS) - ) - self.assertIn("-pthread", flags_by_source[probe]) - musl = InitramfsBuildConstants.MUSL_COMPILER - self.assertEqual(compiler_by_source[probe], musl) - for name in InitramfsBuildConstants.STATIC_HELPERS: - with self.subTest(helper=name): - self.assertEqual( - flags_by_source[name], - list(InitramfsBuildConstants.STATIC_HELPER_CFLAGS), - ) - self.assertEqual(compiler_by_source[name], "cc") - for name in InitramfsBuildConstants.MUSL_STATIC_HELPERS: - with self.subTest(helper=name): - self.assertEqual( - flags_by_source[name], - list(InitramfsBuildConstants.STATIC_HELPER_CFLAGS), - ) - self.assertEqual(compiler_by_source[name], musl) - dockerfile = (BuildConstants.REPO_ROOT / "docker" / "Dockerfile").read_text( - encoding="utf-8" - ) - self.assertIn(" musl-tools \\\n", dockerfile) - self.assertIn( - " for helper in " - + " ".join(InitramfsBuildConstants.MUSL_STATIC_HELPERS) - + "; do \\\n musl-gcc ", - dockerfile, - ) - self.assertEqual(helpers[probe]["source_sha256"], common.sha256_file(source)) - self.assertEqual( - helpers[probe]["binary_sha256"], - hashlib.sha256(f"static-elf {probe}".encode()).hexdigest(), - ) + inspect.assert_called_once_with(prebuilt) + rust_build.assert_not_called() + for name in GuestRustBuildConstants.BINARIES: + with self.subTest(helper=name): + self.assertEqual( + (root / "sbin" / name).read_bytes(), name.encode("ascii") + ) + self.assertEqual(helpers[name]["source_sha256"], "a" * 64) + self.assertEqual(helpers[name]["binary_sha256"], binaries[name]) def test_docker_debug_kernel_target_exports_debug_artifacts(self): dockerfile = (BuildConstants.REPO_ROOT / "docker" / "Dockerfile").read_text( @@ -10815,282 +11169,27 @@ def run(*arguments: str) -> subprocess.CompletedProcess[str]: self.assertNotIn("NVX-UBUNTU-SANDBOX-OK", result.stdout) -class ManagedAgentCgroupTests(unittest.TestCase): - SOURCE = Path(__file__).parents[1] / "guest" / "common" / "nvx-managed-agent.c" - - def setUp(self): - if sys.platform != "linux": - self.skipTest("the managed agent requires Linux") - compiler = shutil.which("cc") or shutil.which("gcc") - if compiler is None: - self.skipTest("C compiler is unavailable") - temporary = tempfile.TemporaryDirectory() - self.addCleanup(temporary.cleanup) - root = Path(temporary.name) - self.cgroup = root / "cgroup" - self.cgroup.mkdir() - (self.cgroup / "cgroup.procs").touch() - self.subtree_control = self.cgroup / "cgroup.subtree_control" - self.subtree_control.touch() - self.execution = self.cgroup / "nvx-exec" - self.execution.mkdir() - self.tasks = self.execution / "pids.current" - self.tasks.write_bytes(b"0\n") - (self.execution / "cgroup.kill").touch() - source = root / "cgroup-test.c" - source.write_text( - f"""#define _GNU_SOURCE -#include -#include -static pid_t harness_waitpid(pid_t pid, int *status, int options); -#define waitpid(pid, status, options) harness_waitpid(pid, status, options) -#define CGROUP_ROOT {json.dumps(str(self.cgroup))} -#define main managed_agent_main -#include {json.dumps(str(self.SOURCE))} -#undef main -#undef waitpid - -static pid_t harness_process = -1; - -/* Like the pids controller, stops charging a process once it is reaped. */ -static pid_t harness_waitpid(pid_t pid, int *status, int options) -{{ - pid_t result = waitpid(pid, status, options); - - if (result > 0 && result == harness_process) {{ - int fd = open(EXEC_CGROUP "/pids.current", O_WRONLY | O_TRUNC | O_CLOEXEC); - - if (fd < 0 || write(fd, "0\\n", 2) != 2 || close(fd) != 0) {{ - _exit(3); - }} - harness_process = -1; - }} - return result; -}} - -/* - * Calls `check` while a workload process that the cgroup charges exits, or - * once it has exited if `exited` is set, and requires `check` to reap it. - */ -static int with_exiting_process(int (*check)(void), int exited) -{{ - siginfo_t information = {{0}}; - int result; - int status; - pid_t child = fork(); - - if (child < 0) {{ - _exit(2); - }} - if (child == 0) {{ - const struct timespec delay = {{.tv_nsec = 50L * 1000L * 1000L}}; - - nanosleep(&delay, NULL); - _exit(0); - }} - harness_process = child; - if (exited && waitid(P_PID, (id_t)child, &information, WEXITED | WNOWAIT) != 0) {{ - _exit(2); - }} - result = check(); - status = errno; - if (harness_process >= 0 || waitpid(child, NULL, WNOHANG) >= 0 || errno != ECHILD) {{ - _exit(4); - }} - errno = status; - return result; -}} - -int main(int argc, char **argv) -{{ - int result; - if (argc != 2) {{ - return 2; - }} - errno = 0; - if (strcmp(argv[1], "retry") == 0) {{ - struct control_session session = {{.fd = STDOUT_FILENO}}; - struct agent_config config = {{.direct = 1}}; - struct exec_config exec_config = {{0}}; - char *command[] = {{"/bin/true", NULL}}; - result = run_exec(&session, &config, 42, 0, command, &exec_config); - return result == 0 - ? run_exec(&session, &config, 43, 0, command, &exec_config) - : result; - }} - if (strcmp(argv[1], "tasks") == 0) {{ - result = exec_cgroup_has_tasks(); - }} else if (strcmp(argv[1], "prepare") == 0) {{ - result = prepare_exec_cgroup(); - }} else if (strcmp(argv[1], "settle") == 0) {{ - result = settle_exec_cgroup(); - }} else if (strcmp(argv[1], "settle-reap") == 0) {{ - result = with_exiting_process(settle_exec_cgroup, 0); - }} else if (strcmp(argv[1], "verify-reap") == 0) {{ - result = with_exiting_process(reap_exec_cgroup, 1); - }} else {{ - return 2; - }} - printf("%d %d\\n", result, errno); - return 0; -}} -""", - encoding="utf-8", - ) - self.helper = root / "cgroup-test" - flags = [ - flag - for flag in InitramfsBuildConstants.STATIC_HELPER_CFLAGS - if flag != "-static" - ] - result = subprocess.run( - [compiler, *flags, "-o", str(self.helper), str(source)], - capture_output=True, - text=True, - timeout=30, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stderr) - - def _check(self, mode: str) -> tuple[int, int]: - result = subprocess.run( - [str(self.helper), mode], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stderr) - value, status = result.stdout.split() - return int(value), int(status) - - def _assert_further_exec_refused(self): - result = subprocess.run( - [str(self.helper), "retry"], - capture_output=True, - timeout=5, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stderr) - frames = result.stdout - for expected_id in (42, 43): - outer = ManagedAgentStopTests.OUTER - app = ManagedAgentStopTests.APP - *_, length = outer.unpack(frames[: outer.size]) - frame = frames[outer.size : outer.size + length] - _, _, kind, _, request_id, status, payload_len = app.unpack( - frame[: app.size] - ) - payload = frame[app.size :] - self.assertEqual((kind, request_id, status), (0xFF, expected_id, 125)) - self.assertEqual(payload_len, len(payload)) - self.assertEqual(payload, b"containment-failed") - frames = frames[outer.size + length :] - self.assertEqual(frames, b"") - - def test_task_count_requires_a_canonical_decimal(self): - for contents, expected in ( - (b"0\n", 0), - (b"1\n", 1), - (b"10\n", 1), - (b"4194304\n", 1), - ): - with self.subTest(contents=contents): - self.tasks.write_bytes(contents) - self.assertEqual(self._check("tasks")[0], expected) - for contents in ( - b"", - b"\n", - b"0", - b"00\n", - b"01\n", - b"-1\n", - b"+1\n", - b" 1\n", - b"1 \n", - b"0\n0\n", - b"1\x00\n", - b"max\n", - ): - with self.subTest(contents=contents): - self.tasks.write_bytes(contents) - self.assertEqual(self._check("tasks"), (-1, errno.EPROTO)) - self.tasks.write_bytes(b"1" * 40 + b"\n") - self.assertEqual(self._check("tasks"), (-1, errno.EOVERFLOW)) - - def test_missing_and_unreadable_task_counts_are_errors(self): - self.tasks.unlink() - self.assertEqual(self._check("tasks"), (-1, errno.ENOENT)) - self._assert_further_exec_refused() - self.tasks.mkdir() - self.assertEqual(self._check("tasks"), (-1, errno.EISDIR)) - self._assert_further_exec_refused() - - def test_preparation_enables_the_pids_controller_once(self): - self.assertEqual(self._check("prepare")[0], 0) - self.assertEqual(self.subtree_control.read_bytes(), b"") - self.tasks.unlink() - self.assertEqual(self._check("prepare")[0], 0) - self.assertEqual(self.subtree_control.read_bytes(), b"+pids") - self.subtree_control.unlink() - self.assertEqual(self._check("prepare"), (-1, errno.ENOENT)) - - def test_settlement_verifies_emptiness(self): - self.assertEqual(self._check("settle")[0], 0) - self.assertEqual((self.execution / "cgroup.kill").read_bytes(), b"1") - - def test_settlement_and_verification_reap_what_the_workload_left(self): - for mode in ("settle-reap", "verify-reap"): - with self.subTest(mode=mode): - self.tasks.write_bytes(b"1\n") - self.assertEqual(self._check(mode)[0], 0) - self.assertEqual(self.tasks.read_bytes(), b"0\n") - - def test_settlement_does_not_hide_kill_or_verification_failures(self): - self.tasks.write_bytes(b"1\n") - (self.execution / "cgroup.kill").unlink() - self.assertEqual(self._check("settle"), (-1, errno.ENOENT)) - self._assert_further_exec_refused() - (self.execution / "cgroup.kill").touch() - self.tasks.unlink() - self.assertEqual(self._check("settle"), (-1, errno.ENOENT)) - self._assert_further_exec_refused() - - def test_unreaped_tasks_at_deadline_are_not_settled(self): - # cgroup.events no longer counts a killed process that awaits its - # reaper, but the pids controller still charges it. - (self.execution / "cgroup.events").write_bytes(b"populated 0\nfrozen 0\n") - self.tasks.write_bytes(b"1\n") - started = time.monotonic() - self.assertEqual(self._check("settle"), (-1, errno.ETIMEDOUT)) - self.assertGreaterEqual(time.monotonic() - started, 1.9) - self._assert_further_exec_refused() - - def test_malformed_task_count_refuses_further_exec_until_verified_empty(self): - self.tasks.write_bytes(b"max\n") - self.assertEqual(self._check("settle"), (-1, errno.EPROTO)) - self._assert_further_exec_refused() - self.tasks.write_bytes(b"0\n") - self.assertEqual(self._check("tasks")[0], 0) - self.assertEqual(self._check("settle")[0], 0) - - def _elf_file( segments: tuple[tuple[int, bytes], ...] = ((1, b"code"),), *, elf_type: int = 2, elf_class: int = 2, + stack_flags: int | None = None, ) -> bytes: - """Returns a minimal ELF file with one program header for each segment.""" + """Returns a minimal ELF file with one program header for each segment, and + a ``PT_GNU_STACK`` header with ``stack_flags`` unless it is ``None``.""" + headers = [(segment_type, 4, payload) for segment_type, payload in segments] + if stack_flags is not None: + headers.append((0x6474E551, stack_flags, b"")) header_size, entry_size = 64, 56 - payload_offset = header_size + entry_size * len(segments) + payload_offset = header_size + entry_size * len(headers) program_headers = b"" payloads = b"" - for segment_type, payload in segments: + for segment_type, flags, payload in headers: program_headers += struct.pack( " subprocess.CompletedProcess[bytes]: - return subprocess.run( - [str(self.binaries[name]), *arguments], - capture_output=True, - check=False, - timeout=10, - ) - - def test_writes_the_exit_code_as_one_byte_at_the_control_port(self): - cases = ( - ((), 1), - (("",), 1), - (("0",), 0), - (("37",), 37), - (("255",), 255), - (("256",), 1), - (("-1",), 1), - (("+5",), 1), - ((" 7",), 1), - (("12a",), 1), - (("99999999999999999999",), 1), - # Out of range after any digit, so the value never wraps. - (("4294967296",), 1), - (("4294967551",), 1), - (("00000000000000000255",), 255), - # Decimal: the old script's printf read a leading zero as octal. - (("010",), 10), - (("7", "9"), 7), - ) - for arguments, expected in cases: - with self.subTest(arguments=arguments): - self.device.write_bytes(b"") - result = self._run("ok", *arguments) - self.assertEqual( - (result.returncode, result.stdout, result.stderr), (0, b"", b"") - ) - self.assertEqual( - self.device.read_bytes(), bytes(self.PORT) + bytes([expected]) - ) - - def test_fails_silently_when_the_port_cannot_be_opened(self): - result = self._run("missing", "0") - self.assertEqual( - (result.returncode, result.stdout, result.stderr), (1, b"", b"") - ) - - -class ManagedAgentStopTests(unittest.TestCase): - SOURCE = Path(__file__).parents[1] / "guest" / "common" / "nvx-managed-agent.c" - OUTER = struct.Struct("<4sHBB16sQQI") - APP = struct.Struct("<4sBBHQiI") - - def setUp(self): - if sys.platform != "linux": - self.skipTest("the managed agent requires a Linux pseudo-terminal") - compiler = shutil.which("cc") or shutil.which("gcc") - if compiler is None: - self.skipTest("C compiler is unavailable") - temporary = tempfile.TemporaryDirectory() - self.addCleanup(temporary.cleanup) - self.agent = Path(temporary.name) / "nvx-managed-agent" - flags = [ - flag - for flag in InitramfsBuildConstants.STATIC_HELPER_CFLAGS - if flag != "-static" - ] - result = subprocess.run( - [compiler, *flags, "-o", str(self.agent), str(self.SOURCE)], - capture_output=True, - text=True, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stderr) - - def _record( - self, record_type: int, instance: bytes, sequence: int, payload: bytes = b"" - ) -> bytes: - header = self.OUTER.pack( - b"NVXS", 1, record_type, 0, instance, 1, sequence, len(payload) - ) - return header + payload - - def _read_exact(self, fd: int, length: int, deadline: float) -> bytes: - data = b"" - while len(data) < length: - remaining = deadline - time.monotonic() - self.assertGreater(remaining, 0, "managed agent did not respond") - readable, _, _ = select.select([fd], [], [], remaining) - if readable: - chunk = os.read(fd, length - len(data)) - self.assertTrue(chunk, "managed agent closed the control tty") - data += chunk - return data - - def _read_record(self, fd: int, deadline: float) -> tuple[int, int, bytes]: - header = self._read_exact(fd, self.OUTER.size, deadline) - magic, version, record_type, flags, _, _, sequence, length = self.OUTER.unpack( - header - ) - self.assertEqual((magic, version, flags), (b"NVXS", 1, 0)) - return record_type, sequence, self._read_exact(fd, length, deadline) - - @staticmethod - def _reap(process: subprocess.Popen[bytes]) -> None: - if process.poll() is None: - process.kill() - process.wait(timeout=5) - if process.stderr is not None: - process.stderr.close() - - def _open_session( - self, rootfs: str - ) -> tuple[subprocess.Popen[bytes], int, bytes, float]: - """Starts an agent and completes the attach and reset handshake.""" - if sys.platform != "linux": - self.skipTest("the managed agent requires a Linux pseudo-terminal") - import pty - - master, slave = pty.openpty() - self.addCleanup(os.close, master) - self.addCleanup(os.close, slave) - process = subprocess.Popen( - [ - str(self.agent), - os.ttyname(slave), - rootfs, - "nvx-sandbox", - "65534", - "65534", - "nobody", - "/nonexistent", - ], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) - self.addCleanup(self._reap, process) - deadline = time.monotonic() + 10 - instance = bytes(range(1, 17)) - - self.assertEqual(self._read_record(master, deadline)[0], 1) - os.write(master, self._record(3, instance, 7)) - record_type, sequence, credit = self._read_record(master, deadline) - self.assertEqual((record_type, sequence, len(credit)), (4, 0, 4)) - return process, master, instance, deadline - - def _request( - self, rootfs: str, kind: int, payload: bytes = b"" - ) -> tuple[int, int, int, bytes]: - """Sends one request to a fresh agent; returns the kind, request ID, status, and payload of its answer.""" - _, master, instance, deadline = self._open_session(rootfs) - frame = self.APP.pack(b"NVXC", 1, kind, 0, 42, 0, len(payload)) + payload - os.write(master, self._record(5, instance, 8, frame)) - self.assertEqual(self._read_record(master, deadline)[0], 9) - record_type, _, answer = self._read_record(master, deadline) - self.assertEqual(record_type, 5) - _, _, answered, _, request_id, status, length = self.APP.unpack( - answer[: self.APP.size] - ) - body = answer[self.APP.size :] - self.assertEqual(length, len(body)) - return answered, request_id, status, body - - def test_sandbox_agent_returns_to_init_agent_after_stop(self): - if sys.platform != "linux": - self.skipTest("the managed agent requires a Linux pseudo-terminal") - process, master, instance, deadline = self._open_session("/run/nvx/rootfs") - stop = self.APP.pack(b"NVXC", 1, 3, 0, 42, 0, 0) - os.write(master, self._record(5, instance, 8, stop)) - self.assertEqual(self._read_record(master, deadline)[0], 9) - record_type, _, payload = self._read_record(master, deadline) - self.assertEqual(record_type, 5) - _, _, kind, _, request_id, status, length = self.APP.unpack(payload) - self.assertEqual((kind, request_id, status, length), (0x85, 42, 0, 0)) - - _, stderr = process.communicate(timeout=10) - self.assertEqual(process.returncode, 0, stderr) - - def test_agent_advertises_the_control_features_of_its_mode(self): - cancel, workload_account, exec_cgroup, environment = (1, 4, 8, 16) - exec_cwd, host_mapping_table = (32, 64) - for rootfs, expected in ( - # Only the direct agent maps host paths, gives each workload a cgroup, and - # refuses a working directory that the workload cannot enter. Bit 1, which - # announced nvx_map= kernel tokens, is retired. - ( - "-", - cancel - | workload_account - | exec_cgroup - | environment - | exec_cwd - | host_mapping_table, - ), - ("/run/nvx/rootfs", cancel | workload_account | environment), - ): - with self.subTest(rootfs=rootfs): - kind, request_id, status, body = self._request(rootfs, 5) - self.assertEqual((kind, request_id, status), (0x81, 42, 0)) - self.assertEqual(struct.unpack(" bytes: - """Encodes an extended EXEC payload that carries `cwd` after the arguments.""" - arguments = b"".join(struct.pack(" -int harness_setenv(const char *name, const char *value, int overwrite); -#define setenv(name, value, overwrite) harness_setenv(name, value, overwrite) -#define CGROUP_ROOT {json.dumps(str(cgroup))} -#define setgroups(size, list) harness_setgroups(size, list) -#define setegid(gid) harness_setegid(gid) -#define seteuid(uid) harness_seteuid(uid) -#define main managed_agent_main -#include {json.dumps(str(self.SOURCE))} -#undef main - -int harness_setenv(const char *name, const char *value, int overwrite) -{{ - if (strcmp(name, "PWD") == 0 && getenv("HARNESS_FAIL_PWD") != NULL) {{ - errno = ENOMEM; - return -1; - }} - return (setenv)(name, value, overwrite); -}} - -int harness_setgroups(size_t size, const gid_t *list) -{{ - (void)size; - (void)list; - if (getenv("HARNESS_FAIL_SETGROUPS") != NULL) {{ - errno = EPERM; - return -1; - }} - return 0; -}} - -int harness_setegid(gid_t gid) -{{ - (void)gid; - return 0; -}} - -int harness_seteuid(uid_t uid) -{{ - (void)uid; - return 0; -}} - -int main(int argc, char **argv) -{{ - if (argc >= 2 && strcmp(argv[1], "--exec-config-fd") == 0) {{ - return launch_workload(argc, argv); - }} - - struct control_session session = {{.fd = STDOUT_FILENO}}; - struct agent_config config = {{ - .direct = 1, - .uid = "65534", - .gid = "65534", - .user = "nobody", - .home = "/", - }}; - char *environment[] = {{getenv("HARNESS_ENVIRONMENT"), NULL}}; - struct exec_config exec_config = {{ - .cwd = argc == 3 ? argv[2] : NULL, - .environment = environment[0] == NULL ? NULL : environment, - .environment_count = environment[0] == NULL ? 0 : 1, - .environment_present = environment[0] != NULL, - .inherit_default_env = - environment[0] != NULL && getenv("HARNESS_LAYER_ENVIRONMENT") != NULL, - }}; - char *command[] = {{"/bin/sh", "-c", argv[1], NULL}}; - - if (argc != 2 && argc != 3) {{ - return 2; - }} - return run_exec(&session, &config, 42, 0, command, &exec_config) == 0 ? 0 : 1; -}} -""", - encoding="utf-8", - ) - flags = [ - flag - for flag in InitramfsBuildConstants.STATIC_HELPER_CFLAGS - if flag != "-static" - ] - result = subprocess.run( - [compiler, *flags, "-o", str(self.harness), str(source)], - capture_output=True, - text=True, - timeout=60, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.environment = { - **os.environ, - "PATH": f"{tools}{os.pathsep}{os.environ.get('PATH', '/usr/bin:/bin')}", - } - for name in ( - "HARNESS_BEFORE_LAUNCH", - "HARNESS_ENVIRONMENT", - "HARNESS_FAIL_PWD", - "HARNESS_FAIL_SETGROUPS", - "HARNESS_LAYER_ENVIRONMENT", - ): - self.environment.pop(name, None) - - def _exec( - self, - script: str, - cwd: str | None = None, - environment: dict[str, str] | None = None, - ) -> list[tuple[int, int, int, bytes]]: - """Runs one workload; returns the kind, request ID, status, and payload of each frame.""" - arguments = [str(self.harness), script] - if cwd is not None: - arguments.append(cwd) - result = subprocess.run( - arguments, - capture_output=True, - timeout=30, - env=environment or self.environment, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stderr) - frames: list[tuple[int, int, int, bytes]] = [] - rest = result.stdout - while rest: - _, _, record_type, _, _, _, _, length = self.OUTER.unpack( - rest[: self.OUTER.size] - ) - payload = rest[self.OUTER.size : self.OUTER.size + length] - rest = rest[self.OUTER.size + length :] - self.assertEqual(record_type, 5) - _, _, kind, _, request_id, status, payload_len = self.APP.unpack( - payload[: self.APP.size] - ) - body = payload[self.APP.size :] - self.assertEqual(payload_len, len(body)) - frames.append((kind, request_id, status, body)) - return frames - - def _completed(self, frames: list[tuple[int, int, int, bytes]]) -> bytes: - """Returns the standard output of a workload that exited with status zero.""" - self.assertEqual(frames[-1], (0x84, 42, 0, b"exit"), frames) - self.assertTrue(all(kind == 0x82 for kind, *_ in frames[:-1]), frames) - return b"".join(body for *_, body in frames[:-1]) - - def _refused( - self, frames: list[tuple[int, int, int, bytes]], cwd: str, error: int - ) -> None: - diagnostic = ( - f"nvx-managed-agent: cannot enter working directory {cwd}: " - f"{os.strerror(error)}\n" - ).encode() - self.assertEqual( - frames, - [(0x83, 42, 0, diagnostic), (0xFF, 42, error, b"cwd-failed")], - ) - - def test_workloads_start_in_the_root_directory_by_default(self): - output = self._completed(self._exec('pwd; printf "%s\\n" "$PWD"')) - self.assertEqual(output, b"/\n/\n") - - def test_workloads_start_in_the_requested_directory(self): - for name in ("a", "b"): - directory = self.root / "work" / name - directory.mkdir(parents=True) - with self.subTest(directory=directory): - output = self._completed( - self._exec('pwd; printf "%s\\n" "$PWD"', str(directory)) - ) - self.assertEqual(output, f"{directory}\n{directory}\n".encode()) - - def test_pwd_keeps_the_requested_name_of_a_canonical_path(self): - target = self.root / "target" - target.mkdir() - link = self.root / "link" - link.symlink_to(target) - output = self._completed(self._exec("pwd; /bin/pwd -P", str(link))) - self.assertEqual(output, f"{link}\n{target}\n".encode()) - # Without a canonical name, PWD names the directory itself. - output = self._completed(self._exec('printf "%s\\n" "$PWD"', f"{link}/.")) - self.assertEqual(output, f"{target}\n".encode()) - - def test_unusable_directories_refuse_the_launch(self): - marker = self.root / "ran" - regular = self.root / "file" - regular.touch() - cases = [ - (self.root / "missing", errno.ENOENT), - (regular, errno.ENOTDIR), - ] - # Root may search any directory, so only an unprivileged user observes EACCES. - if sys.platform == "linux" and os.geteuid() != 0: - locked = self.root / "locked" - locked.mkdir(mode=0) - self.addCleanup(locked.chmod, 0o755) - cases.append((locked, errno.EACCES)) - for directory, error in cases: - with self.subTest(directory=directory): - frames = self._exec(f"touch {marker}", str(directory)) - self._refused(frames, str(directory), error) - self.assertFalse(marker.exists()) - - def test_identity_failures_refuse_the_launch(self): - environment = {**self.environment, "HARNESS_FAIL_SETGROUPS": "1"} - frames = self._exec(f"touch {self.root / 'ran'}", None, environment) - self.assertEqual(frames, [(0xFF, 42, 125, b"launch-failed")]) - self.assertFalse((self.root / "ran").exists()) - - def test_workloads_start_in_the_directory_that_was_checked(self): - checked = self.root / "checked" - retarget = self.root / "retarget" - renamed = self.root / "renamed" - checked.mkdir() - retarget.mkdir() - link = self.root / "link" - link.symlink_to(checked) - # Neither a retargeted link nor a renamed directory may fail the - # launch or move the workload to a directory that was not checked. - quote = shlex.quote - for cwd, change, expected in ( - (link, f"ln -sfn {quote(str(retarget))} {quote(str(link))}", checked), - (retarget, f"mv {quote(str(retarget))} {quote(str(renamed))}", renamed), - ): - with self.subTest(cwd=cwd): - environment = {**self.environment, "HARNESS_BEFORE_LAUNCH": change} - frames = self._exec("/bin/pwd -P", str(cwd), environment) - self.assertEqual(self._completed(frames), f"{expected}\n".encode()) - - def test_pwd_failures_refuse_the_launch(self): - marker = self.root / "ran" - environment = {**self.environment, "HARNESS_FAIL_PWD": "1"} - for cwd in (None, str(self.root)): - with self.subTest(cwd=cwd): - frames = self._exec(f"touch {marker}", cwd, environment) - self.assertEqual(frames, [(0xFF, 42, 125, b"launch-failed")]) - self.assertFalse(marker.exists()) - - def test_exact_environments_control_pwd(self): - # The agent leaves PWD to a request that replaces the environment, so - # it does not try to set PWD, and cannot fail to. - environment = { - **self.environment, - "HARNESS_ENVIRONMENT": "MODE=exact", - "HARNESS_FAIL_PWD": "1", - } - output = self._completed( - self._exec( - 'printf "%s\\n" "$MODE"; /bin/pwd -P', str(self.root), environment - ) - ) - self.assertEqual(output, f"exact\n{self.root}\n".encode()) - - def test_layered_environments_keep_the_agents_pwd(self): - # Entries layered over the default environment keep the PWD that the - # agent points at the directory, which, unlike a PWD that the shell - # computes, names a link as the request did. - target = self.root / "target" - target.mkdir() - link = self.root / "link" - link.symlink_to(target) - layered = { - **self.environment, - "HARNESS_ENVIRONMENT": "MODE=layered", - "HARNESS_LAYER_ENVIRONMENT": "1", - } - output = self._completed( - self._exec('printf "%s %s\\n" "$MODE" "$PWD"', str(link), layered) - ) - self.assertEqual(output, f"layered {link}\n".encode()) - frames = self._exec("true", str(link), {**layered, "HARNESS_FAIL_PWD": "1"}) - self.assertEqual(frames, [(0xFF, 42, 125, b"launch-failed")]) - - class AciSandboxRunnerTests(unittest.TestCase): def _artifacts(self, root: Path) -> dict[str, Path]: names = { @@ -21805,6 +21315,18 @@ def test_package_rejects_tampered_source_metadata_without_replacing_output(self) "0" * 64, "Alpine minirootfs_sha256", ), + ( + "alpine", + "guest_sources", + ["guest/common", "guest/alpine"], + "Alpine guest_sources", + ), + ( + "guest_rust", + "toolchain", + "stable", + "Rust guest toolchain", + ), ) for section, field, invalid_value, error in cases: with ( diff --git a/scripts/test_tool_versions.py b/scripts/test_tool_versions.py index 1456605a3..0fe40bde5 100644 --- a/scripts/test_tool_versions.py +++ b/scripts/test_tool_versions.py @@ -691,6 +691,13 @@ def test_linux_setups_pin_their_tools_to_the_manifest(self): [f"{name}={value}" for name, value in expected.items()], ) + def test_linux_setups_install_the_pinned_musl_target(self): + for script in (LINUX_RUNNER, LINUX_MSHV): + with self.subTest(script=script.name): + source = script.read_text(encoding="utf-8") + self.assertIn('target add --toolchain "$RUST_TOOLCHAIN"', source) + self.assertIn("x86_64-unknown-linux-musl", source) + def test_windows_setup_pins_its_tools_to_the_manifest(self): powershells = _powershells() if not powershells: