From 287f7633c656c5d23c30b9d000f99136737520b5 Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Thu, 15 Oct 2026 16:58:13 -0700 Subject: [PATCH 01/18] NVX0430 phase 1: add minimal signed create-run bundle Add the minimal signed create-and-run NVX0430 bundle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aee99423-dfee-4d61-86e9-421b944c4cc0 --- .cargo/config.toml | 8 + .github/workflows/microvm-e2e.yml | 23 +- README.md | 15 +- build.bat | 34 +- build.sh | 9 +- docs/backends/nanvix/nanvix.md | 287 ++++++------- .../ci-validation-infrastructure.md | 10 +- docs/schema.md | 15 +- src/Cargo.lock | 134 ++++++- src/mxc-sdk/Cargo.toml | 6 +- src/mxc-sdk/README.md | 19 + src/mxc-sdk/build.rs | 18 +- src/mxc-sdk/build/build_nvx_bundled.rs | 224 +++++++++++ src/mxc-sdk/src/backends/mod.rs | 11 +- src/mxc-sdk/src/backends/nvx/mod.rs | 6 + src/mxc-sdk/src/backends/nvx/policy.rs | 256 ++++++++++++ src/mxc-sdk/src/backends/nvx/runner.rs | 342 ++++++++++++++++ src/mxc-sdk/src/backends/nvx/runtime.rs | 41 ++ src/mxc-sdk/src/core/mxc_common/cmdline.rs | 12 +- .../src/core/mxc_common/config_parser.rs | 45 +++ src/mxc-sdk/src/core/mxc_common/models.rs | 10 + src/mxc-sdk/src/core/mxc_engine/mod.rs | 133 ++++++ src/mxc-sdk/src/core/mxc_engine/run.rs | 27 +- src/mxc-sdk/src/lib.rs | 36 +- src/mxc-sdk/tests/nvx_bundle_integrity.rs | 84 ++++ src/mxc-sdk/tests/nvx_e2e.rs | 378 ++++++++++++++++++ .../tests/wxc_e2e_tests_e2e_linux_microvm.rs | 5 + .../tests/wxc_e2e_tests_e2e_windows.rs | 150 +------ src/testing/fuzz/fuzz_targets/validator.rs | 6 +- tests/configs/microvm_error.json | 2 +- tests/configs/microvm_exit_code.json | 2 +- tests/configs/microvm_hello.json | 2 +- tests/configs/microvm_large_output.json | 2 +- tests/configs/microvm_multiline.json | 2 +- tests/configs/microvm_stdlib.json | 2 +- tests/configs/microvm_timeout.json | 4 +- tests/scripts/README.md | 4 +- tests/scripts/run_microvm_tests.ps1 | 36 +- 38 files changed, 1935 insertions(+), 465 deletions(-) create mode 100644 src/mxc-sdk/build/build_nvx_bundled.rs create mode 100644 src/mxc-sdk/src/backends/nvx/mod.rs create mode 100644 src/mxc-sdk/src/backends/nvx/policy.rs create mode 100644 src/mxc-sdk/src/backends/nvx/runner.rs create mode 100644 src/mxc-sdk/src/backends/nvx/runtime.rs create mode 100644 src/mxc-sdk/tests/nvx_bundle_integrity.rs create mode 100644 src/mxc-sdk/tests/nvx_e2e.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index e027d8b4ac..c4476612fc 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,4 +1,12 @@ +[env] +# Keep the official dependency's verifier in the download/staging path while pinning the exact +# release archive approved for this NVX revision. `force` prevents ambient build variables from +# selecting another URL, digest, or the verifier's explicit skip mode. +ACI_EDGE_SANDBOXES_AGENT_BUNDLE = { value = "required", force = true } +ACI_EDGE_SANDBOXES_AGENT_BUNDLE_URL = { value = "https://github.com/microsoft/nvx/releases/download/v0.1.0-dev.0430ce79d125/aci-edge-sandboxes.zip", force = true } +ACI_EDGE_SANDBOXES_AGENT_BUNDLE_SHA256 = { value = "98e3d6011b0664fad6fa783768370a34209639463f5ab812b7776840660ac24e", force = true } + # Hardening flags for Windows targets. Most flags apply to both arches; # /CETCOMPAT is x86/x64 only (Intel Control-flow Enforcement Technology; # ARM64 uses Pointer Authentication / BTI instead). diff --git a/.github/workflows/microvm-e2e.yml b/.github/workflows/microvm-e2e.yml index ee63844377..be3bc98b42 100644 --- a/.github/workflows/microvm-e2e.yml +++ b/.github/workflows/microvm-e2e.yml @@ -40,7 +40,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: cargo build --features microvm --target x86_64-pc-windows-msvc + run: cargo build -p wxc --features microvm --target x86_64-pc-windows-msvc --jobs 2 - name: Exclude build output from Windows Defender shell: pwsh @@ -49,26 +49,18 @@ jobs: Add-MpPreference -ExclusionPath $binDir Write-Host "Added Defender exclusion for $binDir" - - name: Verify MicroVM binaries + - name: Verify MicroVM executable shell: pwsh run: | $binDir = Join-Path $env:GITHUB_WORKSPACE "src\target\x86_64-pc-windows-msvc\debug" - $required = @( - "wxc-exec.exe", - "nanvixd.exe", - "nanvix_rootfs.img", - "python3.initrd", - "bin\kernel.elf", - "snapshots\kernel.vmem", - "snapshots\kernel.whp.cbor" - ) - $missing = $required | Where-Object { -not (Test-Path (Join-Path $binDir $_)) } + $required = @((Join-Path $binDir "wxc-exec.exe")) + $required += Join-Path $binDir "aci-edge-sandboxes\bundle-manifest.json" + $missing = $required | Where-Object { -not (Test-Path $_ -PathType Leaf) } if ($missing) { Write-Host "::error::Missing binaries: $($missing -join ', ')" exit 1 } - $leaves = $required | ForEach-Object { Split-Path $_ -Leaf } - Get-ChildItem $binDir -Include $leaves -Recurse | Format-Table FullName, Length + Get-Item $required | Format-Table FullName, Length - name: Diagnose hypervisor environment shell: pwsh @@ -84,7 +76,8 @@ jobs: shell: pwsh working-directory: src run: | - cargo test -p mxc-sdk --test wxc_e2e_tests_e2e_windows --target x86_64-pc-windows-msvc test_microvm_ -- --nocapture + cargo test -p mxc-sdk --features microvm --test wxc_e2e_tests_e2e_windows --target x86_64-pc-windows-msvc --jobs 2 test_microvm_ -- --nocapture + cargo test -p mxc-sdk --features microvm --test nvx_e2e --target x86_64-pc-windows-msvc --jobs 2 -- --include-ignored --nocapture --test-threads=1 - name: Upload logs on failure if: failure() || cancelled() diff --git a/README.md b/README.md index 1e5a99e608..cde862a789 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ behind a unified containment model and typed SDKs. - **JSON-based configuration**: Versioned container-creation requests and security policies - **Multiple containment backends**: ProcessContainer, Windows Sandbox, LXC, - Bubblewrap, Seatbelt, MicroVM (Nanvix), Hyperlight, IsolationSession, and WSLC + Bubblewrap, Seatbelt, MicroVM (NVX on Windows x64), Hyperlight, + IsolationSession, and WSLC - **Policy-driven sandboxing**: - **Filesystem policy**: Read-only, read-write, and denied path lists - **Network policy**: Proxy support, outbound controls, and backend-dependent @@ -52,11 +53,21 @@ Linux, and macOS. | Runtime platform | Default backend | Other backends | Minimum host OS | |---|---|---|---| | Windows 11 x64 / ARM64 | `processcontainer` | `windows_sandbox`\*, `wslc`, `microvm`\*, `hyperlight`\*, `isolation_session` | [Windows OS-version support](docs/backends/process-container/os-version-support.md)| -| Linux x64 / ARM64 | `bubblewrap` | `lxc`, `microvm`, `hyperlight` | - | +| Linux x64 / ARM64 | `bubblewrap` | `lxc`, `hyperlight` | - | | macOS ARM64 / x64 | `seatbelt` | - | - | \* These backends are **experimental**. +The Windows x64 `microvm` backend is a development NVX integration. A build +with `--with-microvm` stages the pinned signed NVX agent bundle and requires the +runtime `--experimental` opt-in. This initial slice supports create-and-run +execution with the command, timeout, guest working directory, and additive +environment settings. Filesystem, network, proxy, UI, capture-denials, +non-default lifecycle, streaming, PTY, and container lifecycle operations +remain fail-closed. Linux MicroVM execution is inactive in this phase. See the +[NVX backend guide](docs/backends/nanvix/nanvix.md) for release provenance, +required bundle distribution, and the P1/P2/P3 implementation boundary. + ## How do I use MXC? Install an SDK through your package manager. You do not need to clone this diff --git a/build.bat b/build.bat index eb1d6226b6..3b69d4f2ed 100644 --- a/build.bat +++ b/build.bat @@ -5,7 +5,7 @@ setlocal enabledelayedexpansion set "BUILD_CONFIG=release" set "BUILD_ARCH=" set "BUILD_ALL=0" -set "WITH_NANVIX=0" +set "WITH_MICROVM=0" set "WITH_WSLC=0" set "WITH_ISOLATION_SESSION=0" set "WITH_HYPERLIGHT=0" @@ -18,7 +18,7 @@ if /i "%~1"=="--release" ( set "BUILD_CONFIG=release" & shift & goto :parse_arg if /i "%~1"=="--x64" ( set "BUILD_ARCH=x86_64-pc-windows-msvc" & shift & goto :parse_args ) if /i "%~1"=="--arm64" ( set "BUILD_ARCH=aarch64-pc-windows-msvc" & shift & goto :parse_args ) if /i "%~1"=="--all" ( set "BUILD_ALL=1" & shift & goto :parse_args ) -if /i "%~1"=="--with-microvm" ( set "WITH_NANVIX=1" & shift & goto :parse_args ) +if /i "%~1"=="--with-microvm" ( set "WITH_MICROVM=1" & shift & goto :parse_args ) if /i "%~1"=="--with-wslc" ( set "WITH_WSLC=1" & shift & goto :parse_args ) if /i "%~1"=="--with-isolation-session" ( set "WITH_ISOLATION_SESSION=1" & shift & goto :parse_args ) if /i "%~1"=="--with-hyperlight" ( set "WITH_HYPERLIGHT=1" & shift & goto :parse_args ) @@ -44,7 +44,7 @@ if "%BUILD_CONFIG%"=="release" set "CARGO_FLAGS=--release --target" :: workspace feature flags above, so it uses its own profile/target-only flags. set "PLM_FLAGS=--target" if "%BUILD_CONFIG%"=="release" set "PLM_FLAGS=--release --target" -if "%WITH_NANVIX%"=="1" set "CARGO_FLAGS=--features microvm %CARGO_FLAGS%" +if "%WITH_MICROVM%"=="1" set "CARGO_FLAGS=--features microvm %CARGO_FLAGS%" if "%WITH_WSLC%"=="1" set "CARGO_FLAGS=--features wslc %CARGO_FLAGS%" if "%WITH_ISOLATION_SESSION%"=="1" set "CARGO_FLAGS=--features isolation_session %CARGO_FLAGS%" if "%WITH_HYPERLIGHT%"=="1" set "CARGO_FLAGS=--features hyperlight %CARGO_FLAGS%" @@ -131,25 +131,15 @@ for %%T in (x86_64-pc-windows-msvc aarch64-pc-windows-msvc) do ( copy /Y "!BIN_DIR!\mxc_ffi.dll" "sdk\node\bin\!SDK_ARCH!\" >nul echo Copied !SDK_ARCH!\mxc_ffi.dll ) - if "%WITH_NANVIX%"=="1" ( - for %%B in (nanvixd.exe nanvix_rootfs.img python3.initrd) do ( - if exist "!BIN_DIR!\%%B" ( - copy /Y "!BIN_DIR!\%%B" "sdk\node\bin\!SDK_ARCH!\" >nul - echo Copied !SDK_ARCH!\%%B - ) - ) - if exist "!BIN_DIR!\bin\kernel.elf" ( - if not exist "sdk\node\bin\!SDK_ARCH!\bin" mkdir "sdk\node\bin\!SDK_ARCH!\bin" - copy /Y "!BIN_DIR!\bin\kernel.elf" "sdk\node\bin\!SDK_ARCH!\bin\" >nul - echo Copied !SDK_ARCH!\bin\kernel.elf - ) - for %%S in (kernel.vmem kernel.whp.cbor) do ( - if exist "!BIN_DIR!\snapshots\%%S" ( - if not exist "sdk\node\bin\!SDK_ARCH!\snapshots" mkdir "sdk\node\bin\!SDK_ARCH!\snapshots" - copy /Y "!BIN_DIR!\snapshots\%%S" "sdk\node\bin\!SDK_ARCH!\snapshots\" >nul - echo Copied !SDK_ARCH!\snapshots\%%S - ) + if "%WITH_MICROVM%"=="1" if /i "!SDK_ARCH!"=="x64" ( + if not exist "!BIN_DIR!\aci-edge-sandboxes\bundle-manifest.json" ( + echo ERROR: MicroVM build is missing the signed agent bundle + exit /b 1 ) + if exist "sdk\node\bin\!SDK_ARCH!\aci-edge-sandboxes" rmdir /S /Q "sdk\node\bin\!SDK_ARCH!\aci-edge-sandboxes" + xcopy /E /I /Y "!BIN_DIR!\aci-edge-sandboxes" "sdk\node\bin\!SDK_ARCH!\aci-edge-sandboxes" >nul + if errorlevel 1 exit /b 1 + echo Copied !SDK_ARCH!\aci-edge-sandboxes signed agent runtime ) if "!COPY_WSLC_RUNTIME!"=="1" ( if "%WITH_WSLC%"=="1" ( @@ -309,7 +299,7 @@ echo --release Build release configuration echo --x64 Build for x64 only echo --arm64 Build for ARM64 only echo --all Build both Windows architectures and create the .NET NuGet package -echo --with-microvm Download and include NanVix micro-VM binaries +echo --with-microvm Enable signed bundled NVX MicroVM support (x64) echo --with-wslc Build with WSL Container (WSLC SDK) support echo --with-isolation-session Build with IsolationSession backend (IsoEnvBroker) echo --with-hyperlight Build with Hyperlight (micro-VM) backend (x86_64 only) diff --git a/build.sh b/build.sh index 7e37ae9378..747cc6dba2 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,6 @@ BUILD_TYPE="release" BUILD_SDK=true WITH_HYPERLIGHT=false -WITH_MICROVM=false while [[ $# -gt 0 ]]; do case $1 in @@ -30,8 +29,8 @@ while [[ $# -gt 0 ]]; do shift ;; --with-microvm) - WITH_MICROVM=true - shift + echo "error: --with-microvm is currently supported only by build.bat on Windows x64" >&2 + exit 1 ;; --help|-h) echo "Usage: build.sh [OPTIONS]" @@ -40,7 +39,6 @@ while [[ $# -gt 0 ]]; do echo " --debug Build in debug mode (default: release)" echo " --rust-only Only build Rust binaries, skip SDK" echo " --with-hyperlight Build with Hyperlight (micro-VM) backend (x86_64 only)" - echo " --with-microvm Build with NanVix MicroVM backend (KVM required at runtime)" echo " -h, --help Show this help message" exit 0 ;; @@ -79,9 +77,6 @@ FEATURES_LIST=() if [ "$WITH_HYPERLIGHT" = true ]; then FEATURES_LIST+=(hyperlight) fi -if [ "$WITH_MICROVM" = true ]; then - FEATURES_LIST+=(microvm) -fi if [ ${#FEATURES_LIST[@]} -gt 0 ]; then CARGO_FEATURES=(--features "$(IFS=,; echo "${FEATURES_LIST[*]}")") fi diff --git a/docs/backends/nanvix/nanvix.md b/docs/backends/nanvix/nanvix.md index 0ea9ed61db..24fc53c74a 100644 --- a/docs/backends/nanvix/nanvix.md +++ b/docs/backends/nanvix/nanvix.md @@ -1,195 +1,132 @@ -# Nanvix MicroVM Backend +# NVX MicroVM Backend > **Audience:** MXC consumers and developers -Nanvix MicroVM is an experimental containment backend for MxC. It is powered by the -[Nanvix OS/VM](https://aka.ms/nanvix) and runs untrusted code with hardware-enforced isolation -via the Windows Hypervisor Platform (WHP) on Windows or KVM on Linux. +`microvm` is an experimental **Windows x64** backend using the signed native +agent bundled by the official `aci_edge_sandboxes` crate. The previous Nanvix +backend is inactive, including on Linux. This document retains its old location +so existing links continue to work. -## Key Features +## Phase1: minimal signed create-and-run -- **Fast cold-start** — ~100 ms from process spawn to guest code execution (Windows warm-start via WHP snapshot; Linux uses cold boot via KVM every run) -- **Low Memory Footprint** — Resident memory size of ~100 MB -- **Hardware-Enforced Isolation** — Runs guest code inside a lightweight virtual machine (VM) - -## Requirements - -### Windows - -- Windows with WHP enabled (`bcdedit /set hypervisorlaunchtype auto`) -- Nanvix runtime binaries (`nanvixd.exe`, `kernel.elf`, `python3.initrd`, `nanvix_rootfs.img`) placed next to `wxc-exec.exe` -- Build with `--with-microvm` (`build.bat --with-microvm` or `cargo build -p wxc --features microvm`) -- `--experimental` flag (Nanvix MicroVM is an experimental feature) - -### Linux - -- Linux with KVM available at `/dev/kvm` (and the invoking user has read/write access to it) -- Nanvix runtime binaries (`nanvixd.elf`, `kernel.elf`, `python3.initrd`, `nanvix_rootfs.img`) placed next to `lxc-exec` (the build script downloads and stages them automatically) -- Build with `--with-microvm` (`./build.sh --with-microvm` or `cargo build -p lxc --features microvm`) -- `--experimental` flag (Nanvix MicroVM is an experimental feature) - -> **Note:** On Linux, WHP snapshots are not used. Each invocation cold-boots -> the VM via KVM. Snapshot-based warm-start is Windows-only. - -### Offline builds - -By default the `nanvix_binaries` build script downloads the NanVix release -assets at compile time. For air-gapped or hermetic builds, pre-fetch the -binaries and point the `NANVIX_BIN` environment variable at the directory -containing them: +Build with `build.bat --with-microvm`, or, from `src`: +```text +cargo build -p wxc --features microvm --jobs 2 ``` -# Windows (PowerShell) -$env:NANVIX_BIN = "C:\path\to\nanvix-binaries" -# Linux / macOS -export NANVIX_BIN=/path/to/nanvix-binaries -``` - -When `NANVIX_BIN` is set, the build performs no network downloads and uses the -provided directory directly. The directory must contain the required binaries -(the flat files plus the `bin/` subdirectory); their checksums are still -verified against `checksums.json`. The easiest way to produce such a directory -is to run a normal `--with-microvm` build once and copy the staged -`nanvix-binaries` directory out of `OUT_DIR`. - -> **Snapshots are not trusted in offline mode.** WHP warm-start snapshots -> (`snapshots/kernel.vmem`, `snapshots/kernel.whp.cbor`) are *not* covered by -> `checksums.json` — in a normal build they are generated locally, not -> downloaded. Any `snapshots/` directory inside `NANVIX_BIN` is therefore -> ignored (never copied next to the executable), and the runtime cold-boots on -> first use to regenerate a verified snapshot. This prevents an unverified VM -> memory image from being warm-booted against otherwise-verified binaries. - -## Quick Start - -```json -{ - "version": "1.1.0-alpha", - "process": { - "commandLine": "print('Hello from MicroVM!')", - "timeout": 30000 - }, - "containment": "microvm" -} -``` - -```bash -wxc-exec.exe --experimental config.json -``` - -## SDK Usage - -Use `spawnSandboxFromConfig` with `usePty: false` for reliable exit codes and -separate stdout/stderr streams: - -```typescript -const child = spawnSandboxFromConfig(config, { - experimental: true, - usePty: false, -}); - -``` - -## Filesystem Policy - -### readwrite_paths - -Host directories or files listed in `readwritePaths` are copied into a private -per-run staging directory before boot. Nanvix mounts are snapshot-based — host -files are **not** modified while the guest is running. No junctions or live host -mounts are used. +The host needs Windows Hypervisor Platform and hardware virtualization. +Requests require `--experimental`. Unsupported targets fail closed; enabling +the Cargo feature does not enable MicroVM execution on Linux, macOS or ARM64. + +MXC uses `AgentConfig::bundled` and `AciEdgeSandbox::agent`, not the standalone +OpenVMM backend. Cargo fetches immutable source revision +`0430ce79d125203c20f0aa69243480513a774296` from +[`microsoft/nvx`](https://github.com/microsoft/nvx), with only `agent-bundled` +enabled and default features disabled. `mxc-sdk` is not publishable while this +development Git dependency is in use. + +The dependency's `build.rs` calls `build/agent_bundle.rs` to download +`aci-edge-sandboxes.zip` from release `v0.1.0-dev.0430ce79d125`. It verifies +the archive, layout, component checksums and sandbox ABI, stages the bundle, +and compiles in its manifest and DLL pins. MXC's build module requires the +exact path set and SHA-256 of every file in that official staged bundle, +including all guest, kernel, image-tool, manifest, checksum, provenance, and +licence files. This second check is necessary because Cargo does not inherit a +Git or path dependency's `.cargo/config.toml` when MXC is built by a downstream +consumer. The dependency exposes only the extracted directory to MXC, so the +original ZIP byte stream is no longer available there; the complete extracted +tree identity is pinned instead. MXC does not invoke Cargo recursively, +implement a second downloader, or silently fall back to an unsigned or +standalone backend. + +| Release artifact | SHA-256 | +| --- | --- | +| `aci-edge-sandboxes.zip` | `98e3d6011b0664fad6fa783768370a34209639463f5ab812b7776840660ac24e` | +| `bin/aci_edge_agent.dll` | `a203e58b97a642bd4a68824c0387965541895b25582e18db6f55e28947f5f135` | +| `bin/openvmm.exe` | `697f8221e62a1ea4efb3846fefde6f595c89f4f81c0153a2609bf6fb4c81ea9e` | + +Distribute the entire `aci-edge-sandboxes/` directory beside `wxc-exec.exe` +or the consuming executable. Cargo test/example executables can discover it in +their parent profile directory. The repository Windows build also copies this +directory with the Node native runtime. Runtime path overrides are not needed +for normal operation; missing or altered assets fail rather than falling back. +Build-time bundle overrides must still match the approved signed component pins. + +The control initramfs is not a workload userland. P1 selects the official +example's public workload image, +`mcr.microsoft.com/azurelinux/base/core:3.0`. The native agent pulls/converts it +on the host using bundled `direct-images.exe`; this requires host registry +connectivity and does **not** grant guest network access. There is no P1 public +image-selection or credential API. A private state directory is created for +each run, and stop, deprovision and state-directory removal follow execution. + +### Request example + +Commands are POSIX shell commands inside the Linux workload, not Python source +or Windows command lines: ```json { "version": "1.1.0-alpha", + "containment": "microvm", "process": { - "commandLine": "import os\npath = 'C:\\\\Users\\\\me\\\\work'\nwith open(os.path.join(path, 'result.txt'), 'w') as f:\n f.write('done')", + "commandLine": "printf 'hello from NVX\\n'; printf 'stderr\\n' >&2; exit 42", "timeout": 30000 - }, - "containment": "microvm", - "filesystem": { - "readwritePaths": ["C:\\Users\\me\\work"] - } -} -``` - -Inside the guest, host paths in the script are transparently rewritten to their -guest mount equivalents at staging time. The script uses the original host paths -and the staging layer translates them before the code reaches the VM. - -| Host path | Guest path | -| ------------------ | --------------------------- | -| `C:\Users\me\work` | `/mnt/rw/c/Users/me/work` | -| `C:\data\ref-data` | `/mnt/rw/c/data/ref-data` | - -**Copyback semantics:** After `nanvixd` exits normally, MXC copies the modified -snapshot back to the original host paths. Copyback runs for both exit code `0` -and non-zero guest exit codes. It is skipped for preflight failure, spawn -failure, watchdog timeout, and runner/runtime errors — no partial state is -leaked to the host. - -### readonly_paths - -Host directories listed in `readonlyPaths` are copied into the staging directory -with read-only file attributes. Writes return `EACCES`. Read-only paths are -never copied back to the host. - -```json -{ - "filesystem": { - "readonlyPaths": ["C:\\data\\reference"] } } ``` -### denied_paths - -Not supported for MicroVM. If `deniedPaths` is specified, the config is rejected with an error. - -## Constraints - -| Constraint | Value | -| --------------------------------------- | ------------------------------------------- | -| Single file size | < 4 GB (FAT32 limit) | -| Guest RAM | 256 MB | -| Symlinks/reparse points in source paths | Not supported (rejected at preflight) | -| Junctions for staging | Not used | -| `workingDirectory` | Not supported (guest CWD is `/`) | -| Network policy | v1.1 isolated or explicitly unrestricted host networking | - -## Networking - -Host networking is **opt-in**. Exact v1.1 supports two coherent directional -postures: all three of egress default, ingress default and host-loopback deny -leaves networking disabled; all three explicitly allow enables unrestricted -host networking through `-allow-host-networking`. Mixed postures are rejected, -including egress allow with omitted ingress defaults. - -```json -{ - "version": "1.1.0-alpha", - "containment": "microvm", - "process": { "commandLine": "print('network enabled')" }, - "network": { - "egress": { "default": "allow" }, - "ingress": { "default": "allow", "hostLoopback": "allow" } - } -} +```text +wxc-exec.exe --experimental config.json ``` -Disabled networking prevents guest socket creation (`OSError: [Errno 134]`). -Unrestricted networking includes host-backed bind/listen capabilities; -NanVix cannot independently enforce ingress or host-loopback restrictions. -Directional egress rules are explicitly rejected rather than translated to -the guest's IPv4 host filter, which cannot implement their full semantics, -including default-deny DNS. -Runtime proxy configuration is also unsupported. - -## Not Supported - -| Workload | Error | -| -------------------------------------------- | -------------------------------- | -| Mixed directional networking or egress rules | Rejected before VM creation | -| Runtime proxy | Rejected before VM creation | -| File writing outside `/mnt/rw/` | `OSError: Read-only file system` | +The minimum in-process captured surface is MXC's existing JSON facade, +`mxc_sdk::__ffi::run_json(json, true)`. It returns separate stdout/stderr bytes +and an exit or timeout outcome. This is not a new public Rust lifecycle API. + +### Supported and rejected requests + +P1 accepts command, timeout (at most one hour), guest working directory without +`..` components, and additive environment entries (at most 32 KiB). +Supplying `process.env` requires `inheritDefaultEnv: true`. Proxy environment +variables are rejected, case-insensitively. + +P1 rejects filesystem path grants/denials, explicit network or proxy policy, +explicit UI policy, capture-denials, `destroyOnExit: false`, +`preservePolicy: true`, streaming, PTY and container lifecycle operations. +Policy rejection occurs before bundle discovery or sandbox creation. The +native agent remains responsible for validating and enforcing its own +command/environment/runtime constraints. + +## Release attestations versus consumer evidence + +The published bundle records `officialSigned=true`, `complete=true`, +`mxcCompatible=false`, and `runtimeValidated=false`. These are producer +attestations, not an MXC test result. MXC does not rewrite them, treat the +negative validation flags alone as proof of runtime failure, or claim that a +successful build proves consumer runtime compatibility. + +The bundle embeds NVX revision +`19a359bb3b8974f14bd04b9f2a327a2a3f25d868`, which differs from the crate's +`0430ce79…` source revision. The official build warns about this difference. +Keep the warning and both revisions in validation evidence. + +Host validation can check archive/component SHA-256, Windows Authenticode for +the DLL, OpenVMM and image tool, and checksum integrity of the Linux kernel and +guest agent/initramfs. Linux guest files do not carry Authenticode signatures. +Actual guest execution, timeout and cleanup still require a WHP-capable test +host. Ignored tests in `src/mxc-sdk/tests/nvx_e2e.rs` prepare real MXC +facade/executor cases; compiling them or running host-only rejection cases is +not VM smoke evidence. + +## Stacked implementation boundary + +- **P1:** signed bundled crate, minimum distributable staging, fail-closed + routing/policy, and captured create-and-run. +- **P2:** deployment/packaging and runtime-policy hardening, including richer + policy and deployment validation. +- **P3:** public Rust container lifecycle operations and streaming. +- **P4:** CLI work follows P3. + +No P2 implementation is implied by P1's necessary bundle staging. diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index 87ed24a9e7..d2e2694017 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -112,6 +112,14 @@ a capability declaration, not a schedule. Current platforms: +The `microvm` entries below describe the existing CI deployment catalog. +Active MXC MicroVM execution is now the experimental signed NVX P1 backend +on Windows x64 only; the old Nanvix Linux runtime is inactive. The legacy +payload checks in the unified host-preparation scripts do not validate this +new bundle. Updating unified deployment/packaging and its policy-validation +matrix belongs to P2. The dedicated `microvm-e2e.yml` workflow and +`mxc-sdk/tests/nvx_e2e.rs` contain the signed-bundle consumer cases. + | Platform id | Family | x64 pool | arm64 pool | Declared backends (x64) | Declared backends (arm64) | |-------------|--------|----------|------------|--------------------------|------------| | `windows-prerelease-process-container` | windows | `1es-mxc-windows-prerelease-t1-x64` | `1es-mxc-windows-prerelease-t1-arm64` | process-t1, isolation-session, wslc, windows-sandbox, microvm | process-t1, isolation-session | @@ -233,7 +241,7 @@ get fixed or wired. | WSLC | ✅ Good | Might have to retry hung jobs - this is an issue with overzealous agent reclaiming. | | IsolationSession | ✅ Good | Runs the one-shot and state-aware suites, the Rust SDK in-process and helper tests, the C# end-to-end tests, the Node SDK suite and the COM apartment probe. Fails when the host cannot run isolation sessions, when a suite executes nothing, or when the run changes the set of local accounts. | | Windows Sandbox | ⛔ Blocked | Images don't support `Containers-DisposableClientVM` opt. feature | -| MicroVM | ⛔ Not working | Windows cold and warm starts hang; no Linux suite. The artifact payload is currently commented out in the build jobs. | +| MicroVM | Experimental P1 | Signed NVX on Windows x64; minimal captured create-and-run. Real WHP consumer evidence is separate from host-only build/signature checks. Unified artifact/matrix rollout belongs to P2. | | Seatbelt | ✅ Good | Failures are genuine MXC bugs. | ## Host preparation diff --git a/docs/schema.md b/docs/schema.md index c8df27bf01..1d3f4ca276 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -117,6 +117,9 @@ identity-less host proxies omit `allowedProxyPeer` and require deployment: it opens both host-loopback directions without restricting access to a named proxy peer. It does not enforce a proxy-only host-loopback exception. +The initial development Windows x64 NVX MicroVM backend rejects every network +policy and `runtimeConfig.networkProxy` before creating a VM. + ```json { "version": "0.9.0-alpha", @@ -330,7 +333,8 @@ use: | Seatbelt (macOS) | Same precedence, with `~` expanded as the profile expands it; falls back to `/`. | | Bubblewrap (Linux) | No substitution — a policy grant is never adopted. `--chdir` is emitted only for an explicit `process.cwd`, which from 0.9 is also normalized against the sandbox root and used as `HOME`. With no explicit `cwd` there is no `--chdir` and `HOME` is unset — see [`docs/backends/bwrap/bubblewrap-backend.md`](backends/bwrap/bubblewrap-backend.md). | | LXC / WSL Container | The container root — see [`docs/backends/lxc/lxc-backend.md`](backends/lxc/lxc-backend.md). | -| MicroVM (NanVix) / Hyperlight | Not applicable — these backends reject a working directory outright. | +| MicroVM | NVX on Windows x64 accepts an absolute guest working directory. Linux MicroVM execution is inactive in this phase. | +| Hyperlight | Not applicable — this backend rejects a working directory outright. | Policy entries that are blank, name a file, or do not exist yet are skipped: a process cannot be launched in any of them. @@ -382,6 +386,11 @@ The `filesystem` section defines path access policy shared across backends: | `readonlyPaths` | string[] | `[]` | Paths the process can read but not write. | | `deniedPaths` | string[] | `[]` | Paths the process cannot access at all. | +The initial development Windows x64 NVX MicroVM backend rejects +`readwritePaths`, `readonlyPaths`, and `deniedPaths`. The +`processContainer.filesystem.enumeratePaths` field is ProcessContainer-only +and also cannot be combined with `containment: "microvm"`. + The ProcessContainer-only `processContainer.filesystem` section contains: | Field | Type | Default | Description | @@ -471,7 +480,7 @@ force a particular backend. |-------|------------| | `"process"` | `processcontainer` on Windows, `bubblewrap` on Linux, `seatbelt` on macOS | | `"vm"` | Full hardware-virtualised VM isolation. Resolves to `windows_sandbox` on Windows. | -| `"microvm"` | MicroVM on Windows (NanVix via the Windows Hypervisor Platform). Experimental. | +| `"microvm"` | MicroVM on Windows x64 (NVX via the Windows Hypervisor Platform). Experimental. | #### Concrete backends @@ -481,7 +490,7 @@ force a particular backend. | `"windows_sandbox"` | Windows Sandbox VM isolation. Supports create-and-run execution with a fresh disposable VM per execution, and container lifecycle operations backed by a long-lived per-container daemon. | | `"wslc"` | Linux containers via the WSL Container SDK | | `"lxc"` | Native LXC container isolation. No abstract intent resolves to LXC; request it explicitly. | -| `"microvm"` | MicroVM isolation via Windows HyperV Platform (NanVix microkernel) | +| `"microvm"` | MicroVM isolation via NVX on Windows x64 | | `"hyperlight"` | MicroVM isolation via Hyperlight + Unikraft with an embedded CPython snapshot (experimental) | | `"isolation_session"` | Windows isolation session: create-and-run execution uses a freshly provisioned isolated user account in its own OS-managed session. Container lifecycle operations can retain the session between executions. | | `"seatbelt"` | macOS sandbox isolation (Seatbelt). Requires macOS 15 or later — see [`docs/backends/seatbelt/seatbelt-backend.md`](backends/seatbelt/seatbelt-backend.md). | diff --git a/src/Cargo.lock b/src/Cargo.lock index 4a50378eb4..f79061f6ce 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -2,6 +2,34 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aci_edge_sandboxes" +version = "0.1.0" +source = "git+https://github.com/microsoft/nvx?rev=0430ce79d125203c20f0aa69243480513a774296#0430ce79d125203c20f0aa69243480513a774296" +dependencies = [ + "aci_edge_sandboxes_model", + "libc", + "libloading", + "nvx-rs", + "serde", + "serde_json", + "sha2 0.10.9", + "static_assertions", + "windows-sys 0.61.2", +] + +[[package]] +name = "aci_edge_sandboxes_model" +version = "0.1.0" +source = "git+https://github.com/microsoft/nvx?rev=0430ce79d125203c20f0aa69243480513a774296#0430ce79d125203c20f0aa69243480513a774296" +dependencies = [ + "getrandom 0.4.2", + "nvx-rs", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "adler2" version = "2.0.1" @@ -176,7 +204,16 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", ] [[package]] @@ -310,7 +347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core", ] @@ -427,6 +464,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -460,6 +506,16 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -556,15 +612,25 @@ dependencies = [ "syn", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.1", "const-oid", - "crypto-common", + "crypto-common 0.2.2", ] [[package]] @@ -585,7 +651,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -630,7 +696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -746,6 +812,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1007,7 +1083,7 @@ dependencies = [ "rust-embed", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "tempfile", "termcolor", "thiserror", @@ -1033,7 +1109,7 @@ dependencies = [ "hyperlight-host", "rustix", "serde_json", - "sha2", + "sha2 0.11.0", "thiserror", "tracing", "windows-sys 0.61.2", @@ -1483,6 +1559,7 @@ dependencies = [ name = "mxc-sdk" version = "1.0.1" dependencies = [ + "aci_edge_sandboxes", "anyhow", "base64 0.22.1", "cc", @@ -1503,7 +1580,7 @@ dependencies = [ "serde", "serde_json", "serde_path_to_error", - "sha2", + "sha2 0.11.0", "tar", "tempfile", "thiserror", @@ -1624,6 +1701,14 @@ dependencies = [ "syn", ] +[[package]] +name = "nvx-rs" +version = "0.1.0" +source = "git+https://github.com/microsoft/nvx?rev=0430ce79d125203c20f0aa69243480513a774296#0430ce79d125203c20f0aa69243480513a774296" +dependencies = [ + "serde", +] + [[package]] name = "oci-spec" version = "0.10.0" @@ -1919,7 +2004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" dependencies = [ "globset", - "sha2", + "sha2 0.11.0", "walkdir", ] @@ -1948,7 +2033,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2142,6 +2227,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -2149,8 +2245,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -2227,6 +2323,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -2297,10 +2399,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2724,7 +2826,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/src/mxc-sdk/Cargo.toml b/src/mxc-sdk/Cargo.toml index caab172e1f..b7fc97b981 100644 --- a/src/mxc-sdk/Cargo.toml +++ b/src/mxc-sdk/Cargo.toml @@ -8,6 +8,7 @@ repository = "https://github.com/microsoft/mxc" readme = "README.md" build = "build.rs" autobins = false +publish = false [lib] name = "mxc_sdk" @@ -32,7 +33,7 @@ wslc = ["link-wslcsdk", "dep:libloading", "dep:tar"] link-wslcsdk = ["dep:zip"] isolation_session = ["dep:windows-collections", "dep:windows-future"] test-support = [] -microvm = [] +microvm = ["dep:aci_edge_sandboxes"] hyperlight = [ "dep:hyperlight-unikraft", "dep:ureq", @@ -103,6 +104,9 @@ windows-core.workspace = true windows-future = { version = "0.3", optional = true } winreg.workspace = true +[target.'cfg(all(target_os = "windows", target_arch = "x86_64"))'.dependencies] +aci_edge_sandboxes = { git = "https://github.com/microsoft/nvx", rev = "0430ce79d125203c20f0aa69243480513a774296", default-features = false, features = ["agent-bundled"], optional = true } + [target.'cfg(windows)'.build-dependencies] cc = "1" winresource = { version = "0.1.31", default-features = false } diff --git a/src/mxc-sdk/README.md b/src/mxc-sdk/README.md index e12b066a83..f28ca0978b 100644 --- a/src/mxc-sdk/README.md +++ b/src/mxc-sdk/README.md @@ -177,6 +177,25 @@ temporary storage without creating directories. ## Internal modules and executor binaries +### Experimental NVX create-and-run + +The `microvm` feature enables only Windows x64 and requires runtime experimental +authorization. P1 uses the official signed `aci_edge_sandboxes` bundled agent at +immutable Git revision `0430ce79d125203c20f0aa69243480513a774296`; it does not +enable the standalone OpenVMM feature. This development crate has +`publish = false`. + +The existing hidden JSON facade `mxc_sdk::__ffi::run_json(json, true)` supports +the development `1.1.0-alpha` MicroVM create-and-run request with captured +stdout/stderr, exit status and timeout. Typed V1 targeting, streaming, PTY and +public Rust container lifecycle operations are not broadened by P1. +Distribute the complete `aci-edge-sandboxes/` directory beside the consuming +executable. The native agent pulls the official example's public Azure Linux +workload image on the host; guest network access remains ungranted. +See the [NVX backend guide](https://github.com/microsoft/mxc/blob/main/docs/backends/nanvix/nanvix.md) +for rejected policy, release pins/attestations, host prerequisites and the +P2 deployment/hardening and P3 public Rust API boundaries. + Backend implementations, shared runtime support, backend dispatch, and host probing are internal modules of this published crate. The crate root exposes only the versioned public SDK and explicitly hidden compatibility surfaces used diff --git a/src/mxc-sdk/build.rs b/src/mxc-sdk/build.rs index a3c26529d7..6ad3bdfa16 100644 --- a/src/mxc-sdk/build.rs +++ b/src/mxc-sdk/build.rs @@ -10,28 +10,16 @@ mod build_mxc_build_common; mod build_mxc_common; #[path = "build/build_mxc_telemetry.rs"] mod build_mxc_telemetry; -#[path = "build/build_nanvix_binaries.rs"] -mod build_nanvix_binaries; +#[path = "build/build_nvx_bundled.rs"] +mod build_nvx_bundled; #[cfg(feature = "link-wslcsdk")] #[path = "build/build_wslc_common.rs"] mod build_wslc_common; -// The SDK build uses the download/cache subset. Executor build scripts include -// this same module and use its artifact-staging helpers. -#[allow(dead_code)] -#[path = "build/build_nanvix_build_common.rs"] -mod nanvix_build_common; -// The SDK build uses the release metadata subset. Runtime and executor modules -// include the remaining constants and stderr helpers. -#[allow(dead_code)] -#[path = "src/backends/nanvix/common/mod.rs"] -mod nanvix_common; fn main() -> Result<(), Box> { build_isolation_session_bindings::run(); build_mxc_telemetry::run(); - if let Some((bin_dir, use_prefetched_binaries)) = build_nanvix_binaries::run() { - nanvix_build_common::stage_artifacts_next_to_exe(&bin_dir, !use_prefetched_binaries); - } + build_nvx_bundled::run(); #[cfg(feature = "link-wslcsdk")] build_wslc_common::run(); build_mxc_common::run()?; diff --git a/src/mxc-sdk/build/build_nvx_bundled.rs b/src/mxc-sdk/build/build_nvx_bundled.rs new file mode 100644 index 0000000000..d3ac1b8554 --- /dev/null +++ b/src/mxc-sdk/build/build_nvx_bundled.rs @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::collections::BTreeMap; +use std::path::{Component, Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +const OFFICIAL_BUNDLE_FILES: &[(&str, &str)] = &[ + ( + "SHA256SUMS", + "d86b7768ca51e6ea93c40880cc027c85280e718e960e1efe3e0e80bd23727fce", + ), + ( + "SOURCE-MANIFEST.json", + "d5487737b07d663d983373dec82d917bbfcab58e39fed45b6642e8885c3fb01b", + ), + ( + "bin/aci_edge_agent.dll", + "a203e58b97a642bd4a68824c0387965541895b25582e18db6f55e28947f5f135", + ), + ( + "bin/direct-images.exe", + "633b679a8f8f2586626b7475b1e5e3faa3d2ea7119082ffc5873191194feb6fe", + ), + ( + "bin/openvmm.exe", + "697f8221e62a1ea4efb3846fefde6f595c89f4f81c0153a2609bf6fb4c81ea9e", + ), + ( + "bundle-manifest.json", + "44c9c355dee41bd0cd098b9ccfb623ddb7508407c213ca3343090500348e9253", + ), + ( + "guest/initramfs-edge.cpio.gz", + "5e42d4617c1508e9116df8c330c0ff72243e954d7f132091ecdecfc743d904ed", + ), + ( + "guest/nvx-edge-agent", + "d29acada883da8f2977036958704516545cb295114e94999eb269094b81aaff7", + ), + ( + "guest/vmlinux", + "b36ab45b22d1c14438047b24c3724f54cc71dd03601d0f3cbdca11438f40e768", + ), + ( + "guest/vmlinux.config", + "89228d78a10ac9d8f151384b0adc5a155c8a1ee045732b17e236a0e28b49297d", + ), + ( + "provenance/edge-guest.json", + "31512ee712657ede1884ad2ed04f86200f78f282a9fb8e6f0855a4790875507b", + ), + ( + "provenance/edge-windows.json", + "21f2455e680b565dac122ac4be3cf793159e31979ccc6def3a982a4013ae209f", + ), + ( + "provenance/licenses/linux/COPYING-LINUX", + "4fc360c08586ee232e17eaf34e97feae0065dc3daacb2210b754921dc587a5c5", + ), + ( + "provenance/licenses/nvx/LICENSE", + "7df20dcdf9197e9945c14858d41c60f11b52b93e5b69e2b63416b874d598d322", + ), + ( + "provenance/licenses/nvx/THIRD_PARTY_NOTICES.md", + "d659326442a1092e29ba2edec81ff6cb3a4ad63dbdd36a166478eb08fecd9fe0", + ), + ( + "provenance/licenses/openvmm/LICENSE", + "c2cfccb812fe482101a8f04597dfc5a9991a6b2748266c47ac91b6a5aae15383", + ), + ( + "provenance/openvmm.provenance.json", + "adfd57d7a7197f91604acad3eea30b583fa3f442b4b8cabd1147611c5c1b3521", + ), + ( + "provenance/vmlinux.provenance.json", + "a526683d54302c87c2dacbecdfcc879817c4ba2f1e21241f0d2c9d170b17cc58", + ), +]; + +pub(crate) fn run() { + println!("cargo:rerun-if-env-changed=CARGO_FEATURE_MICROVM"); + println!("cargo:rerun-if-env-changed=DEP_ACI_EDGE_SANDBOXES_AGENT_ARTIFACTS_DIR"); + if std::env::var_os("CARGO_FEATURE_MICROVM").is_none() + || std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") + || std::env::var("CARGO_CFG_TARGET_ARCH").as_deref() != Ok("x86_64") + { + return; + } + + // Cargo does not inherit a dependency checkout's .cargo/config.toml. Authenticate the exact + // extracted release here so downstream path and Git consumers cannot select another bundle. + let source = PathBuf::from( + std::env::var_os("DEP_ACI_EDGE_SANDBOXES_AGENT_ARTIFACTS_DIR") + .expect("microvm requires the official signed agent-bundled NVX release"), + ); + verify_bundle(&source); + + let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR is set")); + let target = out_dir + .ancestors() + .nth(3) + .expect("OUT_DIR has target/profile ancestors") + .join("aci-edge-sandboxes"); + copy_bundle(&source, &target); + verify_bundle(&target); +} + +fn sha256(path: &Path) -> String { + let bytes = std::fs::read(path).unwrap_or_else(|error| { + panic!( + "cannot read signed NVX artifact {}: {error}", + path.display() + ) + }); + Sha256::digest(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn verify_bundle(source: &Path) { + verify_bundle_against(source, OFFICIAL_BUNDLE_FILES); +} + +pub(crate) fn verify_bundle_against(source: &Path, expected_files: &[(&str, &str)]) { + let expected: BTreeMap<&str, &str> = expected_files.iter().copied().collect(); + assert_eq!( + expected.len(), + expected_files.len(), + "official NVX bundle file list contains duplicate paths" + ); + + let mut actual = BTreeMap::new(); + collect_bundle_files(source, source, &mut actual); + let actual_paths: Vec<&str> = actual.keys().map(String::as_str).collect(); + let expected_paths: Vec<&str> = expected.keys().copied().collect(); + assert_eq!( + actual_paths, expected_paths, + "microvm requires the complete signed v0.1.0-dev.0430ce79d125 bundle" + ); + + for (relative, path) in actual { + let expected_sha256 = expected[relative.as_str()]; + assert_eq!( + sha256(&path), + expected_sha256, + "microvm requires the signed v0.1.0-dev.0430ce79d125 artifact {relative}" + ); + } +} + +fn collect_bundle_files(source: &Path, directory: &Path, files: &mut BTreeMap) { + for entry in std::fs::read_dir(directory) + .unwrap_or_else(|error| panic!("cannot list {}: {error}", directory.display())) + { + let entry = entry.unwrap_or_else(|error| panic!("cannot read bundle entry: {error}")); + let path = entry.path(); + let kind = entry + .file_type() + .unwrap_or_else(|error| panic!("cannot inspect {}: {error}", path.display())); + assert!(!kind.is_symlink(), "NVX bundle must not contain links"); + if kind.is_dir() { + collect_bundle_files(source, &path, files); + continue; + } + assert!(kind.is_file(), "NVX bundle must contain only regular files"); + let relative = path + .strip_prefix(source) + .expect("bundle entry remains under its source"); + assert!( + relative + .components() + .all(|component| matches!(component, Component::Normal(_))), + "NVX bundle path is not safe: {}", + relative.display() + ); + let relative = relative + .to_str() + .unwrap_or_else(|| panic!("NVX bundle path is not UTF-8: {}", relative.display())) + .replace('\\', "/"); + assert!( + files.insert(relative.clone(), path).is_none(), + "duplicate NVX bundle path: {relative}" + ); + } +} + +fn copy_bundle(source: &Path, target: &Path) { + std::fs::create_dir_all(target) + .unwrap_or_else(|error| panic!("cannot create {}: {error}", target.display())); + for entry in std::fs::read_dir(source) + .unwrap_or_else(|error| panic!("cannot list {}: {error}", source.display())) + { + let entry = entry.unwrap_or_else(|error| panic!("cannot read bundle entry: {error}")); + let from = entry.path(); + let to = target.join(entry.file_name()); + let kind = entry + .file_type() + .unwrap_or_else(|error| panic!("cannot inspect {}: {error}", from.display())); + assert!(!kind.is_symlink(), "NVX bundle must not contain links"); + if kind.is_dir() { + copy_bundle(&from, &to); + } else { + assert!(kind.is_file(), "NVX bundle must contain only regular files"); + println!("cargo:rerun-if-changed={}", from.display()); + let contents = std::fs::read(&from) + .unwrap_or_else(|error| panic!("cannot read {}: {error}", from.display())); + if std::fs::read(&to).is_ok_and(|existing| existing == contents) { + continue; + } + std::fs::copy(&from, &to).unwrap_or_else(|error| { + panic!( + "cannot stage signed NVX artifact {} to {}: {error}", + from.display(), + to.display() + ) + }); + } + } +} diff --git a/src/mxc-sdk/src/backends/mod.rs b/src/mxc-sdk/src/backends/mod.rs index 74182d9766..6b2c143417 100644 --- a/src/mxc-sdk/src/backends/mod.rs +++ b/src/mxc-sdk/src/backends/mod.rs @@ -21,15 +21,8 @@ pub mod lxc { pub mod common; } -#[cfg(any(target_os = "windows", target_os = "linux"))] -pub mod nanvix { - #[cfg(feature = "microvm")] - pub mod binaries; - #[cfg(feature = "microvm")] - pub mod common; - #[cfg(feature = "microvm")] - pub mod runner; -} +#[cfg(all(target_os = "windows", target_arch = "x86_64", feature = "microvm"))] +pub mod nvx; #[cfg(target_os = "windows")] pub mod process_container { diff --git a/src/mxc-sdk/src/backends/nvx/mod.rs b/src/mxc-sdk/src/backends/nvx/mod.rs new file mode 100644 index 0000000000..cce5cd4761 --- /dev/null +++ b/src/mxc-sdk/src/backends/nvx/mod.rs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod policy; +pub mod runner; +mod runtime; diff --git a/src/mxc-sdk/src/backends/nvx/policy.rs b/src/mxc-sdk/src/backends/nvx/policy.rs new file mode 100644 index 0000000000..5075d46c7c --- /dev/null +++ b/src/mxc-sdk/src/backends/nvx/policy.rs @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::mxc_common::models::ExecutionRequest; +use crate::mxc_common::mxc_error::MxcError; + +const MAX_TIMEOUT_MS: u32 = 3_600_000; +const MAX_ENVIRONMENT_BYTES: usize = 32 * 1024; +const PROXY_ENVIRONMENT_KEYS: [&str; 4] = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]; + +pub(super) fn validate_phase_one(request: &ExecutionRequest) -> Result<(), MxcError> { + if !request.lifecycle.destroy_on_exit { + return Err(MxcError::policy_validation( + "Phase 1 NVX requires lifecycle.destroyOnExit=true", + )); + } + if request.lifecycle.preserve_policy { + return Err(MxcError::policy_validation( + "Phase 1 NVX requires lifecycle.preservePolicy=false", + )); + } + if !request.policy.readonly_paths.is_empty() + || !request.policy.readwrite_paths.is_empty() + || !request.policy.enumerate_paths.is_empty() + || !request.policy.denied_paths.is_empty() + { + return Err(MxcError::policy_validation( + "Phase 1 NVX does not support filesystem policy", + )); + } + if request.policy.network_specified + || request.policy.runtime_network_proxy_specified + || request.policy.network_proxy.is_enabled() + { + return Err(MxcError::policy_validation( + "Phase 1 NVX does not support network policy or proxy configuration", + )); + } + if request.policy.ui_specified { + return Err(MxcError::policy_validation( + "Phase 1 NVX does not support UI policy", + )); + } + if request.policy.capture_denials.is_some() { + return Err(MxcError::policy_validation( + "Phase 1 NVX does not support processContainer.captureDenials", + )); + } + if request.policy.least_privilege_specified { + return Err(MxcError::policy_validation( + "Phase 1 NVX does not support processContainer.leastPrivilege", + )); + } + if request.policy.learning_mode_specified { + return Err(MxcError::policy_validation( + "Phase 1 NVX does not support processContainer.learningMode", + )); + } + if request.policy.capabilities_specified { + return Err(MxcError::policy_validation( + "Phase 1 NVX does not support processContainer.capabilities", + )); + } + if request.policy.base_process_ui_specified { + return Err(MxcError::policy_validation( + "Phase 1 NVX does not support processContainer.ui", + )); + } + if request.policy.fallback_specified { + return Err(MxcError::policy_validation( + "Phase 1 NVX does not support fallback policy", + )); + } + validate_process(request) +} + +fn validate_process(request: &ExecutionRequest) -> Result<(), MxcError> { + if request.script_code.contains('\0') || request.working_directory.contains('\0') { + return Err(MxcError::malformed_request( + "NVX process.commandLine and process.cwd must not contain null bytes", + )); + } + if request.script_timeout > MAX_TIMEOUT_MS { + return Err(MxcError::policy_validation( + "Phase 1 NVX process.timeout must not exceed one hour", + )); + } + if request + .working_directory + .split('/') + .any(|component| component == "..") + { + return Err(MxcError::policy_validation( + "Phase 1 NVX process.cwd must not contain '..' path components", + )); + } + if !request.working_directory.is_empty() && !request.working_directory.starts_with('/') { + return Err(MxcError::policy_validation( + "Phase 1 NVX process.cwd must be an absolute POSIX guest path", + )); + } + if let Some(environment) = &request.env { + if !request.inherit_default_env { + return Err(MxcError::policy_validation( + "Phase 1 NVX process.env requires inheritDefaultEnv=true", + )); + } + let bytes = environment + .iter() + .map(|entry| entry.len().saturating_add(1)) + .sum::(); + if bytes > MAX_ENVIRONMENT_BYTES { + return Err(MxcError::policy_validation( + "Phase 1 NVX process.env must not exceed 32 KiB", + )); + } + for entry in environment { + if entry.contains('\0') || entry.split_once('=').is_none_or(|(key, _)| key.is_empty()) { + return Err(MxcError::malformed_request( + "NVX process.env entries must be KEY=VALUE without null bytes", + )); + } + let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); + if PROXY_ENVIRONMENT_KEYS + .iter() + .any(|candidate| key.eq_ignore_ascii_case(candidate)) + { + return Err(MxcError::policy_validation( + "Phase 1 NVX rejects proxy environment variables; use typed network proxy policy in a later phase", + )); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mxc_common::models::{CaptureDenialsConfig, ProxyAddress}; + use crate::mxc_common::mxc_error::MxcErrorCode; + + fn request() -> ExecutionRequest { + ExecutionRequest { + script_code: "echo ok".to_owned(), + ..ExecutionRequest::default() + } + } + + fn assert_policy_error(request: &ExecutionRequest, text: &str) { + let error = validate_phase_one(request).unwrap_err(); + assert_eq!(error.code, MxcErrorCode::PolicyValidation); + assert!(error.message.contains(text), "{error}"); + } + + #[test] + fn accepts_command_timeout_cwd_and_additive_environment() { + let mut request = request(); + request.script_timeout = 30_000; + request.working_directory = "/workspace".to_owned(); + request.env = Some(vec!["KEY=value".to_owned()]); + request.inherit_default_env = true; + assert!(validate_phase_one(&request).is_ok()); + } + + #[test] + fn rejects_filesystem_network_ui_capture_and_lifecycle_expansion() { + let mut filesystem = request(); + filesystem + .policy + .readwrite_paths + .push(r"C:\work".to_owned()); + assert_policy_error(&filesystem, "filesystem policy"); + + let mut network = request(); + network.policy.network_specified = true; + assert_policy_error(&network, "network policy"); + + let mut proxy = request(); + proxy.policy.network_proxy.address = Some(ProxyAddress::new("127.0.0.1".to_owned(), 8080)); + assert_policy_error(&proxy, "proxy configuration"); + + let mut ui = request(); + ui.policy.ui_specified = true; + assert_policy_error(&ui, "UI policy"); + + let mut capture = request(); + capture.policy.capture_denials = Some(CaptureDenialsConfig::default()); + assert_policy_error(&capture, "captureDenials"); + + let mut destroy = request(); + destroy.lifecycle.destroy_on_exit = false; + assert_policy_error(&destroy, "destroyOnExit=true"); + + let mut preserve = request(); + preserve.lifecycle.preserve_policy = true; + assert_policy_error(&preserve, "preservePolicy=false"); + } + + #[test] + fn adopts_signed_profile_process_limits() { + let mut parent = request(); + parent.working_directory = "/workspace/../escape".to_owned(); + assert_policy_error(&parent, "must not contain '..'"); + + let mut replacement = request(); + replacement.env = Some(vec!["KEY=value".to_owned()]); + assert_policy_error(&replacement, "inheritDefaultEnv=true"); + + let mut proxy = request(); + proxy.env = Some(vec!["hTtP_pRoXy=http://proxy".to_owned()]); + proxy.inherit_default_env = true; + assert_policy_error(&proxy, "proxy environment"); + + let mut large = request(); + large.env = Some(vec![format!("KEY={}", "x".repeat(MAX_ENVIRONMENT_BYTES))]); + large.inherit_default_env = true; + assert_policy_error(&large, "32 KiB"); + + let mut timeout = request(); + timeout.script_timeout = MAX_TIMEOUT_MS + 1; + assert_policy_error(&timeout, "one hour"); + } + + #[test] + fn validates_posix_cwd_and_exact_process_boundaries() { + let mut request = request(); + request.script_timeout = MAX_TIMEOUT_MS; + request.working_directory = "/tmp/./workspace".to_owned(); + request.env = Some(vec![format!( + "KEY={}", + "x".repeat(MAX_ENVIRONMENT_BYTES - 5) + )]); + request.inherit_default_env = true; + assert!(validate_phase_one(&request).is_ok()); + + request.working_directory = "C:\\host".to_owned(); + assert_policy_error(&request, "absolute POSIX guest path"); + request.working_directory = "/tmp".to_owned(); + for key in PROXY_ENVIRONMENT_KEYS { + request.env = Some(vec![format!("{}=value", key.to_ascii_lowercase())]); + assert_policy_error(&request, "proxy environment"); + } + request.env = Some(vec!["=missing-key".to_owned()]); + assert_eq!( + validate_phase_one(&request).unwrap_err().code, + MxcErrorCode::MalformedRequest + ); + request.env = None; + request.script_code.push('\0'); + assert_eq!( + validate_phase_one(&request).unwrap_err().code, + MxcErrorCode::MalformedRequest + ); + } +} diff --git a/src/mxc-sdk/src/backends/nvx/runner.rs b/src/mxc-sdk/src/backends/nvx/runner.rs new file mode 100644 index 0000000000..41fbfd0d22 --- /dev/null +++ b/src/mxc-sdk/src/backends/nvx/runner.rs @@ -0,0 +1,342 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Minimal create-and-run adapter for the signed bundled NVX agent. + +use std::time::Duration; + +use aci_edge_sandboxes::{ExecOutcome, ExecRequest, ProvisionRequest}; + +use crate::mxc_common::audit::{AuditEvent, AuditEventName}; +use crate::mxc_common::logger::Logger; +use crate::mxc_common::models::{ExecutionRequest, FailurePhase, ScriptResponse}; +use crate::mxc_common::mxc_error::{MxcError, MxcErrorCode}; +use crate::mxc_common::script_runner::ScriptRunner; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapturedOutcome { + Exited(i32), + TimedOut, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapturedRun { + pub outcome: CapturedOutcome, + pub warnings: Vec, + pub stdout: Vec, + pub stderr: Vec, +} + +#[derive(Debug, Default)] +pub struct NvxRunner; + +impl NvxRunner { + pub fn new() -> Self { + Self + } + + pub fn run_captured( + &mut self, + request: &ExecutionRequest, + logger: &mut Logger, + ) -> Result { + super::policy::validate_phase_one(request)?; + let state_root = tempfile::Builder::new() + .prefix(&format!("mxc-nvx-{}-", std::process::id())) + .tempdir() + .map_err(|error| { + MxcError::backend_unavailable(format!( + "cannot create a private NVX state directory: {error}" + )) + })?; + let prepared = (|| { + let client = super::runtime::create_client(state_root.path())?; + client.probe().map_err(super::runtime::map_nvx_error)?; + let provisioned = client + .provision_with(&ProvisionRequest::new(), &super::runtime::sandbox_spec()) + .map_err(super::runtime::map_nvx_error)?; + Ok((client, provisioned.sandbox_id)) + })(); + let (client, sandbox_id) = match prepared { + Ok(prepared) => prepared, + Err(error) => { + let cleanup = state_root + .close() + .err() + .map(|error| { + MxcError::backend_error(format!("cannot remove NVX state: {error}")) + }) + .into_iter() + .collect(); + return finish_run(Err(error), cleanup, logger); + } + }; + let identity = sandbox_id.to_string(); + logger.log_audit_event( + &AuditEvent::new(AuditEventName::SandboxIdentity) + .str("identity", &identity) + .str("backend", "microvm") + .str("runtime", "signed-bundled-agent"), + ); + let primary = (|| { + client + .start(&sandbox_id) + .map_err(super::runtime::map_nvx_error)?; + client + .exec(&sandbox_id, &exec_request(request)) + .map_err(super::runtime::map_nvx_error)? + .wait_with_output() + .map_err(super::runtime::map_nvx_error) + })(); + + let stop_error = client + .stop(&sandbox_id) + .err() + .map(super::runtime::map_nvx_error); + let stop_error = match stop_error { + Some(error) if primary.is_err() && error.code == MxcErrorCode::AlreadyStopped => None, + other => other, + }; + let deprovision_error = client + .deprovision(&sandbox_id) + .err() + .map(super::runtime::map_nvx_error); + let retain_state = deprovision_error.is_some(); + let mut cleanup_errors: Vec = [stop_error, deprovision_error] + .into_iter() + .flatten() + .collect(); + drop(client); + if retain_state { + let retained = state_root.keep(); + cleanup_errors.push(MxcError::backend_error(format!( + "NVX state retained for recovery after deprovision failure: {}", + retained.display() + ))); + } else if let Err(error) = state_root.close() { + cleanup_errors.push(MxcError::backend_error(format!( + "cannot remove NVX state directory: {error}" + ))); + } + logger.log_audit_event( + &AuditEvent::new(AuditEventName::SandboxTornDown) + .str("identity", &identity) + .str("backend", "microvm") + .str( + "status", + if cleanup_errors.is_empty() { + "ok" + } else { + "failed" + }, + ) + .bool("state_retained", retain_state), + ); + let output = finish_run(primary, cleanup_errors, logger)?; + + Ok(CapturedRun { + outcome: map_outcome(output.outcome)?, + warnings: Vec::new(), + stdout: output.stdout, + stderr: output.stderr, + }) + } +} + +fn finish_run( + primary: Result, + cleanup_errors: Vec, + logger: &mut Logger, +) -> Result { + match primary { + Ok(output) if cleanup_errors.is_empty() => Ok(output), + Ok(_) => Err(MxcError::backend_error(format!( + "NVX cleanup failed after workload completion: {}", + cleanup_errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("; ") + ))), + Err(mut error) => { + for cleanup in cleanup_errors { + logger.warning_line(&format!("NVX cleanup failed: {cleanup}")); + error.message.push_str("; cleanup failure: "); + error.message.push_str(&cleanup.to_string()); + } + Err(error) + } + } +} + +impl ScriptRunner for NvxRunner { + fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { + super::policy::validate_phase_one(request) + .map_err(|error| ScriptResponse::rejected(&error.message)) + } + + fn execute(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { + match self.run_captured(request, logger) { + Ok(result) => captured_to_script_response(result), + Err(error) if error.code == MxcErrorCode::PolicyValidation => { + ScriptResponse::rejected(&error.message) + } + Err(error) => ScriptResponse { + exit_code: -1, + error_message: error.to_string(), + failure_phase: if error.code == MxcErrorCode::BackendUnavailable { + FailurePhase::BackendUnavailable + } else { + FailurePhase::PostLaunchFailed + }, + ..Default::default() + }, + } + } +} + +fn map_outcome(outcome: ExecOutcome) -> Result { + match outcome { + ExecOutcome::Exited(code) => Ok(CapturedOutcome::Exited(code)), + ExecOutcome::TimedOut => Ok(CapturedOutcome::TimedOut), + ExecOutcome::Signaled(signal) => { + Ok(CapturedOutcome::Exited(128_i32.saturating_add(signal))) + } + other => Err(MxcError::backend_error(format!( + "NVX workload did not complete normally: {other}" + ))), + } +} + +fn captured_to_script_response(result: CapturedRun) -> ScriptResponse { + let standard_out = String::from_utf8_lossy(&result.stdout).into_owned(); + let standard_err = String::from_utf8_lossy(&result.stderr).into_owned(); + match result.outcome { + CapturedOutcome::Exited(exit_code) => ScriptResponse { + exit_code, + standard_out, + standard_err, + ..Default::default() + }, + CapturedOutcome::TimedOut => ScriptResponse { + exit_code: -1, + standard_out, + standard_err, + error_message: "NVX workload timed out".to_string(), + failure_phase: FailurePhase::Timeout, + ..Default::default() + }, + } +} + +fn exec_request(request: &ExecutionRequest) -> ExecRequest { + let mut exec = ExecRequest::command_line(request.script_code.clone()); + if request.script_timeout != 0 { + exec = exec.with_timeout(Duration::from_millis(u64::from(request.script_timeout))); + } + if !request.working_directory.is_empty() { + exec = exec.with_cwd(request.working_directory.clone()); + } + if let Some(environment) = &request.env { + exec = exec.with_envs(environment.clone()); + } + exec.with_inherit_default_env(request.inherit_default_env) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_exit_signal_and_timeout_outcomes() { + assert_eq!( + map_outcome(ExecOutcome::Exited(42)).unwrap(), + CapturedOutcome::Exited(42) + ); + assert_eq!( + map_outcome(ExecOutcome::Signaled(9)).unwrap(), + CapturedOutcome::Exited(137) + ); + assert_eq!( + map_outcome(ExecOutcome::TimedOut).unwrap(), + CapturedOutcome::TimedOut + ); + } + + #[test] + fn rejects_non_terminal_success_outcomes() { + assert!(map_outcome(ExecOutcome::Cancelled).is_err()); + } + + #[test] + fn executor_adapter_preserves_exit_and_output() { + let response = captured_to_script_response(CapturedRun { + outcome: CapturedOutcome::Exited(42), + warnings: Vec::new(), + stdout: b"out".to_vec(), + stderr: b"err".to_vec(), + }); + assert_eq!(response.exit_code, 42); + assert_eq!(response.standard_out, "out"); + assert_eq!(response.standard_err, "err"); + assert!(response.error_message.is_empty()); + } + + #[test] + fn executor_adapter_marks_timeout() { + let response = captured_to_script_response(CapturedRun { + outcome: CapturedOutcome::TimedOut, + warnings: Vec::new(), + stdout: Vec::new(), + stderr: Vec::new(), + }); + assert_eq!(response.exit_code, -1); + assert_eq!(response.failure_phase, FailurePhase::Timeout); + } + + #[test] + fn cleanup_failure_is_not_success_and_preserves_primary_error() { + let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); + let cleanup = || vec![MxcError::backend_error("deprovision failed")]; + let success = finish_run(Ok(42), cleanup(), &mut logger).unwrap_err(); + assert!(success + .message + .contains("cleanup failed after workload completion")); + + let failure = finish_run::<()>( + Err(MxcError::backend_unavailable("start failed")), + cleanup(), + &mut logger, + ) + .unwrap_err(); + assert_eq!(failure.code, MxcErrorCode::BackendUnavailable); + assert!(failure.message.contains("start failed")); + assert!(failure.message.contains("deprovision failed")); + } + + #[test] + fn process_mapping_preserves_command_timeout_cwd_and_environment_presence() { + let request = ExecutionRequest { + script_code: "printf '%s' \"$KEY\"".to_owned(), + working_directory: "/tmp".to_owned(), + script_timeout: 250, + env: Some(Vec::new()), + inherit_default_env: true, + ..Default::default() + }; + let mapped = exec_request(&request); + assert_eq!( + mapped.process.command, + aci_edge_sandboxes::Command::CommandLine(request.script_code) + ); + assert_eq!(mapped.process.timeout, Some(Duration::from_millis(250))); + assert_eq!(mapped.process.cwd.as_deref(), Some("/tmp")); + assert_eq!(mapped.process.env, Some(Vec::new())); + assert_eq!(mapped.process.inherit_default_env, Some(true)); + + let defaults = exec_request(&ExecutionRequest::default()); + assert_eq!(defaults.process.env, None); + assert_eq!(defaults.process.timeout, None); + assert_eq!(defaults.process.cwd, None); + } +} diff --git a/src/mxc-sdk/src/backends/nvx/runtime.rs b/src/mxc-sdk/src/backends/nvx/runtime.rs new file mode 100644 index 0000000000..becf01d749 --- /dev/null +++ b/src/mxc-sdk/src/backends/nvx/runtime.rs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::path::Path; + +use aci_edge_sandboxes::agent::AgentConfig; +use aci_edge_sandboxes::AciEdgeSandbox; +use aci_edge_sandboxes::{ImageSource, SandboxSpec}; + +use crate::mxc_common::mxc_error::{MxcError, MxcErrorCode}; + +pub(super) fn create_client(state_root: &Path) -> Result { + let config = AgentConfig::bundled(state_root).map_err(map_nvx_error)?; + AciEdgeSandbox::agent(config).map_err(map_nvx_error) +} + +pub(super) fn sandbox_spec() -> SandboxSpec { + // The edge initramfs is the control guest, not a workload userland. Use the official + // example's public image; its host-side pull does not grant the workload network access. + SandboxSpec::new().with_image(ImageSource::Reference( + "mcr.microsoft.com/azurelinux/base/core:3.0".to_owned(), + )) +} + +pub(super) fn map_nvx_error(error: aci_edge_sandboxes::Error) -> MxcError { + let code = match error.code() { + aci_edge_sandboxes::ErrorCode::MalformedRequest => MxcErrorCode::MalformedRequest, + aci_edge_sandboxes::ErrorCode::MalformedId => MxcErrorCode::MalformedId, + aci_edge_sandboxes::ErrorCode::StaleId => MxcErrorCode::StaleId, + aci_edge_sandboxes::ErrorCode::NotStarted => MxcErrorCode::NotStarted, + aci_edge_sandboxes::ErrorCode::AlreadyStarted => MxcErrorCode::AlreadyStarted, + aci_edge_sandboxes::ErrorCode::AlreadyStopped => MxcErrorCode::AlreadyStopped, + aci_edge_sandboxes::ErrorCode::PolicyValidation => MxcErrorCode::PolicyValidation, + aci_edge_sandboxes::ErrorCode::BackendUnavailable => MxcErrorCode::BackendUnavailable, + _ => MxcErrorCode::BackendError, + }; + MxcError::new( + code, + format!("NVX signed bundled agent: {}", error.message()), + ) +} diff --git a/src/mxc-sdk/src/core/mxc_common/cmdline.rs b/src/mxc-sdk/src/core/mxc_common/cmdline.rs index e97388b247..507a342e42 100644 --- a/src/mxc-sdk/src/core/mxc_common/cmdline.rs +++ b/src/mxc-sdk/src/core/mxc_common/cmdline.rs @@ -42,10 +42,10 @@ impl CommandLineContext { ContainmentBackend::Wslc | ContainmentBackend::Lxc | ContainmentBackend::Seatbelt - | ContainmentBackend::Bubblewrap => Self::PosixShell, + | ContainmentBackend::Bubblewrap + | ContainmentBackend::MicroVm => Self::PosixShell, ContainmentBackend::ProcessContainer | ContainmentBackend::Vm - | ContainmentBackend::MicroVm | ContainmentBackend::Hyperlight => Self::WindowsCreateProcess, } } @@ -381,6 +381,14 @@ mod tests { ); } + #[test] + fn microvm_uses_posix_shell_quoting() { + assert_eq!( + CommandLineContext::for_backend(&ContainmentBackend::MicroVm), + CommandLineContext::PosixShell + ); + } + #[test] fn context_renderer_rejects_null_bytes() { let err = cmdline_from_argv_for_context( diff --git a/src/mxc-sdk/src/core/mxc_common/config_parser.rs b/src/mxc-sdk/src/core/mxc_common/config_parser.rs index 39c3990723..64776ad58e 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -1360,6 +1360,11 @@ fn normalize_common_request_ir( // the newer BaseContainer implementation (ui). let mut process_container_network = None; if let Some(ac) = cfg.process_container { + policy.least_privilege_specified = ac.least_privilege.is_some(); + policy.learning_mode_specified = ac.learning_mode.is_some(); + policy.capabilities_specified = ac.capabilities.is_some(); + policy.base_process_ui_specified = ac.ui.is_some(); + if let Some(lp) = ac.least_privilege { policy.least_privilege_mode = lp; } @@ -1498,6 +1503,7 @@ fn normalize_common_request_ir( normalize_filesystem_paths(&mut policy, logger); // Fallback section + policy.fallback_specified = cfg.fallback.is_some(); if let Some(fbcfg) = cfg.fallback { if let Some(v) = fbcfg.allow_dacl_mutation { policy.fallback.allow_dacl_mutation = v; @@ -1743,6 +1749,35 @@ mod tests { assert!(matches!(parsed, MxcRequest::OneShot(_))); } + #[test] + fn microvm_rejects_process_container_sections_but_accepts_a_minimal_request() { + const ERROR: &str = "Configuration parse error: Multiple containment backends configured: \ + 'containment' is 'microvm' (no per-backend section is defined for this backend), but \ + the config includes backend section(s): processContainer. Only one backend section is \ + allowed; remove the unused section(s)."; + + for process_container in [r#"{}"#, r#"{"leastPrivilege":false}"#] { + let json = format!( + r#"{{"version":"1.1.0-alpha","containment":"microvm","process":{{"commandLine":"echo hello"}},"processContainer":{process_container}}}"# + ); + let error = load_mxc_request_from_json(&json, &mut test_logger()).unwrap_err(); + assert!(matches!(error, ParseError::OneShot(_)), "{error:?}"); + assert_eq!(error.message(), ERROR); + } + + let json = r#"{ + "version": "1.1.0-alpha", + "containment": "microvm", + "process": {"commandLine": "echo hello"} + }"#; + let MxcRequest::OneShot(request) = + load_mxc_request_from_json(json, &mut test_logger()).unwrap() + else { + panic!("expected one-shot request"); + }; + assert_eq!(request.containment, ContainmentBackend::MicroVm); + } + fn parse_exact_for_test(json: &str) -> Result { parse_exact_mxc_request_json(json, &mut test_logger()) } @@ -1762,6 +1797,11 @@ mod tests { network_mode_specified: bool, runtime_network_proxy_specified: bool, ui_specified: bool, + least_privilege_specified: bool, + learning_mode_specified: bool, + capabilities_specified: bool, + base_process_ui_specified: bool, + fallback_specified: bool, requested_sandbox_kind: Option<&'static str>, } @@ -1782,6 +1822,11 @@ mod tests { network_mode_specified: request.policy.network_mode_specified, runtime_network_proxy_specified: request.policy.runtime_network_proxy_specified, ui_specified: request.policy.ui_specified, + least_privilege_specified: request.policy.least_privilege_specified, + learning_mode_specified: request.policy.learning_mode_specified, + capabilities_specified: request.policy.capabilities_specified, + base_process_ui_specified: request.policy.base_process_ui_specified, + fallback_specified: request.policy.fallback_specified, requested_sandbox_kind: request .telemetry .as_ref() diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index 4460de7ce8..cee78d3f8c 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -642,12 +642,20 @@ impl Default for FallbackPolicy { #[derive(Debug, Clone, Default, Serialize)] pub struct ContainerPolicy { pub least_privilege_mode: bool, + #[serde(skip)] + pub least_privilege_specified: bool, pub capabilities: Vec, + #[serde(skip)] + pub learning_mode_specified: bool, + #[serde(skip)] + pub capabilities_specified: bool, pub readwrite_paths: Vec, pub readonly_paths: Vec, pub enumerate_paths: Vec, pub denied_paths: Vec, pub fallback: FallbackPolicy, + #[serde(skip)] + pub fallback_specified: bool, /// Outbound CIDR, protocol, and port policy. pub network_egress: Option, /// Inbound and host-loopback policy. @@ -694,6 +702,8 @@ pub struct ContainerPolicy { pub ui_specified: bool, /// BaseProcessContainer-specific UI config (Windows only, from processContainer.ui). pub base_process_ui: BaseProcessUiConfig, + #[serde(skip)] + pub base_process_ui_specified: bool, /// Windows denial capture (from `processContainer.captureDenials`). When /// `Some`, the runner records the sandboxed process's ungranted access /// attempts to a learning-mode ETL trace. `None` disables capture. diff --git a/src/mxc-sdk/src/core/mxc_engine/mod.rs b/src/mxc-sdk/src/core/mxc_engine/mod.rs index aa1b4a62ec..a3afc14b7a 100644 --- a/src/mxc-sdk/src/core/mxc_engine/mod.rs +++ b/src/mxc-sdk/src/core/mxc_engine/mod.rs @@ -80,6 +80,28 @@ use crate::mxc_common::sandbox_process::{ use crate::mxc_common::state_aware_request::MxcRequest; use crate::mxc_common::telemetry; +#[cfg(all(target_os = "windows", target_arch = "x86_64", feature = "microvm"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CapturedOutcome { + Exited(i32), + TimedOut, +} + +#[cfg(all(target_os = "windows", target_arch = "x86_64", feature = "microvm"))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CapturedExecutionResult { + pub outcome: CapturedOutcome, + pub warnings: Vec, + pub stdout: Vec, + pub stderr: Vec, +} + +pub(crate) enum OneShotExecution { + #[cfg(all(target_os = "windows", target_arch = "x86_64", feature = "microvm"))] + Captured(CapturedExecutionResult), + Streaming(Box), +} + /// Spawn a streaming [`SandboxProcess`] handle for a normalized /// [`ExecutionRequest`]. /// @@ -123,6 +145,31 @@ pub fn spawn_one_shot_json( spawn_execution_request_with_logger(&request, logger) } +#[doc(hidden)] +pub(crate) fn execute_one_shot_json( + request_json: &str, + experimental: bool, +) -> Result { + let mut logger = Logger::new(Mode::Buffer); + let request = parse_one_shot_json(request_json, experimental, &mut logger)?; + + if request.containment == ContainmentBackend::MicroVm { + #[cfg(all(target_os = "windows", target_arch = "x86_64", feature = "microvm"))] + { + return run_captured_execution_request(&request, logger) + .map(OneShotExecution::Captured); + } + #[cfg(not(all(target_os = "windows", target_arch = "x86_64", feature = "microvm")))] + { + return Err(Error::from(MxcError::backend_unavailable( + "MicroVM requires Windows x64 and a build with the microvm feature", + ))); + } + } + + spawn_execution_request_with_logger(&request, logger).map(OneShotExecution::Streaming) +} + fn parse_one_shot_json( request_json: &str, experimental: bool, @@ -146,6 +193,92 @@ fn parse_one_shot_json( Ok(request) } +#[cfg(all(target_os = "windows", target_arch = "x86_64", feature = "microvm"))] +fn run_captured_execution_request( + request: &ExecutionRequest, + mut logger: Logger, +) -> Result { + crate::mxc_engine::experimental::require_experimental_optin( + &request.containment, + request.experimental_enabled, + )?; + + let telemetry_active = request + .telemetry + .as_ref() + .map(|config| telemetry::init(config, &mut logger)) + .unwrap_or(false); + let mut telemetry_registration = TelemetryRegistration::new(telemetry_active); + let requested_sandbox_kind = request + .telemetry + .as_ref() + .and_then(|config| config.requested_sandbox_kind); + crate::mxc_engine::run::log_policy_hash(request, &mut logger); + if let Err(response) = crate::mxc_common::validator::validate_common(request) { + let error = if request.script_code.is_empty() { + MxcError::malformed_request(response.error_message) + } else { + MxcError::policy_validation(response.error_message) + }; + telemetry::emit_sdk_early_exit_with_kind( + telemetry_registration.transfer(), + &request.containment, + requested_sandbox_kind, + telemetry::classify_mxc_error(&error), + ); + return Err(Error::from(error)); + } + + let started = std::time::Instant::now(); + let mut runner = crate::nvx_runner::NvxRunner::new(); + let result = match runner.run_captured(request, &mut logger) { + Ok(result) => result, + Err(error) => { + telemetry::emit_sdk_early_exit_with_kind( + telemetry_registration.transfer(), + &request.containment, + requested_sandbox_kind, + telemetry::classify_mxc_error(&error), + ); + return Err(Error::from(error)); + } + }; + + let response = ScriptResponse { + exit_code: match result.outcome { + crate::nvx_runner::CapturedOutcome::Exited(code) => code, + crate::nvx_runner::CapturedOutcome::TimedOut => -1, + }, + failure_phase: match result.outcome { + crate::nvx_runner::CapturedOutcome::Exited(_) => FailurePhase::None, + crate::nvx_runner::CapturedOutcome::TimedOut => FailurePhase::Timeout, + }, + ..Default::default() + }; + telemetry::emit_sdk_completion_with_kind( + telemetry_registration.transfer(), + &request.containment, + requested_sandbox_kind, + &response, + started.elapsed(), + ); + + let warnings = logger + .take_warnings() + .into_iter() + .chain(result.warnings) + .collect(); + Ok(CapturedExecutionResult { + outcome: match result.outcome { + crate::nvx_runner::CapturedOutcome::Exited(code) => CapturedOutcome::Exited(code), + crate::nvx_runner::CapturedOutcome::TimedOut => CapturedOutcome::TimedOut, + }, + warnings, + stdout: result.stdout, + stderr: result.stderr, + }) +} + fn spawn_execution_request_with_logger( request: &ExecutionRequest, logger: Logger, diff --git a/src/mxc-sdk/src/core/mxc_engine/run.rs b/src/mxc-sdk/src/core/mxc_engine/run.rs index f10e511284..9f275b5be3 100644 --- a/src/mxc-sdk/src/core/mxc_engine/run.rs +++ b/src/mxc-sdk/src/core/mxc_engine/run.rs @@ -288,16 +288,16 @@ fn resolve_runner_inner_windows( "VM backend not yet implemented", )), ContainmentBackend::MicroVm => { - #[cfg(feature = "microvm")] + #[cfg(all(feature = "microvm", target_arch = "x86_64"))] { Ok(ResolvedRunner::without_guard(Box::new( - crate::nanvix_runner::NanVixScriptRunner::new(), + crate::nvx_runner::NvxRunner::new(), ))) } - #[cfg(not(feature = "microvm"))] + #[cfg(not(all(feature = "microvm", target_arch = "x86_64")))] { - Err(MxcError::unsupported_containment( - "MicroVM backend not compiled in (build with --features microvm)", + Err(MxcError::backend_unavailable( + "MicroVM requires Windows x64 and a build with --features microvm", )) } } @@ -351,20 +351,9 @@ fn resolve_runner_inner( match request.containment { ContainmentBackend::Hyperlight => resolve_hyperlight(), - ContainmentBackend::MicroVm => { - #[cfg(feature = "microvm")] - { - Ok(ResolvedRunner::without_guard(Box::new( - crate::nanvix_runner::NanVixScriptRunner::new(), - ))) - } - #[cfg(not(feature = "microvm"))] - { - Err(MxcError::unsupported_containment( - "MicroVM backend not compiled in (build with --features microvm)", - )) - } - } + ContainmentBackend::MicroVm => Err(MxcError::unsupported_containment( + "the NVX MicroVM backend is available only on Windows x64", + )), ContainmentBackend::Bubblewrap => Ok(ResolvedRunner::without_guard(Box::new(Runner::new( crate::bwrap_common::bwrap_runner::BubblewrapScriptRunner::new(), )))), diff --git a/src/mxc-sdk/src/lib.rs b/src/mxc-sdk/src/lib.rs index cef215ddf7..3df844f13d 100644 --- a/src/mxc-sdk/src/lib.rs +++ b/src/mxc-sdk/src/lib.rs @@ -158,14 +158,8 @@ pub use backend_modules::isolation_session::common as isolation_session_common; #[doc(hidden)] pub use backend_modules::lxc::common as lxc_common; #[doc(hidden)] -#[cfg(all(feature = "microvm", any(target_os = "windows", target_os = "linux")))] -pub use backend_modules::nanvix::binaries as nanvix_binaries; -#[doc(hidden)] -#[cfg(all(feature = "microvm", any(target_os = "windows", target_os = "linux")))] -pub use backend_modules::nanvix::common as nanvix_common; -#[doc(hidden)] -#[cfg(all(feature = "microvm", any(target_os = "windows", target_os = "linux")))] -pub use backend_modules::nanvix::runner as nanvix_runner; +#[cfg(all(target_os = "windows", target_arch = "x86_64", feature = "microvm"))] +pub use backend_modules::nvx::runner as nvx_runner; #[doc(hidden)] #[cfg(target_os = "windows")] pub use backend_modules::process_container::common as process_container_common; @@ -412,7 +406,15 @@ pub mod __ffi { /// Run a raw exact-version JSON container request to completion and capture /// its output. The JSON and `experimental` rules match [`spawn_container_json`]. pub fn run_json(request_json: &str, experimental: bool) -> Result { - wait_with_output(spawn_container_json(request_json, experimental)?) + match crate::mxc_engine::execute_one_shot_json(request_json, experimental)? { + #[cfg(all(target_os = "windows", target_arch = "x86_64", feature = "microvm"))] + crate::mxc_engine::OneShotExecution::Captured(result) => { + Ok(captured_execution_result(result)) + } + crate::mxc_engine::OneShotExecution::Streaming(process) => { + wait_with_output(MxcProcess::new(process)) + } + } } /// Run a lifecycle request (as a JSON string) and return the @@ -498,6 +500,22 @@ pub mod __ffi { } } +#[cfg(all(target_os = "windows", target_arch = "x86_64", feature = "microvm"))] +fn captured_execution_result( + result: crate::mxc_engine::CapturedExecutionResult, +) -> ExecutionResult { + ExecutionResult { + outcome: match result.outcome { + crate::mxc_engine::CapturedOutcome::Exited(code) => sandbox::WaitResult::Exited(code), + crate::mxc_engine::CapturedOutcome::TimedOut => sandbox::WaitResult::TimedOut, + }, + warnings: result.warnings, + stdout: result.stdout, + stderr: result.stderr, + output_metadata: None, + } +} + fn wait_with_output(sandbox: MxcProcess) -> Result { sandbox.wait_with_output().map_err(|e| { Error::new( diff --git a/src/mxc-sdk/tests/nvx_bundle_integrity.rs b/src/mxc-sdk/tests/nvx_bundle_integrity.rs new file mode 100644 index 0000000000..0abda76e5d --- /dev/null +++ b/src/mxc-sdk/tests/nvx_bundle_integrity.rs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[allow(dead_code)] +#[path = "../build/build_nvx_bundled.rs"] +mod build_nvx_bundled; + +use sha2::{Digest, Sha256}; + +fn digest(contents: &[u8]) -> String { + Sha256::digest(contents) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn fixture() -> (tempfile::TempDir, Vec<(&'static str, String)>) { + let directory = tempfile::tempdir().expect("bundle fixture"); + let files = [ + ("SHA256SUMS", b"checksums".as_slice()), + ("SOURCE-MANIFEST.json", b"source manifest".as_slice()), + ("bin/direct-images.exe", b"image tool".as_slice()), + ("guest/initramfs-edge.cpio.gz", b"guest".as_slice()), + ]; + for (relative, contents) in files { + let path = directory.path().join(relative); + std::fs::create_dir_all(path.parent().expect("fixture parent")) + .expect("create fixture parent"); + std::fs::write(path, contents).expect("write fixture"); + } + let expected = files + .into_iter() + .map(|(path, contents)| (path, digest(contents))) + .collect(); + (directory, expected) +} + +fn verify(directory: &std::path::Path, expected: &[(&str, String)]) { + let expected: Vec<(&str, &str)> = expected + .iter() + .map(|(path, digest)| (*path, digest.as_str())) + .collect(); + build_nvx_bundled::verify_bundle_against(directory, &expected); +} + +#[test] +fn accepts_complete_pinned_bundle() { + let (directory, expected) = fixture(); + verify(directory.path(), &expected); +} + +#[test] +fn rejects_modified_guest_helper_and_checksum_metadata() { + for relative in [ + "guest/initramfs-edge.cpio.gz", + "bin/direct-images.exe", + "SHA256SUMS", + "SOURCE-MANIFEST.json", + ] { + let (directory, expected) = fixture(); + std::fs::write(directory.path().join(relative), b"modified").expect("tamper fixture"); + let result = std::panic::catch_unwind(|| verify(directory.path(), &expected)); + assert!(result.is_err(), "modified {relative} must be rejected"); + } +} + +#[test] +fn rejects_missing_or_unlisted_bundle_files() { + let (directory, expected) = fixture(); + std::fs::remove_file(directory.path().join("guest/initramfs-edge.cpio.gz")) + .expect("remove fixture"); + assert!( + std::panic::catch_unwind(|| verify(directory.path(), &expected)).is_err(), + "missing official files must be rejected" + ); + + let (directory, expected) = fixture(); + std::fs::write(directory.path().join("mutable-release.txt"), b"unexpected") + .expect("write unexpected fixture"); + assert!( + std::panic::catch_unwind(|| verify(directory.path(), &expected)).is_err(), + "unlisted files must be rejected" + ); +} diff --git a/src/mxc-sdk/tests/nvx_e2e.rs b/src/mxc-sdk/tests/nvx_e2e.rs new file mode 100644 index 0000000000..65aa3acbc7 --- /dev/null +++ b/src/mxc-sdk/tests/nvx_e2e.rs @@ -0,0 +1,378 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use mxc_sdk::v1::ErrorCode; + +fn process_only_request(command: &str) -> serde_json::Value { + serde_json::json!({ + "version": "1.1.0-alpha", + "containment": "microvm", + "process": { "commandLine": command, "timeout": 30_000 } + }) +} + +#[cfg(not(all(windows, target_arch = "x86_64", feature = "microvm")))] +#[test] +fn unsupported_platform_or_feature_is_fail_closed() { + let error = + mxc_sdk::__ffi::run_json(&process_only_request("printf forbidden").to_string(), true) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::BackendUnavailable); + assert!(error.message.contains("Windows x64")); +} + +#[cfg(all(windows, target_arch = "x86_64", feature = "microvm"))] +mod windows_x64 { + use std::path::PathBuf; + use std::process::{Command, Output, Stdio}; + use std::time::{SystemTime, UNIX_EPOCH}; + + use mxc_sdk::v1::{ExecutionResult, WaitResult}; + use serde_json::{json, Value}; + + use super::*; + + const BUNDLE_ENV: &str = "ACI_EDGE_SANDBOXES_AGENT_ARTIFACTS_DIR"; + const MIXED_BACKEND_ERROR: &str = "Configuration parse error: Multiple containment backends \ + configured: 'containment' is 'microvm' (no per-backend section is defined for this \ + backend), but the config includes backend section(s): processContainer. Only one backend \ + section is allowed; remove the unused section(s)."; + + #[test] + fn experimental_opt_in_is_required_before_backend_discovery() { + let error = + mxc_sdk::__ffi::run_json(&process_only_request("printf forbidden").to_string(), false) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::BackendUnavailable); + assert!(error.message.contains("experimental")); + } + + fn rejected_requests() -> Vec<(&'static str, Value)> { + vec![ + ( + "readonly", + json!({"filesystem": {"readonlyPaths": ["C:\\work"]}}), + ), + ( + "readwrite", + json!({"filesystem": {"readwritePaths": ["C:\\work"]}}), + ), + ( + "denied", + json!({"filesystem": {"deniedPaths": ["C:\\work"]}}), + ), + ("network-empty", json!({"network": {}})), + ( + "network-deny", + json!({"network": {"egress": {"default": "deny"}}}), + ), + ( + "network-allow", + json!({"network": {"egress": {"default": "allow"}}}), + ), + ( + "proxy", + json!({"runtimeConfig": {"networkProxy": "http://127.0.0.1:8080"}}), + ), + ("ui", json!({"ui": {"disable": true}})), + ( + "capture", + json!({"processContainer": {"captureDenials": {}}}), + ), + ( + "least-privilege", + json!({"processContainer": {"leastPrivilege": false}}), + ), + ( + "learning-mode", + json!({"processContainer": {"learningMode": false}}), + ), + ( + "capabilities", + json!({"processContainer": {"capabilities": []}}), + ), + ("process-ui", json!({"processContainer": {"ui": {}}})), + ( + "enumerate", + json!({"processContainer": {"filesystem": {"enumeratePaths": ["C:\\work"]}}}), + ), + ("fallback", json!({"fallback": {}})), + ("destroy", json!({"lifecycle": {"destroyOnExit": false}})), + ("preserve", json!({"lifecycle": {"preservePolicy": true}})), + ("timeout-limit", json!({"process": {"timeout": 3_600_001}})), + ("parent-cwd", json!({"process": {"cwd": "/tmp/../escape"}})), + ("relative-cwd", json!({"process": {"cwd": "relative"}})), + ( + "replacement-env", + json!({"process": {"env": ["KEY=value"]}}), + ), + ( + "proxy-env", + json!({"process": {"env": ["hTtP_pRoXy=http://proxy"], "inheritDefaultEnv": true}}), + ), + ( + "large-env", + json!({"process": {"env": [format!("KEY={}", "x".repeat(32 * 1024))], "inheritDefaultEnv": true}}), + ), + ( + "malformed-env", + json!({"process": {"env": ["MISSING_EQUALS"], "inheritDefaultEnv": true}}), + ), + ( + "null-command", + json!({"process": {"commandLine": "printf forbidden\0"}}), + ), + ] + } + + fn request_with_patch(patch: Value) -> Value { + let mut request = process_only_request("printf forbidden"); + for (key, value) in patch.as_object().expect("patch object") { + if key == "process" { + for (field, value) in value.as_object().expect("process patch object") { + request["process"][field] = value.clone(); + } + } else { + request[key] = value.clone(); + } + } + request + } + + #[test] + fn host_only_request_matrix_never_reaches_a_runtime() { + // Only this isolated child sees the deliberately absent bundle. Even a policy regression + // cannot start a VM: native discovery fails before the agent DLL can be opened. + if std::env::var_os("MXC_NVX_MATRIX_CHILD").is_none() { + let directory = tempfile::tempdir().expect("matrix temporary directory"); + let output = Command::new(std::env::current_exe().expect("current test executable")) + .args([ + "--exact", + "windows_x64::host_only_request_matrix_never_reaches_a_runtime", + "--nocapture", + ]) + .env("MXC_NVX_MATRIX_CHILD", "1") + .env(BUNDLE_ENV, directory.path().join("absent")) + .output() + .expect("host-only matrix child"); + assert!( + output.status.success(), + "matrix child failed\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + println!("{}", String::from_utf8_lossy(&output.stdout)); + return; + } + + for (label, patch) in rejected_requests() { + let request = request_with_patch(patch); + let error = mxc_sdk::__ffi::run_json(&request.to_string(), true).unwrap_err(); + assert!( + matches!( + error.code, + ErrorCode::PolicyValidation | ErrorCode::MalformedRequest + ), + "{label} must be rejected before bundle discovery: {error:?}" + ); + if matches!( + label, + "capture" + | "least-privilege" + | "learning-mode" + | "capabilities" + | "process-ui" + | "enumerate" + ) { + assert_eq!( + error.code, + ErrorCode::MalformedRequest, + "{label} must use the parser error contract" + ); + assert_eq!(error.message, MIXED_BACKEND_ERROR, "{label}"); + } else if label == "fallback" { + assert_eq!(error.code, ErrorCode::PolicyValidation); + } + println!( + "HOST_ONLY rejected {label}: {:?}: {}", + error.code, error.message + ); + } + + let request = process_only_request("printf forbidden").to_string(); + let error = mxc_sdk::__ffi::run_json(&request, true).unwrap_err(); + assert_eq!(error.code, ErrorCode::BackendUnavailable); + assert!(error.message.contains("NVX")); + println!( + "HOST_ONLY missing official bundle fails closed: {}", + error.message + ); + assert_no_run_state(std::process::id()); + + let streaming = mxc_sdk::__ffi::spawn_container_json(&request, true) + .err() + .expect("P1 must reject streaming"); + assert_eq!(streaming.code, ErrorCode::UnsupportedContainment); + let pty = + mxc_sdk::__ffi::spawn_with_pty_json(&request, true, Default::default()).unwrap_err(); + assert_eq!(pty.code, ErrorCode::UnsupportedContainment); + let lifecycle = json!({ + "version": "1.1.0-alpha", "phase": "provision", "containment": "microvm" + }); + assert!(mxc_sdk::__ffi::run_lifecycle_json(&lifecycle.to_string(), false, true).is_err()); + } + + fn executor() -> PathBuf { + let current = std::env::current_exe().expect("current test executable"); + let directory = current.parent().expect("test executable directory"); + let beside = directory.join("wxc-exec.exe"); + let path = if beside.is_file() { + beside + } else { + directory + .parent() + .expect("Cargo profile directory") + .join("wxc-exec.exe") + }; + assert!( + path.is_file(), + "build wxc-exec with microvm first: {}", + path.display() + ); + path + } + + fn executor_output(request: &Value, missing_bundle: bool) -> Output { + let directory = tempfile::tempdir().expect("executor request directory"); + let path = directory.path().join("request.json"); + std::fs::write(&path, request.to_string()).expect("write executor request"); + let mut command = Command::new(executor()); + command.arg("--experimental").arg(&path); + if missing_bundle { + command.env(BUNDLE_ENV, directory.path().join("absent")); + } + let child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("MXC executor process"); + let pid = child.id(); + let output = child.wait_with_output().expect("MXC executor output"); + assert_no_run_state(pid); + output + } + + #[test] + #[ignore = "host-only; requires separately built wxc-exec.exe; never creates a VM"] + fn host_only_executor_request_matrix() { + for (label, patch) in rejected_requests() { + let output = executor_output(&request_with_patch(patch), true); + assert!(!output.status.success(), "executor accepted {label}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("policy_validation") + || stderr.contains("malformed_request") + || stderr.contains("Configuration parse error:"), + "{label} was not rejected before bundle discovery: {stderr}" + ); + assert!(!String::from_utf8_lossy(&output.stdout).contains("forbidden")); + println!("HOST_ONLY executor rejected {label}: {stderr}"); + } + } + + fn nonce() -> String { + format!( + "mxc-nvx-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_nanos() + ) + } + + fn assert_no_run_state(pid: u32) { + let prefix = format!("mxc-nvx-{pid}-"); + let retained: Vec<_> = std::fs::read_dir(std::env::temp_dir()) + .expect("temporary directory listing") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with(&prefix)) + .map(|entry| entry.path()) + .collect(); + assert!(retained.is_empty(), "NVX run state leaked: {retained:?}"); + } + + fn facade_run(request: Value) -> ExecutionResult { + let result = mxc_sdk::__ffi::run_json(&request.to_string(), true) + .expect("real MXC signed bundled-agent create-and-run"); + assert_no_run_state(std::process::id()); + result + } + + #[test] + #[ignore = "VM: requires Windows x64, WHP and the exact signed official bundle"] + fn signed_bundle_facade_create_and_run() { + let marker = nonce(); + for _ in 0..2 { + let command = format!( + "test ! -e /tmp/{marker} || exit 99; touch /tmp/{marker}; \ + printf '{marker}'; printf 'separate-stderr' >&2; exit 42" + ); + let result = facade_run(process_only_request(&command)); + assert_eq!(result.outcome, WaitResult::Exited(42)); + assert_eq!(result.stdout, marker.as_bytes()); + assert_eq!(result.stderr, b"separate-stderr"); + } + + let mut environment = process_only_request("printf '%s:%s' \"$MXC_NVX_PROBE\" \"$PWD\""); + environment["process"]["cwd"] = json!("/tmp"); + environment["process"]["env"] = json!([format!("MXC_NVX_PROBE={marker}")]); + environment["process"]["inheritDefaultEnv"] = json!(true); + let result = facade_run(environment); + assert_eq!(result.outcome, WaitResult::Exited(0)); + assert_eq!(result.stdout, format!("{marker}:/tmp").as_bytes()); + + let mut timeout = + process_only_request("printf partial-out; printf partial-err >&2; sleep 5"); + timeout["process"]["timeout"] = json!(200); + let result = facade_run(timeout); + assert_eq!(result.outcome, WaitResult::TimedOut); + assert_eq!(result.stdout, b"partial-out"); + assert_eq!(result.stderr, b"partial-err"); + + let result = facade_run(process_only_request("printf after-timeout")); + assert_eq!(result.outcome, WaitResult::Exited(0)); + assert_eq!(result.stdout, b"after-timeout"); + println!("VM_EXECUTED facade sequential stdout/stderr/exit/env/cwd/timeout/cleanup"); + } + + #[test] + #[ignore = "VM: requires separately built wxc-exec.exe, WHP and exact signed official bundle"] + fn signed_bundle_executor_create_and_run() { + let marker = nonce(); + for _ in 0..2 { + let output = executor_output( + &process_only_request(&format!( + "printf '{marker}'; printf executor-stderr >&2; exit 42" + )), + false, + ); + assert_eq!(output.status.code(), Some(42)); + assert_eq!(output.stdout, marker.as_bytes()); + assert_eq!(output.stderr, b"executor-stderr"); + } + let mut timeout = + process_only_request("printf partial-out; printf partial-err >&2; sleep 5"); + timeout["process"]["timeout"] = json!(200); + let output = executor_output(&timeout, false); + assert_eq!(output.status.code(), Some(-1)); + assert_eq!(output.stdout, b"partial-out"); + assert!(String::from_utf8_lossy(&output.stderr).contains("partial-err")); + assert!(String::from_utf8_lossy(&output.stderr).contains("timeout")); + let output = executor_output(&process_only_request("printf after-timeout"), false); + assert_eq!(output.status.code(), Some(0)); + assert_eq!(output.stdout, b"after-timeout"); + println!("VM_EXECUTED executor sequential stdout/stderr/exit/timeout/cleanup"); + } +} diff --git a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_linux_microvm.rs b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_linux_microvm.rs index a4fea86f8b..f5c9c8a34d 100644 --- a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_linux_microvm.rs +++ b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_linux_microvm.rs @@ -1,8 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#![cfg(any())] + //! Linux MicroVM (NanVix/KVM) E2E integration tests. //! +//! Retained as inactive historical coverage while active `microvm` dispatch is +//! NVX on Windows x64 only. +//! //! These tests mirror the Windows MicroVM E2E suite in `e2e_windows.rs` and //! invoke `lxc-exec` directly with the `microvm` containment backend. //! Tests skip gracefully when prerequisites (binaries, KVM) are missing. diff --git a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_windows.rs b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_windows.rs index b02f9297ad..97ff9f9731 100644 --- a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_windows.rs +++ b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_windows.rs @@ -12,18 +12,20 @@ use std::time::{SystemTime, UNIX_EPOCH}; #[path = "support/wxc_e2e_tests.rs"] mod wxc_e2e_tests; +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] use serde::Serialize; +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] +use wxc_e2e_tests::repo_root; use wxc_e2e_tests::{ assert_exit, assert_pwsh, assert_python, assert_success, assert_success_or_skip_missing_prerequisite, examples_dir, has_hyperlight_runtime, - has_hyperlight_snapshot, has_nanvix_binaries, has_test_driver, has_windows_sandbox_feature, - has_wxc_exe, repo_root, run_test_driver, run_wxc_config, run_wxc_config_value, run_wxc_example, - test_configs_dir, TempDirs, + has_hyperlight_snapshot, has_test_driver, has_windows_sandbox_feature, has_wxc_exe, + run_test_driver, run_wxc_config, run_wxc_config_value, run_wxc_example, test_configs_dir, + TempDirs, }; static HAS_WXC_EXE: OnceLock = OnceLock::new(); static HAS_TEST_DRIVER: OnceLock = OnceLock::new(); -static HAS_NANVIX_BINARIES: OnceLock = OnceLock::new(); static HAS_WINDOWS_SANDBOX: OnceLock = OnceLock::new(); static HAS_HYPERLIGHT: OnceLock = OnceLock::new(); static TEST_LOCK: OnceLock> = OnceLock::new(); @@ -39,11 +41,6 @@ fn cached_has_test_driver() -> bool { *HAS_TEST_DRIVER.get_or_init(has_test_driver) } -/// Caches the NanVix binary probe to avoid repeated prerequisite work. -fn cached_has_nanvix_binaries() -> bool { - *HAS_NANVIX_BINARIES.get_or_init(has_nanvix_binaries) -} - /// Caches the Windows Sandbox feature probe. fn cached_has_windows_sandbox_feature() -> bool { *HAS_WINDOWS_SANDBOX.get_or_init(has_windows_sandbox_feature) @@ -124,99 +121,11 @@ fn examples() { assert_success(&result); } +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] fn microvm_basic() { assert_wxc_success("microvm_hello.json", &["--debug", "--experimental"]); } -fn microvm_network() { - // Drives the `-allow-host-networking` path: the guest opens a loopback TCP - // socket, completes a ping/pong round-trip, and prints NET_OK. Without host - // networking enabled the guest's socket() call fails (errno 134) and the - // process exits non-zero, so a clean exit + marker proves the flag wiring. - let result = run_wxc_config("microvm_network.json", &["--debug", "--experimental"]); - assert_eq!( - result.code, - Some(0), - "expected exit 0, got {:?}\nstdout: {}\nstderr: {}", - result.code, - result.stdout, - result.stderr - ); - let combined = result.combined_output_with_decoded_base64(); - assert!( - combined.contains("NET_OK"), - "guest network round-trip marker missing\ncombined: {}", - combined - ); -} - -/// Full network isolation is supported; directional filtering is separately -/// rejected because the legacy guest filter does not implement that contract. -fn microvm_network_blocked() { - let source = "import _socket\n\ - try:\n\ - \x20 s = _socket.socket(2, 1, 0)\n\ - \x20 print('UNEXPECTED_NETWORK', flush=True)\n\ - except OSError as e:\n\ - \x20 print('RESULT ERRNO %d' % (e.errno,), flush=True)\n"; - let blocked = serde_json::json!({ - "version": "1.1.0-alpha", - "process": { "commandLine": source, "timeout": 30000 }, - "containment": "microvm", - "network": { - "egress": { "default": "deny" }, - "ingress": { "default": "deny", "hostLoopback": "deny" } - } - }); - let blocked_result = run_wxc_config_value( - "microvm_network_blocked", - &blocked, - &["--debug", "--experimental"], - ); - let blocked_out = blocked_result.combined_output_with_decoded_base64(); - assert_eq!( - blocked_result.code, - Some(0), - "isolated run should exit cleanly (the guest catches the error)\ncombined: {}", - blocked_out - ); - assert!( - blocked_out.contains("RESULT ERRNO 134"), - "isolated guest sockets should remain unavailable (errno 134)\ncombined: {}", - blocked_out - ); - - let filtered = serde_json::json!({ - "version": "1.1.0-alpha", - "process": { "commandLine": "print('UNEXPECTED_EXECUTION')", "timeout": 30000 }, - "containment": "microvm", - "network": { - "egress": { - "default": "allow", - "deny": [{ "to": [{ "cidr": "203.0.113.0/24" }] }] - }, - "ingress": { "default": "allow", "hostLoopback": "allow" } - } - }); - let filtered_result = run_wxc_config_value( - "microvm_directional_filter_rejected", - &filtered, - &["--debug", "--experimental"], - ); - let filtered_out = filtered_result.combined_output_with_decoded_base64(); - assert_ne!( - filtered_result.code, - Some(0), - "unsupported directional filtering must fail before execution\ncombined: {}", - filtered_out - ); - assert!( - filtered_out.contains("NanVix cannot enforce directional egress rules"), - "expected the backend's explicit unsupported-filtering error\ncombined: {}", - filtered_out - ); -} - /// Drives `processContainer.captureDenials` end to end and asserts the /// output-file contract: after the child exits, MXC writes a single JSON /// denials document (`{ "denials": [...], "summary": {...} }`) to the @@ -600,38 +509,14 @@ fn test_examples() { } #[test] +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] fn test_microvm_basic() { if !cached_has_wxc_exe() { return; } - if !cached_has_nanvix_binaries() { - return; - } with_test_lock(microvm_basic); } -#[test] -fn test_microvm_network() { - if !cached_has_wxc_exe() { - return; - } - if !cached_has_nanvix_binaries() { - return; - } - with_test_lock(microvm_network); -} - -#[test] -fn test_microvm_network_blocked() { - if !cached_has_wxc_exe() { - return; - } - if !cached_has_nanvix_binaries() { - return; - } - with_test_lock(microvm_network_blocked); -} - #[test] fn test_windows_sandbox() { if !cached_has_wxc_exe() { @@ -645,13 +530,11 @@ fn test_windows_sandbox() { } #[test] +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] fn test_microvm_suite() { if !cached_has_wxc_exe() { return; } - if !cached_has_nanvix_binaries() { - return; - } with_test_lock(microvm_suite); } @@ -806,6 +689,7 @@ fn run_sandbox_case(case: &SandboxCase) { // --------------------------------------------------------------------------- #[derive(Debug)] +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] struct MicrovmCase { config: &'static str, expected_exit: Option, @@ -814,6 +698,7 @@ struct MicrovmCase { expect_non_zero: bool, } +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] #[derive(Debug, Serialize)] struct MicrovmPerfOutput { commit: String, @@ -821,6 +706,7 @@ struct MicrovmPerfOutput { results: Vec, } +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] #[derive(Debug, Serialize)] struct MicrovmPerfEntry { test: String, @@ -830,6 +716,7 @@ struct MicrovmPerfEntry { status: String, } +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] fn microvm_suite() { let cases = [ MicrovmCase { @@ -856,8 +743,8 @@ fn microvm_suite() { MicrovmCase { config: "microvm_stdlib.json", expected_exit: Some(0), - description: "Stdlib (json, math, hashlib)", - output_contains: Some("pi"), + description: "POSIX guest shell and OS", + output_contains: Some("shell=posix"), expect_non_zero: false, }, MicrovmCase { @@ -870,8 +757,8 @@ fn microvm_suite() { MicrovmCase { config: "microvm_error.json", expected_exit: Some(1), - description: "Python exception", - output_contains: Some("ValueError"), + description: "Shell error", + output_contains: Some("intentional test error"), expect_non_zero: false, }, MicrovmCase { @@ -930,6 +817,7 @@ fn microvm_suite() { } } +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] fn command_matches(result: &wxc_e2e_tests::CommandResult, case: &MicrovmCase) -> bool { if case.expect_non_zero { if result.code == Some(0) { @@ -948,6 +836,7 @@ fn command_matches(result: &wxc_e2e_tests::CommandResult, case: &MicrovmCase) -> .contains(expected) } +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] fn expected_exit_description(case: &MicrovmCase) -> String { if case.expect_non_zero { "non-zero exit".to_string() @@ -1210,6 +1099,7 @@ fn test_hyperlight_suite() { // MicroVM perf results // --------------------------------------------------------------------------- +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] fn write_microvm_perf_results(results: Vec) { let output = MicrovmPerfOutput { commit: std::env::var("GITHUB_SHA").unwrap_or_else(|_| "local".to_string()), diff --git a/src/testing/fuzz/fuzz_targets/validator.rs b/src/testing/fuzz/fuzz_targets/validator.rs index cf2cb5ea1e..8aa4a98eb1 100644 --- a/src/testing/fuzz/fuzz_targets/validator.rs +++ b/src/testing/fuzz/fuzz_targets/validator.rs @@ -7,7 +7,7 @@ // path that `--dry-run` takes through the binary. // // Runner-specific validation coverage: -// - NanVix (MicroVm): always (no extra features) +// - NVX (MicroVm): requires `--features microvm` on Windows x64 // - Hyperlight: requires `--features hyperlight` // - IsolationSession: requires `--features isolation_session` // - Seatbelt: macOS-only, not available in Windows fuzz builds @@ -34,9 +34,9 @@ fuzz_target!(|data: &[u8]| { // Dispatch to runner-specific validation based on backend. #[cfg(target_os = "windows")] match req.containment { - #[cfg(feature = "microvm")] + #[cfg(all(feature = "microvm", target_arch = "x86_64"))] ContainmentBackend::MicroVm => { - let runner = mxc_sdk::nanvix_runner::NanVixScriptRunner::new(); + let runner = mxc_sdk::nvx_runner::NvxRunner::new(); let _ = runner.validate_runner(&req); } #[cfg(feature = "hyperlight")] diff --git a/tests/configs/microvm_error.json b/tests/configs/microvm_error.json index 8bf4829308..f7d9698b16 100644 --- a/tests/configs/microvm_error.json +++ b/tests/configs/microvm_error.json @@ -2,7 +2,7 @@ "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", "version": "1.1.0-alpha", "process": { - "commandLine": "raise ValueError('intentional test error')", + "commandLine": "printf 'intentional test error\\n' >&2; exit 1", "timeout": 30000 }, "containment": "microvm" diff --git a/tests/configs/microvm_exit_code.json b/tests/configs/microvm_exit_code.json index de445300d5..5e30bf857d 100644 --- a/tests/configs/microvm_exit_code.json +++ b/tests/configs/microvm_exit_code.json @@ -2,7 +2,7 @@ "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", "version": "1.1.0-alpha", "process": { - "commandLine": "import sys; sys.exit(42)", + "commandLine": "exit 42", "timeout": 30000 }, "containment": "microvm" diff --git a/tests/configs/microvm_hello.json b/tests/configs/microvm_hello.json index 385718e8e7..6798d62bad 100644 --- a/tests/configs/microvm_hello.json +++ b/tests/configs/microvm_hello.json @@ -2,7 +2,7 @@ "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", "version": "1.1.0-alpha", "process": { - "commandLine": "x = 42\ny = 58\nprint('Hello from MicroVM! sum=%d' % (x + y))", + "commandLine": "printf 'Hello from MicroVM! sum=100\\n'", "timeout": 30000 }, "containment": "microvm" diff --git a/tests/configs/microvm_large_output.json b/tests/configs/microvm_large_output.json index a9048f3002..7c535edd88 100644 --- a/tests/configs/microvm_large_output.json +++ b/tests/configs/microvm_large_output.json @@ -2,7 +2,7 @@ "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", "version": "1.1.0-alpha", "process": { - "commandLine": "for i in range(1000):\n print(f'line {i}: ' + 'x' * 80)", + "commandLine": "i=0; while [ \"$i\" -lt 1000 ]; do printf 'line %d: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$i\"; i=$((i + 1)); done", "timeout": 30000 }, "containment": "microvm" diff --git a/tests/configs/microvm_multiline.json b/tests/configs/microvm_multiline.json index 5340585b87..cd78373c21 100644 --- a/tests/configs/microvm_multiline.json +++ b/tests/configs/microvm_multiline.json @@ -2,7 +2,7 @@ "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", "version": "1.1.0-alpha", "process": { - "commandLine": "def fib(n):\n a, b = 0, 1\n for _ in range(n):\n a, b = b, a + b\n return a\n\nfor i in range(10):\n print(f'fib({i}) = {fib(i)}')", + "commandLine": "a=0; b=1; i=0; while [ \"$i\" -lt 10 ]; do printf 'fib(%d) = %d\\n' \"$i\" \"$a\"; next=$((a + b)); a=$b; b=$next; i=$((i + 1)); done", "timeout": 30000 }, "containment": "microvm" diff --git a/tests/configs/microvm_stdlib.json b/tests/configs/microvm_stdlib.json index 0d8e6b93b3..0a66b6bef8 100644 --- a/tests/configs/microvm_stdlib.json +++ b/tests/configs/microvm_stdlib.json @@ -2,7 +2,7 @@ "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", "version": "1.1.0-alpha", "process": { - "commandLine": "import json, math, hashlib\ndata = {'pi': math.pi, 'e': math.e, 'hash': hashlib.sha256(b'nanvix').hexdigest()[:16]}\nprint(json.dumps(data))", + "commandLine": "printf 'shell=posix\\n'; uname -s", "timeout": 30000 }, "containment": "microvm" diff --git a/tests/configs/microvm_timeout.json b/tests/configs/microvm_timeout.json index 7217c9cdcb..02f72c06a2 100644 --- a/tests/configs/microvm_timeout.json +++ b/tests/configs/microvm_timeout.json @@ -1,9 +1,9 @@ { "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", "version": "1.1.0-alpha", - "_comment": "Timeout test. Note: MicroVM adds a 60s boot grace to the 5s script timeout, so actual wall time is ~65s.", + "_comment": "Timeout test for the signed NVX agent workload guest.", "process": { - "commandLine": "import time; time.sleep(120); print('should not reach here')", + "commandLine": "sleep 120; printf 'should not reach here\\n'", "timeout": 5000 }, "containment": "microvm" diff --git a/tests/scripts/README.md b/tests/scripts/README.md index d0c81e4007..ed2eb612f3 100644 --- a/tests/scripts/README.md +++ b/tests/scripts/README.md @@ -39,8 +39,8 @@ Linux / macOS (`.sh`): | `run_filesystem_bfs_spaces_test.ps1` | BFS path-with-spaces test | `wxc-exec.exe` | | `run_test_configs.ps1` | All test configs via wxc-test-driver | `wxc-test-driver.exe` | | `run_examples.ps1` | All examples via wxc-test-driver | `wxc-test-driver.exe` | -| `run_microvm_basic_test.ps1` | MicroVM smoke test | `wxc-exec.exe`, NanVix binaries | -| `run_microvm_tests.ps1` | Full MicroVM E2E suite | WHP enabled, NanVix binaries | +| `run_microvm_basic_test.ps1` | Legacy Nanvix helper (inactive; do not use for NVX validation) | Historical only | +| `run_microvm_tests.ps1` | Windows signed NVX command, output, exit, and timeout E2E suite | WHP enabled, build with `microvm`, complete signed bundle | | `run_windows_sandbox_one_shot_tests.ps1` | Windows Sandbox one-shot E2E suite (fresh disposable VM per test) | Windows Sandbox enabled | | `run_windows_sandbox_state_aware_tests.ps1` | Windows Sandbox state-aware lifecycle E2E (single VM held across provision/start/exec*/stop/deprovision) | Windows Sandbox enabled | | `run_isolation_session_tests.ps1` | IsolationSession one-shot E2E suite | Interactive local session; OS-side IsolationSession service | diff --git a/tests/scripts/run_microvm_tests.ps1 b/tests/scripts/run_microvm_tests.ps1 index dcf6002b56..c9adb80296 100644 --- a/tests/scripts/run_microvm_tests.ps1 +++ b/tests/scripts/run_microvm_tests.ps1 @@ -3,11 +3,11 @@ <# .SYNOPSIS - Runs MicroVM E2E tests. Requires WHP and Nanvix binaries next to wxc-exec.exe. + Runs Windows signed bundled NVX MicroVM E2E tests. Requires WHP. .DESCRIPTION - Locates wxc-exec.exe (built with --features microvm) - - Verifies Nanvix binaries are present + - Uses the signed agent release bundled by the Cargo dependency - Runs each test config via wxc-exec, validates exit codes and stdout content - Reports pass/fail summary with per-test performance timing - Writes microvm-perf-results.json for CI artifact consumption @@ -86,38 +86,22 @@ if (-not (Test-Path $WxcExePath)) { $wxcExe = Resolve-Path $WxcExePath -# -- Verify MicroVM binaries -------------------------------------------------- - -$requiredBinaries = @( - "nanvixd.exe", - "nanvix_rootfs.img", - "python3.initrd", - "bin\kernel.elf", - "snapshots\kernel.vmem", - "snapshots\kernel.whp.cbor" -) -$binDir = Split-Path $wxcExe -$missing = $requiredBinaries | Where-Object { -not (Test-Path (Join-Path $binDir $_)) } - -if ($missing) { - Write-Host "ERROR: Missing MicroVM binaries in ${binDir}:" -ForegroundColor Red - $missing | ForEach-Object { Write-Host " - $_" } - Write-Host " Build with: cd src && cargo build --features microvm" - exit 1 -} - Write-Host "wxc-exec: $wxcExe" -Write-Host "binaries: $binDir" +$bundleDir = Join-Path (Split-Path $wxcExe) "aci-edge-sandboxes" +if (-not (Test-Path (Join-Path $bundleDir "bundle-manifest.json") -PathType Leaf)) { + throw "Missing signed NVX bundle beside wxc-exec: $bundleDir" +} +Write-Host "NVX runtime: signed bundled agent in $bundleDir" # -- Test definitions --------------------------------------------------------- $tests = @( @{ Config = "microvm_hello.json"; ExpectedExit = 0; Description = "Hello world"; OutputContains = "sum=100" }, @{ Config = "microvm_exit_code.json"; ExpectedExit = 42; Description = "Exit code propagation" }, - @{ Config = "microvm_multiline.json"; ExpectedExit = 0; Description = "Multi-line script (fibonacci)"; OutputContains = "fib(" }, - @{ Config = "microvm_stdlib.json"; ExpectedExit = 0; Description = "Stdlib (json, math, hashlib)"; OutputContains = "pi" }, + @{ Config = "microvm_multiline.json"; ExpectedExit = 0; Description = "Shell Fibonacci program"; OutputContains = "fib(" }, + @{ Config = "microvm_stdlib.json"; ExpectedExit = 0; Description = "Guest shell and OS"; OutputContains = "shell=posix" }, @{ Config = "microvm_large_output.json"; ExpectedExit = 0; Description = "Large stdout (1000 lines)"; OutputContains = "line 999" }, - @{ Config = "microvm_error.json"; ExpectedExit = 1; Description = "Python exception"; OutputContains = "ValueError" }, + @{ Config = "microvm_error.json"; ExpectedExit = 1; Description = "Shell error"; OutputContains = "intentional test error" }, @{ Config = "microvm_timeout.json"; ExpectedExit = -1; Description = "Timeout kills VM" } ) From a5ba733fc26b559297e873391948669671d85277 Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Mon, 19 Oct 2026 12:25:57 -0700 Subject: [PATCH 02/18] Address NVX phase 1 review feedback Preserve executor output bytes and propagate test failures. Correct timeout assertions and simplify review wording and documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/microvm-e2e.yml | 8 +- docs/backends/nanvix/nanvix.md | 132 ---------------- .../ci-validation-infrastructure.md | 10 +- src/mxc-sdk/src/backends/nvx/policy.rs | 46 +++--- src/mxc-sdk/src/backends/nvx/runner.rs | 145 ++++++++++++++---- src/mxc-sdk/tests/nvx_e2e.rs | 18 ++- .../tests/wxc_e2e_tests_e2e_linux_microvm.rs | 3 - tests/scripts/README.md | 2 +- 8 files changed, 163 insertions(+), 201 deletions(-) delete mode 100644 docs/backends/nanvix/nanvix.md diff --git a/.github/workflows/microvm-e2e.yml b/.github/workflows/microvm-e2e.yml index be3bc98b42..f9e59769a0 100644 --- a/.github/workflows/microvm-e2e.yml +++ b/.github/workflows/microvm-e2e.yml @@ -40,7 +40,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: cargo build -p wxc --features microvm --target x86_64-pc-windows-msvc --jobs 2 + run: cargo build -p wxc --features microvm --target x86_64-pc-windows-msvc - name: Exclude build output from Windows Defender shell: pwsh @@ -76,8 +76,10 @@ jobs: shell: pwsh working-directory: src run: | - cargo test -p mxc-sdk --features microvm --test wxc_e2e_tests_e2e_windows --target x86_64-pc-windows-msvc --jobs 2 test_microvm_ -- --nocapture - cargo test -p mxc-sdk --features microvm --test nvx_e2e --target x86_64-pc-windows-msvc --jobs 2 -- --include-ignored --nocapture --test-threads=1 + cargo test -p mxc-sdk --features microvm --test wxc_e2e_tests_e2e_windows --target x86_64-pc-windows-msvc test_microvm_ -- --nocapture + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo test -p mxc-sdk --features microvm --test nvx_e2e --target x86_64-pc-windows-msvc -- --include-ignored --nocapture --test-threads=1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Upload logs on failure if: failure() || cancelled() diff --git a/docs/backends/nanvix/nanvix.md b/docs/backends/nanvix/nanvix.md deleted file mode 100644 index 24fc53c74a..0000000000 --- a/docs/backends/nanvix/nanvix.md +++ /dev/null @@ -1,132 +0,0 @@ -# NVX MicroVM Backend - -> **Audience:** MXC consumers and developers - -`microvm` is an experimental **Windows x64** backend using the signed native -agent bundled by the official `aci_edge_sandboxes` crate. The previous Nanvix -backend is inactive, including on Linux. This document retains its old location -so existing links continue to work. - -## Phase1: minimal signed create-and-run - -Build with `build.bat --with-microvm`, or, from `src`: - -```text -cargo build -p wxc --features microvm --jobs 2 -``` - -The host needs Windows Hypervisor Platform and hardware virtualization. -Requests require `--experimental`. Unsupported targets fail closed; enabling -the Cargo feature does not enable MicroVM execution on Linux, macOS or ARM64. - -MXC uses `AgentConfig::bundled` and `AciEdgeSandbox::agent`, not the standalone -OpenVMM backend. Cargo fetches immutable source revision -`0430ce79d125203c20f0aa69243480513a774296` from -[`microsoft/nvx`](https://github.com/microsoft/nvx), with only `agent-bundled` -enabled and default features disabled. `mxc-sdk` is not publishable while this -development Git dependency is in use. - -The dependency's `build.rs` calls `build/agent_bundle.rs` to download -`aci-edge-sandboxes.zip` from release `v0.1.0-dev.0430ce79d125`. It verifies -the archive, layout, component checksums and sandbox ABI, stages the bundle, -and compiles in its manifest and DLL pins. MXC's build module requires the -exact path set and SHA-256 of every file in that official staged bundle, -including all guest, kernel, image-tool, manifest, checksum, provenance, and -licence files. This second check is necessary because Cargo does not inherit a -Git or path dependency's `.cargo/config.toml` when MXC is built by a downstream -consumer. The dependency exposes only the extracted directory to MXC, so the -original ZIP byte stream is no longer available there; the complete extracted -tree identity is pinned instead. MXC does not invoke Cargo recursively, -implement a second downloader, or silently fall back to an unsigned or -standalone backend. - -| Release artifact | SHA-256 | -| --- | --- | -| `aci-edge-sandboxes.zip` | `98e3d6011b0664fad6fa783768370a34209639463f5ab812b7776840660ac24e` | -| `bin/aci_edge_agent.dll` | `a203e58b97a642bd4a68824c0387965541895b25582e18db6f55e28947f5f135` | -| `bin/openvmm.exe` | `697f8221e62a1ea4efb3846fefde6f595c89f4f81c0153a2609bf6fb4c81ea9e` | - -Distribute the entire `aci-edge-sandboxes/` directory beside `wxc-exec.exe` -or the consuming executable. Cargo test/example executables can discover it in -their parent profile directory. The repository Windows build also copies this -directory with the Node native runtime. Runtime path overrides are not needed -for normal operation; missing or altered assets fail rather than falling back. -Build-time bundle overrides must still match the approved signed component pins. - -The control initramfs is not a workload userland. P1 selects the official -example's public workload image, -`mcr.microsoft.com/azurelinux/base/core:3.0`. The native agent pulls/converts it -on the host using bundled `direct-images.exe`; this requires host registry -connectivity and does **not** grant guest network access. There is no P1 public -image-selection or credential API. A private state directory is created for -each run, and stop, deprovision and state-directory removal follow execution. - -### Request example - -Commands are POSIX shell commands inside the Linux workload, not Python source -or Windows command lines: - -```json -{ - "version": "1.1.0-alpha", - "containment": "microvm", - "process": { - "commandLine": "printf 'hello from NVX\\n'; printf 'stderr\\n' >&2; exit 42", - "timeout": 30000 - } -} -``` - -```text -wxc-exec.exe --experimental config.json -``` - -The minimum in-process captured surface is MXC's existing JSON facade, -`mxc_sdk::__ffi::run_json(json, true)`. It returns separate stdout/stderr bytes -and an exit or timeout outcome. This is not a new public Rust lifecycle API. - -### Supported and rejected requests - -P1 accepts command, timeout (at most one hour), guest working directory without -`..` components, and additive environment entries (at most 32 KiB). -Supplying `process.env` requires `inheritDefaultEnv: true`. Proxy environment -variables are rejected, case-insensitively. - -P1 rejects filesystem path grants/denials, explicit network or proxy policy, -explicit UI policy, capture-denials, `destroyOnExit: false`, -`preservePolicy: true`, streaming, PTY and container lifecycle operations. -Policy rejection occurs before bundle discovery or sandbox creation. The -native agent remains responsible for validating and enforcing its own -command/environment/runtime constraints. - -## Release attestations versus consumer evidence - -The published bundle records `officialSigned=true`, `complete=true`, -`mxcCompatible=false`, and `runtimeValidated=false`. These are producer -attestations, not an MXC test result. MXC does not rewrite them, treat the -negative validation flags alone as proof of runtime failure, or claim that a -successful build proves consumer runtime compatibility. - -The bundle embeds NVX revision -`19a359bb3b8974f14bd04b9f2a327a2a3f25d868`, which differs from the crate's -`0430ce79…` source revision. The official build warns about this difference. -Keep the warning and both revisions in validation evidence. - -Host validation can check archive/component SHA-256, Windows Authenticode for -the DLL, OpenVMM and image tool, and checksum integrity of the Linux kernel and -guest agent/initramfs. Linux guest files do not carry Authenticode signatures. -Actual guest execution, timeout and cleanup still require a WHP-capable test -host. Ignored tests in `src/mxc-sdk/tests/nvx_e2e.rs` prepare real MXC -facade/executor cases; compiling them or running host-only rejection cases is -not VM smoke evidence. - -## Stacked implementation boundary - -- **P1:** signed bundled crate, minimum distributable staging, fail-closed - routing/policy, and captured create-and-run. -- **P2:** deployment/packaging and runtime-policy hardening, including richer - policy and deployment validation. -- **P3:** public Rust container lifecycle operations and streaming. -- **P4:** CLI work follows P3. - -No P2 implementation is implied by P1's necessary bundle staging. diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index d2e2694017..0c237ee6ed 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -112,14 +112,6 @@ a capability declaration, not a schedule. Current platforms: -The `microvm` entries below describe the existing CI deployment catalog. -Active MXC MicroVM execution is now the experimental signed NVX P1 backend -on Windows x64 only; the old Nanvix Linux runtime is inactive. The legacy -payload checks in the unified host-preparation scripts do not validate this -new bundle. Updating unified deployment/packaging and its policy-validation -matrix belongs to P2. The dedicated `microvm-e2e.yml` workflow and -`mxc-sdk/tests/nvx_e2e.rs` contain the signed-bundle consumer cases. - | Platform id | Family | x64 pool | arm64 pool | Declared backends (x64) | Declared backends (arm64) | |-------------|--------|----------|------------|--------------------------|------------| | `windows-prerelease-process-container` | windows | `1es-mxc-windows-prerelease-t1-x64` | `1es-mxc-windows-prerelease-t1-arm64` | process-t1, isolation-session, wslc, windows-sandbox, microvm | process-t1, isolation-session | @@ -241,7 +233,7 @@ get fixed or wired. | WSLC | ✅ Good | Might have to retry hung jobs - this is an issue with overzealous agent reclaiming. | | IsolationSession | ✅ Good | Runs the one-shot and state-aware suites, the Rust SDK in-process and helper tests, the C# end-to-end tests, the Node SDK suite and the COM apartment probe. Fails when the host cannot run isolation sessions, when a suite executes nothing, or when the run changes the set of local accounts. | | Windows Sandbox | ⛔ Blocked | Images don't support `Containers-DisposableClientVM` opt. feature | -| MicroVM | Experimental P1 | Signed NVX on Windows x64; minimal captured create-and-run. Real WHP consumer evidence is separate from host-only build/signature checks. Unified artifact/matrix rollout belongs to P2. | +| MicroVM | Experimental | Signed NVX on Windows x64, covered by `microvm-e2e.yml`. The unified matrix still checks legacy Nanvix payloads. | | Seatbelt | ✅ Good | Failures are genuine MXC bugs. | ## Host preparation diff --git a/src/mxc-sdk/src/backends/nvx/policy.rs b/src/mxc-sdk/src/backends/nvx/policy.rs index 5075d46c7c..4b95733c38 100644 --- a/src/mxc-sdk/src/backends/nvx/policy.rs +++ b/src/mxc-sdk/src/backends/nvx/policy.rs @@ -8,15 +8,15 @@ const MAX_TIMEOUT_MS: u32 = 3_600_000; const MAX_ENVIRONMENT_BYTES: usize = 32 * 1024; const PROXY_ENVIRONMENT_KEYS: [&str; 4] = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]; -pub(super) fn validate_phase_one(request: &ExecutionRequest) -> Result<(), MxcError> { +pub(super) fn validate_request(request: &ExecutionRequest) -> Result<(), MxcError> { if !request.lifecycle.destroy_on_exit { return Err(MxcError::policy_validation( - "Phase 1 NVX requires lifecycle.destroyOnExit=true", + "NVX requires lifecycle.destroyOnExit=true", )); } if request.lifecycle.preserve_policy { return Err(MxcError::policy_validation( - "Phase 1 NVX requires lifecycle.preservePolicy=false", + "NVX requires lifecycle.preservePolicy=false", )); } if !request.policy.readonly_paths.is_empty() @@ -25,7 +25,7 @@ pub(super) fn validate_phase_one(request: &ExecutionRequest) -> Result<(), MxcEr || !request.policy.denied_paths.is_empty() { return Err(MxcError::policy_validation( - "Phase 1 NVX does not support filesystem policy", + "NVX does not support filesystem policy", )); } if request.policy.network_specified @@ -33,42 +33,42 @@ pub(super) fn validate_phase_one(request: &ExecutionRequest) -> Result<(), MxcEr || request.policy.network_proxy.is_enabled() { return Err(MxcError::policy_validation( - "Phase 1 NVX does not support network policy or proxy configuration", + "NVX does not support network policy or proxy configuration", )); } if request.policy.ui_specified { return Err(MxcError::policy_validation( - "Phase 1 NVX does not support UI policy", + "NVX does not support UI policy", )); } if request.policy.capture_denials.is_some() { return Err(MxcError::policy_validation( - "Phase 1 NVX does not support processContainer.captureDenials", + "NVX does not support processContainer.captureDenials", )); } if request.policy.least_privilege_specified { return Err(MxcError::policy_validation( - "Phase 1 NVX does not support processContainer.leastPrivilege", + "NVX does not support processContainer.leastPrivilege", )); } if request.policy.learning_mode_specified { return Err(MxcError::policy_validation( - "Phase 1 NVX does not support processContainer.learningMode", + "NVX does not support processContainer.learningMode", )); } if request.policy.capabilities_specified { return Err(MxcError::policy_validation( - "Phase 1 NVX does not support processContainer.capabilities", + "NVX does not support processContainer.capabilities", )); } if request.policy.base_process_ui_specified { return Err(MxcError::policy_validation( - "Phase 1 NVX does not support processContainer.ui", + "NVX does not support processContainer.ui", )); } if request.policy.fallback_specified { return Err(MxcError::policy_validation( - "Phase 1 NVX does not support fallback policy", + "NVX does not support fallback policy", )); } validate_process(request) @@ -82,7 +82,7 @@ fn validate_process(request: &ExecutionRequest) -> Result<(), MxcError> { } if request.script_timeout > MAX_TIMEOUT_MS { return Err(MxcError::policy_validation( - "Phase 1 NVX process.timeout must not exceed one hour", + "NVX process.timeout must not exceed one hour", )); } if request @@ -91,18 +91,18 @@ fn validate_process(request: &ExecutionRequest) -> Result<(), MxcError> { .any(|component| component == "..") { return Err(MxcError::policy_validation( - "Phase 1 NVX process.cwd must not contain '..' path components", + "NVX process.cwd must not contain '..' path components", )); } if !request.working_directory.is_empty() && !request.working_directory.starts_with('/') { return Err(MxcError::policy_validation( - "Phase 1 NVX process.cwd must be an absolute POSIX guest path", + "NVX process.cwd must be an absolute POSIX guest path", )); } if let Some(environment) = &request.env { if !request.inherit_default_env { return Err(MxcError::policy_validation( - "Phase 1 NVX process.env requires inheritDefaultEnv=true", + "NVX process.env requires inheritDefaultEnv=true", )); } let bytes = environment @@ -111,7 +111,7 @@ fn validate_process(request: &ExecutionRequest) -> Result<(), MxcError> { .sum::(); if bytes > MAX_ENVIRONMENT_BYTES { return Err(MxcError::policy_validation( - "Phase 1 NVX process.env must not exceed 32 KiB", + "NVX process.env must not exceed 32 KiB", )); } for entry in environment { @@ -126,7 +126,7 @@ fn validate_process(request: &ExecutionRequest) -> Result<(), MxcError> { .any(|candidate| key.eq_ignore_ascii_case(candidate)) { return Err(MxcError::policy_validation( - "Phase 1 NVX rejects proxy environment variables; use typed network proxy policy in a later phase", + "NVX does not support proxy environment variables", )); } } @@ -148,7 +148,7 @@ mod tests { } fn assert_policy_error(request: &ExecutionRequest, text: &str) { - let error = validate_phase_one(request).unwrap_err(); + let error = validate_request(request).unwrap_err(); assert_eq!(error.code, MxcErrorCode::PolicyValidation); assert!(error.message.contains(text), "{error}"); } @@ -160,7 +160,7 @@ mod tests { request.working_directory = "/workspace".to_owned(); request.env = Some(vec!["KEY=value".to_owned()]); request.inherit_default_env = true; - assert!(validate_phase_one(&request).is_ok()); + assert!(validate_request(&request).is_ok()); } #[test] @@ -232,7 +232,7 @@ mod tests { "x".repeat(MAX_ENVIRONMENT_BYTES - 5) )]); request.inherit_default_env = true; - assert!(validate_phase_one(&request).is_ok()); + assert!(validate_request(&request).is_ok()); request.working_directory = "C:\\host".to_owned(); assert_policy_error(&request, "absolute POSIX guest path"); @@ -243,13 +243,13 @@ mod tests { } request.env = Some(vec!["=missing-key".to_owned()]); assert_eq!( - validate_phase_one(&request).unwrap_err().code, + validate_request(&request).unwrap_err().code, MxcErrorCode::MalformedRequest ); request.env = None; request.script_code.push('\0'); assert_eq!( - validate_phase_one(&request).unwrap_err().code, + validate_request(&request).unwrap_err().code, MxcErrorCode::MalformedRequest ); } diff --git a/src/mxc-sdk/src/backends/nvx/runner.rs b/src/mxc-sdk/src/backends/nvx/runner.rs index 41fbfd0d22..b405d36ce3 100644 --- a/src/mxc-sdk/src/backends/nvx/runner.rs +++ b/src/mxc-sdk/src/backends/nvx/runner.rs @@ -3,6 +3,7 @@ //! Minimal create-and-run adapter for the signed bundled NVX agent. +use std::io::Write; use std::time::Duration; use aci_edge_sandboxes::{ExecOutcome, ExecRequest, ProvisionRequest}; @@ -40,7 +41,7 @@ impl NvxRunner { request: &ExecutionRequest, logger: &mut Logger, ) -> Result { - super::policy::validate_phase_one(request)?; + super::policy::validate_request(request)?; let state_root = tempfile::Builder::new() .prefix(&format!("mxc-nvx-{}-", std::process::id())) .tempdir() @@ -171,13 +172,20 @@ fn finish_run( impl ScriptRunner for NvxRunner { fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { - super::policy::validate_phase_one(request) + super::policy::validate_request(request) .map_err(|error| ScriptResponse::rejected(&error.message)) } fn execute(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { - match self.run_captured(request, logger) { - Ok(result) => captured_to_script_response(result), + let result = self.run_captured(request, logger).and_then(|result| { + captured_to_script_response( + result, + &mut std::io::stdout().lock(), + &mut std::io::stderr().lock(), + ) + }); + match result { + Ok(response) => response, Err(error) if error.code == MxcErrorCode::PolicyValidation => { ScriptResponse::rejected(&error.message) } @@ -208,25 +216,31 @@ fn map_outcome(outcome: ExecOutcome) -> Result { } } -fn captured_to_script_response(result: CapturedRun) -> ScriptResponse { - let standard_out = String::from_utf8_lossy(&result.stdout).into_owned(); - let standard_err = String::from_utf8_lossy(&result.stderr).into_owned(); - match result.outcome { +fn captured_to_script_response( + result: CapturedRun, + stdout: &mut impl Write, + stderr: &mut impl Write, +) -> Result { + stdout + .write_all(&result.stdout) + .and_then(|()| stdout.flush()) + .map_err(|error| MxcError::backend_error(format!("cannot relay NVX stdout: {error}")))?; + stderr + .write_all(&result.stderr) + .and_then(|()| stderr.flush()) + .map_err(|error| MxcError::backend_error(format!("cannot relay NVX stderr: {error}")))?; + Ok(match result.outcome { CapturedOutcome::Exited(exit_code) => ScriptResponse { exit_code, - standard_out, - standard_err, ..Default::default() }, CapturedOutcome::TimedOut => ScriptResponse { exit_code: -1, - standard_out, - standard_err, error_message: "NVX workload timed out".to_string(), failure_phase: FailurePhase::Timeout, ..Default::default() }, - } + }) } fn exec_request(request: &ExecutionRequest) -> ExecRequest { @@ -270,28 +284,101 @@ mod tests { #[test] fn executor_adapter_preserves_exit_and_output() { - let response = captured_to_script_response(CapturedRun { - outcome: CapturedOutcome::Exited(42), - warnings: Vec::new(), - stdout: b"out".to_vec(), - stderr: b"err".to_vec(), - }); - assert_eq!(response.exit_code, 42); - assert_eq!(response.standard_out, "out"); - assert_eq!(response.standard_err, "err"); - assert!(response.error_message.is_empty()); + for (expected_stdout, expected_stderr) in [ + (b"out".as_slice(), b"err".as_slice()), + (b"\xff\0\xfe".as_slice(), b"\x80\0\x81".as_slice()), + (b"".as_slice(), b"".as_slice()), + ] { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let response = captured_to_script_response( + CapturedRun { + outcome: CapturedOutcome::Exited(42), + warnings: Vec::new(), + stdout: expected_stdout.to_vec(), + stderr: expected_stderr.to_vec(), + }, + &mut stdout, + &mut stderr, + ) + .unwrap(); + assert_eq!(response.exit_code, 42); + assert_eq!(stdout, expected_stdout); + assert_eq!(stderr, expected_stderr); + assert!(response.standard_out.is_empty()); + assert!(response.standard_err.is_empty()); + assert!(response.error_message.is_empty()); + } } #[test] fn executor_adapter_marks_timeout() { - let response = captured_to_script_response(CapturedRun { - outcome: CapturedOutcome::TimedOut, - warnings: Vec::new(), - stdout: Vec::new(), - stderr: Vec::new(), - }); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let response = captured_to_script_response( + CapturedRun { + outcome: CapturedOutcome::TimedOut, + warnings: Vec::new(), + stdout: b"partial-out\xff".to_vec(), + stderr: b"partial-err\x80".to_vec(), + }, + &mut stdout, + &mut stderr, + ) + .unwrap(); assert_eq!(response.exit_code, -1); assert_eq!(response.failure_phase, FailurePhase::Timeout); + assert_eq!(response.error_message, "NVX workload timed out"); + assert_eq!(stdout, b"partial-out\xff"); + assert_eq!(stderr, b"partial-err\x80"); + assert!(response.standard_out.is_empty()); + assert!(response.standard_err.is_empty()); + } + + #[test] + fn executor_adapter_surfaces_output_write_and_flush_failures() { + struct FailingWriter { + on_flush: bool, + } + + impl Write for FailingWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if self.on_flush { + Ok(bytes.len()) + } else { + Err(std::io::Error::other("test write failure")) + } + } + + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::other("test flush failure")) + } + } + + for on_flush in [false, true] { + for stream in ["stdout", "stderr"] { + let result = CapturedRun { + outcome: CapturedOutcome::Exited(0), + warnings: Vec::new(), + stdout: b"out".to_vec(), + stderr: b"err".to_vec(), + }; + let mut failing = FailingWriter { on_flush }; + let mut sink = std::io::sink(); + let error = if stream == "stdout" { + captured_to_script_response(result, &mut failing, &mut sink) + } else { + captured_to_script_response(result, &mut sink, &mut failing) + } + .unwrap_err(); + assert_eq!(error.code, MxcErrorCode::BackendError); + let operation = if on_flush { "flush" } else { "write" }; + assert_eq!( + error.message, + format!("cannot relay NVX {stream}: test {operation} failure") + ); + } + } } #[test] diff --git a/src/mxc-sdk/tests/nvx_e2e.rs b/src/mxc-sdk/tests/nvx_e2e.rs index 65aa3acbc7..a227744b5a 100644 --- a/src/mxc-sdk/tests/nvx_e2e.rs +++ b/src/mxc-sdk/tests/nvx_e2e.rs @@ -333,6 +333,13 @@ mod windows_x64 { assert_eq!(result.outcome, WaitResult::Exited(0)); assert_eq!(result.stdout, format!("{marker}:/tmp").as_bytes()); + let result = facade_run(process_only_request( + "printf '\\377\\000\\376'; printf '\\200\\000\\201' >&2; exit 42", + )); + assert_eq!(result.outcome, WaitResult::Exited(42)); + assert_eq!(result.stdout, b"\xff\0\xfe"); + assert_eq!(result.stderr, b"\x80\0\x81"); + let mut timeout = process_only_request("printf partial-out; printf partial-err >&2; sleep 5"); timeout["process"]["timeout"] = json!(200); @@ -362,6 +369,15 @@ mod windows_x64 { assert_eq!(output.stdout, marker.as_bytes()); assert_eq!(output.stderr, b"executor-stderr"); } + let output = executor_output( + &process_only_request( + "printf '\\377\\000\\376'; printf '\\200\\000\\201' >&2; exit 42", + ), + false, + ); + assert_eq!(output.status.code(), Some(42)); + assert_eq!(output.stdout, b"\xff\0\xfe"); + assert_eq!(output.stderr, b"\x80\0\x81"); let mut timeout = process_only_request("printf partial-out; printf partial-err >&2; sleep 5"); timeout["process"]["timeout"] = json!(200); @@ -369,7 +385,7 @@ mod windows_x64 { assert_eq!(output.status.code(), Some(-1)); assert_eq!(output.stdout, b"partial-out"); assert!(String::from_utf8_lossy(&output.stderr).contains("partial-err")); - assert!(String::from_utf8_lossy(&output.stderr).contains("timeout")); + assert!(String::from_utf8_lossy(&output.stderr).contains("NVX workload timed out")); let output = executor_output(&process_only_request("printf after-timeout"), false); assert_eq!(output.status.code(), Some(0)); assert_eq!(output.stdout, b"after-timeout"); diff --git a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_linux_microvm.rs b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_linux_microvm.rs index f5c9c8a34d..609a1e9c76 100644 --- a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_linux_microvm.rs +++ b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_linux_microvm.rs @@ -5,9 +5,6 @@ //! Linux MicroVM (NanVix/KVM) E2E integration tests. //! -//! Retained as inactive historical coverage while active `microvm` dispatch is -//! NVX on Windows x64 only. -//! //! These tests mirror the Windows MicroVM E2E suite in `e2e_windows.rs` and //! invoke `lxc-exec` directly with the `microvm` containment backend. //! Tests skip gracefully when prerequisites (binaries, KVM) are missing. diff --git a/tests/scripts/README.md b/tests/scripts/README.md index ed2eb612f3..d90755d895 100644 --- a/tests/scripts/README.md +++ b/tests/scripts/README.md @@ -39,7 +39,7 @@ Linux / macOS (`.sh`): | `run_filesystem_bfs_spaces_test.ps1` | BFS path-with-spaces test | `wxc-exec.exe` | | `run_test_configs.ps1` | All test configs via wxc-test-driver | `wxc-test-driver.exe` | | `run_examples.ps1` | All examples via wxc-test-driver | `wxc-test-driver.exe` | -| `run_microvm_basic_test.ps1` | Legacy Nanvix helper (inactive; do not use for NVX validation) | Historical only | +| `run_microvm_basic_test.ps1` | MicroVM smoke test | `wxc-exec.exe`, build with `microvm`, complete signed bundle | | `run_microvm_tests.ps1` | Windows signed NVX command, output, exit, and timeout E2E suite | WHP enabled, build with `microvm`, complete signed bundle | | `run_windows_sandbox_one_shot_tests.ps1` | Windows Sandbox one-shot E2E suite (fresh disposable VM per test) | Windows Sandbox enabled | | `run_windows_sandbox_state_aware_tests.ps1` | Windows Sandbox state-aware lifecycle E2E (single VM held across provision/start/exec*/stop/deprovision) | Windows Sandbox enabled | From 6b0fccad414475e4f771d92948d35bce846b5063 Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Mon, 19 Oct 2026 12:52:43 -0700 Subject: [PATCH 03/18] Fix NVX documentation links after removing backend guide Link the root README to the SDK integration notes and document host prerequisites and policy restrictions there. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 6 +++--- src/mxc-sdk/README.md | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cde862a789..5717c2f0a3 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,9 @@ runtime `--experimental` opt-in. This initial slice supports create-and-run execution with the command, timeout, guest working directory, and additive environment settings. Filesystem, network, proxy, UI, capture-denials, non-default lifecycle, streaming, PTY, and container lifecycle operations -remain fail-closed. Linux MicroVM execution is inactive in this phase. See the -[NVX backend guide](docs/backends/nanvix/nanvix.md) for release provenance, -required bundle distribution, and the P1/P2/P3 implementation boundary. +remain fail-closed. Linux MicroVM execution is inactive. See the +[NVX integration notes](src/mxc-sdk/README.md#experimental-nvx-create-and-run) +for build requirements and supported execution. ## How do I use MXC? diff --git a/src/mxc-sdk/README.md b/src/mxc-sdk/README.md index f28ca0978b..e35f7b86a4 100644 --- a/src/mxc-sdk/README.md +++ b/src/mxc-sdk/README.md @@ -192,9 +192,11 @@ public Rust container lifecycle operations are not broadened by P1. Distribute the complete `aci-edge-sandboxes/` directory beside the consuming executable. The native agent pulls the official example's public Azure Linux workload image on the host; guest network access remains ungranted. -See the [NVX backend guide](https://github.com/microsoft/mxc/blob/main/docs/backends/nanvix/nanvix.md) -for rejected policy, release pins/attestations, host prerequisites and the -P2 deployment/hardening and P3 public Rust API boundaries. + +The host requires Windows Hypervisor Platform and hardware virtualization. +Requests cannot include filesystem grants or denials, network or proxy policy, +UI policy, or capture-denials. Containers must be destroyed after execution, +without preserving policy. Backend implementations, shared runtime support, backend dispatch, and host probing are internal modules of this published crate. The crate root exposes From 27c0abfe8e96c77cf9202e846162116445fe2399 Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Mon, 19 Oct 2026 14:59:27 -0700 Subject: [PATCH 04/18] Preserve Nanvix guide and simplify NVX documentation Restore the Nanvix guide unchanged from the PR base and keep the README and CI notes focused on the current integration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 6 +- docs/backends/nanvix/nanvix.md | 195 ++++++++++++++++++ .../ci-validation-infrastructure.md | 2 +- 3 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 docs/backends/nanvix/nanvix.md diff --git a/README.md b/README.md index 5717c2f0a3..9393a831fb 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,9 @@ Linux, and macOS. The Windows x64 `microvm` backend is a development NVX integration. A build with `--with-microvm` stages the pinned signed NVX agent bundle and requires the -runtime `--experimental` opt-in. This initial slice supports create-and-run +runtime `--experimental` opt-in. It supports create-and-run execution with the command, timeout, guest working directory, and additive -environment settings. Filesystem, network, proxy, UI, capture-denials, -non-default lifecycle, streaming, PTY, and container lifecycle operations -remain fail-closed. Linux MicroVM execution is inactive. See the +environment settings. See the [NVX integration notes](src/mxc-sdk/README.md#experimental-nvx-create-and-run) for build requirements and supported execution. diff --git a/docs/backends/nanvix/nanvix.md b/docs/backends/nanvix/nanvix.md new file mode 100644 index 0000000000..0ea9ed61db --- /dev/null +++ b/docs/backends/nanvix/nanvix.md @@ -0,0 +1,195 @@ +# Nanvix MicroVM Backend + +> **Audience:** MXC consumers and developers + +Nanvix MicroVM is an experimental containment backend for MxC. It is powered by the +[Nanvix OS/VM](https://aka.ms/nanvix) and runs untrusted code with hardware-enforced isolation +via the Windows Hypervisor Platform (WHP) on Windows or KVM on Linux. + +## Key Features + +- **Fast cold-start** — ~100 ms from process spawn to guest code execution (Windows warm-start via WHP snapshot; Linux uses cold boot via KVM every run) +- **Low Memory Footprint** — Resident memory size of ~100 MB +- **Hardware-Enforced Isolation** — Runs guest code inside a lightweight virtual machine (VM) + +## Requirements + +### Windows + +- Windows with WHP enabled (`bcdedit /set hypervisorlaunchtype auto`) +- Nanvix runtime binaries (`nanvixd.exe`, `kernel.elf`, `python3.initrd`, `nanvix_rootfs.img`) placed next to `wxc-exec.exe` +- Build with `--with-microvm` (`build.bat --with-microvm` or `cargo build -p wxc --features microvm`) +- `--experimental` flag (Nanvix MicroVM is an experimental feature) + +### Linux + +- Linux with KVM available at `/dev/kvm` (and the invoking user has read/write access to it) +- Nanvix runtime binaries (`nanvixd.elf`, `kernel.elf`, `python3.initrd`, `nanvix_rootfs.img`) placed next to `lxc-exec` (the build script downloads and stages them automatically) +- Build with `--with-microvm` (`./build.sh --with-microvm` or `cargo build -p lxc --features microvm`) +- `--experimental` flag (Nanvix MicroVM is an experimental feature) + +> **Note:** On Linux, WHP snapshots are not used. Each invocation cold-boots +> the VM via KVM. Snapshot-based warm-start is Windows-only. + +### Offline builds + +By default the `nanvix_binaries` build script downloads the NanVix release +assets at compile time. For air-gapped or hermetic builds, pre-fetch the +binaries and point the `NANVIX_BIN` environment variable at the directory +containing them: + +``` +# Windows (PowerShell) +$env:NANVIX_BIN = "C:\path\to\nanvix-binaries" + +# Linux / macOS +export NANVIX_BIN=/path/to/nanvix-binaries +``` + +When `NANVIX_BIN` is set, the build performs no network downloads and uses the +provided directory directly. The directory must contain the required binaries +(the flat files plus the `bin/` subdirectory); their checksums are still +verified against `checksums.json`. The easiest way to produce such a directory +is to run a normal `--with-microvm` build once and copy the staged +`nanvix-binaries` directory out of `OUT_DIR`. + +> **Snapshots are not trusted in offline mode.** WHP warm-start snapshots +> (`snapshots/kernel.vmem`, `snapshots/kernel.whp.cbor`) are *not* covered by +> `checksums.json` — in a normal build they are generated locally, not +> downloaded. Any `snapshots/` directory inside `NANVIX_BIN` is therefore +> ignored (never copied next to the executable), and the runtime cold-boots on +> first use to regenerate a verified snapshot. This prevents an unverified VM +> memory image from being warm-booted against otherwise-verified binaries. + +## Quick Start + +```json +{ + "version": "1.1.0-alpha", + "process": { + "commandLine": "print('Hello from MicroVM!')", + "timeout": 30000 + }, + "containment": "microvm" +} +``` + +```bash +wxc-exec.exe --experimental config.json +``` + +## SDK Usage + +Use `spawnSandboxFromConfig` with `usePty: false` for reliable exit codes and +separate stdout/stderr streams: + +```typescript +const child = spawnSandboxFromConfig(config, { + experimental: true, + usePty: false, +}); + +``` + +## Filesystem Policy + +### readwrite_paths + +Host directories or files listed in `readwritePaths` are copied into a private +per-run staging directory before boot. Nanvix mounts are snapshot-based — host +files are **not** modified while the guest is running. No junctions or live host +mounts are used. + +```json +{ + "version": "1.1.0-alpha", + "process": { + "commandLine": "import os\npath = 'C:\\\\Users\\\\me\\\\work'\nwith open(os.path.join(path, 'result.txt'), 'w') as f:\n f.write('done')", + "timeout": 30000 + }, + "containment": "microvm", + "filesystem": { + "readwritePaths": ["C:\\Users\\me\\work"] + } +} +``` + +Inside the guest, host paths in the script are transparently rewritten to their +guest mount equivalents at staging time. The script uses the original host paths +and the staging layer translates them before the code reaches the VM. + +| Host path | Guest path | +| ------------------ | --------------------------- | +| `C:\Users\me\work` | `/mnt/rw/c/Users/me/work` | +| `C:\data\ref-data` | `/mnt/rw/c/data/ref-data` | + +**Copyback semantics:** After `nanvixd` exits normally, MXC copies the modified +snapshot back to the original host paths. Copyback runs for both exit code `0` +and non-zero guest exit codes. It is skipped for preflight failure, spawn +failure, watchdog timeout, and runner/runtime errors — no partial state is +leaked to the host. + +### readonly_paths + +Host directories listed in `readonlyPaths` are copied into the staging directory +with read-only file attributes. Writes return `EACCES`. Read-only paths are +never copied back to the host. + +```json +{ + "filesystem": { + "readonlyPaths": ["C:\\data\\reference"] + } +} +``` + +### denied_paths + +Not supported for MicroVM. If `deniedPaths` is specified, the config is rejected with an error. + +## Constraints + +| Constraint | Value | +| --------------------------------------- | ------------------------------------------- | +| Single file size | < 4 GB (FAT32 limit) | +| Guest RAM | 256 MB | +| Symlinks/reparse points in source paths | Not supported (rejected at preflight) | +| Junctions for staging | Not used | +| `workingDirectory` | Not supported (guest CWD is `/`) | +| Network policy | v1.1 isolated or explicitly unrestricted host networking | + +## Networking + +Host networking is **opt-in**. Exact v1.1 supports two coherent directional +postures: all three of egress default, ingress default and host-loopback deny +leaves networking disabled; all three explicitly allow enables unrestricted +host networking through `-allow-host-networking`. Mixed postures are rejected, +including egress allow with omitted ingress defaults. + +```json +{ + "version": "1.1.0-alpha", + "containment": "microvm", + "process": { "commandLine": "print('network enabled')" }, + "network": { + "egress": { "default": "allow" }, + "ingress": { "default": "allow", "hostLoopback": "allow" } + } +} +``` + +Disabled networking prevents guest socket creation (`OSError: [Errno 134]`). +Unrestricted networking includes host-backed bind/listen capabilities; +NanVix cannot independently enforce ingress or host-loopback restrictions. +Directional egress rules are explicitly rejected rather than translated to +the guest's IPv4 host filter, which cannot implement their full semantics, +including default-deny DNS. +Runtime proxy configuration is also unsupported. + +## Not Supported + +| Workload | Error | +| -------------------------------------------- | -------------------------------- | +| Mixed directional networking or egress rules | Rejected before VM creation | +| Runtime proxy | Rejected before VM creation | +| File writing outside `/mnt/rw/` | `OSError: Read-only file system` | diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index 0c237ee6ed..91149580fe 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -233,7 +233,7 @@ get fixed or wired. | WSLC | ✅ Good | Might have to retry hung jobs - this is an issue with overzealous agent reclaiming. | | IsolationSession | ✅ Good | Runs the one-shot and state-aware suites, the Rust SDK in-process and helper tests, the C# end-to-end tests, the Node SDK suite and the COM apartment probe. Fails when the host cannot run isolation sessions, when a suite executes nothing, or when the run changes the set of local accounts. | | Windows Sandbox | ⛔ Blocked | Images don't support `Containers-DisposableClientVM` opt. feature | -| MicroVM | Experimental | Signed NVX on Windows x64, covered by `microvm-e2e.yml`. The unified matrix still checks legacy Nanvix payloads. | +| MicroVM | Experimental | Signed NVX on Windows x64, covered by `microvm-e2e.yml`. | | Seatbelt | ✅ Good | Failures are genuine MXC bugs. | ## Host preparation From 721401aa155e8a1b8b23a4e0fb804968717e222c Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Wed, 21 Oct 2026 16:07:44 -0700 Subject: [PATCH 05/18] Run MicroVM host tests only in the dedicated workflow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/microvm-e2e.yml | 2 +- src/mxc-sdk/tests/wxc_e2e_tests_e2e_windows.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/microvm-e2e.yml b/.github/workflows/microvm-e2e.yml index f9e59769a0..9e2b7edc26 100644 --- a/.github/workflows/microvm-e2e.yml +++ b/.github/workflows/microvm-e2e.yml @@ -76,7 +76,7 @@ jobs: shell: pwsh working-directory: src run: | - cargo test -p mxc-sdk --features microvm --test wxc_e2e_tests_e2e_windows --target x86_64-pc-windows-msvc test_microvm_ -- --nocapture + cargo test -p mxc-sdk --features microvm --test wxc_e2e_tests_e2e_windows --target x86_64-pc-windows-msvc test_microvm_ -- --include-ignored --nocapture if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } cargo test -p mxc-sdk --features microvm --test nvx_e2e --target x86_64-pc-windows-msvc -- --include-ignored --nocapture --test-threads=1 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_windows.rs b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_windows.rs index 97ff9f9731..97135b9992 100644 --- a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_windows.rs +++ b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_windows.rs @@ -510,6 +510,7 @@ fn test_examples() { #[test] #[cfg(all(target_arch = "x86_64", feature = "microvm"))] +#[ignore = "VM: run explicitly in microvm-e2e.yml on a prepared Windows host"] fn test_microvm_basic() { if !cached_has_wxc_exe() { return; @@ -531,6 +532,7 @@ fn test_windows_sandbox() { #[test] #[cfg(all(target_arch = "x86_64", feature = "microvm"))] +#[ignore = "VM: run explicitly in microvm-e2e.yml on a prepared Windows host"] fn test_microvm_suite() { if !cached_has_wxc_exe() { return; From 6d2e7ff7d5b2e688439ea641fae25b0ffd4423f4 Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Wed, 21 Oct 2026 16:52:34 -0700 Subject: [PATCH 06/18] Consume the signed NVX bundle through its native DLL interface Remove unpublished Rust dependencies, retain pinned bundle verification, and validate packaging and explicit MicroVM CI execution. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .cargo/config.toml | 8 - .github/workflows/Build.Windows.Job.yml | 7 + .github/workflows/Versioning.Checks.Job.yml | 4 + .github/workflows/microvm-e2e.yml | 13 +- src/Cargo.lock | 122 +-- src/mxc-sdk/Cargo.toml | 7 +- src/mxc-sdk/README.md | 9 +- src/mxc-sdk/build/build_nvx_bundled.rs | 251 +++++- src/mxc-sdk/src/backends/nvx/abi.rs | 860 ++++++++++++++++++ src/mxc-sdk/src/backends/nvx/mod.rs | 1 + src/mxc-sdk/src/backends/nvx/runner.rs | 302 +++++-- src/mxc-sdk/src/backends/nvx/runtime.rs | 916 +++++++++++++++++++- src/mxc-sdk/tests/nvx_bundle_integrity.rs | 115 +++ tests/scripts/run_microvm_tests.ps1 | 2 +- 14 files changed, 2362 insertions(+), 255 deletions(-) create mode 100644 src/mxc-sdk/src/backends/nvx/abi.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index c4476612fc..e027d8b4ac 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,12 +1,4 @@ -[env] -# Keep the official dependency's verifier in the download/staging path while pinning the exact -# release archive approved for this NVX revision. `force` prevents ambient build variables from -# selecting another URL, digest, or the verifier's explicit skip mode. -ACI_EDGE_SANDBOXES_AGENT_BUNDLE = { value = "required", force = true } -ACI_EDGE_SANDBOXES_AGENT_BUNDLE_URL = { value = "https://github.com/microsoft/nvx/releases/download/v0.1.0-dev.0430ce79d125/aci-edge-sandboxes.zip", force = true } -ACI_EDGE_SANDBOXES_AGENT_BUNDLE_SHA256 = { value = "98e3d6011b0664fad6fa783768370a34209639463f5ab812b7776840660ac24e", force = true } - # Hardening flags for Windows targets. Most flags apply to both arches; # /CETCOMPAT is x86/x64 only (Intel Control-flow Enforcement Technology; # ARM64 uses Pointer Authentication / BTI instead). diff --git a/.github/workflows/Build.Windows.Job.yml b/.github/workflows/Build.Windows.Job.yml index e205cdcb20..df03736ef4 100644 --- a/.github/workflows/Build.Windows.Job.yml +++ b/.github/workflows/Build.Windows.Job.yml @@ -76,6 +76,13 @@ jobs: --no-default-features --features "${{ matrix.features }}" + - name: Test NVX host-only executor matrix + if: matrix.target == 'x86_64-pc-windows-msvc' + run: cargo test --locked --release --target ${{ matrix.target }} + -p mxc-sdk --no-default-features --features microvm + --test nvx_e2e windows_x64::host_only_executor_request_matrix + -- --ignored --exact + - name: Build debug executor for isolated telemetry smoke tests run: cargo build --locked -p wxc --features test-support diff --git a/.github/workflows/Versioning.Checks.Job.yml b/.github/workflows/Versioning.Checks.Job.yml index 70a3eb9792..559ca440ce 100644 --- a/.github/workflows/Versioning.Checks.Job.yml +++ b/.github/workflows/Versioning.Checks.Job.yml @@ -79,6 +79,10 @@ jobs: shell: pwsh run: ./scripts/ci/Invoke-CratePackage.ps1 -ValidateOnly + - name: Smoke-test mxc-sdk crate packaging + shell: pwsh + run: ./scripts/ci/Invoke-CratePackage.ps1 -OutDir (Join-Path $env:RUNNER_TEMP 'crates') + - name: Check dotnet ErrorCode parity (C# enum == native MXC_STATUS_*) run: node scripts/check-dotnet-errorcode-parity.js diff --git a/.github/workflows/microvm-e2e.yml b/.github/workflows/microvm-e2e.yml index 9e2b7edc26..f4e2fbcb82 100644 --- a/.github/workflows/microvm-e2e.yml +++ b/.github/workflows/microvm-e2e.yml @@ -6,7 +6,7 @@ on: push: branches: [main] pull_request: - branches: [main] + branches: [main, 'feature/*', 'user/*'] workflow_dispatch: permissions: @@ -62,6 +62,17 @@ jobs: } Get-Item $required | Format-Table FullName, Length + - name: Run MicroVM bundle integrity and host-only tests + shell: pwsh + working-directory: src + run: | + cargo test -p mxc-sdk --features microvm --test nvx_bundle_integrity --target x86_64-pc-windows-msvc + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo test -p mxc-sdk --features microvm --test nvx_e2e --target x86_64-pc-windows-msvc + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo test -p mxc-sdk --features microvm --test nvx_e2e --target x86_64-pc-windows-msvc windows_x64::host_only_executor_request_matrix -- --ignored --exact --nocapture + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Diagnose hypervisor environment shell: pwsh run: ./scripts/ci/diagnose-whp.ps1 diff --git a/src/Cargo.lock b/src/Cargo.lock index f79061f6ce..6df54098e8 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -2,34 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "aci_edge_sandboxes" -version = "0.1.0" -source = "git+https://github.com/microsoft/nvx?rev=0430ce79d125203c20f0aa69243480513a774296#0430ce79d125203c20f0aa69243480513a774296" -dependencies = [ - "aci_edge_sandboxes_model", - "libc", - "libloading", - "nvx-rs", - "serde", - "serde_json", - "sha2 0.10.9", - "static_assertions", - "windows-sys 0.61.2", -] - -[[package]] -name = "aci_edge_sandboxes_model" -version = "0.1.0" -source = "git+https://github.com/microsoft/nvx?rev=0430ce79d125203c20f0aa69243480513a774296#0430ce79d125203c20f0aa69243480513a774296" -dependencies = [ - "getrandom 0.4.2", - "nvx-rs", - "serde", - "serde_json", - "thiserror", -] - [[package]] name = "adler2" version = "2.0.1" @@ -204,16 +176,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures 0.3.0", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", + "cpufeatures", ] [[package]] @@ -347,7 +310,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures", "rand_core", ] @@ -464,15 +427,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "cpufeatures" version = "0.3.0" @@ -506,16 +460,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "crypto-common" version = "0.2.2" @@ -612,25 +556,15 @@ dependencies = [ "syn", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", -] - [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.1", + "block-buffer", "const-oid", - "crypto-common 0.2.2", + "crypto-common", ] [[package]] @@ -812,16 +746,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -1083,7 +1007,7 @@ dependencies = [ "rust-embed", "serde", "serde_json", - "sha2 0.11.0", + "sha2", "tempfile", "termcolor", "thiserror", @@ -1109,7 +1033,7 @@ dependencies = [ "hyperlight-host", "rustix", "serde_json", - "sha2 0.11.0", + "sha2", "thiserror", "tracing", "windows-sys 0.61.2", @@ -1559,7 +1483,6 @@ dependencies = [ name = "mxc-sdk" version = "1.0.1" dependencies = [ - "aci_edge_sandboxes", "anyhow", "base64 0.22.1", "cc", @@ -1580,7 +1503,7 @@ dependencies = [ "serde", "serde_json", "serde_path_to_error", - "sha2 0.11.0", + "sha2", "tar", "tempfile", "thiserror", @@ -1701,14 +1624,6 @@ dependencies = [ "syn", ] -[[package]] -name = "nvx-rs" -version = "0.1.0" -source = "git+https://github.com/microsoft/nvx?rev=0430ce79d125203c20f0aa69243480513a774296#0430ce79d125203c20f0aa69243480513a774296" -dependencies = [ - "serde", -] - [[package]] name = "oci-spec" version = "0.10.0" @@ -2004,7 +1919,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" dependencies = [ "globset", - "sha2 0.11.0", + "sha2", "walkdir", ] @@ -2227,17 +2142,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha2" version = "0.11.0" @@ -2245,8 +2149,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", + "cpufeatures", + "digest", ] [[package]] @@ -2323,12 +2227,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "strsim" version = "0.11.1" diff --git a/src/mxc-sdk/Cargo.toml b/src/mxc-sdk/Cargo.toml index b7fc97b981..c322d42d6e 100644 --- a/src/mxc-sdk/Cargo.toml +++ b/src/mxc-sdk/Cargo.toml @@ -8,7 +8,6 @@ repository = "https://github.com/microsoft/mxc" readme = "README.md" build = "build.rs" autobins = false -publish = false [lib] name = "mxc_sdk" @@ -33,7 +32,7 @@ wslc = ["link-wslcsdk", "dep:libloading", "dep:tar"] link-wslcsdk = ["dep:zip"] isolation_session = ["dep:windows-collections", "dep:windows-future"] test-support = [] -microvm = ["dep:aci_edge_sandboxes"] +microvm = ["dep:libloading", "dep:zip"] hyperlight = [ "dep:hyperlight-unikraft", "dep:ureq", @@ -83,6 +82,7 @@ quick-xml.workspace = true serde_json.workspace = true tempfile.workspace = true tokio = { workspace = true, features = ["test-util"] } +zip = { version = "2", default-features = false, features = ["deflate"] } [target.'cfg(target_arch = "x86_64")'.dependencies] flate2 = { version = "1", optional = true } @@ -104,9 +104,6 @@ windows-core.workspace = true windows-future = { version = "0.3", optional = true } winreg.workspace = true -[target.'cfg(all(target_os = "windows", target_arch = "x86_64"))'.dependencies] -aci_edge_sandboxes = { git = "https://github.com/microsoft/nvx", rev = "0430ce79d125203c20f0aa69243480513a774296", default-features = false, features = ["agent-bundled"], optional = true } - [target.'cfg(windows)'.build-dependencies] cc = "1" winresource = { version = "0.1.31", default-features = false } diff --git a/src/mxc-sdk/README.md b/src/mxc-sdk/README.md index e35f7b86a4..a0344cfe50 100644 --- a/src/mxc-sdk/README.md +++ b/src/mxc-sdk/README.md @@ -180,10 +180,11 @@ temporary storage without creating directories. ### Experimental NVX create-and-run The `microvm` feature enables only Windows x64 and requires runtime experimental -authorization. P1 uses the official signed `aci_edge_sandboxes` bundled agent at -immutable Git revision `0430ce79d125203c20f0aa69243480513a774296`; it does not -enable the standalone OpenVMM feature. This development crate has -`publish = false`. +authorization. P1 stages the official signed `aci-edge-sandboxes.zip` release +bundle (`v0.1.0-dev.0430ce79d125` from NVX revision +`0430ce79d125203c20f0aa69243480513a774296`) during the crate build and loads the +verified `bin/aci_edge_agent.dll` C ABI v1 in-process without unpublished Rust +dependencies; it does not enable standalone OpenVMM execution. The existing hidden JSON facade `mxc_sdk::__ffi::run_json(json, true)` supports the development `1.1.0-alpha` MicroVM create-and-run request with captured diff --git a/src/mxc-sdk/build/build_nvx_bundled.rs b/src/mxc-sdk/build/build_nvx_bundled.rs index d3ac1b8554..a075e4a98a 100644 --- a/src/mxc-sdk/build/build_nvx_bundled.rs +++ b/src/mxc-sdk/build/build_nvx_bundled.rs @@ -1,12 +1,26 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#[cfg(any(test, feature = "microvm"))] use std::collections::BTreeMap; +#[cfg(any(test, feature = "microvm"))] use std::path::{Component, Path, PathBuf}; +#[cfg(feature = "microvm")] +use std::process::Command; +#[cfg(any(test, feature = "microvm"))] use sha2::{Digest, Sha256}; -const OFFICIAL_BUNDLE_FILES: &[(&str, &str)] = &[ +#[cfg(any(test, feature = "microvm"))] +pub(crate) const OFFICIAL_BUNDLE_URL: &str = + "https://github.com/microsoft/nvx/releases/download/v0.1.0-dev.0430ce79d125/aci-edge-sandboxes.zip"; +#[cfg(any(test, feature = "microvm"))] +pub(crate) const OFFICIAL_BUNDLE_ZIP_SHA256: &str = + "98e3d6011b0664fad6fa783768370a34209639463f5ab812b7776840660ac24e"; +const BUNDLE_DIR_ENV: &str = "ACI_EDGE_SANDBOXES_AGENT_BUNDLE_DIR"; + +#[cfg(any(test, feature = "microvm"))] +pub(crate) const OFFICIAL_BUNDLE_FILES: &[(&str, &str)] = &[ ( "SHA256SUMS", "d86b7768ca51e6ea93c40880cc027c85280e718e960e1efe3e0e80bd23727fce", @@ -83,7 +97,7 @@ const OFFICIAL_BUNDLE_FILES: &[(&str, &str)] = &[ pub(crate) fn run() { println!("cargo:rerun-if-env-changed=CARGO_FEATURE_MICROVM"); - println!("cargo:rerun-if-env-changed=DEP_ACI_EDGE_SANDBOXES_AGENT_ARTIFACTS_DIR"); + println!("cargo:rerun-if-env-changed={BUNDLE_DIR_ENV}"); if std::env::var_os("CARGO_FEATURE_MICROVM").is_none() || std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") || std::env::var("CARGO_CFG_TARGET_ARCH").as_deref() != Ok("x86_64") @@ -91,15 +105,37 @@ pub(crate) fn run() { return; } - // Cargo does not inherit a dependency checkout's .cargo/config.toml. Authenticate the exact - // extracted release here so downstream path and Git consumers cannot select another bundle. - let source = PathBuf::from( - std::env::var_os("DEP_ACI_EDGE_SANDBOXES_AGENT_ARTIFACTS_DIR") - .expect("microvm requires the official signed agent-bundled NVX release"), - ); - verify_bundle(&source); + #[cfg(feature = "microvm")] + stage_official_bundle(); +} +#[cfg(feature = "microvm")] +fn stage_official_bundle() { let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR is set")); + let source = match std::env::var_os(BUNDLE_DIR_ENV).filter(|value| !value.is_empty()) { + Some(directory) => { + let directory = PathBuf::from(directory); + let archive = directory.join("aci-edge-sandboxes.zip"); + if archive.is_file() && !directory.join("SOURCE-MANIFEST.json").is_file() { + println!("cargo:rerun-if-changed={}", archive.display()); + let extracted = out_dir.join("aci-edge-sandboxes"); + if !is_verified_bundle(&extracted, OFFICIAL_BUNDLE_FILES) { + extract_verified_archive_against( + &archive, + &extracted, + OFFICIAL_BUNDLE_ZIP_SHA256, + OFFICIAL_BUNDLE_FILES, + ); + } + extracted + } else { + directory + } + } + None => acquire_cached_bundle(&out_dir), + }; + verify_bundle(&source); + let target = out_dir .ancestors() .nth(3) @@ -109,6 +145,199 @@ pub(crate) fn run() { verify_bundle(&target); } +#[cfg(feature = "microvm")] +fn acquire_cached_bundle(out_dir: &Path) -> PathBuf { + let extracted = out_dir.join("aci-edge-sandboxes"); + if is_verified_bundle(&extracted, OFFICIAL_BUNDLE_FILES) { + return extracted; + } + + let archive = out_dir.join("aci-edge-sandboxes.zip"); + let archive_ok = + archive.is_file() && sha256(&archive).eq_ignore_ascii_case(OFFICIAL_BUNDLE_ZIP_SHA256); + if !archive_ok { + let _ = std::fs::remove_file(&archive); + download_archive(OFFICIAL_BUNDLE_URL, &archive); + } + extract_verified_archive_against( + &archive, + &extracted, + OFFICIAL_BUNDLE_ZIP_SHA256, + OFFICIAL_BUNDLE_FILES, + ); + extracted +} + +#[cfg(feature = "microvm")] +fn download_archive(url: &str, destination: &Path) { + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent) + .unwrap_or_else(|error| panic!("cannot create {}: {error}", parent.display())); + } + let partial = destination.with_extension("zip.part"); + let _ = std::fs::remove_file(&partial); + let output = Command::new("curl") + .args([ + "--fail", + "--silent", + "--show-error", + "--location", + "--retry", + "5", + "--retry-delay", + "5", + "--retry-all-errors", + "--output", + ]) + .arg(&partial) + .arg(url) + .output() + .unwrap_or_else(|error| { + let _ = std::fs::remove_file(&partial); + panic!( + "cannot invoke curl to download signed NVX bundle from {url}: {error}; \ + set {BUNDLE_DIR_ENV} to a pre-fetched bundle directory for offline builds" + ) + }); + if !output.status.success() { + let _ = std::fs::remove_file(&partial); + panic!( + "curl failed to download signed NVX bundle from {url} ({}): {}; \ + set {BUNDLE_DIR_ENV} to a pre-fetched bundle directory for offline builds", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + std::fs::rename(&partial, destination).unwrap_or_else(|error| { + let _ = std::fs::remove_file(&partial); + panic!( + "cannot finalize downloaded NVX bundle ({} -> {}): {error}", + partial.display(), + destination.display() + ) + }); +} + +#[cfg(any(test, feature = "microvm"))] +pub(crate) fn extract_verified_archive_against( + archive_path: &Path, + destination: &Path, + expected_archive_sha256: &str, + expected_files: &[(&str, &str)], +) { + let actual_archive_sha256 = sha256(archive_path); + if !actual_archive_sha256.eq_ignore_ascii_case(expected_archive_sha256) { + let _ = std::fs::remove_file(archive_path); + panic!( + "microvm requires the signed v0.1.0-dev.0430ce79d125 archive {}: \ + expected SHA-256 {expected_archive_sha256}, got {actual_archive_sha256}", + archive_path.display() + ); + } + + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent) + .unwrap_or_else(|error| panic!("cannot create {}: {error}", parent.display())); + } + let name = destination + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("aci-edge-sandboxes"); + let staging = destination.with_file_name(format!("{name}.tmp.{}", std::process::id())); + let _ = std::fs::remove_dir_all(&staging); + std::fs::create_dir_all(&staging) + .unwrap_or_else(|error| panic!("cannot create {}: {error}", staging.display())); + + let unpack_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + unpack_zip_strict(archive_path, &staging); + verify_bundle_against(&staging, expected_files); + })); + if let Err(payload) = unpack_result { + let _ = std::fs::remove_dir_all(&staging); + std::panic::resume_unwind(payload); + } + + let _ = std::fs::remove_dir_all(destination); + std::fs::rename(&staging, destination).unwrap_or_else(|error| { + let _ = std::fs::remove_dir_all(&staging); + panic!( + "cannot publish extracted NVX bundle to {}: {error}", + destination.display() + ) + }); +} + +#[cfg(any(test, feature = "microvm"))] +fn unpack_zip_strict(archive_path: &Path, destination: &Path) { + let file = std::fs::File::open(archive_path) + .unwrap_or_else(|error| panic!("cannot open {}: {error}", archive_path.display())); + let mut archive = zip::ZipArchive::new(file).unwrap_or_else(|error| { + panic!( + "cannot read NVX bundle archive {}: {error}", + archive_path.display() + ) + }); + + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .unwrap_or_else(|error| panic!("cannot read NVX zip entry {index}: {error}")); + assert!( + !entry.is_symlink(), + "NVX bundle archive must not contain links: {}", + entry.name() + ); + let relative = entry.enclosed_name().unwrap_or_else(|| { + panic!( + "NVX bundle archive entry path is not safe: {}", + entry.name() + ) + }); + assert!( + !relative.as_os_str().is_empty() + && relative + .components() + .all(|component| matches!(component, Component::Normal(_))), + "NVX bundle archive entry path is not safe: {}", + entry.name() + ); + let out_path = destination.join(&relative); + if entry.is_dir() { + std::fs::create_dir_all(&out_path) + .unwrap_or_else(|error| panic!("cannot create {}: {error}", out_path.display())); + } else { + assert!( + entry.is_file(), + "NVX bundle archive must contain only regular files and directories: {}", + entry.name() + ); + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent) + .unwrap_or_else(|error| panic!("cannot create {}: {error}", parent.display())); + } + assert!( + !out_path.exists(), + "duplicate NVX bundle archive entry: {}", + relative.display() + ); + let mut output = std::fs::File::create(&out_path) + .unwrap_or_else(|error| panic!("cannot create {}: {error}", out_path.display())); + std::io::copy(&mut entry, &mut output) + .unwrap_or_else(|error| panic!("cannot write {}: {error}", out_path.display())); + } + } +} + +#[cfg(feature = "microvm")] +fn is_verified_bundle(source: &Path, expected_files: &[(&str, &str)]) -> bool { + source.is_dir() + && std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + verify_bundle_against(source, expected_files); + })) + .is_ok() +} + +#[cfg(any(test, feature = "microvm"))] fn sha256(path: &Path) -> String { let bytes = std::fs::read(path).unwrap_or_else(|error| { panic!( @@ -122,10 +351,12 @@ fn sha256(path: &Path) -> String { .collect() } +#[cfg(feature = "microvm")] fn verify_bundle(source: &Path) { verify_bundle_against(source, OFFICIAL_BUNDLE_FILES); } +#[cfg(any(test, feature = "microvm"))] pub(crate) fn verify_bundle_against(source: &Path, expected_files: &[(&str, &str)]) { let expected: BTreeMap<&str, &str> = expected_files.iter().copied().collect(); assert_eq!( @@ -153,6 +384,7 @@ pub(crate) fn verify_bundle_against(source: &Path, expected_files: &[(&str, &str } } +#[cfg(any(test, feature = "microvm"))] fn collect_bundle_files(source: &Path, directory: &Path, files: &mut BTreeMap) { for entry in std::fs::read_dir(directory) .unwrap_or_else(|error| panic!("cannot list {}: {error}", directory.display())) @@ -189,6 +421,7 @@ fn collect_bundle_files(source: &Path, directory: &Path, files: &mut BTreeMap() == 24); + +pub(super) type VersionFn = unsafe extern "C" fn() -> u32; +pub(super) type HostOpenFn = + unsafe extern "C" fn(*const u8, usize, *mut u64, *mut *mut u8, *mut usize) -> i32; +pub(super) type ReleaseFn = unsafe extern "C" fn(u64) -> i32; +pub(super) type QueryFn = unsafe extern "C" fn(u64, *mut *mut u8, *mut usize) -> i32; +pub(super) type CallFn = + unsafe extern "C" fn(u64, *const u8, usize, *mut *mut u8, *mut usize) -> i32; +pub(super) type ExecFn = unsafe extern "C" fn( + u64, + *const u8, + usize, + *const u8, + usize, + *mut u64, + *mut *mut u8, + *mut usize, +) -> i32; +pub(super) type ExecNextFn = unsafe extern "C" fn(u64, i64, *mut ExecEvent) -> i32; +pub(super) type ExecWriteFn = + unsafe extern "C" fn(u64, *const u8, usize, i64, *mut usize, *mut *mut u8, *mut usize) -> i32; +pub(super) type BufferFreeFn = unsafe extern "C" fn(*mut u8, usize); + +#[allow(dead_code)] +#[derive(Clone, Copy)] +pub(super) struct Exports { + pub host_open: HostOpenFn, + pub host_close: ReleaseFn, + pub host_info: QueryFn, + pub capabilities: QueryFn, + pub probe: QueryFn, + pub validate_provision: CallFn, + pub validate_exec: CallFn, + pub provision: CallFn, + pub start: CallFn, + pub stop: CallFn, + pub deprovision: CallFn, + pub diagnostics: CallFn, + pub guest_logs: CallFn, + pub exec: ExecFn, + pub exec_next: ExecNextFn, + pub exec_write: ExecWriteFn, + pub exec_close_input: QueryFn, + pub exec_cancel: QueryFn, + pub exec_release: ReleaseFn, + pub image_register: CallFn, + pub image_list: QueryFn, + pub image_verify: CallFn, + pub image_unregister: CallFn, + pub buffer_free: BufferFreeFn, +} + +/// A verified `aci_edge_agent.dll` instance. +/// +/// Once loaded, the library and its `FILE_SHARE_READ` lock handle stay open for the lifetime of +/// the process so internal threads/state inside the agent DLL remain valid and the verified file +/// on disk cannot be modified, deleted, or renamed. +pub(super) struct AgentLibrary { + exports: Exports, + verified_sha256: String, + _library: Option, + _lock_file: Option, +} + +impl fmt::Debug for AgentLibrary { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AgentLibrary") + .field("verified_sha256", &self.verified_sha256) + .finish_non_exhaustive() + } +} + +pub(super) enum Event<'a> { + Stdout(&'a [u8]), + Stderr(&'a [u8]), + Exit(&'a [u8]), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct SetupConfig { + pub schema_version: u32, + pub state_root: PathBuf, + pub runtime: RuntimeSource, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct RuntimeSource { + pub bundle: BundleSource, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct BundleSource { + pub path: PathBuf, + pub manifest_sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct HostInfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_image: Option, + pub runtime_digests: RuntimeDigests, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct RuntimeDigests { + pub initrd: String, + pub kernel: String, + pub openvmm: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct ProvisionRequest {} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct SandboxSpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image: Option, +} + +impl SandboxSpec { + pub fn is_empty(&self) -> bool { + self.image.is_none() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) enum ImageSource { + Reference(String), +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct ProvisionInput { + pub request: ProvisionRequest, + #[serde(default, skip_serializing_if = "SandboxSpec::is_empty")] + pub spec: SandboxSpec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct ProvisionReply { + pub token: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct StartResult { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct StopResult { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct DeprovisionResult { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct ExecInput { + pub request: ExecRequest, + #[serde(default)] + pub stdin: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct ExecRequest { + pub process: ProcessSpec, +} + +impl ExecRequest { + pub fn command_line(command_line: impl Into) -> Self { + Self { + process: ProcessSpec { + command_line: command_line.into(), + cwd: None, + env: None, + inherit_default_env: None, + timeout: None, + }, + } + } + + pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self { + self.process.timeout = Some(timeout_ms); + self + } + + pub fn with_cwd(mut self, cwd: impl Into) -> Self { + self.process.cwd = Some(cwd.into()); + self + } + + pub fn with_envs(mut self, env: Vec) -> Self { + self.process.env = Some(env); + self + } + + pub fn with_inherit_default_env(mut self, inherit: bool) -> Self { + self.process.inherit_default_env = Some(inherit); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct ProcessSpec { + pub command_line: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inherit_default_env: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) enum ExecEnd { + Outcome(ExecOutcome), + Error(ErrorBody), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) enum ExecOutcome { + Exited(i32), + Signaled(i32), + TimedOut, + Cancelled, + Failed(ExecFailure), +} + +impl fmt::Display for ExecOutcome { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Exited(code) => write!(formatter, "exited with status {code}"), + Self::Signaled(signal) => write!(formatter, "terminated by signal {signal}"), + Self::TimedOut => formatter.write_str("timed out"), + Self::Cancelled => formatter.write_str("cancelled"), + Self::Failed(failure) => write!(formatter, "failed: {failure}"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) enum ExecFailure { + LaunchFailed, + WorkingDirectory, + OutputLimitExceeded, + Workload, +} + +impl fmt::Display for ExecFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::LaunchFailed => "the workload could not be launched", + Self::WorkingDirectory => { + "the workload's working directory does not exist, is not a directory, or is not \ + accessible to the workload" + } + Self::OutputLimitExceeded => "the workload exceeded the output limit", + Self::Workload => "the workload's exit status could not be determined", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum WireErrorCode { + MalformedRequest, + MalformedId, + StaleId, + NotStarted, + AlreadyStarted, + AlreadyStopped, + PolicyValidation, + BackendUnavailable, + Unsupported, + BackendError, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct ErrorBody { + pub code: WireErrorCode, + pub message: String, +} + +pub(super) fn map_error_body(body: ErrorBody) -> MxcError { + let code = match body.code { + WireErrorCode::MalformedRequest => MxcErrorCode::MalformedRequest, + WireErrorCode::MalformedId => MxcErrorCode::MalformedId, + WireErrorCode::StaleId => MxcErrorCode::StaleId, + WireErrorCode::NotStarted => MxcErrorCode::NotStarted, + WireErrorCode::AlreadyStarted => MxcErrorCode::AlreadyStarted, + WireErrorCode::AlreadyStopped => MxcErrorCode::AlreadyStopped, + WireErrorCode::PolicyValidation => MxcErrorCode::PolicyValidation, + WireErrorCode::BackendUnavailable => MxcErrorCode::BackendUnavailable, + WireErrorCode::Unsupported | WireErrorCode::BackendError => MxcErrorCode::BackendError, + }; + MxcError::new(code, format!("NVX signed bundled agent: {}", body.message)) +} + +fn error_from_body(bytes: Option<&[u8]>) -> MxcError { + match bytes.map(serde_json::from_slice::) { + Some(Ok(body)) => map_error_body(body), + _ => MxcError::backend_error( + "NVX signed bundled agent: the agent library reported an error without a valid description", + ), + } +} + +fn status_error(status: i32) -> MxcError { + let detail = match status { + STATUS_INVALID_HANDLE => { + "the agent library rejected a released or unknown handle".to_owned() + } + STATUS_INVALID_ARGUMENT => "the agent library rejected an argument".to_owned(), + STATUS_PANIC => "the agent library panicked; the operation's effect is unknown".to_owned(), + other => format!("the agent library returned the unexpected status {other}"), + }; + MxcError::backend_error(format!("NVX signed bundled agent: {detail}")) +} + +fn encode_json(value: &impl Serialize) -> Result, MxcError> { + serde_json::to_vec(value).map_err(|error| { + MxcError::malformed_request(format!( + "NVX signed bundled agent: cannot encode request: {error}" + )) + }) +} + +fn decode_json(bytes: Option>, what: &str) -> Result { + let bytes = bytes.ok_or_else(|| { + MxcError::backend_error(format!( + "NVX signed bundled agent: the agent library returned no {what}" + )) + })?; + serde_json::from_slice(&bytes).map_err(|error| { + MxcError::backend_error(format!( + "NVX signed bundled agent: the agent library returned a malformed {what}: {error}" + )) + }) +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// Opens `path` for read with `FILE_SHARE_READ` (`1`) only, preventing any concurrent writer, +/// deletion, or rename of the DLL or its parent directories while the handle remains open. +pub(super) fn open_for_load(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_SHARE_READ: u32 = 1; + options.share_mode(FILE_SHARE_READ); + } + options.open(path) +} + +fn resolve_export(library: &LoadedLibrary, name: &[u8]) -> Result { + unsafe { library.get::(name) } + .map(|symbol| *symbol) + .map_err(|error| { + MxcError::backend_unavailable(format!( + "NVX signed bundled agent: the agent library is missing required export {}: {error}", + String::from_utf8_lossy(name).trim_end_matches('\0') + )) + }) +} + +impl AgentLibrary { + pub fn load_verified(path: &Path, expected_sha256: &str) -> Result<&'static Self, MxcError> { + type LoadedMap = Mutex>; + static LOADED: OnceLock = OnceLock::new(); + + if !path.is_absolute() { + return Err(MxcError::backend_unavailable( + "NVX signed bundled agent: the agent library path must be absolute", + )); + } + + // Open and lock the DLL with FILE_SHARE_READ before canonicalizing, hashing, or mapping it + // so that no writer or rename can swap the file between verification and LoadLibraryW. + let lock_file = open_for_load(path).map_err(|error| { + MxcError::backend_unavailable(format!( + "NVX signed bundled agent: cannot verify the agent library at {}: {error}", + path.display() + )) + })?; + let canonical = fs::canonicalize(path).map_err(|error| { + MxcError::backend_unavailable(format!( + "NVX signed bundled agent: cannot locate the agent library at {}: {error}", + path.display() + )) + })?; + + let cache = LOADED.get_or_init(Default::default); + let mut loaded = cache.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(&existing) = loaded.get(&canonical) { + if !existing + .verified_sha256 + .eq_ignore_ascii_case(expected_sha256) + { + return Err(MxcError::backend_unavailable(format!( + "NVX signed bundled agent: the agent library at {} does not match the approved SHA-256", + path.display() + ))); + } + return Ok(existing); + } + + let instance = Self::open_locked(&canonical, lock_file, expected_sha256)?; + let leaked: &'static Self = Box::leak(Box::new(instance)); + loaded.insert(canonical, leaked); + Ok(leaked) + } + + fn open_locked( + path: &Path, + mut lock_file: File, + expected_sha256: &str, + ) -> Result { + let mut bytes = Vec::new(); + lock_file.read_to_end(&mut bytes).map_err(|error| { + MxcError::backend_unavailable(format!( + "NVX signed bundled agent: cannot read the agent library at {}: {error}", + path.display() + )) + })?; + let actual_sha256 = sha256_hex(&bytes); + if !actual_sha256.eq_ignore_ascii_case(expected_sha256) { + return Err(MxcError::backend_unavailable(format!( + "NVX signed bundled agent: the agent library at {} does not match the approved SHA-256", + path.display() + ))); + } + + let library = unsafe { LoadedLibrary::new(path) }.map_err(|error| { + MxcError::backend_unavailable(format!( + "NVX signed bundled agent: cannot load the agent library from {}: {error}", + path.display() + )) + })?; + + let version: VersionFn = resolve_export(&library, b"aci_edge_sandbox_api_version\0")?; + let actual_version = unsafe { version() }; + if actual_version != SANDBOX_ABI_VERSION { + return Err(MxcError::backend_unavailable(format!( + "NVX signed bundled agent: the agent library's sandbox ABI is {actual_version}, expected {SANDBOX_ABI_VERSION}" + ))); + } + + let exports = Exports { + host_open: resolve_export(&library, b"aci_edge_sandbox_host_open\0")?, + host_close: resolve_export(&library, b"aci_edge_sandbox_host_close\0")?, + host_info: resolve_export(&library, b"aci_edge_sandbox_host_info\0")?, + capabilities: resolve_export(&library, b"aci_edge_sandbox_capabilities\0")?, + probe: resolve_export(&library, b"aci_edge_sandbox_probe\0")?, + validate_provision: resolve_export(&library, b"aci_edge_sandbox_validate_provision\0")?, + validate_exec: resolve_export(&library, b"aci_edge_sandbox_validate_exec\0")?, + provision: resolve_export(&library, b"aci_edge_sandbox_provision\0")?, + start: resolve_export(&library, b"aci_edge_sandbox_start\0")?, + stop: resolve_export(&library, b"aci_edge_sandbox_stop\0")?, + deprovision: resolve_export(&library, b"aci_edge_sandbox_deprovision\0")?, + diagnostics: resolve_export(&library, b"aci_edge_sandbox_diagnostics\0")?, + guest_logs: resolve_export(&library, b"aci_edge_sandbox_guest_logs\0")?, + exec: resolve_export(&library, b"aci_edge_sandbox_exec\0")?, + exec_next: resolve_export(&library, b"aci_edge_exec_next\0")?, + exec_write: resolve_export(&library, b"aci_edge_exec_write\0")?, + exec_close_input: resolve_export(&library, b"aci_edge_exec_close_input\0")?, + exec_cancel: resolve_export(&library, b"aci_edge_exec_cancel\0")?, + exec_release: resolve_export(&library, b"aci_edge_exec_release\0")?, + image_register: resolve_export(&library, b"aci_edge_image_register\0")?, + image_list: resolve_export(&library, b"aci_edge_image_list\0")?, + image_verify: resolve_export(&library, b"aci_edge_image_verify\0")?, + image_unregister: resolve_export(&library, b"aci_edge_image_unregister\0")?, + buffer_free: resolve_export(&library, b"aci_edge_buffer_free\0")?, + }; + + Ok(Self { + exports, + verified_sha256: actual_sha256, + _library: Some(library), + _lock_file: Some(lock_file), + }) + } + + #[cfg(test)] + pub(super) const fn from_exports_for_test(exports: Exports) -> Self { + Self { + exports, + verified_sha256: String::new(), + _library: None, + _lock_file: None, + } + } + + fn take_buffer(&self, buffer: *mut u8, length: usize) -> Result>, MxcError> { + if buffer.is_null() { + if length == 0 { + return Ok(None); + } + return Err(MxcError::backend_error( + "NVX signed bundled agent: the agent library returned a null buffer with a non-zero length", + )); + } + let bytes = if length == 0 { + Vec::new() + } else { + // SAFETY: `buffer` and `length` were returned by the agent DLL and have not been freed. + unsafe { slice::from_raw_parts(buffer, length) }.to_vec() + }; + unsafe { (self.exports.buffer_free)(buffer, length) }; + Ok(Some(bytes)) + } + + fn call( + &self, + invoke: impl FnOnce(*mut *mut u8, *mut usize) -> i32, + ) -> Result>, MxcError> { + let mut buffer = ptr::null_mut(); + let mut length = 0; + let status = invoke(&mut buffer, &mut length); + let bytes = self.take_buffer(buffer, length)?; + match status { + STATUS_OK => Ok(bytes), + STATUS_ERROR => Err(error_from_body(bytes.as_deref())), + other => Err(status_error(other)), + } + } + + fn call_empty( + &self, + operation: &str, + invoke: impl FnOnce(*mut *mut u8, *mut usize) -> i32, + ) -> Result<(), MxcError> { + match self.call(invoke)? { + None => Ok(()), + Some(_) => Err(MxcError::backend_error(format!( + "NVX signed bundled agent: the agent library returned an unexpected {operation} payload" + ))), + } + } + + fn query(&self, export: QueryFn, handle: u64) -> Result>, MxcError> { + self.call(|out, length| unsafe { export(handle, out, length) }) + } + + fn with_input( + &self, + export: CallFn, + host: u64, + input: &[u8], + ) -> Result>, MxcError> { + self.call(|out, length| unsafe { export(host, input.as_ptr(), input.len(), out, length) }) + } + + pub fn host_open(&self, setup: &SetupConfig) -> Result { + let encoded = encode_json(setup)?; + let mut host = 0u64; + self.call_empty("host_open", |out, length| unsafe { + (self.exports.host_open)(encoded.as_ptr(), encoded.len(), &mut host, out, length) + })?; + if host == 0 { + return Err(MxcError::backend_error( + "NVX signed bundled agent: the agent library returned an invalid zero host handle", + )); + } + Ok(host) + } + + pub fn host_close(&self, host: u64) -> Result<(), MxcError> { + let status = unsafe { (self.exports.host_close)(host) }; + match status { + STATUS_OK => Ok(()), + other => Err(status_error(other)), + } + } + + pub fn host_info(&self, host: u64) -> Result { + decode_json( + self.query(self.exports.host_info, host)?, + "host description", + ) + } + + pub fn probe(&self, host: u64) -> Result<(), MxcError> { + self.call_empty("probe", |out, length| unsafe { + (self.exports.probe)(host, out, length) + }) + } + + pub fn provision(&self, host: u64, input: &ProvisionInput) -> Result { + let encoded = encode_json(input)?; + let reply = self.with_input(self.exports.provision, host, &encoded)?; + decode_json(reply, "provision result") + } + + pub fn start(&self, host: u64, token: &str) -> Result { + decode_json( + self.with_input(self.exports.start, host, token.as_bytes())?, + "start result", + ) + } + + pub fn stop(&self, host: u64, token: &str) -> Result { + decode_json( + self.with_input(self.exports.stop, host, token.as_bytes())?, + "stop result", + ) + } + + pub fn deprovision(&self, host: u64, token: &str) -> Result { + decode_json( + self.with_input(self.exports.deprovision, host, token.as_bytes())?, + "deprovision result", + ) + } + + pub fn exec(&self, host: u64, token: &str, input: &ExecInput) -> Result { + let encoded = encode_json(input)?; + let mut exec_handle = 0u64; + self.call_empty("exec", |out, length| unsafe { + (self.exports.exec)( + host, + token.as_ptr(), + token.len(), + encoded.as_ptr(), + encoded.len(), + &mut exec_handle, + out, + length, + ) + })?; + if exec_handle == 0 { + return Err(MxcError::backend_error( + "NVX signed bundled agent: the agent library returned an invalid zero execution handle", + )); + } + Ok(exec_handle) + } + + pub fn exec_next( + &self, + exec: u64, + timeout_ms: Option, + deliver: impl FnOnce(Event<'_>) -> T, + ) -> Result, MxcError> { + let mut event = ExecEvent { + kind: 0, + data: ptr::null(), + length: 0, + }; + let timeout = timeout_ms.map_or(-1, |ms| i64::try_from(ms).unwrap_or(i64::MAX)); + match unsafe { (self.exports.exec_next)(exec, timeout, &mut event) } { + STATUS_OK => {} + STATUS_TIMEOUT => return Ok(None), + other => return Err(status_error(other)), + } + let data = if event.length == 0 { + &[][..] + } else if event.data.is_null() { + return Err(MxcError::backend_error( + "NVX signed bundled agent: the agent library returned an execution event whose data is missing", + )); + } else { + // SAFETY: the agent DLL keeps the event buffer valid until the next call on `exec`. + unsafe { slice::from_raw_parts(event.data, event.length) } + }; + match event.kind { + EVENT_STDOUT => Ok(Some(deliver(Event::Stdout(data)))), + EVENT_STDERR => Ok(Some(deliver(Event::Stderr(data)))), + EVENT_EXIT => Ok(Some(deliver(Event::Exit(data)))), + other => Err(MxcError::backend_error(format!( + "NVX signed bundled agent: the agent library returned the unknown execution event {other}" + ))), + } + } + + pub fn exec_cancel(&self, exec: u64) -> Result<(), MxcError> { + self.call_empty("exec_cancel", |out, length| unsafe { + (self.exports.exec_cancel)(exec, out, length) + }) + } + + pub fn exec_release(&self, exec: u64) -> Result<(), MxcError> { + let status = unsafe { (self.exports.exec_release)(exec) }; + match status { + STATUS_OK => Ok(()), + other => Err(status_error(other)), + } + } +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + + #[test] + fn open_for_load_blocks_writers_deletes_and_renames() { + let root = tempfile::tempdir().unwrap(); + let directory = root.path().join("agent"); + fs::create_dir(&directory).unwrap(); + let dll_path = directory.join("aci_edge_agent.dll"); + fs::write(&dll_path, b"verified-dll-bytes").unwrap(); + + let lock_file = open_for_load(&dll_path).unwrap(); + assert!(OpenOptions::new().write(true).open(&dll_path).is_err()); + assert!(fs::remove_file(&dll_path).is_err()); + assert!(fs::rename(&dll_path, directory.join("renamed.dll")).is_err()); + assert!(fs::rename(&directory, root.path().join("renamed-dir")).is_err()); + + drop(lock_file); + fs::rename(&directory, root.path().join("renamed-dir")).unwrap(); + } + + #[test] + fn load_verified_rejects_relative_missing_and_mismatched_digest_before_load() { + let relative = AgentLibrary::load_verified(Path::new("relative.dll"), &"0".repeat(64)); + assert_eq!(relative.unwrap_err().code, MxcErrorCode::BackendUnavailable); + + let root = tempfile::tempdir().unwrap(); + let missing = root.path().join("missing.dll"); + assert_eq!( + AgentLibrary::load_verified(&missing, &"0".repeat(64)) + .unwrap_err() + .code, + MxcErrorCode::BackendUnavailable + ); + + let fake_dll = root.path().join("not_a_pe.dll"); + fs::write(&fake_dll, b"not a valid PE image").unwrap(); + let err = AgentLibrary::load_verified(&fake_dll, &"0".repeat(64)).unwrap_err(); + assert_eq!(err.code, MxcErrorCode::BackendUnavailable); + assert!(err.message.contains("does not match the approved SHA-256")); + } + + #[test] + fn wire_types_round_trip_with_exact_contract_casing() { + let setup = SetupConfig { + schema_version: 1, + state_root: PathBuf::from(r"C:\state"), + runtime: RuntimeSource { + bundle: BundleSource { + path: PathBuf::from(r"C:\bundle"), + manifest_sha256: "d5".repeat(32), + }, + }, + }; + let setup_json = serde_json::to_value(&setup).unwrap(); + assert_eq!(setup_json["schemaVersion"], 1); + assert_eq!(setup_json["stateRoot"], r"C:\state"); + assert_eq!(setup_json["runtime"]["bundle"]["path"], r"C:\bundle"); + assert_eq!( + setup_json["runtime"]["bundle"]["manifestSha256"], + "d5".repeat(32) + ); + + let prov = ProvisionInput { + request: ProvisionRequest {}, + spec: SandboxSpec { + image: Some(ImageSource::Reference( + "mcr.microsoft.com/azurelinux/base/core:3.0".to_owned(), + )), + }, + }; + assert_eq!( + serde_json::to_string(&prov).unwrap(), + r#"{"request":{},"spec":{"image":{"reference":"mcr.microsoft.com/azurelinux/base/core:3.0"}}}"# + ); + + for (outcome, text) in [ + (ExecOutcome::Exited(42), r#"{"exited":42}"#), + (ExecOutcome::Signaled(9), r#"{"signaled":9}"#), + (ExecOutcome::TimedOut, r#""timedOut""#), + (ExecOutcome::Cancelled, r#""cancelled""#), + ( + ExecOutcome::Failed(ExecFailure::WorkingDirectory), + r#"{"failed":"workingDirectory"}"#, + ), + ] { + assert_eq!(serde_json::to_string(&outcome).unwrap(), text); + assert_eq!(serde_json::from_str::(text).unwrap(), outcome); + } + } +} diff --git a/src/mxc-sdk/src/backends/nvx/mod.rs b/src/mxc-sdk/src/backends/nvx/mod.rs index cce5cd4761..787470eda7 100644 --- a/src/mxc-sdk/src/backends/nvx/mod.rs +++ b/src/mxc-sdk/src/backends/nvx/mod.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod abi; mod policy; pub mod runner; mod runtime; diff --git a/src/mxc-sdk/src/backends/nvx/runner.rs b/src/mxc-sdk/src/backends/nvx/runner.rs index b405d36ce3..805dde7c97 100644 --- a/src/mxc-sdk/src/backends/nvx/runner.rs +++ b/src/mxc-sdk/src/backends/nvx/runner.rs @@ -4,9 +4,6 @@ //! Minimal create-and-run adapter for the signed bundled NVX agent. use std::io::Write; -use std::time::Duration; - -use aci_edge_sandboxes::{ExecOutcome, ExecRequest, ProvisionRequest}; use crate::mxc_common::audit::{AuditEvent, AuditEventName}; use crate::mxc_common::logger::Logger; @@ -14,6 +11,9 @@ use crate::mxc_common::models::{ExecutionRequest, FailurePhase, ScriptResponse}; use crate::mxc_common::mxc_error::{MxcError, MxcErrorCode}; use crate::mxc_common::script_runner::ScriptRunner; +use super::abi::{ExecOutcome, ExecRequest}; +use super::runtime::SandboxClient; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CapturedOutcome { Exited(i32), @@ -50,16 +50,8 @@ impl NvxRunner { "cannot create a private NVX state directory: {error}" )) })?; - let prepared = (|| { - let client = super::runtime::create_client(state_root.path())?; - client.probe().map_err(super::runtime::map_nvx_error)?; - let provisioned = client - .provision_with(&ProvisionRequest::new(), &super::runtime::sandbox_spec()) - .map_err(super::runtime::map_nvx_error)?; - Ok((client, provisioned.sandbox_id)) - })(); - let (client, sandbox_id) = match prepared { - Ok(prepared) => prepared, + let mut client = match super::runtime::create_client(state_root.path()) { + Ok(client) => client, Err(error) => { let cleanup = state_root .close() @@ -72,76 +64,88 @@ impl NvxRunner { return finish_run(Err(error), cleanup, logger); } }; - let identity = sandbox_id.to_string(); - logger.log_audit_event( - &AuditEvent::new(AuditEventName::SandboxIdentity) - .str("identity", &identity) - .str("backend", "microvm") - .str("runtime", "signed-bundled-agent"), - ); - let primary = (|| { - client - .start(&sandbox_id) - .map_err(super::runtime::map_nvx_error)?; - client - .exec(&sandbox_id, &exec_request(request)) - .map_err(super::runtime::map_nvx_error)? - .wait_with_output() - .map_err(super::runtime::map_nvx_error) - })(); - - let stop_error = client - .stop(&sandbox_id) - .err() - .map(super::runtime::map_nvx_error); - let stop_error = match stop_error { - Some(error) if primary.is_err() && error.code == MxcErrorCode::AlreadyStopped => None, - other => other, - }; - let deprovision_error = client - .deprovision(&sandbox_id) - .err() - .map(super::runtime::map_nvx_error); - let retain_state = deprovision_error.is_some(); - let mut cleanup_errors: Vec = [stop_error, deprovision_error] - .into_iter() - .flatten() - .collect(); - drop(client); - if retain_state { - let retained = state_root.keep(); - cleanup_errors.push(MxcError::backend_error(format!( - "NVX state retained for recovery after deprovision failure: {}", - retained.display() - ))); - } else if let Err(error) = state_root.close() { - cleanup_errors.push(MxcError::backend_error(format!( - "cannot remove NVX state directory: {error}" - ))); + run_with_client(&mut client, state_root, request, logger) + } +} + +fn run_with_client( + client: &mut impl SandboxClient, + state_root: tempfile::TempDir, + request: &ExecutionRequest, + logger: &mut Logger, +) -> Result { + let sandbox_id = match client.probe().and_then(|()| client.provision()) { + Ok(sandbox_id) => sandbox_id, + Err(error) => { + let mut cleanup = Vec::new(); + if let Err(close_error) = client.close() { + cleanup.push(close_error); + } + if let Err(remove_error) = state_root.close() { + cleanup.push(MxcError::backend_error(format!( + "cannot remove NVX state: {remove_error}" + ))); + } + return finish_run(Err(error), cleanup, logger); } - logger.log_audit_event( - &AuditEvent::new(AuditEventName::SandboxTornDown) - .str("identity", &identity) - .str("backend", "microvm") - .str( - "status", - if cleanup_errors.is_empty() { - "ok" - } else { - "failed" - }, - ) - .bool("state_retained", retain_state), - ); - let output = finish_run(primary, cleanup_errors, logger)?; - - Ok(CapturedRun { - outcome: map_outcome(output.outcome)?, - warnings: Vec::new(), - stdout: output.stdout, - stderr: output.stderr, - }) + }; + + let identity = sandbox_id.to_string(); + logger.log_audit_event( + &AuditEvent::new(AuditEventName::SandboxIdentity) + .str("identity", &identity) + .str("backend", "microvm") + .str("runtime", "signed-bundled-agent"), + ); + let primary = (|| { + client.start(&sandbox_id)?; + client.exec_and_wait(&sandbox_id, &exec_request(request)) + })(); + + let stop_error = match client.stop(&sandbox_id).err() { + Some(error) if primary.is_err() && error.code == MxcErrorCode::AlreadyStopped => None, + other => other, + }; + let deprovision_error = client.deprovision(&sandbox_id).err(); + let retain_state = deprovision_error.is_some(); + let close_error = client.close().err(); + let mut cleanup_errors: Vec = [stop_error, deprovision_error, close_error] + .into_iter() + .flatten() + .collect(); + if retain_state { + let retained = state_root.keep(); + cleanup_errors.push(MxcError::backend_error(format!( + "NVX state retained for recovery after deprovision failure: {}", + retained.display() + ))); + } else if let Err(error) = state_root.close() { + cleanup_errors.push(MxcError::backend_error(format!( + "cannot remove NVX state directory: {error}" + ))); } + logger.log_audit_event( + &AuditEvent::new(AuditEventName::SandboxTornDown) + .str("identity", &identity) + .str("backend", "microvm") + .str( + "status", + if cleanup_errors.is_empty() { + "ok" + } else { + "failed" + }, + ) + .bool("state_retained", retain_state), + ); + let output = finish_run(primary, cleanup_errors, logger)?; + + Ok(CapturedRun { + outcome: map_outcome(output.outcome)?, + warnings: Vec::new(), + stdout: output.stdout, + stderr: output.stderr, + }) } fn finish_run( @@ -246,7 +250,7 @@ fn captured_to_script_response( fn exec_request(request: &ExecutionRequest) -> ExecRequest { let mut exec = ExecRequest::command_line(request.script_code.clone()); if request.script_timeout != 0 { - exec = exec.with_timeout(Duration::from_millis(u64::from(request.script_timeout))); + exec = exec.with_timeout_ms(u64::from(request.script_timeout)); } if !request.working_directory.is_empty() { exec = exec.with_cwd(request.working_directory.clone()); @@ -259,6 +263,7 @@ fn exec_request(request: &ExecutionRequest) -> ExecRequest { #[cfg(test)] mod tests { + use super::super::runtime::{ExecOutput, SandboxId}; use super::*; #[test] @@ -412,11 +417,8 @@ mod tests { ..Default::default() }; let mapped = exec_request(&request); - assert_eq!( - mapped.process.command, - aci_edge_sandboxes::Command::CommandLine(request.script_code) - ); - assert_eq!(mapped.process.timeout, Some(Duration::from_millis(250))); + assert_eq!(mapped.process.command_line, request.script_code); + assert_eq!(mapped.process.timeout, Some(250)); assert_eq!(mapped.process.cwd.as_deref(), Some("/tmp")); assert_eq!(mapped.process.env, Some(Vec::new())); assert_eq!(mapped.process.inherit_default_env, Some(true)); @@ -426,4 +428,130 @@ mod tests { assert_eq!(defaults.process.timeout, None); assert_eq!(defaults.process.cwd, None); } + + struct MockClient { + calls: Vec<&'static str>, + probe_res: Result<(), MxcError>, + provision_res: Result, + start_res: Result<(), MxcError>, + exec_res: Result, + stop_res: Result<(), MxcError>, + deprovision_res: Result<(), MxcError>, + close_res: Result<(), MxcError>, + } + + impl MockClient { + fn ok() -> Self { + Self { + calls: Vec::new(), + probe_res: Ok(()), + provision_res: SandboxId::from_token("0123456789abcdef0123456789abcdef"), + start_res: Ok(()), + exec_res: Ok(ExecOutput { + outcome: ExecOutcome::Exited(0), + stdout: b"ok".to_vec(), + stderr: Vec::new(), + }), + stop_res: Ok(()), + deprovision_res: Ok(()), + close_res: Ok(()), + } + } + } + + impl SandboxClient for MockClient { + fn probe(&self) -> Result<(), MxcError> { + self.probe_res.clone() + } + + fn provision(&self) -> Result { + self.provision_res.clone() + } + + fn start(&self, _sandbox_id: &SandboxId) -> Result<(), MxcError> { + self.start_res.clone() + } + + fn exec_and_wait( + &self, + _sandbox_id: &SandboxId, + _request: &ExecRequest, + ) -> Result { + self.exec_res.clone() + } + + fn stop(&self, _sandbox_id: &SandboxId) -> Result<(), MxcError> { + self.stop_res.clone() + } + + fn deprovision(&self, _sandbox_id: &SandboxId) -> Result<(), MxcError> { + self.deprovision_res.clone() + } + + fn close(&mut self) -> Result<(), MxcError> { + self.calls.push("close"); + self.close_res.clone() + } + } + + #[test] + fn lifecycle_cleanup_surfaces_host_close_and_retains_state_on_deprovision_failure() { + let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); + let request = ExecutionRequest { + script_code: "echo ok".to_owned(), + ..Default::default() + }; + + // 1. Probe failure still closes host and surfaces close failure. + let state_root = tempfile::tempdir().unwrap(); + let state_path = state_root.path().to_path_buf(); + let mut client = MockClient::ok(); + client.probe_res = Err(MxcError::backend_unavailable("probe failed")); + client.close_res = Err(MxcError::backend_error("host_close failed")); + let err = run_with_client(&mut client, state_root, &request, &mut logger).unwrap_err(); + assert_eq!(err.code, MxcErrorCode::BackendUnavailable); + assert!(err.message.contains("probe failed")); + assert!(err.message.contains("host_close failed")); + assert_eq!(client.calls, ["close"]); + assert!(!state_path.exists()); + + // 2. Start failure suppresses AlreadyStopped on stop, deprovisions, closes host, removes state. + let state_root = tempfile::tempdir().unwrap(); + let state_path = state_root.path().to_path_buf(); + let mut client = MockClient::ok(); + client.start_res = Err(MxcError::backend_error("start failed")); + client.stop_res = Err(MxcError::new( + MxcErrorCode::AlreadyStopped, + "already stopped", + )); + let err = run_with_client(&mut client, state_root, &request, &mut logger).unwrap_err(); + assert_eq!(err.message, "start failed"); + assert_eq!(client.calls, ["close"]); + assert!(!state_path.exists()); + + // 3. Host close failure after workload success fails the run. + let state_root = tempfile::tempdir().unwrap(); + let state_path = state_root.path().to_path_buf(); + let mut client = MockClient::ok(); + client.close_res = Err(MxcError::backend_error("host_close failed")); + let err = run_with_client(&mut client, state_root, &request, &mut logger).unwrap_err(); + assert!(err + .message + .contains("NVX cleanup failed after workload completion")); + assert!(err.message.contains("host_close failed")); + assert!(!state_path.exists()); + + // 4. Deprovision failure retains state directory and fails the run. + let state_root = tempfile::tempdir().unwrap(); + let state_path = state_root.path().to_path_buf(); + let mut client = MockClient::ok(); + client.deprovision_res = Err(MxcError::backend_error("deprovision failed")); + let err = run_with_client(&mut client, state_root, &request, &mut logger).unwrap_err(); + assert!(err.message.contains("deprovision failed")); + assert!(err + .message + .contains("NVX state retained for recovery after deprovision failure")); + assert!(state_path.exists()); + std::fs::remove_dir_all(&state_path).unwrap(); + } } diff --git a/src/mxc-sdk/src/backends/nvx/runtime.rs b/src/mxc-sdk/src/backends/nvx/runtime.rs index becf01d749..630d74881b 100644 --- a/src/mxc-sdk/src/backends/nvx/runtime.rs +++ b/src/mxc-sdk/src/backends/nvx/runtime.rs @@ -1,41 +1,901 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use std::path::Path; +use std::env; +use std::ffi::OsString; +use std::fmt; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; -use aci_edge_sandboxes::agent::AgentConfig; -use aci_edge_sandboxes::AciEdgeSandbox; -use aci_edge_sandboxes::{ImageSource, SandboxSpec}; +use crate::mxc_common::mxc_error::MxcError; -use crate::mxc_common::mxc_error::{MxcError, MxcErrorCode}; +use super::abi::{ + map_error_body, AgentLibrary, BundleSource, Event, ExecEnd, ExecInput, ExecOutcome, + ExecRequest, HostInfo, ImageSource, ProvisionInput, ProvisionRequest, RuntimeSource, + SandboxSpec, SetupConfig, +}; -pub(super) fn create_client(state_root: &Path) -> Result { - let config = AgentConfig::bundled(state_root).map_err(map_nvx_error)?; - AciEdgeSandbox::agent(config).map_err(map_nvx_error) +pub(super) const ARTIFACTS_DIR_ENV: &str = "ACI_EDGE_SANDBOXES_AGENT_ARTIFACTS_DIR"; +pub(super) const BESIDE_EXE_DIR: &str = "aci-edge-sandboxes"; +pub(super) const SOURCE_MANIFEST: &str = "SOURCE-MANIFEST.json"; + +pub(super) const APPROVED_MANIFEST_SHA256: &str = + "d5487737b07d663d983373dec82d917bbfcab58e39fed45b6642e8885c3fb01b"; +pub(super) const APPROVED_DLL_SHA256: &str = + "a203e58b97a642bd4a68824c0387965541895b25582e18db6f55e28947f5f135"; +pub(super) const APPROVED_OPENVMM_SHA256: &str = + "697f8221e62a1ea4efb3846fefde6f595c89f4f81c0153a2609bf6fb4c81ea9e"; +pub(super) const APPROVED_KERNEL_SHA256: &str = + "b36ab45b22d1c14438047b24c3724f54cc71dd03601d0f3cbdca11438f40e768"; +pub(super) const APPROVED_INITRD_SHA256: &str = + "5e42d4617c1508e9116df8c330c0ff72243e954d7f132091ecdecfc743d904ed"; + +const DEFAULT_IMAGE_REFERENCE: &str = "mcr.microsoft.com/azurelinux/base/core:3.0"; +const HOST_TIMEOUT_SLACK_MS: u64 = 30_000; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct SandboxId { + full: String, +} + +impl SandboxId { + const PREFIX: &'static str = "aci-edge-sandboxes"; + const TOKEN_LEN: usize = 32; + + pub fn from_token(token: &str) -> Result { + if token.len() != Self::TOKEN_LEN + || !token + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(MxcError::backend_error( + "NVX signed bundled agent: the agent library returned a malformed sandbox token", + )); + } + Ok(Self { + full: format!("{}:{token}", Self::PREFIX), + }) + } + + pub fn token(&self) -> &str { + &self.full[Self::PREFIX.len() + 1..] + } +} + +impl fmt::Display for SandboxId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.full) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ExecOutput { + pub outcome: ExecOutcome, + pub stdout: Vec, + pub stderr: Vec, +} + +pub(super) trait SandboxClient { + fn probe(&self) -> Result<(), MxcError>; + fn provision(&self) -> Result; + fn start(&self, sandbox_id: &SandboxId) -> Result<(), MxcError>; + fn exec_and_wait( + &self, + sandbox_id: &SandboxId, + request: &ExecRequest, + ) -> Result; + fn stop(&self, sandbox_id: &SandboxId) -> Result<(), MxcError>; + fn deprovision(&self, sandbox_id: &SandboxId) -> Result<(), MxcError>; + fn close(&mut self) -> Result<(), MxcError>; +} + +#[derive(Debug)] +pub(super) struct NvxClient { + library: &'static AgentLibrary, + host: u64, + closed: bool, +} + +impl NvxClient { + pub(super) fn from_library(library: &'static AgentLibrary, host: u64) -> Self { + Self { + library, + host, + closed: false, + } + } +} + +impl Drop for NvxClient { + fn drop(&mut self) { + if !self.closed { + self.closed = true; + let _ = self.library.host_close(self.host); + } + } +} + +pub(super) fn create_client(state_root: &Path) -> Result { + let bundle_dir = discover_bundle_dir()?; + let dll_path = bundle_dir.join("bin").join("aci_edge_agent.dll"); + let library = AgentLibrary::load_verified(&dll_path, APPROVED_DLL_SHA256)?; + let setup = SetupConfig { + schema_version: 1, + state_root: state_root.to_path_buf(), + runtime: RuntimeSource { + bundle: BundleSource { + path: bundle_dir, + manifest_sha256: APPROVED_MANIFEST_SHA256.to_owned(), + }, + }, + }; + let host = library.host_open(&setup)?; + let mut client = NvxClient::from_library(library, host); + let info = match client.library.host_info(client.host) { + Ok(info) => info, + Err(mut error) => { + if let Err(close_error) = client.close() { + error.message.push_str("; cleanup failure: "); + error.message.push_str(&close_error.to_string()); + } + return Err(error); + } + }; + if let Err(mut error) = verify_runtime_digests(&info) { + if let Err(close_error) = client.close() { + error.message.push_str("; cleanup failure: "); + error.message.push_str(&close_error.to_string()); + } + return Err(error); + } + Ok(client) +} + +fn verify_runtime_digests(info: &HostInfo) -> Result<(), MxcError> { + for (name, actual, expected) in [ + ( + "openvmm", + &info.runtime_digests.openvmm, + APPROVED_OPENVMM_SHA256, + ), + ( + "kernel", + &info.runtime_digests.kernel, + APPROVED_KERNEL_SHA256, + ), + ( + "initrd", + &info.runtime_digests.initrd, + APPROVED_INITRD_SHA256, + ), + ] { + if !actual.eq_ignore_ascii_case(expected) { + return Err(MxcError::backend_unavailable(format!( + "NVX signed bundled agent: runtime digest mismatch for {name}: expected {expected}, got {actual}" + ))); + } + } + Ok(()) } pub(super) fn sandbox_spec() -> SandboxSpec { // The edge initramfs is the control guest, not a workload userland. Use the official // example's public image; its host-side pull does not grant the workload network access. - SandboxSpec::new().with_image(ImageSource::Reference( - "mcr.microsoft.com/azurelinux/base/core:3.0".to_owned(), - )) -} - -pub(super) fn map_nvx_error(error: aci_edge_sandboxes::Error) -> MxcError { - let code = match error.code() { - aci_edge_sandboxes::ErrorCode::MalformedRequest => MxcErrorCode::MalformedRequest, - aci_edge_sandboxes::ErrorCode::MalformedId => MxcErrorCode::MalformedId, - aci_edge_sandboxes::ErrorCode::StaleId => MxcErrorCode::StaleId, - aci_edge_sandboxes::ErrorCode::NotStarted => MxcErrorCode::NotStarted, - aci_edge_sandboxes::ErrorCode::AlreadyStarted => MxcErrorCode::AlreadyStarted, - aci_edge_sandboxes::ErrorCode::AlreadyStopped => MxcErrorCode::AlreadyStopped, - aci_edge_sandboxes::ErrorCode::PolicyValidation => MxcErrorCode::PolicyValidation, - aci_edge_sandboxes::ErrorCode::BackendUnavailable => MxcErrorCode::BackendUnavailable, - _ => MxcErrorCode::BackendError, + SandboxSpec { + image: Some(ImageSource::Reference(DEFAULT_IMAGE_REFERENCE.to_owned())), + } +} + +fn discover_bundle_dir() -> Result { + let discovered = discover_bundle_with(|name| env::var_os(name), env::current_exe)?; + std::path::absolute(&discovered).map_err(|error| { + MxcError::backend_unavailable(format!( + "NVX signed bundled agent: cannot resolve the agent bundle directory {}: {error}", + discovered.display() + )) + }) +} + +fn discover_bundle_with( + variable: impl Fn(&str) -> Option, + executable: impl FnOnce() -> io::Result, +) -> Result { + if let Some(directory) = variable(ARTIFACTS_DIR_ENV).filter(|value| !value.is_empty()) { + let directory = PathBuf::from(directory); + if directory.join(SOURCE_MANIFEST).is_file() { + return Ok(directory); + } + return Err(MxcError::backend_unavailable(format!( + "NVX signed bundled agent: {ARTIFACTS_DIR_ENV} names {}, which holds no agent bundle", + directory.display() + ))); + } + let executable = executable().map_err(|error| { + MxcError::backend_unavailable(format!( + "NVX signed bundled agent: cannot locate the current executable; set {ARTIFACTS_DIR_ENV}: {error}" + )) + })?; + let directory = executable.parent().ok_or_else(|| { + MxcError::backend_unavailable(format!( + "NVX signed bundled agent: {} is not in a directory; set {ARTIFACTS_DIR_ENV}", + executable.display() + )) + })?; + let found = [Some(directory), directory.parent()] + .into_iter() + .flatten() + .map(|candidate| candidate.join(BESIDE_EXE_DIR)) + .find(|candidate| candidate.join(SOURCE_MANIFEST).is_file()); + found.ok_or_else(|| { + MxcError::backend_unavailable(format!( + "NVX signed bundled agent: no agent bundle is beside {}: copy the {BESIDE_EXE_DIR} \ + directory that the build staged next to the executable or its directory, or set \ + {ARTIFACTS_DIR_ENV}", + directory.display() + )) + }) +} + +struct ExecGuard<'a> { + library: &'a AgentLibrary, + handle: Option, + cancel_on_drop: bool, +} + +impl<'a> ExecGuard<'a> { + fn new(library: &'a AgentLibrary, handle: u64) -> Self { + Self { + library, + handle: Some(handle), + cancel_on_drop: true, + } + } + + fn cancel_and_release(&mut self) -> Result<(), MxcError> { + let Some(handle) = self.handle.take() else { + return Ok(()); + }; + self.cancel_on_drop = false; + let cancel_error = self.library.exec_cancel(handle).err(); + let release_error = self.library.exec_release(handle).err(); + match (cancel_error, release_error) { + (None, None) => Ok(()), + (Some(mut cancel), Some(release)) => { + cancel.message.push_str("; release failure: "); + cancel.message.push_str(&release.to_string()); + Err(cancel) + } + (Some(error), None) | (None, Some(error)) => Err(error), + } + } + + fn release(&mut self) -> Result<(), MxcError> { + let Some(handle) = self.handle.take() else { + return Ok(()); + }; + self.cancel_on_drop = false; + self.library.exec_release(handle) + } +} + +impl Drop for ExecGuard<'_> { + fn drop(&mut self) { + if let Some(handle) = self.handle.take() { + if self.cancel_on_drop { + let _ = self.library.exec_cancel(handle); + } + let _ = self.library.exec_release(handle); + } + } +} + +fn remaining_timeout_ms(deadline: Option) -> Option { + let deadline = deadline?; + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return Some(0); }; - MxcError::new( - code, - format!("NVX signed bundled agent: {}", error.message()), - ) + let millis = u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX); + if millis == 0 && !remaining.is_zero() { + Some(1) + } else { + Some(millis) + } +} + +fn append_cleanup_error(primary: &mut MxcError, cleanup: MxcError) { + primary.message.push_str("; cleanup failure: "); + primary.message.push_str(&cleanup.to_string()); +} + +impl SandboxClient for NvxClient { + fn probe(&self) -> Result<(), MxcError> { + self.library.probe(self.host) + } + + fn provision(&self) -> Result { + let reply = self.library.provision( + self.host, + &ProvisionInput { + request: ProvisionRequest {}, + spec: sandbox_spec(), + }, + )?; + SandboxId::from_token(&reply.token) + } + + fn start(&self, sandbox_id: &SandboxId) -> Result<(), MxcError> { + self.library + .start(self.host, sandbox_id.token()) + .map(|_| ()) + } + + fn exec_and_wait( + &self, + sandbox_id: &SandboxId, + request: &ExecRequest, + ) -> Result { + let exec_handle = self.library.exec( + self.host, + sandbox_id.token(), + &ExecInput { + request: request.clone(), + stdin: false, + }, + )?; + let mut guard = ExecGuard::new(self.library, exec_handle); + let deadline = request + .process + .timeout + .filter(|&ms| ms != 0) + .and_then(|ms| { + Instant::now().checked_add(Duration::from_millis( + ms.saturating_add(HOST_TIMEOUT_SLACK_MS), + )) + }); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + loop { + let step = + self.library + .exec_next( + exec_handle, + remaining_timeout_ms(deadline), + |event| match event { + Event::Stdout(bytes) => { + stdout.extend_from_slice(bytes); + None + } + Event::Stderr(bytes) => { + stderr.extend_from_slice(bytes); + None + } + Event::Exit(bytes) => Some(serde_json::from_slice::(bytes)), + }, + ); + + match step { + Ok(Some(None)) => {} + Ok(Some(Some(Ok(ExecEnd::Outcome(outcome))))) => { + guard.release()?; + return Ok(ExecOutput { + outcome, + stdout, + stderr, + }); + } + Ok(Some(Some(Ok(ExecEnd::Error(body))))) => { + let mut error = map_error_body(body); + if let Err(release_error) = guard.release() { + append_cleanup_error(&mut error, release_error); + } + return Err(error); + } + Ok(Some(Some(Err(parse_error)))) => { + let mut error = MxcError::backend_error(format!( + "NVX signed bundled agent: the agent library returned a malformed execution end: {parse_error}" + )); + if let Err(cleanup_error) = guard.cancel_and_release() { + append_cleanup_error(&mut error, cleanup_error); + } + return Err(error); + } + Ok(None) => { + guard.cancel_and_release()?; + return Ok(ExecOutput { + outcome: ExecOutcome::TimedOut, + stdout, + stderr, + }); + } + Err(mut error) => { + if let Err(cleanup_error) = guard.cancel_and_release() { + append_cleanup_error(&mut error, cleanup_error); + } + return Err(error); + } + } + } + } + + fn stop(&self, sandbox_id: &SandboxId) -> Result<(), MxcError> { + self.library.stop(self.host, sandbox_id.token()).map(|_| ()) + } + + fn deprovision(&self, sandbox_id: &SandboxId) -> Result<(), MxcError> { + self.library + .deprovision(self.host, sandbox_id.token()) + .map(|_| ()) + } + + fn close(&mut self) -> Result<(), MxcError> { + if self.closed { + return Ok(()); + } + self.closed = true; + self.library.host_close(self.host) + } +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::fs; + use std::ptr; + use std::sync::{Mutex, OnceLock, PoisonError}; + + use crate::mxc_common::mxc_error::MxcErrorCode; + + use super::super::abi::{ + ErrorBody, ExecEvent, Exports, WireErrorCode, EVENT_EXIT, EVENT_STDERR, EVENT_STDOUT, + STATUS_ERROR, STATUS_INVALID_ARGUMENT, STATUS_INVALID_HANDLE, STATUS_OK, STATUS_TIMEOUT, + }; + use super::*; + + fn write_bundle_manifest(directory: &Path) -> PathBuf { + fs::create_dir_all(directory).unwrap(); + fs::write(directory.join(SOURCE_MANIFEST), "{}").unwrap(); + directory.to_path_buf() + } + + #[test] + fn bundle_discovery_honors_env_override_without_fallback_and_checks_exe_neighbors() { + let root = tempfile::tempdir().unwrap(); + let exe_dir = root.path().join("deps"); + fs::create_dir_all(&exe_dir).unwrap(); + + let no_env = |_: &str| None; + let exe_in = |dir: &Path| { + let path = dir.join("test.exe"); + move || Ok(path) + }; + + let missing = discover_bundle_with(no_env, exe_in(&exe_dir)).unwrap_err(); + assert_eq!(missing.code, MxcErrorCode::BackendUnavailable); + assert!(missing.message.contains(ARTIFACTS_DIR_ENV)); + + let parent_bundle = write_bundle_manifest(&root.path().join(BESIDE_EXE_DIR)); + assert_eq!( + discover_bundle_with(no_env, exe_in(&exe_dir)).unwrap(), + parent_bundle + ); + + let beside_bundle = write_bundle_manifest(&exe_dir.join(BESIDE_EXE_DIR)); + assert_eq!( + discover_bundle_with(no_env, exe_in(&exe_dir)).unwrap(), + beside_bundle + ); + + let absent_override = root.path().join("absent"); + let bad_env = |name: &str| { + (name == ARTIFACTS_DIR_ENV).then(|| absent_override.as_os_str().to_owned()) + }; + let err = discover_bundle_with(bad_env, exe_in(&exe_dir)).unwrap_err(); + assert_eq!(err.code, MxcErrorCode::BackendUnavailable); + assert!(err.message.contains("holds no agent bundle")); + } + + #[test] + fn sandbox_id_requires_32_lowercase_hex_digits() { + let valid = SandboxId::from_token("0123456789abcdef0123456789abcdef").unwrap(); + assert_eq!( + valid.to_string(), + "aci-edge-sandboxes:0123456789abcdef0123456789abcdef" + ); + assert_eq!(valid.token(), "0123456789abcdef0123456789abcdef"); + + for invalid in [ + "", + "0123456789abcdef", + "0123456789ABCDEF0123456789abcdef", + "0123456789abcdef0123456789abcdeg", + ] { + let err = SandboxId::from_token(invalid).unwrap_err(); + assert_eq!(err.code, MxcErrorCode::BackendError); + } + } + + enum FakeStep { + Event { + kind: u32, + bytes: Option>, + reported_len: Option, + }, + Status(i32), + } + + struct FakeExecState { + steps: VecDeque, + last_buf: Vec, + calls: Vec<&'static str>, + cancel_status: i32, + release_status: i32, + } + + static FAKE_EXECS: OnceLock>> = + OnceLock::new(); + static NEXT_HANDLE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(100); + + fn register_fake_exec(steps: Vec) -> u64 { + let handle = NEXT_HANDLE.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + FAKE_EXECS + .get_or_init(Default::default) + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert( + handle, + FakeExecState { + steps: steps.into(), + last_buf: Vec::new(), + calls: Vec::new(), + cancel_status: STATUS_OK, + release_status: STATUS_OK, + }, + ); + handle + } + + fn fake_calls(handle: u64) -> Vec<&'static str> { + FAKE_EXECS + .get_or_init(Default::default) + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(&handle) + .map(|s| s.calls.clone()) + .unwrap_or_default() + } + + fn alloc_buf(bytes: &[u8], out: *mut *mut u8, len: *mut usize) { + let mut owned = bytes.to_vec().into_boxed_slice(); + unsafe { + *len = owned.len(); + *out = owned.as_mut_ptr(); + } + std::mem::forget(owned); + } + + unsafe extern "C" fn fake_buffer_free(ptr: *mut u8, len: usize) { + if !ptr.is_null() { + unsafe { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len))); + } + } + } + + unsafe extern "C" fn fake_host_open( + _in_ptr: *const u8, + _in_len: usize, + host: *mut u64, + _out: *mut *mut u8, + _len: *mut usize, + ) -> i32 { + unsafe { *host = 1 }; + STATUS_OK + } + + unsafe extern "C" fn fake_release(handle: u64) -> i32 { + if let Some(state) = FAKE_EXECS + .get_or_init(Default::default) + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get_mut(&handle) + { + state.calls.push("release"); + return state.release_status; + } + if handle == 999 { + STATUS_INVALID_HANDLE + } else { + STATUS_OK + } + } + + unsafe extern "C" fn fake_probe(host: u64, out: *mut *mut u8, len: *mut usize) -> i32 { + if host == 2 { + alloc_buf(b"{}", out, len); + STATUS_OK + } else if host == 3 { + let body = serde_json::to_vec(&ErrorBody { + code: WireErrorCode::BackendUnavailable, + message: "WHP disabled".to_owned(), + }) + .unwrap(); + alloc_buf(&body, out, len); + STATUS_ERROR + } else { + STATUS_OK + } + } + + unsafe extern "C" fn fake_query_cancel( + handle: u64, + _out: *mut *mut u8, + _len: *mut usize, + ) -> i32 { + if let Some(state) = FAKE_EXECS + .get_or_init(Default::default) + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get_mut(&handle) + { + state.calls.push("cancel"); + return state.cancel_status; + } + STATUS_OK + } + + unsafe extern "C" fn fake_query_invalid( + _handle: u64, + _out: *mut *mut u8, + _len: *mut usize, + ) -> i32 { + STATUS_INVALID_HANDLE + } + + unsafe extern "C" fn fake_call_invalid( + _host: u64, + _in_ptr: *const u8, + _in_len: usize, + _out: *mut *mut u8, + _len: *mut usize, + ) -> i32 { + STATUS_INVALID_HANDLE + } + + unsafe extern "C" fn fake_exec( + host: u64, + _tok_ptr: *const u8, + _tok_len: usize, + _in_ptr: *const u8, + _in_len: usize, + exec_out: *mut u64, + _out: *mut *mut u8, + _len: *mut usize, + ) -> i32 { + unsafe { *exec_out = host }; + STATUS_OK + } + + unsafe extern "C" fn fake_exec_next(handle: u64, _timeout: i64, event: *mut ExecEvent) -> i32 { + let mut map = FAKE_EXECS + .get_or_init(Default::default) + .lock() + .unwrap_or_else(PoisonError::into_inner); + let Some(state) = map.get_mut(&handle) else { + return STATUS_INVALID_HANDLE; + }; + match state.steps.pop_front() { + Some(FakeStep::Event { + kind, + bytes: Some(bytes), + reported_len, + }) => { + state.last_buf = bytes; + let length = reported_len.unwrap_or(state.last_buf.len()); + unsafe { + *event = ExecEvent { + kind, + data: state.last_buf.as_ptr(), + length, + }; + } + STATUS_OK + } + Some(FakeStep::Event { + kind, + bytes: None, + reported_len, + }) => { + unsafe { + *event = ExecEvent { + kind, + data: ptr::null(), + length: reported_len.unwrap_or(4), + }; + } + STATUS_OK + } + Some(FakeStep::Status(status)) => status, + None => STATUS_TIMEOUT, + } + } + + unsafe extern "C" fn fake_exec_write( + _handle: u64, + _ptr: *const u8, + _len: usize, + _timeout: i64, + _written: *mut usize, + _out: *mut *mut u8, + _out_len: *mut usize, + ) -> i32 { + STATUS_INVALID_HANDLE + } + + fn fake_library() -> &'static AgentLibrary { + static LIB: OnceLock = OnceLock::new(); + LIB.get_or_init(|| { + AgentLibrary::from_exports_for_test(Exports { + host_open: fake_host_open, + host_close: fake_release, + host_info: fake_query_invalid, + capabilities: fake_query_invalid, + probe: fake_probe, + validate_provision: fake_call_invalid, + validate_exec: fake_call_invalid, + provision: fake_call_invalid, + start: fake_call_invalid, + stop: fake_call_invalid, + deprovision: fake_call_invalid, + diagnostics: fake_call_invalid, + guest_logs: fake_call_invalid, + exec: fake_exec, + exec_next: fake_exec_next, + exec_write: fake_exec_write, + exec_close_input: fake_query_invalid, + exec_cancel: fake_query_cancel, + exec_release: fake_release, + image_register: fake_call_invalid, + image_list: fake_query_invalid, + image_verify: fake_call_invalid, + image_unregister: fake_call_invalid, + buffer_free: fake_buffer_free, + }) + }) + } + + #[test] + fn probe_and_host_close_fail_closed_on_unexpected_payload_or_status() { + let lib = fake_library(); + assert!(lib.probe(1).is_ok()); + + let unexpected_payload = lib.probe(2).unwrap_err(); + assert_eq!(unexpected_payload.code, MxcErrorCode::BackendError); + assert!(unexpected_payload + .message + .contains("unexpected probe payload")); + + let unavailable = lib.probe(3).unwrap_err(); + assert_eq!(unavailable.code, MxcErrorCode::BackendUnavailable); + assert!(unavailable.message.contains("WHP disabled")); + + assert!(lib.host_close(1).is_ok()); + let close_err = lib.host_close(999).unwrap_err(); + assert_eq!(close_err.code, MxcErrorCode::BackendError); + assert!(close_err.message.contains("unknown handle")); + } + + #[test] + fn exec_and_wait_streams_bytes_and_handles_timeouts_and_protocol_errors() { + let sandbox_id = SandboxId::from_token("0123456789abcdef0123456789abcdef").unwrap(); + let req = ExecRequest::command_line("echo test").with_timeout_ms(100); + + // 1. Normal stdout + stderr (with null bytes) + exit 42 -> release only. + let handle = register_fake_exec(vec![ + FakeStep::Event { + kind: EVENT_STDOUT, + bytes: Some(b"out\0\xff".to_vec()), + reported_len: None, + }, + FakeStep::Event { + kind: EVENT_STDERR, + bytes: Some(b"err\0\x80".to_vec()), + reported_len: None, + }, + FakeStep::Event { + kind: EVENT_EXIT, + bytes: Some(br#"{"outcome":{"exited":42}}"#.to_vec()), + reported_len: None, + }, + ]); + let mut client = NvxClient::from_library(fake_library(), handle); + let output = client.exec_and_wait(&sandbox_id, &req).unwrap(); + assert_eq!(output.outcome, ExecOutcome::Exited(42)); + assert_eq!(output.stdout, b"out\0\xff"); + assert_eq!(output.stderr, b"err\0\x80"); + assert_eq!(fake_calls(handle), ["release"]); + let _ = client.close(); + + // 2. Host backstop STATUS_TIMEOUT -> cancels, releases, and preserves partial output. + let handle = register_fake_exec(vec![ + FakeStep::Event { + kind: EVENT_STDOUT, + bytes: Some(b"partial-out".to_vec()), + reported_len: None, + }, + FakeStep::Status(STATUS_TIMEOUT), + ]); + let mut client = NvxClient::from_library(fake_library(), handle); + let output = client.exec_and_wait(&sandbox_id, &req).unwrap(); + assert_eq!(output.outcome, ExecOutcome::TimedOut); + assert_eq!(output.stdout, b"partial-out"); + assert_eq!(fake_calls(handle), ["cancel", "release"]); + let _ = client.close(); + + // 3. Unknown event kind -> cancels, releases, and fails closed. + let handle = register_fake_exec(vec![FakeStep::Event { + kind: 99, + bytes: Some(b"unknown".to_vec()), + reported_len: None, + }]); + let mut client = NvxClient::from_library(fake_library(), handle); + let err = client.exec_and_wait(&sandbox_id, &req).unwrap_err(); + assert_eq!(err.code, MxcErrorCode::BackendError); + assert!(err.message.contains("unknown execution event 99")); + assert_eq!(fake_calls(handle), ["cancel", "release"]); + let _ = client.close(); + + // 4. Missing event data (null pointer with non-zero length) -> cancels, releases, fails closed. + let handle = register_fake_exec(vec![FakeStep::Event { + kind: EVENT_STDOUT, + bytes: None, + reported_len: Some(8), + }]); + let mut client = NvxClient::from_library(fake_library(), handle); + let err = client.exec_and_wait(&sandbox_id, &req).unwrap_err(); + assert_eq!(err.code, MxcErrorCode::BackendError); + assert!(err.message.contains("data is missing")); + assert_eq!(fake_calls(handle), ["cancel", "release"]); + let _ = client.close(); + + // 5. Native STATUS_INVALID_ARGUMENT -> cancels, releases, fails closed. + let handle = register_fake_exec(vec![FakeStep::Status(STATUS_INVALID_ARGUMENT)]); + let mut client = NvxClient::from_library(fake_library(), handle); + let err = client.exec_and_wait(&sandbox_id, &req).unwrap_err(); + assert_eq!(err.code, MxcErrorCode::BackendError); + assert!(err.message.contains("rejected an argument")); + assert_eq!(fake_calls(handle), ["cancel", "release"]); + let _ = client.close(); + } + + #[test] + fn real_staged_dll_loads_verifies_cached_digest_and_rejects_bad_manifest() { + let bundle_dir = discover_bundle_dir().expect("discover staged NVX bundle"); + let dll_path = bundle_dir.join("bin").join("aci_edge_agent.dll"); + let lib = AgentLibrary::load_verified(&dll_path, APPROVED_DLL_SHA256) + .expect("load verified aci_edge_agent.dll"); + let lib_again = AgentLibrary::load_verified(&dll_path, APPROVED_DLL_SHA256) + .expect("reuse cached verified aci_edge_agent.dll"); + assert!(std::ptr::eq(lib, lib_again)); + + // Even after caching, a wrong expected digest for the same canonical path must fail closed. + let wrong_dll_digest = AgentLibrary::load_verified(&dll_path, &"0".repeat(64)).unwrap_err(); + assert_eq!(wrong_dll_digest.code, MxcErrorCode::BackendUnavailable); + + // Passing a wrong manifest digest to host_open must be rejected by the native DLL. + let state_root = tempfile::tempdir().unwrap(); + let bad_setup = SetupConfig { + schema_version: 1, + state_root: state_root.path().to_path_buf(), + runtime: RuntimeSource { + bundle: BundleSource { + path: bundle_dir, + manifest_sha256: "0".repeat(64), + }, + }, + }; + let bad_host = lib.host_open(&bad_setup).unwrap_err(); + assert_eq!(bad_host.code, MxcErrorCode::BackendUnavailable); + } } diff --git a/src/mxc-sdk/tests/nvx_bundle_integrity.rs b/src/mxc-sdk/tests/nvx_bundle_integrity.rs index 0abda76e5d..eb51ff83eb 100644 --- a/src/mxc-sdk/tests/nvx_bundle_integrity.rs +++ b/src/mxc-sdk/tests/nvx_bundle_integrity.rs @@ -82,3 +82,118 @@ fn rejects_missing_or_unlisted_bundle_files() { "unlisted files must be rejected" ); } + +fn write_zip_fixture(entries: &[(&str, &[u8])]) -> (tempfile::TempDir, std::path::PathBuf, String) { + use std::io::Write; + + let directory = tempfile::tempdir().expect("zip fixture dir"); + let archive_path = directory.path().join("aci-edge-sandboxes.zip"); + let file = std::fs::File::create(&archive_path).expect("create zip fixture"); + let mut writer = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + for (name, contents) in entries { + writer.start_file(*name, options).expect("start zip entry"); + writer.write_all(contents).expect("write zip entry"); + } + writer.finish().expect("finish zip fixture"); + let archive_sha = digest(&std::fs::read(&archive_path).expect("read zip fixture")); + (directory, archive_path, archive_sha) +} + +#[test] +fn extracts_and_verifies_complete_pinned_archive() { + let entries = [ + ("SHA256SUMS", b"checksums".as_slice()), + ("SOURCE-MANIFEST.json", b"source manifest".as_slice()), + ("bin/aci_edge_agent.dll", b"agent dll".as_slice()), + ("bin/direct-images.exe", b"image tool".as_slice()), + ("guest/initramfs-edge.cpio.gz", b"guest".as_slice()), + ]; + let expected: Vec<(&str, String)> = entries + .iter() + .map(|(path, contents)| (*path, digest(contents))) + .collect(); + let expected_refs: Vec<(&str, &str)> = expected + .iter() + .map(|(path, sha)| (*path, sha.as_str())) + .collect(); + let (directory, archive_path, archive_sha) = write_zip_fixture(&entries); + let extracted = directory.path().join("extracted"); + + build_nvx_bundled::extract_verified_archive_against( + &archive_path, + &extracted, + &archive_sha, + &expected_refs, + ); + verify(&extracted, &expected); +} + +#[test] +fn rejects_archive_digest_mismatch_and_unsafe_or_unlisted_entries() { + let entries = [ + ("SHA256SUMS", b"checksums".as_slice()), + ("SOURCE-MANIFEST.json", b"source manifest".as_slice()), + ]; + let expected: Vec<(&str, String)> = entries + .iter() + .map(|(path, contents)| (*path, digest(contents))) + .collect(); + let expected_refs: Vec<(&str, &str)> = expected + .iter() + .map(|(path, sha)| (*path, sha.as_str())) + .collect(); + + let (directory, archive_path, _) = write_zip_fixture(&entries); + let extracted = directory.path().join("extracted"); + assert!( + std::panic::catch_unwind(|| { + build_nvx_bundled::extract_verified_archive_against( + &archive_path, + &extracted, + &"0".repeat(64), + &expected_refs, + ); + }) + .is_err(), + "archive with mismatched SHA-256 must be rejected before extraction" + ); + assert!( + !archive_path.exists(), + "corrupted archive must be removed on SHA-256 mismatch" + ); + assert!(!extracted.exists()); + + let (directory, traversal_archive, traversal_sha) = write_zip_fixture(&[ + ("SHA256SUMS", b"checksums".as_slice()), + ("../escape.txt", b"escape".as_slice()), + ]); + let extracted = directory.path().join("extracted"); + assert!( + std::panic::catch_unwind(|| { + build_nvx_bundled::extract_verified_archive_against( + &traversal_archive, + &extracted, + &traversal_sha, + &expected_refs, + ); + }) + .is_err(), + "archive with path traversal entry must be rejected" + ); + assert!(!extracted.exists()); +} + +#[test] +fn pins_official_release_url_and_hashes() { + assert_eq!( + build_nvx_bundled::OFFICIAL_BUNDLE_URL, + "https://github.com/microsoft/nvx/releases/download/v0.1.0-dev.0430ce79d125/aci-edge-sandboxes.zip" + ); + assert_eq!( + build_nvx_bundled::OFFICIAL_BUNDLE_ZIP_SHA256, + "98e3d6011b0664fad6fa783768370a34209639463f5ab812b7776840660ac24e" + ); + assert_eq!(build_nvx_bundled::OFFICIAL_BUNDLE_FILES.len(), 18); +} diff --git a/tests/scripts/run_microvm_tests.ps1 b/tests/scripts/run_microvm_tests.ps1 index c9adb80296..b673b5c176 100644 --- a/tests/scripts/run_microvm_tests.ps1 +++ b/tests/scripts/run_microvm_tests.ps1 @@ -7,7 +7,7 @@ .DESCRIPTION - Locates wxc-exec.exe (built with --features microvm) - - Uses the signed agent release bundled by the Cargo dependency + - Uses the signed agent release staged by the mxc-sdk build script - Runs each test config via wxc-exec, validates exit codes and stdout content - Reports pass/fail summary with per-test performance timing - Writes microvm-perf-results.json for CI artifact consumption From ffa8778d86aa2cbb514d264f593b9f0ec9a26b57 Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Wed, 21 Oct 2026 17:07:41 -0700 Subject: [PATCH 07/18] Refresh lockfile references after merging fork main Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index 76866655ef..6d723ed07b 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -656,7 +656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2044,7 +2044,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2396,7 +2396,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2815,7 +2815,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] From cfa4d178ef486838b46f148b17fc2b00a07730a1 Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Wed, 21 Oct 2026 17:17:14 -0700 Subject: [PATCH 08/18] Keep disabled MicroVM staging warning-free on all hosts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/mxc-sdk/build/build_nvx_bundled.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/mxc-sdk/build/build_nvx_bundled.rs b/src/mxc-sdk/build/build_nvx_bundled.rs index a075e4a98a..5465009c55 100644 --- a/src/mxc-sdk/build/build_nvx_bundled.rs +++ b/src/mxc-sdk/build/build_nvx_bundled.rs @@ -98,15 +98,13 @@ pub(crate) const OFFICIAL_BUNDLE_FILES: &[(&str, &str)] = &[ pub(crate) fn run() { println!("cargo:rerun-if-env-changed=CARGO_FEATURE_MICROVM"); println!("cargo:rerun-if-env-changed={BUNDLE_DIR_ENV}"); - if std::env::var_os("CARGO_FEATURE_MICROVM").is_none() - || std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") - || std::env::var("CARGO_CFG_TARGET_ARCH").as_deref() != Ok("x86_64") + #[cfg(feature = "microvm")] + if std::env::var_os("CARGO_FEATURE_MICROVM").is_some() + && std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") + && std::env::var("CARGO_CFG_TARGET_ARCH").as_deref() == Ok("x86_64") { - return; + stage_official_bundle(); } - - #[cfg(feature = "microvm")] - stage_official_bundle(); } #[cfg(feature = "microvm")] From d5ce9525fd2310fce36ab920c8323e935b3e5696 Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Thu, 22 Oct 2026 14:11:10 -0700 Subject: [PATCH 09/18] Bound NVX output capture and validate execution before provisioning Enforce the combined 64 MiB output budget with cancellation and cleanup, validate process limits before bundle discovery, and validate the mapped native request before provisioning. Cover boundaries and cleanup through runtime and signed-bundle scenarios. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/schema.md | 7 +- src/mxc-sdk/README.md | 14 + src/mxc-sdk/src/backends/nvx/abi.rs | 7 + src/mxc-sdk/src/backends/nvx/policy.rs | 41 +++ src/mxc-sdk/src/backends/nvx/runner.rs | 173 +++++++++++- src/mxc-sdk/src/backends/nvx/runtime.rs | 356 +++++++++++++++++++++--- src/mxc-sdk/tests/nvx_e2e.rs | 145 +++++++++- 7 files changed, 686 insertions(+), 57 deletions(-) diff --git a/docs/schema.md b/docs/schema.md index 9598e302a0..2f4a4c3061 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -338,7 +338,7 @@ use: | Seatbelt (macOS) | Same precedence, with `~` expanded as the profile expands it; falls back to `/`. | | Bubblewrap (Linux) | No substitution — a policy grant is never adopted. `--chdir` is emitted only for an explicit `process.cwd`, which from 0.9 is also normalized against the sandbox root and used as `HOME`. With no explicit `cwd` there is no `--chdir` and `HOME` is unset — see [`docs/backends/bwrap/bubblewrap-backend.md`](backends/bwrap/bubblewrap-backend.md). | | LXC / WSL Container | The container root — see [`docs/backends/lxc/lxc-backend.md`](backends/lxc/lxc-backend.md). | -| MicroVM | NVX on Windows x64 accepts an absolute guest working directory. Linux MicroVM execution is inactive in this phase. | +| MicroVM | NVX on Windows x64 accepts an absolute guest working directory with no `..` components and at most 4095 UTF-8 bytes. Linux MicroVM execution is inactive in this phase. | | Hyperlight | Not applicable — this backend rejects a working directory outright. | Policy entries that are blank, name a file, or do not exist yet are skipped: @@ -381,6 +381,11 @@ On the WSL Container backend it is the container image's own `ENV`, which MXC neither authors nor enumerates — see [`docs/backends/wslc/wsl-container-getting-started.md`](backends/wslc/wsl-container-getting-started.md#environment). +NVX on Windows x64 requires `process.inheritDefaultEnv: true` when +`process.env` is present. It accepts at most 256 entries and 32 KiB including +entry terminators. Repeated names count as separate entries, with later entries +overriding earlier ones. + ### Filesystem Policy The `filesystem` section defines path access policy shared across backends: diff --git a/src/mxc-sdk/README.md b/src/mxc-sdk/README.md index a0344cfe50..6ae2e52d1b 100644 --- a/src/mxc-sdk/README.md +++ b/src/mxc-sdk/README.md @@ -194,11 +194,25 @@ Distribute the complete `aci-edge-sandboxes/` directory beside the consuming executable. The native agent pulls the official example's public Azure Linux workload image on the host; guest network access remains ungranted. +NVX captures at most 64 MiB (67,108,864 bytes) of stdout and stderr combined, +including when the process timeout is zero. Output up to that limit is returned +unchanged. Exceeding it cancels and releases the execution, tears down the +container, and returns `BackendError` instead of truncated output. Cleanup +failures are reported alongside the capture-limit error. + The host requires Windows Hypervisor Platform and hardware virtualization. Requests cannot include filesystem grants or denials, network or proxy policy, UI policy, or capture-denials. Containers must be destroyed after execution, without preserving policy. +The guest working directory must be absolute, contain no `..` path components, +and use at most 4095 UTF-8 bytes. Additive environment settings require +`inheritDefaultEnv: true` and allow at most 256 entries and 32 KiB including +entry terminators. Repeated names count as separate entries; later entries +override earlier ones. MXC rejects these limits before bundle discovery and +validates the mapped execution request with the native agent before probing +the host or provisioning a container. + Backend implementations, shared runtime support, backend dispatch, and host probing are internal modules of this published crate. The crate root exposes only the versioned public SDK and explicitly hidden compatibility surfaces used diff --git a/src/mxc-sdk/src/backends/nvx/abi.rs b/src/mxc-sdk/src/backends/nvx/abi.rs index a09f493eb5..f4e4d771ef 100644 --- a/src/mxc-sdk/src/backends/nvx/abi.rs +++ b/src/mxc-sdk/src/backends/nvx/abi.rs @@ -664,6 +664,13 @@ impl AgentLibrary { }) } + pub fn validate_exec(&self, host: u64, request: &ExecRequest) -> Result<(), MxcError> { + let encoded = encode_json(request)?; + self.call_empty("validate_exec", |out, length| unsafe { + (self.exports.validate_exec)(host, encoded.as_ptr(), encoded.len(), out, length) + }) + } + pub fn provision(&self, host: u64, input: &ProvisionInput) -> Result { let encoded = encode_json(input)?; let reply = self.with_input(self.exports.provision, host, &encoded)?; diff --git a/src/mxc-sdk/src/backends/nvx/policy.rs b/src/mxc-sdk/src/backends/nvx/policy.rs index 4b95733c38..45f29fbc7c 100644 --- a/src/mxc-sdk/src/backends/nvx/policy.rs +++ b/src/mxc-sdk/src/backends/nvx/policy.rs @@ -6,6 +6,8 @@ use crate::mxc_common::mxc_error::MxcError; const MAX_TIMEOUT_MS: u32 = 3_600_000; const MAX_ENVIRONMENT_BYTES: usize = 32 * 1024; +const MAX_ENVIRONMENT_ENTRIES: usize = 256; +const MAX_CWD_BYTES: usize = 4095; const PROXY_ENVIRONMENT_KEYS: [&str; 4] = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]; pub(super) fn validate_request(request: &ExecutionRequest) -> Result<(), MxcError> { @@ -99,12 +101,22 @@ fn validate_process(request: &ExecutionRequest) -> Result<(), MxcError> { "NVX process.cwd must be an absolute POSIX guest path", )); } + if request.working_directory.len() > MAX_CWD_BYTES { + return Err(MxcError::policy_validation( + "NVX process.cwd must not exceed 4095 UTF-8 bytes", + )); + } if let Some(environment) = &request.env { if !request.inherit_default_env { return Err(MxcError::policy_validation( "NVX process.env requires inheritDefaultEnv=true", )); } + if environment.len() > MAX_ENVIRONMENT_ENTRIES { + return Err(MxcError::policy_validation( + "NVX process.env must not exceed 256 entries", + )); + } let bytes = environment .iter() .map(|entry| entry.len().saturating_add(1)) @@ -222,6 +234,35 @@ mod tests { assert_policy_error(&timeout, "one hour"); } + #[test] + fn environment_entry_limit_counts_duplicates_and_preserves_absent_and_empty() { + let mut request = request(); + assert!(validate_request(&request).is_ok()); + request.env = Some(Vec::new()); + assert_policy_error(&request, "inheritDefaultEnv=true"); + request.inherit_default_env = true; + assert!(validate_request(&request).is_ok()); + request.env = Some(vec!["KEY=value".to_owned(); MAX_ENVIRONMENT_ENTRIES]); + assert!(validate_request(&request).is_ok()); + request.env.as_mut().unwrap().push("KEY=later".to_owned()); + assert_policy_error(&request, "must not exceed 256 entries"); + } + + #[test] + fn cwd_limit_counts_utf8_bytes_instead_of_characters() { + let mut request = request(); + for directory in [ + format!("/{}", "x".repeat(MAX_CWD_BYTES - 1)), + format!("/{}", "\u{00e9}".repeat((MAX_CWD_BYTES - 1) / 2)), + ] { + assert_eq!(directory.len(), MAX_CWD_BYTES); + request.working_directory = directory; + assert!(validate_request(&request).is_ok()); + request.working_directory.push('x'); + assert_policy_error(&request, "must not exceed 4095 UTF-8 bytes"); + } + } + #[test] fn validates_posix_cwd_and_exact_process_boundaries() { let mut request = request(); diff --git a/src/mxc-sdk/src/backends/nvx/runner.rs b/src/mxc-sdk/src/backends/nvx/runner.rs index 805dde7c97..a8fbb1b0ae 100644 --- a/src/mxc-sdk/src/backends/nvx/runner.rs +++ b/src/mxc-sdk/src/backends/nvx/runner.rs @@ -74,7 +74,12 @@ fn run_with_client( request: &ExecutionRequest, logger: &mut Logger, ) -> Result { - let sandbox_id = match client.probe().and_then(|()| client.provision()) { + let exec = exec_request(request); + let sandbox_id = match client + .validate_exec(&exec) + .and_then(|()| client.probe()) + .and_then(|()| client.provision()) + { Ok(sandbox_id) => sandbox_id, Err(error) => { let mut cleanup = Vec::new(); @@ -99,7 +104,7 @@ fn run_with_client( ); let primary = (|| { client.start(&sandbox_id)?; - client.exec_and_wait(&sandbox_id, &exec_request(request)) + client.exec_and_wait(&sandbox_id, &exec) })(); let stop_error = match client.stop(&sandbox_id).err() { @@ -263,6 +268,8 @@ fn exec_request(request: &ExecutionRequest) -> ExecRequest { #[cfg(test)] mod tests { + use std::cell::RefCell; + use super::super::runtime::{ExecOutput, SandboxId}; use super::*; @@ -430,7 +437,10 @@ mod tests { } struct MockClient { - calls: Vec<&'static str>, + calls: RefCell>, + validated_request: RefCell>, + executed_request: RefCell>, + validate_res: Result<(), MxcError>, probe_res: Result<(), MxcError>, provision_res: Result, start_res: Result<(), MxcError>, @@ -443,7 +453,10 @@ mod tests { impl MockClient { fn ok() -> Self { Self { - calls: Vec::new(), + calls: RefCell::new(Vec::new()), + validated_request: RefCell::new(None), + executed_request: RefCell::new(None), + validate_res: Ok(()), probe_res: Ok(()), provision_res: SandboxId::from_token("0123456789abcdef0123456789abcdef"), start_res: Ok(()), @@ -460,36 +473,49 @@ mod tests { } impl SandboxClient for MockClient { + fn validate_exec(&self, request: &ExecRequest) -> Result<(), MxcError> { + self.calls.borrow_mut().push("validate"); + *self.validated_request.borrow_mut() = Some(request.clone()); + self.validate_res.clone() + } + fn probe(&self) -> Result<(), MxcError> { + self.calls.borrow_mut().push("probe"); self.probe_res.clone() } fn provision(&self) -> Result { + self.calls.borrow_mut().push("provision"); self.provision_res.clone() } fn start(&self, _sandbox_id: &SandboxId) -> Result<(), MxcError> { + self.calls.borrow_mut().push("start"); self.start_res.clone() } fn exec_and_wait( &self, _sandbox_id: &SandboxId, - _request: &ExecRequest, + request: &ExecRequest, ) -> Result { + self.calls.borrow_mut().push("exec"); + *self.executed_request.borrow_mut() = Some(request.clone()); self.exec_res.clone() } fn stop(&self, _sandbox_id: &SandboxId) -> Result<(), MxcError> { + self.calls.borrow_mut().push("stop"); self.stop_res.clone() } fn deprovision(&self, _sandbox_id: &SandboxId) -> Result<(), MxcError> { + self.calls.borrow_mut().push("deprovision"); self.deprovision_res.clone() } fn close(&mut self) -> Result<(), MxcError> { - self.calls.push("close"); + self.calls.borrow_mut().push("close"); self.close_res.clone() } } @@ -512,7 +538,7 @@ mod tests { assert_eq!(err.code, MxcErrorCode::BackendUnavailable); assert!(err.message.contains("probe failed")); assert!(err.message.contains("host_close failed")); - assert_eq!(client.calls, ["close"]); + assert_eq!(*client.calls.borrow(), ["validate", "probe", "close"]); assert!(!state_path.exists()); // 2. Start failure suppresses AlreadyStopped on stop, deprovisions, closes host, removes state. @@ -526,7 +552,18 @@ mod tests { )); let err = run_with_client(&mut client, state_root, &request, &mut logger).unwrap_err(); assert_eq!(err.message, "start failed"); - assert_eq!(client.calls, ["close"]); + assert_eq!( + *client.calls.borrow(), + [ + "validate", + "probe", + "provision", + "start", + "stop", + "deprovision", + "close" + ] + ); assert!(!state_path.exists()); // 3. Host close failure after workload success fails the run. @@ -554,4 +591,124 @@ mod tests { assert!(state_path.exists()); std::fs::remove_dir_all(&state_path).unwrap(); } + + #[test] + fn native_validation_failure_closes_host_without_probing_or_provisioning() { + for cleanup_fails in [false, true] { + let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); + let state_root = tempfile::tempdir().unwrap(); + let state_path = state_root.path().to_path_buf(); + let mut client = MockClient::ok(); + client.validate_res = Err(MxcError::policy_validation("native validation rejected")); + if cleanup_fails { + client.close_res = Err(MxcError::backend_error("close failed")); + } + let error = run_with_client( + &mut client, + state_root, + &ExecutionRequest::default(), + &mut logger, + ) + .unwrap_err(); + assert_eq!(error.code, MxcErrorCode::PolicyValidation); + if cleanup_fails { + assert!(error + .message + .starts_with("native validation rejected; cleanup failure: ")); + assert!(error.message.contains("close failed")); + } else { + assert_eq!(error.message, "native validation rejected"); + } + assert_eq!(*client.calls.borrow(), ["validate", "close"]); + assert!(!state_path.exists()); + } + } + + #[test] + fn validates_and_executes_the_same_mapped_request_in_order() { + let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); + for environment in [None, Some(Vec::new()), Some(vec!["KEY=value".to_owned()])] { + let request = ExecutionRequest { + script_code: "printf '%s' \"$KEY\"".to_owned(), + script_timeout: 250, + working_directory: "/tmp".to_owned(), + env: environment, + inherit_default_env: true, + ..Default::default() + }; + let state_root = tempfile::tempdir().unwrap(); + let state_path = state_root.path().to_path_buf(); + let mut client = MockClient::ok(); + let result = run_with_client(&mut client, state_root, &request, &mut logger).unwrap(); + assert_eq!(result.outcome, CapturedOutcome::Exited(0)); + assert_eq!( + *client.validated_request.borrow(), + Some(exec_request(&request)) + ); + assert_eq!( + *client.executed_request.borrow(), + *client.validated_request.borrow() + ); + assert_eq!( + *client.calls.borrow(), + [ + "validate", + "probe", + "provision", + "start", + "exec", + "stop", + "deprovision", + "close" + ] + ); + assert!(!state_path.exists()); + } + } + + #[test] + fn capture_failure_runs_full_teardown_and_preserves_primary_and_cleanup_errors() { + let primary = "NVX signed bundled agent: combined stdout/stderr capture limit of 67108864 bytes exceeded"; + for cleanup_fails in [false, true] { + let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); + let state_root = tempfile::tempdir().unwrap(); + let state_path = state_root.path().to_path_buf(); + let mut client = MockClient::ok(); + client.exec_res = Err(MxcError::backend_error(primary)); + if cleanup_fails { + client.stop_res = Err(MxcError::backend_error("stop failed")); + client.close_res = Err(MxcError::backend_error("close failed")); + } + let error = run_with_client( + &mut client, + state_root, + &ExecutionRequest::default(), + &mut logger, + ) + .unwrap_err(); + assert_eq!(error.code, MxcErrorCode::BackendError); + if cleanup_fails { + assert!(error.message.starts_with(primary)); + assert!(error.message.contains("; cleanup failure: ")); + assert!(error.message.contains("stop failed")); + assert!(error.message.contains("close failed")); + } else { + assert_eq!(error.message, primary); + } + assert_eq!( + *client.calls.borrow(), + [ + "validate", + "probe", + "provision", + "start", + "exec", + "stop", + "deprovision", + "close" + ] + ); + assert!(!state_path.exists()); + } + } } diff --git a/src/mxc-sdk/src/backends/nvx/runtime.rs b/src/mxc-sdk/src/backends/nvx/runtime.rs index 630d74881b..eaddd83889 100644 --- a/src/mxc-sdk/src/backends/nvx/runtime.rs +++ b/src/mxc-sdk/src/backends/nvx/runtime.rs @@ -33,6 +33,7 @@ pub(super) const APPROVED_INITRD_SHA256: &str = const DEFAULT_IMAGE_REFERENCE: &str = "mcr.microsoft.com/azurelinux/base/core:3.0"; const HOST_TIMEOUT_SLACK_MS: u64 = 30_000; +const CAPTURE_LIMIT_BYTES: usize = 64 * 1024 * 1024; #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct SandboxId { @@ -77,6 +78,7 @@ pub(super) struct ExecOutput { } pub(super) trait SandboxClient { + fn validate_exec(&self, request: &ExecRequest) -> Result<(), MxcError>; fn probe(&self) -> Result<(), MxcError>; fn provision(&self) -> Result; fn start(&self, sandbox_id: &SandboxId) -> Result<(), MxcError>; @@ -308,32 +310,12 @@ fn append_cleanup_error(primary: &mut MxcError, cleanup: MxcError) { primary.message.push_str(&cleanup.to_string()); } -impl SandboxClient for NvxClient { - fn probe(&self) -> Result<(), MxcError> { - self.library.probe(self.host) - } - - fn provision(&self) -> Result { - let reply = self.library.provision( - self.host, - &ProvisionInput { - request: ProvisionRequest {}, - spec: sandbox_spec(), - }, - )?; - SandboxId::from_token(&reply.token) - } - - fn start(&self, sandbox_id: &SandboxId) -> Result<(), MxcError> { - self.library - .start(self.host, sandbox_id.token()) - .map(|_| ()) - } - - fn exec_and_wait( +impl NvxClient { + fn exec_and_wait_with_capture_limit( &self, sandbox_id: &SandboxId, request: &ExecRequest, + capture_limit: usize, ) -> Result { let exec_handle = self.library.exec( self.host, @@ -362,22 +344,34 @@ impl SandboxClient for NvxClient { .exec_next( exec_handle, remaining_timeout_ms(deadline), - |event| match event { - Event::Stdout(bytes) => { - stdout.extend_from_slice(bytes); - None + |event| { + let captured = stdout.len() + stderr.len(); + let (buffer, bytes) = match event { + Event::Stdout(bytes) => (&mut stdout, bytes), + Event::Stderr(bytes) => (&mut stderr, bytes), + Event::Exit(bytes) => { + return serde_json::from_slice::(bytes) + .map(Some) + .map_err(|parse_error| MxcError::backend_error(format!( + "NVX signed bundled agent: the agent library returned a malformed execution end: {parse_error}" + ))); + } + }; + // Check the combined budget before either buffer can grow. + if bytes.len() > capture_limit - captured { + return Err(MxcError::backend_error(format!( + "NVX signed bundled agent: combined stdout/stderr capture limit of {capture_limit} bytes exceeded" + ))); } - Event::Stderr(bytes) => { - stderr.extend_from_slice(bytes); - None - } - Event::Exit(bytes) => Some(serde_json::from_slice::(bytes)), + buffer.extend_from_slice(bytes); + Ok(None) }, - ); + ) + .and_then(|step| step.transpose()); match step { Ok(Some(None)) => {} - Ok(Some(Some(Ok(ExecEnd::Outcome(outcome))))) => { + Ok(Some(Some(ExecEnd::Outcome(outcome)))) => { guard.release()?; return Ok(ExecOutput { outcome, @@ -385,22 +379,13 @@ impl SandboxClient for NvxClient { stderr, }); } - Ok(Some(Some(Ok(ExecEnd::Error(body))))) => { + Ok(Some(Some(ExecEnd::Error(body)))) => { let mut error = map_error_body(body); if let Err(release_error) = guard.release() { append_cleanup_error(&mut error, release_error); } return Err(error); } - Ok(Some(Some(Err(parse_error)))) => { - let mut error = MxcError::backend_error(format!( - "NVX signed bundled agent: the agent library returned a malformed execution end: {parse_error}" - )); - if let Err(cleanup_error) = guard.cancel_and_release() { - append_cleanup_error(&mut error, cleanup_error); - } - return Err(error); - } Ok(None) => { guard.cancel_and_release()?; return Ok(ExecOutput { @@ -418,6 +403,41 @@ impl SandboxClient for NvxClient { } } } +} + +impl SandboxClient for NvxClient { + fn validate_exec(&self, request: &ExecRequest) -> Result<(), MxcError> { + self.library.validate_exec(self.host, request) + } + + fn probe(&self) -> Result<(), MxcError> { + self.library.probe(self.host) + } + + fn provision(&self) -> Result { + let reply = self.library.provision( + self.host, + &ProvisionInput { + request: ProvisionRequest {}, + spec: sandbox_spec(), + }, + )?; + SandboxId::from_token(&reply.token) + } + + fn start(&self, sandbox_id: &SandboxId) -> Result<(), MxcError> { + self.library + .start(self.host, sandbox_id.token()) + .map(|_| ()) + } + + fn exec_and_wait( + &self, + sandbox_id: &SandboxId, + request: &ExecRequest, + ) -> Result { + self.exec_and_wait_with_capture_limit(sandbox_id, request, CAPTURE_LIMIT_BYTES) + } fn stop(&self, sandbox_id: &SandboxId) -> Result<(), MxcError> { self.library.stop(self.host, sandbox_id.token()).map(|_| ()) @@ -531,6 +551,9 @@ mod tests { calls: Vec<&'static str>, cancel_status: i32, release_status: i32, + timeouts: Vec, + validation_input: Option, + validation_error: Option, } static FAKE_EXECS: OnceLock>> = @@ -551,6 +574,9 @@ mod tests { calls: Vec::new(), cancel_status: STATUS_OK, release_status: STATUS_OK, + timeouts: Vec::new(), + validation_input: None, + validation_error: None, }, ); handle @@ -663,6 +689,34 @@ mod tests { STATUS_INVALID_HANDLE } + unsafe extern "C" fn fake_validate_exec( + host: u64, + input: *const u8, + input_len: usize, + out: *mut *mut u8, + len: *mut usize, + ) -> i32 { + let mut map = FAKE_EXECS + .get_or_init(Default::default) + .lock() + .unwrap_or_else(PoisonError::into_inner); + let Some(state) = map.get_mut(&host) else { + return STATUS_INVALID_HANDLE; + }; + state.calls.push("validate"); + let input = unsafe { std::slice::from_raw_parts(input, input_len) }; + let Ok(value) = serde_json::from_slice(input) else { + return STATUS_INVALID_ARGUMENT; + }; + state.validation_input = Some(value); + if let Some(error) = &state.validation_error { + alloc_buf(&serde_json::to_vec(error).unwrap(), out, len); + STATUS_ERROR + } else { + STATUS_OK + } + } + unsafe extern "C" fn fake_exec( host: u64, _tok_ptr: *const u8, @@ -677,7 +731,7 @@ mod tests { STATUS_OK } - unsafe extern "C" fn fake_exec_next(handle: u64, _timeout: i64, event: *mut ExecEvent) -> i32 { + unsafe extern "C" fn fake_exec_next(handle: u64, timeout: i64, event: *mut ExecEvent) -> i32 { let mut map = FAKE_EXECS .get_or_init(Default::default) .lock() @@ -685,6 +739,7 @@ mod tests { let Some(state) = map.get_mut(&handle) else { return STATUS_INVALID_HANDLE; }; + state.timeouts.push(timeout); match state.steps.pop_front() { Some(FakeStep::Event { kind, @@ -743,7 +798,7 @@ mod tests { capabilities: fake_query_invalid, probe: fake_probe, validate_provision: fake_call_invalid, - validate_exec: fake_call_invalid, + validate_exec: fake_validate_exec, provision: fake_call_invalid, start: fake_call_invalid, stop: fake_call_invalid, @@ -869,6 +924,215 @@ mod tests { let _ = client.close(); } + #[test] + fn native_validation_sends_the_direct_request_and_preserves_native_error_codes() { + let request = ExecRequest::command_line("printf '%s' \"$KEY\"") + .with_timeout_ms(250) + .with_cwd("/tmp".to_owned()) + .with_envs(vec!["KEY=first".to_owned(), "KEY=last".to_owned()]) + .with_inherit_default_env(true); + for failure in [ + None, + Some(( + WireErrorCode::PolicyValidation, + MxcErrorCode::PolicyValidation, + )), + Some(( + WireErrorCode::MalformedRequest, + MxcErrorCode::MalformedRequest, + )), + Some(( + WireErrorCode::BackendUnavailable, + MxcErrorCode::BackendUnavailable, + )), + ] { + let handle = register_fake_exec(Vec::new()); + if let Some((code, _)) = failure { + FAKE_EXECS + .get() + .unwrap() + .lock() + .unwrap() + .get_mut(&handle) + .unwrap() + .validation_error = Some(ErrorBody { + code, + message: "native validation rejected request".to_owned(), + }); + } + let mut client = NvxClient::from_library(fake_library(), handle); + let result = client.validate_exec(&request); + if let Some((_, code)) = failure { + let error = result.unwrap_err(); + assert_eq!(error.code, code); + assert_eq!( + error.message, + "NVX signed bundled agent: native validation rejected request" + ); + } else { + result.unwrap(); + } + assert_eq!(fake_calls(handle), ["validate"]); + let input = FAKE_EXECS + .get() + .unwrap() + .lock() + .unwrap() + .get(&handle) + .unwrap() + .validation_input + .clone() + .unwrap(); + assert_eq!( + input, + serde_json::json!({ + "process": { + "commandLine": "printf '%s' \"$KEY\"", + "timeout": 250, + "cwd": "/tmp", + "env": ["KEY=first", "KEY=last"], + "inheritDefaultEnv": true + } + }) + ); + client.close().unwrap(); + } + } + + #[test] + fn real_staged_native_validation_checks_process_limits_without_provisioning() { + let state_root = tempfile::tempdir().unwrap(); + let mut client = create_client(state_root.path()).unwrap(); + let command = ExecRequest::command_line("printf forbidden"); + client.validate_exec(&command).unwrap(); + let boundary = command + .clone() + .with_cwd(format!("/{}", "\u{00e9}".repeat(2047))) + .with_envs(vec!["KEY=value".to_owned(); 256]) + .with_inherit_default_env(true); + client.validate_exec(&boundary).unwrap(); + for invalid in [ + boundary + .clone() + .with_cwd(format!("/{}x", "\u{00e9}".repeat(2047))), + boundary.with_envs(vec!["KEY=value".to_owned(); 257]), + ] { + let error = client.validate_exec(&invalid).unwrap_err(); + assert_eq!(error.code, MxcErrorCode::PolicyValidation); + } + client.close().unwrap(); + state_root.close().unwrap(); + } + + #[test] + fn capture_limit_preserves_bytes_at_and_below_the_combined_boundary() { + let sandbox_id = SandboxId::from_token("0123456789abcdef0123456789abcdef").unwrap(); + let request = ExecRequest::command_line("test").with_timeout_ms(0); + assert_eq!(CAPTURE_LIMIT_BYTES, 67_108_864); + + for stderr in [b"\x80\0\x81".as_slice(), b"\x80\0\x81x".as_slice()] { + let handle = register_fake_exec(vec![ + FakeStep::Event { + kind: EVENT_STDOUT, + bytes: Some(b"\xff\0\xfex".to_vec()), + reported_len: None, + }, + FakeStep::Event { + kind: EVENT_STDERR, + bytes: Some(stderr.to_vec()), + reported_len: None, + }, + FakeStep::Event { + kind: EVENT_STDOUT, + bytes: Some(Vec::new()), + reported_len: None, + }, + FakeStep::Event { + kind: EVENT_EXIT, + bytes: Some(br#"{"outcome":{"exited":42}}"#.to_vec()), + reported_len: None, + }, + ]); + let mut client = NvxClient::from_library(fake_library(), handle); + let output = client + .exec_and_wait_with_capture_limit(&sandbox_id, &request, 8) + .unwrap(); + assert_eq!(output.outcome, ExecOutcome::Exited(42)); + assert_eq!(output.stdout, b"\xff\0\xfex"); + assert_eq!(output.stderr, stderr); + assert_eq!(fake_calls(handle), ["release"]); + client.close().unwrap(); + } + } + + #[test] + fn capture_overflow_cancels_releases_and_preserves_cleanup_failures_without_a_timeout() { + let sandbox_id = SandboxId::from_token("0123456789abcdef0123456789abcdef").unwrap(); + let request = ExecRequest::command_line("test").with_timeout_ms(0); + for (first_kind, second_kind, first_bytes, second_bytes) in [ + (EVENT_STDOUT, EVENT_STDERR, 4, 5), + (EVENT_STDERR, EVENT_STDOUT, 4, 5), + (EVENT_STDOUT, EVENT_STDOUT, 8, 1), + (EVENT_STDERR, EVENT_STDERR, 0, 9), + ] { + for cleanup_fails in [false, true] { + let handle = register_fake_exec(vec![ + FakeStep::Event { + kind: first_kind, + bytes: Some(vec![b'a'; first_bytes]), + reported_len: None, + }, + FakeStep::Event { + kind: second_kind, + bytes: Some(vec![b'b'; second_bytes]), + reported_len: None, + }, + FakeStep::Event { + kind: EVENT_EXIT, + bytes: Some(br#"{"outcome":{"exited":0}}"#.to_vec()), + reported_len: None, + }, + ]); + if cleanup_fails { + let mut map = FAKE_EXECS.get().unwrap().lock().unwrap(); + let state = map.get_mut(&handle).unwrap(); + state.cancel_status = STATUS_INVALID_ARGUMENT; + state.release_status = STATUS_INVALID_HANDLE; + } + let mut client = NvxClient::from_library(fake_library(), handle); + let error = client + .exec_and_wait_with_capture_limit(&sandbox_id, &request, 8) + .unwrap_err(); + assert_eq!(error.code, MxcErrorCode::BackendError); + let primary = "NVX signed bundled agent: combined stdout/stderr capture limit of 8 bytes exceeded"; + if cleanup_fails { + assert!(error.message.starts_with(primary)); + assert!(error.message.contains("; cleanup failure: ")); + assert!(error.message.contains("rejected an argument")); + assert!(error.message.contains("; release failure: ")); + assert!(error.message.contains("unknown handle")); + } else { + assert_eq!(error.message, primary); + } + assert_eq!(fake_calls(handle), ["cancel", "release"]); + { + let map = FAKE_EXECS.get().unwrap().lock().unwrap(); + let state = map.get(&handle).unwrap(); + assert_eq!( + state.steps.len(), + 1, + "must not consume the exit after overflow" + ); + assert_eq!(state.timeouts, [-1, -1]); + } + // The fake host uses the same handle, so inspect execution cleanup before close. + client.close().unwrap_or_else(|error| { + assert!(cleanup_fails, "unexpected close error: {error}"); + }); + } + } + } + #[test] fn real_staged_dll_loads_verifies_cached_digest_and_rejects_bad_manifest() { let bundle_dir = discover_bundle_dir().expect("discover staged NVX bundle"); diff --git a/src/mxc-sdk/tests/nvx_e2e.rs b/src/mxc-sdk/tests/nvx_e2e.rs index a227744b5a..ba75744ad0 100644 --- a/src/mxc-sdk/tests/nvx_e2e.rs +++ b/src/mxc-sdk/tests/nvx_e2e.rs @@ -33,6 +33,9 @@ mod windows_x64 { use super::*; const BUNDLE_ENV: &str = "ACI_EDGE_SANDBOXES_AGENT_ARTIFACTS_DIR"; + const CAPTURE_LIMIT: usize = 64 * 1024 * 1024; + const CAPTURE_ERROR: &str = + "NVX signed bundled agent: combined stdout/stderr capture limit of 67108864 bytes exceeded"; const MIXED_BACKEND_ERROR: &str = "Configuration parse error: Multiple containment backends \ configured: 'containment' is 'microvm' (no per-backend section is defined for this \ backend), but the config includes backend section(s): processContainer. Only one backend \ @@ -102,6 +105,18 @@ mod windows_x64 { ("timeout-limit", json!({"process": {"timeout": 3_600_001}})), ("parent-cwd", json!({"process": {"cwd": "/tmp/../escape"}})), ("relative-cwd", json!({"process": {"cwd": "relative"}})), + ( + "cwd-limit", + json!({"process": {"cwd": format!("/{}", "x".repeat(4095))}}), + ), + ( + "cwd-multibyte-limit", + json!({"process": {"cwd": format!("/{}x", "\u{00e9}".repeat(2047))}}), + ), + ( + "env-entry-limit", + json!({"process": {"env": vec!["KEY=value"; 257], "inheritDefaultEnv": true}}), + ), ( "replacement-env", json!({"process": {"env": ["KEY=value"]}}), @@ -139,6 +154,41 @@ mod windows_x64 { request } + fn valid_process_boundaries() -> Vec<(&'static str, Value)> { + vec![ + ( + "cwd-boundary", + json!({"process": {"cwd": format!("/{}", "x".repeat(4094))}}), + ), + ( + "cwd-multibyte-boundary", + json!({"process": {"cwd": format!("/{}", "\u{00e9}".repeat(2047))}}), + ), + ( + "env-entry-boundary", + json!({"process": {"env": vec!["KEY=value"; 256], "inheritDefaultEnv": true}}), + ), + ( + "absent-env", + json!({"process": {"inheritDefaultEnv": true}}), + ), + ( + "empty-env", + json!({"process": {"env": [], "inheritDefaultEnv": true}}), + ), + ] + } + + fn process_limit_error(label: &str) -> Option<&'static str> { + match label { + "cwd-limit" | "cwd-multibyte-limit" => { + Some("NVX process.cwd must not exceed 4095 UTF-8 bytes") + } + "env-entry-limit" => Some("NVX process.env must not exceed 256 entries"), + _ => None, + } + } + #[test] fn host_only_request_matrix_never_reaches_a_runtime() { // Only this isolated child sees the deliberately absent bundle. Even a policy regression @@ -193,12 +243,35 @@ mod windows_x64 { } else if label == "fallback" { assert_eq!(error.code, ErrorCode::PolicyValidation); } + if let Some(expected) = process_limit_error(label) { + assert_eq!(error.code, ErrorCode::PolicyValidation, "{label}"); + assert_eq!(error.message, expected, "{label}"); + } println!( "HOST_ONLY rejected {label}: {:?}: {}", error.code, error.message ); } + for (label, patch) in valid_process_boundaries() { + let error = + mxc_sdk::__ffi::run_json(&request_with_patch(patch).to_string(), true).unwrap_err(); + assert_eq!( + error.code, + ErrorCode::BackendUnavailable, + "{label}: {error:?}" + ); + assert!( + error.message.contains("holds no agent bundle"), + "{label}: {error:?}" + ); + assert_no_run_state(std::process::id()); + println!( + "HOST_ONLY valid {label} reaches missing bundle: {}", + error.message + ); + } + let request = process_only_request("printf forbidden").to_string(); let error = mxc_sdk::__ffi::run_json(&request, true).unwrap_err(); assert_eq!(error.code, ErrorCode::BackendUnavailable); @@ -277,8 +350,24 @@ mod windows_x64 { "{label} was not rejected before bundle discovery: {stderr}" ); assert!(!String::from_utf8_lossy(&output.stdout).contains("forbidden")); + if let Some(expected) = process_limit_error(label) { + assert!(stderr.contains("policy_validation"), "{label}: {stderr}"); + assert!(stderr.contains(expected), "{label}: {stderr}"); + } println!("HOST_ONLY executor rejected {label}: {stderr}"); } + for (label, patch) in valid_process_boundaries() { + let output = executor_output(&request_with_patch(patch), true); + assert!(!output.status.success(), "missing bundle accepted {label}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("backend_unavailable"), "{label}: {stderr}"); + assert!( + stderr.contains("holds no agent bundle"), + "{label}: {stderr}" + ); + assert!(output.stdout.is_empty()); + println!("HOST_ONLY executor valid {label} reaches missing bundle: {stderr}"); + } } fn nonce() -> String { @@ -310,6 +399,22 @@ mod windows_x64 { result } + fn capture_boundary_request(stderr_bytes: usize) -> Value { + // Finite output bounds host memory and run time even if the capture guard regresses. + let mut request = process_only_request(&format!( + "head -c {} /dev/zero && head -c {stderr_bytes} /dev/zero >&2", + CAPTURE_LIMIT / 2 + )); + request["process"]["timeout"] = json!(0); + request + } + + fn assert_capture_boundary(stdout: &[u8], stderr: &[u8], stderr_bytes: usize) { + assert_eq!(stdout.len(), CAPTURE_LIMIT / 2); + assert_eq!(stderr.len(), stderr_bytes); + assert!(stdout.iter().chain(stderr).all(|&byte| byte == 0)); + } + #[test] #[ignore = "VM: requires Windows x64, WHP and the exact signed official bundle"] fn signed_bundle_facade_create_and_run() { @@ -351,7 +456,24 @@ mod windows_x64 { let result = facade_run(process_only_request("printf after-timeout")); assert_eq!(result.outcome, WaitResult::Exited(0)); assert_eq!(result.stdout, b"after-timeout"); - println!("VM_EXECUTED facade sequential stdout/stderr/exit/env/cwd/timeout/cleanup"); + + for stderr_bytes in [CAPTURE_LIMIT / 2 - 1, CAPTURE_LIMIT / 2] { + let result = facade_run(capture_boundary_request(stderr_bytes)); + assert_eq!(result.outcome, WaitResult::Exited(0)); + assert_capture_boundary(&result.stdout, &result.stderr, stderr_bytes); + } + let error = mxc_sdk::__ffi::run_json( + &capture_boundary_request(CAPTURE_LIMIT / 2 + 1).to_string(), + true, + ) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::BackendError); + assert_eq!(error.message, CAPTURE_ERROR); + assert_no_run_state(std::process::id()); + let result = facade_run(process_only_request("printf after-overflow")); + assert_eq!(result.outcome, WaitResult::Exited(0)); + assert_eq!(result.stdout, b"after-overflow"); + println!("VM_EXECUTED facade sequential stdout/stderr/exit/env/cwd/timeout/capture-limit/cleanup"); } #[test] @@ -389,6 +511,25 @@ mod windows_x64 { let output = executor_output(&process_only_request("printf after-timeout"), false); assert_eq!(output.status.code(), Some(0)); assert_eq!(output.stdout, b"after-timeout"); - println!("VM_EXECUTED executor sequential stdout/stderr/exit/timeout/cleanup"); + for stderr_bytes in [CAPTURE_LIMIT / 2 - 1, CAPTURE_LIMIT / 2] { + let output = executor_output(&capture_boundary_request(stderr_bytes), false); + assert_eq!(output.status.code(), Some(0)); + assert_capture_boundary(&output.stdout, &output.stderr, stderr_bytes); + } + let output = executor_output(&capture_boundary_request(CAPTURE_LIMIT / 2 + 1), false); + assert_eq!(output.status.code(), Some(-1)); + assert!( + output.stdout.is_empty(), + "overflow must not relay partial output" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains(CAPTURE_ERROR), "{stderr}"); + assert!(!stderr.contains("NVX workload timed out"), "{stderr}"); + let output = executor_output(&process_only_request("printf after-overflow"), false); + assert_eq!(output.status.code(), Some(0)); + assert_eq!(output.stdout, b"after-overflow"); + println!( + "VM_EXECUTED executor sequential stdout/stderr/exit/timeout/capture-limit/cleanup" + ); } } From 4817d1f85b3aa48b9b0edb166bea75a1421d9d0a Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Mon, 26 Oct 2026 11:33:58 -0700 Subject: [PATCH 10/18] Integrate NVX validation and clarify backend support Address SDK test integration, backend documentation placement, and signed bundle maintenance feedback. Preserve the microvm feature name and Windows x64 Phase1 scope. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/Build.Windows.Job.yml | 16 ++ .github/workflows/microvm-e2e.yml | 116 -------- README.md | 2 +- docs/backends/nanvix/nanvix.md | 199 +------------- docs/backends/nvx/nvx.md | 201 ++++++++++++++ docs/containment-policy/dev/policy.md | 18 +- .../architecture/repository-architecture.md | 2 +- .../ci-validation-infrastructure.md | 25 +- docs/schema.md | 21 +- scripts/ci/prepare-windows-host.ps1 | 19 +- scripts/ci/run_backend_validation_tests.ps1 | 35 ++- scripts/ci/stage-nvx-validation-tests.ps1 | 65 +++++ scripts/ci/validation-test-matrix.json | 4 +- scripts/tests/microvm-validation.test.js | 253 ++++++++++++++++++ src/mxc-sdk/README.md | 16 +- src/mxc-sdk/build/build_nvx_bundled.rs | 1 + src/mxc-sdk/tests/nvx_e2e.rs | 102 ++++++- .../tests/wxc_e2e_tests_e2e_windows.rs | 4 +- tests/scripts/README.md | 16 +- 19 files changed, 745 insertions(+), 370 deletions(-) delete mode 100644 .github/workflows/microvm-e2e.yml create mode 100644 docs/backends/nvx/nvx.md create mode 100644 scripts/ci/stage-nvx-validation-tests.ps1 create mode 100644 scripts/tests/microvm-validation.test.js diff --git a/.github/workflows/Build.Windows.Job.yml b/.github/workflows/Build.Windows.Job.yml index df03736ef4..bdf4d3362e 100644 --- a/.github/workflows/Build.Windows.Job.yml +++ b/.github/workflows/Build.Windows.Job.yml @@ -83,6 +83,20 @@ jobs: --test nvx_e2e windows_x64::host_only_executor_request_matrix -- --ignored --exact + - name: Test MicroVM validation infrastructure + if: matrix.target == 'x86_64-pc-windows-msvc' + working-directory: ${{ github.workspace }} + run: node --test scripts/tests/microvm-validation.test.js + + - name: Stage SDK MicroVM tests for scheduled validation + if: matrix.target == 'x86_64-pc-windows-msvc' + working-directory: ${{ github.workspace }} + shell: pwsh + run: | + & ./scripts/ci/stage-nvx-validation-tests.ps1 ` + -BinaryDirectory 'src/target/${{ matrix.target }}/release' ` + -Features '${{ matrix.features }}' + - name: Build debug executor for isolated telemetry smoke tests run: cargo build --locked -p wxc --features test-support @@ -125,5 +139,7 @@ jobs: src/target/${{ matrix.target }}/release/wslcsdk.dll src/target/${{ matrix.target }}/release/wxc-ui-probe.exe src/target/${{ matrix.target }}/release/wxc-test-driver.exe + src/target/${{ matrix.target }}/release/nvx-e2e-tests.exe + src/target/${{ matrix.target }}/release/aci-edge-sandboxes/ if-no-files-found: error retention-days: 1 diff --git a/.github/workflows/microvm-e2e.yml b/.github/workflows/microvm-e2e.yml deleted file mode 100644 index f4e2fbcb82..0000000000 --- a/.github/workflows/microvm-e2e.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: Integration Tests - -# Retained until the unified test matrix has equivalent MicroVM coverage. - -on: - push: - branches: [main] - pull_request: - branches: [main, 'feature/*', 'user/*'] - workflow_dispatch: - -permissions: - actions: write - contents: read - -jobs: - microvm-e2e: - name: WXC-Exec MicroVM - runs-on: windows-latest - timeout-minutes: 30 - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Setup Rust toolchain - run: | - rustup update stable - rustup target add x86_64-pc-windows-msvc - - - name: Point cargo at the MxcDependencies feed - uses: ./.github/actions/setup-cargo-feed - - - name: Cache Rust build artifacts - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: src - - - name: Build with MicroVM support - working-directory: src - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: cargo build -p wxc --features microvm --target x86_64-pc-windows-msvc - - - name: Exclude build output from Windows Defender - shell: pwsh - run: | - $binDir = Join-Path $env:GITHUB_WORKSPACE "src\target\x86_64-pc-windows-msvc\debug" - Add-MpPreference -ExclusionPath $binDir - Write-Host "Added Defender exclusion for $binDir" - - - name: Verify MicroVM executable - shell: pwsh - run: | - $binDir = Join-Path $env:GITHUB_WORKSPACE "src\target\x86_64-pc-windows-msvc\debug" - $required = @((Join-Path $binDir "wxc-exec.exe")) - $required += Join-Path $binDir "aci-edge-sandboxes\bundle-manifest.json" - $missing = $required | Where-Object { -not (Test-Path $_ -PathType Leaf) } - if ($missing) { - Write-Host "::error::Missing binaries: $($missing -join ', ')" - exit 1 - } - Get-Item $required | Format-Table FullName, Length - - - name: Run MicroVM bundle integrity and host-only tests - shell: pwsh - working-directory: src - run: | - cargo test -p mxc-sdk --features microvm --test nvx_bundle_integrity --target x86_64-pc-windows-msvc - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cargo test -p mxc-sdk --features microvm --test nvx_e2e --target x86_64-pc-windows-msvc - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cargo test -p mxc-sdk --features microvm --test nvx_e2e --target x86_64-pc-windows-msvc windows_x64::host_only_executor_request_matrix -- --ignored --exact --nocapture - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Diagnose hypervisor environment - shell: pwsh - run: ./scripts/ci/diagnose-whp.ps1 - - - name: Check Windows Hypervisor Platform - id: whp-check - shell: pwsh - run: ./scripts/ci/check-whp.ps1 - - - name: Run MicroVM E2E Tests - if: steps.whp-check.outputs.whp_available == 'true' - shell: pwsh - working-directory: src - run: | - cargo test -p mxc-sdk --features microvm --test wxc_e2e_tests_e2e_windows --target x86_64-pc-windows-msvc test_microvm_ -- --include-ignored --nocapture - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cargo test -p mxc-sdk --features microvm --test nvx_e2e --target x86_64-pc-windows-msvc -- --include-ignored --nocapture --test-threads=1 - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Upload logs on failure - if: failure() || cancelled() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: microvm-e2e-logs-${{ github.event.pull_request.number || github.run_number }} - retention-days: 7 - path: | - logs/ - **/*.log - - - name: Print performance summary - if: steps.whp-check.outputs.whp_available == 'true' && !cancelled() - shell: pwsh - run: ./scripts/ci/print-perf-summary.ps1 -JsonPath (Join-Path $env:GITHUB_WORKSPACE "microvm-perf-results.json") - - - name: Upload performance results - if: steps.whp-check.outputs.whp_available == 'true' && !cancelled() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: microvm-perf-results-${{ github.event.pull_request.number || github.run_number }} - retention-days: 30 - path: microvm-perf-results.json diff --git a/README.md b/README.md index 9393a831fb..089a824d38 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ with `--with-microvm` stages the pinned signed NVX agent bundle and requires the runtime `--experimental` opt-in. It supports create-and-run execution with the command, timeout, guest working directory, and additive environment settings. See the -[NVX integration notes](src/mxc-sdk/README.md#experimental-nvx-create-and-run) +[NVX backend guide](docs/backends/nvx/nvx.md) for build requirements and supported execution. ## How do I use MXC? diff --git a/docs/backends/nanvix/nanvix.md b/docs/backends/nanvix/nanvix.md index 0ea9ed61db..e4fc6ea2c6 100644 --- a/docs/backends/nanvix/nanvix.md +++ b/docs/backends/nanvix/nanvix.md @@ -1,195 +1,8 @@ -# Nanvix MicroVM Backend +# MicroVM backend -> **Audience:** MXC consumers and developers +The current `microvm` implementation uses the signed NVX runtime on Windows +x64. See the [NVX backend guide](../nvx/nvx.md) for supported platforms, +operations, policy, build requirements, and bundle maintenance. -Nanvix MicroVM is an experimental containment backend for MxC. It is powered by the -[Nanvix OS/VM](https://aka.ms/nanvix) and runs untrusted code with hardware-enforced isolation -via the Windows Hypervisor Platform (WHP) on Windows or KVM on Linux. - -## Key Features - -- **Fast cold-start** — ~100 ms from process spawn to guest code execution (Windows warm-start via WHP snapshot; Linux uses cold boot via KVM every run) -- **Low Memory Footprint** — Resident memory size of ~100 MB -- **Hardware-Enforced Isolation** — Runs guest code inside a lightweight virtual machine (VM) - -## Requirements - -### Windows - -- Windows with WHP enabled (`bcdedit /set hypervisorlaunchtype auto`) -- Nanvix runtime binaries (`nanvixd.exe`, `kernel.elf`, `python3.initrd`, `nanvix_rootfs.img`) placed next to `wxc-exec.exe` -- Build with `--with-microvm` (`build.bat --with-microvm` or `cargo build -p wxc --features microvm`) -- `--experimental` flag (Nanvix MicroVM is an experimental feature) - -### Linux - -- Linux with KVM available at `/dev/kvm` (and the invoking user has read/write access to it) -- Nanvix runtime binaries (`nanvixd.elf`, `kernel.elf`, `python3.initrd`, `nanvix_rootfs.img`) placed next to `lxc-exec` (the build script downloads and stages them automatically) -- Build with `--with-microvm` (`./build.sh --with-microvm` or `cargo build -p lxc --features microvm`) -- `--experimental` flag (Nanvix MicroVM is an experimental feature) - -> **Note:** On Linux, WHP snapshots are not used. Each invocation cold-boots -> the VM via KVM. Snapshot-based warm-start is Windows-only. - -### Offline builds - -By default the `nanvix_binaries` build script downloads the NanVix release -assets at compile time. For air-gapped or hermetic builds, pre-fetch the -binaries and point the `NANVIX_BIN` environment variable at the directory -containing them: - -``` -# Windows (PowerShell) -$env:NANVIX_BIN = "C:\path\to\nanvix-binaries" - -# Linux / macOS -export NANVIX_BIN=/path/to/nanvix-binaries -``` - -When `NANVIX_BIN` is set, the build performs no network downloads and uses the -provided directory directly. The directory must contain the required binaries -(the flat files plus the `bin/` subdirectory); their checksums are still -verified against `checksums.json`. The easiest way to produce such a directory -is to run a normal `--with-microvm` build once and copy the staged -`nanvix-binaries` directory out of `OUT_DIR`. - -> **Snapshots are not trusted in offline mode.** WHP warm-start snapshots -> (`snapshots/kernel.vmem`, `snapshots/kernel.whp.cbor`) are *not* covered by -> `checksums.json` — in a normal build they are generated locally, not -> downloaded. Any `snapshots/` directory inside `NANVIX_BIN` is therefore -> ignored (never copied next to the executable), and the runtime cold-boots on -> first use to regenerate a verified snapshot. This prevents an unverified VM -> memory image from being warm-booted against otherwise-verified binaries. - -## Quick Start - -```json -{ - "version": "1.1.0-alpha", - "process": { - "commandLine": "print('Hello from MicroVM!')", - "timeout": 30000 - }, - "containment": "microvm" -} -``` - -```bash -wxc-exec.exe --experimental config.json -``` - -## SDK Usage - -Use `spawnSandboxFromConfig` with `usePty: false` for reliable exit codes and -separate stdout/stderr streams: - -```typescript -const child = spawnSandboxFromConfig(config, { - experimental: true, - usePty: false, -}); - -``` - -## Filesystem Policy - -### readwrite_paths - -Host directories or files listed in `readwritePaths` are copied into a private -per-run staging directory before boot. Nanvix mounts are snapshot-based — host -files are **not** modified while the guest is running. No junctions or live host -mounts are used. - -```json -{ - "version": "1.1.0-alpha", - "process": { - "commandLine": "import os\npath = 'C:\\\\Users\\\\me\\\\work'\nwith open(os.path.join(path, 'result.txt'), 'w') as f:\n f.write('done')", - "timeout": 30000 - }, - "containment": "microvm", - "filesystem": { - "readwritePaths": ["C:\\Users\\me\\work"] - } -} -``` - -Inside the guest, host paths in the script are transparently rewritten to their -guest mount equivalents at staging time. The script uses the original host paths -and the staging layer translates them before the code reaches the VM. - -| Host path | Guest path | -| ------------------ | --------------------------- | -| `C:\Users\me\work` | `/mnt/rw/c/Users/me/work` | -| `C:\data\ref-data` | `/mnt/rw/c/data/ref-data` | - -**Copyback semantics:** After `nanvixd` exits normally, MXC copies the modified -snapshot back to the original host paths. Copyback runs for both exit code `0` -and non-zero guest exit codes. It is skipped for preflight failure, spawn -failure, watchdog timeout, and runner/runtime errors — no partial state is -leaked to the host. - -### readonly_paths - -Host directories listed in `readonlyPaths` are copied into the staging directory -with read-only file attributes. Writes return `EACCES`. Read-only paths are -never copied back to the host. - -```json -{ - "filesystem": { - "readonlyPaths": ["C:\\data\\reference"] - } -} -``` - -### denied_paths - -Not supported for MicroVM. If `deniedPaths` is specified, the config is rejected with an error. - -## Constraints - -| Constraint | Value | -| --------------------------------------- | ------------------------------------------- | -| Single file size | < 4 GB (FAT32 limit) | -| Guest RAM | 256 MB | -| Symlinks/reparse points in source paths | Not supported (rejected at preflight) | -| Junctions for staging | Not used | -| `workingDirectory` | Not supported (guest CWD is `/`) | -| Network policy | v1.1 isolated or explicitly unrestricted host networking | - -## Networking - -Host networking is **opt-in**. Exact v1.1 supports two coherent directional -postures: all three of egress default, ingress default and host-loopback deny -leaves networking disabled; all three explicitly allow enables unrestricted -host networking through `-allow-host-networking`. Mixed postures are rejected, -including egress allow with omitted ingress defaults. - -```json -{ - "version": "1.1.0-alpha", - "containment": "microvm", - "process": { "commandLine": "print('network enabled')" }, - "network": { - "egress": { "default": "allow" }, - "ingress": { "default": "allow", "hostLoopback": "allow" } - } -} -``` - -Disabled networking prevents guest socket creation (`OSError: [Errno 134]`). -Unrestricted networking includes host-backed bind/listen capabilities; -NanVix cannot independently enforce ingress or host-loopback restrictions. -Directional egress rules are explicitly rejected rather than translated to -the guest's IPv4 host filter, which cannot implement their full semantics, -including default-deny DNS. -Runtime proxy configuration is also unsupported. - -## Not Supported - -| Workload | Error | -| -------------------------------------------- | -------------------------------- | -| Mixed directional networking or egress rules | Rejected before VM creation | -| Runtime proxy | Rejected before VM creation | -| File writing outside `/mnt/rw/` | `OSError: Read-only file system` | +The previous Nanvix runtime, Linux execution path, and filesystem copy-back +workflow are not available in this phase. diff --git a/docs/backends/nvx/nvx.md b/docs/backends/nvx/nvx.md new file mode 100644 index 0000000000..98dcf04573 --- /dev/null +++ b/docs/backends/nvx/nvx.md @@ -0,0 +1,201 @@ +# NVX MicroVM backend + +> **Audience:** MXC consumers and developers + +The experimental `microvm` backend uses the signed NVX agent bundle to run a +command in a Linux guest on a Windows x64 host. Enable the Cargo feature +`microvm` and authorize experimental execution at runtime. The Cargo feature +and the JSON selector `containment: "microvm"` retain their existing names. + +Phase 1 supports Windows x64 only. Windows ARM64 and Linux hosts do not have an +active MicroVM execution path in this phase; support for those platforms is +future work. Enabling the feature does not make an unsupported host available. + +## Requirements and quick start + +The host needs Windows Hypervisor Platform, hardware virtualization, and a +running hypervisor. Enable the `HypervisorPlatform` Windows optional feature +and restart the host before running a workload. In a virtualized host, nested +virtualization must also be available. + +From the repository root, build the executor: + +```powershell +build.bat --with-microvm +``` + +The build stages the pinned official `aci-edge-sandboxes.zip` release +`v0.1.0-dev.0430ce79d125`, from NVX revision +`0430ce79d125203c20f0aa69243480513a774296`. Distribute the complete +`aci-edge-sandboxes` directory beside the executable, including its manifests, +runtime binaries, guest images, and provenance files. Do not substitute a +standalone OpenVMM executable or a partial bundle. + +Use the development `1.1.0-alpha` request contract: + +```json +{ + "version": "1.1.0-alpha", + "containment": "microvm", + "process": { + "commandLine": "printf 'hello from the guest\\n'", + "timeout": 30000 + } +} +``` + +```powershell +wxc-exec.exe --experimental config.json +``` + +Commands run in the guest's POSIX shell, not in a host shell or a Python +interpreter. The workload image is fixed to +`mcr.microsoft.com/azurelinux/base/core:3.0`; requests cannot select another +image in Phase 1. The native agent pulls that image on the host, so the host +needs access to the registry even though guest networking is not enabled. + +## Supported operations and policy + +Schema-valid fields do not imply backend support. MXC rejects unsupported +policy before creating a VM rather than ignoring it. + +| Surface | Phase 1 support | +|---|---| +| Host platforms | Windows x64 with WHP. Windows ARM64 and Linux execution are unavailable. | +| Execution | Create-and-run development JSON requests with captured stdout, stderr, exit status, and timeout. | +| Typed V1 SDK APIs | This phase does not add MicroVM targeting to the published typed APIs. | +| Streaming, PTY, and container lifecycle operations | Unsupported. | +| Workload image | Fixed Azure Linux image above; no request-level image selection. | +| Working directory | Optional absolute POSIX guest path; see the limits below. | +| Environment | Guest default, optionally extended with additive entries; see below. | +| Process timeout | `0` means no workload timeout; nonzero values may not exceed 3,600,000 ms. | +| Captured output | At most 64 MiB (67,108,864 bytes) combined stdout and stderr. | +| Filesystem policy | No host mounts or copy-back. Nonempty `readonlyPaths`, `readwritePaths`, or `deniedPaths` are rejected. | +| Network policy | A supplied `network` section is rejected, including empty or deny-only sections. Omit it. | +| Runtime proxy | `runtimeConfig.networkProxy` is rejected. Proxy environment variables are also rejected. | +| UI policy | A supplied `ui` section is rejected. | +| Fallback policy | A supplied `fallback` section is rejected. | +| Lifecycle options | `destroyOnExit` must be `true` and `preservePolicy` must be `false`. | + +Backend-specific sections must match the selected backend. For example, +`processContainer.filesystem.enumeratePaths` and +`processContainer.captureDenials` cannot accompany +`containment: "microvm"`; the parser rejects that combination. + +### Working directory and environment + +When `process.cwd` is omitted, MXC leaves it unset in the agent request and +uses the guest's default working directory. An explicit directory is a guest +path, not a host path: it must be absolute, contain no `..` path components, +and occupy at most 4095 UTF-8 bytes. It cannot contain a null byte. + +When `process.env` is present, including an empty array, +`process.inheritDefaultEnv` must be `true`. Entries extend the guest's default +environment rather than replace it. At most 256 entries and 32 KiB including +entry terminators are accepted. Entries must be `KEY=VALUE` strings with +nonempty names and no null bytes. Repeated names count as separate entries; +later entries override earlier ones. + +`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` are rejected +case-insensitively. MXC validates these process limits before bundle discovery, +then validates the mapped request with the native agent before probing the +host or provisioning a container. + +### Output, timeout, and cleanup + +Output up to the capture limit is returned unchanged, including non-UTF-8 +bytes. Exceeding the combined limit cancels and releases execution, tears down +the container, and returns `BackendError` rather than truncated output. The +limit also applies when the workload timeout is zero. Cleanup failures are +reported alongside the original failure. + +A workload timeout preserves captured partial output and tears down the +container. Each create-and-run request gets fresh workload state; this phase +does not offer persistent containers or manual lifecycle operations. + +## Validation + +Host-only policy and bundle-integrity checks run in normal Windows x64 PR +builds. Actual VM execution runs in the unified nightly validation matrix on +Windows 25H2 x64. It consumes the same Windows build artifact, including the +compiled `nvx_e2e` tests and the complete signed bundle; the validation host +does not rebuild them. + +The SDK suite covers both the executor and captured JSON execution, binary +stdout/stderr, exit codes, environment and working-directory settings, +timeouts, capture limits, cleanup, and the seven MicroVM config fixtures. +Missing prerequisites, skipped scenarios, and empty test runs fail validation. +See the [CI validation runbook](../../development/build-and-test/ci-validation-infrastructure.md). + +To run the suite locally on a prepared Windows x64 host, from `src`: + +```powershell +cargo build --locked -p wxc --features microvm --target x86_64-pc-windows-msvc +cargo test --locked -p mxc-sdk --features microvm --test nvx_bundle_integrity --test nvx_e2e --target x86_64-pc-windows-msvc +cargo test --locked -p mxc-sdk --features microvm --test nvx_e2e --target x86_64-pc-windows-msvc -- --include-ignored --nocapture --test-threads=1 +``` + +## Maintainers: updating the bundled runtime + +Update the pinned release as one reviewed change. Do not remove verification +or accept arbitrary replacement files to get an upgrade working. + +1. Select an official signed release from + [microsoft/nvx releases](https://github.com/microsoft/nvx/releases). + Review its signing and provenance records and confirm the supported host + architecture and guest image. Hashing a downloaded archive alone does not + establish its provenance. + +2. Download the candidate archive and inventory its hashes. For example: + + ```powershell + $release = 'v0.1.0-dev.0430ce79d125' # Replace with the reviewed candidate. + $download = Join-Path $env:TEMP "mxc-nvx-$release" + $bundle = Join-Path $download 'bundle' + New-Item -ItemType Directory -Force -Path $download | Out-Null + gh release download $release --repo microsoft/nvx --pattern aci-edge-sandboxes.zip --dir $download + $archive = Join-Path $download 'aci-edge-sandboxes.zip' + Get-FileHash -LiteralPath $archive -Algorithm SHA256 + Expand-Archive -LiteralPath $archive -DestinationPath $bundle + Get-ChildItem -LiteralPath $bundle -File -Recurse | ForEach-Object { + [pscustomobject]@{ + Path = $_.FullName.Substring($bundle.Length + 1).Replace('\', '/') + SHA256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + } | Format-Table -AutoSize + ``` + +3. Check the candidate's C ABI against + `src/mxc-sdk/src/backends/nvx/abi.rs`, including + `SANDBOX_ABI_VERSION`, required exports, request and response formats, + status values, and ownership rules. An incompatible ABI needs adapter + changes and coverage, not just a version-constant change. + +4. Update `OFFICIAL_BUNDLE_URL`, `OFFICIAL_BUNDLE_ZIP_SHA256`, and the complete + `OFFICIAL_BUNDLE_FILES` inventory in + `src/mxc-sdk/build/build_nvx_bundled.rs`, including release-specific + diagnostics. Synchronize the `APPROVED_*_SHA256` runtime pins in + `src/mxc-sdk/src/backends/nvx/runtime.rs`. Keep every manifest, guest, + helper, and provenance file in the inventory. + +5. Update the pinned-release assertions in + `src/mxc-sdk/tests/nvx_bundle_integrity.rs` and the release/revision + references in this guide and `src/mxc-sdk/README.md`. Review any changed + policy or process limits against the candidate agent before adjusting them. + +6. Run the build and three validation commands above against the candidate. + Integrity tests must still reject missing, modified, and unlisted files. + Confirm both executor and captured JSON VM scenarios actually execute, + including timeout, capture-limit, and cleanup checks. From the repository + root, also run: + + ```powershell + node --test scripts\tests\microvm-validation.test.js + node scripts\ci\resolve-validation-test-matrix.mjs --plan nightly + ``` + +For an offline build, set `ACI_EDGE_SANDBOXES_AGENT_BUNDLE_DIR` to a directory +containing the exact pinned archive or its complete extracted contents. The +build still verifies the pinned archive and/or file hashes. Stage the resulting +complete `aci-edge-sandboxes` directory with the executor, and validate the +packaged artifact on a prepared host before accepting the upgrade. diff --git a/docs/containment-policy/dev/policy.md b/docs/containment-policy/dev/policy.md index 4924aee909..c417424f7b 100644 --- a/docs/containment-policy/dev/policy.md +++ b/docs/containment-policy/dev/policy.md @@ -51,8 +51,8 @@ sandbox. | Field | Type and meaning | |---|---| -| `process.commandLine` | Required non-empty string; command to run (guest source code for Hyperlight/MicroVM). | -| `process.cwd` | Optional string; explicit working directory. An unusable path fails rather than falling back. If omitted, each backend chooses its own usable default, not necessarily the caller's directory. Hyperlight and MicroVM reject an explicit working directory. | +| `process.commandLine` | Required non-empty string; command to run (guest source code for Hyperlight, a guest shell command for NVX MicroVM). | +| `process.cwd` | Optional string; explicit working directory. An unusable path fails rather than falling back. If omitted, each backend chooses its own usable default, not necessarily the caller's directory. Hyperlight rejects an explicit working directory; MicroVM uses a guest path, as described in the [NVX guide](../../backends/nvx/nvx.md#working-directory-and-environment). | | `process.timeout` | Optional integer milliseconds, `0` to `4294967295`; `0` means no timeout. | | `process.env` | Optional array of `KEY=VALUE` strings. Omitted uses the backend default; supplied entries replace it unless `inheritDefaultEnv` is `true`. | | `process.inheritDefaultEnv` | Optional boolean (default `false`); layers supplied `env` entries over the backend default, with caller entries winning. | @@ -64,8 +64,10 @@ With `env: []` and `inheritDefaultEnv: false`, the request asks for an empty environment. Windows ProcessContainer refuses a supplied environment missing `SYSTEMROOT` or `LOCALAPPDATA`; IsolationSession cannot replace its user's default environment and requires `inheritDefaultEnv: true` for supplied `env`. -MicroVM rejects `deniedPaths`; Hyperlight checks denied paths against its -mounted grants. Path strings use the selected host/backend's conventions. +Hyperlight checks denied paths against its mounted grants. See the +[NVX capability table](../../backends/nvx/nvx.md#supported-operations-and-policy) +for MicroVM filesystem and environment support. Path strings use the selected +host/backend's conventions. ### Network and UI @@ -153,10 +155,10 @@ WSLc networking supports only all-deny isolated or all-allow bridged rejects mixed postures and per-destination rules. IsolationSession cannot restrict networking: its request must explicitly set **all three** actions to `"allow"` and cannot carry rules or a proxy. An empty or absent `network` -section is rejected for IsolationSession. MicroVM permits all-deny isolated -or explicitly all-allow unrestricted host networking; mixed postures and -per-destination rules are rejected. Hyperlight rejects a supplied `network` -section. Windows Sandbox create-and-run execution blocks networking when +section is rejected for IsolationSession. MicroVM networking support is +described in the [NVX capability table](../../backends/nvx/nvx.md#supported-operations-and-policy). +Hyperlight rejects a supplied `network` section. Windows Sandbox +create-and-run execution blocks networking when `network` is omitted; it rejects supplied `network.egress` or `network.ingress` sections (even empty or deny-only), host filters, and proxies. Backend support is not implied by a field appearing in this diff --git a/docs/development/architecture/repository-architecture.md b/docs/development/architecture/repository-architecture.md index 754d39b58c..d5e9ed3998 100644 --- a/docs/development/architecture/repository-architecture.md +++ b/docs/development/architecture/repository-architecture.md @@ -50,7 +50,7 @@ binary targets of the same publishable package under `src/mxc-sdk/src/bin/`: | Windows Sandbox | SDK common/lifecycle modules plus `src/mxc-sdk/src/bin/windows_sandbox_{daemon,guest}/` | | WSLC | `src/mxc-sdk/src/backends/wslc/common/` plus `src/mxc-sdk/src/bin/wslc_daemon/` | | IsolationSession | SDK bindings and runtime modules under `backends/isolation_session/` | -| NanVix | SDK common, runner, binary-staging, and build modules under `backends/nanvix/` | +| NVX MicroVM | SDK backend modules under `backends/nvx/`; signed-bundle staging in `build/build_nvx_bundled.rs` | | Windows Learning Mode | `src/mxc-sdk/src/core/learning_mode_windows/` over the shared core module | Shared parsing and normalization live in the `mxc_common` module; backend-specific policy diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index 91149580fe..2dd44d8153 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -32,6 +32,7 @@ the individual local test scripts are documented in | `.github/workflows/Validation.Tests.Matrix.Job.yml` | `workflow_call`-only. Resolves the plan and runs the per-family test jobs. | | `.github/workflows/Package.IsolationSession.TestBundle.Job.yml` | `workflow_call`-only. Builds the IsolationSession test bundle natively on x64 and arm64 and uploads it as `isolation-session-test-bundle-`. | | `scripts/ci/build-isolation-session-test-bundle.ps1` | Builds and assembles the IsolationSession test bundle, and checks that each payload discovers its tests. | +| `scripts/ci/stage-nvx-validation-tests.ps1` | Stages the compiled Windows x64 SDK MicroVM tests in the existing Windows artifact and checks required test discovery. | | `scripts/ci/validation-test-matrix.json` | The matrix: OS versions, backends, triggers, job staggering. | | `scripts/ci/resolve-validation-test-matrix.mjs` | Matrix validator + plan expander. Emits the GitHub Actions matrices. | | `scripts/ci/prepare-windows-host.ps1` | Per-backend Windows host preparation / prerequisite assertions, plus the `winget` repair and the workload-tooling install. Runs in Windows PowerShell, because it installs the `pwsh` the steps after it use. | @@ -64,7 +65,7 @@ test jobs only ever `download-artifact`. | Job | What it does | |-----|--------------| | `dependency-feed-check` | Resolves the locked crate graph through the public `MxcDependencies` feed. Gates the builds. | -| `windows` | `Build.Windows.Job.yml` — x64 + arm64 release build, unit tests, uploads `wxc-binaries-`. | +| `windows` | `Build.Windows.Job.yml` — x64 + arm64 release build, unit tests, uploads `wxc-binaries-`. The x64 artifact also includes SDK MicroVM tests and the complete signed NVX bundle. | | `linux` | `Build.Linux.Job.yml` — x64 + arm64 release build, unit tests, Bubblewrap SDK streaming/PTY tests, `wxc_e2e_tests`, uploads `lxc-binaries-`. | | `macos` | `Build.MacOS.Job.yml` — arm64 release build, unit tests, Seatbelt SDK streaming/PTY tests, `wxc_e2e_tests`, uploads `mxc-binaries-aarch64-apple-darwin`. | | `isolation-session-bundle` | `Package.IsolationSession.TestBundle.Job.yml` — uploads `isolation-session-test-bundle-` for x64 + arm64. | @@ -122,7 +123,7 @@ Current platforms: | `windows-24h2` | windows | `1es-mxc-e2e-windows-24h2-pro-x64` | `1es-mxc-e2e-windows-24h2-pro-arm64` | process-t1, wslc, windows-sandbox, microvm | process-t1, isolation-session | | `windows-23h2` | windows | `1es-mxc-e2e-windows-23h2-enterprise-x64` | *(dormant)* | process-t3, wslc, windows-sandbox, microvm | — | | `ubuntu-26.04` | linux | `1es-mxc-e2e-ubuntu-26.04-x64` | *(dormant)* | bubblewrap, lxc | — | -| `ubuntu-24.04` | linux | `1es-mxc-e2e-ubuntu-24.04-x64` | *(dormant)* | bubblewrap, microvm, lxc | — | +| `ubuntu-24.04` | linux | `1es-mxc-e2e-ubuntu-24.04-x64` | *(dormant)* | bubblewrap, lxc | — | | `rhel-10` | linux | `1es-mxc-e2e-rhel-10-x64` | *(dormant)* | bubblewrap, lxc | — | | `debian-13` | linux | `1es-mxc-e2e-debian-13-x64` | *(dormant)* | bubblewrap, lxc | — | | `macos-27` | macos | — | runner `xcode-27` (public preview) | — | seatbelt | @@ -233,7 +234,7 @@ get fixed or wired. | WSLC | ✅ Good | Might have to retry hung jobs - this is an issue with overzealous agent reclaiming. | | IsolationSession | ✅ Good | Runs the one-shot and state-aware suites, the Rust SDK in-process and helper tests, the C# end-to-end tests, the Node SDK suite and the COM apartment probe. Fails when the host cannot run isolation sessions, when a suite executes nothing, or when the run changes the set of local accounts. | | Windows Sandbox | ⛔ Blocked | Images don't support `Containers-DisposableClientVM` opt. feature | -| MicroVM | Experimental | Signed NVX on Windows x64, covered by `microvm-e2e.yml`. | +| MicroVM | Experimental | Windows 25H2 x64 nightly runs the prebuilt `mxc-sdk/tests/nvx_e2e.rs` suite, including executor config scenarios and captured JSON execution. Host-only checks remain in normal PR builds. | | Seatbelt | ✅ Good | Failures are genuine MXC bugs. | ## Host preparation @@ -248,9 +249,11 @@ every entry. `prepare-null-device --no-sacl`. T1 needs them too: the suite deliberately drives the AppContainer fallback tiers, and an unprepared host fails those launches with `WIN32_ERROR(5)` instead of reporting a policy result. -- `microvm` — asserts the NanVix payload is in the artifact, adds a Defender - exclusion for the binary directory, and requires the Windows Hypervisor - Platform feature *and* a running hypervisor. +- `microvm` — asserts the SDK test executable and signed NVX runtime files are + in the artifact, adds a Defender exclusion for the binary directory, and + requires the Windows Hypervisor Platform feature *and* a running hypervisor. + The dispatcher runs the prebuilt SDK tests without Cargo and fails on missing + VM completion markers, empty suites, skipped tests, or filtered tests. - `wslc` — asserts `wslcsdk.dll` shipped, requires the WSL and VirtualMachinePlatform optional features to be baked into the image, then installs/updates the WSL runtime (including the pre-release ring) up to the @@ -284,7 +287,9 @@ a process-container job selects follows from that build. LXC after RHEL 7 and ships no replacement. Without the zone assignment the default zone rejects the container's IPv4 DHCP, and a container holding only an IPv6 address fails every network test. -- `microvm` — asserts the NanVix payload exists. + +The historical Linux `microvm` preparation branch is not scheduled. The +Phase 1 NVX runtime supports only Windows x64. Every install above goes through two shared helpers rather than its own package-manager chain: `resolve_package_manager` picks the first of `apt-get`, @@ -398,6 +403,12 @@ step tees that into `$RUNNER_TEMP/mxc-ci.log`. The IsolationSession test bundle downloads under `artifacts/bin`, outside `$RUNNER_TEMP`, so it is not uploaded again with the logs. +MicroVM validation writes `microvm-nvx.log` and `microvm-perf-results.json` +under the redirected temporary directory, so the normal Windows log upload +collects both. The signed NVX bundle stays under `artifacts/bin` and is not +uploaded again. The former standalone `microvm-e2e.yml` workflow is retired; +real VM execution is scheduled rather than run on each PR. + ## Runbook Always finish with a local resolve, which runs the full catalog validation: diff --git a/docs/schema.md b/docs/schema.md index 2f4a4c3061..69a086b998 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -122,8 +122,8 @@ identity-less host proxies omit `allowedProxyPeer` and require deployment: it opens both host-loopback directions without restricting access to a named proxy peer. It does not enforce a proxy-only host-loopback exception. -The initial development Windows x64 NVX MicroVM backend rejects every network -policy and `runtimeConfig.networkProxy` before creating a VM. +For MicroVM networking and proxy support, see the +[NVX backend capability table](backends/nvx/nvx.md#supported-operations-and-policy). ```json { @@ -338,7 +338,7 @@ use: | Seatbelt (macOS) | Same precedence, with `~` expanded as the profile expands it; falls back to `/`. | | Bubblewrap (Linux) | No substitution — a policy grant is never adopted. `--chdir` is emitted only for an explicit `process.cwd`, which from 0.9 is also normalized against the sandbox root and used as `HOME`. With no explicit `cwd` there is no `--chdir` and `HOME` is unset — see [`docs/backends/bwrap/bubblewrap-backend.md`](backends/bwrap/bubblewrap-backend.md). | | LXC / WSL Container | The container root — see [`docs/backends/lxc/lxc-backend.md`](backends/lxc/lxc-backend.md). | -| MicroVM | NVX on Windows x64 accepts an absolute guest working directory with no `..` components and at most 4095 UTF-8 bytes. Linux MicroVM execution is inactive in this phase. | +| MicroVM | The guest default; see [NVX working-directory behaviour](backends/nvx/nvx.md#working-directory-and-environment). | | Hyperlight | Not applicable — this backend rejects a working directory outright. | Policy entries that are blank, name a file, or do not exist yet are skipped: @@ -381,10 +381,8 @@ On the WSL Container backend it is the container image's own `ENV`, which MXC neither authors nor enumerates — see [`docs/backends/wslc/wsl-container-getting-started.md`](backends/wslc/wsl-container-getting-started.md#environment). -NVX on Windows x64 requires `process.inheritDefaultEnv: true` when -`process.env` is present. It accepts at most 256 entries and 32 KiB including -entry terminators. Repeated names count as separate entries, with later entries -overriding earlier ones. +For MicroVM environment support and limits, see +[NVX working directory and environment](backends/nvx/nvx.md#working-directory-and-environment). ### Filesystem Policy @@ -396,10 +394,11 @@ The `filesystem` section defines path access policy shared across backends: | `readonlyPaths` | string[] | `[]` | Paths the process can read but not write. | | `deniedPaths` | string[] | `[]` | Paths the process cannot access at all. | -The initial development Windows x64 NVX MicroVM backend rejects -`readwritePaths`, `readonlyPaths`, and `deniedPaths`. The -`processContainer.filesystem.enumeratePaths` field is ProcessContainer-only -and also cannot be combined with `containment: "microvm"`. +Backend support for filesystem policy is described in each backend guide; see +the [NVX capability table](backends/nvx/nvx.md#supported-operations-and-policy) +for MicroVM. Backend-specific sections, including +`processContainer.filesystem.enumeratePaths`, can only accompany their matching +`containment` selection. The ProcessContainer-only `processContainer.filesystem` section contains: diff --git a/scripts/ci/prepare-windows-host.ps1 b/scripts/ci/prepare-windows-host.ps1 index 41f3005f60..b9e774f5a9 100644 --- a/scripts/ci/prepare-windows-host.ps1 +++ b/scripts/ci/prepare-windows-host.ps1 @@ -577,19 +577,20 @@ function Install-WorkloadTooling { } function Initialize-MicroVmHost { - # Staged next to wxc-exec.exe by the --features microvm build, so their - # absence means a broken artifact rather than a host problem. Snapshots are - # excluded: they are a warm-start cache the runner regenerates on demand. Assert-RequiredFile @( 'wxc-exec.exe', - 'nanvixd.exe', - 'nanvix_rootfs.img', - 'python3.initrd', - 'bin\kernel.elf' + 'nvx-e2e-tests.exe', + 'aci-edge-sandboxes\bundle-manifest.json', + 'aci-edge-sandboxes\SOURCE-MANIFEST.json', + 'aci-edge-sandboxes\SHA256SUMS', + 'aci-edge-sandboxes\bin\aci_edge_agent.dll', + 'aci-edge-sandboxes\bin\direct-images.exe', + 'aci-edge-sandboxes\bin\openvmm.exe', + 'aci-edge-sandboxes\guest\vmlinux', + 'aci-edge-sandboxes\guest\initramfs-edge.cpio.gz' ) - # NanVix boots a VM from these images on every invocation; Defender scanning - # them can push boot past its timeout. + # Scanning the signed runtime and guest images can push VM boot past its timeout. Add-MpPreference -ExclusionPath $BinaryDirectory Write-Host "Added Defender exclusion for $BinaryDirectory" diff --git a/scripts/ci/run_backend_validation_tests.ps1 b/scripts/ci/run_backend_validation_tests.ps1 index 380e6ea061..4af7a1de48 100644 --- a/scripts/ci/run_backend_validation_tests.ps1 +++ b/scripts/ci/run_backend_validation_tests.ps1 @@ -374,6 +374,37 @@ function Invoke-IsolationSessionSuites { } } +function Invoke-MicroVmTests { + if ($Architecture -ne 'x64') { + throw 'MicroVM validation requires x64.' + } + $tests = Join-Path $binaryDirectoryPath 'nvx-e2e-tests.exe' + Assert-File -Path $tests + $env:ACI_EDGE_SANDBOXES_AGENT_ARTIFACTS_DIR = Join-Path $binaryDirectoryPath 'aci-edge-sandboxes' + $run = Invoke-LoggedNative -FilePath $tests -LogName 'microvm-nvx.log' -ArgumentList @( + '--include-ignored', '--nocapture', '--test-threads=1' + ) + $failure = Get-LibtestFailure -Run $run + if ($failure) { + throw "MicroVM SDK tests $failure." + } + $output = Get-Content -LiteralPath $run.Log -Raw + if ($output -match '\bSKIP(?:PED)?:' -or + $output -notmatch '(?m)^test result: ok\. \d+ passed; 0 failed; 0 ignored; 0 measured; 0 filtered out;') { + throw 'MicroVM SDK validation skipped or filtered tests.' + } + $scenarios = [ordered]@{ + 'facade' = 'VM_EXECUTED facade sequential ' + 'executor' = 'VM_EXECUTED executor sequential ' + 'executor configs' = 'VM_EXECUTED executor configs ' + } + foreach ($scenario in $scenarios.Keys) { + if (-not $output.Contains($scenarios[$scenario])) { + throw "MicroVM SDK validation did not execute the $scenario VM scenario." + } + } +} + Redirect-TempToRunnerTemp # The matrix entry names the tier the job exists to exercise, but the host picks @@ -409,8 +440,6 @@ switch ($Backend) { } } 'microvm' { - Invoke-TestScript -Path (Join-Path $testScriptRoot 'run_microvm_tests.ps1') -Arguments @{ - BinDir = $binaryDirectoryPath - } + Invoke-MicroVmTests } } diff --git a/scripts/ci/stage-nvx-validation-tests.ps1 b/scripts/ci/stage-nvx-validation-tests.ps1 new file mode 100644 index 0000000000..f2e70f7c27 --- /dev/null +++ b/scripts/ci/stage-nvx-validation-tests.ps1 @@ -0,0 +1,65 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS +Builds and stages the Windows x64 SDK MicroVM tests for artifact-only validation. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$BinaryDirectory, + + [Parameter(Mandatory)] + [string]$Features +) + +$ErrorActionPreference = 'Stop' +$binaryDirectoryPath = (Resolve-Path -LiteralPath $BinaryDirectory).Path +$srcRoot = Join-Path $PSScriptRoot '..\..\src' + +Push-Location $srcRoot +try { + $messages = @(cargo test --locked --release --target x86_64-pc-windows-msvc ` + -p mxc-sdk --no-default-features --features $Features ` + --test nvx_e2e --no-run --message-format=json) + if ($LASTEXITCODE -ne 0) { + $messages | ForEach-Object { Write-Host $_ } + throw "cargo test --no-run failed with exit code $LASTEXITCODE." + } +} +finally { + Pop-Location +} + +$artifacts = @($messages | + Where-Object { $_.StartsWith('{') } | + ConvertFrom-Json | + Where-Object { + $_.reason -eq 'compiler-artifact' -and $_.target.name -eq 'nvx_e2e' -and + $_.profile.test -and $_.executable + }) +if ($artifacts.Count -ne 1) { + throw "Expected one nvx_e2e test executable from cargo; found $($artifacts.Count)." +} + +$destination = Join-Path $binaryDirectoryPath 'nvx-e2e-tests.exe' +Copy-Item -LiteralPath $artifacts[0].executable -Destination $destination -Force +$discovered = @(& $destination --list) +if ($LASTEXITCODE -ne 0) { + throw "Listing the staged NVX tests failed with exit code $LASTEXITCODE." +} +foreach ($name in @( + 'windows_x64::host_only_executor_request_matrix', + 'windows_x64::signed_bundle_executor_configs', + 'windows_x64::signed_bundle_executor_create_and_run', + 'windows_x64::signed_bundle_facade_create_and_run' +)) { + if ($discovered -notcontains "$name`: test") { + throw "The staged NVX payload did not discover $name." + } +} +if (-not (Test-Path -LiteralPath (Join-Path $binaryDirectoryPath 'aci-edge-sandboxes\bundle-manifest.json') -PathType Leaf)) { + throw 'The Windows x64 artifact is missing the signed NVX bundle.' +} +Write-Host "Staged SDK MicroVM tests: $destination" diff --git a/scripts/ci/validation-test-matrix.json b/scripts/ci/validation-test-matrix.json index 6beed6b39a..870c03968a 100644 --- a/scripts/ci/validation-test-matrix.json +++ b/scripts/ci/validation-test-matrix.json @@ -220,7 +220,6 @@ "pool": "1es-mxc-e2e-ubuntu-24.04-x64", "backends": [ "bubblewrap", - "microvm", "lxc" ] }, @@ -367,7 +366,8 @@ "os": "windows-25h2", "backends": [ "process-t1", - "wslc" + "wslc", + "microvm" ] }, { diff --git a/scripts/tests/microvm-validation.test.js b/scripts/tests/microvm-validation.test.js new file mode 100644 index 0000000000..6856204141 --- /dev/null +++ b/scripts/tests/microvm-validation.test.js @@ -0,0 +1,253 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const { after, before, test } = require("node:test"); +const assert = require("node:assert/strict"); +const { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} = require("node:fs"); +const { tmpdir } = require("node:os"); +const { delimiter, join, resolve } = require("node:path"); +const { spawnSync } = require("node:child_process"); +const { pathToFileURL } = require("node:url"); +const { stripVTControlCharacters } = require("node:util"); + +const root = resolve(__dirname, "..", ".."); +const dispatcher = join(root, "scripts", "ci", "run_backend_validation_tests.ps1"); +const staging = join(root, "scripts", "ci", "stage-nvx-validation-tests.ps1"); +let scratch; +let fixture; +let tools; + +function runScript(script, args, env) { + const result = spawnSync( + "pwsh", + ["-NoProfile", "-File", script, ...args], + { + cwd: root, + env: { ...process.env, ...env }, + encoding: "utf8", + timeout: 30_000, + } + ); + assert.ifError(result.error); + return { + status: result.status, + output: stripVTControlCharacters(result.stdout + result.stderr), + }; +} + +before(() => { + scratch = mkdtempSync(join(tmpdir(), "mxc-microvm-validation-")); + tools = join(scratch, "tools"); + mkdirSync(tools); + fixture = join(scratch, "fixture.exe"); + const source = join(scratch, "fixture.rs"); + // A native fixture exercises the real dispatcher's process and exit handling. + writeFileSync(source, String.raw` +use std::{env, path::Path, process}; + +fn main() { + let args: Vec<_> = env::args().skip(1).collect(); + let mode = env::var("MXC_CI_FIXTURE_MODE").unwrap_or_default(); + let exe = env::current_exe().unwrap(); + if exe.file_stem().unwrap() == "cargo" { + assert!(args.contains(&"--locked".to_owned())); + assert!(args.contains(&"--release".to_owned())); + assert!(args.contains(&"--no-run".to_owned())); + assert!(args.windows(2).any(|pair| pair == ["--test", "nvx_e2e"])); + assert!(args.windows(2).any(|pair| pair == ["--target", "x86_64-pc-windows-msvc"])); + if mode == "cargo-failure" { + eprintln!("fixture cargo failed"); + process::exit(17); + } + if mode != "cargo-no-artifact" { + let executable = env::var("MXC_CI_FIXTURE_EXECUTABLE").unwrap(); + assert!(Path::new(&executable).is_file()); + println!("{{\"reason\":\"compiler-artifact\",\"target\":{{\"name\":\"nvx_e2e\"}},\"profile\":{{\"test\":true}},\"executable\":{executable:?}}}"); + } + return; + } + if args == ["--list"] { + println!("windows_x64::host_only_executor_request_matrix: test"); + println!("windows_x64::signed_bundle_executor_configs: test"); + println!("windows_x64::signed_bundle_executor_create_and_run: test"); + if mode != "missing-discovery" { + println!("windows_x64::signed_bundle_facade_create_and_run: test"); + } + return; + } + assert!(args.contains(&"--include-ignored".to_owned())); + assert!(args.contains(&"--nocapture".to_owned())); + assert!(args.contains(&"--test-threads=1".to_owned())); + if mode == "native-failure" { + eprintln!("fixture runtime failed"); + process::exit(23); + } + if mode == "zero-tests" { + println!("test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out;"); + return; + } + if mode != "missing-facade" { + println!("VM_EXECUTED facade sequential stdout/stderr/exit/env/cwd/timeout/capture-limit/cleanup"); + } + if mode != "missing-executor" { + println!("VM_EXECUTED executor sequential stdout/stderr/exit/timeout/capture-limit/cleanup"); + } + if mode != "missing-configs" { + println!("VM_EXECUTED executor configs all seven fixtures"); + } + if mode == "skipped" { + println!("SKIPPED: fixture VM unavailable"); + } + let ignored = if mode == "ignored" { 1 } else { 0 }; + println!("test result: ok. 6 passed; 0 failed; {ignored} ignored; 0 measured; 0 filtered out;"); +} +`); + const build = spawnSync( + "rustc", + ["--edition=2021", "--crate-name", "mxc_ci_fixture", source, "-o", fixture], + { cwd: root, encoding: "utf8", timeout: 60_000 } + ); + assert.ifError(build.error); + assert.equal(build.status, 0, build.stdout + build.stderr); + copyFileSync(fixture, join(tools, "cargo.exe")); +}); + +after(() => { + if (scratch) rmSync(scratch, { recursive: true, force: true }); +}); + +function artifact() { + const directory = mkdtempSync(join(scratch, "artifact-")); + copyFileSync(fixture, join(directory, "wxc-exec.exe")); + copyFileSync(fixture, join(directory, "nvx-e2e-tests.exe")); + mkdirSync(join(directory, "aci-edge-sandboxes")); + writeFileSync( + join(directory, "aci-edge-sandboxes", "bundle-manifest.json"), + "{}" + ); + return directory; +} + +function dispatch(directory, mode = "", architecture = "x64") { + return runScript( + dispatcher, + ["-Backend", "microvm", "-BinaryDirectory", directory, "-Architecture", architecture], + { + MXC_CI_FIXTURE_MODE: mode, + RUNNER_TEMP: join(directory, "logs"), + } + ); +} + +test("nightly schedules exactly Windows 25H2 x64 MicroVM", async () => { + const { expandPlan, readCatalog } = await import( + pathToFileURL(join(root, "scripts", "ci", "resolve-validation-test-matrix.mjs")) + ); + const catalog = readCatalog(join(root, "scripts", "ci", "validation-test-matrix.json")); + assert.deepEqual( + expandPlan(catalog, "nightly") + .filter((entry) => entry.backend === "microvm") + .map(({ os, architecture, target, artifact }) => ({ os, architecture, target, artifact })), + [{ + os: "windows-25h2", + architecture: "x64", + target: "x86_64-pc-windows-msvc", + artifact: "wxc-binaries-x86_64-pc-windows-msvc", + }] + ); +}); + +test("Windows builds stage and upload the SDK tests and the complete runtime bundle", () => { + const workflow = readFileSync(join(root, ".github", "workflows", "Build.Windows.Job.yml"), "utf8"); + assert.match(workflow, /node --test scripts\/tests\/microvm-validation\.test\.js/); + assert.match(workflow, /scripts\/ci\/stage-nvx-validation-tests\.ps1/); + assert.ok(workflow.includes("src/target/${{ matrix.target }}/release/nvx-e2e-tests.exe")); + assert.ok(workflow.includes("src/target/${{ matrix.target }}/release/aci-edge-sandboxes/")); + assert.equal(existsSync(join(root, ".github", "workflows", "microvm-e2e.yml")), false); +}); + +test("artifact dispatcher executes prebuilt SDK tests and preserves the log", () => { + const directory = artifact(); + const result = dispatch(directory); + assert.equal(result.status, 0, result.output); + const log = readFileSync(join(directory, "logs", "microvm-nvx.log"), "utf8"); + assert.match(log, /VM_EXECUTED facade /); + assert.match(log, /VM_EXECUTED executor /); +}); + +for (const [mode, message] of [ + ["native-failure", /exited 23/], + ["zero-tests", /executed no tests/], + ["missing-facade", /did not execute the facade VM scenario/], + ["missing-executor", /did not execute the executor VM scenario/], + ["missing-configs", /did not execute the executor configs VM scenario/], + ["skipped", /skipped or filtered tests/], + ["ignored", /skipped or filtered tests/], +]) { + test(`artifact dispatcher fails closed for ${mode}`, () => { + const result = dispatch(artifact(), mode); + assert.notEqual(result.status, 0, result.output); + assert.match(result.output, message); + }); +} + +test("artifact dispatcher rejects a missing test payload", () => { + const directory = artifact(); + rmSync(join(directory, "nvx-e2e-tests.exe")); + const result = dispatch(directory); + assert.notEqual(result.status, 0, result.output); + assert.match(result.output, /Required CI artifact file is missing:[\s\S]*nvx-e2e-tests\.exe/); +}); + +test("artifact dispatcher rejects ARM64 instead of running the x64 suite", () => { + const result = dispatch(artifact(), "", "arm64"); + assert.notEqual(result.status, 0, result.output); + assert.match(result.output, /MicroVM validation requires x64/); +}); + +function stage(mode = "") { + const directory = artifact(); + rmSync(join(directory, "nvx-e2e-tests.exe")); + const result = runScript( + staging, + ["-BinaryDirectory", directory, "-Features", "microvm"], + { + PATH: tools + delimiter + process.env.PATH, + MXC_CI_FIXTURE_MODE: mode, + MXC_CI_FIXTURE_EXECUTABLE: fixture, + } + ); + return { ...result, directory }; +} + +test("staging uses Cargo's reported executable and checks VM test discovery", () => { + const result = stage(); + assert.equal(result.status, 0, result.output); + assert.deepEqual( + readFileSync(join(result.directory, "nvx-e2e-tests.exe")), + readFileSync(fixture) + ); +}); + +for (const [mode, message] of [ + ["cargo-failure", /cargo test --no-run failed with exit code 17/], + ["cargo-no-artifact", /Expected one nvx_e2e test executable/], + ["missing-discovery", /did not discover windows_x64::signed_bundle_facade_create_and_run/], +]) { + test(`staging rejects ${mode}`, () => { + const result = stage(mode); + assert.notEqual(result.status, 0, result.output); + assert.match(result.output, message); + if (mode !== "missing-discovery") { + assert.equal(existsSync(join(result.directory, "nvx-e2e-tests.exe")), false); + } + }); +} diff --git a/src/mxc-sdk/README.md b/src/mxc-sdk/README.md index 6ae2e52d1b..88b638e7f7 100644 --- a/src/mxc-sdk/README.md +++ b/src/mxc-sdk/README.md @@ -200,18 +200,10 @@ unchanged. Exceeding it cancels and releases the execution, tears down the container, and returns `BackendError` instead of truncated output. Cleanup failures are reported alongside the capture-limit error. -The host requires Windows Hypervisor Platform and hardware virtualization. -Requests cannot include filesystem grants or denials, network or proxy policy, -UI policy, or capture-denials. Containers must be destroyed after execution, -without preserving policy. - -The guest working directory must be absolute, contain no `..` path components, -and use at most 4095 UTF-8 bytes. Additive environment settings require -`inheritDefaultEnv: true` and allow at most 256 entries and 32 KiB including -entry terminators. Repeated names count as separate entries; later entries -override earlier ones. MXC rejects these limits before bundle discovery and -validates the mapped execution request with the native agent before probing -the host or provisioning a container. +See the [NVX backend guide](../../docs/backends/nvx/nvx.md) for host +requirements, the supported-operation and policy matrix, working-directory and +environment limits, and the maintainer bundle-upgrade procedure. Windows ARM64 +and Linux MicroVM execution are not available in this phase. Backend implementations, shared runtime support, backend dispatch, and host probing are internal modules of this published crate. The crate root exposes diff --git a/src/mxc-sdk/build/build_nvx_bundled.rs b/src/mxc-sdk/build/build_nvx_bundled.rs index 5465009c55..899a6dc440 100644 --- a/src/mxc-sdk/build/build_nvx_bundled.rs +++ b/src/mxc-sdk/build/build_nvx_bundled.rs @@ -11,6 +11,7 @@ use std::process::Command; #[cfg(any(test, feature = "microvm"))] use sha2::{Digest, Sha256}; +// Upgrade procedure: docs/backends/nvx/nvx.md#maintainers-updating-the-bundled-runtime. #[cfg(any(test, feature = "microvm"))] pub(crate) const OFFICIAL_BUNDLE_URL: &str = "https://github.com/microsoft/nvx/releases/download/v0.1.0-dev.0430ce79d125/aci-edge-sandboxes.zip"; diff --git a/src/mxc-sdk/tests/nvx_e2e.rs b/src/mxc-sdk/tests/nvx_e2e.rs index ba75744ad0..ae704f992f 100644 --- a/src/mxc-sdk/tests/nvx_e2e.rs +++ b/src/mxc-sdk/tests/nvx_e2e.rs @@ -25,7 +25,7 @@ fn unsupported_platform_or_feature_is_fail_closed() { mod windows_x64 { use std::path::PathBuf; use std::process::{Command, Output, Stdio}; - use std::time::{SystemTime, UNIX_EPOCH}; + use std::time::{Instant, SystemTime, UNIX_EPOCH}; use mxc_sdk::v1::{ExecutionResult, WaitResult}; use serde_json::{json, Value}; @@ -476,6 +476,106 @@ mod windows_x64 { println!("VM_EXECUTED facade sequential stdout/stderr/exit/env/cwd/timeout/capture-limit/cleanup"); } + #[test] + #[ignore = "VM: requires separately built wxc-exec.exe, WHP and exact signed official bundle"] + fn signed_bundle_executor_configs() { + let cases = [ + ( + "microvm_hello.json", + include_str!("../../../tests/configs/microvm_hello.json"), + 0, + Some("sum=100"), + "Hello world", + ), + ( + "microvm_exit_code.json", + include_str!("../../../tests/configs/microvm_exit_code.json"), + 42, + None, + "Exit code propagation", + ), + ( + "microvm_multiline.json", + include_str!("../../../tests/configs/microvm_multiline.json"), + 0, + Some("fib("), + "Shell Fibonacci program", + ), + ( + "microvm_stdlib.json", + include_str!("../../../tests/configs/microvm_stdlib.json"), + 0, + Some("shell=posix"), + "Guest shell and OS", + ), + ( + "microvm_large_output.json", + include_str!("../../../tests/configs/microvm_large_output.json"), + 0, + Some("line 999"), + "Large stdout (1000 lines)", + ), + ( + "microvm_error.json", + include_str!("../../../tests/configs/microvm_error.json"), + 1, + Some("intentional test error"), + "Shell error", + ), + ( + "microvm_timeout.json", + include_str!("../../../tests/configs/microvm_timeout.json"), + -1, + Some("NVX workload timed out"), + "Timeout kills VM", + ), + ]; + let mut results = Vec::new(); + let mut failures = Vec::new(); + for (name, source, expected_exit, expected_output, description) in cases { + let request: Value = serde_json::from_str(source).expect("MicroVM config fixture"); + let started = Instant::now(); + let output = executor_output(&request, false); + let elapsed = started.elapsed().as_millis(); + let combined = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let passed = output.status.code() == Some(expected_exit) + && expected_output.is_none_or(|expected| combined.contains(expected)); + let status = if passed { "PASS" } else { "FAIL" }; + println!("{status} {description} ({name}): {elapsed} ms"); + results.push(json!({ + "test": name, + "description": description, + "status": status, + "exit_code": output.status.code(), + "wall_time_ms": elapsed, + })); + if !passed { + failures.push(format!( + "{name}: expected exit {expected_exit}, got {:?}\n{combined}", + output.status.code() + )); + } + } + let performance = json!({ + "commit": std::env::var("GITHUB_SHA").unwrap_or_else(|_| "local".to_owned()), + "timestamp": chrono::Utc::now().to_rfc3339(), + "results": results, + }); + let path = std::env::temp_dir().join("microvm-perf-results.json"); + std::fs::write( + &path, + serde_json::to_vec_pretty(&performance).expect("MicroVM performance JSON"), + ) + .expect("write MicroVM performance results"); + println!("MicroVM performance results: {}", path.display()); + assert!(failures.is_empty(), "{}", failures.join("\n")); + println!("VM_EXECUTED executor configs all seven fixtures"); + } + #[test] #[ignore = "VM: requires separately built wxc-exec.exe, WHP and exact signed official bundle"] fn signed_bundle_executor_create_and_run() { diff --git a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_windows.rs b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_windows.rs index 97135b9992..0bf386b5d0 100644 --- a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_windows.rs +++ b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_windows.rs @@ -510,7 +510,7 @@ fn test_examples() { #[test] #[cfg(all(target_arch = "x86_64", feature = "microvm"))] -#[ignore = "VM: run explicitly in microvm-e2e.yml on a prepared Windows host"] +#[ignore = "VM: requires Windows x64, WHP and the signed NVX bundle"] fn test_microvm_basic() { if !cached_has_wxc_exe() { return; @@ -532,7 +532,7 @@ fn test_windows_sandbox() { #[test] #[cfg(all(target_arch = "x86_64", feature = "microvm"))] -#[ignore = "VM: run explicitly in microvm-e2e.yml on a prepared Windows host"] +#[ignore = "VM: requires Windows x64, WHP and the signed NVX bundle"] fn test_microvm_suite() { if !cached_has_wxc_exe() { return; diff --git a/tests/scripts/README.md b/tests/scripts/README.md index ee6e3f658a..7c2644118a 100644 --- a/tests/scripts/README.md +++ b/tests/scripts/README.md @@ -209,15 +209,23 @@ Run them explicitly on capable machines with ```powershell cd src -cargo build --features microvm --target x86_64-pc-windows-msvc +cargo build --locked -p wxc --features microvm --target x86_64-pc-windows-msvc ``` ### Run ```powershell cd src -cargo test -p wxc_e2e_tests --target x86_64-pc-windows-msvc test_microvm_suite -- --nocapture +cargo test --locked -p mxc-sdk --features microvm --test nvx_e2e --target x86_64-pc-windows-msvc -- --include-ignored --nocapture --test-threads=1 ``` -The MicroVM suite runs 6 functional tests + 1 timeout behavior test. -It generates `microvm-perf-results.json` with per-test timing and status data (uploaded as CI artifact). +The SDK suite runs the seven MicroVM config scenarios plus executor and +captured JSON checks for output, environment, working directory, timeout, +capture limits, and cleanup. VM scenarios require a prepared Windows x64 +host and the complete signed bundle. The config scenarios write +`microvm-perf-results.json` under the temporary directory. + +The unified Windows 25H2 x64 nightly job runs the prebuilt SDK suite and +collects its logs and performance JSON. `run_microvm_tests.ps1` remains a +local config-only helper; it is not the scheduled CI entry point. See the +[NVX backend guide](../../docs/backends/nvx/nvx.md). From 4984887a22bbfaebeb500a244635de4b6168235d Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Mon, 26 Oct 2026 15:21:36 -0700 Subject: [PATCH 11/18] Address NVX documentation and package review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/Build.Windows.Job.yml | 11 ++ README.md | 10 +- docs/backends/nvx/nvx.md | 28 +++++ scripts/ci/Invoke-CratePackage.ps1 | 41 +++++++ scripts/tests/microvm-validation.test.js | 105 ++++++++++++++++++ scripts/versioning/validate-configs.js | 1 + src/mxc-sdk/README.md | 35 ++---- src/mxc-sdk/src/backends/nvx/policy.rs | 20 ---- .../src/core/mxc_common/config_parser.rs | 56 +++++++--- src/mxc-sdk/src/core/mxc_common/models.rs | 9 +- .../fixtures/microvm}/microvm_error.json | 2 +- .../fixtures/microvm}/microvm_exit_code.json | 2 +- .../fixtures/microvm}/microvm_hello.json | 2 +- .../microvm}/microvm_large_output.json | 2 +- .../fixtures/microvm}/microvm_multiline.json | 2 +- .../fixtures/microvm}/microvm_stdlib.json | 2 +- .../fixtures/microvm}/microvm_timeout.json | 2 +- src/mxc-sdk/tests/nvx_e2e.rs | 25 +++-- .../tests/wxc_e2e_tests_e2e_windows.rs | 30 +++-- tests/scripts/README.md | 3 + tests/scripts/run_microvm_basic_test.ps1 | 2 +- tests/scripts/run_microvm_tests.ps1 | 4 +- 22 files changed, 292 insertions(+), 102 deletions(-) rename {tests/configs => src/mxc-sdk/tests/fixtures/microvm}/microvm_error.json (61%) rename {tests/configs => src/mxc-sdk/tests/fixtures/microvm}/microvm_exit_code.json (55%) rename {tests/configs => src/mxc-sdk/tests/fixtures/microvm}/microvm_hello.json (60%) rename {tests/configs => src/mxc-sdk/tests/fixtures/microvm}/microvm_large_output.json (71%) rename {tests/configs => src/mxc-sdk/tests/fixtures/microvm}/microvm_multiline.json (69%) rename {tests/configs => src/mxc-sdk/tests/fixtures/microvm}/microvm_stdlib.json (59%) rename {tests/configs => src/mxc-sdk/tests/fixtures/microvm}/microvm_timeout.json (68%) diff --git a/.github/workflows/Build.Windows.Job.yml b/.github/workflows/Build.Windows.Job.yml index bdf4d3362e..5bdcafab0f 100644 --- a/.github/workflows/Build.Windows.Job.yml +++ b/.github/workflows/Build.Windows.Job.yml @@ -88,6 +88,17 @@ jobs: working-directory: ${{ github.workspace }} run: node --test scripts/tests/microvm-validation.test.js + - name: Verify packaged MicroVM tests + if: matrix.target == 'x86_64-pc-windows-msvc' + working-directory: ${{ github.workspace }} + shell: pwsh + env: + ACI_EDGE_SANDBOXES_AGENT_BUNDLE_DIR: ${{ github.workspace }}/src/target/${{ matrix.target }}/release/aci-edge-sandboxes + run: | + & ./scripts/ci/Invoke-CratePackage.ps1 ` + -OutDir (Join-Path $env:RUNNER_TEMP 'microvm-crate') ` + -VerifyMicrovmTests + - name: Stage SDK MicroVM tests for scheduled validation if: matrix.target == 'x86_64-pc-windows-msvc' working-directory: ${{ github.workspace }} diff --git a/README.md b/README.md index 089a824d38..68adb22af8 100644 --- a/README.md +++ b/README.md @@ -56,15 +56,7 @@ Linux, and macOS. | Linux x64 / ARM64 | `bubblewrap` | `lxc`, `hyperlight` | - | | macOS ARM64 / x64 | `seatbelt` | - | - | -\* These backends are **experimental**. - -The Windows x64 `microvm` backend is a development NVX integration. A build -with `--with-microvm` stages the pinned signed NVX agent bundle and requires the -runtime `--experimental` opt-in. It supports create-and-run -execution with the command, timeout, guest working directory, and additive -environment settings. See the -[NVX backend guide](docs/backends/nvx/nvx.md) -for build requirements and supported execution. +\* These backends are **experimental**. MicroVM is currently available on Windows x64 only. ## How do I use MXC? diff --git a/docs/backends/nvx/nvx.md b/docs/backends/nvx/nvx.md index 98dcf04573..910ae45f99 100644 --- a/docs/backends/nvx/nvx.md +++ b/docs/backends/nvx/nvx.md @@ -59,6 +59,15 @@ needs access to the registry even though guest networking is not enabled. Schema-valid fields do not imply backend support. MXC rejects unsupported policy before creating a VM rather than ignoring it. +The table describes this MXC integration, not every capability of NVX. The +signed agent limits workloads to one hour and constrains their working +directories and environments as described below. Fixed image selection, +captured-only execution, and refusal of filesystem, network, UI, fallback, +and public lifecycle policies are MXC Phase 1 restrictions. +The [signed-agent documentation](https://github.com/microsoft/nvx/blob/0430ce79d125203c20f0aa69243480513a774296/aci_edge_sandboxes/README.md#agent-backend-opt-in) +describes its contract; the direct OpenVMM backend uses a different guest and +does not define the limits of this integration. + | Surface | Phase 1 support | |---|---| | Host platforms | Windows x64 with WHP. Windows ARM64 and Linux execution are unavailable. | @@ -113,6 +122,20 @@ A workload timeout preserves captured partial output and tears down the container. Each create-and-run request gets fresh workload state; this phase does not offer persistent containers or manual lifecycle operations. +### Temporary agent workspace + +MXC allocates a private temporary directory for each create-and-run operation. +The signed agent owns the sandbox records, VM control data, diagnostics, and +image registry/cache stored there. MXC coordinates the agent's stop and +deprovision operations, closes the client, and removes the directory after +successful deprovisioning; it does not maintain a persistent container database. + +If deprovisioning fails, MXC reports the failure and retains the directory, +including its location in the error. This preserves the agent's resource +records rather than deleting them while a VM might remain. Retention does not +provide automatic recovery. Confirm that the operation's VM has stopped before +manually removing retained storage. + ## Validation Host-only policy and bundle-integrity checks run in normal Windows x64 PR @@ -127,6 +150,11 @@ timeouts, capture limits, cleanup, and the seven MicroVM config fixtures. Missing prerequisites, skipped scenarios, and empty test runs fail validation. See the [CI validation runbook](../../development/build-and-test/ci-validation-infrastructure.md). +The seven executor configuration fixtures live in +`src/mxc-sdk/tests/fixtures/microvm` and are included in the Rust crate. +Windows x64 PR builds also compile `nvx_e2e` from the extracted crate archive +with `microvm` enabled, without running a VM. + To run the suite locally on a prepared Windows x64 host, from `src`: ```powershell diff --git a/scripts/ci/Invoke-CratePackage.ps1 b/scripts/ci/Invoke-CratePackage.ps1 index 7b58621a32..dba9d8a82e 100644 --- a/scripts/ci/Invoke-CratePackage.ps1 +++ b/scripts/ci/Invoke-CratePackage.ps1 @@ -18,6 +18,9 @@ param [Parameter(Mandatory, ParameterSetName = 'Validate')] [switch] $ValidateOnly, + [Parameter(ParameterSetName = 'Package')] + [switch] $VerifyMicrovmTests, + [string] $ManifestPath = 'src/Cargo.toml', [string] $RootCrate = 'mxc-sdk', [string] $PackageRegistry = 'crates-io', @@ -27,6 +30,11 @@ param $ErrorActionPreference = 'Stop' +if ($VerifyMicrovmTests -and -not $IsWindows) +{ + throw 'MicroVM crate verification requires Windows and targets x86_64-pc-windows-msvc.' +} + $metadata = cargo metadata --locked --format-version 1 --no-deps --manifest-path $ManifestPath | ConvertFrom-Json if ($LASTEXITCODE -ne 0) { throw "cargo metadata failed with exit $LASTEXITCODE" } @@ -115,6 +123,39 @@ if (-not (Test-Path -LiteralPath $archive)) { throw "cargo package did not produce $archive" } +if ($VerifyMicrovmTests) +{ + $extraction = Join-Path ([System.IO.Path]::GetTempPath()) ("mxc-crate-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $extraction | Out-Null + try + { + tar --extract --gzip --file $archive --directory $extraction + if ($LASTEXITCODE -ne 0) { throw "crate extraction failed with exit $LASTEXITCODE" } + $extractedPackage = Join-Path $extraction "$RootCrate-$($package.version)" + foreach ($fixture in @( + 'microvm_hello.json', 'microvm_exit_code.json', 'microvm_multiline.json', + 'microvm_stdlib.json', 'microvm_large_output.json', 'microvm_error.json', + 'microvm_timeout.json' + )) + { + $fixturePath = Join-Path $extractedPackage "tests/fixtures/microvm/$fixture" + if (-not (Test-Path -LiteralPath $fixturePath -PathType Leaf)) + { + throw "Packaged MicroVM fixture is missing: $fixture" + } + } + cargo test --offline --locked --release --target x86_64-pc-windows-msvc ` + --manifest-path (Join-Path $extractedPackage 'Cargo.toml') ` + --target-dir $metadata.target_directory ` + --no-default-features --features microvm --test nvx_e2e --no-run + if ($LASTEXITCODE -ne 0) { throw "packaged MicroVM test compilation failed with exit $LASTEXITCODE" } + Write-Host 'Compiled the MicroVM test target from the extracted mxc-sdk archive.' + } + finally + { + Remove-Item -LiteralPath $extraction -Recurse -Force + } +} if (Test-Path -LiteralPath $OutDir) { Remove-Item -LiteralPath $OutDir -Recurse -Force diff --git a/scripts/tests/microvm-validation.test.js b/scripts/tests/microvm-validation.test.js index 6856204141..d1eac440e4 100644 --- a/scripts/tests/microvm-validation.test.js +++ b/scripts/tests/microvm-validation.test.js @@ -21,6 +21,16 @@ const { stripVTControlCharacters } = require("node:util"); const root = resolve(__dirname, "..", ".."); const dispatcher = join(root, "scripts", "ci", "run_backend_validation_tests.ps1"); const staging = join(root, "scripts", "ci", "stage-nvx-validation-tests.ps1"); +const packaging = join(root, "scripts", "ci", "Invoke-CratePackage.ps1"); +const microvmFixtures = [ + "microvm_hello.json", + "microvm_exit_code.json", + "microvm_multiline.json", + "microvm_stdlib.json", + "microvm_large_output.json", + "microvm_error.json", + "microvm_timeout.json", +]; let scratch; let fixture; let tools; @@ -58,11 +68,39 @@ fn main() { let mode = env::var("MXC_CI_FIXTURE_MODE").unwrap_or_default(); let exe = env::current_exe().unwrap(); if exe.file_stem().unwrap() == "cargo" { + if args.first().map(String::as_str) == Some("metadata") { + let target = env::var("MXC_CI_PACKAGE_TARGET").unwrap(); + let manifest = env::var("MXC_CI_PACKAGE_MANIFEST").unwrap(); + println!(r#"{{"packages":[{{"name":"mxc-sdk","version":"1.0.0","manifest_path":{manifest:?},"description":"fixture","repository":"https://github.com/microsoft/mxc","license":"MIT","dependencies":[]}}],"target_directory":{target:?}}}"#); + return; + } + if args.first().map(String::as_str) == Some("fetch") { + return; + } + if args.first().map(String::as_str) == Some("package") { + assert!(args.contains(&"--no-verify".to_owned())); + let directory = Path::new(&env::var("MXC_CI_PACKAGE_TARGET").unwrap()).join("package"); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::copy(env::var("MXC_CI_PACKAGE_ARCHIVE").unwrap(), directory.join("mxc-sdk-1.0.0.crate")).unwrap(); + return; + } assert!(args.contains(&"--locked".to_owned())); assert!(args.contains(&"--release".to_owned())); assert!(args.contains(&"--no-run".to_owned())); assert!(args.windows(2).any(|pair| pair == ["--test", "nvx_e2e"])); assert!(args.windows(2).any(|pair| pair == ["--target", "x86_64-pc-windows-msvc"])); + if let Some(index) = args.iter().position(|arg| arg == "--manifest-path") { + let manifest = Path::new(&args[index + 1]); + assert!(manifest.is_file()); + assert_eq!(manifest.parent().unwrap().file_name().unwrap(), "mxc-sdk-1.0.0"); + assert!(args.contains(&"--offline".to_owned())); + if mode == "packaged-build-failure" { + eprintln!("fixture packaged compilation failed"); + process::exit(29); + } + println!("PACKAGE_COMPILED extracted nvx_e2e target"); + return; + } if mode == "cargo-failure" { eprintln!("fixture cargo failed"); process::exit(17); @@ -171,9 +209,76 @@ test("Windows builds stage and upload the SDK tests and the complete runtime bun assert.match(workflow, /scripts\/ci\/stage-nvx-validation-tests\.ps1/); assert.ok(workflow.includes("src/target/${{ matrix.target }}/release/nvx-e2e-tests.exe")); assert.ok(workflow.includes("src/target/${{ matrix.target }}/release/aci-edge-sandboxes/")); + assert.match(workflow, /Invoke-CratePackage\.ps1[\s\S]*-VerifyMicrovmTests/); + assert.match(workflow, /ACI_EDGE_SANDBOXES_AGENT_BUNDLE_DIR:/); assert.equal(existsSync(join(root, ".github", "workflows", "microvm-e2e.yml")), false); }); +test("MicroVM fixtures are canonical packaged inputs and remain in schema validation", () => { + const fixtureRoot = join(root, "src", "mxc-sdk", "tests", "fixtures", "microvm"); + const sdkTests = readFileSync(join(root, "src", "mxc-sdk", "tests", "nvx_e2e.rs"), "utf8"); + for (const name of microvmFixtures) { + const request = JSON.parse(readFileSync(join(fixtureRoot, name), "utf8")); + assert.equal(request.version, "1.1.0-alpha"); + assert.equal(request.containment, "microvm"); + assert.equal(existsSync(join(root, "tests", "configs", name)), false); + assert.ok(sdkTests.includes(`include_str!("fixtures/microvm/${name}")`), name); + } + const validation = readFileSync(join(root, "scripts", "versioning", "validate-configs.js"), "utf8"); + assert.ok(validation.includes('join("src", "mxc-sdk", "tests", "fixtures", "microvm")')); +}); + +function verifyPackage(mode = "") { + const directory = mkdtempSync(join(scratch, "package-")); + const inputs = join(directory, "inputs"); + const packageRoot = join(inputs, "mxc-sdk-1.0.0"); + const fixtureRoot = join(packageRoot, "tests", "fixtures", "microvm"); + mkdirSync(fixtureRoot, { recursive: true }); + const manifest = join(packageRoot, "Cargo.toml"); + writeFileSync(manifest, '[package]\nname = "mxc-sdk"\nversion = "1.0.0"\n'); + for (const name of microvmFixtures) { + if (mode === "packaged-missing-fixture" && name === "microvm_timeout.json") continue; + copyFileSync(join(root, "src", "mxc-sdk", "tests", "fixtures", "microvm", name), join(fixtureRoot, name)); + } + const archive = join(directory, "fixture.crate"); + const packed = spawnSync( + "tar", + ["--create", "--gzip", "--file", archive, "--directory", inputs, "mxc-sdk-1.0.0"], + { encoding: "utf8", timeout: 30_000 } + ); + assert.ifError(packed.error); + assert.equal(packed.status, 0, packed.stdout + packed.stderr); + return runScript( + packaging, + ["-OutDir", join(directory, "output"), "-VerifyMicrovmTests"], + { + PATH: tools + delimiter + process.env.PATH, + MXC_CI_FIXTURE_MODE: mode, + MXC_CI_PACKAGE_ARCHIVE: archive, + MXC_CI_PACKAGE_TARGET: join(directory, "target"), + MXC_CI_PACKAGE_MANIFEST: manifest, + } + ); +} + +test("package gate compiles MicroVM tests from the extracted archive", () => { + const result = verifyPackage(); + assert.equal(result.status, 0, result.output); + assert.match(result.output, /PACKAGE_COMPILED extracted nvx_e2e target/); +}); + +for (const [mode, message] of [ + ["packaged-missing-fixture", /Packaged MicroVM fixture is missing: microvm_timeout\.json/], + ["packaged-build-failure", /packaged MicroVM test compilation failed with exit 29/], +]) { + test(`package gate rejects ${mode}`, () => { + const result = verifyPackage(mode); + assert.notEqual(result.status, 0, result.output); + assert.match(result.output, message); + assert.doesNotMatch(result.output, /Compiled the MicroVM test target/); + }); +} + test("artifact dispatcher executes prebuilt SDK tests and preserves the log", () => { const directory = artifact(); const result = dispatch(directory); diff --git a/scripts/versioning/validate-configs.js b/scripts/versioning/validate-configs.js index 765eadba11..b8c0403a39 100644 --- a/scripts/versioning/validate-configs.js +++ b/scripts/versioning/validate-configs.js @@ -42,6 +42,7 @@ for (const required of [schemaVer.min, schemaVer.maxSupported]) { const CONFIG_DIRS = [ join("tests", "examples"), join("tests", "configs"), + join("src", "mxc-sdk", "tests", "fixtures", "microvm"), join("tests", "policy", "sdk-v1", "expected"), ]; diff --git a/src/mxc-sdk/README.md b/src/mxc-sdk/README.md index 88b638e7f7..788b81ba62 100644 --- a/src/mxc-sdk/README.md +++ b/src/mxc-sdk/README.md @@ -177,33 +177,14 @@ temporary storage without creating directories. ## Internal modules and executor binaries -### Experimental NVX create-and-run - -The `microvm` feature enables only Windows x64 and requires runtime experimental -authorization. P1 stages the official signed `aci-edge-sandboxes.zip` release -bundle (`v0.1.0-dev.0430ce79d125` from NVX revision -`0430ce79d125203c20f0aa69243480513a774296`) during the crate build and loads the -verified `bin/aci_edge_agent.dll` C ABI v1 in-process without unpublished Rust -dependencies; it does not enable standalone OpenVMM execution. - -The existing hidden JSON facade `mxc_sdk::__ffi::run_json(json, true)` supports -the development `1.1.0-alpha` MicroVM create-and-run request with captured -stdout/stderr, exit status and timeout. Typed V1 targeting, streaming, PTY and -public Rust container lifecycle operations are not broadened by P1. -Distribute the complete `aci-edge-sandboxes/` directory beside the consuming -executable. The native agent pulls the official example's public Azure Linux -workload image on the host; guest network access remains ungranted. - -NVX captures at most 64 MiB (67,108,864 bytes) of stdout and stderr combined, -including when the process timeout is zero. Output up to that limit is returned -unchanged. Exceeding it cancels and releases the execution, tears down the -container, and returns `BackendError` instead of truncated output. Cleanup -failures are reported alongside the capture-limit error. - -See the [NVX backend guide](../../docs/backends/nvx/nvx.md) for host -requirements, the supported-operation and policy matrix, working-directory and -environment limits, and the maintainer bundle-upgrade procedure. Windows ARM64 -and Linux MicroVM execution are not available in this phase. +### Experimental MicroVM support + +Enable the `microvm` Cargo feature to build experimental MicroVM support on +Windows x64. Execution also requires runtime experimental opt-in. This phase +supports development JSON create-and-run requests, not MicroVM targeting through +the typed V1 SDK APIs, streaming, PTY, or container lifecycle operations. See the +[NVX backend guide](https://github.com/microsoft/mxc/blob/main/docs/backends/nvx/nvx.md) +for prerequisites and usage. Backend implementations, shared runtime support, backend dispatch, and host probing are internal modules of this published crate. The crate root exposes diff --git a/src/mxc-sdk/src/backends/nvx/policy.rs b/src/mxc-sdk/src/backends/nvx/policy.rs index 45f29fbc7c..5946b77980 100644 --- a/src/mxc-sdk/src/backends/nvx/policy.rs +++ b/src/mxc-sdk/src/backends/nvx/policy.rs @@ -48,26 +48,6 @@ pub(super) fn validate_request(request: &ExecutionRequest) -> Result<(), MxcErro "NVX does not support processContainer.captureDenials", )); } - if request.policy.least_privilege_specified { - return Err(MxcError::policy_validation( - "NVX does not support processContainer.leastPrivilege", - )); - } - if request.policy.learning_mode_specified { - return Err(MxcError::policy_validation( - "NVX does not support processContainer.learningMode", - )); - } - if request.policy.capabilities_specified { - return Err(MxcError::policy_validation( - "NVX does not support processContainer.capabilities", - )); - } - if request.policy.base_process_ui_specified { - return Err(MxcError::policy_validation( - "NVX does not support processContainer.ui", - )); - } if request.policy.fallback_specified { return Err(MxcError::policy_validation( "NVX does not support fallback policy", diff --git a/src/mxc-sdk/src/core/mxc_common/config_parser.rs b/src/mxc-sdk/src/core/mxc_common/config_parser.rs index e73b29f67b..918205fd45 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -1439,11 +1439,6 @@ fn normalize_common_request_ir( // the newer BaseContainer implementation (ui). let mut process_container_network = None; if let Some(ac) = cfg.process_container { - policy.least_privilege_specified = ac.least_privilege.is_some(); - policy.learning_mode_specified = ac.learning_mode.is_some(); - policy.capabilities_specified = ac.capabilities.is_some(); - policy.base_process_ui_specified = ac.ui.is_some(); - if let Some(lp) = ac.least_privilege { policy.least_privilege_mode = lp; } @@ -1873,6 +1868,49 @@ mod tests { assert_eq!(request.containment, ContainmentBackend::MicroVm); } + #[test] + fn preserves_fallback_presence_without_changing_backend_defaults() { + for containment in ["microvm", "processcontainer"] { + for (fallback, supplied, allow_dacl_mutation) in [ + (None, false, true), + (Some(serde_json::json!({})), true, true), + ( + Some(serde_json::json!({"allowDaclMutation": true})), + true, + true, + ), + ( + Some(serde_json::json!({"allowDaclMutation": false})), + true, + false, + ), + ] { + let mut json = serde_json::json!({ + "version": "1.1.0-alpha", + "containment": containment, + "process": {"commandLine": "echo hello"} + }); + if let Some(fallback) = fallback { + json["fallback"] = fallback; + } + let MxcRequest::OneShot(request) = + load_mxc_request_from_json(&json.to_string(), &mut test_logger()).unwrap() + else { + panic!("expected a create-and-run request"); + }; + assert_eq!(request.policy.fallback_specified, supplied); + assert_eq!( + request.policy.fallback.allow_dacl_mutation, + allow_dacl_mutation + ); + assert!(serde_json::to_value(request.policy) + .unwrap() + .get("fallback_specified") + .is_none()); + } + } + } + fn parse_exact_for_test(json: &str) -> Result { parse_exact_mxc_request_json(json, &mut test_logger()) } @@ -1892,10 +1930,6 @@ mod tests { network_mode_specified: bool, runtime_network_proxy_specified: bool, ui_specified: bool, - least_privilege_specified: bool, - learning_mode_specified: bool, - capabilities_specified: bool, - base_process_ui_specified: bool, fallback_specified: bool, requested_sandbox_kind: Option<&'static str>, } @@ -1917,10 +1951,6 @@ mod tests { network_mode_specified: request.policy.network_mode_specified, runtime_network_proxy_specified: request.policy.runtime_network_proxy_specified, ui_specified: request.policy.ui_specified, - least_privilege_specified: request.policy.least_privilege_specified, - learning_mode_specified: request.policy.learning_mode_specified, - capabilities_specified: request.policy.capabilities_specified, - base_process_ui_specified: request.policy.base_process_ui_specified, fallback_specified: request.policy.fallback_specified, requested_sandbox_kind: request .telemetry diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index 6c2cd276ea..fe59133145 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -660,18 +660,13 @@ impl Default for FallbackPolicy { #[derive(Debug, Clone, Default, Serialize)] pub struct ContainerPolicy { pub least_privilege_mode: bool, - #[serde(skip)] - pub least_privilege_specified: bool, pub capabilities: Vec, - #[serde(skip)] - pub learning_mode_specified: bool, - #[serde(skip)] - pub capabilities_specified: bool, pub readwrite_paths: Vec, pub readonly_paths: Vec, pub enumerate_paths: Vec, pub denied_paths: Vec, pub fallback: FallbackPolicy, + /// Whether the caller supplied fallback policy, including an empty section. #[serde(skip)] pub fallback_specified: bool, /// Outbound CIDR, protocol, and port policy. @@ -720,8 +715,6 @@ pub struct ContainerPolicy { pub ui_specified: bool, /// BaseProcessContainer-specific UI config (Windows only, from processContainer.ui). pub base_process_ui: BaseProcessUiConfig, - #[serde(skip)] - pub base_process_ui_specified: bool, /// Windows denial capture (from `processContainer.captureDenials`). When /// `Some`, the runner records the sandboxed process's ungranted access /// attempts to a learning-mode ETL trace. `None` disables capture. diff --git a/tests/configs/microvm_error.json b/src/mxc-sdk/tests/fixtures/microvm/microvm_error.json similarity index 61% rename from tests/configs/microvm_error.json rename to src/mxc-sdk/tests/fixtures/microvm/microvm_error.json index f7d9698b16..9cfccfa04d 100644 --- a/tests/configs/microvm_error.json +++ b/src/mxc-sdk/tests/fixtures/microvm/microvm_error.json @@ -1,5 +1,5 @@ { - "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "$schema": "https://raw.githubusercontent.com/microsoft/mxc/main/schemas/dev/mxc-config.schema.1.1.0-alpha.json", "version": "1.1.0-alpha", "process": { "commandLine": "printf 'intentional test error\\n' >&2; exit 1", diff --git a/tests/configs/microvm_exit_code.json b/src/mxc-sdk/tests/fixtures/microvm/microvm_exit_code.json similarity index 55% rename from tests/configs/microvm_exit_code.json rename to src/mxc-sdk/tests/fixtures/microvm/microvm_exit_code.json index 5e30bf857d..1c23a7ac16 100644 --- a/tests/configs/microvm_exit_code.json +++ b/src/mxc-sdk/tests/fixtures/microvm/microvm_exit_code.json @@ -1,5 +1,5 @@ { - "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "$schema": "https://raw.githubusercontent.com/microsoft/mxc/main/schemas/dev/mxc-config.schema.1.1.0-alpha.json", "version": "1.1.0-alpha", "process": { "commandLine": "exit 42", diff --git a/tests/configs/microvm_hello.json b/src/mxc-sdk/tests/fixtures/microvm/microvm_hello.json similarity index 60% rename from tests/configs/microvm_hello.json rename to src/mxc-sdk/tests/fixtures/microvm/microvm_hello.json index 6798d62bad..cb780d67ed 100644 --- a/tests/configs/microvm_hello.json +++ b/src/mxc-sdk/tests/fixtures/microvm/microvm_hello.json @@ -1,5 +1,5 @@ { - "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "$schema": "https://raw.githubusercontent.com/microsoft/mxc/main/schemas/dev/mxc-config.schema.1.1.0-alpha.json", "version": "1.1.0-alpha", "process": { "commandLine": "printf 'Hello from MicroVM! sum=100\\n'", diff --git a/tests/configs/microvm_large_output.json b/src/mxc-sdk/tests/fixtures/microvm/microvm_large_output.json similarity index 71% rename from tests/configs/microvm_large_output.json rename to src/mxc-sdk/tests/fixtures/microvm/microvm_large_output.json index 7c535edd88..f2f332b327 100644 --- a/tests/configs/microvm_large_output.json +++ b/src/mxc-sdk/tests/fixtures/microvm/microvm_large_output.json @@ -1,5 +1,5 @@ { - "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "$schema": "https://raw.githubusercontent.com/microsoft/mxc/main/schemas/dev/mxc-config.schema.1.1.0-alpha.json", "version": "1.1.0-alpha", "process": { "commandLine": "i=0; while [ \"$i\" -lt 1000 ]; do printf 'line %d: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$i\"; i=$((i + 1)); done", diff --git a/tests/configs/microvm_multiline.json b/src/mxc-sdk/tests/fixtures/microvm/microvm_multiline.json similarity index 69% rename from tests/configs/microvm_multiline.json rename to src/mxc-sdk/tests/fixtures/microvm/microvm_multiline.json index cd78373c21..cd43a7b380 100644 --- a/tests/configs/microvm_multiline.json +++ b/src/mxc-sdk/tests/fixtures/microvm/microvm_multiline.json @@ -1,5 +1,5 @@ { - "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "$schema": "https://raw.githubusercontent.com/microsoft/mxc/main/schemas/dev/mxc-config.schema.1.1.0-alpha.json", "version": "1.1.0-alpha", "process": { "commandLine": "a=0; b=1; i=0; while [ \"$i\" -lt 10 ]; do printf 'fib(%d) = %d\\n' \"$i\" \"$a\"; next=$((a + b)); a=$b; b=$next; i=$((i + 1)); done", diff --git a/tests/configs/microvm_stdlib.json b/src/mxc-sdk/tests/fixtures/microvm/microvm_stdlib.json similarity index 59% rename from tests/configs/microvm_stdlib.json rename to src/mxc-sdk/tests/fixtures/microvm/microvm_stdlib.json index 0a66b6bef8..7f120934de 100644 --- a/tests/configs/microvm_stdlib.json +++ b/src/mxc-sdk/tests/fixtures/microvm/microvm_stdlib.json @@ -1,5 +1,5 @@ { - "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "$schema": "https://raw.githubusercontent.com/microsoft/mxc/main/schemas/dev/mxc-config.schema.1.1.0-alpha.json", "version": "1.1.0-alpha", "process": { "commandLine": "printf 'shell=posix\\n'; uname -s", diff --git a/tests/configs/microvm_timeout.json b/src/mxc-sdk/tests/fixtures/microvm/microvm_timeout.json similarity index 68% rename from tests/configs/microvm_timeout.json rename to src/mxc-sdk/tests/fixtures/microvm/microvm_timeout.json index 02f72c06a2..696f5e6678 100644 --- a/tests/configs/microvm_timeout.json +++ b/src/mxc-sdk/tests/fixtures/microvm/microvm_timeout.json @@ -1,5 +1,5 @@ { - "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", + "$schema": "https://raw.githubusercontent.com/microsoft/mxc/main/schemas/dev/mxc-config.schema.1.1.0-alpha.json", "version": "1.1.0-alpha", "_comment": "Timeout test for the signed NVX agent workload guest.", "process": { diff --git a/src/mxc-sdk/tests/nvx_e2e.rs b/src/mxc-sdk/tests/nvx_e2e.rs index ae704f992f..b5325cecc3 100644 --- a/src/mxc-sdk/tests/nvx_e2e.rs +++ b/src/mxc-sdk/tests/nvx_e2e.rs @@ -100,6 +100,14 @@ mod windows_x64 { json!({"processContainer": {"filesystem": {"enumeratePaths": ["C:\\work"]}}}), ), ("fallback", json!({"fallback": {}})), + ( + "fallback-allow", + json!({"fallback": {"allowDaclMutation": true}}), + ), + ( + "fallback-deny", + json!({"fallback": {"allowDaclMutation": false}}), + ), ("destroy", json!({"lifecycle": {"destroyOnExit": false}})), ("preserve", json!({"lifecycle": {"preservePolicy": true}})), ("timeout-limit", json!({"process": {"timeout": 3_600_001}})), @@ -240,8 +248,9 @@ mod windows_x64 { "{label} must use the parser error contract" ); assert_eq!(error.message, MIXED_BACKEND_ERROR, "{label}"); - } else if label == "fallback" { + } else if matches!(label, "fallback" | "fallback-allow" | "fallback-deny") { assert_eq!(error.code, ErrorCode::PolicyValidation); + assert_eq!(error.message, "NVX does not support fallback policy"); } if let Some(expected) = process_limit_error(label) { assert_eq!(error.code, ErrorCode::PolicyValidation, "{label}"); @@ -482,49 +491,49 @@ mod windows_x64 { let cases = [ ( "microvm_hello.json", - include_str!("../../../tests/configs/microvm_hello.json"), + include_str!("fixtures/microvm/microvm_hello.json"), 0, Some("sum=100"), "Hello world", ), ( "microvm_exit_code.json", - include_str!("../../../tests/configs/microvm_exit_code.json"), + include_str!("fixtures/microvm/microvm_exit_code.json"), 42, None, "Exit code propagation", ), ( "microvm_multiline.json", - include_str!("../../../tests/configs/microvm_multiline.json"), + include_str!("fixtures/microvm/microvm_multiline.json"), 0, Some("fib("), "Shell Fibonacci program", ), ( "microvm_stdlib.json", - include_str!("../../../tests/configs/microvm_stdlib.json"), + include_str!("fixtures/microvm/microvm_stdlib.json"), 0, Some("shell=posix"), "Guest shell and OS", ), ( "microvm_large_output.json", - include_str!("../../../tests/configs/microvm_large_output.json"), + include_str!("fixtures/microvm/microvm_large_output.json"), 0, Some("line 999"), "Large stdout (1000 lines)", ), ( "microvm_error.json", - include_str!("../../../tests/configs/microvm_error.json"), + include_str!("fixtures/microvm/microvm_error.json"), 1, Some("intentional test error"), "Shell error", ), ( "microvm_timeout.json", - include_str!("../../../tests/configs/microvm_timeout.json"), + include_str!("fixtures/microvm/microvm_timeout.json"), -1, Some("NVX workload timed out"), "Timeout kills VM", diff --git a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_windows.rs b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_windows.rs index 0bf386b5d0..ca8a87705c 100644 --- a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_windows.rs +++ b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_windows.rs @@ -123,7 +123,11 @@ fn examples() { #[cfg(all(target_arch = "x86_64", feature = "microvm"))] fn microvm_basic() { - assert_wxc_success("microvm_hello.json", &["--debug", "--experimental"]); + let config = microvm_configs_dir().join("microvm_hello.json"); + assert_wxc_success( + &config.display().to_string(), + &["--debug", "--experimental"], + ); } /// Drives `processContainer.captureDenials` end to end and asserts the @@ -690,6 +694,14 @@ fn run_sandbox_case(case: &SandboxCase) { // MicroVM suite // --------------------------------------------------------------------------- +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] +fn microvm_configs_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("microvm") +} + #[derive(Debug)] #[cfg(all(target_arch = "x86_64", feature = "microvm"))] struct MicrovmCase { @@ -776,14 +788,18 @@ fn microvm_suite() { let mut failures = Vec::new(); for case in cases { - let config_path = test_configs_dir().join(case.config); - if !config_path.exists() { - println!("SKIPPED: config not found: {}", config_path.display()); - continue; - } + let config_path = microvm_configs_dir().join(case.config); + assert!( + config_path.is_file(), + "Missing MicroVM fixture: {}", + config_path.display() + ); println!("--- {} ({}) ---", case.description, case.config); - let result = run_wxc_config(case.config, &["--debug", "--experimental"]); + let result = run_wxc_config( + &config_path.display().to_string(), + &["--debug", "--experimental"], + ); let status = if command_matches(&result, &case) { "PASS" } else { diff --git a/tests/scripts/README.md b/tests/scripts/README.md index 7c2644118a..aa5f64f36b 100644 --- a/tests/scripts/README.md +++ b/tests/scripts/README.md @@ -10,6 +10,9 @@ of shelling through these scripts. Arguments and build-profile defaults vary by suite. Use the script's help or header comment for its supported parameters. +The MicroVM scripts use the packaged fixtures under +`src/mxc-sdk/tests/fixtures/microvm`, not `tests/configs`. + ## Prerequisites Shared: diff --git a/tests/scripts/run_microvm_basic_test.ps1 b/tests/scripts/run_microvm_basic_test.ps1 index 5dffe2214f..b0eecccb7b 100644 --- a/tests/scripts/run_microvm_basic_test.ps1 +++ b/tests/scripts/run_microvm_basic_test.ps1 @@ -24,7 +24,7 @@ if (-not $BinDir) { } $WxcExec = Join-Path $BinDir "wxc-exec.exe" -$TestConfig = Join-Path $RepoRoot "tests\configs\microvm_hello.json" +$TestConfig = Join-Path $RepoRoot "src\mxc-sdk\tests\fixtures\microvm\microvm_hello.json" if (-not (Test-Path $WxcExec)) { Write-Host "ERROR: wxc-exec.exe not found at $WxcExec" -ForegroundColor Red diff --git a/tests/scripts/run_microvm_tests.ps1 b/tests/scripts/run_microvm_tests.ps1 index b673b5c176..42f8673e87 100644 --- a/tests/scripts/run_microvm_tests.ps1 +++ b/tests/scripts/run_microvm_tests.ps1 @@ -19,7 +19,7 @@ Explicit binary directory. Overrides -Release logic when provided. .PARAMETER ConfigDir - Path to test configs directory. Defaults to \tests\configs + Path to test configs directory. Defaults to \src\mxc-sdk\tests\fixtures\microvm .EXAMPLE .\run_microvm_tests.ps1 @@ -45,7 +45,7 @@ if (-not $BinDir) { } if (-not $ConfigDir) { - $ConfigDir = Join-Path $RepoRoot "tests\configs" + $ConfigDir = Join-Path $RepoRoot "src\mxc-sdk\tests\fixtures\microvm" } $WxcExePath = Join-Path $BinDir "wxc-exec.exe" From 4bcb0983bd422e7fe5ae9cfd939474653aaf30ce Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Mon, 26 Oct 2026 15:30:14 -0700 Subject: [PATCH 12/18] Document and scope the Windows crate validation gate Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/Build.Windows.Job.yml | 2 +- .../development/build-and-test/ci-validation-infrastructure.md | 3 ++- scripts/ci/Invoke-CratePackage.ps1 | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/Build.Windows.Job.yml b/.github/workflows/Build.Windows.Job.yml index 5bdcafab0f..431a23fdeb 100644 --- a/.github/workflows/Build.Windows.Job.yml +++ b/.github/workflows/Build.Windows.Job.yml @@ -93,7 +93,7 @@ jobs: working-directory: ${{ github.workspace }} shell: pwsh env: - ACI_EDGE_SANDBOXES_AGENT_BUNDLE_DIR: ${{ github.workspace }}/src/target/${{ matrix.target }}/release/aci-edge-sandboxes + ACI_EDGE_SANDBOXES_AGENT_BUNDLE_DIR: ${{ github.workspace }}\src\target\${{ matrix.target }}\release\aci-edge-sandboxes run: | & ./scripts/ci/Invoke-CratePackage.ps1 ` -OutDir (Join-Path $env:RUNNER_TEMP 'microvm-crate') ` diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index 2dd44d8153..527f179a31 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -33,6 +33,7 @@ the individual local test scripts are documented in | `.github/workflows/Package.IsolationSession.TestBundle.Job.yml` | `workflow_call`-only. Builds the IsolationSession test bundle natively on x64 and arm64 and uploads it as `isolation-session-test-bundle-`. | | `scripts/ci/build-isolation-session-test-bundle.ps1` | Builds and assembles the IsolationSession test bundle, and checks that each payload discovers its tests. | | `scripts/ci/stage-nvx-validation-tests.ps1` | Stages the compiled Windows x64 SDK MicroVM tests in the existing Windows artifact and checks required test discovery. | +| `scripts/ci/Invoke-CratePackage.ps1 -VerifyMicrovmTests` | Extracts the `mxc-sdk` archive, requires its seven MicroVM fixtures, and compiles `nvx_e2e` with `microvm` on Windows x64 without running a VM. | | `scripts/ci/validation-test-matrix.json` | The matrix: OS versions, backends, triggers, job staggering. | | `scripts/ci/resolve-validation-test-matrix.mjs` | Matrix validator + plan expander. Emits the GitHub Actions matrices. | | `scripts/ci/prepare-windows-host.ps1` | Per-backend Windows host preparation / prerequisite assertions, plus the `winget` repair and the workload-tooling install. Runs in Windows PowerShell, because it installs the `pwsh` the steps after it use. | @@ -234,7 +235,7 @@ get fixed or wired. | WSLC | ✅ Good | Might have to retry hung jobs - this is an issue with overzealous agent reclaiming. | | IsolationSession | ✅ Good | Runs the one-shot and state-aware suites, the Rust SDK in-process and helper tests, the C# end-to-end tests, the Node SDK suite and the COM apartment probe. Fails when the host cannot run isolation sessions, when a suite executes nothing, or when the run changes the set of local accounts. | | Windows Sandbox | ⛔ Blocked | Images don't support `Containers-DisposableClientVM` opt. feature | -| MicroVM | Experimental | Windows 25H2 x64 nightly runs the prebuilt `mxc-sdk/tests/nvx_e2e.rs` suite, including executor config scenarios and captured JSON execution. Host-only checks remain in normal PR builds. | +| MicroVM | Experimental | Windows 25H2 x64 nightly runs the prebuilt `mxc-sdk/tests/nvx_e2e.rs` suite, including executor config scenarios and captured JSON execution. Host-only and extracted-crate compilation checks remain in normal Windows x64 PR builds. | | Seatbelt | ✅ Good | Failures are genuine MXC bugs. | ## Host preparation diff --git a/scripts/ci/Invoke-CratePackage.ps1 b/scripts/ci/Invoke-CratePackage.ps1 index dba9d8a82e..5dffb8f563 100644 --- a/scripts/ci/Invoke-CratePackage.ps1 +++ b/scripts/ci/Invoke-CratePackage.ps1 @@ -138,7 +138,7 @@ if ($VerifyMicrovmTests) 'microvm_timeout.json' )) { - $fixturePath = Join-Path $extractedPackage "tests/fixtures/microvm/$fixture" + $fixturePath = Join-Path $extractedPackage "tests\fixtures\microvm\$fixture" if (-not (Test-Path -LiteralPath $fixturePath -PathType Leaf)) { throw "Packaged MicroVM fixture is missing: $fixture" From 0fae092ccba923684258ec924c794a766e03a6a7 Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Mon, 26 Oct 2026 15:45:48 -0700 Subject: [PATCH 13/18] fix(nvx): preserve startup diagnostics and coordinate executor cancellation Share captured and streaming invocation orchestration while retaining their distinct execution contracts. Capture bounded signed-agent diagnostics before teardown and route console interruption through native execution cancellation and ordered cleanup. Cover cancellation races and real console interruption during startup and silent execution. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/mxc-sdk/src/backends/nvx/abi.rs | 43 ++- src/mxc-sdk/src/backends/nvx/runner.rs | 340 ++++++++++++++++-- src/mxc-sdk/src/backends/nvx/runtime.rs | 332 ++++++++++++++++- .../src/core/mxc_common/script_runner.rs | 53 +++ src/mxc-sdk/src/core/mxc_engine/mod.rs | 229 ++++++------ src/mxc-sdk/tests/nvx_e2e.rs | 215 +++++++++++ src/tools/wxc/src/main.rs | 166 +++++++-- 7 files changed, 1193 insertions(+), 185 deletions(-) diff --git a/src/mxc-sdk/src/backends/nvx/abi.rs b/src/mxc-sdk/src/backends/nvx/abi.rs index f4e4d771ef..8142e8853f 100644 --- a/src/mxc-sdk/src/backends/nvx/abi.rs +++ b/src/mxc-sdk/src/backends/nvx/abi.rs @@ -217,6 +217,14 @@ pub(super) struct DeprovisionResult { pub metadata: Option>, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct SandboxDiagnostics { + pub vmm_log: PathBuf, + pub console_log: PathBuf, + pub outcome_report: PathBuf, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(super) struct ExecInput { @@ -569,7 +577,12 @@ impl AgentLibrary { } } - fn take_buffer(&self, buffer: *mut u8, length: usize) -> Result>, MxcError> { + fn take_buffer( + &self, + buffer: *mut u8, + length: usize, + limit: Option, + ) -> Result>, MxcError> { if buffer.is_null() { if length == 0 { return Ok(None); @@ -578,6 +591,12 @@ impl AgentLibrary { "NVX signed bundled agent: the agent library returned a null buffer with a non-zero length", )); } + if limit.is_some_and(|limit| length > limit) { + unsafe { (self.exports.buffer_free)(buffer, length) }; + return Err(MxcError::backend_error( + "NVX signed bundled agent: diagnostics response exceeds the 65536-byte limit", + )); + } let bytes = if length == 0 { Vec::new() } else { @@ -591,11 +610,19 @@ impl AgentLibrary { fn call( &self, invoke: impl FnOnce(*mut *mut u8, *mut usize) -> i32, + ) -> Result>, MxcError> { + self.call_with_limit(invoke, None) + } + + fn call_with_limit( + &self, + invoke: impl FnOnce(*mut *mut u8, *mut usize) -> i32, + limit: Option, ) -> Result>, MxcError> { let mut buffer = ptr::null_mut(); let mut length = 0; let status = invoke(&mut buffer, &mut length); - let bytes = self.take_buffer(buffer, length)?; + let bytes = self.take_buffer(buffer, length, limit)?; match status { STATUS_OK => Ok(bytes), STATUS_ERROR => Err(error_from_body(bytes.as_deref())), @@ -698,6 +725,18 @@ impl AgentLibrary { ) } + pub fn diagnostics(&self, host: u64, token: &str) -> Result { + decode_json( + self.call_with_limit( + |out, length| unsafe { + (self.exports.diagnostics)(host, token.as_ptr(), token.len(), out, length) + }, + Some(64 * 1024), + )?, + "diagnostics", + ) + } + pub fn exec(&self, host: u64, token: &str, input: &ExecInput) -> Result { let encoded = encode_json(input)?; let mut exec_handle = 0u64; diff --git a/src/mxc-sdk/src/backends/nvx/runner.rs b/src/mxc-sdk/src/backends/nvx/runner.rs index a8fbb1b0ae..7e291ee264 100644 --- a/src/mxc-sdk/src/backends/nvx/runner.rs +++ b/src/mxc-sdk/src/backends/nvx/runner.rs @@ -9,7 +9,7 @@ use crate::mxc_common::audit::{AuditEvent, AuditEventName}; use crate::mxc_common::logger::Logger; use crate::mxc_common::models::{ExecutionRequest, FailurePhase, ScriptResponse}; use crate::mxc_common::mxc_error::{MxcError, MxcErrorCode}; -use crate::mxc_common::script_runner::ScriptRunner; +use crate::mxc_common::script_runner::{RunCancellation, ScriptRunner}; use super::abi::{ExecOutcome, ExecRequest}; use super::runtime::SandboxClient; @@ -28,20 +28,51 @@ pub struct CapturedRun { pub stderr: Vec, } +impl CapturedRun { + pub(crate) fn response(&self) -> ScriptResponse { + match self.outcome { + CapturedOutcome::Exited(exit_code) => ScriptResponse { + exit_code, + ..Default::default() + }, + CapturedOutcome::TimedOut => ScriptResponse { + exit_code: -1, + error_message: "NVX workload timed out".to_owned(), + failure_phase: FailurePhase::Timeout, + ..Default::default() + }, + } + } +} + #[derive(Debug, Default)] -pub struct NvxRunner; +pub struct NvxRunner { + cancellation: RunCancellation, +} impl NvxRunner { pub fn new() -> Self { - Self + Self::default() } pub fn run_captured( &mut self, request: &ExecutionRequest, logger: &mut Logger, + ) -> Result { + self.cancellation.begin(); + let result = self.run_captured_inner(request, logger); + self.cancellation.complete(); + result + } + + fn run_captured_inner( + &mut self, + request: &ExecutionRequest, + logger: &mut Logger, ) -> Result { super::policy::validate_request(request)?; + self.cancellation.check()?; let state_root = tempfile::Builder::new() .prefix(&format!("mxc-nvx-{}-", std::process::id())) .tempdir() @@ -64,7 +95,13 @@ impl NvxRunner { return finish_run(Err(error), cleanup, logger); } }; - run_with_client(&mut client, state_root, request, logger) + run_with_client( + &mut client, + state_root, + request, + logger, + Some(&self.cancellation), + ) } } @@ -73,11 +110,14 @@ fn run_with_client( state_root: tempfile::TempDir, request: &ExecutionRequest, logger: &mut Logger, + cancellation: Option<&RunCancellation>, ) -> Result { let exec = exec_request(request); let sandbox_id = match client .validate_exec(&exec) + .and_then(|()| cancellation.map_or(Ok(()), RunCancellation::check)) .and_then(|()| client.probe()) + .and_then(|()| cancellation.map_or(Ok(()), RunCancellation::check)) .and_then(|()| client.provision()) { Ok(sandbox_id) => sandbox_id, @@ -103,8 +143,28 @@ fn run_with_client( .str("runtime", "signed-bundled-agent"), ); let primary = (|| { - client.start(&sandbox_id)?; - client.exec_and_wait(&sandbox_id, &exec) + if let Some(cancellation) = cancellation { + cancellation.check()?; + } + if let Err(mut error) = client.start(&sandbox_id) { + error.message.push_str("; startup diagnostics"); + match client.diagnostics(&sandbox_id) { + Ok(diagnostics) => { + error + .message + .push_str(&super::runtime::startup_diagnostics(&diagnostics)); + } + Err(diagnostic_error) => { + error.message.push_str(" unavailable: "); + error.message.push_str(&diagnostic_error.to_string()); + } + } + return Err(error); + } + if let Some(cancellation) = cancellation { + cancellation.check()?; + } + client.exec_and_wait(&sandbox_id, &exec, cancellation, logger) })(); let stop_error = match client.stop(&sandbox_id).err() { @@ -180,6 +240,10 @@ fn finish_run( } impl ScriptRunner for NvxRunner { + fn cancellation(&self) -> Option { + Some(self.cancellation.clone()) + } + fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { super::policy::validate_request(request) .map_err(|error| ScriptResponse::rejected(&error.message)) @@ -238,18 +302,7 @@ fn captured_to_script_response( .write_all(&result.stderr) .and_then(|()| stderr.flush()) .map_err(|error| MxcError::backend_error(format!("cannot relay NVX stderr: {error}")))?; - Ok(match result.outcome { - CapturedOutcome::Exited(exit_code) => ScriptResponse { - exit_code, - ..Default::default() - }, - CapturedOutcome::TimedOut => ScriptResponse { - exit_code: -1, - error_message: "NVX workload timed out".to_string(), - failure_phase: FailurePhase::Timeout, - ..Default::default() - }, - }) + Ok(result.response()) } fn exec_request(request: &ExecutionRequest) -> ExecRequest { @@ -270,6 +323,7 @@ fn exec_request(request: &ExecutionRequest) -> ExecRequest { mod tests { use std::cell::RefCell; + use super::super::abi::SandboxDiagnostics; use super::super::runtime::{ExecOutput, SandboxId}; use super::*; @@ -289,6 +343,32 @@ mod tests { ); } + #[test] + fn cancelled_runner_disarms_after_cleanup_and_can_be_reused() { + let mut runner = NvxRunner::new(); + let signal = runner.cancellation().unwrap(); + assert!(signal.request()); + let request = ExecutionRequest { + script_code: "printf test".to_owned(), + ..Default::default() + }; + let error = runner + .run_captured( + &request, + &mut Logger::new(crate::mxc_common::logger::Mode::Buffer), + ) + .unwrap_err(); + assert_eq!(error.message, "execution cancelled"); + assert!(signal.was_cancelled()); + assert!( + !signal.request(), + "a finished backend must not claim output-relay interruption" + ); + signal.begin(); + assert!(signal.check().is_ok()); + assert!(!signal.was_cancelled()); + } + #[test] fn rejects_non_terminal_success_outcomes() { assert!(map_outcome(ExecOutcome::Cancelled).is_err()); @@ -444,10 +524,12 @@ mod tests { probe_res: Result<(), MxcError>, provision_res: Result, start_res: Result<(), MxcError>, + diagnostics_res: Result, exec_res: Result, stop_res: Result<(), MxcError>, deprovision_res: Result<(), MxcError>, close_res: Result<(), MxcError>, + cancel_at: Option<(&'static str, RunCancellation)>, } impl MockClient { @@ -460,6 +542,7 @@ mod tests { probe_res: Ok(()), provision_res: SandboxId::from_token("0123456789abcdef0123456789abcdef"), start_res: Ok(()), + diagnostics_res: Err(MxcError::backend_error("diagnostics unavailable")), exec_res: Ok(ExecOutput { outcome: ExecOutcome::Exited(0), stdout: b"ok".to_vec(), @@ -468,38 +551,58 @@ mod tests { stop_res: Ok(()), deprovision_res: Ok(()), close_res: Ok(()), + cancel_at: None, + } + } + + fn record(&self, phase: &'static str) { + self.calls.borrow_mut().push(phase); + if let Some((at, cancellation)) = &self.cancel_at { + if *at == phase { + cancellation.request(); + } } } } impl SandboxClient for MockClient { fn validate_exec(&self, request: &ExecRequest) -> Result<(), MxcError> { - self.calls.borrow_mut().push("validate"); + self.record("validate"); *self.validated_request.borrow_mut() = Some(request.clone()); self.validate_res.clone() } fn probe(&self) -> Result<(), MxcError> { - self.calls.borrow_mut().push("probe"); + self.record("probe"); self.probe_res.clone() } fn provision(&self) -> Result { - self.calls.borrow_mut().push("provision"); + self.record("provision"); self.provision_res.clone() } fn start(&self, _sandbox_id: &SandboxId) -> Result<(), MxcError> { - self.calls.borrow_mut().push("start"); + self.record("start"); self.start_res.clone() } + fn diagnostics(&self, _sandbox_id: &SandboxId) -> Result { + self.calls.borrow_mut().push("diagnostics"); + self.diagnostics_res.clone() + } + fn exec_and_wait( &self, _sandbox_id: &SandboxId, request: &ExecRequest, + cancellation: Option<&RunCancellation>, + _logger: &mut Logger, ) -> Result { - self.calls.borrow_mut().push("exec"); + self.record("exec"); + if let Some(cancellation) = cancellation { + cancellation.check()?; + } *self.executed_request.borrow_mut() = Some(request.clone()); self.exec_res.clone() } @@ -520,6 +623,175 @@ mod tests { } } + #[test] + fn cancellation_during_startup_or_execution_runs_ordered_teardown() { + for phase in ["validate", "probe", "provision", "start", "exec"] { + for cleanup_fails in [false, true] { + let cancellation = RunCancellation::default(); + let mut client = MockClient::ok(); + client.cancel_at = Some((phase, cancellation.clone())); + if cleanup_fails { + client.close_res = Err(MxcError::backend_error("host_close failed")); + } + let state_root = tempfile::tempdir().unwrap(); + let path = state_root.path().to_path_buf(); + let error = run_with_client( + &mut client, + state_root, + &ExecutionRequest::default(), + &mut Logger::new(crate::mxc_common::logger::Mode::Buffer), + Some(&cancellation), + ) + .unwrap_err(); + assert_eq!(error.code, MxcErrorCode::BackendError); + assert!(error.message.starts_with("execution cancelled")); + assert!(cancellation.was_cancelled()); + assert!(!path.exists()); + let expected: &[&str] = match phase { + "validate" => &["validate", "close"], + "probe" => &["validate", "probe", "close"], + "provision" => &[ + "validate", + "probe", + "provision", + "stop", + "deprovision", + "close", + ], + "start" => &[ + "validate", + "probe", + "provision", + "start", + "stop", + "deprovision", + "close", + ], + _ => &[ + "validate", + "probe", + "provision", + "start", + "exec", + "stop", + "deprovision", + "close", + ], + }; + assert_eq!(client.calls.borrow().as_slice(), expected); + if cleanup_fails { + assert!(error.message.contains("cleanup failure")); + assert!(error.message.contains("host_close failed")); + } else { + assert_eq!(error.message, "execution cancelled"); + } + } + } + } + + #[test] + fn start_failure_wins_over_a_racing_cancellation() { + let cancellation = RunCancellation::default(); + let mut client = MockClient::ok(); + client.cancel_at = Some(("start", cancellation.clone())); + client.start_res = Err(MxcError::backend_unavailable("primary start failure")); + let state_root = tempfile::tempdir().unwrap(); + let path = state_root.path().to_path_buf(); + let error = run_with_client( + &mut client, + state_root, + &ExecutionRequest::default(), + &mut Logger::new(crate::mxc_common::logger::Mode::Buffer), + Some(&cancellation), + ) + .unwrap_err(); + assert_eq!(error.code, MxcErrorCode::BackendUnavailable); + assert!(error + .message + .starts_with("primary start failure; startup diagnostics unavailable")); + assert!( + !cancellation.was_cancelled(), + "the primary failure must keep its category" + ); + assert!(!path.exists()); + assert_eq!( + client.calls.borrow().as_slice(), + [ + "validate", + "probe", + "provision", + "start", + "diagnostics", + "stop", + "deprovision", + "close" + ] + ); + } + + #[test] + fn startup_diagnostics_survive_cleanup_with_bounded_logs_and_secondary_errors() { + for unavailable in [false, true] { + let state_root = tempfile::tempdir().unwrap(); + let state_path = state_root.path().to_path_buf(); + let log = state_path.join("native-vmm.log"); + std::fs::write( + &log, + format!("{}boot failure marker", "x".repeat(40 * 1024)), + ) + .unwrap(); + let mut client = MockClient::ok(); + client.start_res = Err(MxcError::backend_unavailable("primary start failure") + .with_details(serde_json::json!({"phase": "boot"}))); + if !unavailable { + client.diagnostics_res = Ok(SandboxDiagnostics { + vmm_log: log, + console_log: state_path.join("missing-console"), + outcome_report: state_path.join("missing-outcome"), + }); + } + let error = run_with_client( + &mut client, + state_root, + &ExecutionRequest::default(), + &mut Logger::new(crate::mxc_common::logger::Mode::Buffer), + None, + ) + .unwrap_err(); + assert!( + !state_path.exists(), + "diagnostics must not retain run state" + ); + assert_eq!(error.code, MxcErrorCode::BackendUnavailable); + assert_eq!(error.details, Some(serde_json::json!({"phase": "boot"}))); + assert!(error + .message + .starts_with("primary start failure; startup diagnostics")); + if unavailable { + assert!(error.message.contains("diagnostics unavailable")); + } else { + assert!(error.message.contains("[truncated]")); + assert!(error.message.contains("boot failure marker")); + assert!(error.message.contains("console_log: unavailable")); + assert!(error.message.contains("outcome_report: unavailable")); + assert!(error.message.len() < 12 * 1024); + } + assert_eq!( + *client.calls.borrow(), + [ + "validate", + "probe", + "provision", + "start", + "diagnostics", + "stop", + "deprovision", + "close" + ] + ); + } + } + #[test] fn lifecycle_cleanup_surfaces_host_close_and_retains_state_on_deprovision_failure() { let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer); @@ -534,7 +806,8 @@ mod tests { let mut client = MockClient::ok(); client.probe_res = Err(MxcError::backend_unavailable("probe failed")); client.close_res = Err(MxcError::backend_error("host_close failed")); - let err = run_with_client(&mut client, state_root, &request, &mut logger).unwrap_err(); + let err = + run_with_client(&mut client, state_root, &request, &mut logger, None).unwrap_err(); assert_eq!(err.code, MxcErrorCode::BackendUnavailable); assert!(err.message.contains("probe failed")); assert!(err.message.contains("host_close failed")); @@ -550,8 +823,11 @@ mod tests { MxcErrorCode::AlreadyStopped, "already stopped", )); - let err = run_with_client(&mut client, state_root, &request, &mut logger).unwrap_err(); - assert_eq!(err.message, "start failed"); + let err = + run_with_client(&mut client, state_root, &request, &mut logger, None).unwrap_err(); + assert!(err + .message + .starts_with("start failed; startup diagnostics unavailable: ")); assert_eq!( *client.calls.borrow(), [ @@ -559,6 +835,7 @@ mod tests { "probe", "provision", "start", + "diagnostics", "stop", "deprovision", "close" @@ -571,7 +848,8 @@ mod tests { let state_path = state_root.path().to_path_buf(); let mut client = MockClient::ok(); client.close_res = Err(MxcError::backend_error("host_close failed")); - let err = run_with_client(&mut client, state_root, &request, &mut logger).unwrap_err(); + let err = + run_with_client(&mut client, state_root, &request, &mut logger, None).unwrap_err(); assert!(err .message .contains("NVX cleanup failed after workload completion")); @@ -583,7 +861,8 @@ mod tests { let state_path = state_root.path().to_path_buf(); let mut client = MockClient::ok(); client.deprovision_res = Err(MxcError::backend_error("deprovision failed")); - let err = run_with_client(&mut client, state_root, &request, &mut logger).unwrap_err(); + let err = + run_with_client(&mut client, state_root, &request, &mut logger, None).unwrap_err(); assert!(err.message.contains("deprovision failed")); assert!(err .message @@ -608,6 +887,7 @@ mod tests { state_root, &ExecutionRequest::default(), &mut logger, + None, ) .unwrap_err(); assert_eq!(error.code, MxcErrorCode::PolicyValidation); @@ -639,7 +919,8 @@ mod tests { let state_root = tempfile::tempdir().unwrap(); let state_path = state_root.path().to_path_buf(); let mut client = MockClient::ok(); - let result = run_with_client(&mut client, state_root, &request, &mut logger).unwrap(); + let result = + run_with_client(&mut client, state_root, &request, &mut logger, None).unwrap(); assert_eq!(result.outcome, CapturedOutcome::Exited(0)); assert_eq!( *client.validated_request.borrow(), @@ -684,6 +965,7 @@ mod tests { state_root, &ExecutionRequest::default(), &mut logger, + None, ) .unwrap_err(); assert_eq!(error.code, MxcErrorCode::BackendError); diff --git a/src/mxc-sdk/src/backends/nvx/runtime.rs b/src/mxc-sdk/src/backends/nvx/runtime.rs index eaddd83889..cd8a5b29f7 100644 --- a/src/mxc-sdk/src/backends/nvx/runtime.rs +++ b/src/mxc-sdk/src/backends/nvx/runtime.rs @@ -4,16 +4,18 @@ use std::env; use std::ffi::OsString; use std::fmt; -use std::io; +use std::io::{self, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; +use crate::mxc_common::logger::Logger; use crate::mxc_common::mxc_error::MxcError; +use crate::mxc_common::script_runner::RunCancellation; use super::abi::{ map_error_body, AgentLibrary, BundleSource, Event, ExecEnd, ExecInput, ExecOutcome, ExecRequest, HostInfo, ImageSource, ProvisionInput, ProvisionRequest, RuntimeSource, - SandboxSpec, SetupConfig, + SandboxDiagnostics, SandboxSpec, SetupConfig, }; pub(super) const ARTIFACTS_DIR_ENV: &str = "ACI_EDGE_SANDBOXES_AGENT_ARTIFACTS_DIR"; @@ -34,6 +36,8 @@ pub(super) const APPROVED_INITRD_SHA256: &str = const DEFAULT_IMAGE_REFERENCE: &str = "mcr.microsoft.com/azurelinux/base/core:3.0"; const HOST_TIMEOUT_SLACK_MS: u64 = 30_000; const CAPTURE_LIMIT_BYTES: usize = 64 * 1024 * 1024; +const STARTUP_LOG_LIMIT: usize = 8 * 1024; +const CANCELLATION_POLL_MS: u64 = 100; #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct SandboxId { @@ -82,16 +86,57 @@ pub(super) trait SandboxClient { fn probe(&self) -> Result<(), MxcError>; fn provision(&self) -> Result; fn start(&self, sandbox_id: &SandboxId) -> Result<(), MxcError>; + fn diagnostics(&self, sandbox_id: &SandboxId) -> Result; fn exec_and_wait( &self, sandbox_id: &SandboxId, request: &ExecRequest, + cancellation: Option<&RunCancellation>, + logger: &mut Logger, ) -> Result; fn stop(&self, sandbox_id: &SandboxId) -> Result<(), MxcError>; fn deprovision(&self, sandbox_id: &SandboxId) -> Result<(), MxcError>; fn close(&mut self) -> Result<(), MxcError>; } +pub(super) fn startup_diagnostics(diagnostics: &SandboxDiagnostics) -> String { + let mut excerpt = String::new(); + for (name, path) in [ + ("vmm_log", &diagnostics.vmm_log), + ("console_log", &diagnostics.console_log), + ("outcome_report", &diagnostics.outcome_report), + ] { + excerpt.push_str(&format!("; {name}: ")); + match read_startup_log(path) { + Ok((bytes, truncated)) => { + if truncated { + excerpt.push_str("[truncated] "); + } + if bytes.is_empty() { + excerpt.push_str("[empty]"); + } else { + excerpt.push_str(&String::from_utf8_lossy(&bytes)); + } + } + Err(error) => excerpt.push_str(&format!("unavailable ({}): {error}", path.display())), + } + } + excerpt +} + +fn read_startup_log(path: &Path) -> io::Result<(Vec, bool)> { + let mut file = std::fs::File::open(path)?; + let length = file.metadata()?.len(); + let start = length.saturating_sub(STARTUP_LOG_LIMIT as u64); + file.seek(SeekFrom::Start(start))?; + let mut bytes = Vec::new(); + file.take(STARTUP_LOG_LIMIT as u64 + 1) + .read_to_end(&mut bytes)?; + let truncated = start != 0 || bytes.len() > STARTUP_LOG_LIMIT; + bytes.truncate(STARTUP_LOG_LIMIT); + Ok((bytes, truncated)) +} + #[derive(Debug)] pub(super) struct NvxClient { library: &'static AgentLibrary, @@ -316,7 +361,12 @@ impl NvxClient { sandbox_id: &SandboxId, request: &ExecRequest, capture_limit: usize, + cancellation: Option<&RunCancellation>, + logger: &mut Logger, ) -> Result { + if let Some(cancellation) = cancellation { + cancellation.check()?; + } let exec_handle = self.library.exec( self.host, sandbox_id.token(), @@ -326,6 +376,7 @@ impl NvxClient { }, )?; let mut guard = ExecGuard::new(self.library, exec_handle); + logger.log_diagnostic_line("NVX execution started"); let deadline = request .process .timeout @@ -339,11 +390,29 @@ impl NvxClient { let mut stdout = Vec::new(); let mut stderr = Vec::new(); loop { + if let Some(cancellation) = cancellation { + if let Err(mut error) = cancellation.check() { + if let Err(cleanup_error) = guard.cancel_and_release() { + append_cleanup_error(&mut error, cleanup_error); + } + return Err(error); + } + } + let remaining = remaining_timeout_ms(deadline); + let wait_ms = if cancellation.is_some() { + Some( + remaining + .unwrap_or(CANCELLATION_POLL_MS) + .min(CANCELLATION_POLL_MS), + ) + } else { + remaining + }; let step = self.library .exec_next( exec_handle, - remaining_timeout_ms(deadline), + wait_ms, |event| { let captured = stdout.len() + stderr.len(); let (buffer, bytes) = match event { @@ -387,6 +456,10 @@ impl NvxClient { return Err(error); } Ok(None) => { + // A short cancellation poll is not a workload timeout. + if cancellation.is_some() && remaining_timeout_ms(deadline) != Some(0) { + continue; + } guard.cancel_and_release()?; return Ok(ExecOutput { outcome: ExecOutcome::TimedOut, @@ -431,12 +504,24 @@ impl SandboxClient for NvxClient { .map(|_| ()) } + fn diagnostics(&self, sandbox_id: &SandboxId) -> Result { + self.library.diagnostics(self.host, sandbox_id.token()) + } + fn exec_and_wait( &self, sandbox_id: &SandboxId, request: &ExecRequest, + cancellation: Option<&RunCancellation>, + logger: &mut Logger, ) -> Result { - self.exec_and_wait_with_capture_limit(sandbox_id, request, CAPTURE_LIMIT_BYTES) + self.exec_and_wait_with_capture_limit( + sandbox_id, + request, + CAPTURE_LIMIT_BYTES, + cancellation, + logger, + ) } fn stop(&self, sandbox_id: &SandboxId) -> Result<(), MxcError> { @@ -465,6 +550,7 @@ mod tests { use std::ptr; use std::sync::{Mutex, OnceLock, PoisonError}; + use crate::mxc_common::logger::Mode; use crate::mxc_common::mxc_error::MxcErrorCode; use super::super::abi::{ @@ -537,6 +623,7 @@ mod tests { } enum FakeStep { + Cancel(RunCancellation), Event { kind: u32, bytes: Option>, @@ -554,6 +641,10 @@ mod tests { timeouts: Vec, validation_input: Option, validation_error: Option, + diagnostics_reply: Option>, + cancel_on_exec: Option, + cancel_on_exit: Option, + exec_started: bool, } static FAKE_EXECS: OnceLock>> = @@ -577,6 +668,10 @@ mod tests { timeouts: Vec::new(), validation_input: None, validation_error: None, + diagnostics_reply: None, + cancel_on_exec: None, + cancel_on_exit: None, + exec_started: false, }, ); handle @@ -602,6 +697,7 @@ mod tests { } unsafe extern "C" fn fake_buffer_free(ptr: *mut u8, len: usize) { + FREED_BUFFERS.with(|count| count.set(count.get() + 1)); if !ptr.is_null() { unsafe { drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len))); @@ -609,6 +705,34 @@ mod tests { } } + thread_local! { + static FREED_BUFFERS: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + unsafe extern "C" fn fake_diagnostics( + host: u64, + _input: *const u8, + _input_len: usize, + out: *mut *mut u8, + len: *mut usize, + ) -> i32 { + let map = FAKE_EXECS + .get() + .unwrap() + .lock() + .unwrap_or_else(PoisonError::into_inner); + match map + .get(&host) + .and_then(|state| state.diagnostics_reply.as_ref()) + { + Some(bytes) => { + alloc_buf(bytes, out, len); + STATUS_OK + } + None => STATUS_INVALID_HANDLE, + } + } + unsafe extern "C" fn fake_host_open( _in_ptr: *const u8, _in_len: usize, @@ -727,6 +851,17 @@ mod tests { _out: *mut *mut u8, _len: *mut usize, ) -> i32 { + let mut map = FAKE_EXECS + .get() + .unwrap() + .lock() + .unwrap_or_else(PoisonError::into_inner); + if let Some(state) = map.get_mut(&host) { + state.exec_started = true; + if let Some(cancellation) = &state.cancel_on_exec { + cancellation.request(); + } + } unsafe { *exec_out = host }; STATUS_OK } @@ -741,11 +876,20 @@ mod tests { }; state.timeouts.push(timeout); match state.steps.pop_front() { + Some(FakeStep::Cancel(cancellation)) => { + cancellation.request(); + STATUS_TIMEOUT + } Some(FakeStep::Event { kind, bytes: Some(bytes), reported_len, }) => { + if kind == EVENT_EXIT { + if let Some(cancellation) = &state.cancel_on_exit { + cancellation.request(); + } + } state.last_buf = bytes; let length = reported_len.unwrap_or(state.last_buf.len()); unsafe { @@ -803,7 +947,7 @@ mod tests { start: fake_call_invalid, stop: fake_call_invalid, deprovision: fake_call_invalid, - diagnostics: fake_call_invalid, + diagnostics: fake_diagnostics, guest_logs: fake_call_invalid, exec: fake_exec, exec_next: fake_exec_next, @@ -820,6 +964,90 @@ mod tests { }) } + #[test] + fn diagnostics_response_is_bounded_decoded_and_freed_even_when_rejected() { + for bytes in [ + br#"{"vmmLog":"vmm","consoleLog":"console","outcomeReport":"outcome"}"#.to_vec(), + b"malformed".to_vec(), + vec![b'x'; 64 * 1024 + 1], + ] { + let handle = register_fake_exec(Vec::new()); + FAKE_EXECS + .get() + .unwrap() + .lock() + .unwrap() + .get_mut(&handle) + .unwrap() + .diagnostics_reply = Some(bytes.clone()); + let before = FREED_BUFFERS.with(std::cell::Cell::get); + let result = fake_library().diagnostics(handle, "0123456789abcdef0123456789abcdef"); + assert_eq!(FREED_BUFFERS.with(std::cell::Cell::get), before + 1); + if bytes.len() > 64 * 1024 { + assert!(result.unwrap_err().message.contains("65536-byte limit")); + } else if bytes == b"malformed" { + assert_eq!(result.unwrap_err().code, MxcErrorCode::BackendError); + } else { + assert_eq!(result.unwrap().vmm_log, PathBuf::from("vmm")); + } + } + } + + #[test] + fn cancellation_wakes_silent_native_wait_without_becoming_timeout_and_releases_once() { + for timeout in [0, 1000] { + for cleanup_fails in [false, true] { + let cancellation = RunCancellation::default(); + let handle = register_fake_exec(vec![ + FakeStep::Event { + kind: EVENT_STDOUT, + bytes: Some(b"binary\0\xff".to_vec()), + reported_len: None, + }, + FakeStep::Status(STATUS_TIMEOUT), + FakeStep::Cancel(cancellation.clone()), + ]); + if cleanup_fails { + let mut map = FAKE_EXECS.get().unwrap().lock().unwrap(); + let state = map.get_mut(&handle).unwrap(); + state.cancel_status = STATUS_INVALID_ARGUMENT; + state.release_status = STATUS_INVALID_HANDLE; + } + let mut client = NvxClient::from_library(fake_library(), handle); + let error = client + .exec_and_wait( + &SandboxId::from_token("0123456789abcdef0123456789abcdef").unwrap(), + &ExecRequest::command_line("silent").with_timeout_ms(timeout), + Some(&cancellation), + &mut Logger::new(Mode::Buffer), + ) + .unwrap_err(); + assert_eq!(error.code, MxcErrorCode::BackendError); + assert!(error.message.starts_with("execution cancelled")); + assert!(!error.message.contains("timed out")); + assert_eq!(fake_calls(handle), ["cancel", "release"]); + assert!(cancellation.was_cancelled()); + let waits = FAKE_EXECS + .get() + .unwrap() + .lock() + .unwrap() + .get(&handle) + .unwrap() + .timeouts + .clone(); + assert_eq!(waits, [100, 100, 100]); + if cleanup_fails { + assert!(error.message.contains("cleanup failure")); + assert!(error.message.contains("release failure")); + } else { + assert_eq!(error.message, "execution cancelled"); + } + assert_eq!(client.close().is_err(), cleanup_fails); + } + } + } + #[test] fn probe_and_host_close_fail_closed_on_unexpected_payload_or_status() { let lib = fake_library(); @@ -841,6 +1069,64 @@ mod tests { assert!(close_err.message.contains("unknown handle")); } + #[test] + fn cancellation_races_preserve_handle_lifetime_and_completed_outcome() { + for phase in ["before", "during-exec", "terminal"] { + let cancellation = RunCancellation::default(); + let handle = register_fake_exec(vec![FakeStep::Event { + kind: EVENT_EXIT, + bytes: Some(br#"{"outcome":{"exited":42}}"#.to_vec()), + reported_len: None, + }]); + { + let mut map = FAKE_EXECS.get().unwrap().lock().unwrap(); + let state = map.get_mut(&handle).unwrap(); + match phase { + "before" => { + cancellation.request(); + } + "during-exec" => state.cancel_on_exec = Some(cancellation.clone()), + _ => state.cancel_on_exit = Some(cancellation.clone()), + } + } + let mut client = NvxClient::from_library(fake_library(), handle); + let result = client.exec_and_wait( + &SandboxId::from_token("0123456789abcdef0123456789abcdef").unwrap(), + &ExecRequest::command_line("test"), + Some(&cancellation), + &mut Logger::new(Mode::Buffer), + ); + match phase { + "before" => { + assert_eq!(result.unwrap_err().message, "execution cancelled"); + assert!(fake_calls(handle).is_empty()); + assert!( + !FAKE_EXECS + .get() + .unwrap() + .lock() + .unwrap() + .get(&handle) + .unwrap() + .exec_started + ); + assert!(cancellation.was_cancelled()); + } + "during-exec" => { + assert_eq!(result.unwrap_err().message, "execution cancelled"); + assert_eq!(fake_calls(handle), ["cancel", "release"]); + assert!(cancellation.was_cancelled()); + } + _ => { + assert_eq!(result.unwrap().outcome, ExecOutcome::Exited(42)); + assert_eq!(fake_calls(handle), ["release"]); + assert!(!cancellation.was_cancelled()); + } + } + client.close().unwrap(); + } + } + #[test] fn exec_and_wait_streams_bytes_and_handles_timeouts_and_protocol_errors() { let sandbox_id = SandboxId::from_token("0123456789abcdef0123456789abcdef").unwrap(); @@ -865,7 +1151,9 @@ mod tests { }, ]); let mut client = NvxClient::from_library(fake_library(), handle); - let output = client.exec_and_wait(&sandbox_id, &req).unwrap(); + let output = client + .exec_and_wait(&sandbox_id, &req, None, &mut Logger::new(Mode::Buffer)) + .unwrap(); assert_eq!(output.outcome, ExecOutcome::Exited(42)); assert_eq!(output.stdout, b"out\0\xff"); assert_eq!(output.stderr, b"err\0\x80"); @@ -882,7 +1170,9 @@ mod tests { FakeStep::Status(STATUS_TIMEOUT), ]); let mut client = NvxClient::from_library(fake_library(), handle); - let output = client.exec_and_wait(&sandbox_id, &req).unwrap(); + let output = client + .exec_and_wait(&sandbox_id, &req, None, &mut Logger::new(Mode::Buffer)) + .unwrap(); assert_eq!(output.outcome, ExecOutcome::TimedOut); assert_eq!(output.stdout, b"partial-out"); assert_eq!(fake_calls(handle), ["cancel", "release"]); @@ -895,7 +1185,9 @@ mod tests { reported_len: None, }]); let mut client = NvxClient::from_library(fake_library(), handle); - let err = client.exec_and_wait(&sandbox_id, &req).unwrap_err(); + let err = client + .exec_and_wait(&sandbox_id, &req, None, &mut Logger::new(Mode::Buffer)) + .unwrap_err(); assert_eq!(err.code, MxcErrorCode::BackendError); assert!(err.message.contains("unknown execution event 99")); assert_eq!(fake_calls(handle), ["cancel", "release"]); @@ -908,7 +1200,9 @@ mod tests { reported_len: Some(8), }]); let mut client = NvxClient::from_library(fake_library(), handle); - let err = client.exec_and_wait(&sandbox_id, &req).unwrap_err(); + let err = client + .exec_and_wait(&sandbox_id, &req, None, &mut Logger::new(Mode::Buffer)) + .unwrap_err(); assert_eq!(err.code, MxcErrorCode::BackendError); assert!(err.message.contains("data is missing")); assert_eq!(fake_calls(handle), ["cancel", "release"]); @@ -917,7 +1211,9 @@ mod tests { // 5. Native STATUS_INVALID_ARGUMENT -> cancels, releases, fails closed. let handle = register_fake_exec(vec![FakeStep::Status(STATUS_INVALID_ARGUMENT)]); let mut client = NvxClient::from_library(fake_library(), handle); - let err = client.exec_and_wait(&sandbox_id, &req).unwrap_err(); + let err = client + .exec_and_wait(&sandbox_id, &req, None, &mut Logger::new(Mode::Buffer)) + .unwrap_err(); assert_eq!(err.code, MxcErrorCode::BackendError); assert!(err.message.contains("rejected an argument")); assert_eq!(fake_calls(handle), ["cancel", "release"]); @@ -1055,7 +1351,13 @@ mod tests { ]); let mut client = NvxClient::from_library(fake_library(), handle); let output = client - .exec_and_wait_with_capture_limit(&sandbox_id, &request, 8) + .exec_and_wait_with_capture_limit( + &sandbox_id, + &request, + 8, + None, + &mut Logger::new(Mode::Buffer), + ) .unwrap(); assert_eq!(output.outcome, ExecOutcome::Exited(42)); assert_eq!(output.stdout, b"\xff\0\xfex"); @@ -1101,7 +1403,13 @@ mod tests { } let mut client = NvxClient::from_library(fake_library(), handle); let error = client - .exec_and_wait_with_capture_limit(&sandbox_id, &request, 8) + .exec_and_wait_with_capture_limit( + &sandbox_id, + &request, + 8, + None, + &mut Logger::new(Mode::Buffer), + ) .unwrap_err(); assert_eq!(error.code, MxcErrorCode::BackendError); let primary = "NVX signed bundled agent: combined stdout/stderr capture limit of 8 bytes exceeded"; diff --git a/src/mxc-sdk/src/core/mxc_common/script_runner.rs b/src/mxc-sdk/src/core/mxc_common/script_runner.rs index 7e94f4365f..822740959d 100644 --- a/src/mxc-sdk/src/core/mxc_common/script_runner.rs +++ b/src/mxc-sdk/src/core/mxc_common/script_runner.rs @@ -3,10 +3,58 @@ use crate::mxc_common::logger::Logger; use crate::mxc_common::models::{ExecutionRequest, ScriptResponse}; +use crate::mxc_common::mxc_error::MxcError; use crate::mxc_common::validator::{ validate_common, validate_network_policy_support, NetworkPolicySupport, }; +/// Executor-owned cooperative cancellation. It owns no native handles and installs no handlers. +#[doc(hidden)] +#[derive(Debug, Clone, Default)] +pub struct RunCancellation(std::sync::Arc); + +impl RunCancellation { + const REQUESTED: u8 = 1; + const OBSERVED: u8 = 2; + const COMPLETE: u8 = 4; + + /// Returns false once the execution owner has finished cleanup. + pub fn request(&self) -> bool { + self.0 + .fetch_or(Self::REQUESTED, std::sync::atomic::Ordering::AcqRel) + & Self::COMPLETE + == 0 + } + + pub fn was_cancelled(&self) -> bool { + self.0.load(std::sync::atomic::Ordering::Acquire) & Self::OBSERVED != 0 + } + + pub fn begin(&self) { + // Preserve a request that arrived before a new run, but reset a reused runner. + let _ = self.0.fetch_update( + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + |state| (state & Self::COMPLETE != 0).then_some(0), + ); + } + + pub fn complete(&self) { + self.0 + .fetch_or(Self::COMPLETE, std::sync::atomic::Ordering::AcqRel); + } + + pub fn check(&self) -> Result<(), MxcError> { + if self.0.load(std::sync::atomic::Ordering::Acquire) & Self::REQUESTED != 0 { + self.0 + .fetch_or(Self::OBSERVED, std::sync::atomic::Ordering::AcqRel); + Err(MxcError::backend_error("execution cancelled")) + } else { + Ok(()) + } + } +} + /// Trait for executing scripts within a containment backend. /// /// Each backend (AppContainer, Windows Sandbox, etc.) implements this trait @@ -17,6 +65,11 @@ use crate::mxc_common::validator::{ /// [`run`](ScriptRunner::run) method handles validation, dry-run mode, /// and delegates to [`execute`](ScriptRunner::execute). pub trait ScriptRunner { + /// A cancellation signal only when this runner can cooperatively tear down its run. + fn cancellation(&self) -> Option { + None + } + /// Validate shared network support and runner-specific constraints. fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { validate_network_policy_support(request, NetworkPolicySupport::default())?; diff --git a/src/mxc-sdk/src/core/mxc_engine/mod.rs b/src/mxc-sdk/src/core/mxc_engine/mod.rs index a3afc14b7a..0efa5d0da7 100644 --- a/src/mxc-sdk/src/core/mxc_engine/mod.rs +++ b/src/mxc-sdk/src/core/mxc_engine/mod.rs @@ -81,20 +81,7 @@ use crate::mxc_common::state_aware_request::MxcRequest; use crate::mxc_common::telemetry; #[cfg(all(target_os = "windows", target_arch = "x86_64", feature = "microvm"))] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CapturedOutcome { - Exited(i32), - TimedOut, -} - -#[cfg(all(target_os = "windows", target_arch = "x86_64", feature = "microvm"))] -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct CapturedExecutionResult { - pub outcome: CapturedOutcome, - pub warnings: Vec, - pub stdout: Vec, - pub stderr: Vec, -} +pub(crate) use crate::nvx_runner::{CapturedOutcome, CapturedRun as CapturedExecutionResult}; pub(crate) enum OneShotExecution { #[cfg(all(target_os = "windows", target_arch = "x86_64", feature = "microvm"))] @@ -156,8 +143,23 @@ pub(crate) fn execute_one_shot_json( if request.containment == ContainmentBackend::MicroVm { #[cfg(all(target_os = "windows", target_arch = "x86_64", feature = "microvm"))] { - return run_captured_execution_request(&request, logger) - .map(OneShotExecution::Captured); + experimental::require_experimental_optin( + &request.containment, + request.experimental_enabled, + )?; + return invoke_execution_request(&request, logger, |request, logger| { + run::log_policy_hash(request, logger); + crate::mxc_common::validator::validate_common(request).map_err(|response| { + if request.script_code.is_empty() { + MxcError::malformed_request(response.error_message) + } else { + MxcError::policy_validation(response.error_message) + } + })?; + crate::nvx_runner::NvxRunner::new() + .run_captured(request, logger) + .map(OneShotExecution::Captured) + }); } #[cfg(not(all(target_os = "windows", target_arch = "x86_64", feature = "microvm")))] { @@ -193,92 +195,6 @@ fn parse_one_shot_json( Ok(request) } -#[cfg(all(target_os = "windows", target_arch = "x86_64", feature = "microvm"))] -fn run_captured_execution_request( - request: &ExecutionRequest, - mut logger: Logger, -) -> Result { - crate::mxc_engine::experimental::require_experimental_optin( - &request.containment, - request.experimental_enabled, - )?; - - let telemetry_active = request - .telemetry - .as_ref() - .map(|config| telemetry::init(config, &mut logger)) - .unwrap_or(false); - let mut telemetry_registration = TelemetryRegistration::new(telemetry_active); - let requested_sandbox_kind = request - .telemetry - .as_ref() - .and_then(|config| config.requested_sandbox_kind); - crate::mxc_engine::run::log_policy_hash(request, &mut logger); - if let Err(response) = crate::mxc_common::validator::validate_common(request) { - let error = if request.script_code.is_empty() { - MxcError::malformed_request(response.error_message) - } else { - MxcError::policy_validation(response.error_message) - }; - telemetry::emit_sdk_early_exit_with_kind( - telemetry_registration.transfer(), - &request.containment, - requested_sandbox_kind, - telemetry::classify_mxc_error(&error), - ); - return Err(Error::from(error)); - } - - let started = std::time::Instant::now(); - let mut runner = crate::nvx_runner::NvxRunner::new(); - let result = match runner.run_captured(request, &mut logger) { - Ok(result) => result, - Err(error) => { - telemetry::emit_sdk_early_exit_with_kind( - telemetry_registration.transfer(), - &request.containment, - requested_sandbox_kind, - telemetry::classify_mxc_error(&error), - ); - return Err(Error::from(error)); - } - }; - - let response = ScriptResponse { - exit_code: match result.outcome { - crate::nvx_runner::CapturedOutcome::Exited(code) => code, - crate::nvx_runner::CapturedOutcome::TimedOut => -1, - }, - failure_phase: match result.outcome { - crate::nvx_runner::CapturedOutcome::Exited(_) => FailurePhase::None, - crate::nvx_runner::CapturedOutcome::TimedOut => FailurePhase::Timeout, - }, - ..Default::default() - }; - telemetry::emit_sdk_completion_with_kind( - telemetry_registration.transfer(), - &request.containment, - requested_sandbox_kind, - &response, - started.elapsed(), - ); - - let warnings = logger - .take_warnings() - .into_iter() - .chain(result.warnings) - .collect(); - Ok(CapturedExecutionResult { - outcome: match result.outcome { - crate::nvx_runner::CapturedOutcome::Exited(code) => CapturedOutcome::Exited(code), - crate::nvx_runner::CapturedOutcome::TimedOut => CapturedOutcome::TimedOut, - }, - warnings, - stdout: result.stdout, - stderr: result.stderr, - }) -} - fn spawn_execution_request_with_logger( request: &ExecutionRequest, logger: Logger, @@ -288,9 +204,25 @@ fn spawn_execution_request_with_logger( fn spawn_execution_request_with_logger_and( request: &ExecutionRequest, - mut logger: Logger, + logger: Logger, spawn: impl FnOnce(&ExecutionRequest, &mut Logger) -> Result, MxcError>, ) -> Result, Error> { + match invoke_execution_request(request, logger, |request, logger| { + spawn(request, logger).map(OneShotExecution::Streaming) + })? { + OneShotExecution::Streaming(process) => Ok(process), + #[cfg(all(target_os = "windows", target_arch = "x86_64", feature = "microvm"))] + OneShotExecution::Captured(_) => { + unreachable!("streaming invocation returned captured output") + } + } +} + +fn invoke_execution_request( + request: &ExecutionRequest, + mut logger: Logger, + execute: impl FnOnce(&ExecutionRequest, &mut Logger) -> Result, +) -> Result { let telemetry_active = request .telemetry .as_ref() @@ -303,8 +235,8 @@ fn spawn_execution_request_with_logger_and( .and_then(|config| config.requested_sandbox_kind); let containment = request.containment.clone(); let started = std::time::Instant::now(); - let process = match spawn(request, &mut logger) { - Ok(process) => process, + let execution = match execute(request, &mut logger) { + Ok(execution) => execution, Err(error) => { // Preserve the actual error category so bounded telemetry // categorises malformed requests, unsupported containment, and @@ -321,19 +253,36 @@ fn spawn_execution_request_with_logger_and( } }; let extra_warnings = logger.take_warnings(); - let process: Box = ProcessWithWarnings::wrap(process, extra_warnings); - if telemetry_registration.active() { - Ok(Box::new(TelemetryProcess::new( - process, - telemetry_registration.transfer(), - TelemetryMode::OneShot { - containment, + match execution { + OneShotExecution::Streaming(process) => { + let process = ProcessWithWarnings::wrap(process, extra_warnings); + let process: Box = if telemetry_registration.active() { + Box::new(TelemetryProcess::new( + process, + telemetry_registration.transfer(), + TelemetryMode::OneShot { + containment, + requested_sandbox_kind, + }, + started, + )) + } else { + process + }; + Ok(OneShotExecution::Streaming(process)) + } + #[cfg(all(target_os = "windows", target_arch = "x86_64", feature = "microvm"))] + OneShotExecution::Captured(mut result) => { + telemetry::emit_sdk_completion_with_kind( + telemetry_registration.transfer(), + &containment, requested_sandbox_kind, - }, - started, - ))) - } else { - Ok(process) + &result.response(), + started.elapsed(), + ); + result.warnings = extra_warnings.into_iter().chain(result.warnings).collect(); + Ok(OneShotExecution::Captured(result)) + } } } @@ -1236,6 +1185,52 @@ mod telemetry_process_tests { assert_eq!(process.warnings(), ["PTY spawn warning"]); } + #[cfg(all(target_os = "windows", target_arch = "x86_64", feature = "microvm"))] + #[test] + fn shared_invocation_preserves_captured_results_warnings_and_errors() { + for outcome in [CapturedOutcome::Exited(42), CapturedOutcome::TimedOut] { + let mut calls = 0; + let result = invoke_execution_request( + &ExecutionRequest::default(), + Logger::new(Mode::Buffer), + |_, logger| { + calls += 1; + logger.warning_line("invocation warning"); + Ok(OneShotExecution::Captured(CapturedExecutionResult { + outcome, + warnings: vec!["backend warning".to_owned()], + stdout: b"\xff\0\xfe".to_vec(), + stderr: b"\x80\0\x81".to_vec(), + })) + }, + ) + .unwrap(); + assert_eq!(calls, 1); + let OneShotExecution::Captured(result) = result else { + panic!("captured invocation must stay captured"); + }; + assert_eq!(result.outcome, outcome); + assert_eq!(result.warnings, ["invocation warning", "backend warning"]); + assert_eq!(result.stdout, b"\xff\0\xfe"); + assert_eq!(result.stderr, b"\x80\0\x81"); + } + for primary in [ + MxcError::malformed_request("malformed"), + MxcError::policy_validation("policy"), + MxcError::backend_unavailable("unavailable"), + MxcError::backend_error("execution"), + ] { + let error = invoke_execution_request( + &ExecutionRequest::default(), + Logger::new(Mode::Buffer), + |_, _| Err(primary.clone()), + ) + .err() + .expect("invocation failure"); + assert_eq!(error, Error::from(primary)); + } + } + #[test] fn terminal_paths_consume_the_exactly_once_slot() { let mut waited = wrapped(TryWaitResult::Running); diff --git a/src/mxc-sdk/tests/nvx_e2e.rs b/src/mxc-sdk/tests/nvx_e2e.rs index b5325cecc3..34b7fc45c7 100644 --- a/src/mxc-sdk/tests/nvx_e2e.rs +++ b/src/mxc-sdk/tests/nvx_e2e.rs @@ -585,6 +585,221 @@ mod windows_x64 { println!("VM_EXECUTED executor configs all seven fixtures"); } + #[test] + #[ignore = "VM: requires separately built wxc-exec.exe, WHP and exact signed official bundle"] + fn signed_bundle_executor_console_interrupt() { + use mxc_sdk::mxc_common::process_util::OwnedHandle; + use std::os::windows::process::CommandExt; + use std::time::Duration; + use windows::Win32::Foundation::{ERROR_NO_MORE_FILES, WAIT_OBJECT_0, WAIT_TIMEOUT}; + use windows::Win32::System::Console::{ + GenerateConsoleCtrlEvent, SetConsoleCtrlHandler, CTRL_C_EVENT, + }; + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + use windows::Win32::System::Threading::{ + OpenProcess, TerminateProcess, WaitForSingleObject, CREATE_NEW_CONSOLE, + PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, + }; + + const CHILD_ENV: &str = "MXC_NVX_CONSOLE_CHILD"; + if std::env::var_os(CHILD_ENV).is_none() { + for phase in ["startup", "execution"] { + // A separate console prevents Ctrl-C from reaching the suite or other sessions. + let output = Command::new(std::env::current_exe().expect("test executable")) + .args([ + "--exact", + "windows_x64::signed_bundle_executor_console_interrupt", + "--ignored", + "--nocapture", + ]) + .creation_flags(CREATE_NEW_CONSOLE.0) + .env(CHILD_ENV, phase) + .output() + .expect("isolated console helper"); + assert!( + output.status.success(), + "{phase} helper failed\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + println!( + "VM_EXECUTED executor console interruption startup/execution/owned-vmm/cleanup" + ); + return; + } + + unsafe extern "system" fn helper_handler(event: u32) -> windows::core::BOOL { + windows::core::BOOL(i32::from(event == CTRL_C_EVENT)) + } + unsafe { SetConsoleCtrlHandler(Some(helper_handler), true) } + .expect("helper control handler"); + + struct ExecutorGuard { + child: std::process::Child, + vmm: Option, + } + impl Drop for ExecutorGuard { + fn drop(&mut self) { + match self.child.try_wait() { + Ok(Some(_)) => {} + _ => { + if let Err(error) = self.child.kill() { + eprintln!("failed-test executor cleanup failed: {error}"); + } + if let Err(error) = self.child.wait() { + eprintln!("failed-test executor wait failed: {error}"); + } + } + } + if let Some(vmm) = &self.vmm { + if unsafe { WaitForSingleObject(vmm.get(), 0) } == WAIT_TIMEOUT { + if let Err(error) = unsafe { TerminateProcess(vmm.get(), 1) } { + eprintln!("failed-test owned VMM cleanup failed: {error}"); + } else if unsafe { WaitForSingleObject(vmm.get(), 30_000) } != WAIT_OBJECT_0 + { + eprintln!("failed-test owned VMM cleanup did not complete"); + } + } + } + } + } + + fn owned_vmm(pid: u32) -> Option { + let snapshot = OwnedHandle::new( + unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) } + .expect("process snapshot"), + ); + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let mut next = unsafe { Process32FirstW(snapshot.get(), &mut entry) }; + loop { + match next { + Ok(()) => {} + Err(error) if error.code() == ERROR_NO_MORE_FILES.to_hresult() => return None, + Err(error) => panic!("process enumeration failed: {error}"), + } + let end = entry + .szExeFile + .iter() + .position(|&c| c == 0) + .unwrap_or(entry.szExeFile.len()); + let name = String::from_utf16_lossy(&entry.szExeFile[..end]); + if entry.th32ParentProcessID == pid && name.eq_ignore_ascii_case("openvmm.exe") { + let handle = unsafe { + OpenProcess( + PROCESS_SYNCHRONIZE | PROCESS_TERMINATE, + false, + entry.th32ProcessID, + ) + } + .expect("owned OpenVMM handle"); + return Some(OwnedHandle::new(handle)); + } + next = unsafe { Process32NextW(snapshot.get(), &mut entry) }; + } + } + + let directory = tempfile::tempdir().expect("interruption test directory"); + let state = directory.path().join("state"); + std::fs::create_dir(&state).expect("isolated state directory"); + let request_path = directory.path().join("request.json"); + let log_path = directory.path().join("executor.log"); + let stdout_path = directory.path().join("stdout"); + let stderr_path = directory.path().join("stderr"); + let mut request = process_only_request("sleep 600"); + request["process"]["timeout"] = json!(0); + std::fs::write(&request_path, request.to_string()).expect("interruption request"); + let mut guard = ExecutorGuard { + child: Command::new(executor()) + .args(["--experimental", "--log-file"]) + .arg(&log_path) + .arg(&request_path) + .env("TEMP", &state) + .env("TMP", &state) + .stdin(Stdio::null()) + .stdout(std::fs::File::create(&stdout_path).expect("stdout file")) + .stderr(std::fs::File::create(&stderr_path).expect("stderr file")) + .spawn() + .expect("interruptible executor"), + vmm: None, + }; + let phase = std::env::var(CHILD_ENV).expect("interruption phase"); + let ready_deadline = Instant::now() + Duration::from_secs(180); + loop { + assert!( + guard.child.try_wait().expect("executor status").is_none(), + "executor exited before interruption: {}", + std::fs::read_to_string(&stderr_path).expect("executor stderr") + ); + if guard.vmm.is_none() { + guard.vmm = owned_vmm(guard.child.id()); + } + let executing = match std::fs::read_to_string(&log_path) { + Ok(log) => log.contains("NVX execution started"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => panic!("executor log read failed: {error}"), + }; + if guard.vmm.is_some() && (phase == "startup" || executing) { + assert!( + phase != "startup" || !executing, + "missed the startup interruption window" + ); + break; + } + assert!( + Instant::now() < ready_deadline, + "executor never reached {phase}" + ); + std::thread::sleep(Duration::from_millis(20)); + } + let interrupted = Instant::now(); + unsafe { GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0) }.expect("real Ctrl-C delivery"); + let exit = loop { + if let Some(exit) = guard.child.try_wait().expect("cancelled executor status") { + break exit; + } + assert!( + interrupted.elapsed() < Duration::from_secs(180), + "cancellation teardown hung" + ); + std::thread::sleep(Duration::from_millis(20)); + }; + assert_eq!( + exit.code(), + Some(130), + "Ctrl-C must exit through coordinated cancellation" + ); + assert_eq!( + unsafe { WaitForSingleObject(guard.vmm.as_ref().expect("owned VM").get(), 0) }, + WAIT_OBJECT_0, + "owned OpenVMM is still running", + ); + assert!( + std::fs::read_dir(&state) + .expect("run state listing") + .next() + .is_none(), + "cancelled run state was retained" + ); + assert!(std::fs::read(&stdout_path) + .expect("stdout bytes") + .is_empty()); + let stderr = std::fs::read_to_string(&stderr_path).expect("cancellation stderr"); + assert!(stderr.contains("execution cancelled"), "{stderr}"); + assert!(!stderr.contains("timed out"), "{stderr}"); + assert!(std::fs::read_to_string(&log_path) + .expect("teardown log") + .contains("mxc.SandboxTornDown")); + unsafe { SetConsoleCtrlHandler(Some(helper_handler), false) } + .expect("remove helper handler"); + } + #[test] #[ignore = "VM: requires separately built wxc-exec.exe, WHP and exact signed official bundle"] fn signed_bundle_executor_create_and_run() { diff --git a/src/tools/wxc/src/main.rs b/src/tools/wxc/src/main.rs index a30ad4fd09..efaa931e1a 100644 --- a/src/tools/wxc/src/main.rs +++ b/src/tools/wxc/src/main.rs @@ -18,6 +18,8 @@ use mxc_sdk::mxc_common::error::WxcError; use mxc_sdk::mxc_common::logger::{Logger, Mode}; use mxc_sdk::mxc_common::models::{ContainmentBackend, ExecutionRequest, ScriptResponse}; use mxc_sdk::mxc_common::mxc_error::{MxcError, MxcErrorCode, ResponseEnvelope}; +#[cfg(target_os = "windows")] +use mxc_sdk::mxc_common::script_runner::RunCancellation; use mxc_sdk::mxc_common::script_runner::{handle_dry_run_exit, ScriptRunner}; use mxc_sdk::mxc_common::state_aware_dispatch::{resolve_backend, DispatchOutcome}; use mxc_sdk::mxc_common::state_aware_request::{MxcRequest, ParsedStateAwareRequest, Phase}; @@ -874,24 +876,70 @@ fn config_file_path(cli: &Cli) -> Option { // threads through it. // 3. **Ctrl-C / Ctrl-Break / console close / logoff / shutdown** — the // default Windows handler calls `ExitProcess` directly, skipping -// every Rust destructor. We install a `SetConsoleCtrlHandler` that -// takes-and-drops the parked manager before yielding to the -// default handler. +// every Rust destructor. Cooperative runners claim Ctrl-C / Ctrl-Break +// until their owner completes backend cleanup. Other signals and runners +// restore the parked manager before yielding to the default handler. // // The mutex in the slot serializes the Ctrl-C handler and the guard // against each other, so the manager is taken (and therefore // `Drop`'d) at most once. // -// Parent-process kill (`TerminateProcess`) still bypasses every -// handler; any leak there is reaped by `recover_orphaned_state` on -// the next wxc-exec startup (which we already run at the top of -// `main`). +// Parent-process kill (`TerminateProcess`) bypasses every handler. +// DACL orphan recovery is separate; this does not provide automatic NVX recovery. #[cfg(target_os = "windows")] static DACL_CLEANUP_SLOT: OnceLock< Mutex>, > = OnceLock::new(); +#[cfg(target_os = "windows")] +static RUN_CANCELLATION_SLOT: Mutex> = Mutex::new(None); + +#[cfg(target_os = "windows")] +struct ConsoleRunGuard { + cancellation: Option, +} + +#[cfg(target_os = "windows")] +impl ConsoleRunGuard { + fn new(cancellation: Option) -> Self { + *RUN_CANCELLATION_SLOT + .lock() + .unwrap_or_else(|p| p.into_inner()) = cancellation.clone(); + Self { cancellation } + } + + fn finish(mut self) -> bool { + // Clear the handler's signal only after backend and DACL cleanup have finished. + *RUN_CANCELLATION_SLOT + .lock() + .unwrap_or_else(|p| p.into_inner()) = None; + self.cancellation + .take() + .is_some_and(|signal| signal.was_cancelled()) + } +} + +#[cfg(target_os = "windows")] +impl Drop for ConsoleRunGuard { + fn drop(&mut self) { + *RUN_CANCELLATION_SLOT + .lock() + .unwrap_or_else(|p| p.into_inner()) = None; + } +} + +#[cfg(target_os = "windows")] +fn request_console_cancellation(ctrl_type: u32, cancellation: Option<&RunCancellation>) -> bool { + use windows::Win32::System::Console::{CTRL_BREAK_EVENT, CTRL_C_EVENT}; + if matches!(ctrl_type, CTRL_C_EVENT | CTRL_BREAK_EVENT) { + if let Some(cancellation) = cancellation { + return cancellation.request(); + } + } + false +} + #[cfg(target_os = "windows")] fn dacl_cleanup_slot() -> &'static Mutex> { @@ -945,10 +993,9 @@ impl Drop for ParkedDaclGuard { } /// Windows console-control handler. Called by the OS on Ctrl-C, Ctrl-Break, -/// console close, logoff, and shutdown. Takes the parked DACL manager and -/// drops it — `Drop` runs `restore()` which removes the ACEs we applied. -/// Returns `FALSE` so the default handler still runs (which terminates -/// the process). +/// console close, logoff, and shutdown. Cooperative runners claim Ctrl-C / +/// Ctrl-Break and leave cleanup to the execution owner. Otherwise, drops the +/// parked DACL manager and returns `FALSE` to the default termination handler. /// /// Acquires the slot with a bounded wait /// (`plm::coordination::CTRL_HANDLER_DRAIN_TIMEOUT`), not `try_lock`. @@ -961,7 +1008,25 @@ impl Drop for ParkedDaclGuard { /// first. On timeout we proceed anyway; the recovery scan on the next /// `wxc-exec` startup reaps anything left behind. #[cfg(target_os = "windows")] -unsafe extern "system" fn dacl_ctrl_handler(_ctrl_type: u32) -> windows::core::BOOL { +unsafe extern "system" fn dacl_ctrl_handler(ctrl_type: u32) -> windows::core::BOOL { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + handle_console_control(ctrl_type) + })) + .unwrap_or(windows::core::BOOL(0)) +} + +#[cfg(target_os = "windows")] +fn handle_console_control(ctrl_type: u32) -> windows::core::BOOL { + { + let cancellation = RUN_CANCELLATION_SLOT + .lock() + .unwrap_or_else(|p| p.into_inner()); + if request_console_cancellation(ctrl_type, cancellation.as_ref()) { + // The execution owner wakes its bounded native wait and runs teardown. + // Never call native lifecycle APIs or restore DACLs on this handler thread. + return windows::core::BOOL(1); + } + } // Ordering guarantee: best-effort cancellation telemetry runs STRICTLY // BEFORE the up-to-5s DACL cleanup loop, which can consume the OS // shutdown-handler budget. Factored through `cancel_then_cleanup` so a @@ -1025,15 +1090,13 @@ fn cancel_then_cleanup(emit: impl FnOnce(), cleanup: impl FnOnce()) { cleanup(); } -/// Install the console-control handler. Idempotent — calling twice -/// registers the same handler twice, which is harmless because the -/// take-and-drop is `Option::take`-based. +/// Install the executor's console-control handler before backend execution. #[cfg(target_os = "windows")] -fn install_dacl_ctrl_handler() { +fn install_dacl_ctrl_handler() -> windows::core::Result<()> { use windows::Win32::System::Console::SetConsoleCtrlHandler; // SAFETY: `dacl_ctrl_handler` has the correct ABI; the `Add=TRUE` // call merely appends to the OS handler chain. - let _ = unsafe { SetConsoleCtrlHandler(Some(dacl_ctrl_handler), true) }; + unsafe { SetConsoleCtrlHandler(Some(dacl_ctrl_handler), true) } } fn main() { @@ -1150,7 +1213,10 @@ fn main() { // DaclManager on signal. Cheap and idempotent. Windows-only: the parked // DACL manager it drains lives behind `#[cfg(target_os = "windows")]`. #[cfg(target_os = "windows")] - install_dacl_ctrl_handler(); + if let Err(error) = install_dacl_ctrl_handler() { + eprintln!("cannot install executor console-control handler: {error}"); + process::exit(1); + } // Stack-owned witness so a panic anywhere below — between // `park_dacl_for_cleanup` and the explicit `drop(take_parked_dacl())` @@ -1656,6 +1722,8 @@ fn main() { .and_then(|path| path.parent().map(std::path::Path::to_path_buf)) .unwrap_or_else(|| std::path::PathBuf::from(".")); + #[cfg(target_os = "windows")] + let console_run = ConsoleRunGuard::new(runner.cancellation()); let run_start = Instant::now(); let mut response = runner.run(&request, &mut logger); let run_elapsed = run_start.elapsed(); @@ -1701,6 +1769,10 @@ fn main() { drop(runner); #[cfg(target_os = "windows")] drop(take_parked_dacl()); + #[cfg(target_os = "windows")] + let cancelled = console_run.finish(); + #[cfg(not(target_os = "windows"))] + let cancelled = false; // Surface security warnings (e.g. permissiveLearningMode relaxing // deny-by-default under --audit). The logger only records these rather than @@ -1724,13 +1796,25 @@ fn main() { )); } - telemetry::emit_completion_with_kind( - telemetry_active, - &request.containment, - requested_sandbox_kind, - &response, - run_elapsed, - ); + if cancelled { + telemetry::emit_early_exit_with_kind( + telemetry_active, + &request.containment, + requested_sandbox_kind, + telemetry::FailureReason::Cancelled, + ); + if response.exit_code != 0 { + response.exit_code = 130; + } + } else { + telemetry::emit_completion_with_kind( + telemetry_active, + &request.containment, + requested_sandbox_kind, + &response, + run_elapsed, + ); + } if cli.dry_run { handle_dry_run_exit(&response, &mut logger); @@ -3126,6 +3210,38 @@ mod tests { assert!(err.message.contains("invalid CLI command override")); } + #[cfg(target_os = "windows")] + #[test] + fn console_handler_claims_only_cooperatively_cancellable_interrupts() { + use windows::Win32::System::Console::{CTRL_BREAK_EVENT, CTRL_CLOSE_EVENT, CTRL_C_EVENT}; + for event in [CTRL_C_EVENT, CTRL_BREAK_EVENT] { + let cancellation = RunCancellation::default(); + let guard = ConsoleRunGuard::new(Some(cancellation.clone())); + assert!(unsafe { dacl_ctrl_handler(event) }.as_bool()); + assert!( + !cancellation.was_cancelled(), + "requesting must not pretend cleanup completed" + ); + assert!( + !guard.finish(), + "a completed run wins over an unobserved cancellation" + ); + } + let cancellation = RunCancellation::default(); + assert!(!request_console_cancellation( + CTRL_CLOSE_EVENT, + Some(&cancellation) + )); + assert!(!request_console_cancellation(CTRL_C_EVENT, None)); + assert!(!request_console_cancellation(CTRL_BREAK_EVENT, None)); + cancellation.complete(); + assert!(!request_console_cancellation( + CTRL_C_EVENT, + Some(&cancellation) + )); + assert!(RUN_CANCELLATION_SLOT.lock().unwrap().is_none()); + } + #[cfg(target_os = "windows")] #[test] fn cancel_then_cleanup_emits_before_cleanup() { From 2c4ba80615ebe49a67efe8fa4a808f1c7da9e033 Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Mon, 26 Oct 2026 15:56:12 -0700 Subject: [PATCH 14/18] Require real NVX interruption coverage in scheduled validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/backends/nvx/nvx.md | 15 +++++++++++++++ .../ci-validation-infrastructure.md | 2 +- scripts/ci/run_backend_validation_tests.ps1 | 1 + scripts/ci/stage-nvx-validation-tests.ps1 | 1 + scripts/tests/microvm-validation.test.js | 13 +++++++++++-- 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/backends/nvx/nvx.md b/docs/backends/nvx/nvx.md index 910ae45f99..e86cfcc2c5 100644 --- a/docs/backends/nvx/nvx.md +++ b/docs/backends/nvx/nvx.md @@ -122,6 +122,18 @@ A workload timeout preserves captured partial output and tears down the container. Each create-and-run request gets fresh workload state; this phase does not offer persistent containers or manual lifecycle operations. +On startup failure, the returned error includes bounded tails of the agent's +VM log, boot-console log, and outcome report before teardown removes them. +Each tail is limited to 8 KiB; truncation and unavailable diagnostics are +identified without replacing the original failure. + +In the Windows executor, Ctrl-C and Ctrl-Break request cancellation and allow +VM shutdown, deprovisioning, and temporary-directory cleanup before exit code +130 is returned. A request received during startup is observed when the native +startup call returns. Closing the console or forcibly terminating the process +does not provide this cooperative-cleanup guarantee. Embedding the SDK does not +install a process-wide console handler. + ### Temporary agent workspace MXC allocates a private temporary directory for each create-and-run operation. @@ -147,6 +159,9 @@ does not rebuild them. The SDK suite covers both the executor and captured JSON execution, binary stdout/stderr, exit codes, environment and working-directory settings, timeouts, capture limits, cleanup, and the seven MicroVM config fixtures. +An isolated-console scenario interrupts both startup and running execution, +then verifies that the owned OpenVMM process has terminated and run storage +has been removed. Missing prerequisites, skipped scenarios, and empty test runs fail validation. See the [CI validation runbook](../../development/build-and-test/ci-validation-infrastructure.md). diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index 527f179a31..5f8efe8a0d 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -235,7 +235,7 @@ get fixed or wired. | WSLC | ✅ Good | Might have to retry hung jobs - this is an issue with overzealous agent reclaiming. | | IsolationSession | ✅ Good | Runs the one-shot and state-aware suites, the Rust SDK in-process and helper tests, the C# end-to-end tests, the Node SDK suite and the COM apartment probe. Fails when the host cannot run isolation sessions, when a suite executes nothing, or when the run changes the set of local accounts. | | Windows Sandbox | ⛔ Blocked | Images don't support `Containers-DisposableClientVM` opt. feature | -| MicroVM | Experimental | Windows 25H2 x64 nightly runs the prebuilt `mxc-sdk/tests/nvx_e2e.rs` suite, including executor config scenarios and captured JSON execution. Host-only and extracted-crate compilation checks remain in normal Windows x64 PR builds. | +| MicroVM | Experimental | Windows 25H2 x64 nightly runs the prebuilt `mxc-sdk/tests/nvx_e2e.rs` suite, including executor config scenarios, captured JSON execution, and real console interruption during startup and execution with VM/state cleanup assertions. Host-only and extracted-crate compilation checks remain in normal Windows x64 PR builds. | | Seatbelt | ✅ Good | Failures are genuine MXC bugs. | ## Host preparation diff --git a/scripts/ci/run_backend_validation_tests.ps1 b/scripts/ci/run_backend_validation_tests.ps1 index 4af7a1de48..ae70c122fa 100644 --- a/scripts/ci/run_backend_validation_tests.ps1 +++ b/scripts/ci/run_backend_validation_tests.ps1 @@ -397,6 +397,7 @@ function Invoke-MicroVmTests { 'facade' = 'VM_EXECUTED facade sequential ' 'executor' = 'VM_EXECUTED executor sequential ' 'executor configs' = 'VM_EXECUTED executor configs ' + 'executor interruption' = 'VM_EXECUTED executor console interruption ' } foreach ($scenario in $scenarios.Keys) { if (-not $output.Contains($scenarios[$scenario])) { diff --git a/scripts/ci/stage-nvx-validation-tests.ps1 b/scripts/ci/stage-nvx-validation-tests.ps1 index f2e70f7c27..7e021e10f3 100644 --- a/scripts/ci/stage-nvx-validation-tests.ps1 +++ b/scripts/ci/stage-nvx-validation-tests.ps1 @@ -53,6 +53,7 @@ foreach ($name in @( 'windows_x64::host_only_executor_request_matrix', 'windows_x64::signed_bundle_executor_configs', 'windows_x64::signed_bundle_executor_create_and_run', + 'windows_x64::signed_bundle_executor_console_interrupt', 'windows_x64::signed_bundle_facade_create_and_run' )) { if ($discovered -notcontains "$name`: test") { diff --git a/scripts/tests/microvm-validation.test.js b/scripts/tests/microvm-validation.test.js index d1eac440e4..42980f73c9 100644 --- a/scripts/tests/microvm-validation.test.js +++ b/scripts/tests/microvm-validation.test.js @@ -116,6 +116,9 @@ fn main() { println!("windows_x64::host_only_executor_request_matrix: test"); println!("windows_x64::signed_bundle_executor_configs: test"); println!("windows_x64::signed_bundle_executor_create_and_run: test"); + if mode != "missing-interruption-discovery" { + println!("windows_x64::signed_bundle_executor_console_interrupt: test"); + } if mode != "missing-discovery" { println!("windows_x64::signed_bundle_facade_create_and_run: test"); } @@ -141,11 +144,14 @@ fn main() { if mode != "missing-configs" { println!("VM_EXECUTED executor configs all seven fixtures"); } + if mode != "missing-interruption" { + println!("VM_EXECUTED executor console interruption startup/execution/owned-vmm/cleanup"); + } if mode == "skipped" { println!("SKIPPED: fixture VM unavailable"); } let ignored = if mode == "ignored" { 1 } else { 0 }; - println!("test result: ok. 6 passed; 0 failed; {ignored} ignored; 0 measured; 0 filtered out;"); + println!("test result: ok. 7 passed; 0 failed; {ignored} ignored; 0 measured; 0 filtered out;"); } `); const build = spawnSync( @@ -286,6 +292,7 @@ test("artifact dispatcher executes prebuilt SDK tests and preserves the log", () const log = readFileSync(join(directory, "logs", "microvm-nvx.log"), "utf8"); assert.match(log, /VM_EXECUTED facade /); assert.match(log, /VM_EXECUTED executor /); + assert.match(log, /VM_EXECUTED executor console interruption /); }); for (const [mode, message] of [ @@ -294,6 +301,7 @@ for (const [mode, message] of [ ["missing-facade", /did not execute the facade VM scenario/], ["missing-executor", /did not execute the executor VM scenario/], ["missing-configs", /did not execute the executor configs VM scenario/], + ["missing-interruption", /did not execute the executor interruption VM scenario/], ["skipped", /skipped or filtered tests/], ["ignored", /skipped or filtered tests/], ]) { @@ -346,12 +354,13 @@ for (const [mode, message] of [ ["cargo-failure", /cargo test --no-run failed with exit code 17/], ["cargo-no-artifact", /Expected one nvx_e2e test executable/], ["missing-discovery", /did not discover windows_x64::signed_bundle_facade_create_and_run/], + ["missing-interruption-discovery", /did not discover windows_x64::signed_bundle_executor_console_interrupt/], ]) { test(`staging rejects ${mode}`, () => { const result = stage(mode); assert.notEqual(result.status, 0, result.output); assert.match(result.output, message); - if (mode !== "missing-discovery") { + if (!["missing-discovery", "missing-interruption-discovery"].includes(mode)) { assert.equal(existsSync(join(result.directory, "nvx-e2e-tests.exe")), false); } }); From 8b28a4e0218fa544c7537f8e38429c5241b28abe Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Mon, 26 Oct 2026 17:15:06 -0700 Subject: [PATCH 15/18] Honor retired fallback compatibility without shared runtime plumbing Restore the exact adapters and normalized models to main. Ignore legacy fallback settings consistently, with equivalent-policy and real executor/facade coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/backends/nvx/nvx.md | 4 +- docs/schema.md | 3 -- src/mxc-sdk/src/backends/nvx/policy.rs | 5 -- .../src/core/mxc_common/common_request_ir.rs | 1 - .../config_contract_adapters/dev/one_shot.rs | 3 +- .../dev/state_aware.rs | 1 - .../config_contract_adapters/v0_9/one_shot.rs | 3 +- .../v0_9/state_aware.rs | 1 - .../config_contract_adapters/v1_0/one_shot.rs | 3 +- .../v1_0/state_aware.rs | 1 - .../src/core/mxc_common/config_parser.rs | 24 ++++----- src/mxc-sdk/src/core/mxc_common/models.rs | 3 -- src/mxc-sdk/src/core/mxc_common/sdk_input.rs | 1 - src/mxc-sdk/tests/nvx_e2e.rs | 54 +++++++++++-------- 14 files changed, 50 insertions(+), 57 deletions(-) diff --git a/docs/backends/nvx/nvx.md b/docs/backends/nvx/nvx.md index e86cfcc2c5..c70a45610f 100644 --- a/docs/backends/nvx/nvx.md +++ b/docs/backends/nvx/nvx.md @@ -62,7 +62,7 @@ policy before creating a VM rather than ignoring it. The table describes this MXC integration, not every capability of NVX. The signed agent limits workloads to one hour and constrains their working directories and environments as described below. Fixed image selection, -captured-only execution, and refusal of filesystem, network, UI, fallback, +captured-only execution, and refusal of filesystem, network, UI, and public lifecycle policies are MXC Phase 1 restrictions. The [signed-agent documentation](https://github.com/microsoft/nvx/blob/0430ce79d125203c20f0aa69243480513a774296/aci_edge_sandboxes/README.md#agent-backend-opt-in) describes its contract; the direct OpenVMM backend uses a different guest and @@ -83,7 +83,7 @@ does not define the limits of this integration. | Network policy | A supplied `network` section is rejected, including empty or deny-only sections. Omit it. | | Runtime proxy | `runtimeConfig.networkProxy` is rejected. Proxy environment variables are also rejected. | | UI policy | A supplied `ui` section is rejected. | -| Fallback policy | A supplied `fallback` section is rejected. | +| Legacy fallback settings | Accepted and ignored for contract compatibility; they do not enable host-DACL mutation. | | Lifecycle options | `destroyOnExit` must be `true` and `preservePolicy` must be `false`. | Backend-specific sections must match the selected backend. For example, diff --git a/docs/schema.md b/docs/schema.md index 6e0b570b7d..c9bfb0cd86 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -307,9 +307,6 @@ that can be executed independently. > for compatibility and ignore it. It does not enable host-DACL mutation or > change native enforcement. Removing this accepted field from the 1.x line > requires a new major contract. -> Backend support is checked separately: the experimental MicroVM integration -> rejects supplied fallback policy, as described in the -> [NVX backend guide](backends/nvx/nvx.md#supported-operations-and-policy). > **Lifecycle operation fields.** The `phase` top-level field selects a > container lifecycle operation: JSON that includes it is parsed as input for diff --git a/src/mxc-sdk/src/backends/nvx/policy.rs b/src/mxc-sdk/src/backends/nvx/policy.rs index 5946b77980..00862b67c9 100644 --- a/src/mxc-sdk/src/backends/nvx/policy.rs +++ b/src/mxc-sdk/src/backends/nvx/policy.rs @@ -48,11 +48,6 @@ pub(super) fn validate_request(request: &ExecutionRequest) -> Result<(), MxcErro "NVX does not support processContainer.captureDenials", )); } - if request.policy.fallback_specified { - return Err(MxcError::policy_validation( - "NVX does not support fallback policy", - )); - } validate_process(request) } diff --git a/src/mxc-sdk/src/core/mxc_common/common_request_ir.rs b/src/mxc-sdk/src/core/mxc_common/common_request_ir.rs index 0bde92560f..c223c896e4 100644 --- a/src/mxc-sdk/src/core/mxc_common/common_request_ir.rs +++ b/src/mxc-sdk/src/core/mxc_common/common_request_ir.rs @@ -21,7 +21,6 @@ pub(crate) struct CommonRequestIR { pub(crate) lxc: Option, pub(crate) wslc: Option, pub(crate) filesystem: Option, - pub(crate) fallback_specified: bool, pub(crate) network: Option, pub(crate) runtime_config: Option, pub(crate) ui: Option, diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot.rs index 9abe5bde3b..3bfe4c5fbd 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/one_shot.rs @@ -273,7 +273,7 @@ pub(super) fn into_common_request_ir( lifecycle, process, filesystem, - fallback, + fallback: _, network, lxc, process_container, @@ -302,7 +302,6 @@ pub(super) fn into_common_request_ir( lxc: lxc.into_option().map(convert_lxc), wslc: wslc.into_option().map(convert_wslc), filesystem: filesystem.into_option().map(convert_filesystem), - fallback_specified: fallback.into_option().is_some(), network: network.into_option().map(convert_network), runtime_config: runtime_config.into_option().map(convert_runtime_config), telemetry: telemetry.into_option().map(convert_telemetry), diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs index 951bfc605b..dc850f9af8 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/dev/state_aware.rs @@ -137,7 +137,6 @@ fn state_aware_common( sandbox_id: None, process: None, filesystem: None, - fallback_specified: false, network: None, runtime_config: None, telemetry: telemetry.into_option().map(convert_telemetry), diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot.rs index 5019a708b1..4c202a2dae 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/one_shot.rs @@ -246,7 +246,7 @@ pub(super) fn into_common_request_ir( lifecycle, process, filesystem, - fallback, + fallback: _, network, lxc, process_container, @@ -272,7 +272,6 @@ pub(super) fn into_common_request_ir( lxc: lxc.into_option().map(convert_lxc), wslc: wslc.into_option().map(convert_wslc), filesystem: filesystem.into_option().map(convert_filesystem), - fallback_specified: fallback.into_option().is_some(), network: network.into_option().map(convert_network), runtime_config: runtime_config.into_option().map(convert_runtime_config), telemetry: telemetry.into_option().map(convert_telemetry), diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware.rs index cc0ed77955..7cbc307e34 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v0_9/state_aware.rs @@ -87,7 +87,6 @@ fn state_aware_common( sandbox_id: None, process: None, filesystem: None, - fallback_specified: false, network: None, runtime_config: None, telemetry: telemetry.into_option().map(convert_telemetry), diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot.rs index 37a4757058..1dc5a05ca5 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/one_shot.rs @@ -246,7 +246,7 @@ pub(super) fn into_common_request_ir( lifecycle, process, filesystem, - fallback, + fallback: _, network, lxc, process_container, @@ -272,7 +272,6 @@ pub(super) fn into_common_request_ir( lxc: lxc.into_option().map(convert_lxc), wslc: wslc.into_option().map(convert_wslc), filesystem: filesystem.into_option().map(convert_filesystem), - fallback_specified: fallback.into_option().is_some(), network: network.into_option().map(convert_network), runtime_config: runtime_config.into_option().map(convert_runtime_config), telemetry: telemetry.into_option().map(convert_telemetry), diff --git a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware.rs b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware.rs index e03a4b5f25..e6e0fdab57 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_contract_adapters/v1_0/state_aware.rs @@ -87,7 +87,6 @@ fn state_aware_common( sandbox_id: None, process: None, filesystem: None, - fallback_specified: false, network: None, runtime_config: None, telemetry: telemetry.into_option().map(convert_telemetry), diff --git a/src/mxc-sdk/src/core/mxc_common/config_parser.rs b/src/mxc-sdk/src/core/mxc_common/config_parser.rs index 344253adbf..30daff4910 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -1576,8 +1576,6 @@ fn normalize_common_request_ir( validate_filesystem_paths(&policy)?; normalize_filesystem_paths(&mut policy, logger); - policy.fallback_specified = cfg.fallback_specified; - parse_network_policy( &mut policy, NetworkSections { @@ -1863,18 +1861,19 @@ mod tests { } #[test] - fn preserves_fallback_presence_without_restoring_retired_policy() { + fn legacy_fallback_settings_do_not_change_runtime_policy() { for (version, containment) in [ ("0.9.0-alpha", "processcontainer"), ("1.0.0", "processcontainer"), ("1.1.0-alpha", "processcontainer"), ("1.1.0-alpha", "microvm"), ] { - for (fallback, supplied) in [ - (None, false), - (Some(serde_json::json!({})), true), - (Some(serde_json::json!({"allowDaclMutation": true})), true), - (Some(serde_json::json!({"allowDaclMutation": false})), true), + let mut expected = None; + for fallback in [ + None, + Some(serde_json::json!({})), + Some(serde_json::json!({"allowDaclMutation": true})), + Some(serde_json::json!({"allowDaclMutation": false})), ] { let mut json = serde_json::json!({ "version": version, @@ -1889,10 +1888,13 @@ mod tests { else { panic!("expected a create-and-run request"); }; - assert_eq!(request.policy.fallback_specified, supplied); let serialized = serde_json::to_value(request.policy).unwrap(); - assert!(serialized.get("fallback_specified").is_none()); assert!(serialized.get("fallback").is_none()); + if let Some(expected) = &expected { + assert_eq!(&serialized, expected); + } else { + expected = Some(serialized); + } } } } @@ -1916,7 +1918,6 @@ mod tests { network_mode_specified: bool, runtime_network_proxy_specified: bool, ui_specified: bool, - fallback_specified: bool, requested_sandbox_kind: Option<&'static str>, } @@ -1937,7 +1938,6 @@ mod tests { network_mode_specified: request.policy.network_mode_specified, runtime_network_proxy_specified: request.policy.runtime_network_proxy_specified, ui_specified: request.policy.ui_specified, - fallback_specified: request.policy.fallback_specified, requested_sandbox_kind: request .telemetry .as_ref() diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index c77d27fa2c..d9524696af 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -637,9 +637,6 @@ pub struct ContainerPolicy { pub readonly_paths: Vec, pub enumerate_paths: Vec, pub denied_paths: Vec, - /// Whether the caller supplied fallback policy, including an empty section. - #[serde(skip)] - pub fallback_specified: bool, /// Outbound CIDR, protocol, and port policy. pub network_egress: Option, /// Inbound and host-loopback policy. diff --git a/src/mxc-sdk/src/core/mxc_common/sdk_input.rs b/src/mxc-sdk/src/core/mxc_common/sdk_input.rs index c435543017..a94b03f92f 100644 --- a/src/mxc-sdk/src/core/mxc_common/sdk_input.rs +++ b/src/mxc-sdk/src/core/mxc_common/sdk_input.rs @@ -174,7 +174,6 @@ impl SdkStateAwareInput { timeout: process.timeout, }), lifecycle: None, - fallback_specified: false, process_container: None, lxc: None, wslc: None, diff --git a/src/mxc-sdk/tests/nvx_e2e.rs b/src/mxc-sdk/tests/nvx_e2e.rs index 34b7fc45c7..caea79bc95 100644 --- a/src/mxc-sdk/tests/nvx_e2e.rs +++ b/src/mxc-sdk/tests/nvx_e2e.rs @@ -99,15 +99,6 @@ mod windows_x64 { "enumerate", json!({"processContainer": {"filesystem": {"enumeratePaths": ["C:\\work"]}}}), ), - ("fallback", json!({"fallback": {}})), - ( - "fallback-allow", - json!({"fallback": {"allowDaclMutation": true}}), - ), - ( - "fallback-deny", - json!({"fallback": {"allowDaclMutation": false}}), - ), ("destroy", json!({"lifecycle": {"destroyOnExit": false}})), ("preserve", json!({"lifecycle": {"preservePolicy": true}})), ("timeout-limit", json!({"process": {"timeout": 3_600_001}})), @@ -164,6 +155,15 @@ mod windows_x64 { fn valid_process_boundaries() -> Vec<(&'static str, Value)> { vec![ + ("legacy-fallback-empty", json!({"fallback": {}})), + ( + "legacy-fallback-true", + json!({"fallback": {"allowDaclMutation": true}}), + ), + ( + "legacy-fallback-false", + json!({"fallback": {"allowDaclMutation": false}}), + ), ( "cwd-boundary", json!({"process": {"cwd": format!("/{}", "x".repeat(4094))}}), @@ -248,9 +248,6 @@ mod windows_x64 { "{label} must use the parser error contract" ); assert_eq!(error.message, MIXED_BACKEND_ERROR, "{label}"); - } else if matches!(label, "fallback" | "fallback-allow" | "fallback-deny") { - assert_eq!(error.code, ErrorCode::PolicyValidation); - assert_eq!(error.message, "NVX does not support fallback policy"); } if let Some(expected) = process_limit_error(label) { assert_eq!(error.code, ErrorCode::PolicyValidation, "{label}"); @@ -428,12 +425,21 @@ mod windows_x64 { #[ignore = "VM: requires Windows x64, WHP and the exact signed official bundle"] fn signed_bundle_facade_create_and_run() { let marker = nonce(); - for _ in 0..2 { + for fallback in [ + None, + Some(json!({})), + Some(json!({"allowDaclMutation": true})), + Some(json!({"allowDaclMutation": false})), + ] { let command = format!( "test ! -e /tmp/{marker} || exit 99; touch /tmp/{marker}; \ printf '{marker}'; printf 'separate-stderr' >&2; exit 42" ); - let result = facade_run(process_only_request(&command)); + let mut request = process_only_request(&command); + if let Some(fallback) = fallback { + request["fallback"] = fallback; + } + let result = facade_run(request); assert_eq!(result.outcome, WaitResult::Exited(42)); assert_eq!(result.stdout, marker.as_bytes()); assert_eq!(result.stderr, b"separate-stderr"); @@ -804,13 +810,19 @@ mod windows_x64 { #[ignore = "VM: requires separately built wxc-exec.exe, WHP and exact signed official bundle"] fn signed_bundle_executor_create_and_run() { let marker = nonce(); - for _ in 0..2 { - let output = executor_output( - &process_only_request(&format!( - "printf '{marker}'; printf executor-stderr >&2; exit 42" - )), - false, - ); + for fallback in [ + None, + Some(json!({})), + Some(json!({"allowDaclMutation": true})), + Some(json!({"allowDaclMutation": false})), + ] { + let mut request = process_only_request(&format!( + "printf '{marker}'; printf executor-stderr >&2; exit 42" + )); + if let Some(fallback) = fallback { + request["fallback"] = fallback; + } + let output = executor_output(&request, false); assert_eq!(output.status.code(), Some(42)); assert_eq!(output.stdout, marker.as_bytes()); assert_eq!(output.stderr, b"executor-stderr"); From 16eaf3016cf24127e34f214a3064c4ee92f0dbd8 Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Mon, 26 Oct 2026 17:40:50 -0700 Subject: [PATCH 16/18] Include the signed NVX bundle in Azure executor artifacts Preserve the complete pinned runtime in release ZIPs and VPack without re-signing. Exclude the unsupported MicroVM payload from npm with a scoped package rule and cover copy, ZIP hashes, missing inputs, and actual npm tarball contents. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .azure-pipelines/templates/Rust.Build.Job.yml | 12 ++ docs/backends/nvx/nvx.md | 6 + scripts/tests/microvm-validation.test.js | 159 ++++++++++++++++++ sdk/node/bin/.npmignore | 1 + 4 files changed, 178 insertions(+) create mode 100644 sdk/node/bin/.npmignore diff --git a/.azure-pipelines/templates/Rust.Build.Job.yml b/.azure-pipelines/templates/Rust.Build.Job.yml index 0c3fae0622..c2d9629d3f 100644 --- a/.azure-pipelines/templates/Rust.Build.Job.yml +++ b/.azure-pipelines/templates/Rust.Build.Job.yml @@ -210,6 +210,18 @@ jobs: contents: wslcsdk.dll targetFolder: $(outputDirectory)/$(targetTriple) + # The NVX release is already signed and hash-pinned; copy it without re-signing. + - pwsh: | + $ErrorActionPreference = 'Stop' + $bundle = Join-Path '$(targetTripleDir)' 'aci-edge-sandboxes' + if (-not (Test-Path -LiteralPath (Join-Path $bundle 'SOURCE-MANIFEST.json') -PathType Leaf)) { + throw "The Windows x64 build is missing the signed NVX bundle: $bundle" + } + $artifact = Join-Path '$(outputDirectory)' '$(targetTriple)' + Copy-Item -LiteralPath $bundle -Destination $artifact -Recurse + displayName: Copy signed NVX runtime bundle + condition: and(succeeded(), eq('${{ item.os }}', 'windows'), eq('${{ item.arch }}', 'x64')) + # Copy symbol files into a sibling `symbols/` subdir, NOT alongside the # binaries. Workaround for BinSkim BA2007: when the pdb is co-located, # BinSkim reads its S_COMPILE3 records and flags `-wd4146` in the diff --git a/docs/backends/nvx/nvx.md b/docs/backends/nvx/nvx.md index c70a45610f..6d7201beba 100644 --- a/docs/backends/nvx/nvx.md +++ b/docs/backends/nvx/nvx.md @@ -31,6 +31,12 @@ The build stages the pinned official `aci-edge-sandboxes.zip` release runtime binaries, guest images, and provenance files. Do not substitute a standalone OpenVMM executable or a partial bundle. +Windows x64 Azure build artifacts, release ZIPs, and VPack artifacts include +this complete directory beside the executor. The Node SDK package excludes +it because typed V1 does not support MicroVM targeting. The bundle retains the +NVX release's signatures and pinned hashes; MXC's executable-signing step does +not modify it. + Use the development `1.1.0-alpha` request contract: ```json diff --git a/scripts/tests/microvm-validation.test.js b/scripts/tests/microvm-validation.test.js index 86a036a953..d8582cfcc8 100644 --- a/scripts/tests/microvm-validation.test.js +++ b/scripts/tests/microvm-validation.test.js @@ -9,12 +9,14 @@ const { mkdirSync, mkdtempSync, readFileSync, + readdirSync, rmSync, writeFileSync, } = require("node:fs"); const { tmpdir } = require("node:os"); const { delimiter, join, resolve } = require("node:path"); const { spawnSync } = require("node:child_process"); +const { createHash } = require("node:crypto"); const { pathToFileURL } = require("node:url"); const { stripVTControlCharacters } = require("node:util"); @@ -222,6 +224,163 @@ test("Windows builds stage and upload the SDK tests and the complete runtime bun assert.equal(existsSync(join(root, ".github", "workflows", "microvm-e2e.yml")), false); }); +function azureBundleStep() { + const pipeline = readFileSync( + join(root, ".azure-pipelines", "templates", "Rust.Build.Job.yml"), + "utf8" + ); + const match = pipeline.match( + / - pwsh: \|\r?\n((?: .*\r?\n)+) displayName: Copy signed NVX runtime bundle\r?\n condition: (.*)/ + ); + assert.ok(match, "Azure builds must stage the signed NVX runtime bundle"); + assert.equal( + match[2].trim(), + "and(succeeded(), eq('${{ item.os }}', 'windows'), eq('${{ item.arch }}', 'x64'))" + ); + assert.ok(pipeline.indexOf(match[0]) < pipeline.search(/displayName: Publish\r?\n/)); + const signing = pipeline.match(/ signPattern: \|\r?\n((?: .*\r?\n)+)/); + assert.ok(signing, "Windows signing patterns must remain explicit"); + assert.doesNotMatch(signing[1], /aci.edge|openvmm|direct-images|guest[\\/]|[*?]/); + return match[1].replace(/^ /gm, ""); +} + +function azureBundleFixture(mode = "") { + const directory = mkdtempSync(join(scratch, "azure-bundle-")); + const target = join(directory, "target"); + const outputDirectory = join(directory, "out"); + const triplet = "x86_64-pc-windows-msvc"; + const bundle = join(target, "aci-edge-sandboxes"); + mkdirSync(target); + mkdirSync(join(outputDirectory, triplet), { recursive: true }); + const pins = readFileSync(join(root, "src", "mxc-sdk", "build", "build_nvx_bundled.rs"), "utf8"); + const inventory = [...pins.matchAll(/"([^"\r\n]+)",\s*"([0-9a-f]{64})"/g)].map((match) => match[1]); + assert.equal(inventory.length, 18, "fixture must exercise the complete approved bundle inventory"); + if (mode !== "absent") { + mkdirSync(bundle); + if (mode !== "empty") { + for (const relative of inventory) { + if (mode === "missing-manifest" && relative === "SOURCE-MANIFEST.json") continue; + const path = join(bundle, ...relative.split("/")); + mkdirSync(resolve(path, ".."), { recursive: true }); + writeFileSync(path, Buffer.from(`signed fixture bytes: ${relative}\0\xff`, "latin1")); + } + } + } + const script = azureBundleStep() + .replaceAll("$(targetTripleDir)", target.replaceAll("'", "''")) + .replaceAll("$(outputDirectory)", outputDirectory.replaceAll("'", "''")) + .replaceAll("$(targetTriple)", triplet); + const scriptPath = join(directory, "copy-bundle.ps1"); + writeFileSync(scriptPath, script); + const result = runScript(scriptPath, [], {}); + return { ...result, directory, bundle, outputDirectory, triplet, inventory }; +} + +function fileDigests(directory, prefix = "") { + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + return entry.isDirectory() + ? fileDigests(join(directory, entry.name), relative) + : [[relative, createHash("sha256").update(readFileSync(join(directory, entry.name))).digest("hex")]]; + }) + .sort(([left], [right]) => left.localeCompare(right)); +} + +test("Azure release artifact includes the complete signed NVX bundle with unchanged hashes", () => { + const result = azureBundleFixture(); + assert.equal(result.status, 0, result.output); + const expected = fileDigests(result.bundle); + assert.equal(expected.length, result.inventory.length); + assert.deepEqual(fileDigests(join(result.outputDirectory, result.triplet, "aci-edge-sandboxes")), expected); + + // The release job downloads the Windows x64 artifact into x64/ before archiving. + const release = readFileSync( + join(root, ".azure-pipelines", "templates", "Mxc.Binary.Packaging.Job.yml"), + "utf8" + ); + assert.match(release, /artifact: wxc-binaries-x86_64-pc-windows-msvc\s+sdkArch: x64/); + assert.ok(release.includes("path: $(stagingDirectory)/${{ target.sdkArch }}")); + assert.ok(release.includes("includeRootFolder: false")); + const staging = join(result.directory, "release-staging"); + const x64 = join(staging, "x64"); + const archive = join(result.directory, "mxc-release-binaries.zip"); + const extracted = join(result.directory, "extracted"); + mkdirSync(x64, { recursive: true }); + mkdirSync(extracted); + const script = join(result.directory, "release-archive.ps1"); + const quote = (path) => `'${path.replaceAll("'", "''")}'`; + writeFileSync(script, [ + "$ErrorActionPreference = 'Stop'", + `Copy-Item -LiteralPath ${quote(join(result.outputDirectory, result.triplet, "aci-edge-sandboxes"))} -Destination ${quote(x64)} -Recurse`, + `Compress-Archive -Path ${quote(join(staging, "*"))} -DestinationPath ${quote(archive)}`, + `Expand-Archive -LiteralPath ${quote(archive)} -DestinationPath ${quote(extracted)}`, + ].join("\n")); + const archived = runScript(script, [], {}); + assert.equal(archived.status, 0, archived.output); + assert.deepEqual(fileDigests(join(extracted, "x64", "aci-edge-sandboxes")), expected); +}); + +test("npm pack excludes the NVX bundle without removing supported SDK binaries", () => { + const result = azureBundleFixture(); + assert.equal(result.status, 0, result.output); + const packageRoot = join(result.directory, "node-sdk"); + const binaryRoot = join(packageRoot, "bin"); + const x64 = join(binaryRoot, "x64"); + mkdirSync(x64, { recursive: true }); + copyFileSync(join(root, "sdk", "node", "package.json"), join(packageRoot, "package.json")); + copyFileSync(join(root, "sdk", "node", "bin", ".npmignore"), join(binaryRoot, ".npmignore")); + writeFileSync(join(x64, "wxc-exec.exe"), "executor fixture"); + writeFileSync(join(x64, "mxc_ffi.dll"), "SDK library fixture"); + const script = join(result.directory, "npm-artifact.ps1"); + const quote = (path) => `'${path.replaceAll("'", "''")}'`; + writeFileSync(script, [ + "$ErrorActionPreference = 'Stop'", + `Copy-Item -LiteralPath ${quote(join(result.outputDirectory, result.triplet, "aci-edge-sandboxes"))} -Destination ${quote(x64)} -Recurse`, + ].join("\n")); + const copied = runScript(script, [], {}); + assert.equal(copied.status, 0, copied.output); + const packed = spawnSync("npm pack --ignore-scripts --offline --json", { + cwd: packageRoot, + shell: true, + encoding: "utf8", + timeout: 30_000, + }); + assert.ifError(packed.error); + assert.equal(packed.status, 0, packed.stdout + packed.stderr); + const files = JSON.parse(packed.stdout)[0].files.map(({ path }) => path).sort(); + assert.deepEqual(files, ["bin/x64/mxc_ffi.dll", "bin/x64/wxc-exec.exe", "package.json"]); + const expected = fileDigests(result.bundle); + assert.deepEqual(fileDigests(join(x64, "aci-edge-sandboxes")), expected); + assert.deepEqual(fileDigests(join(result.outputDirectory, result.triplet, "aci-edge-sandboxes")), expected); + + rmSync(join(binaryRoot, ".npmignore")); + const unfiltered = spawnSync("npm pack --ignore-scripts --offline --json", { + cwd: packageRoot, + shell: true, + encoding: "utf8", + timeout: 30_000, + }); + assert.ifError(unfiltered.error); + assert.equal(unfiltered.status, 0, unfiltered.stdout + unfiltered.stderr); + assert.deepEqual( + JSON.parse(unfiltered.stdout)[0].files + .map(({ path }) => path) + .filter((path) => path.startsWith("bin/x64/aci-edge-sandboxes/")) + .sort(), + result.inventory.map((path) => `bin/x64/aci-edge-sandboxes/${path}`).sort() + ); +}); + +for (const mode of ["absent", "empty", "missing-manifest"]) { + test(`Azure bundle copy rejects ${mode} runtime input`, () => { + const result = azureBundleFixture(mode); + assert.notEqual(result.status, 0, result.output); + assert.match(result.output, /missing the signed NVX bundle/); + assert.equal(existsSync(join(result.outputDirectory, result.triplet, "aci-edge-sandboxes")), false); + }); +} + test("MicroVM fixtures are canonical packaged inputs and remain in schema validation", () => { const fixtureRoot = join(root, "src", "mxc-sdk", "tests", "fixtures", "microvm"); const sdkTests = readFileSync(join(root, "src", "mxc-sdk", "tests", "nvx_e2e.rs"), "utf8"); diff --git a/sdk/node/bin/.npmignore b/sdk/node/bin/.npmignore new file mode 100644 index 0000000000..c3e0b8d0b8 --- /dev/null +++ b/sdk/node/bin/.npmignore @@ -0,0 +1 @@ +*/aci-edge-sandboxes/ From 820fb2be9c7582677724fbebd81bc00c318b5dbc Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Tue, 27 Oct 2026 09:30:51 -0700 Subject: [PATCH 17/18] Exclude the NVX validation executable from npm packages Keep the prebuilt suite in backend validation artifacts without publishing it as an SDK binary. Reproduce the failing CI tarball contents in the real npm pack regression and preserve the strict package allowlist. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- scripts/tests/microvm-validation.test.js | 13 ++++++++++--- sdk/node/bin/.npmignore | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/tests/microvm-validation.test.js b/scripts/tests/microvm-validation.test.js index d8582cfcc8..eb515ae788 100644 --- a/scripts/tests/microvm-validation.test.js +++ b/scripts/tests/microvm-validation.test.js @@ -321,7 +321,7 @@ test("Azure release artifact includes the complete signed NVX bundle with unchan assert.deepEqual(fileDigests(join(extracted, "x64", "aci-edge-sandboxes")), expected); }); -test("npm pack excludes the NVX bundle without removing supported SDK binaries", () => { +test("npm pack excludes NVX runtime and validation payloads without removing supported SDK binaries", () => { const result = azureBundleFixture(); assert.equal(result.status, 0, result.output); const packageRoot = join(result.directory, "node-sdk"); @@ -332,6 +332,10 @@ test("npm pack excludes the NVX bundle without removing supported SDK binaries", copyFileSync(join(root, "sdk", "node", "bin", ".npmignore"), join(binaryRoot, ".npmignore")); writeFileSync(join(x64, "wxc-exec.exe"), "executor fixture"); writeFileSync(join(x64, "mxc_ffi.dll"), "SDK library fixture"); + const validationSuite = Buffer.from("nightly validation executable"); + const artifactSuite = join(result.outputDirectory, result.triplet, "nvx-e2e-tests.exe"); + writeFileSync(artifactSuite, validationSuite); + writeFileSync(join(x64, "nvx-e2e-tests.exe"), validationSuite); const script = join(result.directory, "npm-artifact.ps1"); const quote = (path) => `'${path.replaceAll("'", "''")}'`; writeFileSync(script, [ @@ -353,6 +357,8 @@ test("npm pack excludes the NVX bundle without removing supported SDK binaries", const expected = fileDigests(result.bundle); assert.deepEqual(fileDigests(join(x64, "aci-edge-sandboxes")), expected); assert.deepEqual(fileDigests(join(result.outputDirectory, result.triplet, "aci-edge-sandboxes")), expected); + assert.deepEqual(readFileSync(artifactSuite), validationSuite); + assert.deepEqual(readFileSync(join(x64, "nvx-e2e-tests.exe")), validationSuite); rmSync(join(binaryRoot, ".npmignore")); const unfiltered = spawnSync("npm pack --ignore-scripts --offline --json", { @@ -363,9 +369,10 @@ test("npm pack excludes the NVX bundle without removing supported SDK binaries", }); assert.ifError(unfiltered.error); assert.equal(unfiltered.status, 0, unfiltered.stdout + unfiltered.stderr); + const unfilteredFiles = JSON.parse(unfiltered.stdout)[0].files.map(({ path }) => path); + assert.ok(unfilteredFiles.includes("bin/x64/nvx-e2e-tests.exe")); assert.deepEqual( - JSON.parse(unfiltered.stdout)[0].files - .map(({ path }) => path) + unfilteredFiles .filter((path) => path.startsWith("bin/x64/aci-edge-sandboxes/")) .sort(), result.inventory.map((path) => `bin/x64/aci-edge-sandboxes/${path}`).sort() diff --git a/sdk/node/bin/.npmignore b/sdk/node/bin/.npmignore index c3e0b8d0b8..26cb560c7b 100644 --- a/sdk/node/bin/.npmignore +++ b/sdk/node/bin/.npmignore @@ -1 +1,2 @@ */aci-edge-sandboxes/ +*/nvx-e2e-tests.exe From 2404192254bf527f5e7d764fd329f5d0246fa505 Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Tue, 27 Oct 2026 12:18:45 -0700 Subject: [PATCH 18/18] Restore main dependency selections without unrelated lockfile drift Use the authoritative main lockfile unchanged; retain the NVX feature and all intended merge resolutions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Cargo.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index 0fb5ca5c1a..6c89d45fad 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -611,7 +611,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -650,7 +650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2021,7 +2021,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2392,10 +2392,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2814,7 +2814,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]]