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/.github/workflows/Build.Windows.Job.yml b/.github/workflows/Build.Windows.Job.yml index 93bb311868..840ee0b175 100644 --- a/.github/workflows/Build.Windows.Job.yml +++ b/.github/workflows/Build.Windows.Job.yml @@ -91,6 +91,38 @@ 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: 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: 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 }} + 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 @@ -130,5 +162,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/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 deleted file mode 100644 index ee63844377..0000000000 --- a/.github/workflows/microvm-e2e.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: Integration Tests - -# Retained until the unified test matrix has equivalent MicroVM coverage. - -on: - push: - branches: [main] - pull_request: - branches: [main] - 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 --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 binaries - 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 $_)) } - 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 - - - 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 --test wxc_e2e_tests_e2e_windows --target x86_64-pc-windows-msvc test_microvm_ -- --nocapture - - - 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 1e5a99e608..68adb22af8 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,10 +53,10 @@ 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**. +\* These backends are **experimental**. MicroVM is currently available on Windows x64 only. ## How do I use MXC? diff --git a/build.bat b/build.bat index cf02f30e16..b7a9147810 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 ) @@ -40,7 +40,7 @@ if "%BUILD_ALL%"=="0" if "%BUILD_ARCH%"=="" ( :: Build flags set "CARGO_FLAGS=--target" if "%BUILD_CONFIG%"=="release" set "CARGO_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%" @@ -113,25 +113,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" ( @@ -288,7 +278,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 8eb0680674..e4fc6ea2c6 100644 --- a/docs/backends/nanvix/nanvix.md +++ b/docs/backends/nanvix/nanvix.md @@ -1,188 +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 - -MicroVM is an experimental backend, and the published v1 SDKs do not authorize -experimental backends. Run MicroVM requests through `wxc-exec --experimental` -as shown above. - -## 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..6d7201beba --- /dev/null +++ b/docs/backends/nvx/nvx.md @@ -0,0 +1,250 @@ +# 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. + +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 +{ + "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. + +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, +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. | +| 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. | +| 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, +`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. + +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. +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 +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. +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). + +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 +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 25bfa2bad8..21eec364da 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 c0944551b4..1ca712bb70 100644 --- a/docs/development/architecture/repository-architecture.md +++ b/docs/development/architecture/repository-architecture.md @@ -49,7 +49,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 b0eb01aad6..159ef0e18e 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -33,6 +33,8 @@ the individual local test scripts are documented in | `.github/workflows/Package.WindowsSdk.TestBundle.Job.yml` | `workflow_call`-only. Builds the Windows SDK test bundle natively on x64 and arm64 and uploads it as `windows-sdk-test-bundle-`. | | `scripts/ci/build-windows-sdk-test-bundle.ps1` | Builds the IsolationSession, ProcessContainer, and WSLC SDK test payloads and checks that each payload discovers its tests. | | `scripts/ci/stage-isolation-session-test-helpers.ps1` | Inspects staged suites and copies the known helpers they dot-source; a required helper missing from the selected checkout fails packaging. | +| `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. | @@ -65,7 +67,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 including Bubblewrap seccomp and argument generation, 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`. | | `windows-sdk-bundle` | `Package.WindowsSdk.TestBundle.Job.yml` — uploads `windows-sdk-test-bundle-` for x64 + arm64. | @@ -126,7 +128,7 @@ Current platforms: | `windows-24h2` | windows | `1es-mxc-e2e-windows-24h2-pro-x64` | `1es-mxc-e2e-windows-24h2-pro-arm64` | process, wslc, windows-sandbox, microvm | process, isolation-session | | `windows-23h2` | windows | `1es-mxc-e2e-windows-23h2-enterprise-x64` | *(dormant)* | 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 | @@ -223,7 +225,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 | 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. | ### ProcessContainer SDK streaming @@ -262,9 +264,11 @@ every entry. - `process` — no host mutation; the test requires the native probe to report PSEC. -- `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 @@ -350,7 +354,9 @@ UDP/53 rules remains required. 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`, @@ -463,6 +469,15 @@ also use. Linux and macOS need none of this — those suites log to stdout, and the run step tees that into `$RUNNER_TEMP/mxc-ci.log`. +The Windows SDK 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 34e4586890..c9bfb0cd86 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -122,6 +122,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. +For MicroVM networking and proxy support, see the +[NVX backend capability table](backends/nvx/nvx.md#supported-operations-and-policy). + ```json { "version": "0.9.0-alpha", @@ -332,7 +335,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 | 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: a process cannot be launched in any of them. @@ -378,6 +382,9 @@ 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). +For MicroVM environment support and limits, see +[NVX working directory and environment](backends/nvx/nvx.md#working-directory-and-environment). + ### Filesystem Policy The `filesystem` section defines path access policy shared across backends: @@ -388,6 +395,12 @@ 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. | +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: | Field | Type | Default | Description | @@ -473,7 +486,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 @@ -483,7 +496,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/scripts/ci/Invoke-CratePackage.ps1 b/scripts/ci/Invoke-CratePackage.ps1 index 7b58621a32..5dffb8f563 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/ci/prepare-windows-host.ps1 b/scripts/ci/prepare-windows-host.ps1 index 7ecf5bc0a6..8c10e32d5f 100644 --- a/scripts/ci/prepare-windows-host.ps1 +++ b/scripts/ci/prepare-windows-host.ps1 @@ -550,19 +550,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 df0e9cefc5..b86cfd4453 100644 --- a/scripts/ci/run_backend_validation_tests.ps1 +++ b/scripts/ci/run_backend_validation_tests.ps1 @@ -398,6 +398,38 @@ 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 ' + 'executor interruption' = 'VM_EXECUTED executor console interruption ' + } + foreach ($scenario in $scenarios.Keys) { + if (-not $output.Contains($scenarios[$scenario])) { + throw "MicroVM SDK validation did not execute the $scenario VM scenario." + } + } +} + Redirect-Temp switch ($Backend) { @@ -414,8 +446,6 @@ switch ($Backend) { Invoke-WslcSuites } '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..7e021e10f3 --- /dev/null +++ b/scripts/ci/stage-nvx-validation-tests.ps1 @@ -0,0 +1,66 @@ +# 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_executor_console_interrupt', + '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 7cce6f124f..045151763e 100644 --- a/scripts/ci/validation-test-matrix.json +++ b/scripts/ci/validation-test-matrix.json @@ -219,7 +219,6 @@ "pool": "1es-mxc-e2e-ubuntu-24.04-x64", "backends": [ "bubblewrap", - "microvm", "lxc" ] }, @@ -366,7 +365,8 @@ "os": "windows-25h2", "backends": [ "process", - "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..eb515ae788 --- /dev/null +++ b/scripts/tests/microvm-validation.test.js @@ -0,0 +1,535 @@ +// 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, + 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"); + +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; + +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" { + 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); + } + 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-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"); + } + 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 != "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. 7 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, + "-TempDirectory", join(directory, "logs"), + ], + { + MXC_CI_FIXTURE_MODE: mode, + } + ); +} + +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.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); +}); + +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 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"); + 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 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, [ + "$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); + 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", { + cwd: packageRoot, + shell: true, + encoding: "utf8", + timeout: 30_000, + }); + 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( + unfilteredFiles + .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"); + 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); + 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 /); + assert.match(log, /VM_EXECUTED executor console interruption /); +}); + +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/], + ["missing-interruption", /did not execute the executor interruption 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/], + ["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 (!["missing-discovery", "missing-interruption-discovery"].includes(mode)) { + assert.equal(existsSync(join(result.directory, "nvx-e2e-tests.exe")), false); + } + }); +} 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/sdk/node/bin/.npmignore b/sdk/node/bin/.npmignore new file mode 100644 index 0000000000..26cb560c7b --- /dev/null +++ b/sdk/node/bin/.npmignore @@ -0,0 +1,2 @@ +*/aci-edge-sandboxes/ +*/nvx-e2e-tests.exe diff --git a/src/mxc-sdk/Cargo.toml b/src/mxc-sdk/Cargo.toml index 6a7b2a273c..2abe20b853 100644 --- a/src/mxc-sdk/Cargo.toml +++ b/src/mxc-sdk/Cargo.toml @@ -32,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 = [] +microvm = ["dep:libloading", "dep:zip"] hyperlight = [ "dep:hyperlight-unikraft", "dep:ureq", @@ -80,6 +80,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_os = "windows")'.dev-dependencies] serial_test.workspace = true diff --git a/src/mxc-sdk/README.md b/src/mxc-sdk/README.md index a24f365cb5..a0eae3d55f 100644 --- a/src/mxc-sdk/README.md +++ b/src/mxc-sdk/README.md @@ -188,6 +188,15 @@ temporary storage without creating directories. ## Internal modules and executor binaries +### 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 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..899a6dc440 --- /dev/null +++ b/src/mxc-sdk/build/build_nvx_bundled.rs @@ -0,0 +1,456 @@ +// 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}; + +// 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"; +#[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", + ), + ( + "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={BUNDLE_DIR_ENV}"); + #[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") + { + 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) + .expect("OUT_DIR has target/profile ancestors") + .join("aci-edge-sandboxes"); + copy_bundle(&source, &target); + 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!( + "cannot read signed NVX artifact {}: {error}", + path.display() + ) + }); + Sha256::digest(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .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!( + 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}" + ); + } +} + +#[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())) + { + 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}" + ); + } +} + +#[cfg(feature = "microvm")] +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/abi.rs b/src/mxc-sdk/src/backends/nvx/abi.rs new file mode 100644 index 0000000000..8142e8853f --- /dev/null +++ b/src/mxc-sdk/src/backends/nvx/abi.rs @@ -0,0 +1,906 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Checked C ABI v1 bindings for the signed bundled `aci_edge_agent.dll`. + +use std::collections::HashMap; +use std::fmt; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::ptr; +use std::slice; +use std::sync::{Mutex, OnceLock, PoisonError}; + +use libloading::Library as LoadedLibrary; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::mxc_common::mxc_error::{MxcError, MxcErrorCode}; + +pub(super) const SANDBOX_ABI_VERSION: u32 = 1; + +pub(super) const STATUS_OK: i32 = 0; +pub(super) const STATUS_ERROR: i32 = 1; +pub(super) const STATUS_TIMEOUT: i32 = 2; +pub(super) const STATUS_INVALID_HANDLE: i32 = -1; +pub(super) const STATUS_INVALID_ARGUMENT: i32 = -2; +pub(super) const STATUS_PANIC: i32 = -3; + +pub(super) const EVENT_STDOUT: u32 = 1; +pub(super) const EVENT_STDERR: u32 = 2; +pub(super) const EVENT_EXIT: u32 = 3; + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub(super) struct ExecEvent { + pub kind: u32, + pub data: *const u8, + pub length: usize, +} + +const _: () = assert!(std::mem::size_of::() == 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 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 { + 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, + limit: Option, + ) -> 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", + )); + } + 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 { + // 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> { + 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, limit)?; + 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 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)?; + 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 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; + 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 new file mode 100644 index 0000000000..787470eda7 --- /dev/null +++ b/src/mxc-sdk/src/backends/nvx/mod.rs @@ -0,0 +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/policy.rs b/src/mxc-sdk/src/backends/nvx/policy.rs new file mode 100644 index 0000000000..00862b67c9 --- /dev/null +++ b/src/mxc-sdk/src/backends/nvx/policy.rs @@ -0,0 +1,272 @@ +// 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 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> { + if !request.lifecycle.destroy_on_exit { + return Err(MxcError::policy_validation( + "NVX requires lifecycle.destroyOnExit=true", + )); + } + if request.lifecycle.preserve_policy { + return Err(MxcError::policy_validation( + "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( + "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( + "NVX does not support network policy or proxy configuration", + )); + } + if request.policy.ui_specified { + return Err(MxcError::policy_validation( + "NVX does not support UI policy", + )); + } + if request.policy.capture_denials.is_some() { + return Err(MxcError::policy_validation( + "NVX does not support processContainer.captureDenials", + )); + } + 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( + "NVX process.timeout must not exceed one hour", + )); + } + if request + .working_directory + .split('/') + .any(|component| component == "..") + { + return Err(MxcError::policy_validation( + "NVX process.cwd must not contain '..' path components", + )); + } + if !request.working_directory.is_empty() && !request.working_directory.starts_with('/') { + return Err(MxcError::policy_validation( + "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)) + .sum::(); + if bytes > MAX_ENVIRONMENT_BYTES { + return Err(MxcError::policy_validation( + "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( + "NVX does not support proxy environment variables", + )); + } + } + } + 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_request(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_request(&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 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(); + 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_request(&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_request(&request).unwrap_err().code, + MxcErrorCode::MalformedRequest + ); + request.env = None; + request.script_code.push('\0'); + assert_eq!( + 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 new file mode 100644 index 0000000000..7e291ee264 --- /dev/null +++ b/src/mxc-sdk/src/backends/nvx/runner.rs @@ -0,0 +1,996 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Minimal create-and-run adapter for the signed bundled NVX agent. + +use std::io::Write; + +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::{RunCancellation, ScriptRunner}; + +use super::abi::{ExecOutcome, ExecRequest}; +use super::runtime::SandboxClient; + +#[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, +} + +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 { + cancellation: RunCancellation, +} + +impl NvxRunner { + pub fn new() -> 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() + .map_err(|error| { + MxcError::backend_unavailable(format!( + "cannot create a private NVX state directory: {error}" + )) + })?; + let mut client = match super::runtime::create_client(state_root.path()) { + Ok(client) => client, + 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); + } + }; + run_with_client( + &mut client, + state_root, + request, + logger, + Some(&self.cancellation), + ) + } +} + +fn run_with_client( + client: &mut impl SandboxClient, + 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, + 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); + } + }; + + 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 = (|| { + 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() { + 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( + 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 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)) + } + + fn execute(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { + 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) + } + 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, + 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(result.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_ms(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 std::cell::RefCell; + + use super::super::abi::SandboxDiagnostics; + use super::super::runtime::{ExecOutput, SandboxId}; + 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 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()); + } + + #[test] + fn executor_adapter_preserves_exit_and_output() { + 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 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] + 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_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)); + + let defaults = exec_request(&ExecutionRequest::default()); + assert_eq!(defaults.process.env, None); + assert_eq!(defaults.process.timeout, None); + assert_eq!(defaults.process.cwd, None); + } + + struct MockClient { + calls: RefCell>, + validated_request: RefCell>, + executed_request: RefCell>, + validate_res: Result<(), MxcError>, + 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 { + fn ok() -> Self { + Self { + 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(()), + diagnostics_res: Err(MxcError::backend_error("diagnostics unavailable")), + 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(()), + 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.record("validate"); + *self.validated_request.borrow_mut() = Some(request.clone()); + self.validate_res.clone() + } + + fn probe(&self) -> Result<(), MxcError> { + self.record("probe"); + self.probe_res.clone() + } + + fn provision(&self) -> Result { + self.record("provision"); + self.provision_res.clone() + } + + fn start(&self, _sandbox_id: &SandboxId) -> Result<(), MxcError> { + 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.record("exec"); + if let Some(cancellation) = cancellation { + cancellation.check()?; + } + *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.borrow_mut().push("close"); + self.close_res.clone() + } + } + + #[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); + 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, None).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.borrow(), ["validate", "probe", "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, None).unwrap_err(); + assert!(err + .message + .starts_with("start failed; startup diagnostics unavailable: ")); + assert_eq!( + *client.calls.borrow(), + [ + "validate", + "probe", + "provision", + "start", + "diagnostics", + "stop", + "deprovision", + "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, None).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, None).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(); + } + + #[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, + None, + ) + .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, None).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, + None, + ) + .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 new file mode 100644 index 0000000000..cd8a5b29f7 --- /dev/null +++ b/src/mxc-sdk/src/backends/nvx/runtime.rs @@ -0,0 +1,1473 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::env; +use std::ffi::OsString; +use std::fmt; +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, + SandboxDiagnostics, SandboxSpec, SetupConfig, +}; + +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; +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 { + 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 validate_exec(&self, request: &ExecRequest) -> Result<(), MxcError>; + 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, + 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 { + 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); + }; + 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 NvxClient { + fn exec_and_wait_with_capture_limit( + &self, + 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(), + &ExecInput { + request: request.clone(), + stdin: false, + }, + )?; + let mut guard = ExecGuard::new(self.library, exec_handle); + logger.log_diagnostic_line("NVX execution started"); + 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 { + 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, + wait_ms, + |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" + ))); + } + buffer.extend_from_slice(bytes); + Ok(None) + }, + ) + .and_then(|step| step.transpose()); + + match step { + Ok(Some(None)) => {} + Ok(Some(Some(ExecEnd::Outcome(outcome)))) => { + guard.release()?; + return Ok(ExecOutput { + outcome, + stdout, + stderr, + }); + } + 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(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, + stdout, + stderr, + }); + } + Err(mut error) => { + if let Err(cleanup_error) = guard.cancel_and_release() { + append_cleanup_error(&mut error, cleanup_error); + } + return Err(error); + } + } + } + } +} + +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 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, + cancellation, + logger, + ) + } + + 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::logger::Mode; + 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 { + Cancel(RunCancellation), + 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, + 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>> = + 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, + timeouts: Vec::new(), + validation_input: None, + validation_error: None, + diagnostics_reply: None, + cancel_on_exec: None, + cancel_on_exit: None, + exec_started: false, + }, + ); + 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) { + 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))); + } + } + } + + 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, + 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_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, + _tok_len: usize, + _in_ptr: *const u8, + _in_len: usize, + exec_out: *mut u64, + _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 + } + + 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; + }; + 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 { + *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_validate_exec, + provision: fake_call_invalid, + start: fake_call_invalid, + stop: fake_call_invalid, + deprovision: fake_call_invalid, + diagnostics: fake_diagnostics, + 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 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(); + 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 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(); + 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, 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"); + 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, 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"]); + 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, 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"]); + 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, 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"]); + 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, 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"]); + 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, + None, + &mut Logger::new(Mode::Buffer), + ) + .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, + 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"; + 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"); + 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/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 626a8e707e..30daff4910 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -1831,6 +1831,74 @@ 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); + } + + #[test] + 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"), + ] { + 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, + "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"); + }; + let serialized = serde_json::to_value(request.policy).unwrap(); + assert!(serialized.get("fallback").is_none()); + if let Some(expected) = &expected { + assert_eq!(&serialized, expected); + } else { + expected = Some(serialized); + } + } + } + } + fn parse_exact_for_test(json: &str) -> Result { parse_exact_mxc_request_json(json, &mut test_logger()) } 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 1a5f6b935a..79865a762c 100644 --- a/src/mxc-sdk/src/core/mxc_engine/mod.rs +++ b/src/mxc-sdk/src/core/mxc_engine/mod.rs @@ -74,6 +74,15 @@ 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"))] +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"))] + Captured(CapturedExecutionResult), + Streaming(Box), +} + /// Spawn a streaming [`SandboxProcess`] handle for a normalized /// [`ExecutionRequest`]. /// @@ -121,6 +130,46 @@ pub fn spawn_one_shot_json( spawn_execution_request_with_logger(&request, logger, stdin) } +#[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"))] + { + 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")))] + { + 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, false).map(OneShotExecution::Streaming) +} + fn parse_one_shot_json( request_json: &str, experimental: bool, @@ -156,9 +205,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() @@ -171,8 +236,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 @@ -189,19 +254,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)) + } } } @@ -1104,6 +1186,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/src/core/mxc_engine/run.rs b/src/mxc-sdk/src/core/mxc_engine/run.rs index 80d81a9117..ceceacc1db 100644 --- a/src/mxc-sdk/src/core/mxc_engine/run.rs +++ b/src/mxc-sdk/src/core/mxc_engine/run.rs @@ -220,16 +220,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", )) } } @@ -283,20 +283,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 bc47c16417..1b7be7489c 100644 --- a/src/mxc-sdk/src/lib.rs +++ b/src/mxc-sdk/src/lib.rs @@ -160,14 +160,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; @@ -436,7 +430,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 an [MXC lifecycle JSON request](https://github.com/microsoft/mxc/blob/main/docs/development/architecture/container-lifecycle.md#raw-json-entry-points) and return its JSON response envelope. @@ -518,6 +520,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/fixtures/microvm/microvm_error.json b/src/mxc-sdk/tests/fixtures/microvm/microvm_error.json new file mode 100644 index 0000000000..9cfccfa04d --- /dev/null +++ b/src/mxc-sdk/tests/fixtures/microvm/microvm_error.json @@ -0,0 +1,9 @@ +{ + "$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", + "timeout": 30000 + }, + "containment": "microvm" +} diff --git a/src/mxc-sdk/tests/fixtures/microvm/microvm_exit_code.json b/src/mxc-sdk/tests/fixtures/microvm/microvm_exit_code.json new file mode 100644 index 0000000000..1c23a7ac16 --- /dev/null +++ b/src/mxc-sdk/tests/fixtures/microvm/microvm_exit_code.json @@ -0,0 +1,9 @@ +{ + "$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", + "timeout": 30000 + }, + "containment": "microvm" +} diff --git a/src/mxc-sdk/tests/fixtures/microvm/microvm_hello.json b/src/mxc-sdk/tests/fixtures/microvm/microvm_hello.json new file mode 100644 index 0000000000..cb780d67ed --- /dev/null +++ b/src/mxc-sdk/tests/fixtures/microvm/microvm_hello.json @@ -0,0 +1,9 @@ +{ + "$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'", + "timeout": 30000 + }, + "containment": "microvm" +} diff --git a/src/mxc-sdk/tests/fixtures/microvm/microvm_large_output.json b/src/mxc-sdk/tests/fixtures/microvm/microvm_large_output.json new file mode 100644 index 0000000000..f2f332b327 --- /dev/null +++ b/src/mxc-sdk/tests/fixtures/microvm/microvm_large_output.json @@ -0,0 +1,9 @@ +{ + "$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", + "timeout": 30000 + }, + "containment": "microvm" +} diff --git a/src/mxc-sdk/tests/fixtures/microvm/microvm_multiline.json b/src/mxc-sdk/tests/fixtures/microvm/microvm_multiline.json new file mode 100644 index 0000000000..cd43a7b380 --- /dev/null +++ b/src/mxc-sdk/tests/fixtures/microvm/microvm_multiline.json @@ -0,0 +1,9 @@ +{ + "$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", + "timeout": 30000 + }, + "containment": "microvm" +} diff --git a/src/mxc-sdk/tests/fixtures/microvm/microvm_stdlib.json b/src/mxc-sdk/tests/fixtures/microvm/microvm_stdlib.json new file mode 100644 index 0000000000..7f120934de --- /dev/null +++ b/src/mxc-sdk/tests/fixtures/microvm/microvm_stdlib.json @@ -0,0 +1,9 @@ +{ + "$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", + "timeout": 30000 + }, + "containment": "microvm" +} diff --git a/src/mxc-sdk/tests/fixtures/microvm/microvm_timeout.json b/src/mxc-sdk/tests/fixtures/microvm/microvm_timeout.json new file mode 100644 index 0000000000..696f5e6678 --- /dev/null +++ b/src/mxc-sdk/tests/fixtures/microvm/microvm_timeout.json @@ -0,0 +1,10 @@ +{ + "$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": { + "commandLine": "sleep 120; printf 'should not reach here\\n'", + "timeout": 5000 + }, + "containment": "microvm" +} 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..eb51ff83eb --- /dev/null +++ b/src/mxc-sdk/tests/nvx_bundle_integrity.rs @@ -0,0 +1,199 @@ +// 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" + ); +} + +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/src/mxc-sdk/tests/nvx_e2e.rs b/src/mxc-sdk/tests/nvx_e2e.rs new file mode 100644 index 0000000000..caea79bc95 --- /dev/null +++ b/src/mxc-sdk/tests/nvx_e2e.rs @@ -0,0 +1,871 @@ +// 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::{Instant, 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 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 \ + 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"]}}}), + ), + ("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"}})), + ( + "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"]}}), + ), + ( + "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 + } + + 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))}}), + ), + ( + "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 + // 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}"); + } + 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); + 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")); + 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 { + 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 + } + + 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() { + let marker = nonce(); + 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 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"); + } + + 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 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); + 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"); + + 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] + #[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!("fixtures/microvm/microvm_hello.json"), + 0, + Some("sum=100"), + "Hello world", + ), + ( + "microvm_exit_code.json", + include_str!("fixtures/microvm/microvm_exit_code.json"), + 42, + None, + "Exit code propagation", + ), + ( + "microvm_multiline.json", + include_str!("fixtures/microvm/microvm_multiline.json"), + 0, + Some("fib("), + "Shell Fibonacci program", + ), + ( + "microvm_stdlib.json", + include_str!("fixtures/microvm/microvm_stdlib.json"), + 0, + Some("shell=posix"), + "Guest shell and OS", + ), + ( + "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!("fixtures/microvm/microvm_error.json"), + 1, + Some("intentional test error"), + "Shell error", + ), + ( + "microvm_timeout.json", + include_str!("fixtures/microvm/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_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() { + let marker = nonce(); + 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"); + } + 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); + 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("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"); + 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" + ); + } +} 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..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 @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#![cfg(any())] + //! Linux MicroVM (NanVix/KVM) E2E integration tests. //! //! These tests mirror the Windows MicroVM E2E suite in `e2e_windows.rs` and 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 c3e9ec90ec..d510fca7ff 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) @@ -123,97 +120,13 @@ 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, + let config = microvm_configs_dir().join("microvm_hello.json"); + assert_wxc_success( + &config.display().to_string(), &["--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 @@ -624,38 +537,15 @@ fn test_examples() { } #[test] +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] +#[ignore = "VM: requires Windows x64, WHP and the signed NVX bundle"] 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() { @@ -669,13 +559,12 @@ fn test_windows_sandbox() { } #[test] +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] +#[ignore = "VM: requires Windows x64, WHP and the signed NVX bundle"] fn test_microvm_suite() { if !cached_has_wxc_exe() { return; } - if !cached_has_nanvix_binaries() { - return; - } with_test_lock(microvm_suite); } @@ -828,7 +717,16 @@ 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 { config: &'static str, expected_exit: Option, @@ -837,6 +735,7 @@ struct MicrovmCase { expect_non_zero: bool, } +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] #[derive(Debug, Serialize)] struct MicrovmPerfOutput { commit: String, @@ -844,6 +743,7 @@ struct MicrovmPerfOutput { results: Vec, } +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] #[derive(Debug, Serialize)] struct MicrovmPerfEntry { test: String, @@ -853,6 +753,7 @@ struct MicrovmPerfEntry { status: String, } +#[cfg(all(target_arch = "x86_64", feature = "microvm"))] fn microvm_suite() { let cases = [ MicrovmCase { @@ -879,8 +780,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 { @@ -893,8 +794,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 { @@ -910,14 +811,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 { @@ -953,6 +858,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) { @@ -971,6 +877,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() @@ -1233,6 +1140,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/src/tools/wxc/src/main.rs b/src/tools/wxc/src/main.rs index 25f296a83b..d2979faf69 100644 --- a/src/tools/wxc/src/main.rs +++ b/src/tools/wxc/src/main.rs @@ -6,6 +6,8 @@ mod audit; #[cfg(target_os = "windows")] use std::fmt::Write; use std::process; +#[cfg(target_os = "windows")] +use std::sync::Mutex; use std::time::Instant; use clap::{Parser, ValueEnum}; @@ -17,6 +19,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}; @@ -837,18 +841,85 @@ fn config_file_path(cli: &Cli) -> Option { .map(std::path::PathBuf::from) } -/// Emit cancellation telemetry before the default Windows handler terminates -/// the process. #[cfg(target_os = "windows")] -unsafe extern "system" fn telemetry_ctrl_handler(_ctrl_type: u32) -> windows::core::BOOL { - let _ = std::panic::catch_unwind(telemetry::emit_cancellation); +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 cleanup has 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 +} + +/// Claim cooperative interrupts; otherwise emit telemetry before default termination. +#[cfg(target_os = "windows")] +unsafe extern "system" fn executor_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 on this handler thread. + return windows::core::BOOL(1); + } + } + telemetry::emit_cancellation(); windows::core::BOOL(0) } #[cfg(target_os = "windows")] -fn install_telemetry_ctrl_handler() { +fn install_executor_ctrl_handler() -> windows::core::Result<()> { use windows::Win32::System::Console::SetConsoleCtrlHandler; - let _ = unsafe { SetConsoleCtrlHandler(Some(telemetry_ctrl_handler), true) }; + // SAFETY: `executor_ctrl_handler` has the correct ABI; the `Add=TRUE` + // call merely appends to the OS handler chain. + unsafe { SetConsoleCtrlHandler(Some(executor_ctrl_handler), true) } } fn main() { @@ -943,7 +1014,10 @@ fn main() { }; #[cfg(target_os = "windows")] - install_telemetry_ctrl_handler(); + if let Err(error) = install_executor_ctrl_handler() { + eprintln!("cannot install executor console-control handler: {error}"); + process::exit(1); + } // --setup-hyperlight: warm up the snapshot and exit. Runs before // config parsing so the user doesn't need a JSON file on disk @@ -1407,6 +1481,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(); @@ -1439,6 +1515,10 @@ fn main() { } drop(runner); + #[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 @@ -1462,13 +1542,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); @@ -2848,6 +2940,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 { executor_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()); + } + #[test] fn pre_dispatch_vector_is_empty_when_telemetry_inactive() { for phase in [ diff --git a/tests/configs/microvm_error.json b/tests/configs/microvm_error.json deleted file mode 100644 index 8bf4829308..0000000000 --- a/tests/configs/microvm_error.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", - "version": "1.1.0-alpha", - "process": { - "commandLine": "raise ValueError('intentional test error')", - "timeout": 30000 - }, - "containment": "microvm" -} diff --git a/tests/configs/microvm_exit_code.json b/tests/configs/microvm_exit_code.json deleted file mode 100644 index de445300d5..0000000000 --- a/tests/configs/microvm_exit_code.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "../../schemas/dev/mxc-config.schema.1.1.0-alpha.json", - "version": "1.1.0-alpha", - "process": { - "commandLine": "import sys; sys.exit(42)", - "timeout": 30000 - }, - "containment": "microvm" -} diff --git a/tests/configs/microvm_hello.json b/tests/configs/microvm_hello.json deleted file mode 100644 index 385718e8e7..0000000000 --- a/tests/configs/microvm_hello.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$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))", - "timeout": 30000 - }, - "containment": "microvm" -} diff --git a/tests/configs/microvm_large_output.json b/tests/configs/microvm_large_output.json deleted file mode 100644 index a9048f3002..0000000000 --- a/tests/configs/microvm_large_output.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$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)", - "timeout": 30000 - }, - "containment": "microvm" -} diff --git a/tests/configs/microvm_multiline.json b/tests/configs/microvm_multiline.json deleted file mode 100644 index 5340585b87..0000000000 --- a/tests/configs/microvm_multiline.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$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)}')", - "timeout": 30000 - }, - "containment": "microvm" -} diff --git a/tests/configs/microvm_stdlib.json b/tests/configs/microvm_stdlib.json deleted file mode 100644 index 0d8e6b93b3..0000000000 --- a/tests/configs/microvm_stdlib.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$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))", - "timeout": 30000 - }, - "containment": "microvm" -} diff --git a/tests/configs/microvm_timeout.json b/tests/configs/microvm_timeout.json deleted file mode 100644 index 7217c9cdcb..0000000000 --- a/tests/configs/microvm_timeout.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$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.", - "process": { - "commandLine": "import time; time.sleep(120); print('should not reach here')", - "timeout": 5000 - }, - "containment": "microvm" -} diff --git a/tests/scripts/README.md b/tests/scripts/README.md index f30d45e8cd..64f7946bed 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: @@ -46,8 +49,8 @@ Linux / macOS (`.sh`): | `run_pwsh_test.ps1` | PowerShell Set-Location 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` | 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 | | `run_isolation_session_tests.ps1` | IsolationSession one-shot E2E suite | Interactive local session; OS-side IsolationSession service | @@ -314,15 +317,23 @@ Run them explicitly on a capable machine 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). 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 dcf6002b56..42f8673e87 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 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 @@ -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" @@ -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" } )