From ea06acf99fb4a346c47f4b81b014a78fda9cd99a Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Sat, 17 Oct 2026 07:48:07 -0300 Subject: [PATCH 1/8] scripts: record the provenance of nvx-rs nvx.py runs the nvx-rs that NVX_RS names or else the one in nvx-rs/target/release, and never rebuilds one that exists, so nothing tells whether that executable is a build of the checkout's nvx-rs sources. OpenVMM has the same problem and solves it with build/openvmm.provenance.json, which binds the digest of its binary to its gitlink revision. Releases are about to ship nvx-rs, and packaging and `nvx.py download` need the same binding for it. build/nvx-rs.provenance.json now records, for the nvx-rs that nvx.py runs: - source_sha256: a digest of the files that build nvx-rs, the crate's Cargo.toml, Cargo.lock, build.rs, and src/ tree, and rust-toolchain.toml, by path and with line endings normalized, so that LF and CRLF checkouts of the same sources agree. The tests, README.md, and artifacts.json build nothing, and stay out; - toolchain: the Rust release that rust-toolchain.toml pins; - target: the Rust target of the build; - features: ["cli"], which BUILD_COMMAND builds; - executable_sha256: the digest of the executable. nvx.py records it after each Cargo build that it makes, for the host target that `rustc -vV` reports. The new `nvx.py record-nvx-rs-provenance [--target TARGET]` command records it for a build made otherwise, of the executable that NVX_RS names or else of nvx-rs/target/release/nvx-rs[.exe]. The caller vouches that this checkout built that executable for that target; the record lets later steps check the claim against the sources. RELEASE_TARGETS names the targets that the NVX CLI jobs build for releases, x86_64-unknown-linux-musl and x86_64-pc-windows-msvc, and a record for the musl target requires a static ELF executable, since a build for the host's own Linux target is dynamic. The new constants live in nvx_tools/nvx_rs.py, which no guest artifact digests, so the existing guest provenance stays valid. doc/usage.md documents the command and the record, doc/build.md the file beside the other provenance sidecars, nvx-rs/README.md who records it, and doc/ci.md that the Copilot setup's first nvx-rs build records it too. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/build.md | 6 +- doc/ci.md | 4 +- doc/usage.md | 29 ++++++ nvx-rs/README.md | 4 +- scripts/nvx.py | 18 ++++ scripts/nvx_tools/nvx_rs.py | 176 +++++++++++++++++++++++++++++++++- scripts/test_nvx_tools.py | 183 ++++++++++++++++++++++++++++++++++++ 7 files changed, 414 insertions(+), 6 deletions(-) diff --git a/doc/build.md b/doc/build.md index 8a94ce10..197c5a26 100644 --- a/doc/build.md +++ b/doc/build.md @@ -127,7 +127,11 @@ input configuration, generated configuration, and output hash; bind the 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. +provenance. `build/nvx-rs.provenance.json` likewise binds the `nvx-rs` +executable that `nvx.py` runs to the digest of its sources and its build's +Rust release, target, and features; `nvx.py` writes it when it builds +`nvx-rs`, and [`record-nvx-rs-provenance`](usage.md#record-nvx-rs-provenance) +writes it for a build made otherwise. Ubuntu adds: diff --git a/doc/ci.md b/doc/ci.md index 2faa4f10..40e6b3fb 100644 --- a/doc/ci.md +++ b/doc/ci.md @@ -681,8 +681,8 @@ through the `restore-only` input of [`build-guest-artifacts`](../.github/actions/build-guest-artifacts/action.yml) and the KVM OpenVMM binary that `build-openvmm` caches for the pinned revision, so agents can run microVM tests without rebuilding either. It doesn't build -`nvx-rs`: `nvx.py` builds it with Cargo the first time that a command needs -it. The guest artifact +`nvx-rs`: `nvx.py` builds it with Cargo, and records its provenance, the first +time that a command needs it. The guest artifact restore computes its keys with the same action that saves them. The binary restore repeats the key, paths, and gzip compression of the cache entry that `build-openvmm` saves, and a test in `scripts/test_nvx_tools.py` fails when diff --git a/doc/usage.md b/doc/usage.md index 1e219d6c..d595d379 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -41,6 +41,15 @@ or delete the executable, after changing `nvx-rs`. The `managed-lifecycle` scenario of [`test-microvm`](#test-microvm) also needs Cargo, which runs its `nvx-rs` test. +The provenance of the `nvx-rs` that `nvx.py` runs, +`build/nvx-rs.provenance.json`, binds the SHA-256 of its executable to a +digest of the `nvx-rs` sources that built it, the crate's manifest, lock +file, build script, and `src/` tree and `rust-toolchain.toml`, and to the +Rust release, target, and Cargo features of the build. `nvx.py` records it +when it builds `nvx-rs`, and +[`record-nvx-rs-provenance`](#record-nvx-rs-provenance) records it for a +build made otherwise. + ## Commands | Command | Description | @@ -56,6 +65,7 @@ scenario of [`test-microvm`](#test-microvm) also needs Cargo, which runs its | `verify-guest-determinism` | Rebuild Ubuntu guest artifacts twice and compare SHA-256 values. | | `build-openvmm` | Build the OpenVMM release binary. | | `record-openvmm-provenance` | Bind an existing OpenVMM binary to the pinned source revision. | +| `record-nvx-rs-provenance` | Bind an existing `nvx-rs` executable to its sources, Rust release, target, and features. | | `materialize-kernel-provenance-inputs` | Write kernel provenance inputs from raw run-head blobs. | | `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. | @@ -334,6 +344,25 @@ already exist at `openvmm/target/release/openvmm` on POSIX or `openvmm/target/release/openvmm.exe` on Windows. The command fails if either prerequisite is missing. +### `record-nvx-rs-provenance` + +```text +python3 scripts/nvx.py record-nvx-rs-provenance [--target TARGET] +``` + +| Option | Default | Description | +| --- | --- | --- | +| `--target TARGET` | the host target that `rustc -vV` reports | Rust target that built `nvx-rs`. | + +Records `build/nvx-rs.provenance.json` for the `nvx-rs` that `nvx.py` runs +without building it, the one that `NVX_RS` names or else +`nvx-rs/target/release/nvx-rs[.exe]`; see [nvx-rs](#nvx-rs). The record binds +the executable's SHA-256 to the digest of this checkout's `nvx-rs` sources, +the Rust release that `rust-toolchain.toml` pins, the target, and the `cli` +feature, so record it right after building `nvx-rs` from this checkout. For +`x86_64-unknown-linux-musl`, the target of the Linux release packages, the +executable must be static. `nvx.py` records the builds that it makes itself. + ### `build` ```text diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 75dd0ed7..6e5a2347 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -162,7 +162,9 @@ cargo build --release --features cli # target/release/nvx-rs `nvx.py` runs the executable that `NVX_RS` names, else `nvx-rs/target/release/nvx-rs[.exe]` of its checkout, and else builds that one with Cargo first, with its output on standard error; see -[Usage](../doc/usage.md#nvx-rs). It runs `nvx-rs --root REPOSITORY --prog +[Usage](../doc/usage.md#nvx-rs). `nvx.py` records the provenance of the builds +that it makes, and `nvx.py record-nvx-rs-provenance` that of a build made +otherwise. It runs `nvx-rs --root REPOSITORY --prog "nvx.py sandbox" sandbox OPERATION OPTIONS` with the options that the user gave, in full, and the standard streams and exit status of the command; `nvx-rs egress compile PATH` and `nvx-rs --root REPOSITORY artifacts` for diff --git a/scripts/nvx.py b/scripts/nvx.py index 88111a09..697dfb54 100644 --- a/scripts/nvx.py +++ b/scripts/nvx.py @@ -293,6 +293,11 @@ def command_record_openvmm_provenance(_: argparse.Namespace) -> None: record_openvmm_provenance(OpenVmmBuildConfig()) +def command_record_nvx_rs_provenance(args: argparse.Namespace) -> None: + path = nvx_rs.record_provenance(args.target) + print(f">> recorded the provenance of nvx-rs in {path}") + + def command_materialize_kernel_provenance_inputs(_: argparse.Namespace) -> None: materialize_kernel_provenance_inputs() @@ -800,6 +805,19 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: ) provenance.set_defaults(handler=command_record_openvmm_provenance) + nvx_rs_provenance = subparsers.add_parser( + "record-nvx-rs-provenance", + help="bind an existing nvx-rs executable to the nvx-rs sources", + ) + nvx_rs_provenance.add_argument( + "--target", + help=( + "Rust target that built nvx-rs (default: the host target that " + "rustc reports)" + ), + ) + nvx_rs_provenance.set_defaults(handler=command_record_nvx_rs_provenance) + kernel_provenance = subparsers.add_parser( "materialize-kernel-provenance-inputs", help="write kernel provenance inputs from raw run-head blobs", diff --git a/scripts/nvx_tools/nvx_rs.py b/scripts/nvx_tools/nvx_rs.py index d260a4a3..5d41979f 100644 --- a/scripts/nvx_tools/nvx_rs.py +++ b/scripts/nvx_tools/nvx_rs.py @@ -11,11 +11,18 @@ standard error so that it never mixes with the output of the command. Without Cargo, nvx.py fails and names these choices. + +The provenance of an nvx-rs, build/nvx-rs.provenance.json, binds the digest +of its executable to source_sha256() of the sources that built it, and to the +Rust release, target, and features of the build, as OpenVMM's provenance +binds it to its source revision. nvx.py records it for the nvx-rs that it +builds, and `nvx.py record-nvx-rs-provenance` for one built otherwise. """ from __future__ import annotations import contextlib +import hashlib import json import os import shlex @@ -30,8 +37,16 @@ from types import FrameType from typing import cast -from .build_constants import BuildConstants -from .common import ScriptError +from .build_constants import BuildConstants, OpenVMMBuildConstants +from .common import ( + ScriptError, + artifact_path, + normalized_sha256_file, + require_file, + sha256_file, +) +from .elf import read_elf_executable +from .guest_binaries import pinned_guest_rust_toolchain # The environment variable that names the nvx-rs executable to run. VARIABLE = "NVX_RS" @@ -55,6 +70,23 @@ "--target-dir", "nvx-rs/target", ) +# The features of the nvx-rs that BUILD_COMMAND builds and that releases ship. +FEATURES = ("cli",) +# The Rust targets of the nvx-rs that releases ship, by operating system, as +# CI's NVX CLI jobs build it: a static musl executable, which runs on every +# x86-64 Linux host, and an MSVC executable that links the C runtime +# statically, which runs without the Visual C++ redistributable. +RELEASE_TARGETS = { + "linux": OpenVMMBuildConstants.MUSL_RUST_TARGET, + "windows": OpenVMMBuildConstants.WINDOWS_RUST_TARGET, +} +# The domain and format of the inventory that source_sha256() hashes. +SOURCE_DIGEST_DOMAIN = "nvx-rs-cli-sources" +SOURCE_DIGEST_FORMAT = 1 +# The provenance of the nvx-rs that nvx.py runs, in build/, beside that of +# OpenVMM, and the format of its record. +PROVENANCE_NAME = "nvx-rs.provenance.json" +PROVENANCE_FORMAT = 1 def binary_path() -> Path: @@ -69,6 +101,104 @@ def binary_path() -> Path: return DIRECTORY / "target" / "release" / name +def release_target() -> str: + """Return the Rust target of the nvx-rs that releases for this host's + operating system ship.""" + return RELEASE_TARGETS["windows" if os.name == "nt" else "linux"] + + +def source_files(root: Path | None = None) -> tuple[Path, ...]: + """Return the files that determine the nvx-rs executable of the checkout at + `root`, this one by default, in path order: the crate's manifest, lock + file, build script, and sources, and rust-toolchain.toml, which pins the + Rust release that builds it.""" + root = DIRECTORY.parent if root is None else root + crate = root / DIRECTORY.name + files = [ + crate / "Cargo.toml", + crate / "Cargo.lock", + crate / "build.rs", + root / "rust-toolchain.toml", + ] + sources = crate / "src" + if not sources.is_dir(): + raise ScriptError(f"nvx-rs source directory not found: {sources}") + files.extend(path for path in sources.rglob("*") if path.is_file()) + return tuple( + sorted( + (require_file(path, "nvx-rs source") for path in files), + key=lambda path: path.relative_to(root).as_posix(), + ) + ) + + +def source_sha256(root: Path | None = None) -> str: + """Hash the paths and contents of source_files(root), with line endings + normalized, so that every checkout of the same sources has one digest.""" + root = DIRECTORY.parent if root is None else root + inventory = { + "domain": SOURCE_DIGEST_DOMAIN, + "format": SOURCE_DIGEST_FORMAT, + "files": [ + { + "path": path.relative_to(root).as_posix(), + "sha256": normalized_sha256_file(path), + } + for path in source_files(root) + ], + } + encoded = json.dumps(inventory, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def provenance_path() -> Path: + """Return build/nvx-rs.provenance.json, the provenance of the nvx-rs that + nvx.py runs.""" + return artifact_path(PROVENANCE_NAME) + + +def host_target() -> str: + """Return the target that Cargo builds for by default here: the host of + the Rust release that rust-toolchain.toml selects.""" + try: + completed = subprocess.run( + ("rustc", "-vV"), + cwd=BuildConstants.REPO_ROOT, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + check=False, + ) + except OSError as error: + raise ScriptError(f"failed to run rustc for its host target: {error}") from ( + error + ) + if completed.returncode == 0: + for line in completed.stdout.splitlines(): + if line.startswith("host: "): + return line.removeprefix("host: ").strip() + raise ScriptError(f"rustc -vV did not report a host target: {completed.stderr}") + + +def check_target(executable: Path, target: str) -> None: + """Fail unless `executable` is what a build for `target` gives, where the + target makes that checkable: for the Linux release target, a static + executable, which a build for the host's own target is not.""" + if target != RELEASE_TARGETS["linux"]: + return + try: + static = read_elf_executable(executable).static + except (OSError, ValueError) as error: + raise ScriptError(f"invalid {target} nvx-rs executable: {error}") from error + if not static: + raise ScriptError( + f"nvx-rs executable {executable} is not static, but a {target} build " + f"is: build it with `cargo build --release --locked --features cli " + f"--manifest-path nvx-rs/Cargo.toml --target {target}`, and copy " + f"nvx-rs/target/{target}/release/{BINARY_NAME} to {binary_path()}" + ) + + def _format_command(command: Sequence[str]) -> str: return subprocess.list2cmdline(command) if os.name == "nt" else shlex.join(command) @@ -103,6 +233,46 @@ def find_binary() -> Path | None: return path if path.is_file() else None +def provenance_record(executable: Path, target: str) -> dict[str, object]: + """Return the provenance of the nvx-rs executable at `executable`, built + for `target` with FEATURES from this checkout's sources.""" + return { + "format": PROVENANCE_FORMAT, + "source_sha256": source_sha256(), + "toolchain": pinned_guest_rust_toolchain(DIRECTORY.parent), + "target": target, + "features": list(FEATURES), + "executable_sha256": sha256_file(executable), + } + + +def record_provenance(target: str | None = None) -> Path: + """Record the provenance of the nvx-rs that nvx.py runs without building + it, built from this checkout for `target`, by default the host's, and + return where. + + The caller vouches for the build; the record binds its executable to this + checkout's sources, which is what packaging and the adversarial executor + check. + """ + executable = find_binary() + if executable is None: + raise ScriptError( + f"nvx-rs is not built at {binary_path()}: build it with " + f"`{_format_command(BUILD_COMMAND)}`, or set {VARIABLE} to an " + "nvx-rs executable" + ) + target = target or host_target() + check_target(executable, target) + path = provenance_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(provenance_record(executable, target), indent=2) + "\n", + encoding="utf-8", + ) + return path + + def _stderr_target() -> int: """Return the descriptor of standard error, to which a build's output goes, or DEVNULL if standard error has none, as when a test replaced it.""" @@ -136,6 +306,8 @@ def build_binary() -> Path: path = binary_path() if not path.is_file(): raise ScriptError(f"Cargo did not build {path}") + # A Cargo build replaces the executable that an earlier record described. + record_provenance() return path diff --git a/scripts/test_nvx_tools.py b/scripts/test_nvx_tools.py index a72a28f3..7ed73f90 100644 --- a/scripts/test_nvx_tools.py +++ b/scripts/test_nvx_tools.py @@ -2098,11 +2098,14 @@ def build( patch("shutil.which", return_value="cargo"), patch("subprocess.run", side_effect=build) as run, patch.object(nvx_rs, "_stderr_target", return_value=2), + patch.object(nvx_rs, "record_provenance") as record, contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr), ): self.assertEqual(nvx_rs.require_binary(), binary) run.assert_called_once() + # The build records the provenance of what it built, for the host. + record.assert_called_once_with() self.assertEqual( run.call_args.args[0], ( @@ -2161,6 +2164,184 @@ def test_missing_cargo_names_every_way_to_get_nvx_rs(self): ): self.assertIn(expected, message) + def test_release_targets_follow_the_operating_system(self): + self.assertEqual( + nvx_rs.release_target(), + "x86_64-pc-windows-msvc" + if os.name == "nt" + else "x86_64-unknown-linux-musl", + ) + self.assertEqual(nvx_rs.FEATURES, ("cli",)) + # BUILD_COMMAND builds the features that releases ship. + features = nvx_rs.BUILD_COMMAND.index("--features") + 1 + self.assertEqual(nvx_rs.BUILD_COMMAND[features].split(","), ["cli"]) + + def test_source_digest_covers_the_build_inputs_in_any_line_endings(self): + relative = [ + path.relative_to(BuildConstants.REPO_ROOT).as_posix() + for path in nvx_rs.source_files() + ] + self.assertEqual(relative, sorted(relative)) + for name in ( + "nvx-rs/Cargo.toml", + "nvx-rs/Cargo.lock", + "nvx-rs/build.rs", + "nvx-rs/src/bin/nvx-rs.rs", + "rust-toolchain.toml", + ): + self.assertIn(name, relative) + # Tests, documentation, and the bundled release pin build no part of + # the executable. + for name in relative: + self.assertNotRegex(name, r"^nvx-rs/(tests/|README\.md$|artifacts\.json$)") + self.assertEqual(nvx_rs.source_sha256(), nvx_rs.source_sha256()) + + with tempfile.TemporaryDirectory() as temporary: + checkouts = [Path(temporary) / "lf", Path(temporary) / "crlf"] + for checkout, newline in zip(checkouts, (b"\n", b"\r\n"), strict=True): + for name in ( + "nvx-rs/Cargo.toml", + "nvx-rs/Cargo.lock", + "nvx-rs/build.rs", + "nvx-rs/src/lib.rs", + "nvx-rs/src/bin/nvx-rs.rs", + "rust-toolchain.toml", + ): + path = checkout / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(newline.join((name.encode(), b"line", b""))) + digest = nvx_rs.source_sha256(checkouts[0]) + # Checkouts differ only in line endings, as on Windows. + self.assertEqual(nvx_rs.source_sha256(checkouts[1]), digest) + source = checkouts[0] / "nvx-rs" / "src" / "lib.rs" + source.write_bytes(b"changed\n") + self.assertNotEqual(nvx_rs.source_sha256(checkouts[0]), digest) + # A new source file changes the digest too. + (checkouts[1] / "nvx-rs" / "src" / "new.rs").write_bytes(b"") + self.assertNotEqual(nvx_rs.source_sha256(checkouts[1]), digest) + shutil.rmtree(checkouts[1] / "nvx-rs" / "src") + with self.assertRaisesRegex( + common.ScriptError, "nvx-rs source directory not found" + ): + nvx_rs.source_sha256(checkouts[1]) + + def test_host_target_is_the_host_of_the_pinned_rust(self): + version = ( + "rustc 1.95.0 (abc 2026-01-01)\nbinary: rustc\n" + "host: x86_64-unknown-linux-gnu\nrelease: 1.95.0\n" + ) + with patch( + "subprocess.run", + return_value=subprocess.CompletedProcess(["rustc"], 0, version, ""), + ) as run: + self.assertEqual(nvx_rs.host_target(), "x86_64-unknown-linux-gnu") + self.assertEqual(run.call_args.args[0], ("rustc", "-vV")) + # From the repository root, so that rust-toolchain.toml applies. + self.assertEqual(run.call_args.kwargs["cwd"], BuildConstants.REPO_ROOT) + for result in ( + subprocess.CompletedProcess(["rustc"], 1, "", "no rustc"), + subprocess.CompletedProcess(["rustc"], 0, "release: 1.95.0\n", ""), + ): + with ( + self.subTest(result=result), + patch("subprocess.run", return_value=result), + self.assertRaisesRegex(common.ScriptError, "host target"), + ): + nvx_rs.host_target() + with ( + patch("subprocess.run", side_effect=OSError("missing")), + self.assertRaisesRegex(common.ScriptError, "failed to run rustc"), + ): + nvx_rs.host_target() + + def test_provenance_binds_the_executable_that_nvx_py_runs(self): + linux = nvx_rs.RELEASE_TARGETS["linux"] + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + binary = root / "nvx-rs" + other = root / "other-nvx-rs" + provenance = root / "build" / nvx_rs.PROVENANCE_NAME + with ( + patch.object(nvx_rs, "binary_path", return_value=binary), + patch.object(nvx_rs, "provenance_path", return_value=provenance), + patch.object(nvx_rs, "host_target", return_value="host-triple"), + ): + with ( + _without_nvx_rs_variable(), + self.assertRaisesRegex( + common.ScriptError, + f"nvx-rs is not built at {re.escape(str(binary))}", + ), + ): + nvx_rs.record_provenance() + self.assertFalse(provenance.exists()) + + binary.write_bytes(b"nvx-rs") + with _without_nvx_rs_variable(): + self.assertEqual(nvx_rs.record_provenance(), provenance) + self.assertEqual( + json.loads(provenance.read_text(encoding="utf-8")), + { + "format": 1, + "source_sha256": nvx_rs.source_sha256(), + "toolchain": GuestRustBuildConstants.TOOLCHAIN, + "target": "host-triple", + "features": ["cli"], + "executable_sha256": common.sha256_file(binary), + }, + ) + + # The executable that NVX_RS names is the one that nvx.py + # runs, and so the one that the record describes. + other.write_bytes(b"other nvx-rs") + with patch.dict(os.environ, {nvx_rs.VARIABLE: str(other)}): + nvx_rs.record_provenance("x86_64-pc-windows-msvc") + record = json.loads(provenance.read_text(encoding="utf-8")) + self.assertEqual(record["target"], "x86_64-pc-windows-msvc") + self.assertEqual(record["executable_sha256"], common.sha256_file(other)) + + # A record for the Linux release target needs the static + # executable that such a build gives. + with ( + _without_nvx_rs_variable(), + self.assertRaisesRegex( + common.ScriptError, f"invalid {linux} nvx-rs executable" + ), + ): + nvx_rs.record_provenance(linux) + binary.write_bytes( + _elf_file(((1, b"code"), (3, b"/lib/ld-musl.so.1\0")), elf_type=3) + ) + with ( + _without_nvx_rs_variable(), + self.assertRaisesRegex(common.ScriptError, "is not static"), + ): + nvx_rs.record_provenance(linux) + binary.write_bytes(_elf_file(elf_type=3)) + with _without_nvx_rs_variable(): + nvx_rs.record_provenance(linux) + self.assertEqual( + json.loads(provenance.read_text(encoding="utf-8"))["target"], + linux, + ) + + def test_record_command_records_the_provenance(self): + args = nvx.parse_args( + ["record-nvx-rs-provenance", "--target", "x86_64-unknown-linux-musl"] + ) + self.assertEqual(args.target, "x86_64-unknown-linux-musl") + self.assertIsNone(nvx.parse_args(["record-nvx-rs-provenance"]).target) + recorded = Path("build") / nvx_rs.PROVENANCE_NAME + with ( + patch.object(nvx_rs, "record_provenance", return_value=recorded) as record, + contextlib.redirect_stdout(io.StringIO()) as stdout, + ): + args.handler(args) + record.assert_called_once_with("x86_64-unknown-linux-musl") + self.assertEqual( + stdout.getvalue(), f">> recorded the provenance of nvx-rs in {recorded}\n" + ) + def test_failed_builds_report_cargo(self): with tempfile.TemporaryDirectory() as temporary: binary = Path(temporary) / "nvx-rs" @@ -2174,10 +2355,12 @@ def test_failed_builds_report_cargo(self): patch.object(nvx_rs, "binary_path", return_value=binary), patch("shutil.which", return_value="cargo"), patch("subprocess.run", return_value=_completed(returncode)), + patch.object(nvx_rs, "record_provenance") as record, contextlib.redirect_stderr(io.StringIO()), self.assertRaisesRegex(common.ScriptError, message), ): nvx_rs.require_binary() + record.assert_not_called() def test_commands_name_the_program_and_the_repository_root(self): binary = Path("bin") / "nvx-rs" From 89eff5fe67a11f0eb7dad69cc6ae331173365f24 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Sat, 17 Oct 2026 07:58:05 -0300 Subject: [PATCH 2/8] release: package nvx-rs with its provenance #359 makes nvx-rs the backend of nvx.py sandbox and of the egress and artifact steps of nvx.py run, so a release that nvx.py download installs is of little use without it. Every package now ships the release build of nvx-rs beside OpenVMM: - bin/nvx-rs in the Linux packages, the static x86_64-unknown-linux-musl executable that runs on every x86-64 Linux host, and bin/nvx-rs.exe in the Windows package, the x86_64-pc-windows-msvc executable with a static C runtime; - provenance/nvx-rs.provenance.json, its build provenance; - an nvx_rs section in SOURCE-MANIFEST.json with the executable's digest, the source digest, the Rust release, the target, and the features; - both files in SHA256SUMS, as every packaged file is. `nvx.py package` takes them from nvx-rs/target/release and build/, as it takes OpenVMM, and refuses a provenance that does not describe the executable as a build of the checkout's nvx-rs sources, with its Rust release and features, for the release target of the host's OS, and a Linux executable that is not static. It checks the staged copies against the provenance, as it does for OpenVMM. Source-inclusive packages ship nvx-rs too; their project source archive already holds its sources. CI provides the build. Each NVX CLI job now records the provenance of the nvx-rs that it built and tested, with --target, and uploads it as nvx-rs-linux-musl-provenance or nvx-rs-windows-msvc-provenance beside the executable. The workflows now name the pair by its base name, as they name the OpenVMM artifacts, so run-nvx-microvm-tests.yml appends -executable itself. run-platform.yml takes the base name and passes it to the package-release action, which downloads both into place before it packages. The platform jobs already wait for their CLI job. Before it publishes, publish_development_release.py checks, from each archive's member list, that every platform's package ships bin/openvmm and bin/nvx-rs, or their .exe forms, so that a development release without them never goes out. The bundled feature of nvx-rs extracts and checks only OpenVMM, the Alpine guest, and SOURCE-MANIFEST.json of a release, so a release with bin/nvx-rs stages as the pinned older release does, and the pin stays. The synthetic release of the check-nvx-rs action now has the current layout, with nvx-rs and its provenance in the bundle and in its SHA256SUMS. doc/distribution.md, doc/build.md, doc/usage.md, doc/ci.md, and nvx-rs/README.md describe the package contents and the handoff. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/actions/check-nvx-rs/action.yml | 14 +- .github/actions/package-release/action.yml | 23 ++ .github/workflows/ci.yml | 47 ++- .github/workflows/run-nvx-microvm-tests.yml | 4 +- .github/workflows/run-platform.yml | 5 + doc/build.md | 13 +- doc/ci.md | 43 ++- doc/distribution.md | 28 +- doc/usage.md | 15 +- nvx-rs/README.md | 13 +- scripts/nvx_tools/nvx_rs.py | 3 + scripts/nvx_tools/release.py | 112 +++++++ scripts/publish_development_release.py | 39 ++- scripts/test_development_release.py | 127 +++++++ scripts/test_nvx_tools.py | 351 ++++++++++++++++++-- 15 files changed, 754 insertions(+), 83 deletions(-) diff --git a/.github/actions/check-nvx-rs/action.yml b/.github/actions/check-nvx-rs/action.yml index f4ff8af1..de4540de 100644 --- a/.github/actions/check-nvx-rs/action.yml +++ b/.github/actions/check-nvx-rs/action.yml @@ -61,7 +61,8 @@ runs: cargo "+${{ steps.toolchains.outputs.toolchain }}" test --locked --all-features cargo "+${{ steps.toolchains.outputs.toolchain }}" test --locked - # Stages a synthetic release, which the bundled-artifacts test then requires to find. + # Stages a synthetic release in the current layout, with nvx-rs and its + # provenance, which the bundled-artifacts test then requires to find. - name: Check artifact bundling shell: bash working-directory: nvx-rs @@ -69,18 +70,23 @@ runs: set -euo pipefail bundle=$(mktemp -d) executable=openvmm + cli=nvx-rs bundle_dir="${bundle}" if [[ "${RUNNER_OS}" == Windows ]]; then executable=openvmm.exe + cli=nvx-rs.exe bundle_dir=$(cygpath -w "${bundle}") fi - mkdir -p "${bundle}/bin" "${bundle}/guest" + mkdir -p "${bundle}/bin" "${bundle}/guest" "${bundle}/provenance" printf openvmm > "${bundle}/bin/${executable}" + printf nvx-rs > "${bundle}/bin/${cli}" + printf '{}' > "${bundle}/provenance/nvx-rs.provenance.json" printf kernel > "${bundle}/guest/vmlinux" printf initramfs > "${bundle}/guest/initramfs.cpio.gz" cp ../SOURCE-MANIFEST.json "${bundle}/" - (cd "${bundle}" && sha256sum "bin/${executable}" guest/vmlinux \ - guest/initramfs.cpio.gz SOURCE-MANIFEST.json > SHA256SUMS) + (cd "${bundle}" && sha256sum "bin/${executable}" "bin/${cli}" guest/vmlinux \ + guest/initramfs.cpio.gz provenance/nvx-rs.provenance.json \ + SOURCE-MANIFEST.json > SHA256SUMS) NVX_BUNDLE_DIR="${bundle_dir}" cargo "+${{ steps.toolchains.outputs.toolchain }}" test --locked --lib \ --features bundled artifacts diff --git a/.github/actions/package-release/action.yml b/.github/actions/package-release/action.yml index 036485c4..a44f0144 100644 --- a/.github/actions/package-release/action.yml +++ b/.github/actions/package-release/action.yml @@ -5,10 +5,33 @@ inputs: platform: description: Platform suffix used in the package and artifact names required: true + nvx-rs-artifact: + description: Base name of the nvx-rs artifacts for the runner's OS + required: true runs: using: composite steps: + # Every package ships the nvx-rs that the NVX CLI job of its OS built and + # tested, with its provenance, which nvx.py download installs where + # nvx.py looks for them. + - name: Download nvx-rs executable + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.nvx-rs-artifact }}-executable + path: nvx-rs/target/release + + - name: Download nvx-rs provenance + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.nvx-rs-artifact }}-provenance + path: build + + - name: Make nvx-rs executable + if: runner.os != 'Windows' + shell: bash + run: chmod +x nvx-rs/target/release/nvx-rs + - name: Rematerialize canonical kernel inputs if: runner.os == 'Windows' shell: powershell diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35ad4410..2854f310 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,6 +140,14 @@ jobs: nvx-rs/target/release/nvx-rs echo "NVX_RS=${GITHUB_WORKSPACE}/nvx-rs/target/release/nvx-rs" >> "${GITHUB_ENV}" + # Packages ship the build with its provenance, which binds it to the + # nvx-rs sources, the pinned Rust release, the target, and the features. + - name: Record nvx-rs provenance + shell: bash + run: >- + python3 scripts/nvx.py record-nvx-rs-provenance + --target x86_64-unknown-linux-musl + - name: Validate NVX CLI uses: ./.github/actions/validate-nvx @@ -152,6 +160,15 @@ jobs: overwrite: true retention-days: 1 + - name: Upload nvx-rs provenance + uses: actions/upload-artifact@v7 + with: + name: nvx-rs-linux-musl-provenance + path: build/nvx-rs.provenance.json + if-no-files-found: error + overwrite: true + retention-days: 1 + nvx-cli-windows: name: NVX CLI / Windows if: >- @@ -213,6 +230,12 @@ jobs: "NVX_RS=$env:GITHUB_WORKSPACE\nvx-rs\target\release\nvx-rs.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Record nvx-rs provenance + shell: powershell + run: | + python scripts\nvx.py record-nvx-rs-provenance --target x86_64-pc-windows-msvc + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Validate NVX CLI uses: ./.github/actions/validate-nvx @@ -225,6 +248,15 @@ jobs: overwrite: true retention-days: 1 + - name: Upload nvx-rs provenance + uses: actions/upload-artifact@v7 + with: + name: nvx-rs-windows-msvc-provenance + path: build/nvx-rs.provenance.json + if-no-files-found: error + overwrite: true + retention-days: 1 + openvmm-changes: name: Detect CI workload inputs if: github.event_name != 'pull_request' || github.event.pull_request.draft == false @@ -953,7 +985,7 @@ jobs: runner: '["self-hosted", "linux", "kvm", "virtual-machine"]' backend: kvm openvmm-artifact: openvmm-linux-gnu - nvx-rs-artifact: nvx-rs-linux-musl-executable + nvx-rs-artifact: nvx-rs-linux-musl secrets: OPENVMM_DEPLOY_KEY: ${{ secrets.OPENVMM_DEPLOY_KEY }} @@ -986,7 +1018,7 @@ jobs: runner: '["self-hosted", "linux", "mshv", "virtual-machine"]' backend: mshv openvmm-artifact: openvmm-linux-gnu - nvx-rs-artifact: nvx-rs-linux-musl-executable + nvx-rs-artifact: nvx-rs-linux-musl secrets: OPENVMM_DEPLOY_KEY: ${{ secrets.OPENVMM_DEPLOY_KEY }} @@ -1019,7 +1051,7 @@ jobs: runner: '["self-hosted", "windows", "whp", "virtual-machine"]' backend: whp openvmm-artifact: openvmm-windows-msvc - nvx-rs-artifact: nvx-rs-windows-msvc-executable + nvx-rs-artifact: nvx-rs-windows-msvc secrets: OPENVMM_DEPLOY_KEY: ${{ secrets.OPENVMM_DEPLOY_KEY }} @@ -1055,7 +1087,7 @@ jobs: runner: '["self-hosted", "linux", "kvm", "virtual-machine"]' backend: kvm openvmm-artifact: openvmm-linux-gnu - nvx-rs-artifact: nvx-rs-linux-musl-executable + nvx-rs-artifact: nvx-rs-linux-musl debug-kernel: true secrets: OPENVMM_DEPLOY_KEY: ${{ secrets.OPENVMM_DEPLOY_KEY }} @@ -1083,7 +1115,7 @@ jobs: runner: '["self-hosted", "linux", "mshv", "virtual-machine"]' backend: mshv openvmm-artifact: openvmm-linux-gnu - nvx-rs-artifact: nvx-rs-linux-musl-executable + nvx-rs-artifact: nvx-rs-linux-musl debug-kernel: true secrets: OPENVMM_DEPLOY_KEY: ${{ secrets.OPENVMM_DEPLOY_KEY }} @@ -1111,7 +1143,7 @@ jobs: runner: '["self-hosted", "windows", "whp", "virtual-machine"]' backend: whp openvmm-artifact: openvmm-windows-msvc - nvx-rs-artifact: nvx-rs-windows-msvc-executable + nvx-rs-artifact: nvx-rs-windows-msvc debug-kernel: true secrets: OPENVMM_DEPLOY_KEY: ${{ secrets.OPENVMM_DEPLOY_KEY }} @@ -1152,6 +1184,7 @@ jobs: release-platform: linux-kvm benchmark-platform: linux-kvm-virtual-machine openvmm-artifact: openvmm-linux-gnu + nvx-rs-artifact: nvx-rs-linux-musl teardown-mode: guest-exit benchmark-cpus: 0-7 host-cpu-reserve: 0 @@ -1191,6 +1224,7 @@ jobs: release-platform: linux-mshv benchmark-platform: linux-mshv-virtual-machine openvmm-artifact: openvmm-linux-musl + nvx-rs-artifact: nvx-rs-linux-musl teardown-mode: guest-exit benchmark-cpus: 0-7 host-cpu-reserve: 0 @@ -1230,6 +1264,7 @@ jobs: release-platform: windows-whp benchmark-platform: windows-whp-virtual-machine openvmm-artifact: openvmm-windows-msvc + nvx-rs-artifact: nvx-rs-windows-msvc teardown-mode: guest-exit benchmark-cpus: 0-7 host-cpu-reserve: 0 diff --git a/.github/workflows/run-nvx-microvm-tests.yml b/.github/workflows/run-nvx-microvm-tests.yml index 4d361e8e..ea1dcf21 100644 --- a/.github/workflows/run-nvx-microvm-tests.yml +++ b/.github/workflows/run-nvx-microvm-tests.yml @@ -20,7 +20,7 @@ on: required: true type: string nvx-rs-artifact: - description: Name of the nvx-rs executable artifact for the runner's OS + description: Base name of the nvx-rs artifacts for the runner's OS required: true type: string debug-kernel: @@ -94,7 +94,7 @@ jobs: - name: Download nvx-rs executable uses: actions/download-artifact@v8 with: - name: ${{ inputs.nvx-rs-artifact }} + name: ${{ inputs.nvx-rs-artifact }}-executable path: nvx-rs/target/release - name: Make nvx-rs executable diff --git a/.github/workflows/run-platform.yml b/.github/workflows/run-platform.yml index d39a1681..47221e64 100644 --- a/.github/workflows/run-platform.yml +++ b/.github/workflows/run-platform.yml @@ -27,6 +27,10 @@ on: description: Base name of the compatible OpenVMM artifacts required: true type: string + nvx-rs-artifact: + description: Base name of the nvx-rs artifacts for the runner's OS + required: true + type: string teardown-mode: description: Guest teardown mode used by the benchmark required: true @@ -134,3 +138,4 @@ jobs: uses: ./.github/actions/package-release with: platform: ${{ inputs.release-platform }} + nvx-rs-artifact: ${{ inputs.nvx-rs-artifact }} diff --git a/doc/build.md b/doc/build.md index 197c5a26..71c08790 100644 --- a/doc/build.md +++ b/doc/build.md @@ -330,13 +330,20 @@ and SHA-256. The debug kernel is a CI artifact and is never packaged. The Release packaging needs the OpenVMM binary, the guest artifacts in the layout below, and the OpenVMM, kernel, and Alpine initramfs provenance sidecars, so run -`build-openvmm` and `build-guest --guest all` first. A source-inclusive package +`build-openvmm` and `build-guest --guest all` first. It also needs the release +build of `nvx-rs` at `nvx-rs/target/release/nvx-rs[.exe]`, a static +`x86_64-unknown-linux-musl` executable on Linux and an +`x86_64-pc-windows-msvc` one on Windows, with the provenance that +[`record-nvx-rs-provenance`](usage.md#record-nvx-rs-provenance) writes to +`build/nvx-rs.provenance.json`. A source-inclusive package does not need the Azure Linux artifacts. Packaging stages and verifies a 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. Its `guest_rust` section pins Rust 1.95.0 and +manifest. Its `nvx_rs` section records the `nvx-rs` executable's SHA-256, the +digest of the `nvx-rs` sources that built it, and the build's Rust release, +target, and features. 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 @@ -347,6 +354,7 @@ A binary release directory has this layout: ```text bin/openvmm[.exe] +bin/nvx-rs[.exe] guest/vmlinux guest/vmlinux.config guest/initramfs.cpio.gz @@ -360,6 +368,7 @@ guest/initramfs-azurelinux.cpio.gz.packages.json provenance/openvmm.provenance.json provenance/initramfs.provenance.json provenance/vmlinux.provenance.json +provenance/nvx-rs.provenance.json licenses/LICENSE-OPENVMM licenses/COPYING-LINUX LICENSE diff --git a/doc/ci.md b/doc/ci.md index 40e6b3fb..61e6193c 100644 --- a/doc/ci.md +++ b/doc/ci.md @@ -366,20 +366,29 @@ then restores executable permissions on Linux. `nvx.py` runs [`nvx-rs`](../nvx-rs/README.md#the-nvx-rs-cli) for its `sandbox` command and for the egress-policy and artifact steps of its `run` command, so -the microVM test jobs take `nvx-rs` the same way. Each NVX CLI job builds it -for its operating system with `--release --locked --features cli`, puts it at -`nvx-rs/target/release/`, where `nvx.py` looks for it, and runs the CLI tests -with it, with `NVX_RS` naming it for the tests that run it directly: `nvx-cli-linux` builds a static `x86_64-unknown-linux-musl` -executable, which runs on every self-hosted Linux runner, and -`nvx-cli-windows` an executable with a static C runtime, which needs no Visual -C++ redistributable. Once the tests pass, the job uploads it as the one-day -`nvx-rs-linux-musl-executable` or `nvx-rs-windows-msvc-executable` artifact, -and each microVM test job of that operating system downloads it into -`nvx-rs/target/release/` and restores its executable permission on Linux. -The platform jobs run neither `nvx.py sandbox` nor `nvx.py run`, so they don't -take it. The `managed-lifecycle` scenario also builds and runs the ignored -`nvx-rs` test `tests/managed_lifecycle.rs` with Cargo on the runner, as -`test-aci-edge-sandboxes` builds the tests of its crate. +the microVM test jobs take `nvx-rs` the same way, and every release package +ships it. Each NVX CLI job builds it for its operating system with +`--release --locked --features cli`, puts it at `nvx-rs/target/release/`, +where `nvx.py` looks for it, and runs the CLI tests with it, with `NVX_RS` +naming it for the tests that run it directly: `nvx-cli-linux` builds a static +`x86_64-unknown-linux-musl` executable, which runs on every self-hosted Linux +runner, and `nvx-cli-windows` an `x86_64-pc-windows-msvc` executable with a +static C runtime, which needs no Visual C++ redistributable. Each job records +the build's provenance with +[`record-nvx-rs-provenance`](usage.md#record-nvx-rs-provenance), and, once the +tests pass, uploads the executable and the provenance as the one-day +`nvx-rs-linux-musl-executable` and `nvx-rs-linux-musl-provenance` artifacts, or +the `nvx-rs-windows-msvc-*` pair. The workflows that take them name the pair by +its base name, `nvx-rs-linux-musl` or `nvx-rs-windows-msvc`, as they name the +OpenVMM artifacts. Each microVM test job of that operating system downloads +the executable into `nvx-rs/target/release/` and restores its executable +permission on Linux. The platform jobs run neither `nvx.py sandbox` nor +`nvx.py run`, but their +[`package-release`](../.github/actions/package-release/action.yml) step +downloads both, the provenance into `build/`, so that each package ships the +`nvx-rs` of its operating system. The `managed-lifecycle` scenario also builds +and runs the ignored `nvx-rs` test `tests/managed_lifecycle.rs` with Cargo on +the runner, as `test-aci-edge-sandboxes` builds the tests of its crate. Once the required artifacts are ready, benchmarks run in parallel with the NVX test layer, each on its backend's @@ -437,7 +446,7 @@ reads, and uses Python 3.12, the release that the self-hosted runners provide; the Windows job also installs the pinned Rust release with the minimal profile for the curl shim test and the `nvx-rs` build. Each job first builds the `nvx-rs` executable that `nvx.py` runs, tests with it, and hands it to the -microVM test jobs of its operating system, as described above. Fork pull requests lack the OpenVMM deploy key, so the +microVM test and platform jobs of its operating system, as described above. Fork pull requests lack the OpenVMM deploy key, so the CLI jobs run, like the hypervisor jobs, only for pushes and same-repository pull requests. Because the microVM and platform jobs of each operating system wait for its CLI job, a broken CLI does not occupy the hypervisor runners. @@ -622,7 +631,9 @@ executables. The development release job depends on `aci-edge-sandboxes` and `nvx-rs` and requires their combined Linux/Windows results to be successful; failed, -cancelled, or skipped crate checks cannot publish a release. +cancelled, or skipped crate checks cannot publish a release. Before it uploads +anything, it checks that each platform's package ships `bin/openvmm` and +`bin/nvx-rs`, or their `.exe` forms on Windows, which `download` installs. Each `nvx-microvm-tests-{kvm,mshv,whp}` job then runs `nvx.py test-aci-edge-sandboxes` on its self-hosted runner; the debug-kernel diff --git a/doc/distribution.md b/doc/distribution.md index da6ed1c7..64682c47 100644 --- a/doc/distribution.md +++ b/doc/distribution.md @@ -1,9 +1,22 @@ # Package and source delivery `package` stages a release directory, `dist/` by default, from the -built OpenVMM binary and guest artifacts. [Build](build.md) lists the -artifacts and provenance it requires and the binary release layout. Every -package is either binary-only or source-inclusive, and you must choose one. +built OpenVMM binary, `nvx-rs` executable, and guest artifacts. [Build](build.md) +lists the artifacts and provenance it requires and the binary release layout. +Every package is either binary-only or source-inclusive, and you must choose +one. + +Every package ships the release build of `nvx-rs`, the Rust command-line +interface that `nvx.py` runs ([Usage](usage.md#nvx-rs)): `bin/nvx-rs`, a +static `x86_64-unknown-linux-musl` executable, in the Linux packages, and +`bin/nvx-rs.exe`, an `x86_64-pc-windows-msvc` executable with a static C +runtime, in the Windows package. `provenance/nvx-rs.provenance.json` binds it +to the digest of the `nvx-rs` sources that built it, the Rust release, the +target, and the `cli` feature, and the packaged `SOURCE-MANIFEST.json` records +the same in its `nvx_rs` section with the executable's SHA-256. `package` +requires `nvx-rs/target/release/nvx-rs[.exe]` and its provenance in `build/`, +which must describe it as such a build of the checkout's sources; see +[`record-nvx-rs-provenance`](usage.md#record-nvx-rs-provenance). ## Binary-only packages @@ -92,13 +105,18 @@ python3 scripts/nvx.py archive-release \ `archive-release` checks the staged files against their `SHA256SUMS` before it writes a `.tar.gz` or `.zip` archive outside the staged directory; see -[`archive-release`](usage.md#archive-release). +[`archive-release`](usage.md#archive-release). `SHA256SUMS` lists every +packaged file, `bin/nvx-rs[.exe]` and its provenance included, and every +archive marks the executables in `bin/` executable. CI follows the binary-only path. On a `dev` push that changes more than documentation, the `linux-kvm`, `linux-mshv`, and `windows-whp` platform jobs each stage `dist/nvx--` with `--binary-only`, then archive it as `.tar.gz` on Linux and `.zip` on Windows through the -[`package-release`](../.github/actions/package-release/action.yml) action. +[`package-release`](../.github/actions/package-release/action.yml) action, +with the `nvx-rs` and provenance that the NVX CLI job of their operating +system built and tested. The development release job checks that each +package ships OpenVMM and `nvx-rs` before it publishes them. [Continuous integration](ci.md) describes the jobs that gate and publish the resulting development release. diff --git a/doc/usage.md b/doc/usage.md index d595d379..90e53f0d 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -48,7 +48,9 @@ file, build script, and `src/` tree and `rust-toolchain.toml`, and to the Rust release, target, and Cargo features of the build. `nvx.py` records it when it builds `nvx-rs`, and [`record-nvx-rs-provenance`](#record-nvx-rs-provenance) records it for a -build made otherwise. +build made otherwise. [`package`](#package) refuses an `nvx-rs` that it does +not describe as built from the checkout's sources, so a stale build never +ships unnoticed. ## Commands @@ -361,7 +363,8 @@ the executable's SHA-256 to the digest of this checkout's `nvx-rs` sources, the Rust release that `rust-toolchain.toml` pins, the target, and the `cli` feature, so record it right after building `nvx-rs` from this checkout. For `x86_64-unknown-linux-musl`, the target of the Linux release packages, the -executable must be static. `nvx.py` records the builds that it makes itself. +executable must be static. CI's NVX CLI jobs record the builds that they hand +to the packages, and `nvx.py` records the builds that it makes itself. ### `build` @@ -1197,7 +1200,13 @@ python3 scripts/nvx.py package | `--binary-only` | Stage binaries only; publish corresponding source separately. | | `--force` | Replace an existing staging destination. | -Exactly one of `--include-source` and `--binary-only` is required. See +Exactly one of `--include-source` and `--binary-only` is required. Every +package ships `nvx-rs` with its provenance, which must describe +`nvx-rs/target/release/nvx-rs[.exe]` as built from the checkout's sources +for the release target of the host's operating system: +`x86_64-unknown-linux-musl`, a static executable, on Linux, and +`x86_64-pc-windows-msvc` on Windows; see +[`record-nvx-rs-provenance`](#record-nvx-rs-provenance). See [Package and source delivery](distribution.md) for release procedures and source-publication requirements. diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 6e5a2347..c8a8767e 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -159,6 +159,14 @@ cd nvx-rs cargo build --release --features cli # target/release/nvx-rs ``` +Every NVX release package ships the release build as `bin/nvx-rs`, a static +`x86_64-unknown-linux-musl` executable, in the Linux packages, and as +`bin/nvx-rs.exe`, with a static C runtime, in the Windows package, with its +provenance in `provenance/nvx-rs.provenance.json` +([Package and source delivery](../doc/distribution.md)). The `nvx-rs` of an +extracted release runs as is: without `--root`, it uses the release that holds +it; see [Artifact root](#artifact-root). + `nvx.py` runs the executable that `NVX_RS` names, else `nvx-rs/target/release/nvx-rs[.exe]` of its checkout, and else builds that one with Cargo first, with its output on standard error; see @@ -457,7 +465,10 @@ Without `NVX_BUNDLE_DIR`, the build script downloads the release package that Windows packages are ZIP archives, which need bsdtar (Windows' own `tar`) or `unzip`. Every staged file must match the source's `SHA256SUMS`, a download must match its pinned digest, and the release must declare the control contract -of `nvx::contract`. The build scripts of dependents receive the staged +of `nvx::contract`. The build script extracts and verifies only the files that +the bundle holds, so a release that ships `bin/nvx-rs[.exe]` stages as the +older release that `artifacts.json` pins, which doesn't. The build scripts of +dependents receive the staged directory as `DEP_NVX_ARTIFACTS_DIR` and can ship it next to their executables as `nvx/`. diff --git a/scripts/nvx_tools/nvx_rs.py b/scripts/nvx_tools/nvx_rs.py index 5d41979f..e9169ebc 100644 --- a/scripts/nvx_tools/nvx_rs.py +++ b/scripts/nvx_tools/nvx_rs.py @@ -17,6 +17,9 @@ Rust release, target, and features of the build, as OpenVMM's provenance binds it to its source revision. nvx.py records it for the nvx-rs that it builds, and `nvx.py record-nvx-rs-provenance` for one built otherwise. +Packaging rejects an nvx-rs that it does not describe as built from this +checkout's sources. Releases ship nvx-rs as bin/nvx-rs[.exe], built for +RELEASE_TARGETS with FEATURES, with its provenance. """ from __future__ import annotations diff --git a/scripts/nvx_tools/release.py b/scripts/nvx_tools/release.py index 4024260c..75a65458 100644 --- a/scripts/nvx_tools/release.py +++ b/scripts/nvx_tools/release.py @@ -22,6 +22,7 @@ from pathlib import Path, PurePosixPath from typing import cast +from . import nvx_rs from .archive import create_reproducible_release_archive, create_reproducible_tar_gz from .azurelinux import input_sha256 as azurelinux_input_sha256 from .azurelinux import package_lock_sha256 as azurelinux_package_lock_sha256 @@ -500,6 +501,29 @@ def _release_archive_layout( raise ScriptError(f"unsupported release archive: {archive_path.name}") +def release_package_executables(platform: str) -> tuple[str, ...]: + """Return the paths of the executables that every package of the release + platform `platform`, such as linux-kvm, ships: OpenVMM and nvx-rs.""" + suffix = ".exe" if platform.startswith("windows-") else "" + return tuple( + f"bin/{name}{suffix}" + for name in (OpenVMMBuildConstants.BINARY_NAME, nvx_rs.BINARY_NAME) + ) + + +def require_release_package_executables(archive_path: Path, platform: str) -> None: + """Fail unless the release archive at `archive_path` ships the executables + of `platform`, which its member list shows without extracting it.""" + root, layout = _release_archive_layout(archive_path) + missing = [ + path + for path in release_package_executables(platform) + if layout.get(f"{root}/{path}") is not False + ] + if missing: + raise ScriptError(f"{archive_path.name} does not ship {', '.join(missing)}") + + def _extract_release_archive(archive_path: Path, destination: Path) -> None: _release_archive_layout(archive_path) try: @@ -1092,6 +1116,47 @@ def _require_runtime_provenance_paths() -> tuple[Path, Path, Path]: ) +def _describes_checkout_nvx_rs( + provenance: dict[str, object], *, target: str | None +) -> bool: + """Return whether the nvx-rs provenance `provenance` describes a build of + this checkout's nvx-rs sources with its features and Rust release, for + `target` if given, whatever executable it names.""" + recorded_target = provenance.get("target") + return ( + provenance.get("format") == nvx_rs.PROVENANCE_FORMAT + and provenance.get("source_sha256") + == nvx_rs.source_sha256(BuildConstants.REPO_ROOT) + and provenance.get("toolchain") == pinned_guest_rust_toolchain() + and provenance.get("features") == list(nvx_rs.FEATURES) + and isinstance(recorded_target, str) + and (target is None or recorded_target == target) + ) + + +def _validate_nvx_rs_provenance( + executable: Path, + provenance_path: Path, + *, + target: str | None, +) -> dict[str, object]: + """Check that the nvx-rs provenance at `provenance_path` describes the + executable at `executable`, built with nvx-rs's features and this + checkout's Rust release from its nvx-rs sources, for `target` if given, + and return it.""" + provenance = _read_json_object(provenance_path, "nvx-rs build provenance") + if not _describes_checkout_nvx_rs(provenance, target=target) or provenance.get( + "executable_sha256" + ) != sha256_file(executable): + build = "a build" if target is None else f"a {target} build" + raise ScriptError( + f"nvx-rs build provenance does not describe {executable} as {build} " + "from the current nvx-rs sources; build nvx-rs from them, and record " + "its provenance with `nvx.py record-nvx-rs-provenance`" + ) + return provenance + + def validate_runtime_artifact_provenance() -> None: binary = require_file(openvmm_binary_path(), "OpenVMM release binary") kernel = require_file( @@ -1124,12 +1189,29 @@ def validate_runtime_artifact_provenance() -> None: ) +def _require_release_nvx_rs() -> tuple[Path, Path, dict[str, object]]: + """Return the nvx-rs that a package ships, its provenance path, and its + provenance, which must show a build for the release target of this + operating system: on Linux, the static musl executable that runs on every + host, which a build for the host's own target is not.""" + executable = require_file(nvx_rs.binary_path(), "nvx-rs release executable") + provenance_path = require_file( + artifact_path(nvx_rs.PROVENANCE_NAME), "nvx-rs build provenance" + ) + target = nvx_rs.release_target() + provenance = _validate_nvx_rs_provenance(executable, provenance_path, target=target) + nvx_rs.check_target(executable, target) + return executable, provenance_path, provenance + + def _packaged_source_manifest( root_manifest: dict[str, object], release_version: str, release_root: Path, openvmm_provenance: dict[str, object], binary_name: str, + nvx_rs_provenance: dict[str, object], + nvx_rs_name: str, *, include_azurelinux: bool, ) -> bytes: @@ -1178,6 +1260,13 @@ def _packaged_source_manifest( ubuntu_section["distro_layer_manifest_sha256"] = sha256_file( release_root / "guest" / UbuntuBuildConstants.DISTRO_MANIFEST_NAME ) + root_manifest["nvx_rs"] = { + "executable_sha256": sha256_file(release_root / "bin" / nvx_rs_name), + **{ + field: nvx_rs_provenance[field] + for field in ("source_sha256", "toolchain", "target", "features") + }, + } if include_azurelinux: azurelinux_section = cast(dict[str, object], azurelinux) azurelinux_section["initramfs_sha256"] = sha256_file( @@ -1528,6 +1617,7 @@ def package_release( ).resolve() _validate_release_replacement(release_destination, force) binary = require_file(openvmm_binary_path(), "OpenVMM release binary") + nvx_rs_binary, nvx_rs_provenance_path, nvx_rs_provenance = _require_release_nvx_rs() kernel = require_file( artifact_path(KernelBuildConstants.BINARY_NAME), "required guest artifact vmlinux", @@ -1586,6 +1676,7 @@ def package_release( preserve_staging = False try: _copy_release_file(binary, staging / "bin" / binary.name) + _copy_release_file(nvx_rs_binary, staging / "bin" / nvx_rs_binary.name) for name in guest_names: _copy_release_file(artifact_path(name), staging / "guest" / name) _copy_release_file( @@ -1600,6 +1691,10 @@ def package_release( initramfs_provenance_path, staging / "provenance" / InitramfsBuildConstants.PROVENANCE_NAME, ) + _copy_release_file( + nvx_rs_provenance_path, + staging / "provenance" / nvx_rs.PROVENANCE_NAME, + ) for name in ("LICENSE", "README.md", "THIRD_PARTY_NOTICES.md"): _copy_release_file(BuildConstants.REPO_ROOT / name, staging / name) _copy_release_file( @@ -1652,6 +1747,13 @@ def package_release( raise ScriptError( "packaged OpenVMM executable does not match its build provenance" ) + if ( + sha256_file(staging / "bin" / nvx_rs_binary.name) + != nvx_rs_provenance["executable_sha256"] + ): + raise ScriptError( + "packaged nvx-rs executable does not match its build provenance" + ) if sha256_file(packaged_kernel) != kernel_provenance["kernel_sha256"]: raise ScriptError("packaged kernel does not match its build provenance") if sha256_file(packaged_config) != kernel_provenance["config_sha256"]: @@ -1690,6 +1792,14 @@ def package_release( != initramfs_provenance ): raise ScriptError("packaged initramfs provenance changed while staging") + if ( + _read_json_object( + staging / "provenance" / nvx_rs.PROVENANCE_NAME, + "packaged nvx-rs build provenance", + ) + != nvx_rs_provenance + ): + raise ScriptError("packaged nvx-rs provenance changed while staging") (staging / "SOURCE-MANIFEST.json").write_bytes( _packaged_source_manifest( root_manifest, @@ -1697,6 +1807,8 @@ def package_release( staging, openvmm_provenance, binary.name, + nvx_rs_provenance, + nvx_rs_binary.name, include_azurelinux=not include_source, ) ) diff --git a/scripts/publish_development_release.py b/scripts/publish_development_release.py index d6bde64d..d143b9d1 100644 --- a/scripts/publish_development_release.py +++ b/scripts/publish_development_release.py @@ -13,6 +13,14 @@ ) from nvx_tools.common import ScriptError, require_file from nvx_tools.development_release import publish_development_release +from nvx_tools.release import require_release_package_executables + +# The release platforms and the archive formats of their packages. +PLATFORMS = ( + ("linux-kvm", ".tar.gz"), + ("linux-mshv", ".tar.gz"), + ("windows-whp", ".zip"), +) def _required_environment(name: str) -> str: @@ -22,22 +30,37 @@ def _required_environment(name: str) -> str: return value +def release_packages(directory: Path, version: str) -> dict[str, Path]: + """Return the package archive of each release platform in `directory`.""" + return { + platform: directory / f"nvx-{version}-{platform}{extension}" + for platform, extension in PLATFORMS + } + + +def check_release_packages(packages: dict[str, Path]) -> None: + """Fail unless every package ships the executables of its platform, + OpenVMM and nvx-rs, which `nvx.py download` installs.""" + for platform, package in packages.items(): + require_file(package, f"development release package {package.name}") + require_release_package_executables(package, platform) + + def main() -> None: version_path = require_file(BuildConstants.REPO_ROOT / "VERSION", "NVX version") try: version = version_path.read_text(encoding="ascii").strip() except OSError as error: raise ScriptError(f"failed to read {version_path}: {error}") from error - packages = ( - Path(ReleaseBuildConstants.DIRECTORY_NAME) / f"nvx-{version}-linux-kvm.tar.gz", - Path(ReleaseBuildConstants.DIRECTORY_NAME) / f"nvx-{version}-linux-mshv.tar.gz", - Path(ReleaseBuildConstants.DIRECTORY_NAME) / f"nvx-{version}-windows-whp.zip", - ) + repository = _required_environment("GH_REPO") + target_sha = _required_environment("GITHUB_SHA") + packages = release_packages(Path(ReleaseBuildConstants.DIRECTORY_NAME), version) + check_release_packages(packages) publish_development_release( - _required_environment("GH_REPO"), - _required_environment("GITHUB_SHA"), + repository, + target_sha, version, - packages, + tuple(packages.values()), ) diff --git a/scripts/test_development_release.py b/scripts/test_development_release.py index 1651dee9..8ea760e5 100644 --- a/scripts/test_development_release.py +++ b/scripts/test_development_release.py @@ -2,14 +2,22 @@ # pyright: reportPrivateUsage=false import io +import os +import re import sys +import tarfile import tempfile import unittest +import zipfile +from collections.abc import Mapping from pathlib import Path from unittest.mock import call, patch sys.path.insert(0, str(Path(__file__).parent)) +import publish_development_release # noqa: E402 from nvx_tools import development_release # noqa: E402 +from nvx_tools.build_constants import BuildConstants # noqa: E402 +from nvx_tools.common import ScriptError # noqa: E402 class DevelopmentReleaseTests(unittest.TestCase): @@ -478,5 +486,124 @@ def test_new_release_is_created_as_draft_without_assets(self): self.assertFalse(any(item.endswith((".zip", ".tar.gz")) for item in command)) +def _write_package(path: Path, members: Mapping[str, bytes | None]) -> None: + """Write a release archive whose package root holds `members`, files with + their contents, or directories where the contents are None.""" + path.parent.mkdir(parents=True, exist_ok=True) + root = path.name.removesuffix(".tar.gz").removesuffix(".zip") + if path.name.endswith(".tar.gz"): + with tarfile.open(path, "w:gz") as archive: + for name, data in members.items(): + info = tarfile.TarInfo(f"{root}/{name}") + if data is None: + info.type = tarfile.DIRTYPE + archive.addfile(info) + continue + info.size = len(data) + archive.addfile(info, io.BytesIO(data)) + return + with zipfile.ZipFile(path, "w") as archive: + for name, data in members.items(): + archive.writestr( + f"{root}/{name}" + ("/" if data is None else ""), data or b"" + ) + + +class DevelopmentReleasePackageTests(unittest.TestCase): + """The packages that publication checks: each ships OpenVMM and nvx-rs.""" + + def _packages(self, directory: Path) -> dict[str, Path]: + packages = publish_development_release.release_packages(directory, "1.2.3") + for platform, package in packages.items(): + suffix = ".exe" if platform.startswith("windows-") else "" + _write_package( + package, + { + "SHA256SUMS": b"sums", + f"bin/openvmm{suffix}": b"openvmm", + f"bin/nvx-rs{suffix}": b"nvx-rs", + }, + ) + return packages + + def test_packages_are_named_after_their_platforms(self): + self.assertEqual( + publish_development_release.release_packages(Path("dist"), "1.2.3"), + { + "linux-kvm": Path("dist") / "nvx-1.2.3-linux-kvm.tar.gz", + "linux-mshv": Path("dist") / "nvx-1.2.3-linux-mshv.tar.gz", + "windows-whp": Path("dist") / "nvx-1.2.3-windows-whp.zip", + }, + ) + + def test_every_package_must_ship_openvmm_and_nvx_rs(self): + with tempfile.TemporaryDirectory() as temporary: + packages = self._packages(Path(temporary)) + publish_development_release.check_release_packages(packages) + for platform, members, missing in ( + ("linux-kvm", {"bin/openvmm": b"openvmm"}, "bin/nvx-rs"), + ("linux-mshv", {"bin/nvx-rs": b"nvx-rs"}, "bin/openvmm"), + ( + "linux-kvm", + {"bin/openvmm": b"openvmm", "bin/nvx-rs": None}, + "bin/nvx-rs", + ), + # Windows packages ship Windows executables. + ( + "windows-whp", + {"bin/openvmm.exe": b"openvmm", "bin/nvx-rs": b"nvx-rs"}, + "bin/nvx-rs.exe", + ), + ): + with self.subTest(platform=platform, members=sorted(members)): + package = packages[platform] + package.unlink() + _write_package(package, members) + with self.assertRaisesRegex( + ScriptError, + f"^{re.escape(package.name)} does not ship {missing}$", + ): + publish_development_release.check_release_packages(packages) + self._packages(Path(temporary)) + packages["linux-mshv"].unlink() + with self.assertRaisesRegex( + ScriptError, "development release package nvx-1.2.3-linux-mshv.tar.gz" + ): + publish_development_release.check_release_packages(packages) + + def test_publication_checks_the_packages_before_it_publishes(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "VERSION").write_text("1.2.3\n", encoding="ascii") + packages = self._packages(root / "dist") + environment = {"GH_REPO": "example/nvx", "GITHUB_SHA": "a" * 40} + previous = os.getcwd() + os.chdir(root) + try: + with ( + patch.dict(os.environ, environment), + patch.object(BuildConstants, "REPO_ROOT", root), + patch.object( + publish_development_release, "publish_development_release" + ) as publish, + ): + publish_development_release.main() + publish.assert_called_once_with( + "example/nvx", + "a" * 40, + "1.2.3", + tuple( + Path("dist") / package.name for package in packages.values() + ), + ) + publish.reset_mock() + _write_package(packages["windows-whp"], {"bin/openvmm.exe": b""}) + with self.assertRaisesRegex(ScriptError, "bin/nvx-rs.exe"): + publish_development_release.main() + publish.assert_not_called() + finally: + os.chdir(previous) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/test_nvx_tools.py b/scripts/test_nvx_tools.py index 7ed73f90..8ada7545 100644 --- a/scripts/test_nvx_tools.py +++ b/scripts/test_nvx_tools.py @@ -252,6 +252,15 @@ def _write_release_fixture( ): path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(path.name.encode("ascii")) + # The release build of nvx-rs, static as the Linux packages require, and + # its provenance. + nvx_rs_binary = root / "nvx-rs" / "target" / "release" / nvx_rs.binary_path().name + nvx_rs_binary.parent.mkdir(parents=True, exist_ok=True) + nvx_rs_binary.write_bytes(_elf_file()) + (build_dir / nvx_rs.PROVENANCE_NAME).write_text( + json.dumps(nvx_rs.provenance_record(nvx_rs_binary, nvx_rs.release_target())), + encoding="utf-8", + ) for artifact_name, manifest_name in ( ( "initramfs-ubuntu.cpio.gz", @@ -455,6 +464,7 @@ def _write_release_fixture( "source": source_dir, "openvmm": openvmm_dir, "binary": binary, + "nvx_rs": nvx_rs_binary, } return paths, kernel_inputs, revision @@ -3577,6 +3587,31 @@ def test_crate_checks_run_on_every_os(self): self.assertIn(f"uses: ./.github/actions/{action}", definition) self.assertEqual(ci.REQUIRED_CI_RESULT_ENVIRONMENTS["nvx-rs"], "NVX_RS_RESULT") + def test_nvx_rs_bundles_a_release_in_the_current_layout(self): + # The bundled feature stages what a release holds besides nvx-rs, so + # its synthetic release ships nvx-rs and its provenance, as releases + # do, and the pinned older release, which doesn't, stages as well. + action = ( + BuildConstants.REPO_ROOT + / ".github" + / "actions" + / "check-nvx-rs" + / "action.yml" + ).read_text(encoding="utf-8") + script = _composite_action_script(action, "Check artifact bundling") + for member in ( + "bin/${executable}", + "bin/${cli}", + "guest/vmlinux", + "guest/initramfs.cpio.gz", + f"provenance/{nvx_rs.PROVENANCE_NAME}", + ): + with self.subTest(member=member): + self.assertIn(f'"${{bundle}}/{member}"', script) + self.assertIn(member, script.split("sha256sum", 1)[1]) + self.assertIn("cli=nvx-rs.exe", script) + self.assertIn("--features bundled artifacts", script) + def test_nvx_rs_builds_and_checks_every_guest_binary_for_musl(self): crate = BuildConstants.REPO_ROOT / "nvx-rs" action = ( @@ -5255,46 +5290,83 @@ def test_ci_validates_the_cli_once_per_os_after_checking_out_openvmm(self): f" needs.{cli}.result == 'success' &&\n", job ) - def test_ci_hands_nvx_rs_to_the_microvm_jobs(self): + def test_ci_hands_nvx_rs_to_the_microvm_and_platform_jobs(self): # nvx.py runs nvx-rs for its sandbox command and for run's egress and # artifact steps, and finds it where `cargo build --release` puts it. - # Each NVX CLI job builds it for its OS, tests with it, and hands it to - # the microVM jobs, as the OpenVMM build jobs hand over OpenVMM. - workflows = BuildConstants.REPO_ROOT / ".github" / "workflows" - workflow = (workflows / "ci.yml").read_text(encoding="utf-8") - microvm = (workflows / "run-nvx-microvm-tests.yml").read_text(encoding="utf-8") - executables = { + # Each NVX CLI job builds it for its OS, records its provenance, tests + # with it, and hands both to the microVM jobs, which run it, and to the + # platform jobs, which package it, as the OpenVMM build jobs hand + # over OpenVMM. + github = BuildConstants.REPO_ROOT / ".github" + workflow = (github / "workflows" / "ci.yml").read_text(encoding="utf-8") + microvm = (github / "workflows" / "run-nvx-microvm-tests.yml").read_text( + encoding="utf-8" + ) + platform = (github / "workflows" / "run-platform.yml").read_text( + encoding="utf-8" + ) + package = (github / "actions" / "package-release" / "action.yml").read_text( + encoding="utf-8" + ) + builds = { "nvx-cli-linux": ( - "nvx-rs-linux-musl-executable", + "nvx-rs-linux-musl", "nvx-rs/target/release/nvx-rs", + "x86_64-unknown-linux-musl", ), "nvx-cli-windows": ( - "nvx-rs-windows-msvc-executable", + "nvx-rs-windows-msvc", "nvx-rs/target/release/nvx-rs.exe", + "x86_64-pc-windows-msvc", ), } + current = builds["nvx-cli-windows" if os.name == "nt" else "nvx-cli-linux"] self.assertEqual( nvx_rs.binary_path().relative_to(BuildConstants.REPO_ROOT).as_posix(), - executables["nvx-cli-windows" if os.name == "nt" else "nvx-cli-linux"][1], + current[1], + ) + # Packages ship the build for the release target of their OS. + self.assertEqual(nvx_rs.release_target(), current[2]) + self.assertEqual( + set(nvx_rs.RELEASE_TARGETS.values()), + {target for _, _, target in builds.values()}, ) - for job_name, (artifact, path) in executables.items(): + for job_name, (artifact, path, target) in builds.items(): with self.subTest(job=job_name): job = _workflow_job(workflow, job_name) build = _workflow_step(workflow, job_name, "Build nvx-rs") + record = _workflow_step(workflow, job_name, "Record nvx-rs provenance") upload = _workflow_step(workflow, job_name, "Upload nvx-rs executable") + provenance = _workflow_step( + workflow, job_name, "Upload nvx-rs provenance" + ) for option in ("--release", "--locked", "--features cli"): self.assertIn(option, build) self.assertIn("--manifest-path nvx-rs/Cargo.toml", build) - self.assertIn(f"name: {artifact}\n", upload) + self.assertIn( + f"record-nvx-rs-provenance --target {target}", + _yaml_scalar(record, "run"), + ) + self.assertIn(f"name: {artifact}-executable\n", upload) self.assertIn(f"path: {path}\n", upload) - self.assertIn("if-no-files-found: error", upload) - # The CLI tests run with the build, and only a validated build - # leaves the job. + self.assertIn(f"name: {artifact}-provenance\n", provenance) + self.assertIn(f"path: build/{nvx_rs.PROVENANCE_NAME}\n", provenance) + for step in (upload, provenance): + self.assertIn("if-no-files-found: error", step) + # The build is recorded as it is built, the CLI tests run with + # it, and only a validated build leaves the job. validate = job.index("uses: ./.github/actions/validate-nvx") - self.assertLess(job.index("- name: Build nvx-rs"), validate) + self.assertLess( + job.index("- name: Build nvx-rs"), + job.index("- name: Record nvx-rs provenance"), + ) + self.assertLess(job.index("- name: Record nvx-rs provenance"), validate) self.assertGreater( job.index("- name: Upload nvx-rs executable"), validate ) + self.assertGreater( + job.index("- name: Upload nvx-rs provenance"), validate + ) # Self-contained executables run on every self-hosted runner. linux = _workflow_step(workflow, "nvx-cli-linux", "Build nvx-rs") self.assertEqual(linux.count("x86_64-unknown-linux-musl"), 3) @@ -5327,34 +5399,36 @@ def test_ci_hands_nvx_rs_to_the_microvm_jobs(self): *ci.REQUIRED_CI_MICROVM_TEST_JOBS, *ci.REQUIRED_CI_MICROVM_DEBUG_JOBS, *ci.REQUIRED_CI_MICROVM_DEBUG_PUSH_JOBS, + *ci.REQUIRED_CI_PLATFORM_JOBS, ): cli = "nvx-cli-windows" if job_name.endswith("-whp") else "nvx-cli-linux" with self.subTest(consumer=job_name): job = _workflow_job(workflow, job_name) self.assertIn( - "uses: ./.github/workflows/run-nvx-microvm-tests.yml", job + "uses: ./.github/workflows/run-platform.yml" + if job_name in ci.REQUIRED_CI_PLATFORM_JOBS + else "uses: ./.github/workflows/run-nvx-microvm-tests.yml", + job, ) self.assertEqual( re.findall(r"^ nvx-rs-artifact: (\S+)$", job, re.MULTILINE), - [executables[cli][0]], + [builds[cli][0]], ) - for job_name in ci.REQUIRED_CI_PLATFORM_JOBS: - with self.subTest(consumer=job_name): - self.assertNotIn("nvx-rs", _workflow_job(workflow, job_name)) - self.assertIn( - " nvx-rs-artifact:\n" - " description: Name of the nvx-rs executable artifact for the " - "runner's OS\n" - " required: true\n" - " type: string\n", - microvm, - ) + for configuration in (microvm, platform): + self.assertIn( + " nvx-rs-artifact:\n" + " description: Base name of the nvx-rs artifacts for the " + "runner's OS\n" + " required: true\n" + " type: string\n", + configuration, + ) download = microvm.index(" - name: Download nvx-rs executable\n") self.assertIn( " uses: actions/download-artifact@v8\n" " with:\n" - " name: ${{ inputs.nvx-rs-artifact }}\n" + " name: ${{ inputs.nvx-rs-artifact }}-executable\n" " path: nvx-rs/target/release\n", microvm[download:], ) @@ -5368,6 +5442,33 @@ def test_ci_hands_nvx_rs_to_the_microvm_jobs(self): self.assertLess(download, chmod) self.assertLess(chmod, microvm.index("scripts/nvx.py")) + # The platform jobs package nvx-rs with its provenance: they download + # both where nvx.py package looks for them, before they package. + self.assertIn( + " nvx-rs-artifact: ${{ inputs.nvx-rs-artifact }}", + _workflow_step(platform, "run", "Package release").splitlines(), + ) + steps = _composite_action_steps(package) + names = [_yaml_scalar(step, "name") for step in steps] + executable = steps[names.index("Download nvx-rs executable")] + provenance = steps[names.index("Download nvx-rs provenance")] + self.assertIn("name: ${{ inputs.nvx-rs-artifact }}-executable\n", executable) + self.assertIn("path: nvx-rs/target/release\n", executable) + self.assertIn("name: ${{ inputs.nvx-rs-artifact }}-provenance\n", provenance) + self.assertIn("path: build\n", provenance) + self.assertIn( + "run: chmod +x nvx-rs/target/release/nvx-rs", + steps[names.index("Make nvx-rs executable")], + ) + for name in ("Package Linux release", "Package Windows release"): + self.assertLess( + names.index("Download nvx-rs executable"), names.index(name) + ) + self.assertLess( + names.index("Download nvx-rs provenance"), names.index(name) + ) + self.assertLess(names.index("Make nvx-rs executable"), names.index(name)) + def test_ci_preserves_failed_openvmm_test_diagnostics(self): workflow = ( BuildConstants.REPO_ROOT / ".github" / "workflows" / "ci.yml" @@ -17110,6 +17211,7 @@ def artifact_path(name: str) -> Path: side_effect=artifact_path, ), patch.object(release, "openvmm_binary_path", return_value=binary), + patch.object(nvx_rs, "binary_path", return_value=paths["nvx_rs"]), patch.object( release, "kernel_provenance_inputs", @@ -17130,6 +17232,14 @@ def artifact_path(name: str) -> Path: ) self.assertTrue((destination / "bin" / binary.name).is_file()) + # The release build of nvx-rs ships beside OpenVMM, under the name + # that nvx.py download installs on this OS. + packaged_nvx_rs = destination / "bin" / nvx_rs.binary_path().name + self.assertEqual(packaged_nvx_rs.read_bytes(), paths["nvx_rs"].read_bytes()) + self.assertEqual( + sorted(path.name for path in (destination / "bin").iterdir()), + sorted((binary.name, packaged_nvx_rs.name)), + ) for name in ReleaseBuildConstants.GUEST_ARTIFACT_NAMES: self.assertTrue((destination / "guest" / name).is_file()) self.assertTrue( @@ -17191,7 +17301,44 @@ def artifact_path(name: str) -> Path: / AzureLinuxBuildConstants.PACKAGE_MANIFEST_NAME ), ) - common.verify_sha256_sums(destination) + # The package ships the provenance of nvx-rs, which binds it to the + # sources that build it, the Rust release, the target, and the + # features, and the manifest records them with its digest. + packaged_provenance = json.loads( + (destination / "provenance" / nvx_rs.PROVENANCE_NAME).read_text( + encoding="utf-8" + ) + ) + self.assertEqual( + packaged_provenance, + { + "format": 1, + "source_sha256": nvx_rs.source_sha256(root), + "toolchain": GuestRustBuildConstants.TOOLCHAIN, + "target": nvx_rs.release_target(), + "features": ["cli"], + "executable_sha256": common.sha256_file(packaged_nvx_rs), + }, + ) + self.assertEqual( + packaged_provenance["source_sha256"], nvx_rs.source_sha256() + ) + self.assertEqual( + manifest["nvx_rs"], + { + "executable_sha256": common.sha256_file(packaged_nvx_rs), + "features": ["cli"], + "source_sha256": nvx_rs.source_sha256(root), + "target": nvx_rs.release_target(), + "toolchain": GuestRustBuildConstants.TOOLCHAIN, + }, + ) + inventory = dict(common.verify_sha256_sums(destination).files) + self.assertEqual( + inventory[f"bin/{packaged_nvx_rs.name}"], + common.sha256_file(paths["nvx_rs"]), + ) + self.assertIn(f"provenance/{nvx_rs.PROVENANCE_NAME}", inventory) self.assertIn("binary-only package", stderr.getvalue()) def test_source_package_omits_azure_linux_artifacts(self): @@ -17253,6 +17400,7 @@ def write_source_archive(output: Path, *_args: object) -> None: side_effect=artifact_path, ), patch.object(release, "openvmm_binary_path", return_value=binary), + patch.object(nvx_rs, "binary_path", return_value=paths["nvx_rs"]), patch.object( release, "kernel_provenance_inputs", @@ -17304,9 +17452,94 @@ def write_source_archive(output: Path, *_args: object) -> None: (destination / "SOURCE-MANIFEST.json").read_text(encoding="utf-8") ) self.assertNotIn("azurelinux", manifest) + # Source-inclusive packages ship nvx-rs too; the project source + # archive holds the sources that build it. + packaged_nvx_rs = destination / "bin" / paths["nvx_rs"].name + self.assertEqual(packaged_nvx_rs.read_bytes(), paths["nvx_rs"].read_bytes()) + self.assertEqual( + manifest["nvx_rs"]["executable_sha256"], + common.sha256_file(packaged_nvx_rs), + ) common.verify_sha256_sums(destination) self.assertIn("omits the Azure Linux guest", stderr.getvalue()) + def test_packages_require_the_release_build_of_nvx_rs(self): + # A package ships the nvx-rs build for the release target of its + # operating system, with the provenance that binds it to the current + # sources. On Linux, that build is the static musl executable that + # runs on every host, which a build for the host's own target is not. + linux = nvx_rs.RELEASE_TARGETS["linux"] + windows = nvx_rs.RELEASE_TARGETS["windows"] + dynamic = _elf_file( + ((1, b"code"), (3, b"/lib64/ld-linux-x86-64.so.2\0")), elf_type=3 + ) + mismatch = "from the current nvx-rs sources" + with tempfile.TemporaryDirectory() as temporary: + build_dir = Path(temporary) / "build" + build_dir.mkdir() + executable = Path(temporary) / "nvx-rs" + provenance = build_dir / nvx_rs.PROVENANCE_NAME + cases: tuple[ + tuple[str, bytes | None, dict[str, object] | None, str | None], ... + ] = ( + (linux, _elf_file(), {}, None), + (linux, _elf_file(elf_type=3), {}, None), + (windows, b"MZ", {}, None), + (linux, dynamic, {}, "is not static"), + (linux, b"MZ", {}, f"invalid {linux} nvx-rs executable"), + (linux, _elf_file(), {"target": windows}, f"as a {linux} build"), + (windows, b"MZ", {"target": linux}, f"as a {windows} build"), + (linux, _elf_file(), {"source_sha256": "0" * 64}, mismatch), + (linux, _elf_file(), {"executable_sha256": "0" * 64}, mismatch), + (linux, _elf_file(), {"toolchain": "1.0.0"}, mismatch), + (linux, _elf_file(), {"features": []}, mismatch), + (linux, _elf_file(), {"format": 2}, mismatch), + (linux, _elf_file(), None, "nvx-rs build provenance not found"), + (linux, None, {}, "nvx-rs release executable not found"), + ) + for target, contents, changes, error in cases: + with self.subTest(target=target, changes=changes, error=error): + executable.unlink(missing_ok=True) + provenance.unlink(missing_ok=True) + if contents is not None: + executable.write_bytes(contents) + if contents is not None and changes is not None: + record = nvx_rs.provenance_record(executable, target) + record.update(changes) + provenance.write_text(json.dumps(record), encoding="utf-8") + with ( + patch.object(nvx_rs, "binary_path", return_value=executable), + patch.object(nvx_rs, "release_target", return_value=target), + patch.object( + release, "artifact_path", side_effect=build_dir.joinpath + ), + ): + if error is None: + self.assertEqual( + release._require_release_nvx_rs(), + ( + executable, + provenance, + json.loads(provenance.read_text(encoding="utf-8")), + ), + ) + continue + with self.assertRaisesRegex( + common.ScriptError, re.escape(error) + ) as raised: + release._require_release_nvx_rs() + if error == "is not static": + # The message names the build that packages need. + self.assertIn( + f"--features cli --manifest-path nvx-rs/Cargo.toml " + f"--target {linux}", + str(raised.exception), + ) + if error == mismatch: + self.assertIn( + "nvx.py record-nvx-rs-provenance", str(raised.exception) + ) + def test_runtime_provenance_validation_uses_required_paths(self): build_dir = Path("build") binary = Path("openvmm") @@ -17676,6 +17909,7 @@ def artifact_path( "openvmm_binary_path", return_value=paths["binary"], ), + patch.object(nvx_rs, "binary_path", return_value=paths["nvx_rs"]), patch.object( release, "kernel_provenance_inputs", @@ -17705,6 +17939,14 @@ def artifact_path( ) def test_failed_staged_verification_preserves_existing_release(self): + for corrupted, error in ( + ("binary", "packaged OpenVMM executable"), + ("nvx_rs", "packaged nvx-rs executable does not match its build"), + ): + with self.subTest(corrupted=corrupted): + self._check_failed_staged_verification(corrupted, error) + + def _check_failed_staged_verification(self, corrupted: str, error: str) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) paths, kernel_inputs, revision = _write_release_fixture(root) @@ -17721,7 +17963,7 @@ def artifact_path(name: str) -> Path: def corrupt_packaged_binary(source: Path, target: Path) -> None: original_copy(source, target) - if target.name == paths["binary"].name: + if target.name == paths[corrupted].name: target.write_bytes(b"corrupt") with ( @@ -17739,6 +17981,7 @@ def corrupt_packaged_binary(source: Path, target: Path) -> None: "openvmm_binary_path", return_value=paths["binary"], ), + patch.object(nvx_rs, "binary_path", return_value=paths["nvx_rs"]), patch.object( release, "kernel_provenance_inputs", @@ -17755,10 +17998,7 @@ def corrupt_packaged_binary(source: Path, target: Path) -> None: side_effect=corrupt_packaged_binary, ), patch("sys.stderr", io.StringIO()), - self.assertRaisesRegex( - common.ScriptError, - "packaged OpenVMM executable", - ), + self.assertRaisesRegex(common.ScriptError, error), ): release.package_release( version="1.0.0", @@ -18367,6 +18607,45 @@ def test_release_archives_are_reproducible_with_fixed_layout_and_modes(self): ) ) + def test_release_archives_ship_both_executables_executable(self): + # Every package ships OpenVMM and nvx-rs in bin/, executable whatever + # mode the staging host stored, and publication finds both. + for suffix, platform, extension in ( + (".tar.gz", "linux-kvm", ""), + (".zip", "windows-whp", ".exe"), + ): + with ( + self.subTest(suffix=suffix), + tempfile.TemporaryDirectory() as temporary, + ): + root = Path(temporary) + source = root / f"nvx-1.0.0-{platform}" + for name in (f"openvmm{extension}", f"nvx-rs{extension}"): + executable = source / "bin" / name + executable.parent.mkdir(parents=True, exist_ok=True) + executable.write_bytes(name.encode("ascii")) + executable.chmod(0o644) + common.write_sha256_sums(source) + package = root / f"nvx-1.0.0-{platform}{suffix}" + release.create_release_archive(source, package) + + if suffix == ".tar.gz": + with tarfile.open(package, "r:gz") as archive_file: + modes = { + member.name: member.mode + for member in archive_file.getmembers() + } + else: + with zipfile.ZipFile(package) as archive_file: + modes = { + member.filename: (member.external_attr >> 16) & 0o777 + for member in archive_file.infolist() + } + for path in release.release_package_executables(platform): + self.assertEqual(modes[f"{source.name}/{path}"], 0o755) + self.assertEqual(modes[f"{source.name}/SHA256SUMS"], 0o644) + release.require_release_package_executables(package, platform) + def test_release_archive_rejects_internally_inconsistent_staging(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) From 92a699e85e78369bfc1b052d87b002b5d79611f8 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Sat, 17 Oct 2026 08:01:18 -0300 Subject: [PATCH 3/8] notices: cover the nvx-rs binary Packages now ship bin/nvx-rs[.exe], which statically links the Rust standard library, the crates of the nvx-rs cli feature, and a C runtime, so THIRD_PARTY_NOTICES.md, which every package ships, must cover them, as it has covered the Rust guest binaries since #561. The crate rows follow `cargo tree --locked --features cli -e normal` in nvx-rs for both release targets, x86_64-unknown-linux-musl and x86_64-pc-windows-msvc: 26 crates, with the licenses that `cargo metadata` reports. - The rows of the crates that the guest binaries already link, the Rust standard library, compiler_builtins, libc, nix, getrandom, bitflags, cfg-if, and memoffset, now name bin/nvx-rs too. - New rows cover serde, serde_core, serde_json and its itoa, memchr, and zmij, sha2 and its RustCrypto dependencies, and windows-sys and windows-link on Windows. serde_derive and the proc-macro2, quote, syn, and unicode-ident crates that it runs on are marked as build time only, not linked. - The musl row now includes the bin/nvx-rs of the linux-kvm and linux-mshv packages, under the musl terms that it already documents. - A new LLVM libunwind row covers the unwinder that the Rust musl target links into the guest binaries and the Linux nvx-rs, under Apache-2.0 WITH LLVM-exception, which lifts the attribution conditions for compiled code. - A new Microsoft C runtime row covers the static CRT of bin/nvx-rs.exe, under the Visual Studio redistribution terms. doc/distribution.md says the same in its licensing section. A test parses nvx-rs/Cargo.lock, requires each of its crates to be classified as shipped or not, and checks that every shipped crate has a row with its exact version that names bin/nvx-rs, and that the runtime rows name it too. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- THIRD_PARTY_NOTICES.md | 40 ++++++++++++++---- doc/distribution.md | 6 +++ scripts/test_nvx_tools.py | 87 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 9 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 43e15f92..330e723d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -9,15 +9,37 @@ 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`, `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/). | +| Rust core and standard libraries | linked into all nine Rust guest binaries listed in the musl row, and into `bin/nvx-rs`, or `bin/nvx-rs.exe`, of every release package | 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 and `bin/nvx-rs[.exe]` | 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 and the Linux `bin/nvx-rs` through `nix` and `getrandom` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are at [`rust-lang/libc` tag 0.2.189](https://github.com/rust-lang/libc/tree/0.2.189). | +| Rust `nix` crate 0.31.3 | system-call wrappers used by the guest binaries and the Linux `bin/nvx-rs` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`nix-rust/nix`](https://crates.io/crates/nix/0.31.3). | +| Rust `getrandom` crate 0.4.3 | random bytes for the guest binaries, such as `nvx-reseed --sample`, and for `bin/nvx-rs[.exe]`, such as control capabilities | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`rust-random/getrandom`](https://crates.io/crates/getrandom/0.4.3). | +| Rust `bitflags` crate 2.13.2 | transitive `nix` dependency in the guest binaries and the Linux `bin/nvx-rs` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`bitflags/bitflags`](https://crates.io/crates/bitflags/2.13.2). | +| Rust `cfg-if` crate 1.0.5 | transitive `nix`, `getrandom`, and `sha2` dependency in the guest binaries and `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`rust-lang/cfg-if`](https://crates.io/crates/cfg-if/1.0.5). | +| Rust `memoffset` crate 0.9.1 | transitive `nix` dependency in the guest binaries and the Linux `bin/nvx-rs` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`Gilnaa/memoffset`](https://crates.io/crates/memoffset/0.9.1). | +| Rust `serde` crate 1.0.229 | serialization framework of `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`serde-rs/serde`](https://crates.io/crates/serde/1.0.229). | +| Rust `serde_core` crate 1.0.229 | transitive `serde` and `serde_json` dependency in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`serde-rs/serde`](https://crates.io/crates/serde_core/1.0.229). | +| Rust `serde_derive` crate 1.0.229 | procedural macro that generates the `serde` derives of `nvx-rs` while it builds; the code that it generates, not the crate, is in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`serde-rs/serde`](https://crates.io/crates/serde_derive/1.0.229). | +| Rust `proc-macro2` crate 1.0.107 | build-time `serde_derive` dependency, not linked into `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`dtolnay/proc-macro2`](https://crates.io/crates/proc-macro2/1.0.107). | +| Rust `quote` crate 1.0.47 | build-time `serde_derive` dependency, not linked into `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`dtolnay/quote`](https://crates.io/crates/quote/1.0.47). | +| Rust `syn` crate 3.0.6 | build-time `serde_derive` dependency, not linked into `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`dtolnay/syn`](https://crates.io/crates/syn/3.0.6). | +| Rust `unicode-ident` crate 1.0.26 | build-time `proc-macro2` and `syn` dependency, not linked into `bin/nvx-rs[.exe]` | (MIT OR Apache-2.0) AND Unicode-3.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`dtolnay/unicode-ident`](https://crates.io/crates/unicode-ident/1.0.26). | +| Rust `serde_json` crate 1.0.151 | JSON reading and writing in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`serde-rs/json`](https://crates.io/crates/serde_json/1.0.151). | +| Rust `itoa` crate 1.0.18 | integer formatting for `serde_json` in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`dtolnay/itoa`](https://crates.io/crates/itoa/1.0.18). | +| Rust `memchr` crate 2.8.3 | byte search for `serde_json` in `bin/nvx-rs[.exe]` | Unlicense OR MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`BurntSushi/memchr`](https://crates.io/crates/memchr/2.8.3). | +| Rust `zmij` crate 1.0.23 | floating-point formatting for `serde_json` in `bin/nvx-rs[.exe]` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`dtolnay/zmij`](https://crates.io/crates/zmij/1.0.23). | +| Rust `sha2` crate 0.10.9 | SHA-256 in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`RustCrypto/hashes`](https://crates.io/crates/sha2/0.10.9). | +| Rust `digest` crate 0.10.7 | transitive `sha2` dependency in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`RustCrypto/traits`](https://crates.io/crates/digest/0.10.7). | +| Rust `crypto-common` crate 0.1.7 | transitive `sha2` dependency in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`RustCrypto/traits`](https://crates.io/crates/crypto-common/0.1.7). | +| Rust `block-buffer` crate 0.10.4 | transitive `sha2` dependency in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`RustCrypto/utils`](https://crates.io/crates/block-buffer/0.10.4). | +| Rust `cpufeatures` crate 0.2.17 | transitive `sha2` dependency in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`RustCrypto/utils`](https://crates.io/crates/cpufeatures/0.2.17). | +| Rust `generic-array` crate 0.14.7 | transitive `sha2` dependency in `bin/nvx-rs[.exe]` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`fizyk20/generic-array`](https://crates.io/crates/generic-array/0.14.7). | +| Rust `typenum` crate 1.20.1 | transitive `sha2` dependency in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`paholg/typenum`](https://crates.io/crates/typenum/1.20.1). | +| Rust `windows-sys` crate 0.61.2 | Windows API bindings of `bin/nvx-rs.exe` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`microsoft/windows-rs`](https://crates.io/crates/windows-sys/0.61.2). | +| Rust `windows-link` crate 0.2.1 | transitive `windows-sys` dependency in `bin/nvx-rs.exe` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`microsoft/windows-rs`](https://crates.io/crates/windows-link/0.2.1). | +| 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, and into `bin/nvx-rs` of the `linux-kvm` and `linux-mshv` release packages | MIT; Copyright © 2005-2020 Rich Felker, et al. The exact implementation is supplied by the pinned Rust release's `x86_64-unknown-linux-musl` target. Upstream source and the full copyright notice are at [musl libc](https://musl.libc.org/). | +| LLVM `libunwind` | statically linked, with musl, into the nine guest binaries and the Linux `bin/nvx-rs`, which unwind and print backtraces through it | Apache-2.0 WITH LLVM-exception, whose exception lifts the attribution conditions for code embedded in a compiled program. The exact implementation is the `libunwind.a` that the pinned Rust release's `x86_64-unknown-linux-musl` target supplies, which the Rust project builds from its fork of [`llvm-project/libunwind`](https://github.com/llvm/llvm-project/tree/main/libunwind). | +| Microsoft C runtime: the Visual C++ runtime and the Universal C Runtime | statically linked into `bin/nvx-rs.exe` of the `windows-whp` release package, which CI builds with `-C target-feature=+crt-static`, so that it runs without the Visual C++ Redistributable | Proprietary Microsoft object code, linked in by the Visual Studio build tools and the Windows SDK of CI's Windows runner under their Microsoft Software License Terms, which permit distributing the programs built with them; see [Redistribute Visual C++ files](https://learn.microsoft.com/cpp/windows/redistributing-visual-cpp-files). No source is distributed. | | 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/distribution.md b/doc/distribution.md index 64682c47..36cea9b5 100644 --- a/doc/distribution.md +++ b/doc/distribution.md @@ -93,6 +93,12 @@ OpenVMM is MIT licensed: retain its notice, but its source does not have to be published merely because it is aggregated with Linux. See `THIRD_PARTY_NOTICES.md`. +The `nvx-rs` executables are MIT licensed NVX code that statically links the +Rust standard library and permissively licensed crates, with musl and LLVM's +`libunwind` on Linux and the Microsoft C runtime on Windows. +`THIRD_PARTY_NOTICES.md` lists each with its license; `nvx-rs/Cargo.lock` +pins every crate by checksum. + ## Release archives Turn a staged release directory into one deterministic archive: diff --git a/scripts/test_nvx_tools.py b/scripts/test_nvx_tools.py index 8ada7545..52a4fe46 100644 --- a/scripts/test_nvx_tools.py +++ b/scripts/test_nvx_tools.py @@ -7333,6 +7333,93 @@ def test_guest_binary_third_party_notices_cover_the_rust_runtime(self): with self.subTest(component=component): self.assertIn(component, notices) + def test_nvx_rs_third_party_notices_cover_every_linked_crate(self): + # The crates of `cargo tree --locked --features cli -e normal` for the + # release targets, x86_64-unknown-linux-musl and x86_64-pc-windows-msvc: + # what bin/nvx-rs[.exe] links, and serde_derive with the crates that it + # runs on while nvx-rs builds. + shipped = { + "bitflags", + "block-buffer", + "cfg-if", + "cpufeatures", + "crypto-common", + "digest", + "generic-array", + "getrandom", + "itoa", + "libc", + "memchr", + "memoffset", + "nix", + "proc-macro2", + "quote", + "serde", + "serde_core", + "serde_derive", + "serde_json", + "sha2", + "syn", + "typenum", + "unicode-ident", + "windows-link", + "windows-sys", + "zmij", + } + # Build scripts, other targets, and the tests. + unshipped = { + "autocfg", + "cfg_aliases", + "errno", + "fastrand", + "linux-raw-sys", + "once_cell", + "r-efi", + "rustix", + "tempfile", + "version_check", + } + lock = (BuildConstants.REPO_ROOT / "nvx-rs" / "Cargo.lock").read_text( + encoding="utf-8" + ) + versions = dict( + re.findall( + r'^\[\[package\]\]\nname = "([^"]+)"\nversion = "([^"]+)"$', + lock, + re.MULTILINE, + ) + ) + # A new dependency is one or the other, and a shipped one needs notices. + self.assertEqual(set(versions) - {"nvx-rs"}, shipped | unshipped) + self.assertFalse(shipped & unshipped) + notices = (BuildConstants.REPO_ROOT / "THIRD_PARTY_NOTICES.md").read_text( + encoding="utf-8" + ) + rows = { + line.split(" | ", 1)[0]: line + for line in notices.splitlines() + if line.startswith("| ") + } + for name in sorted(shipped): + with self.subTest(crate=name): + row = rows[f"| Rust `{name}` crate {versions[name]}"] + self.assertIn("bin/nvx-rs", row) + self.assertIn("nvx-rs/Cargo.lock", row) + # The runtimes that the release executables link statically. + for component, location in ( + ("| Rust core and standard libraries", "`bin/nvx-rs`"), + ("| Rust `compiler_builtins`", "`bin/nvx-rs[.exe]`"), + ("| musl libc", "`bin/nvx-rs` of the `linux-kvm` and `linux-mshv`"), + ("| LLVM `libunwind`", "the Linux `bin/nvx-rs`"), + ( + "| Microsoft C runtime: the Visual C++ runtime and the Universal C " + "Runtime", + "`bin/nvx-rs.exe`", + ), + ): + with self.subTest(component=component): + self.assertIn(location, rows[component]) + def test_project_source_archive_excludes_the_nvx_rs_target(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) From 90f1835d246940e6c25709ca0e5badefa4c53cb9 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Sat, 17 Oct 2026 08:04:45 -0300 Subject: [PATCH 4/8] ci: package releases on pull requests The platform jobs packaged a release only on dev pushes, so a pull request that broke packaging, as one that changes what a package ships can, passed CI and failed the next development release instead. The Windows packaging failure that the first commit of this series fixes had gone unnoticed that way. The Package release step of run-platform.yml now runs whenever the platform packages releases, and tells the package-release action through a new upload input whether to upload the archive: true only on dev pushes. The action stages the package, which checks every input against its provenance, and archives it, which checks the staged files against SHA256SUMS, on every run, and uploads the archive for the development release only when asked. Pull requests still publish nothing; the release job runs only on dev pushes. Packaging adds about a minute and a half to each Linux platform job and twenty seconds to the Windows one. doc/distribution.md describes it, and a test checks the conditions. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/actions/package-release/action.yml | 9 ++++-- .github/workflows/run-platform.yml | 12 ++++---- doc/distribution.md | 15 ++++++---- scripts/test_nvx_tools.py | 34 ++++++++++++++++++++++ 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/.github/actions/package-release/action.yml b/.github/actions/package-release/action.yml index a44f0144..ec465008 100644 --- a/.github/actions/package-release/action.yml +++ b/.github/actions/package-release/action.yml @@ -1,5 +1,5 @@ name: Package release -description: Build and upload an NVX binary release for one platform +description: Build an NVX binary release for one platform, and upload it for publication inputs: platform: @@ -8,6 +8,9 @@ inputs: nvx-rs-artifact: description: Base name of the nvx-rs artifacts for the runner's OS required: true + upload: + description: Whether to upload the package for the development release + required: true runs: using: composite @@ -75,7 +78,7 @@ runs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Upload Linux release - if: runner.os != 'Windows' + if: runner.os != 'Windows' && inputs.upload == 'true' uses: actions/upload-artifact@v7 with: name: release-${{ inputs.platform }} @@ -85,7 +88,7 @@ runs: retention-days: 1 - name: Upload Windows release - if: runner.os == 'Windows' + if: runner.os == 'Windows' && inputs.upload == 'true' uses: actions/upload-artifact@v7 with: name: release-${{ inputs.platform }} diff --git a/.github/workflows/run-platform.yml b/.github/workflows/run-platform.yml index 47221e64..2567aaf4 100644 --- a/.github/workflows/run-platform.yml +++ b/.github/workflows/run-platform.yml @@ -48,7 +48,9 @@ on: required: true type: number package-release: - description: Whether the platform produces a development release package + description: >- + Whether the platform packages a release, which dev pushes upload for + the development release required: true type: boolean secrets: @@ -130,12 +132,12 @@ jobs: host-cpu-reserve: ${{ inputs.host-cpu-reserve }} marker-timeout: ${{ inputs.marker-timeout }} + # Pull requests package and archive the release too, so that they prove + # that it builds, but only dev pushes upload it for publication. - name: Package release - if: >- - github.event_name == 'push' && - github.ref == 'refs/heads/dev' && - inputs.package-release + if: inputs.package-release uses: ./.github/actions/package-release with: platform: ${{ inputs.release-platform }} nvx-rs-artifact: ${{ inputs.nvx-rs-artifact }} + upload: ${{ github.event_name == 'push' && github.ref == 'refs/heads/dev' }} diff --git a/doc/distribution.md b/doc/distribution.md index 36cea9b5..763b1cab 100644 --- a/doc/distribution.md +++ b/doc/distribution.md @@ -115,14 +115,17 @@ it writes a `.tar.gz` or `.zip` archive outside the staged directory; see packaged file, `bin/nvx-rs[.exe]` and its provenance included, and every archive marks the executables in `bin/` executable. -CI follows the binary-only path. On a `dev` push that changes more than -documentation, the `linux-kvm`, `linux-mshv`, and `windows-whp` platform jobs -each stage `dist/nvx--` with `--binary-only`, then archive -it as `.tar.gz` on Linux and `.zip` on Windows through the +CI follows the binary-only path. On a `dev` push, or a ready pull request from +a branch of this repository, that changes more than documentation, the +`linux-kvm`, `linux-mshv`, and `windows-whp` platform jobs each stage +`dist/nvx--` with `--binary-only`, then archive it as +`.tar.gz` on Linux and `.zip` on Windows through the [`package-release`](../.github/actions/package-release/action.yml) action, with the `nvx-rs` and provenance that the NVX CLI job of their operating -system built and tested. The development release job checks that each -package ships OpenVMM and `nvx-rs` before it publishes them. +system built and tested. Only `dev` pushes upload the archives, so a pull +request proves that its packages build without publishing them. The +development release job checks that each package ships OpenVMM and `nvx-rs` +before it publishes them. [Continuous integration](ci.md) describes the jobs that gate and publish the resulting development release. diff --git a/scripts/test_nvx_tools.py b/scripts/test_nvx_tools.py index 52a4fe46..1b6de048 100644 --- a/scripts/test_nvx_tools.py +++ b/scripts/test_nvx_tools.py @@ -5469,6 +5469,40 @@ def test_ci_hands_nvx_rs_to_the_microvm_and_platform_jobs(self): ) self.assertLess(names.index("Make nvx-rs executable"), names.index(name)) + def test_pull_requests_package_releases_that_only_dev_uploads(self): + # Pull requests package and archive each platform's release, which + # proves that it builds; only a dev push uploads it for publication. + github = BuildConstants.REPO_ROOT / ".github" + platform = (github / "workflows" / "run-platform.yml").read_text( + encoding="utf-8" + ) + package = (github / "actions" / "package-release" / "action.yml").read_text( + encoding="utf-8" + ) + step = _workflow_step(platform, "run", "Package release") + self.assertIn(" if: inputs.package-release\n", step) + self.assertNotIn("github.event_name", _yaml_scalar(step, "if")) + self.assertIn( + " upload: ${{ github.event_name == 'push' && " + "github.ref == 'refs/heads/dev' }}", + step, + ) + steps = _composite_action_steps(package) + names = [_yaml_scalar(step, "name") for step in steps] + for name, operating_system in ( + ("Upload Linux release", "runner.os != 'Windows'"), + ("Upload Windows release", "runner.os == 'Windows'"), + ): + with self.subTest(step=name): + self.assertEqual( + _yaml_scalar(steps[names.index(name)], "if"), + f"{operating_system} && inputs.upload == 'true'", + ) + for name in ("Package Linux release", "Package Windows release"): + self.assertNotIn("upload", _yaml_scalar(steps[names.index(name)], "if")) + self.assertIn("--binary-only", steps[names.index(name)]) + self.assertIn("archive-release", steps[names.index(name)]) + def test_ci_preserves_failed_openvmm_test_diagnostics(self): workflow = ( BuildConstants.REPO_ROOT / ".github" / "workflows" / "ci.yml" From 950b02155716fec42bfb958717c61554f57daa14 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Sat, 17 Oct 2026 08:09:05 -0300 Subject: [PATCH 5/8] release: install nvx-rs with nvx.py download nvx.py download installed OpenVMM and the guest artifacts of a release, so nvx.py sandbox and nvx.py run still needed Cargo to build nvx-rs afterwards. It now installs the package's bin/nvx-rs[.exe] at nvx-rs/target/release/nvx-rs[.exe], where nvx.py finds it, and its provenance at build/nvx-rs.provenance.json, so that both commands run without Cargo after a download. nvx.py runs that executable without ever rebuilding it, and download may fall back to the release of an earlier first-parent commit, so it installs nvx-rs only when the package's provenance describes the build of this checkout's nvx-rs sources that nvx.py runs: the same source digest, Rust release, and features, for the release target of this OS. Otherwise, as for a release from before packages shipped nvx-rs, it installs everything else, removes any earlier nvx-rs and its provenance, which may not match the checkout either, and says that nvx.py builds nvx-rs with Cargo when a command needs it, unless NVX_RS names one. Older releases thus stay installable. A package whose nvx-rs does not match its own provenance is broken, and fails the download before it replaces anything. The installed OpenVMM and nvx-rs are now executable on POSIX whatever mode the archive records. The message that nvx.py prints without Cargo, the discovery notes in nvx_rs.py, doc/usage.md, doc/setup.md, doc/distribution.md, README.md, and nvx-rs/README.md describe the installed nvx-rs. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 9 +- doc/distribution.md | 15 ++ doc/setup.md | 8 +- doc/usage.md | 27 +++- nvx-rs/README.md | 6 +- scripts/nvx_tools/nvx_rs.py | 16 ++- scripts/nvx_tools/release.py | 61 +++++++- scripts/test_nvx_tools.py | 269 +++++++++++++++++++++++++++++------ 8 files changed, 343 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index f7ef91b2..c6b0ef2d 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,12 @@ their common `/sbin/nvx-*` paths. ## Quick Start -Python 3.10 or newer is required, and so is [rustup](https://rustup.rs/): `nvx.py run` runs -[`nvx-rs`](doc/usage.md#nvx-rs), which `nvx.py` builds with Cargo the first time. The commands -below download the prebuilt NVX package for the +Python 3.10 or newer is required. The commands below download the prebuilt NVX package for the selected platform from the cloned revision's release when available, or from its nearest released -first-parent ancestor otherwise, so no build of the guest or OpenVMM is required. A successful boot prints +first-parent ancestor otherwise, so no build of the guest, OpenVMM, or +[`nvx-rs`](doc/usage.md#nvx-rs), which `nvx.py run` runs, is required. Should the release's `nvx-rs` +come from other sources than the checkout's, `nvx.py` builds it with Cargo, which +[rustup](https://rustup.rs/) installs. A successful boot prints `NVX-GUEST-BOOT-OK: alpine` and opens a root shell. Select Ubuntu or Azure Linux userland with the same NVX kernel by passing `--guest ubuntu` or `--guest azurelinux`. diff --git a/doc/distribution.md b/doc/distribution.md index 763b1cab..13fc512c 100644 --- a/doc/distribution.md +++ b/doc/distribution.md @@ -115,6 +115,21 @@ it writes a `.tar.gz` or `.zip` archive outside the staged directory; see packaged file, `bin/nvx-rs[.exe]` and its provenance included, and every archive marks the executables in `bin/` executable. +[`download`](usage.md#download) verifies the package against its `SHA256SUMS` +and installs OpenVMM, the guest artifacts, their provenance, and `nvx-rs` into +the checkout, `nvx-rs` at `nvx-rs/target/release/nvx-rs[.exe]` with its +provenance in `build/`, so that `nvx.py run` and `nvx.py sandbox` need no +Cargo. It installs `nvx-rs` only when its provenance describes the build of +the checkout's `nvx-rs` sources that `nvx.py` runs. Releases from before +packages shipped `nvx-rs` remain installable: `download` installs the rest and +leaves `nvx.py` to build `nvx-rs` with Cargo, as it does for a fallback +release whose `nvx-rs` sources differ from the checkout's. + +When a package includes `nvx-rs` and its provenance, `download` checks their +consistency before deciding whether the build is compatible. A mismatched +executable fails before runtime replacement, even for an incompatible +fallback release. + CI follows the binary-only path. On a `dev` push, or a ready pull request from a branch of this repository, that changes more than documentation, the `linux-kvm`, `linux-mshv`, and `windows-whp` platform jobs each stage diff --git a/doc/setup.md b/doc/setup.md index 02fafa10..a13674bf 100644 --- a/doc/setup.md +++ b/doc/setup.md @@ -30,9 +30,11 @@ python3 scripts/nvx.py init Python 3.10 or newer and Git are required on every platform. Building OpenVMM or the Rust crates also requires [rustup](https://rustup.rs/), which installs the Rust release that [`rust-toolchain.toml`](../rust-toolchain.toml) pins on -first use. `nvx.py run` and `nvx.py sandbox` run `nvx-rs`, which `nvx.py` -builds with that release the first time that a command needs it, unless -`NVX_RS` names an executable; see [Usage](usage.md#nvx-rs). +first use. `nvx.py run` and `nvx.py sandbox` run `nvx-rs`, which +[`download`](usage.md#download) installs from a release built from the +checkout's `nvx-rs` sources. Otherwise, unless `NVX_RS` names an executable, +`nvx.py` builds it with that release the first time that a command needs it; +see [Usage](usage.md#nvx-rs). By default, a microVM boots on the built-in [CPU profile](usage.md#cpu-profiles) of its host's CPU: four Intel diff --git a/doc/usage.md b/doc/usage.md index 90e53f0d..5753a083 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -27,14 +27,18 @@ which it runs entirely, and for the egress-policy and artifact steps of 1. the executable that the `NVX_RS` environment variable names; 2. `nvx-rs/target/release/nvx-rs`, or `nvx-rs.exe` on Windows, in the - checkout, where `cargo build --release` puts it; or else + checkout, where `cargo build --release` puts it, and where + [`download`](#download) installs the `nvx-rs` of a release; or else 3. that executable, after it builds it from the repository root with `cargo build --release --locked --features cli --manifest-path nvx-rs/Cargo.toml --target-dir nvx-rs/target`, the Rust release that `rust-toolchain.toml` pins, and Cargo's output on standard error, so that it never mixes with the output of the command. -Without `NVX_RS`, a built executable, or Cargo, such a command fails and names +After `download`, `nvx.py` needs no Cargo: every release package ships +`bin/nvx-rs`, or `bin/nvx-rs.exe`, which `download` installs when the +release was built from the checkout's `nvx-rs` sources. Without `NVX_RS`, a +built or installed executable, or Cargo, such a command fails and names these choices; [rustup](https://rustup.rs) installs Cargo. `nvx.py` rebuilds nothing on its own once the executable exists, so run the build command again, or delete the executable, after changing `nvx-rs`. The `managed-lifecycle` @@ -46,7 +50,7 @@ The provenance of the `nvx-rs` that `nvx.py` runs, digest of the `nvx-rs` sources that built it, the crate's manifest, lock file, build script, and `src/` tree and `rust-toolchain.toml`, and to the Rust release, target, and Cargo features of the build. `nvx.py` records it -when it builds `nvx-rs`, and +when it builds `nvx-rs`, `download` installs a release's, and [`record-nvx-rs-provenance`](#record-nvx-rs-provenance) records it for a build made otherwise. [`package`](#package) refuses an `nvx-rs` that it does not describe as built from the checkout's sources, so a stale build never @@ -623,7 +627,22 @@ checkout to install a newer release. Installing a release replaces the packaged guest artifacts under `build/` and removes any known guest artifact that the release does not declare, such as the -Azure Linux guest that source-inclusive packages omit. +Azure Linux guest that source-inclusive packages omit. It installs the +packaged OpenVMM at `openvmm/target/release/openvmm[.exe]` and the packaged +`nvx-rs` at `nvx-rs/target/release/nvx-rs[.exe]`, both executable, with their +provenance in `build/`, so that [`run`](#run) and [`sandbox`](#sandbox) need +no Cargo. + +`download` installs the packaged `nvx-rs` only when its provenance describes +the build of the checkout's `nvx-rs` sources that `nvx.py` runs, with the Rust +release that the checkout pins, the release target, and the `cli` feature, +because `nvx.py` runs that executable without rebuilding it. A fallback +release built from other `nvx-rs` sources, and a release from before packages +shipped `nvx-rs`, still install everything else: `download` then removes any +earlier `nvx-rs` and its provenance and reports why, and `nvx.py` builds +`nvx-rs` from the checkout with Cargo when a command needs it; see +[nvx-rs](#nvx-rs). A package whose `nvx-rs` does not match its own provenance +fails the download before it replaces anything. Windows release downloads support WHP. Linux release downloads support KVM and MSHV. `download` first uses `GH_TOKEN` or `GITHUB_TOKEN` when configured. diff --git a/nvx-rs/README.md b/nvx-rs/README.md index c8a8767e..434f0666 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -166,13 +166,15 @@ provenance in `provenance/nvx-rs.provenance.json` ([Package and source delivery](../doc/distribution.md)). The `nvx-rs` of an extracted release runs as is: without `--root`, it uses the release that holds it; see [Artifact root](#artifact-root). +[`nvx.py download`](../doc/usage.md#download) installs it in a checkout of the +sources that built it, so that `nvx.py` runs it without Cargo. `nvx.py` runs the executable that `NVX_RS` names, else `nvx-rs/target/release/nvx-rs[.exe]` of its checkout, and else builds that one with Cargo first, with its output on standard error; see [Usage](../doc/usage.md#nvx-rs). `nvx.py` records the provenance of the builds -that it makes, and `nvx.py record-nvx-rs-provenance` that of a build made -otherwise. It runs `nvx-rs --root REPOSITORY --prog +that it makes and installs, and `nvx.py record-nvx-rs-provenance` that of a +build made otherwise. It runs `nvx-rs --root REPOSITORY --prog "nvx.py sandbox" sandbox OPERATION OPTIONS` with the options that the user gave, in full, and the standard streams and exit status of the command; `nvx-rs egress compile PATH` and `nvx-rs --root REPOSITORY artifacts` for diff --git a/scripts/nvx_tools/nvx_rs.py b/scripts/nvx_tools/nvx_rs.py index e9169ebc..7109bd3d 100644 --- a/scripts/nvx_tools/nvx_rs.py +++ b/scripts/nvx_tools/nvx_rs.py @@ -6,7 +6,8 @@ 1. the executable that the NVX_RS environment variable names; 2. nvx-rs/target/release/nvx-rs[.exe] in this checkout, where `cargo build - --release` puts it; or else + --release` puts it, and where `nvx.py download` installs the nvx-rs of a + release built from this checkout's nvx-rs sources; or else 3. that executable, after it builds it with Cargo, whose output goes to standard error so that it never mixes with the output of the command. @@ -19,7 +20,8 @@ builds, and `nvx.py record-nvx-rs-provenance` for one built otherwise. Packaging rejects an nvx-rs that it does not describe as built from this checkout's sources. Releases ship nvx-rs as bin/nvx-rs[.exe], built for -RELEASE_TARGETS with FEATURES, with its provenance. +RELEASE_TARGETS with FEATURES, with its provenance, and `nvx.py download` +installs both only when that provenance names this checkout's sources. """ from __future__ import annotations @@ -96,9 +98,8 @@ def binary_path() -> Path: """Return the nvx-rs of this checkout, nvx-rs/target/release/nvx-rs[.exe], where BUILD_COMMAND puts it. - This is also where a release's bin/nvx-rs[.exe] belongs once `nvx.py - download` installs it, as it installs bin/openvmm[.exe] where the OpenVMM - build puts it. + This is also where `nvx.py download` installs a release's bin/nvx-rs[.exe], + as it installs bin/openvmm[.exe] where the OpenVMM build puts it. """ name = f"{BINARY_NAME}.exe" if os.name == "nt" else BINARY_NAME return DIRECTORY / "target" / "release" / name @@ -209,8 +210,9 @@ def _format_command(command: Sequence[str]) -> str: def _missing_cargo_message() -> str: return ( f"nvx-rs is not built at {binary_path()}, and Cargo is not on PATH to " - f"build it: set {VARIABLE} to an nvx-rs executable, install a release " - "that includes it with `nvx.py download`, or install Rust " + f"build it: set {VARIABLE} to an nvx-rs executable, install the nvx-rs " + "of a release built from this checkout's nvx-rs sources with " + "`nvx.py download`, or install Rust " "(https://rustup.rs) and build it from the repository root with " f"`{_format_command(BUILD_COMMAND)}`" ) diff --git a/scripts/nvx_tools/release.py b/scripts/nvx_tools/release.py index 75a65458..fff4fe31 100644 --- a/scripts/nvx_tools/release.py +++ b/scripts/nvx_tools/release.py @@ -671,6 +671,47 @@ def _packaged_guest_artifact_names(package_root: Path) -> list[str]: return names +def _install_executable(source: Path, destination: Path) -> None: + _replace_runtime_file(source, destination) + if os.name != "nt": + destination.chmod(0o755) + + +def _packaged_nvx_rs( + package_root: Path, package_name: str +) -> tuple[tuple[Path, Path] | None, str]: + """Return the package's nvx-rs and its provenance if the provenance shows + the build of this checkout's nvx-rs sources that nvx.py runs, and + otherwise None, with the reason. + + nvx.py runs the nvx-rs that download installs without rebuilding it, so an + nvx-rs of other sources, such as that of an older release or of a checkout + whose nvx-rs changed, or with other features, would run with this + checkout's nvx.py. A package whose nvx-rs does not match its own + provenance is broken, and fails. + """ + name = nvx_rs.binary_path().name + executable = package_root / "bin" / name + provenance = package_root / "provenance" / nvx_rs.PROVENANCE_NAME + if not executable.is_file(): + return None, f"{package_name} ships no bin/{name}" + if not provenance.is_file(): + return None, f"{package_name} ships no provenance for its bin/{name}" + target = nvx_rs.release_target() + record = _read_json_object(provenance, "packaged nvx-rs build provenance") + if record.get("executable_sha256") != sha256_file(executable): + raise ScriptError( + f"the bin/{name} of {package_name} does not match its build provenance" + ) + if not _describes_checkout_nvx_rs(record, target=target): + return ( + None, + f"the bin/{name} of {package_name} is not the {target} build of this " + "checkout's nvx-rs sources that nvx.py runs", + ) + return (executable, provenance), "" + + def _install_release_archive(archive_path: Path) -> None: with tempfile.TemporaryDirectory(prefix="nvx-release-") as temporary: extraction_root = Path(temporary) @@ -706,7 +747,10 @@ def _install_release_archive(archive_path: Path) -> None: InitramfsBuildConstants.PROVENANCE_NAME, ) } - _replace_runtime_file(binary_source, binary_destination) + packaged_nvx_rs, nvx_rs_skipped = _packaged_nvx_rs( + package_root, archive_path.name + ) + _install_executable(binary_source, binary_destination) for name, source in guest_sources.items(): _replace_runtime_file(source, artifact_path(name)) for name in ReleaseBuildConstants.GUEST_ARTIFACT_NAMES: @@ -714,6 +758,21 @@ def _install_release_archive(archive_path: Path) -> None: artifact_path(name).unlink(missing_ok=True) for name, source in provenance_sources.items(): _replace_runtime_file(source, artifact_path(name)) + nvx_rs_provenance = artifact_path(nvx_rs.PROVENANCE_NAME) + if packaged_nvx_rs is None: + # Remove the nvx-rs that an earlier download or build left, which + # may not match this checkout, so that nvx.py builds one from it. + nvx_rs.binary_path().unlink(missing_ok=True) + nvx_rs_provenance.unlink(missing_ok=True) + print( + f">> {nvx_rs_skipped}, so nvx.py builds nvx-rs from this checkout " + f"with Cargo when a command needs it, unless {nvx_rs.VARIABLE} " + "names one" + ) + else: + executable, provenance = packaged_nvx_rs + _install_executable(executable, nvx_rs.binary_path()) + _replace_runtime_file(provenance, nvx_rs_provenance) def download_checkout_release(repository: str, platform: str) -> None: diff --git a/scripts/test_nvx_tools.py b/scripts/test_nvx_tools.py index 1b6de048..fec14012 100644 --- a/scripts/test_nvx_tools.py +++ b/scripts/test_nvx_tools.py @@ -29,7 +29,7 @@ import urllib.request import uuid import zipfile -from collections.abc import Callable, Generator +from collections.abc import Callable, Generator, Mapping from pathlib import Path, PurePosixPath from typing import cast from unittest.mock import MagicMock, call, patch @@ -16612,6 +16612,17 @@ def _missing_release() -> urllib.error.HTTPError: class ReleaseTests(unittest.TestCase): + def setUp(self) -> None: + super().setUp() + # Installing a release replaces or removes nvx-rs; no test may do + # either to the nvx-rs of this checkout. + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.nvx_rs_path = Path(temporary.name) / nvx_rs.binary_path().name + patcher = patch.object(nvx_rs, "binary_path", return_value=self.nvx_rs_path) + patcher.start() + self.addCleanup(patcher.stop) + def test_alpine_source_validation_rejects_malformed_manifest(self): with tempfile.TemporaryDirectory() as temporary: source_dir = Path(temporary) @@ -18265,59 +18276,98 @@ def test_publication_rechecks_force_destination_scope(self): ) self.assertFalse(staging.exists()) + def _runtime_release_archive( + self, + root: Path, + *, + nvx_rs_contents: bytes | None, + nvx_rs_provenance: Mapping[str, object] | None, + ) -> Path: + """Write the archive of a binary package for this OS, with or without + nvx-rs, and with or without its provenance, which `nvx_rs_provenance` + changes from the record of a build of this checkout's sources.""" + package_root = root / "package" / "nvx-1.2.3-test" + binary_name = "openvmm.exe" if os.name == "nt" else "openvmm" + binary = package_root / "bin" / binary_name + binary.parent.mkdir(parents=True) + binary.write_bytes(b"openvmm") + for name in ReleaseBuildConstants.GUEST_ARTIFACT_NAMES: + guest = package_root / "guest" / name + guest.parent.mkdir(parents=True, exist_ok=True) + guest.write_bytes(name.encode("ascii")) + for name in ( + OpenVMMBuildConstants.PROVENANCE_NAME, + KernelBuildConstants.PROVENANCE_NAME, + InitramfsBuildConstants.PROVENANCE_NAME, + ): + provenance = package_root / "provenance" / name + provenance.parent.mkdir(parents=True, exist_ok=True) + provenance.write_bytes(name.encode("ascii")) + if nvx_rs_contents is not None: + packaged_nvx_rs = package_root / "bin" / self.nvx_rs_path.name + packaged_nvx_rs.write_bytes(nvx_rs_contents) + # The installed nvx-rs is executable, whatever mode the archive + # records. + packaged_nvx_rs.chmod(0o644) + if nvx_rs_provenance is not None: + record = nvx_rs.provenance_record( + packaged_nvx_rs, nvx_rs.release_target() + ) + record.update(nvx_rs_provenance) + (package_root / "provenance" / nvx_rs.PROVENANCE_NAME).write_text( + json.dumps(record), encoding="utf-8" + ) + (package_root / "SOURCE-MANIFEST.json").write_text( + json.dumps({"linux": {}, "alpine": {}, "ubuntu": {}, "azurelinux": {}}), + encoding="utf-8", + ) + common.write_sha256_sums(package_root) + if os.name == "nt": + archive_path = root / "nvx-1.2.3-windows-whp.zip" + with zipfile.ZipFile(archive_path, "w") as package: + for path in package_root.rglob("*"): + package.write(path, path.relative_to(package_root.parent)) + else: + archive_path = root / "nvx-1.2.3-linux-kvm.tar.gz" + with tarfile.open(archive_path, "w:gz") as package: + package.add(package_root, arcname=package_root.name) + return archive_path + + def _install_runtime_archive(self, root: Path, archive_path: Path) -> str: + """Install the archive into the runtime directory below `root`, and + return what the installer printed.""" + binary_name = "openvmm.exe" if os.name == "nt" else "openvmm" + with ( + patch.object( + release, + "artifact_path", + side_effect=(root / "runtime" / "build").joinpath, + ), + patch.object( + release, + "openvmm_binary_path", + return_value=root / "runtime" / "openvmm" / binary_name, + ), + contextlib.redirect_stdout(io.StringIO()) as stdout, + ): + release._install_release_archive(archive_path) + return stdout.getvalue() + def test_release_archive_installs_runtime_layout(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - package_root = root / "package" / "nvx-1.2.3-test" - binary_name = "openvmm.exe" if os.name == "nt" else "openvmm" - binary = package_root / "bin" / binary_name - binary.parent.mkdir(parents=True) - binary.write_bytes(b"openvmm") - for name in ReleaseBuildConstants.GUEST_ARTIFACT_NAMES: - guest = package_root / "guest" / name - guest.parent.mkdir(parents=True, exist_ok=True) - guest.write_bytes(name.encode("ascii")) - for name in ( - OpenVMMBuildConstants.PROVENANCE_NAME, - KernelBuildConstants.PROVENANCE_NAME, - InitramfsBuildConstants.PROVENANCE_NAME, - ): - provenance = package_root / "provenance" / name - provenance.parent.mkdir(parents=True, exist_ok=True) - provenance.write_bytes(name.encode("ascii")) - common.write_sha256_sums(package_root) - - if os.name == "nt": - archive_path = root / "nvx-1.2.3-windows-whp.zip" - with zipfile.ZipFile(archive_path, "w") as package: - for path in package_root.rglob("*"): - package.write(path, path.relative_to(package_root.parent)) - else: - archive_path = root / "nvx-1.2.3-linux-kvm.tar.gz" - with tarfile.open(archive_path, "w:gz") as package: - package.add(package_root, arcname=package_root.name) - + archive_path = self._runtime_release_archive( + root, nvx_rs_contents=b"nvx-rs", nvx_rs_provenance={} + ) build_dir = root / "runtime" / "build" binary_destination = ( - root / "runtime" / "openvmm" / "target" / "release" / binary_name + root + / "runtime" + / "openvmm" + / ("openvmm.exe" if os.name == "nt" else "openvmm") ) - def artifact_path(name: str) -> Path: - return build_dir / name - - with ( - patch.object( - release, - "artifact_path", - side_effect=artifact_path, - ), - patch.object( - release, - "openvmm_binary_path", - return_value=binary_destination, - ), - ): - release._install_release_archive(archive_path) + output = self._install_runtime_archive(root, archive_path) self.assertEqual(binary_destination.read_bytes(), b"openvmm") for name in ReleaseBuildConstants.GUEST_ARTIFACT_NAMES: @@ -18334,6 +18384,130 @@ def artifact_path(name: str) -> Path: (build_dir / name).read_bytes(), name.encode("ascii"), ) + # nvx-rs, which this checkout's sources built, goes where nvx.py + # finds it, executable, with its provenance beside OpenVMM's. + self.assertEqual(self.nvx_rs_path.read_bytes(), b"nvx-rs") + self.assertEqual(output, "") + if os.name != "nt": + self.assertEqual(self.nvx_rs_path.stat().st_mode & 0o777, 0o755) + self.assertEqual(binary_destination.stat().st_mode & 0o777, 0o755) + installed_provenance = build_dir / nvx_rs.PROVENANCE_NAME + self.assertEqual( + json.loads(installed_provenance.read_text(encoding="utf-8")), + nvx_rs.provenance_record(self.nvx_rs_path, nvx_rs.release_target()), + ) + release._validate_nvx_rs_provenance( + self.nvx_rs_path, installed_provenance, target=None + ) + # nvx.py then runs it without Cargo. + with ( + _without_nvx_rs_variable(), + patch("shutil.which", return_value=None), + patch("subprocess.run") as run, + ): + self.assertEqual(nvx_rs.require_binary(), self.nvx_rs_path) + run.assert_not_called() + + def test_release_archive_leaves_nvx_rs_of_other_sources_to_cargo(self): + # nvx.py runs the nvx-rs that download installs without rebuilding it, + # so download installs only the build of the checkout's nvx-rs sources + # that nvx.py runs, and removes an earlier one and its provenance, so + # that nvx.py builds nvx-rs from the checkout instead. Releases that + # predate nvx-rs install too. + name = self.nvx_rs_path.name + archive_name = ( + "nvx-1.2.3-windows-whp.zip" + if os.name == "nt" + else "nvx-1.2.3-linux-kvm.tar.gz" + ) + target = nvx_rs.release_target() + other_target = ( + "x86_64-unknown-linux-gnu" + if target == nvx_rs.RELEASE_TARGETS["linux"] + else nvx_rs.RELEASE_TARGETS["linux"] + ) + other_build = ( + f"the bin/{name} of {archive_name} is not the {target} build of this " + "checkout's nvx-rs sources that nvx.py runs" + ) + cases: tuple[tuple[str, bytes | None, dict[str, object] | None, str], ...] = ( + ("older release", None, None, f"{archive_name} ships no bin/{name}"), + ( + "no provenance", + b"nvx-rs", + None, + f"{archive_name} ships no provenance for its bin/{name}", + ), + ("other sources", b"nvx-rs", {"source_sha256": "0" * 64}, other_build), + ("other Rust release", b"nvx-rs", {"toolchain": "1.0.0"}, other_build), + ("other features", b"nvx-rs", {"features": ["cli", "x"]}, other_build), + ("other target", b"nvx-rs", {"target": other_target}, other_build), + ("other format", b"nvx-rs", {"format": 2}, other_build), + ) + for case, contents, provenance, reason in cases: + with ( + self.subTest(case=case), + tempfile.TemporaryDirectory() as temporary, + ): + root = Path(temporary) + archive_path = self._runtime_release_archive( + root, nvx_rs_contents=contents, nvx_rs_provenance=provenance + ) + build_dir = root / "runtime" / "build" + build_dir.mkdir(parents=True) + stale_provenance = build_dir / nvx_rs.PROVENANCE_NAME + stale_provenance.write_text("{}", encoding="utf-8") + self.nvx_rs_path.write_bytes(b"stale nvx-rs") + + output = self._install_runtime_archive(root, archive_path) + + self.assertTrue( + (build_dir / KernelBuildConstants.BINARY_NAME).is_file() + ) + self.assertFalse(self.nvx_rs_path.exists()) + self.assertFalse(stale_provenance.exists()) + self.assertIn(reason, output) + self.assertIn( + "so nvx.py builds nvx-rs from this checkout with Cargo when a " + "command needs it, unless NVX_RS names one", + output, + ) + # Without Cargo, nvx.py names the ways to get nvx-rs. + with ( + _without_nvx_rs_variable(), + patch("shutil.which", return_value=None), + self.assertRaisesRegex(common.ScriptError, "nvx.py download"), + ): + nvx_rs.require_binary() + + def test_release_archive_with_inconsistent_nvx_rs_installs_nothing(self): + # A package whose nvx-rs does not match its own provenance is broken, + # even when its sources differ, so download replaces nothing. + for provenance in ( + {"executable_sha256": "0" * 64}, + {"source_sha256": "0" * 64, "executable_sha256": "0" * 64}, + ): + with ( + self.subTest(provenance=provenance), + tempfile.TemporaryDirectory() as temporary, + ): + root = Path(temporary) + archive_path = self._runtime_release_archive( + root, + nvx_rs_contents=b"nvx-rs", + nvx_rs_provenance=provenance, + ) + self.nvx_rs_path.write_bytes(b"earlier nvx-rs") + with self.assertRaisesRegex( + common.ScriptError, + re.escape( + f"the bin/{self.nvx_rs_path.name} of {archive_path.name} " + "does not match its build provenance" + ), + ): + self._install_runtime_archive(root, archive_path) + self.assertFalse((root / "runtime").exists()) + self.assertEqual(self.nvx_rs_path.read_bytes(), b"earlier nvx-rs") def test_source_release_archive_removes_stale_azure_artifacts(self): with tempfile.TemporaryDirectory() as temporary: @@ -18395,6 +18569,7 @@ def artifact_path(name: str) -> Path: "openvmm_binary_path", return_value=binary_destination, ), + contextlib.redirect_stdout(io.StringIO()), ): release._install_release_archive(archive_path) From 85b16e32fe5342440082c457f6464d184061e466 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Sat, 17 Oct 2026 08:13:00 -0300 Subject: [PATCH 6/8] adversarial: check the nvx-rs that the scenarios run The adversarial executor validates and hashes OpenVMM, the kernel, the initramfs, and their provenance before the baseline boot, so that a campaign runs what the staged commit built. Scenarios such as l3-l4-egress-policy also run nvx-rs through nvx.py, which never rebuilds an nvx-rs that exists, so one left from before a change of the nvx-rs sources ran unchecked. validate_runtime_artifact_provenance() now also requires the nvx-rs that nvx.py runs, the one that NVX_RS names or else nvx-rs/target/release/nvx-rs[.exe], with a build/nvx-rs.provenance.json that describes it as a build of the checkout's nvx-rs sources, for any target. The executor's artifact metadata hashes both, beside the other artifacts. A stale, unrecorded, or missing nvx-rs now fails the initialization instead of running. Targets already had to build nvx-rs before exposure; the preparation in doc/adversarial-testing.md now also records its provenance, which nvx.py does itself for the builds that it makes, and a target with the release of the staged commit gets both from nvx.py download. doc/ci.md and doc/usage.md mention the check. Closes #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/adversarial-testing.md | 20 +++++- doc/ci.md | 5 +- doc/usage.md | 7 +- scripts/nvx_tools/adversarial.py | 2 + scripts/nvx_tools/adversarial_executor.py | 9 +++ scripts/nvx_tools/nvx_rs.py | 9 +-- scripts/nvx_tools/release.py | 18 ++++++ scripts/test_adversarial.py | 79 ++++++++++++++++++++++- scripts/test_nvx_tools.py | 77 +++++++++++++++++++++- 9 files changed, 212 insertions(+), 14 deletions(-) diff --git a/doc/adversarial-testing.md b/doc/adversarial-testing.md index 9f46eb59..10b838af 100644 --- a/doc/adversarial-testing.md +++ b/doc/adversarial-testing.md @@ -98,7 +98,8 @@ The executor: 1. requires the exact controller NVX commit; 2. runs `scripts/nvx.py verify`; 3. validates and hashes the kernel, initramfs, initramfs package manifest, - OpenVMM executable, and provenance documents before baseline boot; + OpenVMM executable, the [nvx-rs](#nvx-rs-on-the-target) that the scenarios + run, and their provenance documents before baseline boot; 4. starts filesystem and network canaries plus the watchdog; 5. refuses every scenario or canary dispatch after sticky oracle evidence records a filesystem or network violation; @@ -110,6 +111,10 @@ The executor: 10. inventories structured outcome reports and preserves complete logs; and 11. boots and stops a fresh VM before final success. +The initialization response must include all nine artifact entries, including +`nvx_rs` and `nvx_rs_provenance`; the controller validates each path, SHA-256, +and size. + Child test processes receive a small environment allowlist. In particular, GitHub, Copilot, SSH-agent, token, and arbitrary controller environment variables are not forwarded. The allowlist carries the paths that @@ -167,13 +172,24 @@ outside the home directory. A scenario's timeout includes any build, an existing nvx-rs is never rebuilt, and a target may have no network access for Cargo. So, before exposure, build -both at the staged commit from the repository root: +both at the staged commit from the repository root, and record the provenance +of the nvx-rs build: ```bash cargo build --release --locked --features cli --manifest-path nvx-rs/Cargo.toml --target-dir nvx-rs/target cargo test --no-run --locked --manifest-path nvx-rs/Cargo.toml --test managed_lifecycle +python3 scripts/nvx.py record-nvx-rs-provenance ``` +Because `nvx.py` never rebuilds an nvx-rs that exists, the executor checks it +with the other artifacts before the baseline boot: the provenance in +`build/nvx-rs.provenance.json` must describe the nvx-rs that the scenarios +run, the one at `NVX_RS` or else in `nvx-rs/target/release`, as built from the +staged commit's nvx-rs sources. A stale build, or a missing one, fails the +initialization instead of running unnoticed. Run `record-nvx-rs-provenance` +with `NVX_RS` set when it names the build. On a target with the release built +from the staged commit, [`download`](usage.md#download) installs both. + ### Independent oracles The executor-side oracle watchdog runs without consulting Copilot. It diff --git a/doc/ci.md b/doc/ci.md index 61e6193c..b454aa96 100644 --- a/doc/ci.md +++ b/doc/ci.md @@ -661,8 +661,9 @@ Copilot or initiate login. The wrapper provisions a distinct disposable KVM, MSHV, or WHP target with no production or GitHub credentials and forwards only the typed executor protocol. Besides the guest and OpenVMM artifacts, the target needs the Rust -toolchain and a prebuilt nvx-rs for the scenarios that run it, as -[nvx-rs on the target](adversarial-testing.md#nvx-rs-on-the-target) explains. +toolchain and a prebuilt nvx-rs with its provenance for the scenarios that run +it, as [nvx-rs on the target](adversarial-testing.md#nvx-rs-on-the-target) +explains. Existing persistent microVM and performance runners are not valid adversarial targets. Loss of the target heartbeat, a policy oracle, or a teardown/post-campaign boot failure fails the job and requires quarantine and diff --git a/doc/usage.md b/doc/usage.md index 5753a083..21e4e024 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -52,9 +52,10 @@ file, build script, and `src/` tree and `rust-toolchain.toml`, and to the Rust release, target, and Cargo features of the build. `nvx.py` records it when it builds `nvx-rs`, `download` installs a release's, and [`record-nvx-rs-provenance`](#record-nvx-rs-provenance) records it for a -build made otherwise. [`package`](#package) refuses an `nvx-rs` that it does -not describe as built from the checkout's sources, so a stale build never -ships unnoticed. +build made otherwise. [`package`](#package) and the +[adversarial executor](adversarial-testing.md#nvx-rs-on-the-target) refuse an +`nvx-rs` that it does not describe as built from the checkout's sources, so a +stale build never ships or runs unnoticed. ## Commands diff --git a/scripts/nvx_tools/adversarial.py b/scripts/nvx_tools/adversarial.py index 8fb71572..9439e882 100644 --- a/scripts/nvx_tools/adversarial.py +++ b/scripts/nvx_tools/adversarial.py @@ -638,6 +638,8 @@ def _validate_artifact_evidence(value: object) -> None: "openvmm", "kernel_provenance", "openvmm_provenance", + "nvx_rs", + "nvx_rs_provenance", } artifacts = _exact_object( value, diff --git a/scripts/nvx_tools/adversarial_executor.py b/scripts/nvx_tools/adversarial_executor.py index a7cee045..dfbdbd2c 100644 --- a/scripts/nvx_tools/adversarial_executor.py +++ b/scripts/nvx_tools/adversarial_executor.py @@ -14,6 +14,7 @@ from pathlib import Path from typing import cast +from nvx_tools import nvx_rs from nvx_tools.adversarial_broker import ( SCHEMA_VERSION, CaseDefinition, @@ -177,6 +178,14 @@ def _artifact_metadata() -> dict[str, object]: artifact_path(OpenVMMBuildConstants.PROVENANCE_NAME), "OpenVMM provenance", ), + # The nvx-rs that the scenarios run through nvx.py, which never + # rebuilds it, so a stale one would run without its provenance check. + "nvx_rs": nvx_rs.find_binary() + or require_file(nvx_rs.binary_path(), "nvx-rs executable"), + "nvx_rs_provenance": require_file( + artifact_path(nvx_rs.PROVENANCE_NAME), + "nvx-rs provenance", + ), } validate_runtime_artifact_provenance() return { diff --git a/scripts/nvx_tools/nvx_rs.py b/scripts/nvx_tools/nvx_rs.py index 7109bd3d..b8d04148 100644 --- a/scripts/nvx_tools/nvx_rs.py +++ b/scripts/nvx_tools/nvx_rs.py @@ -18,10 +18,11 @@ Rust release, target, and features of the build, as OpenVMM's provenance binds it to its source revision. nvx.py records it for the nvx-rs that it builds, and `nvx.py record-nvx-rs-provenance` for one built otherwise. -Packaging rejects an nvx-rs that it does not describe as built from this -checkout's sources. Releases ship nvx-rs as bin/nvx-rs[.exe], built for -RELEASE_TARGETS with FEATURES, with its provenance, and `nvx.py download` -installs both only when that provenance names this checkout's sources. +Packaging and the adversarial executor reject an nvx-rs that it does not +describe as built from this checkout's sources. Releases ship nvx-rs as +bin/nvx-rs[.exe], built for RELEASE_TARGETS with FEATURES, with its +provenance, and `nvx.py download` installs both only when that provenance +names this checkout's sources. """ from __future__ import annotations diff --git a/scripts/nvx_tools/release.py b/scripts/nvx_tools/release.py index fff4fe31..0fe512b4 100644 --- a/scripts/nvx_tools/release.py +++ b/scripts/nvx_tools/release.py @@ -1216,7 +1216,23 @@ def _validate_nvx_rs_provenance( return provenance +def _require_runtime_nvx_rs() -> tuple[Path, Path]: + """Return the nvx-rs that nvx.py runs without building it and its + provenance.""" + executable = nvx_rs.find_binary() or require_file( + nvx_rs.binary_path(), "nvx-rs executable" + ) + provenance = require_file( + artifact_path(nvx_rs.PROVENANCE_NAME), "nvx-rs build provenance" + ) + return executable, provenance + + def validate_runtime_artifact_provenance() -> None: + """Check that OpenVMM, the kernel, the Alpine initramfs, and the nvx-rs + that nvx.py runs match their build provenance and this checkout's + sources, so that nothing stale runs, such as an nvx-rs left from before a + change of its sources, which nvx.py never rebuilds.""" binary = require_file(openvmm_binary_path(), "OpenVMM release binary") kernel = require_file( artifact_path(KernelBuildConstants.BINARY_NAME), @@ -1239,6 +1255,7 @@ def validate_runtime_artifact_provenance() -> None: kernel_provenance_path, initramfs_provenance_path, ) = _require_runtime_provenance_paths() + nvx_rs_executable, nvx_rs_provenance_path = _require_runtime_nvx_rs() _validate_openvmm_provenance(binary, openvmm_provenance_path) _validate_kernel_provenance(kernel, kernel_config, kernel_provenance_path) _validate_initramfs_provenance( @@ -1246,6 +1263,7 @@ def validate_runtime_artifact_provenance() -> None: package_manifest, initramfs_provenance_path, ) + _validate_nvx_rs_provenance(nvx_rs_executable, nvx_rs_provenance_path, target=None) def _require_release_nvx_rs() -> tuple[Path, Path, dict[str, object]]: diff --git a/scripts/test_adversarial.py b/scripts/test_adversarial.py index 7d3e0006..fc5e34da 100644 --- a/scripts/test_adversarial.py +++ b/scripts/test_adversarial.py @@ -33,6 +33,7 @@ _minimize_failure, _positive_float, _prefix_reproduces, + _validate_artifact_evidence, _validate_replay_manifest, local_executor_environment, run_campaign, @@ -227,6 +228,8 @@ def request( "openvmm", "kernel_provenance", "openvmm_provenance", + "nvx_rs", + "nvx_rs_provenance", ) }, "verify": { @@ -2185,6 +2188,8 @@ def test_executor_resolves_build_artifacts_with_shared_helper(self) -> None: root = Path(temporary) openvmm = root / OpenVMMBuildConstants.BINARY_NAME openvmm.write_bytes(b"openvmm") + nvx_rs_executable = root / "nvx-rs" + nvx_rs_executable.write_bytes(b"nvx-rs") def artifact_path(name: str) -> Path: path = root / name @@ -2200,11 +2205,41 @@ def artifact_path(name: str) -> Path: "nvx_tools.adversarial_executor.openvmm_binary_path", return_value=openvmm, ), + # The nvx-rs that nvx.py runs, which NVX_RS may name. patch( - "nvx_tools.adversarial_executor.validate_runtime_artifact_provenance" + "nvx_tools.adversarial_executor.nvx_rs.find_binary", + return_value=nvx_rs_executable, ), + patch( + "nvx_tools.adversarial_executor.validate_runtime_artifact_provenance" + ) as validate, ): - _artifact_metadata() + artifacts = _artifact_metadata() + + validate.assert_called_once_with() + _validate_artifact_evidence(artifacts) + for name in ("nvx_rs", "nvx_rs_provenance"): + incomplete = artifacts.copy() + incomplete.pop(name) + with ( + self.subTest(missing=name), + self.assertRaisesRegex( + ScriptError, "executor artifact evidence has invalid properties" + ), + ): + _validate_artifact_evidence(incomplete) + self.assertEqual( + artifacts["nvx_rs"], + { + "path": str(nvx_rs_executable), + "sha256": hashlib.sha256(b"nvx-rs").hexdigest(), + "size": len(b"nvx-rs"), + }, + ) + self.assertEqual( + cast(dict[str, object], artifacts["nvx_rs_provenance"])["path"], + str(root / "nvx-rs.provenance.json"), + ) self.assertEqual( [entry.args[0] for entry in resolve.call_args_list], @@ -2215,9 +2250,49 @@ def artifact_path(name: str) -> Path: InitramfsBuildConstants.PROVENANCE_NAME, KernelBuildConstants.PROVENANCE_NAME, OpenVMMBuildConstants.PROVENANCE_NAME, + "nvx-rs.provenance.json", ], ) + def test_executor_requires_the_nvx_rs_that_the_scenarios_run(self) -> None: + # Without NVX_RS, the scenarios run nvx-rs/target/release/nvx-rs, + # which nvx.py would otherwise build during a scenario's timeout. + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + + def artifact_path(name: str) -> Path: + path = root / name + path.write_bytes(name.encode()) + return path + + missing = root / "missing" / "nvx-rs" + with ( + patch( + "nvx_tools.adversarial_executor.artifact_path", + side_effect=artifact_path, + ), + patch( + "nvx_tools.adversarial_executor.openvmm_binary_path", + return_value=artifact_path("openvmm"), + ), + patch( + "nvx_tools.adversarial_executor.nvx_rs.find_binary", + return_value=None, + ), + patch( + "nvx_tools.adversarial_executor.nvx_rs.binary_path", + return_value=missing, + ), + patch( + "nvx_tools.adversarial_executor.validate_runtime_artifact_provenance" + ) as validate, + self.assertRaisesRegex( + ScriptError, f"nvx-rs executable not found: .*{missing.name}" + ), + ): + _artifact_metadata() + validate.assert_not_called() + def test_external_executor_also_drops_controller_credentials(self) -> None: process = MagicMock() process.stdin = MagicMock() diff --git a/scripts/test_nvx_tools.py b/scripts/test_nvx_tools.py index fec14012..5efd6141 100644 --- a/scripts/test_nvx_tools.py +++ b/scripts/test_nvx_tools.py @@ -17675,6 +17675,7 @@ def test_packages_require_the_release_build_of_nvx_rs(self): def test_runtime_provenance_validation_uses_required_paths(self): build_dir = Path("build") binary = Path("openvmm") + nvx_rs_binary = Path("nvx-rs") def artifact_path(name: str) -> Path: return build_dir / name @@ -17685,6 +17686,7 @@ def require_file(path: Path, _description: str) -> Path: with ( patch.object(release, "artifact_path", side_effect=artifact_path), patch.object(release, "openvmm_binary_path", return_value=binary), + patch.object(nvx_rs, "find_binary", return_value=nvx_rs_binary), patch.object( release, "require_file", @@ -17693,11 +17695,12 @@ def require_file(path: Path, _description: str) -> Path: patch.object(release, "_validate_openvmm_provenance") as openvmm, patch.object(release, "_validate_kernel_provenance"), patch.object(release, "_validate_initramfs_provenance"), + patch.object(release, "_validate_nvx_rs_provenance") as nvx_rs_check, ): release.validate_runtime_artifact_provenance() self.assertEqual( - require.call_args_list[-3:], + require.call_args_list[-4:], [ call( build_dir / OpenVMMBuildConstants.PROVENANCE_NAME, @@ -17711,11 +17714,83 @@ def require_file(path: Path, _description: str) -> Path: build_dir / InitramfsBuildConstants.PROVENANCE_NAME, "initramfs build provenance", ), + call(build_dir / nvx_rs.PROVENANCE_NAME, "nvx-rs build provenance"), ], ) openvmm.assert_called_once_with( binary, build_dir / OpenVMMBuildConstants.PROVENANCE_NAME ) + # The nvx-rs that nvx.py runs, of any target. + nvx_rs_check.assert_called_once_with( + nvx_rs_binary, build_dir / nvx_rs.PROVENANCE_NAME, target=None + ) + + def test_runtime_provenance_validation_rejects_a_stale_nvx_rs(self): + # nvx.py never rebuilds an nvx-rs that exists, so one left from + # before a change of its sources, or that its provenance does not + # describe, would run. The adversarial executor validates it first. + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + build_dir = root / "build" + build_dir.mkdir() + provenance = build_dir / nvx_rs.PROVENANCE_NAME + other = root / "other-nvx-rs" + other.write_bytes(b"another nvx-rs") + + def require_file(path: Path, _description: str) -> Path: + return path + + def validate() -> None: + with ( + patch.object( + release, "artifact_path", side_effect=build_dir.joinpath + ), + patch.object(release, "openvmm_binary_path"), + patch.object(release, "require_file", side_effect=require_file), + patch.object(release, "_validate_openvmm_provenance"), + patch.object(release, "_validate_kernel_provenance"), + patch.object(release, "_validate_initramfs_provenance"), + ): + release.validate_runtime_artifact_provenance() + + self.nvx_rs_path.write_bytes(b"nvx-rs") + provenance.write_text( + json.dumps( + nvx_rs.provenance_record( + self.nvx_rs_path, "x86_64-unknown-linux-gnu" + ) + ), + encoding="utf-8", + ) + with _without_nvx_rs_variable(): + validate() + # A rebuild after the record. + self.nvx_rs_path.write_bytes(b"rebuilt nvx-rs") + with self.assertRaisesRegex( + common.ScriptError, "does not describe .* as a build from the" + ): + validate() + # The nvx-rs that NVX_RS names is the one that nvx.py runs. + with ( + patch.dict(os.environ, {nvx_rs.VARIABLE: str(other)}), + self.assertRaisesRegex(common.ScriptError, re.escape(str(other))), + ): + validate() + provenance.write_text( + json.dumps(nvx_rs.provenance_record(other, "x86_64-unknown-linux-gnu")), + encoding="utf-8", + ) + with patch.dict(os.environ, {nvx_rs.VARIABLE: str(other)}): + validate() + # A record of other sources. + record = nvx_rs.provenance_record(other, "x86_64-unknown-linux-gnu") + record["source_sha256"] = "0" * 64 + provenance.write_text(json.dumps(record), encoding="utf-8") + with ( + patch.dict(os.environ, {nvx_rs.VARIABLE: str(other)}), + self.assertRaisesRegex(common.ScriptError, "current nvx-rs sources"), + ): + validate() def test_package_rejects_dirty_openvmm_provenance(self): with tempfile.TemporaryDirectory() as temporary: From c931a3ae099998a179379e308ad5df612b24e4d6 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Sat, 17 Oct 2026 08:52:34 -0300 Subject: [PATCH 7/8] nvx-rs: count a VM as running until its last thread exits nvx-rs sandbox identifies the OpenVMM of a running sandbox by its process ID and start time, which identity.rs reads on Linux from /proc/PID/stat. Ported from the former sandbox_lifecycle.py, it counted every zombie as exited. But /proc reports a process as a zombie as soon as its main thread exits, while its other threads may still run and hold its descriptors, so exec and stop could reject a live VM as stale, and deprovision could remove the state of a VM that still runs. The library's platform::process_start_time, with which the process helpers wait for and kill OpenVMM, already counts such a process as running until its last thread exits, as Windows does, and so does the CLI's own Windows branch; only its Linux branch disagreed. linux_start_time now asks, for a zombie only, whether other threads remain, through the platform's other_threads_remain, which reads /proc/PID/task, and otherwise keeps its Python-compatible parsing and messages. other_threads_remain becomes pub(crate) for that. The stat-line test covers a zombie with and without other threads, and an error while listing them. A new Linux test, like process::tests::a_process_runs_until_its_last_thread_exits, makes the main thread of a Python process exit through pthread_exit while another thread sleeps: process_running holds until the process is killed. It fails without the fix. Copilot's review of #598 flagged this in code that #596 added, the nvx-rs CLI; the platform helper is already on dev. The change stays within the CLI's Linux identity check. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/src/host/cli/sandbox/identity.rs | 109 +++++++++++++++++++++--- nvx-rs/src/host/platform/linux.rs | 2 +- 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/nvx-rs/src/host/cli/sandbox/identity.rs b/nvx-rs/src/host/cli/sandbox/identity.rs index bd95bb75..7175d45c 100644 --- a/nvx-rs/src/host/cli/sandbox/identity.rs +++ b/nvx-rs/src/host/cli/sandbox/identity.rs @@ -15,9 +15,11 @@ use crate::host::python::fs::os_error_message; /// `_process_start_time` does, or the message of the error with which the state of the process /// cannot be determined. /// -/// A zombie counts as exited. On Windows, so does a process that the caller cannot open: OpenVMM -/// runs as the caller, so such a process cannot be its VM. Other hosts have no `/proc`, so every -/// process counts as exited there, as in Python. +/// A process runs until its last thread exits, as [`crate::host::platform::process_start_time`] +/// holds, so a zombie counts as exited only once its other threads have exited too, where Python +/// counted every zombie as exited. On Windows, a process that the caller cannot open counts as +/// exited: OpenVMM runs as the caller, so such a process cannot be its VM. Other hosts have no +/// `/proc`, so every process counts as exited there, as in Python. pub(crate) fn process_start_time(pid: &Int) -> std::result::Result, String> { #[cfg(target_os = "linux")] { @@ -32,7 +34,11 @@ pub(crate) fn process_start_time(pid: &Int) -> std::result::Result, } Err(error) => return Err(os_error_message(&error, Some(&path))), }; - linux_start_time(&stat) + linux_start_time(&stat, || { + pid.to_u32() + .map_or(Ok(false), crate::host::platform::other_threads_remain) + .map_err(|error| os_error_message(&error, Some(&format!("/proc/{pid}/task")))) + }) } #[cfg(windows)] { @@ -51,9 +57,13 @@ pub(crate) fn process_start_time(pid: &Int) -> std::result::Result, /// Reads the start time from the `/proc/PID/stat` line of a process, as `_linux_start_time` /// does: the command name may contain spaces and parentheses, so the fields after it start at -/// its last `)`, with the state as field 3 and `starttime` as field 22. +/// its last `)`, with the state as field 3 and `starttime` as field 22. Only for a zombie does it +/// ask `other_threads_remain` whether the process still runs. #[cfg(any(target_os = "linux", test))] -fn linux_start_time(stat: &[u8]) -> std::result::Result, String> { +fn linux_start_time( + stat: &[u8], + other_threads_remain: impl FnOnce() -> std::result::Result, +) -> std::result::Result, String> { let unexpected = || "process status has an unexpected format".to_owned(); let fields: Vec<&[u8]> = match stat.iter().rposition(|&byte| byte == b')') { Some(end) => stat[end + 1..] @@ -66,7 +76,8 @@ fn linux_start_time(stat: &[u8]) -> std::result::Result, String> { if fields.len() < 20 || start.is_empty() || !start.iter().all(u8::is_ascii_digit) { return Err(unexpected()); } - if matches!(fields[0], b"Z" | b"X") { + let state = fields[0]; + if state == b"X" || (state == b"Z" && !other_threads_remain()?) { return Ok(None); } std::str::from_utf8(start) @@ -105,22 +116,96 @@ mod tests { "123 (my (odd) prog) {state} 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 4242 19 20\n" ) }; - assert_eq!(linux_start_time(line("S").as_bytes()), Ok(Some(4242))); - assert_eq!(linux_start_time(line("R").as_bytes()), Ok(Some(4242))); - assert_eq!(linux_start_time(line("Z").as_bytes()), Ok(None)); - assert_eq!(linux_start_time(line("X").as_bytes()), Ok(None)); + let unasked = + || -> std::result::Result { panic!("only a zombie's threads matter") }; + let alone = || Ok(false); + let threads = || Ok(true); + assert_eq!( + linux_start_time(line("S").as_bytes(), unasked), + Ok(Some(4242)) + ); + assert_eq!( + linux_start_time(line("R").as_bytes(), unasked), + Ok(Some(4242)) + ); + assert_eq!(linux_start_time(line("Z").as_bytes(), alone), Ok(None)); + // A zombie main thread whose other threads still run is a live process. + assert_eq!( + linux_start_time(line("Z").as_bytes(), threads), + Ok(Some(4242)) + ); + assert_eq!( + linux_start_time(line("Z").as_bytes(), || Err("denied".to_owned())), + Err("denied".to_owned()) + ); + assert_eq!(linux_start_time(line("X").as_bytes(), unasked), Ok(None)); for malformed in [ &b"123 (prog) S 1 2"[..], b"no parenthesis", b"1 (p) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 x", ] { assert_eq!( - linux_start_time(malformed), + linux_start_time(malformed, unasked), Err("process status has an unexpected format".to_owned()) ); } } + /// Kills and reaps a child when dropped, so that a failed assertion leaves no process. + #[cfg(target_os = "linux")] + struct Reaped(std::process::Child); + + #[cfg(target_os = "linux")] + impl Drop for Reaped { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn a_vm_runs_until_its_last_thread_exits() { + // As in `process::tests::a_process_runs_until_its_last_thread_exits`, the main thread + // leaves through pthread_exit, which turns it into a zombie, while another thread sleeps. + use std::time::{Duration, Instant}; + let spawned = std::process::Command::new("python3") + .args([ + "-c", + "import ctypes, threading, time\n\ + threading.Thread(target=time.sleep, args=(60,)).start()\n\ + ctypes.CDLL(None).pthread_exit(None)\n", + ]) + .stdin(std::process::Stdio::null()) + .spawn(); + let mut child = match spawned { + Ok(child) => Reaped(child), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + eprintln!("skipping: python3 is not installed"); + return; + } + Err(error) => panic!("cannot start python3: {error}"), + }; + let pid = Int::from(child.0.id()); + let start_time = Int::from(process_start_time(&pid).unwrap().expect("python3 runs")); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).unwrap(); + if stat[stat.rfind(')').unwrap() + 1..] + .trim_start() + .starts_with('Z') + { + break; + } + assert!(Instant::now() < deadline, "the main thread did not exit"); + std::thread::sleep(Duration::from_millis(10)); + } + assert!(process_running(&pid, Some(&start_time)).unwrap()); + child.0.kill().unwrap(); + child.0.wait().unwrap(); + assert!(!process_running(&pid, Some(&start_time)).unwrap()); + } + #[test] fn this_process_runs_and_others_are_identified_by_start_time() { let pid = Int::from(std::process::id()); diff --git a/nvx-rs/src/host/platform/linux.rs b/nvx-rs/src/host/platform/linux.rs index b7bc3892..8af44fb2 100644 --- a/nvx-rs/src/host/platform/linux.rs +++ b/nvx-rs/src/host/platform/linux.rs @@ -64,7 +64,7 @@ pub(crate) fn start_time_from_stat( } /// Returns whether a process has threads besides its main thread. -fn other_threads_remain(pid: u32) -> io::Result { +pub(crate) fn other_threads_remain(pid: u32) -> io::Result { match fs::read_dir(format!("/proc/{pid}/task")) { Ok(threads) => Ok(threads.count() > 1), Err(error) From fdeb9592765db3d2f5e9f7b57c475bfc8e26636c Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Sat, 17 Oct 2026 08:52:34 -0300 Subject: [PATCH 8/8] adversarial: hand the scenarios the nvx-rs that the executor checked The executor validates and hashes the nvx-rs that NVX_RS names before the baseline boot, and nvx.py resolves a relative NVX_RS against its working directory: the executor's, at that point. The scenarios inherit NVX_RS unchanged but run in the repository root, so with a relative NVX_RS they ran another executable than the one validated, or found none. sanitized_environment() now makes NVX_RS absolute against the executor's working directory, so that the scenarios run the executable that the initialization checked. An empty NVX_RS, which nvx.py ignores, passes unchanged. A new test validates a relative NVX_RS in one working directory and resolves it in a scenario process in another; it fails without the fix. The environment test now expects the absolute path. Copilot's review of #598 flagged this. #597 added NVX_RS to the scenarios' environment, and the earlier commit of this series that validates the executor's nvx-rs made the mismatch matter. Closes #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/nvx_tools/adversarial_executor.py | 6 ++++ scripts/test_adversarial.py | 39 ++++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/scripts/nvx_tools/adversarial_executor.py b/scripts/nvx_tools/adversarial_executor.py index dfbdbd2c..8b2bd2fc 100644 --- a/scripts/nvx_tools/adversarial_executor.py +++ b/scripts/nvx_tools/adversarial_executor.py @@ -204,6 +204,12 @@ def sanitized_environment() -> dict[str, str]: for key, value in os.environ.items() if key.upper() in _ENVIRONMENT_ALLOWLIST } + # The scenarios run in the repository root, where a relative NVX_RS would + # name another executable than the one that the initialization validated + # in the executor's working directory. + configured = environment.get(nvx_rs.VARIABLE) + if configured: + environment[nvx_rs.VARIABLE] = str(Path(configured).absolute()) environment["PYTHONUNBUFFERED"] = "1" return environment diff --git a/scripts/test_adversarial.py b/scripts/test_adversarial.py index fc5e34da..996eba46 100644 --- a/scripts/test_adversarial.py +++ b/scripts/test_adversarial.py @@ -79,6 +79,7 @@ ReleaseBuildConstants, ) from nvx_tools.common import ScriptError +from nvx_tools.nvx_rs import find_binary as find_nvx_rs def _executor_result( @@ -2155,13 +2156,49 @@ def test_executor_environment_locates_nvx_rs_and_the_rust_toolchain(self) -> Non environment, { "PATH": "test-path", - "NVX_RS": "test-nvx-rs", + "NVX_RS": str(Path("test-nvx-rs").absolute()), "CARGO_HOME": "test-cargo-home", "RUSTUP_HOME": "test-rustup-home", "PYTHONUNBUFFERED": "1", }, ) + def test_scenarios_run_the_relative_nvx_rs_that_the_executor_validated( + self, + ) -> None: + # The executor validates a relative NVX_RS in its own working + # directory, and the scenarios run in another, the repository root, + # so the environment that they inherit names that executable. + previous = Path.cwd() + self.addCleanup(os.chdir, previous) + with tempfile.TemporaryDirectory() as temporary: + executor_directory = Path(temporary) + executable = executor_directory / "bin" / "nvx-rs" + executable.parent.mkdir() + executable.write_bytes(b"") + os.chdir(executor_directory) + try: + with patch.dict(os.environ, {"NVX_RS": os.path.join("bin", "nvx-rs")}): + validated = find_nvx_rs() + environment = sanitized_environment() + finally: + os.chdir(previous) + completed = subprocess.run( + [ + sys.executable, + "-c", + "from nvx_tools import nvx_rs; print(nvx_rs.find_binary())", + ], + cwd=Path(__file__).resolve().parent, + env=environment, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual(validated, executable.absolute()) + self.assertEqual(completed.stdout.strip(), str(validated)) + def test_scenarios_find_nvx_rs_with_the_executor_environment(self) -> None: with tempfile.TemporaryDirectory() as temporary: nvx_rs = Path(temporary) / "nvx-rs"