Skip to content

Add the nvx-rs CLI - #596

Open
Pedro Henrique Penna (ppenna) wants to merge 24 commits into
devfrom
agents/fix-issue-359-cli
Open

Pedro Henrique Penna (ppenna) wants to merge 24 commits into
devfrom
agents/fix-issue-359-cli

Conversation

@ppenna

@ppenna Pedro Henrique Penna (ppenna) commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Part of #359 (PR 3 of 5).

Summary

Adds the nvx-rs command-line interface: a cli feature, which implies host, and an nvx-rs binary (required-features = ["cli"]). It ports the runtime steps that nvx.py will delegate to it in PR D, faithfully: the same options, wire formats, limits, messages, outputs, exit statuses, outcome reports, locks, and state directories.

CLI reference

nvx-rs [-h] [--root PATH] artifacts
nvx-rs [-h] [--root PATH] egress compile PATH
nvx-rs [-h] [--root PATH] sandbox [run|provision|start|exec|stop|deprovision] [OPTIONS]

Build: cd nvx-rs && cargo build --release --features cli (target/release/nvx-rs[.exe]).

Artifact root

  • --root PATH: an NVX repository checkout (build/, openvmm/target/release/) or an extracted release (guest/, bin/), told apart as Artifacts::from_dir does. PR D passes its repository root.
  • Without --root: the release whose bin/ holds the executable (<root>/bin/nvx-rs[.exe]), or the checkout whose nvx-rs/target/ holds it at any depth, each with its SOURCE-MANIFEST.json; otherwise error: cannot locate the NVX artifacts: pass --root ..., status 1.
  • NVX_OPENVMM: an OpenVMM executable to run instead of the root's, made absolute against the working directory. It must be valid Unicode.

artifacts

Prints json.dumps(value, indent=2, sort_keys=True) of:

{
  "guests": [
    {"distribution": "Alpine Linux", "distro_layer": null, "distro_manifest": null,
     "initramfs": "ROOT/build/initramfs.cpio.gz", "name": "alpine",
     "package_manifest": "ROOT/build/initramfs.cpio.gz.packages.json"},
    {"distribution": "Ubuntu Base", "distro_layer": "ROOT/build/ubuntu-distro.erofs",
     "distro_manifest": "ROOT/build/ubuntu-distro.erofs.manifest.json", "...": "..."},
    {"distribution": "Azure Linux", "name": "azurelinux", "...": "..."}
  ],
  "kernel": "ROOT/build/vmlinux",
  "layout": "repository",
  "openvmm": "ROOT/openvmm/target/release/openvmm",
  "root": "ROOT"
}

layout is repository or release; every path is absolute and unchecked; openvmm is NVX_OPENVMM when set; guests are in nvx.py's order (alpine, ubuntu, azurelinux).

egress compile PATH

Prints {"allow": [RULE, ...], "deny": [RULE, ...]} in the same JSON layout, each list in the order in which OpenVMM receives --network-egress-allow and --network-egress-deny. A refused file prints error: <compile_policy_file's message> and exits 1.

sandbox

Every option of the sandbox subparser of scripts/nvx.py, with the same names, defaults, types, and choices (nvx-rs sandbox -h prints argparse's help with nvx-rs sandbox as the program). --opt value and --opt=value; a value that starts with - needs = unless it looks like a negative number or holds a space; repeated options accumulate; --mount-deny, --mount-allow, and --mount-write attach to the --mount before them; the operation defaults to run.

Operation Output Exit status
run >> COMMAND (subprocess.list2cmdline on Windows, shlex.join elsewhere), then OpenVMM's own streams OpenVMM's; 0 with --dry-run
provision nothing 0
start nothing 0
exec the workload's stdout, then its stderr the workload's
stop nothing 0
deprovision nothing 0

--outcome-report (run, exec): run passes --microvm-report to OpenVMM; exec checks the destination before it runs and writes {"operation_id": HEX32, "outcome": {"category": ..., "operation": "exec", "status_code": ...}, "schema_version": 1}.

State directory: config.json (formats 1 to 6, the lowest that holds the sandbox), runtime.json (format 1: pid, start_time, control_endpoint), control.capability, control.sock (POSIX), openvmm.log, outcome.json, and lifecycle.lock (deprovisioned\n tombstone), all written as Python writes them; JSON is json.dumps(value, indent=2, sort_keys=True) + "\n" byte for byte, mode 0600, through a temporary file and a rename.

Exit statuses

Status Meaning
0 success, or help
1 error: MESSAGE on stderr: what nvx.py's main() prints for a ScriptError, OSError, RuntimeError, or ValueError
2 usage error: the parser's usage and PROG: error: MESSAGE, as argparse; unrecognized arguments are reported by the top-level parser
130 Ctrl-C: Interrupted
other OpenVMM's status (run) or the workload's (exec), as SystemExit(status)

Text ends lines as Python's print does: \r\n on Windows, \n elsewhere; workload output passes through unchanged.

Test hooks (for PR D)

  • --root PATH and NVX_OPENVMM=PATH.
  • nvx-fake-openvmm (--features testing) now serves the sandbox profile: --microvm-lifecycle managed (capability on stdin, control console, SANDBOX_AGENT features, outcome report at stop) and one-shot (runs /bin/echo, /bin/sleep, /bin/true, /bin/false; others "not found" with 127; writes the outcome report if asked). Kernel command-line knobs: fake_exit_on_start=STATUS, fake_exit_status=STATUS, fake_guest_features=MASK, fake_legacy_guest=1, fake_boot_delay_ms, fake_crash_after_ms, fake_ignore_stop=1.
  • Example: NVX_OPENVMM=target/debug/nvx-fake-openvmm target/debug/nvx-rs --root .. sandbox run --layer distro,LAYER,UUID --scratch SCRATCH --entrypoint /bin/echo --arg hello.

Stack

Based on #595 (agents/fix-issue-359-launch-profile, 1fa32b8), which is based on #594 (agents/fix-issue-359-egress-policy, 78e77c3) and #561 (agents/fix-issue-358-build-switch, f1b8428). Until those merge, this PR's diff includes their commits. This PR's own commits:

  1. 0441360 nvx-rs: add the nvx-rs CLI with artifacts, egress, and sandbox run
  2. a7c803d nvx-rs: port the sandbox lifecycle of nvx.py to nvx-rs sandbox
  3. 6ae9554 nvx-rs: port the control-session tests of nvx.py
  4. 388d6fc nvx-rs: require the guest features of layered sandboxes
  5. 579813e nvx-rs: serve sandboxes from the fake OpenVMM
  6. cbdb791 nvx-rs: replay golden nvx.py sandbox scenarios
  7. 2ea9de3 doc: describe the nvx-rs CLI
  8. e74e3c6 nvx-rs: end an interrupted sandbox run as interrupted (review fix)

Each commit builds and passes clippy and the tests on Windows and Linux. Commit 1's lifecycle operations fail after their option checks until commit 2.

Changes to code that #594 and #595 own, kept additive: the private python module gains dumps, shell, value, float(), and lstat/lexists/fstat (cfg cli); scripts/nvx_rs_fixtures.py gains a CLI section and requires CPython 3.14.5+ for it. Base code gains ControlSession::send_request/read_response, process::Launched waits and kills, Windows byte locks and console interrupts, a Unix SIGINT handler, and GuestFeatures::LAYERED_SANDBOX. aci_edge_sandboxes and nvx::host::instance are unchanged.

Faithful-port notes

FEATURES

As decided in #359, start sends FEATURES after its PING and requires GuestFeatures::LAYERED_SANDBOX, not REQUIRED (the direct agent's set):

A guest that lacks a required feature, or predates the request (an unsupported-operation refusal counts as no features), is stopped and its runtime files removed as for a failed start, and start fails with error: the guest image lacks control features that layered sandboxes need (per-execution environments); use an NVX release or build that includes them, naming the missing features with GuestFeatures::missing. The Rust agent of #561 and the C agent before it advertise bits 0, 2, and 4 in sandbox mode, and the real start on all three hypervisors negotiated successfully. This is the one intended behavior change; nvx.py does not ask.

Other intended deviations

  • No abbreviations of long options (PR D passes canonical options).
  • Help and usage are argparse's for 80 columns (a pipe); an invalid choice lists its choices quoted, as CPython 3.14.5 and later do (3.14.0–3.14.4 print them unquoted).
  • Where nvx.py would end with a traceback (for example a config.json without a field that _start reads, or an OverflowError), nvx-rs prints error: <str(exception)>, status 1.
  • Output to pipes and files is UTF-8 on Windows, where Python uses the ANSI code page unless PYTHONIOENCODING/UTF-8 mode is set.
  • start treats a control endpoint served by a process other than the launched OpenVMM as not yet available (as nvx::host::session does), and starts OpenVMM without a console window on Windows (as nvx::host::process does).
  • exec refuses more than the 1 MiB of workload output that the guest agent forwards (only a misbehaving guest sends more).
  • Like nvx.py, a start whose OpenVMM exits at once waits until --timeout for the endpoint before reporting the exit; that is kept, not fixed.

Port details worth reviewing

  • Locks interoperate with Python's: POSIX flock(LOCK_EX|LOCK_NB); Windows CreateFileW with read/write/delete sharing and FILE_FLAG_OPEN_REPARSE_POINT, then LockFile of byte 0 as msvcrt.locking(LK_NBLCK, 1). Standalone-regular-file checks, 25 ms retries within --timeout, hand-off waits, and the directory-removal protocol are ported, with Windows errors classed as CPython maps them.
  • Process identity is Python's (/proc/PID/stat start time with zombies as exited; Windows creation time), so runtime records are valid across implementations.
  • The session keeps control_session.py's messages, per-operation deadlines, direction-specific timeout messages, ControlEndpointClosed versus reset, closing after a failed transfer, and exec's validation order. Waits run in 250 ms slices so Ctrl-C ends them.
  • start: 32-byte non-zero capability on stdin through a pipe closed before OpenVMM starts; //./pipe/openvmm-microvm-<uuid4 hex> on Windows, <state>/control.sock elsewhere; _start's command line and network-option order; detached (own session / CREATE_NEW_PROCESS_GROUP), log appended; failed-start cleanup order and _startup_exit_message.
  • Ctrl-C during run: OpenVMM gets 0.25 s on POSIX, then is killed, as subprocess.run does; on Windows the CLI waits, as Python's wait cannot be interrupted either. A recorded Ctrl-C decides how a command ends, as KeyboardInterrupt does, also when the same Ctrl-C ended OpenVMM first.

test_control_session.py mapping

Python test Rust test (host/cli/session/tests.rs unless noted)
test_exec_streams_output_and_returns_bounded_status exec_streams_output_and_returns_bounded_status
test_exec_rejects_unbounded_or_relative_arguments exec_rejects_unbounded_or_relative_arguments
test_exec_encodes_extended_cwd_and_environment_exactly exec_encodes_extended_cwd_and_environment_exactly
test_exec_distinguishes_omitted_and_empty_environment exec_distinguishes_omitted_and_empty_environment
test_exec_layers_only_a_supplied_environment_over_the_defaults exec_layers_only_a_supplied_environment_over_the_defaults
test_exec_accepts_full_uint32_timeout_range_without_waiting exec_accepts_full_uint32_timeout_range_without_waiting
test_exec_rejects_invalid_timeout_and_execution_fields_before_sending exec_rejects_invalid_timeout_and_execution_fields_before_sending (the TypeError cases have no CLI equivalent: values arrive parsed)
test_exec_rejects_invalid_response_timeout_before_sending exec_rejects_invalid_response_timeout_before_sending
test_exec_rejects_unknown_exit_category exec_rejects_unknown_exit_category (also cancelled and non-ASCII)
test_exec_refusal_keeps_status_category_and_diagnostic exec_refusal_keeps_status_category_and_diagnostic (nvx.py prints only the message)
test_session_reset_is_not_a_closed_endpoint session_reset_is_not_a_closed_endpoint
test_operations_bound_their_requests_by_their_deadlines operations_bound_their_requests_by_their_deadlines
test_failed_transfer_closes_the_session failed_transfer_closes_the_session
CapabilityPipeTests (3) Not ported: the pipe is nvx::host::process::spawn's (#561), which closes it before OpenVMM starts; every golden lifecycle start exercises it, since the fake reads exactly 32 bytes then EOF and refuses an all-zero capability
test_write_that_sends_nothing_reports_a_closed_endpoint (mock) a_closed_endpoint_ends_the_operation_with_its_own_error
test_read_reports_a_closed_endpoint endpoints::read_reports_a_closed_endpoint (POSIX)
test_reset_and_broken_pipe_report_a_closed_endpoint endpoints::reset_and_broken_pipe_report_a_closed_endpoint (POSIX)
test_request_to_a_stalled_endpoint_times_out (socket) request_to_a_stalled_endpoint_times_out_and_closes_the_session
test_write_that_moves_nothing_reports_a_closed_endpoint (mock) endpoints::write_reports_a_closed_endpoint (Windows)
test_write_reports_a_closed_endpoint endpoints::write_reports_a_closed_endpoint (Windows)
test_read_reports_a_closed_endpoint_after_its_data endpoints::read_reports_a_closed_endpoint_after_its_data (Windows)
test_read_times_out_without_losing_later_data nvx::host::session::tests::timed_out_polls_keep_partial_records (buffering) and endpoints::read_times_out_and_closes_the_session (Windows)
test_interrupted_write_is_cancelled_before_the_interruption_propagates Not ported: the CLI checks Ctrl-C between waits and bounds each write by its timeout; nvx::host::platform cancels a pipe write that times out
test_request_to_a_stalled_endpoint_times_out_and_closes_the_session (pipe) request_to_a_stalled_endpoint_times_out_and_closes_the_session

Added: response_that_does_not_arrive_times_out, output_beyond_the_guest_limit_breaks_the_protocol, features_of_a_guest_that_predates_them_are_none, protocol_violations_keep_the_messages_of_python, and endpoints::endpoints_that_never_appear_time_out_with_their_path on both hosts.

Golden scenarios and interoperability

scripts/nvx_rs_fixtures.py (single --check) records nvx-rs/tests/fixtures/cli.json from nvx.py sandbox run as a command (subprocess, artifact_path and openvmm_binary_path patched to a temporary root whose OpenVMM is a copy of nvx-fake-openvmm) and from the Python functions behind egress compile and artifacts: 24 usage errors and helps, 69–70 command_sandbox and launch checks, 10–11 dry runs, 11 one-shot runs, lifecycles in every config format (format 3 on POSIX only, since Windows refuses caller-owned shares), and 8 refusal/failure scenarios; 215 steps on POSIX and 209 on Windows. Each step records status, stdout, stderr, and state files (config.json formats 1–6, runtime.json, entries and modes, outcome reports, and the command line start passed to OpenVMM), with the root, PIDs, start times, and random identifiers as placeholders. The posix and windows sections are recorded on each host kind. The generator then plays every scenario with nvx-rs, and every lifecycle with nvx.py and nvx-rs alternating both ways (Python provisions and Rust starts, executes, stops, and deprovisions, and the reverse), and fails on any difference. nvx-rs/tests/cli.rs replays the host's section without Python, plus the FEATURES rejection, root discovery, relative NVX_OPENVMM, top-level usage errors, and Linux Ctrl-C during run and exec.

Validation

Environment Evidence
Windows 11 (local), Rust 1.95.0 and 1.89 nvx.py verify; nvx-rs fmt, Clippy (--all-targets --all-features, --lib --no-default-features), tests (--all-features and default), rustdoc -D warnings, MSRV 1.89, aarch64-apple-darwin check, bundled-artifacts test; aci_edge_sandboxes fmt, Clippy ×2, tests ×2, doc, MSRV; test-agent-bundling --toolchain 1.95.0; ruff check/format; pyright Linux and Windows; markdown links; validate-nvx unittest list; nvx_rs_fixtures.py --check (CPython 3.14.7): all pass
Ubuntu (WSL2), Rust 1.95.0 and 1.89 Same nvx-rs and aci_edge_sandboxes gates, macOS check of both crates, bundled-artifacts test, test-agent-bundling, validate-nvx unittests, nvx_rs_fixtures.py --check (CPython 3.14.7): all pass; every commit of this PR checked with fmt, Clippy ×2, and test --all-features
Linux KVM (prometheus32, bare metal) Release nvx-rs --features cli on this branch, OpenVMM 5ab8a84 and Alpine initramfs from #594's worktree, Ubuntu distro layer and scratch template from #561's: one-shot run (+--dry-run, --outcome-report, /bin/false → 1); provision, start (FEATURES negotiated), exec with --cwd, --environment, --environment-file, --inherit-default-environment, --outcome-report, --exec-timeout-ms (124, timeout), missing program and cwd (125); stop, start again, exec, stop, deprovision; a nvx.py-provisioned sandbox started and exec'd by nvx-rs, exec'd and stopped by nvx.py, deprovisioned by nvx-rs: all as expected
Linux MSHV (prometheus30, bare metal) Same sequence with --hypervisor mshv: all as expected
Windows WHP (prometheus28, bare metal) Same sequence with --hypervisor whp (named-pipe endpoint): all as expected

Left to CI: the workload jobs (test-microvm, test-openvmm, guest builds and determinism, benchmarks), which keep exercising the Python path until PR D; the guest-binary steps of check-nvx-rs; and CI's own runs of the jobs above.

Replace native C helper compilation with the shared Rust guest-binary
builder introduced by #528. Build one checked set of all nine
executables per initramfs, install it at the established /sbin/nvx-*
paths, and record the aggregate source and individual binary digests.

Remove the obsolete C compiler flags, helper inventories, and build
functions. Keep C test-only flags local until the C-driven suites are
deleted later in this stack.

Part of #358.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Build one checked set of Rust guest executables in the digest-pinned Rust
1.95.0 image and reuse its exact bytes in Alpine, Ubuntu, and Azure
Linux through a guest-only base, so the kernel and Linux source targets
never compile them. Keep the toolchain available only for Ubuntu's two
fresh determinism attempts, and remove the guest C compilers from the
base.

Bind reusable binary sets to normalized Rust-source, toolchain, target,
profile, and executable digests, and refuse the output of an earlier
build that a rebuild would upgrade in place. Record all nine helpers in
the Azure Linux package manifest.

Part of #358.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Cover the nvx-rs guest inputs in every image identity, package the crate
without its target directory, and declare the exact Rust toolchain and
musl target in SOURCE-MANIFEST.json. Verify those pins and inputs from
nvx.py verify and release packaging.

Make device-I/O benchmark provenance validate the shared Rust source
digest, and bind the Azure Linux cache input to the newly tracked files.

Part of #358.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Hash the complete nvx-rs tree except target, the exact toolchain pin, and
the shared build modules into every guest cache, and advance the guest
cache schemas. Because hashFiles hashes contents only, also append the
path-aware Rust source digest that the guest package manifests record,
which the new guest-binary-source-identity command prints, so a module
move that keeps every file's bytes still rebuilds the images. Treat
nvx-rs changes as OpenVMM VMM-test inputs so they rebuild and boot the
affected images.

Keep Copilot setup and both Linux setup paths on the pinned musl target,
and revalidate Copilot setup for every nvx-rs change.

Part of #358.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove the nine remaining guest C implementations and the C-compiled
Python suites whose behavior the nvx-rs unit, guest-binary, loopback, and
real-VM tests now cover. Drop the deleted modules from both CLI validation legs.

Add a repository regression that rejects C, header, or assembler sources
outside the OpenVMM submodule.

Part of #358.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Document the one pinned build shared by native builds, Docker, and CI,
the nine binaries shipped by every guest, their provenance, cache inputs,
test ownership, and release source. Now that the Rust definitions are
authoritative, replace the obsolete source comparisons with literal wire
values and remove C-era wording.

Extend the third-party notices for the Rust standard libraries,
compiler_builtins and its compiler-rt-derived routines, libc, nix,
getrandom, and their linked transitive crates, and musl's complete
guest-binary scope.

Closes #358.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port scripts/nvx_tools/egress_policy.py, which compiles the files of
nvx.py's --network-egress-policy-file option, to
nvx::host::network::policy, so that the nvx-rs CLI can replace it.
Python stays authoritative until nvx.py delegates to nvx-rs. The port
accepts the same files, compiles them to the same rules, and fails with
the same messages:

- compile_policy_file reads at most 1 MiB and one byte of a file, and
  compile_policy_bytes and compile_policy compile its contents or an
  already parsed serde_json value. The CompiledEgressPolicy result has
  the serde form {"allow": [...], "deny": [...]}.
- The format keeps Python's root fields, the MXC and legacy flat rule
  forms, the tcp, udp, icmp, and any protocols, and TCP and UDP port
  ranges, and runs every check in Python's order with its message.
- The lowering keeps Python's steps: address-only rules, rules for every
  port of ICMP, TCP, and UDP widened into the address-only cover, the
  sweep over TCP and UDP port boundaries, the budget of 256 native rules
  that both families of an action share, and the sort by family,
  address, prefix length, protocol, and ports.

network.rs becomes network/mod.rs, beside address.rs, the networks and
address intervals, and lowering.rs, the rule model and the lowering.
openvmm_arguments still expands NetworkPolicy rules as before.

The format depends on Python semantics, which a crate-private
host::python module provides for this and later ports:

- python::json reads a document as json.loads does, with hooks in the
  place of object_pairs_hook and parse_int: NaN and Infinity are floats,
  numbers beyond a float's range are infinities, integers keep their
  digits, and a \ud800 escape stays an unpaired surrogate. It keeps
  values in an arena and never recurses. The policy reader's hooks
  refuse a repeated field when its object closes and an integer of more
  than 64 digits when it is read, so their errors surface in Python's
  order. serde_json, which nvx-rs already depends on, cannot do this: it
  refuses NaN, Infinity, 1e400, and lone surrogates, which Python reads
  and the compiler then reports with its own messages. No dependency is
  added.
- python::text holds strings that may contain unpaired surrogates,
  ordered by code point as Python orders them, and shows a surrogate as
  \udXXX, which is how Python prints it.
- python::path gives str(pathlib.Path(path)) for POSIX and Windows
  paths, as CPython 3.14 splits them, and the normalized path that
  Python opens, so that messages name files as nvx.py does.

Networks are read as ipaddress.ip_interface reads them: host bits are
cleared, IPv4 netmasks and hostmasks, prefix lengths with leading zeros
up to Python's 4300-digit int limit, and embedded IPv4 addresses are
accepted, and IPv6 scopes are found and refused. They print as Python
3.13 and later print them, with IPv4-mapped addresses in dotted form.

One input differs. Python's decoder raises RecursionError for a
document nested more deeply than its stack allows, at a depth that
depends on the Python version and the platform. This reader reads any
depth, so such a document fails as an invalid policy instead.

The tests port each case of scripts/test_egress_policy.py under its
name. The cases that mock ipaddress.summarize_address_range and
_merge_intervals to bound the work read thread-local counters of the
lowering instead. Unit tests cover the JSON reader, strings, paths, and
networks against values that Python 3.14 produced.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add scripts/generate_egress_policy_fixtures.py, which runs the Python
compiler of scripts/nvx_tools/egress_policy.py over a corpus of policy
files and records each file with its outcome, its native rules or its
error message, in nvx-rs/tests/fixtures/egress-policy.json. The Rust
tests replay every entry through compile_policy_bytes and, from a real
file, compile_policy_file, and need no Python, so the fixture outlives
the Python compiler, which a later change in this stack deletes.

The corpus holds 784 policy files:

- every file that scripts/test_egress_policy.py compiles, which the
  script captures by running that suite, except those of the two tests
  whose files exceed 64 KiB, which pin their complete outcomes in
  Python and in their Rust ports;
- hand-written cases that reach every message and JSON edge: syntax
  that json.loads refuses, NaN, Infinity, floats, Booleans, and 64- and
  65-digit integers as ports, repeated fields and long integers in
  document order, unpaired surrogates in names, networks, scopes, and
  protocols, unknown fields in code-point order, each check of both
  rule forms in order, network spellings as ip_interface reads them,
  the rule limit across protocols and families, and files at and beyond
  the 1 MiB limit; and
- 200 random policies from seed 359 that mix both rule forms and both
  families, dense and sparse networks written with host bits, masks,
  and exceptions, overlapping protocols and port ranges, crowds of
  rules near the limit, broken fields, and varied, truncated, and
  altered JSON text.

The fixture also records the errors for a missing file and a directory,
and how PurePosixPath and PureWindowsPath print 55 paths, which the
tests compare with python::path. Messages name the policy file
{path}, and show unpaired surrogates as Python prints them. Inputs are
text, or hexadecimal for bytes that are not UTF-8, with runs of 256 or
more characters recorded as repetitions, and each entry is a line of
ASCII JSON, about 1.1 MB in all.

The script needs Python 3.13 or later, whose ipaddress module prints
IPv4-mapped IPv6 addresses in dotted form, as the port does. Its output
depends only on the seed and the corpus size, so --check verifies that
the fixture is current. --random-cases and --output write larger
corpora, and NVX_EGRESS_POLICY_FIXTURE points the tests at one. The
script passes ruff and strict pyright on Linux and Windows.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx::host::network::openvmm_arguments, which the OpenVMM backend of
aci_edge_sandboxes calls, expanded each EgressRule into OpenVMM rules on
its own, then sorted and deduplicated the strings. It now lowers each
action's rules with the lowering of egress policy files, so that NVX
has one lowering. An EgressRule is a policy file's MXC rule whose empty
to or ports stands for an absent field, and messages name its fields
after EgressRules::name, where a file's messages say allow or deny, as
in "network.egress.allow[0].to[1].cidr is not a valid IPv4 or IPv6
CIDR: x".

openvmm_arguments keeps the checks that only a NetworkPolicy has, which
the Python compiler lacks: no device for a closed posture, the /29
limit of host-loopback access, and the rules of host-loopback forwards.
The check that no deny rule covers the guest network of forwards now
reads the deny rules as the lowering parsed them, so it follows the same
normalization. Before, it parsed them with the stricter Cidr and missed
a network written with host bits, which the old expansion also refused.

The observable changes, which aci_edge_sandboxes/README.md lists as
breaking changes:

- The OpenVMM rules follow family, address, prefix length, protocol,
  and ports, instead of string order.
- Merging destinations and port ranges, and subsuming narrower rules,
  usually needs fewer rules, so some policies beyond the limit of 256
  now fit: 256 adjacent /24 destinations become one /16 rule. Port
  ranges that overlap only partly across networks can also need a rule
  for their union.
- The backend's rule checks report the compiler's messages. Through
  AciEdgeSandbox, whose request validation runs first, only the limit
  message, now "network.egress.allow emits at most 256 native rules",
  changes. The error codes are unchanged.
- Without that validation, the backend accepts networks with host bits
  and IPv4 netmasks, and refuses port 0, which it passed to OpenVMM.

Tests: the nvx-rs network tests pin the new order, merging, messages,
and coverage check. ipv6_rules_expand_exactly, now
ipv6_rules_lower_exactly, expects ::/0 first.
inexpressible_rules_are_rejected, now
invalid_rules_are_rejected_with_the_policy_compilers_messages, checks
each message, and its 300-destination rule, which now lowers to one
rule, moved to rules_lower_like_egress_policy_files.
exceptions_that_split_a_network_too_finely_are_rejected expects the
limit message. network_policies_convert_field_for_field expects the new
ICMP message and also checks the scope and limit messages. The other
aci_edge_sandboxes tests, including the fake-OpenVMM test that pins
the launch arguments, pass unchanged.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Describe nvx::host::network::policy in nvx-rs/README.md: what it ports
and that Python stays authoritative until nvx.py delegates to nvx-rs,
the policy format, the lowering and the order of its rules, how
openvmm_arguments, and so aci_edge_sandboxes, share that lowering, the
fidelity of its messages and the one input on which it differs from
Python, and the parity fixture with its generator. The module table
gains the policy module, and its network row describes NetworkPolicy
and the shared lowering instead of the expansion.

doc/project-structure.md lists the generator, the parity fixture, and
the compiler in its descriptions of scripts/ and nvx-rs/.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
An MXC rule pairs each family of its destinations with each of its
port selectors, and `combine`, formerly `mxc_rules`, gave every pair
its own copy of the family's address intervals. Python's
`_parse_mxc_rule` and `_parse_legacy_rule` share one tuple among the
rules of a family instead. A policy file well within the 1 MiB limit,
such as 8192 destinations, none adjacent to another, with 8192 port
selectors, made the port copy 2 GiB of 32-byte intervals, and the
unions of `_compile_family` flattened the shared addresses once for
each selector again.

- `Rule.addresses` is an `Rc<[Interval]>` that the rules of one rule's
  selectors share: for MXC rules, legacy rules, and the typed rules of
  `openvmm_arguments` alike, which all build their rules with
  `combine`.
- The union of the rules for every protocol, and of those for every
  port of each protocol, merges each distinct address set once. Python
  merges the same addresses once for each selector; the union is the
  same.
- The port sweep asks once for each address set whether `covered`
  holds it entirely, rather than once for each rule.
- An `AddressSet` compares rule addresses by value, as Python's tuples
  compare, and compares a shared allocation with itself without
  reading it, as CPython's dictionaries do. The sweep counts its
  active rules by it, as `_lower_protocol_rules` counts them in a
  `Counter`.

The output, the errors, and the order of both are unchanged: the
parity fixture and the ported Python tests still pass. New tests check
that the rules of a rule's selectors share one allocation, that
address sets compare by value, that the unions read shared addresses
once (64 intervals rather than 4096), and that three 8192-by-8192
policies compile as Python 3.14 compiles them: to `0.0.0.0/0` with a
covering rule, and to the 256-rule limit errors without one. Python
takes 15 to 27 seconds for each. Before this change, the debug build
of that test took 171 seconds and peaked at 4.05 GiB; it now takes
0.5 seconds and 48 MiB.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port SandboxLaunch, SandboxLayer, SandboxMount, and the helpers of
scripts/nvx_tools/sandbox.py to a new nvx::host::sandbox module, so
that the nvx-rs CLI can launch sandboxes as nvx.py sandbox does.
Python stays authoritative and unchanged until nvx.py delegates to
nvx-rs.

The port keeps the Python contract byte for byte: the limits, option
semantics, OpenVMM arguments, kernel command line, and messages, with
refused requests as Unsupported, missing images as Unavailable, and
failed host queries as Io errors in Python's [Errno N] form.

- Layers: one to three EROFS layers, distro, runtime, and custom,
  ordered by role, with duplicate roles refused in sorted order. A
  --layer value is ROLE,PATH,EROFS_UUID, and the UUID takes every form
  that uuid.UUID reads: braces, urn:uuid:, no hyphens, and uppercase.
  Layer and scratch paths with ',' or ';' are refused.
- Settings: an absolute entrypoint and nonempty arguments without
  whitespace, a lowercase RFC 1123 hostname label, a non-root workload
  identity, 65534:65534 by default, which parse_workload_identity
  reads as int(field, 10) does, and positive limits, with
  nvx_pids_max one above the request.
- Shares: --mount GUEST_TARGET,HOST_PATH[,ro|rw], whose target ends at
  the first comma and whose mode follows the last one; the target
  rules, at most 4096 UTF-8 bytes, and the reserved targets; at most
  128 denied, allowed, and writable paths each; the overlap and
  single-owner checks; caller ownership refused on Windows; file and
  directory shares; the aggregate at /run/nvx/shares with children
  named INDEX-SHA256(TARGET)[:32] and its nvx_share= tokens; and the
  host-path checks, by Path.resolve() and (st_dev, st_ino) lineage.
- Kernel command line: NUL and the nvx_* and virtfs_* tokens refused,
  the tokens in Python's order, and the 1024 bytes that OpenVMM leaves
  of the 2048-byte x86 command line, which also hold OpenVMM's
  bootstrap tokens for the shares and the NUL.

The private python module that the egress policy compiler introduced
gains what these rules rely on, after CPython 3.14: builtins, with
str.isspace, which unlike char::is_whitespace includes U+001C to
U+001F, str.split() and str.strip(), repr of a str, and int, with
signs, underscores, Unicode digits, surrounding whitespace, and the
4300-digit limit; uuid, for uuid.UUID; unicode, the Unicode 16.0.0
tables of str.isprintable and int; and fs, the queries of os.path and
os.stat, with Path.resolve() ported from posixpath.realpath and
ntpath.realpath. On Windows, os.stat identifies a file by the 64-bit
volume serial number and 128-bit file ID of FILE_ID_INFO, as Python
3.12 and later do, through the windows-sys features that the crate
already enables. Operating-system strings that are not valid Unicode,
which Python would carry as surrogates, are refused.

python::path gains PurePath, a Unicode path of either pathlib flavor
with its parts, parents, joins, and case-insensitive comparison on
Windows, and Flavor, with the splitroot, join, isabs, and split of
posixpath and ntpath, and ntpath.normpath. One parser serves both
python_path and PurePath, so the printed and the pure paths cannot
disagree. Windows paths follow CPython on Windows, whose splitroot and
normpath are C functions that index UTF-16 code units: a character
beyond the Basic Multilingual Plane before a colon is no drive there,
although Python's own ntpath, which other hosts use, reads one.

scripts/generate_egress_policy_fixtures.py becomes
scripts/nvx_rs_fixtures.py, one generator for every Python-parity
file of nvx-rs, which now also writes the Unicode tables, with one
--check for all of them. It needs CPython 3.14, whose Unicode database
the tables record, and says so; --output and --random-cases still
write a larger egress policy corpus. The egress policy fixture
regenerates byte for byte, except its comment, which names the
script.

Aggregate child names need SHA-256 on the host, so the host feature
enables sha2 0.10, which the bundled build script already uses and
locks, as does the aci_edge_sandboxes agent, instead of another crate.

The unit tests port the launch-profile cases of
scripts/test_nvx_tools.py, and check require_file, share kinds,
host-path identity, and bind and hard-link identity on temporary
directories on Linux and Windows.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port the pure checks that scripts/nvx.py applies to the options of
nvx.py sandbox and nvx.py run to nvx::host::sandbox::options, so that
the nvx-rs CLI keeps their order, semantics, and messages and stays a
thin layer over the library.

- SandboxOptions::check: the checks of command_sandbox that precede
  the launch, in its order: --dry-run outside run, --environment with
  --environment-file, managed exec options outside exec, the managed
  exec environment, network policy options outside run and provision,
  systemd entrypoints, --outcome-report outside run and exec, share
  options without --mount, --mount outside run and provision, --net
  without --network-profile or the reverse, and a missing --layer or
  --scratch.
- sandbox_mounts, from _sandbox_mounts: with one --mount, every
  --mount-deny, --mount-allow, and --mount-write names a path in it;
  with several, each names a path in the --mount before it. Each
  share takes the kind that mount_kind finds for its host path.
- validate_sandbox_systemd_policy and SYSTEMD_ENTRYPOINTS: a distro
  layer's manifest, next to it, must be a JSON object of format 1 that
  names the layer and its SHA-256 digest and lists packages with
  names, none of them systemd. The format is compared as == compares
  it, so true and 1.0 match, and the manifest is read with universal
  newlines, as read_text reads it.
- validate_run_share_targets, from _validate_run_share_targets: the
  guest targets of several nvx.py run shares, or of a shared file.
- validate_exec_environment and exec_environment_from_file: at most
  256 nonempty KEY=VALUE entries of at most 4096 UTF-8 bytes, without
  NUL or a repeated key, from --environment or from a JSON array in a
  file of at most 1 MiB, as encode_exec_environment checks them.

These checks fail with the messages of Python's JSON decoder and UTF-8
codec, so the private python module gains them:

- python::json keeps its API, its hooks, and its arena, but its reader
  is now a port of the C scanner of CPython 3.14, scan_once_unicode
  and the functions that it calls, with the checks of
  JSONDecoder.decode around them. It accepts what the previous reader
  accepted, as the egress policy fixture and tests confirm, and a
  refusal now carries the message and the position of Python's
  JSONDecodeError, such as Expecting ',' delimiter, instead of the
  offset at which reading stopped, which is not always Python's
  position: an unterminated string fails at its opening quote.
  syntax_error formats it as str() of the error, with its line,
  column, and character. Loads are the hooks of json.loads without
  arguments, whose int refuses more than 4300 digits, and
  Document::get reads a member as the dict that Python builds does.
- python::text gains PyText::encode_utf8 and decode_utf8, with the
  messages of UnicodeEncodeError and UnicodeDecodeError, so PyText is
  the one string type that holds surrogates.
- python::path gains name and with_name, and python::fs exists.

Argument parsing and type conversion stay with the CLI, as do the
lifecycle operations.

The unit tests port the option cases of scripts/test_nvx_tools.py:
the attribution of policy paths to shares, file shares, the systemd
policy, the run share targets, and the environment rules, with the
environment files and distro manifests on temporary files. The JSON
and codec errors have tests of their own.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Record what the Python code does with fixed and seeded random inputs,
and compare the Rust port with those records in golden tests, so that
the port stays faithful byte for byte without Python at test time,
also after nvx.py delegates to nvx-rs and the Python code goes away.

scripts/nvx_rs_fixtures.py now also writes three fixtures to
nvx-rs/tests/fixtures, which its --check covers with the others:

- python.json: the str.isspace, decimal digit, and str.isprintable
  tables; repr, str.split(), str.strip(), int, and uuid.UUID of
  strings; str(), parts, name, parents, and is_absolute of pure paths,
  joins, and Windows path equality; ntpath.normpath; and the values
  and errors of json.loads and of the UTF-8 codec.
- sandbox.json: parse_workload_identity, SandboxLayer.parse,
  parse_mount_value, validate_mount_target, aggregate_child_name,
  SandboxMount with its OpenVMM arguments, command-line fragment, and
  absolute paths, and SandboxLaunch with its OpenVMM arguments and
  kernel command line. The launches include, for each kind of share
  set, command lines that fill the 1024-byte budget exactly and that
  exceed it by one byte.
- sandbox_options.json: _validate_run_share_targets, _sandbox_mounts,
  encode_exec_environment, the --environment-file rules of
  command_sandbox, which runs with exec_workload patched, and
  _validate_sandbox_systemd_policy, on temporary files.

The generator patches sandbox.Path with pure POSIX and Windows paths
whose current directory is fixed, so that the cases of both flavors
come out the same on any host: CPython 3.14 writes identical files on
Linux and Windows. It writes ASCII JSON with one case per line. The
random corpus is mostly valid input, so that most cases reach the
arguments and command lines, and invalid at a fixed rate. Each
fixture has a random generator of its own, so the egress policy
fixture is unchanged.

The Rust golden tests read the fixtures with serde_json, run the same
inputs through the port, and report each case that differs. They run
the cases of both path flavors on every host, through the internal
constructors that take a flavor and a fixed current directory, and
the file cases on temporary files. The JSON cases go through
python::json with the hooks of json.loads, so they check the reader
that the egress policy compiler shares. .gitattributes marks the
fixtures, about 1.2 MB, as generated, so that reviews collapse them.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extend nvx::host::artifacts to every artifact of the guests that
nvx.py runs, so that nvx-rs can stand in for the runtime lookups of
scripts/nvx_tools/common.py: artifact_path, openvmm_binary_path, and
the require_file checks of nvx.py run and nvx.py sandbox.

- Guest describes the Alpine, Ubuntu, and Azure Linux guests of
  scripts/nvx_tools/guests.py: their names, distributions, initramfs
  images and package manifests, and the Ubuntu distro layer,
  ubuntu-distro.erofs, with its manifest. Guest::from_name fails as
  guest_descriptor does.
- ArtifactDir names them in a repository checkout, as build/NAME and
  openvmm/target/release/openvmm[.exe], where local builds write them
  and where nvx.py download installs a release, or in an extracted
  release package, as guest/NAME and bin/openvmm[.exe], where
  package_release stages them. ArtifactDir::from_dir tells the
  layouts apart as Artifacts::from_dir does, and makes the directory
  absolute without resolving links, so callers pass the resolved
  REPO_ROOT of nvx.py as it is.
- require_openvmm, require_kernel, and require_initramfs check the
  files with the messages of nvx.py: "OpenVMM release binary not
  found: PATH", "Linux direct kernel not found: PATH", and
  "DISTRIBUTION initramfs not found: PATH". sandbox_artifacts checks
  the OpenVMM executable, the kernel, and the Alpine initramfs in the
  order of nvx.py sandbox run and start, which call the last
  "initramfs".
- paths lists every artifact as ArtifactPaths, which serializes as
  JSON, with the guests in nvx.py's order, for nvx-rs artifacts.

Like nvx.py, the lookup reads no SOURCE-MANIFEST.json, and checks only
the files that a command needs. Artifacts and its discovery, which
aci_edge_sandboxes uses, are unchanged: Artifacts::from_dir only
shares its layout detection, and the systemd policy of the sandbox
options now takes the distro manifest suffix from here.

The tests cover both layouts, the layout of this repository, the
order and messages of the checks, and the JSON form.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Describe in nvx-rs/README.md the nvx::host::sandbox module, with its
mount and options submodules, and the guest artifact lookup that
ArtifactDir, Guest, and ArtifactPaths add to nvx::host::artifacts,
beside the discovery that aci_edge_sandboxes uses. A new Python parity
section explains which Python semantics the ports keep, including the
egress policy compiler, the private python module that implements
them, and the fixtures that scripts/nvx_rs_fixtures.py records, with
the commands that regenerate and check them, the CPython 3.14 that
they need, and why CI does not check them; the egress policy section
points to it. The features table names sha2, which the host feature
now enables.

Update the trees and the nvx-rs summary of doc/project-structure.md
for the sandbox launch profile and the golden fixtures.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add the cli feature, which implies host, and the nvx-rs binary, with
required-features = ["cli"]: the command-line interface to which
nvx.py will delegate its sandbox, egress-policy, and artifact steps
(#359). This commit adds the command-line framework and three
commands; the managed lifecycle of nvx-rs sandbox follows in the next
commit, and until then provision, start, exec, stop, and deprovision
fail after their option checks.

- nvx-rs [--root PATH] artifacts prints ArtifactDir::paths as JSON,
  as json.dumps(value, indent=2, sort_keys=True) writes it. The root
  is --root, which nvx.py will pass, or else the release whose bin/
  holds the executable, or the checkout whose nvx-rs/target/ holds
  it, either with its SOURCE-MANIFEST.json. The NVX_OPENVMM variable
  names an OpenVMM executable to run instead of the root's, such as a
  test double; a relative path is made absolute.
- nvx-rs egress compile PATH prints the rules of compile_policy_file
  as {"allow": [...], "deny": [...]}, each list in the order in which
  OpenVMM receives them, and fails with the compiler's message.
- nvx-rs sandbox ports the sandbox parser and command_sandbox of
  scripts/nvx.py: every option with its name, default, type, and
  choices, and the checks of SandboxOptions::check, the egress policy
  file, the mounts, the launch, and the systemd policy, in Python's
  order and with its messages. A one-shot run prints ">> COMMAND",
  quoted as subprocess.list2cmdline quotes it on Windows and as
  shlex.join does elsewhere, and unless --dry-run runs OpenVMM with
  the standard streams and exits with its status. The artifact checks
  keep the messages and order of nvx.py, and --hypervisor auto
  resolves to whp on Windows and to kvm elsewhere.

The parser reproduces argparse step by step: --opt value and
--opt=value; values that start with a dash only after "=", unless
they look like negative numbers or hold a space; the alternation of
options and positionals; the optional operation positional, which
defaults to run; repeated options that accumulate; and the messages
and status 2 of usage errors, with unrecognized arguments reported by
the top-level parser after the subcommand parsed. It does not expand
abbreviations, since nvx.py will pass canonical options; that is an
intended deviation. Help and usage are argparse's for 80 columns.

Errors print "error: MESSAGE" and exit with status 1, as main() of
nvx.py does for a ScriptError. Ctrl-C prints "Interrupted" and exits
with status 130. A SIGINT handler on Unix, installed only if SIGINT is
not ignored, and a console control handler on Windows record it, and
a run then gives OpenVMM 0.25 s to exit before it kills it on POSIX,
as subprocess.run does, and waits for it on Windows, where Python
cannot interrupt the wait either. Text ends its lines as Python's
print ends them, with \r\n on Windows.

The private python module of #594 and #595 gains dumps, json.dumps
with indent=2, sort_keys=True, and ensure_ascii; shell,
subprocess.list2cmdline, shlex.quote, and shlex.join; and float() in
builtins for positive_float. The sys and platform modules gain the
interrupt primitives, and windows-sys its console bindings.

Unit tests compare the parser with argparse, the Python helpers with
CPython, and cover the discovery of the artifact root. Golden
scenarios recorded from nvx.py follow in a later commit.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port scripts/nvx_tools/sandbox_lifecycle.py, and the ControlSession of
control_session.py that it uses, so that nvx-rs sandbox provision,
start, exec, stop, and deprovision keep the contract of nvx.py
sandbox: the same state directory, files, formats, locks, messages,
outputs, and exit statuses. Either implementation can operate a
sandbox that the other provisioned or started.

State, in sandbox/state.rs:
- config.json in the lowest of formats 1 to 6 that holds the sandbox,
  and runtime.json, format 1, with the pid, start_time, and
  control_endpoint of OpenVMM. Both are written as json.dumps(value,
  indent=2, sort_keys=True) + "\n" writes them, ASCII-escaped and with
  \r\n line ends on Windows as write_text produces them, through a
  temporary file, chmod 0600, and a rename.
- Reading applies Python's semantics to whatever another writer left:
  strict UTF-8, == comparisons of the format, str() and int() of the
  fields, and the order and messages of _deserialize_launch.

Locks, in sandbox/lock.rs: lifecycle.lock is locked as Python locks
it, so the implementations exclude each other: flock(LOCK_EX |
LOCK_NB) on POSIX, and on Windows CreateFileW with read, write, and
delete sharing and FILE_FLAG_OPEN_REPARSE_POINT, then LockFile of the
first byte, as msvcrt.locking(LK_NBLCK, 1) takes it. The
standalone-regular-file checks, the retries every 25 ms within
--timeout, the hand-off waits, the "deprovisioned" tombstone, and the
removal of the directory are ported, with Windows errors classed as
CPython maps them to errno.

Process identity, in sandbox/identity.rs, is Python's: on Linux the
start time in /proc/PID/stat, with zombies as exited, and on Windows
the creation time, so that a runtime record that either implementation
wrote is valid for the other.

The lifecycle, in sandbox/lifecycle.rs:
- start generates a 32-byte capability, never all zero, writes
  control.capability, passes the capability on OpenVMM's standard
  input through a pipe closed before OpenVMM starts, appends its
  output to openvmm.log, and starts it detached, in its own session
  on POSIX and process group on Windows, with _start's command line:
  the managed lifecycle, its network options in _start's order, and
  the endpoint STATE/control.sock, or //./pipe/openvmm-microvm-HEX
  with a uuid4 on Windows. It records runtime.json, then connects,
  attaches, and pings. A failed start ends OpenVMM and removes the
  runtime files in _start's order, and reports OpenVMM's exit as
  _startup_exit_message does.
- exec buffers the workload's output, writes standard output, then
  standard error, and exits with the workload's status. With
  --outcome-report, it checks the destination before it runs and
  writes {"operation_id", "outcome", "schema_version": 1} as
  _write_new_json does.
- stop asks the guest to shut down, waits for OpenVMM, reads its
  outcome report, removes the runtime files, and prints nothing, as
  nvx.py sandbox stop does. deprovision removes the state directory.

The session, in session.rs, keeps the observable behavior of
control_session.py on nvx::host::session, whose ControlSession gains
send_request and read_response for callers that check the responses
themselves: connection retries every 25 ms until the timeout, then
"managed control endpoint did not become available: PATH"; one
deadline per operation for its request and its responses; "managed
control request timed out" or "managed control response timed out"
by the direction of the transfer; ControlEndpointClosed for a closed
endpoint, but not for a reset; a session closed after a failed
transfer; and exec's checks, in their order and with their messages,
its exit categories, and its refusals with their status and ASCII
category. Waits run in 250 ms slices, so that Ctrl-C ends them.

Intended deviations: an endpoint that a process other than the
launched OpenVMM serves counts as one that has not appeared yet, as
in nvx::host::session; OpenVMM starts without a console window on
Windows, as nvx::host::process starts it; exec refuses more than the
1 MiB of output that the guest agent forwards; and where Python would
end with a traceback, as for a configuration without a field that
_start reads, nvx-rs prints "error: MESSAGE" with the exception's
message and exits with status 1.

The base modules gain what the port needs: process::Launched waits
for, terminates, and kills OpenVMM as Popen does; the Windows platform
module locks the first byte of a file and reads exit codes; and the
python module adds value, str(), int(), and == of JSON values, and
lstat, lexists, and fstat with st_file_attributes on Windows.

Unit tests cover the formats, the JSON writer, identity parsing, the
locks, and the lifecycle helpers. The tests of control_session.py and
golden scenarios recorded from nvx.py follow.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port scripts/test_control_session.py to the session of nvx-rs
sandbox, in host/cli/session/tests.rs, except the cases that the tests
of nvx::host::session, whose framing the session reuses, already
cover. Session::connect now attaches through Session::attach, which
the tests call on their own transports.

The ControlSessionTests run on an in-memory stream pair, like the
socketpair of the Python tests, with a guest thread that plays
OpenVMM's endpoint: exec streams output and returns its status;
refuses relative entrypoints, more than 64 arguments, and invalid
arguments, timeouts, working directories, environments, and response
timeouts before it sends anything; encodes the legacy and extended
requests, with their working directory and omitted, empty, replaced,
or layered environments, exactly; accepts the whole u32 timeout range;
refuses unknown exit categories, including "cancelled", which the CLI
never requests; and keeps the status and category of a refusal. A
reset is not a closed endpoint, every transfer of an operation gets no
more than what is left of its timeout, and a failed transfer closes
the session.

The stream tests run on the endpoints of each host, which this
process serves: on POSIX, Unix-domain sockets whose closure and
partial reads report a closed endpoint; on Windows, named pipes whose
closure reports a closed endpoint after the data that they held, and
whose read timeout closes the session. A stalled endpoint, which
reads nothing, times out the request within its timeout on both
hosts, and an endpoint that never appears times out with its path.
The Windows tests create their pipe servers through windows-sys, so
their module allows unsafe code.

Not ported: the capability-pipe tests, since the pipe is
nvx::host::process::spawn's, which writes the capability and closes
the pipe before OpenVMM starts, and which every start of the golden
lifecycle scenarios exercises, since the fake OpenVMM requires exactly
the capability and the end of the file on its standard input; the
mocked transfers that move nothing, which the closed-endpoint tests
cover; reads that time out without losing data, which
nvx::host::session's timed_out_polls_keep_partial_records covers; and
the cancellation of an interrupted pipe write, since the CLI checks
Ctrl-C between waits and bounds each write by its timeout.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
As the #359 discussion decided, nvx-rs sandbox start now negotiates
the guest's control features, with a required set of its own rather
than GuestFeatures::REQUIRED, which describes the direct agent of
nvx::host::instance. This is the one intended change from nvx.py
sandbox start, which does not ask.

GuestFeatures::LAYERED_SANDBOX holds exactly what the CLI's launches
use: EXEC_ENVIRONMENT (bit 4), because exec sends a working directory
and an environment in an extended EXEC. It holds no CANCEL (bit 0):
the CLI never sends APP_CANCEL, since, as nvx.py did, Ctrl-C ends an
exec by closing the session, and the guest agent ends the workload
when OpenVMM resets the session that it ran for. It holds no
WORKLOAD_ACCOUNT (bit 2), which no sandbox launch requests and the
layered root does not perform (#566). The NVX guest agents, the Rust
agent of #561 and the C agent before it, advertise bits 0, 2, and 4 in
sandbox mode.

After its PING, start sends FEATURES. An agent that predates the
request refuses it as an unsupported operation and so provides none.
If the guest lacks a required feature, start ends OpenVMM and removes
the runtime files, as a failed start does, and fails with

  error: the guest image lacks control features that layered
  sandboxes need (per-execution environments); use an NVX release or
  build that includes them

which names the missing features with GuestFeatures::missing, as the
reject_guest of nvx::host::instance does.

Tests cover the required set, the features responses of current,
older, and misbehaving agents, and, with the golden scenarios that
follow, a start against guests that lack the feature or predate the
request.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extend nvx::testing::fake_openvmm, and so nvx-fake-openvmm, to the
sandbox profile that nvx.py sandbox and nvx-rs sandbox launch, so that
both can run end to end without a VM. A --microvm-sandbox-block option
selects the profile; direct launches, which nvx::host::instance and
aci_edge_sandboxes use, are unchanged.

- The fake accepts the sandbox's arguments and checks them as OpenVMM
  would: read-only distro, runtime, and custom layers and one scratch
  disk, each an existing file; nvx_sandbox=1, one nvx_entrypoint=,
  and one nvx_hostname= on the kernel command line; one --mount of a
  directory, or the --mount-child children of an aggregate at
  /run/nvx/shares; --mount-owner and --network-proxy; and the network
  options, which it summarizes as OpenVMM's report does.
- --microvm-lifecycle managed serves the control console as before,
  with the capability on standard input, and its guest advertises
  GuestFeatures::SANDBOX_AGENT. The guest targets of directory shares,
  from --mount and the nvx_share= tokens of an aggregate, are
  directories that a workload can enter.
- --microvm-lifecycle one-shot reads no capability and serves no
  console: it runs the entrypoint and arguments of the kernel command
  line on its own standard streams, /bin/echo, /bin/sleep, /bin/true,
  or /bin/false, with any other program not found and status 127,
  writes an outcome report of operation "run" if asked, and exits with
  the workload's status, or with fake_exit_status=STATUS.
- The outcome reports of sandboxes take the form of OpenVMM's: the
  schema version, a random instance ID, the backend, the outcome, the
  network policy, and every teardown step, as JSON with a final
  newline.

The existing knobs keep working for sandboxes, among them
fake_exit_on_start, fake_guest_features, and fake_legacy_guest, which
the golden scenarios and the guest-feature tests use. The fake
OpenVMM tests still pass unchanged.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Record what nvx.py sandbox does, as a command, and replay it with
nvx-rs in tests that need no Python, so that the CLI stays faithful to
nvx.py after PR D deletes the Python code.

scripts/nvx_rs_fixtures.py, the generator of #595, now also writes
nvx-rs/tests/fixtures/cli.json, which its single --check covers. It
builds nvx-rs and nvx-fake-openvmm with cargo and plays scenarios in
fresh temporary roots, each with fake guest artifacts and a copy of
nvx-fake-openvmm as the root's OpenVMM executable:

- 24 usage errors and helps of the sandbox parser, 69 option and
  launch checks of command_sandbox, 11 dry runs, 11 one-shot runs,
  the lifecycles of sandboxes in every configuration format, and 8
  scenarios of the refusals and failures of the lifecycle operations,
  215 steps in all on POSIX and 209 on Windows, where a caller-owned
  share, and so format 3, is refused;
- nvx-rs egress compile and nvx-rs artifacts, from the Python
  functions that they port, compile_policy_file and the paths of
  scripts/nvx_tools/common.py and guests.py.

Each step records its exit status, standard output and error, and
the files that it leaves, such as config.json in formats 1 to 6,
runtime.json, the state directory's entries and modes, outcome
reports, and the command line that start passed to OpenVMM. The
generator runs nvx.py in a subprocess, with artifact_path and
openvmm_binary_path patched, unbuffered and with UTF-8 standard
streams, no terminal size, and no colors. It replaces the root, as
printed and as JSON escapes it, process IDs, start times, and random
identifiers with placeholders, and the program names of nvx.py with
those of nvx-rs, whose parser names have the same length, so usage
wraps alike. The posix and windows sections hold the scenarios of each
kind of host, recorded there; the script rewrites or checks the
section of the host that runs it and keeps the other. Recording needs
CPython 3.14.5 or later, whose argparse quotes the choices of an
invalid choice, as nvx-rs does.

The script then plays every scenario with nvx-rs, and each lifecycle
with nvx.py and nvx-rs taking turns both ways, so that each provisions,
starts, executes in, stops, and deprovisions a sandbox of the other,
and fails if any step differs from nvx.py. Both sections pass on
Windows and Linux.

nvx-rs/tests/cli.rs replays the section of its host with nvx-rs,
four scenarios at a time, in canonical temporary roots. It also
checks what nvx-rs adds: a start against guests that lack per-execution
environments or predate the features request, the default root of the
executable in a checkout, a relative NVX_OPENVMM, the top-level usage
errors, and on Linux, Ctrl-C during a run, which ends OpenVMM, and
during an exec, which leaves the sandbox running. .gitattributes marks
the fixture, about 370 KB, as generated.

This changes the generator of #595 only by adding the CLI section.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Describe in nvx-rs/README.md the nvx-rs CLI that the cli feature
builds, which PR D's nvx.py will run:

- its commands and how it finds the artifact root: --root, which
  nvx.py will pass, or else the release or checkout that holds the
  executable, and the NVX_OPENVMM override;
- the JSON forms of artifacts and egress compile, and the contract of
  sandbox: its options, outputs, exit statuses, outcome reports, and
  the state directory that it shares with nvx.py;
- the guest features that start requires, and why bits 0 and 2 are not
  among them;
- the other intended differences from nvx.py;
- the test hooks, --root, NVX_OPENVMM, and the fake OpenVMM's sandbox
  profile, and the golden scenarios with their interoperability
  checks.

The layout, module, and features tables, the Python parity and
testing sections, and the introduction mention the CLI, and
doc/project-structure.md lists it in the nvx-rs tree and summary.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Cross-platform lifecycle and guest-build changes need human review, and the Ctrl-C result-reporting issue remains unresolved.

1 open issue
What changed in this PR

Adds the nvx-rs CLI as groundwork for replacing Python runtime logic in #359. The stacked changes also migrate guest builds to Rust.

Changes:

  • Adds artifact lookup, egress compilation, and sandbox commands with Python-compatible behavior.
  • Adds process control, interrupt handling, and layered-sandbox feature requirements.
  • Updates guest builds, provenance, tests, and documentation.
File Description
THIRD_PARTY_NOTICES.md Adds Rust runtime and dependency notices.
SOURCE-MANIFEST.json Records Rust guest inputs.
scripts/​test_tool_versions.py Checks musl target installation.
scripts/​test_guest_binaries.py Expands build and provenance tests.
scripts/​setup/​setup-linux-runner.sh Explicitly selects target toolchain.
scripts/​setup/​setup-linux-mshv.sh Installs and verifies musl target.
scripts/​nvx.py Adds guest source-identity command.
scripts/​nvx_tools/​ubuntu.py Includes Rust inputs in provenance.
scripts/​nvx_tools/​release.py Validates Rust source metadata.
scripts/​nvx_tools/​guest_binaries.py Adds staged-build validation.
scripts/​nvx_tools/​ci.py Tracks Rust guest test inputs.
scripts/​nvx_tools/​build.py Installs shared Rust guest binaries.
scripts/​nvx_tools/​build_constants.py Defines Rust build and packaging inputs.
scripts/​nvx_tools/​benchmark.py Records Rust helper provenance.
scripts/​nvx_tools/​azurelinux.py Includes Rust build inputs.
scripts/​nvx_tools/​archive.py Excludes generated Rust targets.
README.md Describes Rust guest binaries.
nvx-rs/​src/​protocol/​tests.rs Pins wire values and feature requirements.
nvx-rs/​src/​protocol/​mod.rs Updates shared-protocol documentation.
nvx-rs/​src/​protocol/​features.rs Defines layered-sandbox requirements.
nvx-rs/​src/​host/​sys/​mod.rs Exposes Unix interrupt helpers.
nvx-rs/​src/​host/​sys/​interrupt.rs Records Unix Ctrl-C signals.
nvx-rs/​src/​host/​session.rs Exposes request and response operations.
nvx-rs/​src/​host/​sandbox/​layer.rs Implements sandbox layer parsing.
nvx-rs/​src/​host/​python/​value.rs Implements Python value conversions.
nvx-rs/​src/​host/​python/​uuid.rs Implements Python-compatible UUID parsing.
nvx-rs/​src/​host/​python/​text.rs Preserves Python string semantics.
nvx-rs/​src/​host/​python/​shell.rs Formats displayed command lines.
nvx-rs/​src/​host/​python/​mod.rs Organizes Python compatibility helpers.
nvx-rs/​src/​host/​python/​dumps.rs Produces Python-compatible JSON.
nvx-rs/​src/​host/​process.rs Adds child wait and termination helpers.
nvx-rs/​src/​host/​platform/​windows.rs Adds waits, interrupts, and byte locks.
nvx-rs/​src/​host/​platform/​other.rs Provides interrupt compatibility hooks.
nvx-rs/​src/​host/​platform/​linux.rs Exposes Linux interrupt handling.
nvx-rs/​src/​host/​mod.rs Registers CLI and sandbox modules.
nvx-rs/​src/​host/​cli/​sandbox/​identity.rs Checks persisted process identities.
nvx-rs/​src/​host/​cli/​output.rs Preserves Python stream formatting.
nvx-rs/​src/​host/​cli/​mod.rs Dispatches commands and reports results.
nvx-rs/​src/​host/​cli/​egress.rs Adds egress compilation command.
nvx-rs/​src/​host/​cli/​deadline.rs Adds interruptible deadline helpers.
nvx-rs/​src/​host/​cli/​artifacts.rs Discovers and prints artifact paths.
nvx-rs/​src/​guest/​time/​mod.rs Updates historical C-port comments.
nvx-rs/​src/​guest/​time_probe/​mod.rs Updates historical C-port comments.
nvx-rs/​src/​guest/​reseed.rs Clarifies comments without behavior changes.
nvx-rs/​src/​bin/​nvx-rs.rs Adds binary entry point.
nvx-rs/​Cargo.toml Declares CLI feature and dependencies.
guest/​common/​nvx-reseed.c Removes replaced C implementation.
guest/​common/​nvx-mmio-write.c Removes replaced C implementation.
guest/​common/​nvx-exit.c Removes replaced C implementation.
guest/​common/​nvx-device-io.c Removes replaced C implementation.
guest/​common/​nvx-console-pending.c Removes replaced C implementation.
docker/​Dockerfile Shares Rust binaries across guest images.
doc/​usage.md Documents source-identity command.
doc/​setup.md Documents Rust guest prerequisites.
doc/​project-structure.md Describes CLI and Rust guest layout.
doc/​guests/​ubuntu-guest.md Documents shared Rust binaries.
doc/​guests/​azurelinux-guest.md Documents Rust binary provenance.
doc/​contribute.md Updates guest testing guidance.
doc/​ci.md Describes Rust build and cache inputs.
doc/​build.md Documents Rust builds and packaging.
doc/​benchmarks.md Updates helper provenance documentation.
aci_edge_sandboxes/​src/​openvmm/​mod.rs Updates egress documentation and tests.
aci_edge_sandboxes/​README.md Documents shared egress lowering changes.
.github/​workflows/​copilot-setup-steps.yml Tracks all Rust crate changes.
.github/​copilot-instructions.md Documents musl setup prerequisites.
.github/​actions/​validate-nvx/​action.yml Removes retired C-driven test suites.
.github/​actions/​build-guest-artifacts/​action.yml Adds Rust-aware guest cache keys.
.gitattributes Marks generated compatibility fixtures.

Review effort: Balanced

Comment thread nvx-rs/src/host/cli/mod.rs Outdated
A terminal's Ctrl-C reaches the whole foreground process group, so
nvx-rs sandbox run and the OpenVMM that it runs on the terminal
receive it together. run_openvmm polled OpenVMM before it checked
for the interrupt, so when OpenVMM had already exited from the same
Ctrl-C, the run returned OpenVMM's status instead of ending as
interrupted: on Unix, a SIGINT-ended OpenVMM made nvx-rs exit with
254 and print nothing, where nvx.py prints "Interrupted" and exits
with 130. Python's wait raises KeyboardInterrupt once SIGINT arrives,
whether or not the child has exited meanwhile.

run_openvmm now checks for the interrupt after each poll and before
it reports a status, and only waits for, then kills, an OpenVMM that
is still running. main() also lets a recorded Ctrl-C decide how any
command ends, as KeyboardInterrupt does wherever it finds a Python
command, so that a command that finished while Ctrl-C arrived still
prints "Interrupted" and exits with 130.

A new Linux test sends SIGINT to the CLI's whole process group, as a
terminal does, during a one-shot run whose fake OpenVMM the signal
ends at once. It failed with status 254 before this change, in three
of three runs, and passes now; the test that interrupts the CLI alone
still passes.

Reported in the review of #596.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 17, 2026 04:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Cross-platform lifecycle, locking, signal handling, and guest-build integration warrant final human review and CI confirmation.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

Pedro Henrique Penna (ppenna) added a commit that referenced this pull request Oct 17, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants