diff --git a/.azure-pipelines/templates/Rust.Build.Job.yml b/.azure-pipelines/templates/Rust.Build.Job.yml index 6d1254308..ae1fe2e83 100644 --- a/.azure-pipelines/templates/Rust.Build.Job.yml +++ b/.azure-pipelines/templates/Rust.Build.Job.yml @@ -46,7 +46,19 @@ jobs: workingDirectory: $(Build.SourcesDirectory)/src # Explicit feature set (no --all-features): tier2_bfs must NOT ship # on Win 11 25H2 (bfscfg.exe risks an OS hang). - cargoFeatures: hyperlight isolation_session microvm wslc + # Keep the x64-only MicroVM feature coupled to its published NVX payload. + ${{ if eq(item.arch, 'x64') }}: + cargoFeatures: hyperlight isolation_session microvm wslc + microvmSignPattern: bin/openvmm.exe + microvmArtifactPattern: | + bin/openvmm.exe + guest/vmlinux + guest/initramfs.cpio.gz + images/** + ${{ else }}: + cargoFeatures: hyperlight isolation_session wslc + microvmSignPattern: '' + microvmArtifactPattern: '' signPattern: | wxc-exec.exe plm.exe @@ -65,6 +77,8 @@ jobs: artifactPrefix: lxc-binaries workingDirectory: $(Build.SourcesDirectory)/src/core/lxc cargoFeatures: hyperlight + microvmSignPattern: '' + microvmArtifactPattern: '' signPattern: | lxc-exec unix-test-proxy @@ -169,6 +183,7 @@ jobs: FolderPath: $(targetTripleDir) Pattern: | $(signPattern) + $(microvmSignPattern) UseMinimatch: true signConfigType: 'inlineSignParams' inlineOperation: >- @@ -201,6 +216,7 @@ jobs: sourceFolder: $(targetTripleDir) contents: | $(signPattern) + $(microvmArtifactPattern) targetFolder: $(outputDirectory)/$(targetTriple) # Copy wslcsdk.dll diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3e71099c1..c7988f19d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -13,10 +13,10 @@ MXC (Microsoft eXecution Container) is a cross-platform sandboxed code execution ## Architecture invariants - `wxc_common` is the cross-platform foundation. Do not move backend execution or enforcement into it, or add new backend implementation dependencies. -- Backend crates generally depend on `wxc_common`; avoid cross-dependencies between backend crates. The existing optional `nanvix_common` dependency supplies shared MicroVM data/constants rather than backend dispatch. +- Backend crates generally depend on `wxc_common`; avoid cross-dependencies between backend crates. - `mxc_engine` is the single execution engine. Executor binaries and `mxc-sdk` delegate backend routing to it. - Keep `wxc`, `lxc`, and `mxc_darwin` thin. Do not add backend-selection matches to the binaries. -- Keep build-time staging in `mxc_build_common` or `nanvix_build_common`, not runtime crates. +- Keep build-time staging in `mxc_build_common` or backend-specific build helpers such as `nvx_build_common`, not runtime crates. - Use `#[cfg(target_os = "...")]` and existing Cargo feature gates for platform-specific code. - Preserve the distinction between run-to-completion, streaming, and state-aware lifecycle APIs. - Unsupported policy must fail closed. Do not accept a field that the selected backend cannot enforce. @@ -37,6 +37,7 @@ The Rust toolchain is pinned by `src/rust-toolchain.toml`. Run Rust commands fro ```text build.bat +build.bat --with-microvm # Include the incomplete MicroVM (NVX) foundation (Windows x64) ./build.sh diff --git a/.github/workflows/Build.Windows.Job.yml b/.github/workflows/Build.Windows.Job.yml index 9c1b59d4c..ce94ecd7f 100644 --- a/.github/workflows/Build.Windows.Job.yml +++ b/.github/workflows/Build.Windows.Job.yml @@ -14,11 +14,11 @@ jobs: runner: windows-2025 target: x86_64-pc-windows-msvc # tier2_bfs must NOT ship on Win 11 25H2 (bfscfg.exe hangs the host). - features: hyperlight isolation_session microvm wslc + features: hyperlight isolation_session wslc - arch: arm64 runner: windows-11-arm target: aarch64-pc-windows-msvc - # Hyperlight and MicroVM runtimes are x64-only. + # Hyperlight is x64-only. features: isolation_session wslc runs-on: ${{ matrix.runner }} defaults: @@ -66,6 +66,14 @@ jobs: --no-default-features --features "${{ matrix.features }}" + # Keep MicroVM (NVX) out of the uploaded release executable while its workload + # image bundle is unavailable, but verify its opt-in build and staging. + - name: Check opt-in MicroVM packaging + if: matrix.arch == 'x64' + run: cargo check --locked --target ${{ matrix.target }} -p wxc + --no-default-features + --features microvm + - name: Test telemetry consent and policy in release mode run: | cargo test --locked --release --target ${{ matrix.target }} -p wxc_common telemetry::consent diff --git a/.github/workflows/issue-triage.md b/.github/workflows/issue-triage.md index 8843772f5..2a3022d61 100644 --- a/.github/workflows/issue-triage.md +++ b/.github/workflows/issue-triage.md @@ -90,7 +90,7 @@ Assign matching owner(s) with `assign_to_user` using this map: | @mgudgin | AppContainer / BaseContainer / process isolation/container | | @bbonaby | AppContainer / BaseContainer / process isolation/container / networking / firewall / DNS / proxy / iptables | | @SohamDas2021 | Linux / LXC / WSLC / Bubblewrap (bwrap) / proxy on Linux / iptables | -| @huzaifa-d | MicroVM / NanVix / Hyperlight / Windows Sandbox | +| @huzaifa-d | MicroVM / NVX / Hyperlight / Windows Sandbox | | @adpa-ms | IsolationSession / session isolation | | @richiemsft | macOS / Seatbelt | | @mgudgin | SDK configuration and policy (Area-SDK-Configuration, Area-SDK-Policy) | diff --git a/.github/workflows/microvm-e2e.yml b/.github/workflows/microvm-e2e.yml deleted file mode 100644 index 43e2940ba..000000000 --- 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 wxc_e2e_tests --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/.gitignore b/.gitignore index 2feaea311..6f63c4527 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,6 @@ bld/ # Test artifacts *.etl *.log -microvm-perf-results*.json # npm pack artifacts *.tgz diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83648ad81..f0bd81ff6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -150,7 +150,7 @@ MXC has a Rust core (under `src/`) and a TypeScript SDK (under `sdk/node/`). The build.bat :: Release build for current architecture build.bat --debug :: Debug build build.bat --all :: Release build for both x64 and ARM64 -build.bat --with-microvm :: Include NanVix micro-VM binaries +build.bat --with-microvm :: Include the MicroVM (NVX) foundation (Windows x64) ``` **Linux** — `./build.sh`: diff --git a/README.md b/README.md index eff50e39f..5a1a9aa5d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ MXC is a **sandboxed code execution system** for running untrusted code (model o - **Cross-platform**: Windows, Linux, and macOS support with platform-appropriate containment backends - **JSON-based Configuration**: Define execution parameters and security policies via a versioned JSON schema -- **Multiple Containment Backends**: ProcessContainer, Windows Sandbox, LXC, Bubblewrap, Seatbelt (macOS), MicroVM (NanVix), Hyperlight, IsolationSession, and WSLC +- **Multiple Containment Backends**: ProcessContainer, Windows Sandbox, LXC, Bubblewrap, Seatbelt (macOS), MicroVM (NVX), Hyperlight, IsolationSession, and WSLC - **Policy-driven Sandboxing**: - **Filesystem Policy**: Read-only and read-write path lists (denied paths not yet supported on Windows) - **Network Policy**: Proxy support (cooperative on Linux/macOS), allow/block outbound, and backend-dependent host filtering @@ -27,7 +27,7 @@ MXC ships a native container wrapper plus a TypeScript SDK — see the [SDK READ | Platform | Default backend | Other backends | Minimum build | | --- | --- | --- | --- | | Windows 11 24H2+ (verified on 25H2) | `processcontainer` | `windows_sandbox`, `wslc`, `microvm`, `hyperlight`, `isolation_session` | `processcontainer`: 26100 (24H2)
`isolation_session`: 26340.9212 ([Insider Preview](https://learn.microsoft.com/en-us/windows-insider/release-notes/experimental/preview-build-26340-9212)) | -| Linux x64 / ARM64 | `bubblewrap` | `lxc`, `microvm`, `hyperlight` | — | +| Linux x64 / ARM64 | `bubblewrap` | `lxc`, `hyperlight` | — | | macOS ARM64 / x64 (schema `0.7.0-alpha`+) | `seatbelt` | — | — | @@ -70,9 +70,23 @@ layout, crate responsibilities, dependency direction, and execution surfaces. build.bat # Release build for current architecture build.bat --debug # Debug build build.bat --all # Release build for both x64 and ARM64 -build.bat --with-microvm # Include NanVix micro-VM binaries +build.bat --with-microvm # Include the incomplete MicroVM (NVX) foundation (Windows x64) ``` +`--with-microvm` acquires the pinned NVX platform archive at build time and verifies every staged file by +SHA-256; sandbox launches never download artifacts. Set `NVX_BIN` to a +pre-fetched, checksum-verifiable bundle directory for an offline build. The +current pin, `v0.1.0-dev.5c86da3dff02`, contains only `openvmm.exe`, the guest +kernel, and the guest initramfs, not the workload-image bundle. + +The exact `0.9.0-alpha` contract exposes the experimental `microvm` containment +value, implemented internally by NVX. Build with `--with-microvm`; execution +currently returns a typed backend-unavailable error, and capability probes do +not advertise MicroVM while the runtime is incomplete. +Runtime work remains blocked on NVX-produced distro/runtime EROFS images +and scratch image, a proven combined managed-sandbox/virtio-fs contract, and the +required WHP runner. + #### Linux ```bash @@ -227,7 +241,7 @@ See the [SDK README](sdk/node/README.md) for full API documentation. ## Schema Versions -Released, immutable stable schemas live in [`schemas/stable/`](schemas/stable); the in-progress dev schema (experimental backends, state-aware lifecycle) lives in [`schemas/dev/`](schemas/dev). The current stable and dev versions are tracked canonically in [`schemas/schema-version.json`](schemas/schema-version.json). +Released, revision-locked stable schemas live in [`schemas/stable/`](schemas/stable); the in-progress dev schema (remaining development backends and fields) lives in [`schemas/dev/`](schemas/dev). The current stable and dev versions, plus auditable publication revisions, are tracked canonically in [`schemas/schema-version.json`](schemas/schema-version.json). Pick the latest stable schema for new code on any supported platform. See [docs/versioning.md](docs/versioning.md) for the full versioning design. diff --git a/build.bat b/build.bat index 43676988b..2d7ffba33 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 ) @@ -37,6 +37,17 @@ if "%BUILD_ALL%"=="0" if "%BUILD_ARCH%"=="" ( ) ) +if "%WITH_MICROVM%"=="1" ( + if "%BUILD_ALL%"=="1" ( + echo ERROR: --with-microvm supports x64 only and cannot be combined with --all. + exit /b 1 + ) + if /i not "%BUILD_ARCH%"=="x86_64-pc-windows-msvc" ( + echo ERROR: --with-microvm supports x64 only. Use --x64 on an ARM64 host. + exit /b 1 + ) +) + :: Build flags set "CARGO_FLAGS=--target" if "%BUILD_CONFIG%"=="release" set "CARGO_FLAGS=--release --target" @@ -44,7 +55,7 @@ if "%BUILD_CONFIG%"=="release" set "CARGO_FLAGS=--release --target" :: workspace feature flags above, so it uses its own profile/target-only flags. set "PLM_FLAGS=--target" if "%BUILD_CONFIG%"=="release" set "PLM_FLAGS=--release --target" -if "%WITH_NANVIX%"=="1" set "CARGO_FLAGS=--features microvm %CARGO_FLAGS%" +if "%WITH_MICROVM%"=="1" set "CARGO_FLAGS=--features microvm %CARGO_FLAGS%" if "%WITH_WSLC%"=="1" set "CARGO_FLAGS=--features wslc %CARGO_FLAGS%" if "%WITH_ISOLATION_SESSION%"=="1" set "CARGO_FLAGS=--features isolation_session %CARGO_FLAGS%" if "%WITH_HYPERLIGHT%"=="1" set "CARGO_FLAGS=--features hyperlight %CARGO_FLAGS%" @@ -126,24 +137,74 @@ 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 + for %%B in (nanvixd.exe nanvix_rootfs.img python3.initrd bin\kernel.elf snapshots\kernel.vmem snapshots\kernel.whp.cbor) do ( + if exist "sdk\node\bin\!SDK_ARCH!\%%B" del /Q "sdk\node\bin\!SDK_ARCH!\%%B" + ) + if exist "sdk\node\bin\!SDK_ARCH!\snapshots" rd "sdk\node\bin\!SDK_ARCH!\snapshots" 2>nul + if exist "sdk\node\bin\!SDK_ARCH!\bin" rd "sdk\node\bin\!SDK_ARCH!\bin" 2>nul + if "%%T"=="x86_64-pc-windows-msvc" ( + if "%WITH_MICROVM%"=="1" ( + for %%B in (bin\openvmm.exe guest\vmlinux guest\initramfs.cpio.gz) do ( + if not exist "!BIN_DIR!\%%B" ( + echo ERROR: MicroVM ^(NVX^) Node runtime is missing !BIN_DIR!\%%B + exit /b 1 + ) + ) + set "NVX_WORKLOAD_IMAGE_COUNT=0" + for %%B in (images\distro.erofs images\runtime.erofs images\scratch.ext4) do ( + if exist "!BIN_DIR!\%%B" set /A NVX_WORKLOAD_IMAGE_COUNT+=1 + ) + if not "!NVX_WORKLOAD_IMAGE_COUNT!"=="0" if not "!NVX_WORKLOAD_IMAGE_COUNT!"=="3" ( + echo ERROR: MicroVM ^(NVX^) Node runtime has an incomplete workload-image bundle. + exit /b 1 + ) + for %%B in (images\distro.erofs images\runtime.erofs images\scratch.ext4) do ( + if exist "sdk\node\bin\!SDK_ARCH!\%%B" del /Q "sdk\node\bin\!SDK_ARCH!\%%B" + ) + if exist "sdk\node\bin\!SDK_ARCH!\images" rd "sdk\node\bin\!SDK_ARCH!\images" 2>nul + for %%B in (bin\openvmm.exe guest\vmlinux guest\initramfs.cpio.gz) do ( + for %%D in ("sdk\node\bin\!SDK_ARCH!\%%B") do ( + if not exist "%%~dpD" ( + mkdir "%%~dpD" + if errorlevel 1 ( + echo ERROR: Failed to create NVX Node runtime directory %%~dpD + exit /b 1 + ) + ) + ) + copy /Y "!BIN_DIR!\%%B" "sdk\node\bin\!SDK_ARCH!\%%B" >nul + if errorlevel 1 ( + echo ERROR: Failed to copy NVX Node runtime artifact %%B + exit /b 1 + ) 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 "!NVX_WORKLOAD_IMAGE_COUNT!"=="3" ( + for %%B in (images\distro.erofs images\runtime.erofs images\scratch.ext4) do ( + for %%D in ("sdk\node\bin\!SDK_ARCH!\%%B") do ( + if not exist "%%~dpD" ( + mkdir "%%~dpD" + if errorlevel 1 ( + echo ERROR: Failed to create NVX Node runtime directory %%~dpD + exit /b 1 + ) + ) + ) + copy /Y "!BIN_DIR!\%%B" "sdk\node\bin\!SDK_ARCH!\%%B" >nul + if errorlevel 1 ( + echo ERROR: Failed to copy NVX Node runtime artifact %%B + exit /b 1 + ) + echo Copied !SDK_ARCH!\%%B + ) + ) + ) else ( + for %%B in (bin\openvmm.exe guest\vmlinux guest\initramfs.cpio.gz images\distro.erofs images\runtime.erofs images\scratch.ext4) do ( + if exist "sdk\node\bin\!SDK_ARCH!\%%B" del /Q "sdk\node\bin\!SDK_ARCH!\%%B" ) + if exist "sdk\node\bin\!SDK_ARCH!\bin" rd "sdk\node\bin\!SDK_ARCH!\bin" 2>nul + if exist "sdk\node\bin\!SDK_ARCH!\guest" rd "sdk\node\bin\!SDK_ARCH!\guest" 2>nul + if exist "sdk\node\bin\!SDK_ARCH!\images" rd "sdk\node\bin\!SDK_ARCH!\images" 2>nul ) ) if "!COPY_WSLC_RUNTIME!"=="1" ( @@ -277,7 +338,9 @@ echo --release Build release configuration echo --x64 Build for x64 only echo --arm64 Build for ARM64 only echo --all Build for both x64 and ARM64 -echo --with-microvm Download and include NanVix micro-VM binaries +echo --with-microvm Add the incomplete MicroVM (NVX) foundation and platform artifacts (x64) +echo Runtime preflight remains unavailable until NVX publishes +echo the workload image bundle 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 4b75b4893..1a204b56c 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 @@ -29,10 +28,6 @@ while [[ $# -gt 0 ]]; do WITH_HYPERLIGHT=true shift ;; - --with-microvm) - WITH_MICROVM=true - shift - ;; --help|-h) echo "Usage: build.sh [OPTIONS]" echo "" @@ -40,7 +35,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 ;; @@ -77,9 +71,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/architecture.md b/docs/architecture.md index f414879c1..5768b1a5b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,8 +40,8 @@ The workspace members and shared Rust dependencies are declared in `wxc_common` provides the backend-neutral foundation. Backend crates generally depend on it, while `mxc_engine` depends on the platform backends and owns -dispatch. The existing optional `wxc_common` dependency on `nanvix_common` -provides shared MicroVM data and constants. +dispatch. Backend-specific build helpers such as `nvx_build_common` own +artifact staging and acquisition without creating runtime cross-dependencies. ## Backend layout @@ -55,7 +55,7 @@ logic. Backends with additional processes use several crates: | Windows Sandbox | `common/`, `lifecycle/`, `daemon/`, and `guest/` | | WSLC | `common/` and `daemon/` | | IsolationSession | bindings and `common/` | -| NanVix | common data, build support, binaries, and runner crates | +| NVX | common data, build support, binaries, and runner crates | | Windows Learning Mode | Windows implementation over `learning_mode_core` | Shared parsing and normalization live in `wxc_common`; backend-specific policy diff --git a/docs/backend-support-probe-api-plan.md b/docs/backend-support-probe-api-plan.md index 3f023a85d..86c609dde 100644 --- a/docs/backend-support-probe-api-plan.md +++ b/docs/backend-support-probe-api-plan.md @@ -126,7 +126,7 @@ for them **cannot be built just yet**. | --- | --- | --- | --- | | `windows_sandbox` | DISM/registry check of the *Containers-DisposableClientVM* optional feature | only a private "is the `.exe` on disk" check | reports available when the feature is off → launch fails | | `isolation_session` | activation of the in-proc `Windows.AI.IsolationSession.Preview` `IsoSessionOps` runtime class succeeds (the API class is registered on the OS **and** its OS feature gate is on) **and** the backend feature is compiled | as of #761, detection queries whether the API class is registered rather than gating on a build number; a `CLASS_E_CLASSNOTAVAILABLE` / `REGDB_E_CLASSNOTREG` activation failure means unavailable | none for false-availability now — a machine without the API registered fails activation cleanly; still needs a cheap probe seam so callers don't have to attempt a real activation | -| `microvm` | feature compiled, NanVix runtime files staged, and WHP usable on Windows or `/dev/kvm` readable/writable on Linux | nothing | checking only a hypervisor can report availability when required runtime files are missing | +| `microvm` | feature compiled, pinned NVX runtime files staged, and WHP usable on Windows x64 | nothing | checking only a hypervisor can report availability when required runtime files are missing | | `hyperlight` | hypervisor present + feature compiled | nothing | same VM-boot risk | ### 4.2 The parity rule: detect once, project into TS diff --git a/docs/ci-validation-infrastructure.md b/docs/ci-validation-infrastructure.md index 1a4560008..3b1a75129 100644 --- a/docs/ci-validation-infrastructure.md +++ b/docs/ci-validation-infrastructure.md @@ -106,14 +106,14 @@ Current platforms: | Platform id | Family | x64 pool | arm64 pool | Declared backends (x64) | |-------------|--------|----------|------------|--------------------------| -| `windows-prerelease-process-container` | windows | `1es-mxc-windows-prerelease-t1-x64` | *(dormant)* | process-t1, process-t3, isolation-session, wslc, windows-sandbox, microvm, hyperlight | +| `windows-prerelease-process-container` | windows | `1es-mxc-windows-prerelease-t1-x64` | *(dormant)* | process-t1, process-t3, isolation-session, wslc, windows-sandbox, hyperlight | | `windows-prerelease-isolation-session` | windows | *(dormant)* | *(dormant)* | same as above | -| `windows-canary` | windows | *(dormant)* | *(dormant)* | process-t1, process-t3, wslc, windows-sandbox, microvm, hyperlight | +| `windows-canary` | windows | *(dormant)* | *(dormant)* | process-t1, process-t3, wslc, windows-sandbox, hyperlight | | `windows-25h2` | windows | `1es-mxc-e2e-windows-25h2-pro-x64` | *(dormant)* | same as above | | `windows-24h2` | windows | `1es-mxc-e2e-windows-24h2-pro-x64` | *(dormant)* | same as above | -| `windows-23h2` | windows | `1es-mxc-e2e-windows-23h2-enterprise-x64` | *(dormant)* | process-t3, wslc, windows-sandbox, microvm, hyperlight | +| `windows-23h2` | windows | `1es-mxc-e2e-windows-23h2-enterprise-x64` | *(dormant)* | process-t3, wslc, windows-sandbox, hyperlight | | `ubuntu-26.04` | linux | `1es-mxc-e2e-ubuntu-26.04-x64` | *(dormant)* | bubblewrap, hyperlight, lxc | -| `ubuntu-24.04` | linux | `1es-mxc-e2e-ubuntu-24.04-x64` | *(dormant)* | bubblewrap, microvm, hyperlight, lxc | +| `ubuntu-24.04` | linux | `1es-mxc-e2e-ubuntu-24.04-x64` | *(dormant)* | bubblewrap, hyperlight, lxc | | `rhel-10` | linux | `1es-mxc-e2e-rhel-10-x64` | *(dormant)* | bubblewrap, hyperlight, lxc | | `debian-13` | linux | `1es-mxc-e2e-debian-13-x64` | *(dormant)* | bubblewrap, hyperlight, lxc | | `macos-26` | macos | — | runner `macos-26` | seatbelt | @@ -229,7 +229,6 @@ 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 suite plus state aware tests (provision/start/exec/stop/deprovision lifecycle). | | 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. | | Hyperlight | ⛔ Not implemented | No suite on any platform. | | Seatbelt | ✅ Good | Failures are genuine MXC bugs. | @@ -245,9 +244,6 @@ every entry. `prepare-null-device --no-sacl`. T1 needs them too: the suite deliberately drives the AppContainer fallback tiers, and an unprepared host fails those launches with `WIN32_ERROR(5)` instead of reporting a policy result. -- `microvm` — asserts the NanVix payload is in the artifact, adds a Defender - exclusion for the binary directory, and requires the Windows Hypervisor - Platform feature *and* a running hypervisor. - `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 @@ -279,7 +275,6 @@ a process-container job selects follows from that build. for `lxcbr0`, enables bridge netfilter, and makes sure the bridge's NAT rule is in place. On RHEL-likes it needs EPEL first, because Red Hat dropped LXC after RHEL 7 and ships no replacement. -- `microvm` — asserts the NanVix payload exists. - `hyperlight` — no-op. Every install above goes through two shared helpers rather than its own @@ -497,7 +492,7 @@ cron *and* a job condition *and* a dispatch choice. Set the ARM64 `pool` for the platform *and* remove or narrow `suppressNonMacArm64` in the resolver. Note that the resolver rejects -`hyperlight` and `microvm` on ARM64 outright (x64-only runtimes), and the WSLC +`hyperlight` on ARM64 outright (x64-only runtime), and the WSLC dispatcher still refuses non-x64. ## Testing Your Changes to the Validation Infrastructure diff --git a/docs/isolation-session/oneshot.md b/docs/isolation-session/oneshot.md index ff5945c5e..d9944b2c0 100644 --- a/docs/isolation-session/oneshot.md +++ b/docs/isolation-session/oneshot.md @@ -312,7 +312,7 @@ A test runner at `tests/scripts/run_isolation_session_tests.ps1` invokes both configs via `wxc-exec.exe`, validates exit codes and expected output substrings, and reports a pass/fail summary. Pattern follows the existing per-backend integration scripts (e.g. -`run_microvm_tests.ps1`, `run_wslc_all_tests.ps1`). +`run_wslc_all_tests.ps1`). The script must run **interactively** on the test host. The OS-side service's calling-process identity check rejects network-logon tokens, so diff --git a/docs/linux-wsl-roadmap-june-2026.md b/docs/linux-wsl-roadmap-june-2026.md index 9b19ece87..1b723cce1 100644 --- a/docs/linux-wsl-roadmap-june-2026.md +++ b/docs/linux-wsl-roadmap-june-2026.md @@ -501,7 +501,7 @@ These items depend on the WSLC SDK team and are not unilaterally schedulable. > **Why network enforcement must be container-scoped (host vs. VM vs. container).** Network policy can be enforced at three layers: the Windows **host** (Windows Firewall), the WSL2 **VM**, or the **container** network namespace inside the VM. GA decision **D6 (per-sandbox scoping)** requires every sandbox's policy to be independent — concurrent WSLC containers must not affect each other's access — and names the container network namespace as WSLC's scoping identity. A machine-wide **host** firewall can't attribute traffic to one container vs. another, so it violates D6 (and per **D8**, host firewalls apply *on top of* enforcement, never *as* it). A **VM-wide** rule fails the same way when one utility VM hosts multiple containers — sandbox A's rules would bleed into sandbox B. Only the **container namespace** is inherently per-sandbox, which is why it's the required enforcement point. The catch: MXC can't install rules into that namespace today (`Privileged` doesn't grant `CAP_NET_ADMIN`, and the VM may lack iptables tooling). Hence SDK dep #1 — a VM-level API that applies rules **scoped to a specific container's namespace**: physically enforced at the VM boundary, logically attributed to one container. > -> **Contrast with Hyperlight/Nanvix, and the state-aware wrinkle.** Hyperlight (host-proxied sockets, per-instance) and Nanvix (per-guest egress filter) get D6 scoping for free because each sandbox *is* its own VM instance/process — no shared surface to bleed across. WSLC today is also effectively 1 sandbox : 1 VM (the one-shot flow creates a session, one container, then tears it down), but the highest-value WSLC optimization — **state-aware session reuse** (Misc #29), keeping a warm VM to amortize startup cost — makes one VM host **multiple** containers, at which point a host- or VM-wide rule genuinely bleeds across co-resident sandboxes. That is exactly when namespace-scoped enforcement (SDK dep #1) stops being merely cleaner and becomes mandatory. +> **Contrast with Hyperlight/NVX, and the state-aware wrinkle.** Hyperlight (host-proxied sockets, per-instance) and NVX (per-guest egress filter) get D6 scoping for free because each sandbox *is* its own VM instance/process — no shared surface to bleed across. WSLC today is also effectively 1 sandbox : 1 VM (the one-shot flow creates a session, one container, then tears it down), but the highest-value WSLC optimization — **state-aware session reuse** (Misc #29), keeping a warm VM to amortize startup cost — makes one VM host **multiple** containers, at which point a host- or VM-wide rule genuinely bleeds across co-resident sandboxes. That is exactly when namespace-scoped enforcement (SDK dep #1) stops being merely cleaner and becomes mandatory. --- diff --git a/docs/nanvix-microvm/nanvix-integration-plan.md b/docs/nanvix-microvm/nanvix-integration-plan.md deleted file mode 100644 index 9a065f1c5..000000000 --- a/docs/nanvix-microvm/nanvix-integration-plan.md +++ /dev/null @@ -1,397 +0,0 @@ -# MXC NanVix Integration — Design Document - -## Problem - -MXC (Microsoft eXecution Container) runs untrusted code in sandboxed environments. Today it supports multiple backends: **AppContainer** (process-level isolation), **Windows Sandbox** (full VM), **LXC** and **WSLC** (Linux containers via WSL). - -There is a need for a **micro-VM backend** that can execute various forms of code generated by agents — including Python, JavaScript, C, C++, and Rust applications — with full hardware isolation. - -## Proposed Solution - -Add a **NanVix micro-VM backend** directly into the existing `wxc-exec.exe` binary. When the JSON config specifies `"containment": "microvm"`, the binary routes to a new `NanVixScriptRunner` (NanVix is the implementation behind the `microvm` containment). The runner stages the user script into a temporary host directory, spawns `nanvixd.exe` with that directory bind-mounted into the guest, and relays stdout/stderr directly to the parent process. The guest's stdin is closed (`Stdio::null()`) — the script is loaded from the mounted staging directory, not piped over stdin. - -**NanVix** is a lightweight microkernel OS that runs inside a WHP (Windows Hypervisor Platform) virtual machine. It provides POSIX-compatible process execution with hardware-enforced isolation. NanVix supports multiple runtimes — the initial integration uses CPython 3.12 with a trimmed FAT32 stdlib filesystem, but the architecture supports JavaScript (QuickJS), C, C++, and Rust binaries as configurable runtimes. - -## How It Works - -``` -Path A — CLI (direct): - User: wxc-exec.exe config.json - └── Parses args → loads JSON → dispatches to NanVixScriptRunner - -Path B — SDK (programmatic): - App calls: spawnSandbox("print('hello')", policy, { containment: "microvm" }) - ├── Builds JSON config with containment = "microvm" - └── Spawns wxc-exec.exe with the config - -Both paths converge here: - wxc-exec.exe - ├── Parses JSON config → sees containment = "microvm" - ├── Creates NanVixScriptRunner (via existing Box dispatch) - ├── Validates paths next to wxc-exec.exe: - │ nanvixd.exe, bin/kernel.elf, nanvix_rootfs.img, python3.initrd - ├── Builds a temp staging directory containing bootstrap.py - │ (user script wrapped in a small loader preamble) plus any - │ readwrite/readonly host paths resolved by the policy. - ├── Ensures a WHP warm-start snapshot exists under /snapshots/ - │ (cold-boots once to generate kernel.vmem + kernel.whp.cbor on - │ first run; subsequent runs restore from the snapshot) - ├── Spawns nanvixd.exe as a child process (cwd = snapshot home): - │ nanvixd.exe -snapshot snapshots/kernel.whp.cbor - │ -bin-dir /bin - │ -ramfs /nanvix_rootfs.img - │ -mount - │ -- python3.initrd - ├── stdin is set to Stdio::null() (guest never reads host stdin); - │ stdout/stderr are streamed live to the parent via relay threads. - ├── Starts watchdog thread - ├── Waits for process exit or timeout - ├── Signals watchdog to cancel, joins all threads - └── Returns exit code (stdout/stderr relayed directly to parent) - -Inside the NanVix VM: - nanvixd.exe restores (or cold-boots) a WHP virtual machine: - ├── Loads kernel.elf (NanVix microkernel) from -bin-dir - ├── Loads python3.initrd as initrd payload - ├── Maps nanvix_rootfs.img (FAT32 stdlib) into guest memory - ├── Bind-mounts the host staging directory into the guest - │ (exposes bootstrap.py and any policy-mapped host paths) - ├── Kernel splits cmdline on ';': - │ argv = ["python3.initrd", "/mnt/bootstrap.py"] - │ env = ["PYTHONHOME=/sysroot"] - ├── Python executes bootstrap.py from the mounted staging dir - ├── Script output → stdout (via IKC) → host stdout - ├── Kernel traces → host stderr - └── sys.exit(N) → nanvixd exits N (exit code propagated) -``` - -## Architecture: - -``` - wxc-exec.exe - │ - config_parser.rs - reads "containment" field - │ - ┌──────────────┼──────────────┐ - │ │ │ - AppContainerScript Sandbox NanVix - Runner (existing) ScriptRunner ScriptRunner (new) - │ (existing) │ - AppContainer │ Spawn nanvixd.exe - NTFS ACLs Windows ├── Mount staging dir - WFP firewall Sandbox VM └── Relay stdout/stderr -``` - -## Key Design Decisions - -1. **Staging-directory mount for script delivery** — NanVix's cmdline has a 255-byte limit and splits on spaces, so the script cannot be passed as an argument. Instead, the runner writes a `bootstrap.py` (user script + small loader preamble) into a per-invocation temp directory and bind-mounts that directory into the guest via `nanvixd -mount `. Python then executes `bootstrap.py` from the mount. Host stdin is closed (`Stdio::null()`) — no stdin relay is involved. Zero changes to CPython or NanVix. - -2. **Raw Python source in `process.commandLine` field** — For AppContainer/Sandbox, `process.commandLine` is a shell command. For the microvm backend, it's raw Python source code. The runner handles interpreter invocation internally. This avoids users needing to understand NanVix's cmdline constraints. - -3. **Pre-built artifacts, not built from source** — NanVix binaries (`nanvixd.exe`, `kernel.elf`, `python3.initrd`, `nanvix_rootfs.img`) are downloaded from GitHub pre-releases. MXC does not compile or build NanVix components. - - -4. **Unsupported policies are rejected** — If a config specifies `network` or `processContainer` policies with `containment: "microvm"`, the runner returns a clear error. (Filesystem readwrite/readonly paths are honored via the staging-dir mount.) - -5. **Host-side I/O risk mitigated by IKC framing** — `nanvixd.exe` parses guest I/O via IKC (Inter-Kernel Communication) messages using fixed-size frames with bounds checking. A crafted guest could attempt malformed messages. Mitigation: formal fuzzing (future work). - -6. **Artifact integrity via hash verification** — Guest artifacts (`python3.initrd`, `kernel.elf`, `nanvix_rootfs.img`) are loaded from disk. If an attacker can modify these files, they control the guest. Mitigation: hash verification at install time via setup scripts (future work). - -7. **Timeout = boot + script** — Total timeout is `boot_timeout_ms` (default 60000ms, grace for VM boot + Python init) + `script_timeout` (from JSON `timeout` field). A background watchdog thread terminates the process if the total expires. - -8. **Cleanup guarantee** — On normal exit, timeout, or crash: relay threads joined (stdout/stderr fully drained), watchdog cancelled and joined, `nanvixd.exe` termination releases the WHP partition. No orphaned VMs. - -## Workspace Changes - -``` -mxc/src/ -├── Cargo.toml # Add NanVix to workspace (no new deps) -├── wxc/ -│ ├── Cargo.toml # UNCHANGED -│ └── src/main.rs # Add NanVix match arm (2 lines) -├── wxc_common/ -│ ├── Cargo.toml # UNCHANGED -│ └── src/ -│ ├── lib.rs # Add: pub mod nanvix_runner (1 line) -│ ├── models.rs # Add: NanVixConfig struct, ContainmentBackend::MicroVm -│ ├── wire.rs # Add: MicroVm containment variant (schema source); regenerate schema -│ ├── config_parser.rs # Add: map_wire_containment "microvm" arm -│ ├── error.rs # Add: WxcError::NanVix variant -│ ├── nanvix_runner.rs # NEW — NanVixScriptRunner implementation -│ ├── appcontainer.rs # UNCHANGED -│ ├── windows_sandbox_runner.rs # UNCHANGED -│ ├── script_runner.rs # UNCHANGED -│ └── ... # All other modules UNCHANGED -├── wxc_test_driver/ # UNCHANGED -├── wxc_windows_sandbox_guest/ # UNCHANGED -└── wxc_windows_sandbox_daemon/ # UNCHANGED - -mxc/docs/nanvix-microvm/ -└── nanvix-integration-plan.md # NEW — this document - -mxc/tests/configs/ -└── microvm_hello.json # NEW — example microvm config -``` - -## Error Handling & Output Semantics - -### I/O Model - -**stdout/stderr** are relayed live between nanvixd and the parent via pipe relay threads — the runner does not capture or buffer either stream. **stdin is not relayed**: the runner spawns nanvixd with `stdin = Stdio::null()`, so the guest sees no host input. - -``` -wxc-exec stdin ──╳ (closed; not relayed into the guest) -wxc-exec stdout ◀── nanvixd stdout ◀── guest python stdout -wxc-exec stderr ◀── nanvixd stderr ◀── kernel traces -``` - -**Script delivery via mount, not stdin**: The runner writes `bootstrap.py` (the user script wrapped in a loader preamble) into a per-invocation temp staging directory and passes that directory to `nanvixd -mount `. Inside the guest, Python executes `bootstrap.py` from the mounted directory. The SDK does not write the script to wxc-exec's stdin. - -### Exit Code Propagation - -`sys.exit(42)` inside the guest → `nanvixd.exe` exits with code 42. The runner returns this directly as the process exit code. - -### Error Classification - -The runner classifies errors using a `NanVixError` enum, allowing consumers to match on error types: - -```rust -enum NanVixError { - Preflight(String), // Missing binaries, invalid paths - Platform(String), // WHP unavailable, spawn failure - Runtime(String), // Stdin broken pipe, VM crash - Timeout { // Watchdog killed the process - boot_timeout_ms: u32, - script_timeout_ms: u32, - total_ms: u64, - }, -} -``` - -| Variant | Trigger | Example | -|---------|---------|---------| -| `Preflight` | Path validation before spawn | `nanvixd not found at /path/to/nanvixd.exe` | -| `Platform` | `Command::new()` fails | `Failed to spawn nanvixd: The system cannot find the file specified` | -| `Runtime` | Stdin write or process error | `Failed to write script to nanvixd stdin: Broken pipe` | -| `Timeout` | Watchdog fires | `NanVix execution timed out after 90000ms` | - -All variants are surfaced via stderr output. Preflight and Platform errors prevent the process from spawning (exit code -1). Runtime errors surface as non-zero exit codes from nanvixd. On success, the nanvixd exit code (which reflects the guest Python exit code) is returned directly. - -## Configuration Semantics - -### JSON Config Format - -```json -{ - "process": { - "commandLine": "print('Hello from NanVix!')", - "timeout": 30000 - }, - "containment": "microvm" -} -``` - -### Config Field Mapping - -| JSON Field | Rust Field (`ExecutionRequest`) | NanVix Behavior | -|------------|---------------------------|----------------| -| `process.commandLine` | `script_code: String` | ✅ **Used** — raw Python source code (not a shell command) | -| `process.timeout` | `script_timeout: u32` | ✅ **Used** — script execution timeout in ms | -| `containment` | `containment: ContainmentBackend` | ✅ **Used** — must be `"microvm"` | -| `workingDirectory` | `working_directory: String` | ❌ **Rejected** — guest has its own filesystem namespace | -| `processContainer.*` | `policy: ContainerPolicy` | ❌ **Rejected** — not applicable to NanVix | -| `filesystem.readwritePaths` / `readonlyPaths` | (part of `policy`) | ✅ **Used** — host paths bind-mounted into the guest via the staging dir | -| `network.*` | (part of `policy`) | ❌ **Rejected** — no network stack in guest | -| `sandbox.*` | `sandbox_config: WindowsSandboxConfig` | ❌ **Rejected** — microvm is not Windows Sandbox | - -**Policy validation**: If a config specifies `containment: "microvm"` alongside `network`, `processContainer`, or `workingDirectory` fields, the runner returns an error. - -## Binary Distribution & Versioning - -NanVix artifacts are **NOT bundled** in the MXC npm package. They are distributed as **pre-release binaries** from their respective GitHub repositories: - -| Artifact | Size | Source | -|----------|------|--------| -| `nanvixd.exe` | 7.5 MB | [nanvix/nanvix](https://github.com/nanvix/nanvix) releases | -| `kernel.elf` | 10.5 MB | [nanvix/nanvix](https://github.com/nanvix/nanvix) releases | -| `python3.initrd` | 9.1 MB | [nanvix/cpython](https://github.com/nanvix/cpython) releases | -| `nanvix_rootfs.img` | 35.6 MB | [nanvix/cpython](https://github.com/nanvix/cpython) releases | - -Setup scripts (PowerShell & Bash) will download matching pre-release binaries and verify checksums. - -## Security Model - -### Isolation Comparison - -| Property | AppContainer | Windows Sandbox | NanVix | -|----------|-------------|-----------------|--------| -| **Isolation level** | Process | Full VM (Hyper-V) | Micro VM (WHP) | -| **Host FS access** | Restricted by ACLs | Mapped folders only | None (read-only ramfs) | -| **Network access** | Filtered by firewall | NAT/bridged | None | -| **Writable storage** | Host FS (restricted) | VM disk | None (read-only) | -| **Guest OS** | Windows (host) | Windows (guest) | NanVix microkernel | - -## Development Phases - -### Phase 1 — Backend Implementation - -**Goal:** Add NanVix as a functional containment backend in `wxc-exec.exe`. - -**What changed:** -- `models.rs` — Added `MicroVm` variant to `ContainmentBackend`, added `NanVixConfig` struct, added `nanvix_config` field to `ExecutionRequest` -- `config_parser.rs` — Added `"microvm"` containment parsing and NanVix config - section parsing (originally via `Raw*` structs; exact adapters now construct - `common_request_ir::CommonRequestIR`, and normalization maps its nested DTOs - in `normalize_common_request_ir`) -- `error.rs` — Added `WxcError::NanVix(String)` variant -- `nanvix_runner.rs` — **NEW** — `NanVixScriptRunner` implementing `ScriptRunner` trait -- `lib.rs` — Added `pub mod nanvix_runner` -- `main.rs` — Added `ContainmentBackend::MicroVm` dispatch arm - -**Verified:** Build succeeds, all existing tests pass, E2E test produces correct output. - -### Phase 2 — SDK Types & Platform Detection - -**Goal:** Make the TypeScript SDK aware of NanVix as a backend option. - -**What changes:** -- `sdk/node/src/types.ts` — Add `'microvm'` to `SandboxingMethod` type, add `NanVixConfig` interface -- `sdk/node/src/platform.ts` — Detect NanVix availability via `wxc-exec.exe --check-platform` -- `sdk/node/src/sandbox.ts` — Accept `containment: 'microvm'` in `SandboxSpawnOptions` -- `wxc/src/main.rs` — Add `--check-platform` subcommand returning JSON capabilities - -### Phase 3 — CLI Flags & Setup Scripts - -**Goal:** Let users invoke NanVix from the CLI and set up the runtime. - -**What changes:** -- `wxc/src/main.rs` — Add `--microvm` flag (sets `containment: "microvm"` automatically) -- `scripts/setup-nanvix.ps1` — PowerShell script to download NanVix pre-release binaries -- `scripts/setup-nanvix.sh` — Bash equivalent for WSL/Linux/CI - -### Phase 4 — Testing & Hardening - -**Goal:** Comprehensive test coverage for the NanVix backend. - -**What changes:** -- Integration tests exercising full MXC→nanvixd→Python→output pipeline -- Negative tests: missing modules, network I/O, file writes, timeout -- WHP-conditional test execution (skip on runners without WHP) -- Mock `nanvixd.exe` for fast unit testing of error/timeout paths - -## Supported Workloads - -### Supported - -| Workload | Example | Notes | -|----------|---------|-------| -| Pure computation | `sum(range(1000000))` | Full Python numeric stack | -| String processing | `re.findall(r'\d+', text)` | Regex, string ops, encodings | -| JSON/data manipulation | `json.loads(data)` | json, csv, collections | -| Math/statistics | `math.factorial(100)` | math, decimal, fractions | -| Date/time operations | `datetime.datetime.now()` | datetime, calendar | -| Hash computation | `hashlib.sha256(b'data')` | hashlib | -| Data structures | `dict`, `list`, `set`, `heapq` | All built-in data structures | -| Multi-line scripts | Functions, classes, loops | Full Python syntax | - -### Not Supported - -| Workload | Why | Error User Sees | -|----------|-----|----------------| -| Network I/O (`urllib`, `socket`, `http`) | No network stack in guest | `OSError: Function not implemented` | -| File writing (`open('f','w')`, `tempfile`) | Read-only FAT32 ramfs | `OSError: Read-only file system` | -| Subprocess (`subprocess.run`, `os.system`) | `fork()` is stubbed | `OSError: Function not implemented` | -| SSL/TLS (`import ssl`) | `_ssl` module not built | `ModuleNotFoundError: No module named '_ssl'` | -| ctypes (`import ctypes`) | `_ctypes` module not built | `ModuleNotFoundError: No module named '_ctypes'` | -| GUI (`tkinter`, `turtle`) | No display server | Module removed from ramfs | -| Interactive input (`input()`) | guest stdin is closed (`Stdio::null()`); no host stdin relay | `EOFError: EOF when reading a line` | -| Large memory (>128MB) | Default VM memory limit | Process killed by kernel OOM | - -## End-User Experience - -### CLI Usage - -```bash -# Run a Python script in a NanVix micro-VM -wxc-exec.exe microvm_config.json - -# With debug output -wxc-exec.exe --debug microvm_config.json -``` - -### Example Config (`microvm_config.json`) - -```json -{ - "process": { - "commandLine": "import sys\nprint(f'Python {sys.version} on {sys.platform}')", - "timeout": 30000 - }, - "containment": "microvm" -} -``` - -### Expected Output - -``` -$ wxc-exec.exe --debug microvm_config.json -Script code length: 58 -Working directory: -Script timeout: 30000 -Container name: CLI -NanVix: nanvixd="C:\\nanvix\\bin\\nanvixd.exe" -NanVix: bin_dir="C:\\nanvix\\bin" -NanVix: ramfs="C:\\nanvix\\bin\\nanvix_rootfs.img" -NanVix: python="C:\\nanvix\\bin\\python3.initrd" -NanVix: process exited with code 0 -Exit code: 0 -Python 3.12.3 (tags/0715636-nanvix-03bba66:0715636) on nanvix -``` - -### SDK Usage (After Phase 2) - -```typescript -import { spawnSandboxAsync } from '@microsoft/mxc-sdk'; - -const result = await spawnSandboxAsync( - "print('Hello from NanVix!')", - {}, // no policy needed — NanVix is isolated by design - { containment: 'microvm' } -); - -console.log(result.stdout); // "Hello from NanVix!" -console.log(result.exitCode); // 0 -``` - - -## Testing Strategy - -### Unit Tests - -| Test | What It Validates | -|------|------------------| -| `default_config_values` | NanVixConfig defaults (python3.initrd, /sysroot, 60s) | -| `total_timeout_adds_boot_and_script` | Timeout arithmetic | -| `resolve_nanvixd_missing_returns_error` | Path resolution error handling | -| Config parser: `"microvm"` containment | JSON parsing of microvm section | - -### Integration Tests (requires WHP + NanVix binaries) - -| Test | What It Validates | -|------|------------------| -| Hello world | Basic script → stdout pipeline (script delivered via mounted staging dir) | -| Script with spaces | Mount-based delivery bypasses the cmdline space-splitting limit | -| Exit code propagation | `sys.exit(42)` → exit code 42 | -| Missing nanvixd | Preflight error message | -| Timeout | Watchdog kills after deadline | -| Large script | >255 bytes (would exceed cmdline limit if it weren't mounted) | - -## Open Design Questions - -| # | Question | Status | -|---|----------|--------| -| 1 | Should NanVix VMs be pooled/warm-started? | | -| 2 | Should there be a size limit to the VM | | -| 3 | Should there be a writable FS area in the guest? | Parked for Phase 2 | -| 4 | Should we support TypeScript or other payloads? | Architecture supports it | diff --git a/docs/nanvix-microvm/nanvix.md b/docs/nanvix-microvm/nanvix.md deleted file mode 100644 index 708cae788..000000000 --- a/docs/nanvix-microvm/nanvix.md +++ /dev/null @@ -1,250 +0,0 @@ -# Nanvix MicroVM Backend - -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": "0.10.0-alpha", - "process": { - "commandLine": "print('Hello from MicroVM!')", - "timeout": 30000 - }, - "containment": "microvm" -} -``` - -```bash -wxc-exec.exe --experimental config.json -``` - -## SDK Usage - -Use `spawnSandboxFromConfig` with `usePty: false` for reliable exit codes and -separate stdout/stderr streams: - -```typescript -const child = spawnSandboxFromConfig(config, { - experimental: true, - usePty: false, -}); - -``` - -## Filesystem Policy - -### readwrite_paths - -Host directories or files listed in `readwritePaths` are copied into a private -per-run staging directory before boot. Nanvix mounts are snapshot-based — host -files are **not** modified while the guest is running. No junctions or live host -mounts are used. - -```json -{ - "version": "0.10.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 | v0.10 isolated or explicitly unrestricted host networking | - -## Networking - -Host networking is **opt-in**. Exact v0.10 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": "0.10.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 because the legacy IPv4 -filter does not implement their full semantics, including default-deny DNS. -Runtime proxy configuration is also unsupported. - -### Legacy per-host filter implementation (compatibility/reference only) - -The following describes the retained legacy runtime filter, not accepted v0.10 -JSON vocabulary. The exact cutover does not silently translate directional -rules into this weaker contract. - -`allowedHosts` and `blockedHosts` are supported and forwarded to the guest's -host-side socket proxy, which enforces egress at `connect()`. The guest filter -is **allow-XOR-block**, so the two lists are mutually exclusive (specifying both -is rejected at preflight). Presence of either list implies host networking, so -`defaultPolicy` is ignored when a list is set: - -| `allowedHosts` | `blockedHosts` | Effect | -| -------------- | -------------- | ------ | -| _(empty)_ | _(empty)_ | follows `defaultPolicy` (`block` = no egress, `allow` = unrestricted) | -| `[A, ...]` | _(empty)_ | **allowlist** — only the listed destinations are reachable | -| _(empty)_ | `[B, ...]` | **blocklist** — everything except the listed destinations is reachable | -| `[A, ...]` | `[B, ...]` | rejected at preflight (mutually exclusive) | - -Entries may be IPv4 literals (`93.184.216.34`), IPv4 CIDR blocks -(`10.0.0.0/8`), or hostnames. Hostnames are resolved to their IPv4 (A-record) -addresses at preflight; IPv6 (AAAA) results are dropped because the guest filter -is IPv4-only. Resolution failures are handled per direction so neither list ever -fails open: - -- **allowlist** (deny-by-default): each dropped entry is logged as a warning and - the run continues, since dropping an entry only *narrows* access. If the list - resolves to **no** IPv4 address at all, the run is rejected at preflight rather - than silently allowing all traffic. -- **blocklist** (allow-by-default): **any** entry that resolves to no IPv4 - address rejects the run at preflight. Silently dropping a blocked host would - let traffic the policy explicitly blocks flow freely, and the static preflight - filter cannot enforce a name that does not resolve — so the blocklist - fails closed. - -**DNS:** in allowlist mode the guest daemon automatically exempts the DNS port -(53), so name resolution works without adding the resolver to `allowedHosts`. - -Network proxies (`network.proxy`) are not supported and are rejected at -preflight. - -```jsonc -{ - "containment": "microvm", - "process": { "commandLine": "import urllib.request; ..." }, - // Historical legacy shape, not accepted by the exact v0.10 contract: - "network": { "defaultPolicy": "allow" } -} -``` - -```jsonc -{ - "containment": "microvm", - "process": { "commandLine": "import urllib.request; ..." }, - // Historical legacy shape, not accepted by the exact v0.10 contract: - "network": { "allowedHosts": ["example.com", "10.0.0.0/8"] } -} -``` - -## Not Supported - -| Workload | Error | -| ------------------------------- | ----------------------------------- | -| Both `allowedHosts` + `blockedHosts` | Rejected at preflight (mutually exclusive) | -| File writing outside `/mnt/rw/` | `OSError: Read-only file system` | diff --git a/docs/nvx-backend-gaps.md b/docs/nvx-backend-gaps.md new file mode 100644 index 000000000..7c0806058 --- /dev/null +++ b/docs/nvx-backend-gaps.md @@ -0,0 +1,257 @@ +# NVX Backend: MXC policy compatibility and remaining gaps + +## Status and scope + +The original NVX prototype accepted a real MXC `0.9.0-dev` JSON, validated it +against the schema, and adapted supported fields into typed NVX launch plans. +The prototype first exercised those plans on Windows through OpenVMM and WHP. + +MicroVM remains the sole public MXC backend identity in the published exact +`0.9.0-alpha` one-shot contract, selected with `containment: "microvm"`. NVX is +the concrete implementation behind that abstraction. A MicroVM-enabled build +returns a typed backend-unavailable error for execution, and capability probes +do not advertise MicroVM while the runtime is incomplete. + +## Current architecture + +The intended MXC integration uses a direct ownership boundary: + +```mermaid +flowchart LR + MXC[MXC] --> OpenVMM[OpenVMM / WHP] + OpenVMM --> Agent[NVX PID 1 guest agent] +``` + +MXC owns the OpenVMM process and the host side of the versioned guest-agent +control channel; no additional host service sits between them. The NVX policy +harness stands in for the future MXC adapter and proves this boundary end to +end. + +## Implemented and validated capabilities + +| MXC policy or control | Validated behavior | +| --- | --- | +| `filesystem.readonlyPaths` | Host paths were exposed to the guest as read-only mappings. Guest writes were denied. | +| `filesystem.readwritePaths` | Host paths were exposed as writable mappings and guest writes were reflected on the host. | +| `filesystem.deniedPaths` | Denied subtrees are hidden by the host filesystem provider, including direct, parent-relative, symlink/junction alias, and second-mount access. Unsafe policies are rejected before boot. | +| Mapping validation | Duplicate, overlapping, escaping, symlink, reparse-point, and containment-invalid mappings were rejected before launch. | +| Undeclared-path isolation | The guest could not access undeclared host paths or bypass policy through raw virtio-fs exports. | +| `process.commandLine` | The command was lowered exactly to `["/bin/sh", "-c", commandLine]`. | +| `process.cwd` | The requested guest working directory was preserved. | +| `process.env` | Non-reserved environment variables were preserved exactly. | +| `process.timeout` | The timeout flowed from real MXC JSON into the OpenVMM process execution plan. | +| Exec-time `runtimeConfig.networkProxy` | The sole supported proxy form was normalized and injected as controlled proxy environment variables during exec. | +| `network.egress.default` | Both `allow` and `deny` are enforced by the portable network profile. | +| `network.ingress.default: deny` | New inbound connections are denied while replies to guest-initiated traffic remain available. Unsupported unrestricted ingress is rejected before launch. | +| `network.egress.allow` and `network.egress.deny` | IPv4/CIDR TCP and UDP destination rules are enforced, with deny precedence and default-deny behavior. | +| `network.ingress.hostLoopback: deny` | General guest-to-host loopback and host-to-guest forwards are denied while the exact TCP runtime-proxy endpoint remains reachable. UDP on the proxy port remains denied. | +| Explicit host-to-guest forwarding | Selected TCP or UDP localhost ports can be deliberately published with NVX-specific forward options. | +| Fixed workload identity | Managed workloads run as the selected fixed non-root UID/GID with capabilities removed and `no_new_privs`. | +| State-aware lifecycle | Provision, start, repeated exec, stop, and deprovision preserve warm guest state and reject invalid transitions. | +| Bounded outcomes | Execution and VM-level reports expose bounded result categories, numeric status, operation IDs, and teardown outcomes without including command arguments, environment values, or credentials. | +| Version, containment, phase, and IDs | Exact schema version, temporary `containment: "vm"`, lifecycle phase, `sandboxId`, and `containerId` constraints were enforced. | + +## Remaining NVX policy gaps + +| MXC policy or control | Current NVX behavior | Remaining decision or implementation | +| --- | --- | --- | +| `network.ingress.hostLoopback: allow` | The portable socket-NAT profile requires explicit per-port forwarding. Generic `allow` without forwards is rejected before VM resources are opened. | Implement true bidirectional host-loopback connectivity without an NVX-specific port list, or retain the fail-closed rejection as a documented backend limitation. | +| Full schema 0.9 egress-rule vocabulary | NVX supports IPv4/CIDR rules with one TCP or UDP destination port. | Decide whether to implement IPv6, CIDR `except`, port ranges, ICMP, protocol `any`, and omitted destination/port selectors. Every unsupported form must be rejected before launch. | +| `network.ingress.default: allow` | NVX implements fixed ingress denial and rejects unrestricted ingress. | Implement unrestricted inbound support only if it is required for the NVX backend; otherwise preserve and document the rejection. | +| Caller-provided proxy environment variables | NVX rejects `HTTP_PROXY`, `HTTPS_PROXY`, lowercase variants, `NO_PROXY`, and mixed-case equivalents so `runtimeConfig.networkProxy` remains authoritative. | Decide whether this remains a permanent proxy-hygiene invariant or whether caller-provided values should be supported with explicit precedence and enforcement semantics. | +| Detailed telemetry | NVX returns bounded execution and teardown outcomes. | Decide whether MXC needs additional structured lifecycle, startup, performance, and failure facts from NVX. MXC remains responsible for consent, administrative policy, per-request gating, and event emission. | + +## Intentionally unsupported policy + +These fields are not missing NVX mechanisms. The MXC adapter should reject +them for this backend: + +| Policy | Required behavior | +| --- | --- | +| `lifecycle` on the one-shot surface | One-shot execution destroys the VM. Persistence and reuse belong to the explicit state-aware lifecycle. Requests for retained one-shot state must be rejected. | +| Policy mutation in the wrong phase | Provision-only and exec-only fields are rejected outside their valid lifecycle phase. | +| Wrong version, non-VM containment, or invalid lifecycle IDs | Invalid control-plane inputs are rejected before effects. | +| `ui` | A Linux microVM has no equivalent MXC desktop-policy surface. | +| `fallback` | NVX does not silently select a weaker containment backend. | + +## Accepted inert metadata + +| Field | Behavior | +| --- | --- | +| `$schema` | Accepted for schema tooling; it does not change the launch plan. | +| `_comment` | Accepted as descriptive metadata; it does not change runtime behavior. | +| Omitted optional parent sections | Accepted; their absence does not create unsupported policy or change the supported defaults. | + +## Schema changes + +The published exact `0.9.0-alpha` one-shot contract includes +`containment: "microvm"` in its generated schema and TypeScript wire types. +The broader `0.10.0-alpha` development contract accepts the same public value +for forward compatibility. The internal `nvx` implementation name is not +accepted as a public containment value. An `experimental.nvx.provision` +section is necessary only if images remain caller-configurable. + +Future state-aware provision design: + +> **Not runnable in Phase 1.** The exact development state-aware registry does +> not yet include NVX, so the current parser rejects this request before +> dispatch. This example records the intended Phase 2 request shape only. + +```json +{ + "$schema": "https://aka.ms/mxc/schemas/0.10.0-alpha.json", + "version": "0.10.0-alpha", + "phase": "provision", + "containment": "microvm", + "filesystem": { + "readonlyPaths": [ + "C:\\workspace\\source" + ], + "readwritePaths": [ + "C:\\workspace\\output" + ], + "deniedPaths": [] + }, + "network": { + "egress": { + "default": "deny" + }, + "ingress": { + "default": "deny", + "hostLoopback": "deny" + } + }, + "experimental": { + "nvx": { + "provision": { + "layers": [ + { + "role": "distro", + "path": "C:\\nvx\\images\\distro.erofs", + "uuid": "11111111-1111-1111-1111-111111111111" + }, + { + "role": "runtime", + "path": "C:\\nvx\\images\\runtime.erofs", + "uuid": "22222222-2222-2222-2222-222222222222" + } + ], + "scratchPath": "C:\\nvx\\images\\scratch.ext4" + } + } + } +} +``` + +- `distro` is the read-only base operating-system and userspace layer. +- `runtime` is an optional read-only layer containing the workload runtime and + supporting files. +- `scratchPath` is the writable ext4 image used for changes made while the + sandbox is running. + +## Binary acquisition and packaging + +`build.bat --with-microvm` enables the incomplete x64 Windows/WHP foundation. It is +the only active micro-VM packaging path. + +The build pins the exact `microsoft/nvx` release tag, platform asset name, and +per-file SHA-256 checksums in the repository. It downloads and verifies the +platform archive during the build, reuses the verified Cargo-output cache, and +stages the files beside the MXC executor. Sandbox execution never downloads +artifacts. `NVX_BIN` is an offline override for a pre-fetched bundle directory; +its files must pass the same checksum validation. + +The current published pin is `v0.1.0-dev.5c86da3dff02`. Its Windows/WHP asset +contains only the platform files (`openvmm.exe`, `vmlinux`, and +`initramfs.cpio.gz`); it does not contain the distro/runtime EROFS images or +writable scratch image required to run a workload. + +## Remaining MXC integration work + +The schema/wire, policy/model, typed-unavailable dispatch, and pinned Windows +artifact-acquisition foundations preserve MicroVM as the public abstraction +while replacing the removed NanVix implementation with NVX. +Phase 2 runtime remains blocked on all of the following: + +- NVX-produced distro and runtime EROFS images plus a writable scratch image; +- a proven combined managed-sandbox/virtio-fs contract; and +- the required WHP runner. + +After those inputs are available, MXC still needs the state-aware and one-shot +OpenVMM runtime, guest streams and lifecycle outcomes, applicable SDK/FFI +surfaces, capability advertisement, and MXC-native E2E/CI coverage. + + +### E2E testing plan + +- Verify one-shot launch, command execution, output, exit status, and teardown. +- Verify provision, start, repeated exec, stop, and deprovision, including + invalid lifecycle IDs and phase transitions. +- Run positive and negative checks for every supported filesystem, network, and + process policy. +- Prove every unsupported policy is rejected before VM or host effects. +- Exercise timeout, cancellation, guest/OpenVMM failure, reconnect, stream, and + cleanup behavior. +- Run applicable Rust, TypeScript, C#, and FFI paths on Windows x64 and ARM64 CI + hosts. + +## Appendix A: Current OpenVMM and NVX baseline + +This is the current direct OpenVMM/NVX capability baseline after the merged +policy work. An MXC backend adapter still needs to select these controls from +the exact `0.10.0-alpha` request and expose their outcomes through MXC APIs. + +| Area | Supported today | Limitation for MXC | +| --- | --- | --- | +| Filesystem | Virtio-fs mappings support `ro`/`rw`, safe common-root planning, undeclared-path isolation, and explicit denied subtrees. | Policies that cannot be represented safely are rejected before boot. | +| Sandbox filesystem | The sandbox workload uses read-only EROFS layers, a writable ext4 scratch layer, private mount/PID/UTS namespaces, a private `/dev`, a fixed unprivileged identity, no Linux capabilities, and `no_new_privs`. | The MXC adapter must package and select the appropriate image and working layers. | +| Lifecycle | The host and guest protocols support provision, start, repeated exec, stop, deprovision, reconnect, operation cancellation, and bounded outcomes. | MXC still needs to register and dispatch NVX on its one-shot and state-aware surfaces. | +| Networking | The portable network profile works across WHP, KVM, and MSHV and enforces egress defaults, IPv4/CIDR TCP/UDP rules, deny ingress, host-loopback denial, exact proxy reachability, and explicit host-to-guest forwards. | Generic host-loopback allow, unrestricted ingress, and the broader schema 0.9 rule vocabulary remain unsupported. | +| HTTP/HTTPS proxy | An exec-time `runtimeConfig.networkProxy` endpoint is injected as controlled proxy environment variables and allowed as one exact TCP endpoint under host-loopback denial. | Caller-provided proxy variables remain rejected; UDP to the same endpoint is not allowed. | +| Outcomes and telemetry | Execution, lifecycle, and teardown return bounded status and failure categories without workload secrets. | Richer MXC telemetry facts are optional future work; MXC owns telemetry policy and emission. | + +See the NVX [run guide](https://github.com/microsoft/nvx/blob/dev/doc/run.md) +and [command-line reference](https://github.com/microsoft/nvx/blob/dev/doc/usage.md) +for the current direct runtime options. + +## Appendix B: NVX issue tracking + +The historical issues captured the original prototype gaps. Repository +migration means some issue links may no longer resolve; the merged PRs above +are the durable implementation references. + +| Historical issue | Current outcome | +| --- | --- | +| #37 - read-only/read-write mappings and mount isolation | Closed after prototype validation. | +| #38 - directional ingress and egress defaults | Implemented by microsoft/nvx#51 and nanvix/openvmm#71. NVX supports both egress defaults and a deny ingress posture; unrestricted ingress remains rejected. | +| #39 - fixed unprivileged workload identity | Implemented by microsoft/nvx#58 and nanvix/openvmm#75. | +| #40 - sandbox lifecycle | Implemented by microsoft/nvx#58 and nanvix/openvmm#75. | +| #41 - L3/L4 egress-rule filtering | Implemented for IPv4/CIDR TCP and UDP by microsoft/nvx#58 and nanvix/openvmm#75. The broader schema 0.9 vocabulary remains a compatibility decision. | +| #42 - host-loopback network policy | Partially resolved by microsoft/nvx#61 and nanvix/openvmm#76. Deny is enforced, the exact TCP runtime proxy is preserved, UDP leakage is closed, and explicit forwards remain available. Generic `hostLoopback: allow` without forwards remains unsupported. | +| #43 - denied filesystem paths | Implemented by microsoft/nvx#58 and nanvix/openvmm#75. | +| #44 - exec-time proxy configuration | Closed after prototype validation; subsequent work preserved an exact TCP-only proxy exception under host-loopback denial. | +| #45 - fail-closed backend selection and policy admission | Implemented across the policy and lifecycle changes; unsupported policy is rejected rather than weakened. | +| #46 - typed execution/lifecycle outcomes | Implemented as bounded execution, operation, VM-exit, and teardown reports. | +| #47 - detailed telemetry | Bounded outcomes are implemented. Richer telemetry facts remain optional follow-up work rather than a schema 0.9 blocker. | + +## Appendix C: GitHub Copilot CLI schema migration + +The current GitHub Copilot CLI builds MXC policy version `0.7.0-alpha` and +emits the legacy `allowOutbound`, `allowLocalNetwork`, and `network.proxy` +fields. It no longer provides a raw `sandbox.config` passthrough. + +Before the CLI can use the MicroVM (NVX) backend, it must migrate its generated +policy to the exact published `0.9.0-alpha` contract: + +- map outbound allow/block to `network.egress.default`; +- map local-network intent to `network.ingress.default` and + `network.ingress.hostLoopback`; +- express direct egress exceptions through `network.egress.allow` and + `network.egress.deny`; +- move the supported loopback HTTP/HTTPS proxy endpoint to + `runtimeConfig.networkProxy`; +- update its sandbox settings, policy checks, telemetry, UI, and tests to use + the new directional model. + +This migration belongs to the GitHub Copilot CLI and is a prerequisite for NVX +integration; NVX does not need to implement the legacy network contract. diff --git a/docs/sandbox-policy/0.8.0/networking/networking.md b/docs/sandbox-policy/0.8.0/networking/networking.md index 0d606cd19..c0ae6d35e 100644 --- a/docs/sandbox-policy/0.8.0/networking/networking.md +++ b/docs/sandbox-policy/0.8.0/networking/networking.md @@ -557,7 +557,7 @@ limit it hit; neither is silently truncated, and no partial policy is installed. reserved for the backend migration work; until that lands, callers must continue using the legacy unrestricted acknowledgment. Other network/proxy policy is rejected. In GA for process isolation only (identity, lifecycle). -- **Hyperlight, Nanvix:** Not in this GA scope doc. Additional follow up is needed to confirm their capabilities and whether they align with this doc. +- **Hyperlight, NVX:** Not in this GA scope doc. Additional follow up is needed to confirm their capabilities and whether they align with this doc. ## Gaps and limitations diff --git a/docs/schema-codegen.md b/docs/schema-codegen.md index 036feafce..a9b94a125 100644 --- a/docs/schema-codegen.md +++ b/docs/schema-codegen.md @@ -7,12 +7,15 @@ MXC generates artifacts from exact registered configuration contracts: | Exact `0.9.0-alpha` | `src/core/mxc_config_contract/src/published/v0_9_0_alpha/` | Authoritative closed published contract and versioned TypeScript oracle | | Exact `0.10.0-alpha` | `src/core/mxc_config_contract/src/dev/` | Authoritative closed development contract and versioned TypeScript oracle | -Published schemas under `schemas/stable/` are immutable release artifacts. +Published schemas under `schemas/stable/` are revision-locked release artifacts. `mxc_schema_gen` renders published v0.9 into temporary output so `check-contract-codegen.js` can compare the enforcing Rust model with the committed stable schema and TypeScript oracle. The gate also compares pre-existing stable schemas with the merge base and validates their registry -identities. +identities. A deliberate published-contract amendment must increment that +version's `stableRevisions` entry in `schemas/schema-version.json` exactly once; +an artifact change without the bump, or a bump without an artifact change, +fails validation. ## Sources of truth @@ -23,9 +26,9 @@ and `string_marker!` macros implement `JsonSchema` so deserialization and generated constants cannot drift. `src/core/mxc_config_contract/src/published/v0_9_0_alpha/` defines the exact -published v0.9 contract, including IsolationSession and WSLC one-shot and -state-aware roots. It remains renderable for verification; generation does -not make the stable artifact mutable. +published v0.9 contract, including MicroVM one-shot and IsolationSession and +WSLC one-shot and state-aware roots. It remains renderable for verification; +generation does not by itself authorize a published-contract amendment. `mxc_schema_support` owns shared integer normalization, deterministic root rendering, and TypeScript emission. `mxc_schema_gen` uses those helpers for diff --git a/docs/schema.md b/docs/schema.md index 81e2b434f..aa1eb5b68 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -279,7 +279,8 @@ use: | Windows ProcessContainer (AppContainer / BaseContainer) | First `readwritePaths` entry that is an existing directory, else the first such `readonlyPaths` entry, else the system drive root (`%SystemDrive%\`). Never `NULL`. | | Seatbelt (macOS) | Same precedence, with `~` expanded as the profile expands it; falls back to `/`. | | LXC / WSL Container | The container root — see [`docs/lxc-support/lxc-backend.md`](lxc-support/lxc-backend.md). | -| MicroVM (NanVix) / Hyperlight | Not applicable — these backends reject a working directory outright. | +| NVX | Guest root when omitted; a caller-supplied guest path is preserved. | +| 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. @@ -383,7 +384,12 @@ 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"` | Compatibility-preserved public MicroVM identity. The same wire value directly selects the concrete MicroVM backend, implemented internally by NVX. | + +The one-shot `microvm` value is available in the published exact +`0.9.0-alpha` contract. It remains runtime-experimental and Windows x64-only; +publication does not remove the `--experimental` authorization requirement. +The abstract `vm` intent remains development-only in `0.10.0-alpha`. #### Concrete backends @@ -393,7 +399,7 @@ force a particular backend. | `"windows_sandbox"` | Windows Sandbox VM isolation. Dual-mode: a transient **one-shot** runner that launches a fresh disposable VM per execution, and a **state-aware** lifecycle backed by a long-lived per-sandbox 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 implemented by NVX and hosted by OpenVMM/WHP (experimental, Windows x64 foundation; runtime unavailable in Phase 1) | | `"hyperlight"` | MicroVM isolation via Hyperlight + Unikraft with an embedded CPython snapshot (experimental) | | `"isolation_session"` | Windows isolation session — runs the workload as a freshly-provisioned, per-execution isolated user account in its own OS-managed session. Dual-mode: one-shot and state-aware. | | `"seatbelt"` | macOS sandbox isolation (Seatbelt). Requires macOS 15 or later — see [`docs/seatbelt/seatbelt-backend.md`](seatbelt/seatbelt-backend.md). | @@ -474,7 +480,7 @@ Registered contracts: | `"0.6.0-alpha"` | Published; minimum supported | | `"0.7.0-alpha"` | Published | | `"0.8.0-alpha"` | Published | -| `"0.9.0-alpha"` | Published; current stable | +| `"0.9.0-alpha"` | Published; current stable; includes one-shot `microvm` | | `"0.10.0-alpha"` | Mutable development contract | An absent version, a retired version, or any unregistered spelling such as diff --git a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api-overview.md b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api-overview.md index aa7f4ff56..162004d03 100644 --- a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api-overview.md +++ b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api-overview.md @@ -20,7 +20,7 @@ function signatures throughout. One-line summaries; full definitions live in | Type | Where | Role | |---|---|---| -| `ContainmentType` / `ContainmentBackend` | `sdk/node/src/types.ts` | Two-tier containment names: `ContainmentType` for abstract intents (`'process' \| 'vm' \| 'microvm'` today); `ContainmentBackend` for concrete runners (`'processcontainer' \| 'windows_sandbox' \| 'lxc' \| 'wslc' \| 'microvm' \| 'seatbelt' \| 'isolation_session'`). Wire `containment` accepts either. The deprecated alias `SandboxingMethod = ContainmentType \| ContainmentBackend` is retained for back-compat. | +| `ContainmentType` / `ContainmentBackend` | `sdk/node/src/types.ts` | Two-tier containment names: `ContainmentType` for public intents (`'process' \| 'vm' \| 'microvm'`); `ContainmentBackend` for concrete runners (`'processcontainer' \| 'windows_sandbox' \| 'lxc' \| 'wslc' \| 'microvm' \| 'hyperlight' \| 'seatbelt' \| 'isolation_session'`). The compatibility-preserved `microvm` wire value appears in both because it is the public MicroVM identity and directly selects that backend; NVX remains internal. Wire `containment` accepts either. The deprecated alias `SandboxingMethod = ContainmentType \| ContainmentBackend` is retained for back-compat. | | `ProcessConfig` | `sdk/node/src/types.ts` | Per-process settings: `commandLine`, `cwd`, `env`, `timeout`. Reused inside state-aware exec Configs. | | `FilesystemConfig`, `NetworkConfig`, `UiConfig` | `sdk/node/src/types.ts` | Wire-format-aligned cross-cutting interfaces. Reused inline as field types inside the per-(backend, phase) state-aware Configs. | | `SandboxSpawnOptions` | `sdk/node/src/sandbox.ts` | Options for experimental authorization, dry-run validation, and cancellation on promise-returning operations. Live `execInSandbox` callers use the returned process's `kill()` method. | diff --git a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md index 979320e0a..0e94c4418 100644 --- a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md +++ b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md @@ -1449,11 +1449,9 @@ that shape and reuses `ExecutionRequest` for five concrete reasons: 1. **The field-ignore precedent is established across every existing backend.** Every `ScriptRunner` impl in the workspace today (`AppContainer`, `BaseContainer`, - `NanVix`, `WindowsSandbox`, `IsolationSession`, `Lxc`, `Wslc`) takes - `&ExecutionRequest` and reads only the fields it needs. `NanVix` and - `IsolationSession` go further and actively reject fields they cannot honor (e.g., - `NanVixScriptRunner::validate_runner` rejects filesystem paths, network rules, - network proxy, and a non-empty working directory). State-aware follows the same + `WindowsSandbox`, `IsolationSession`, `Lxc`, `Wslc`) takes + `&ExecutionRequest` and reads only the fields it needs. `IsolationSession` + goes further and actively rejects fields it cannot honor. State-aware follows the same pattern, so the trait ergonomic stays consistent across one-shot and state-aware surfaces. diff --git a/docs/versioning.md b/docs/versioning.md index 278dd1f3d..658ef8cee 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -92,9 +92,12 @@ mxc/schemas/ └── mxc-config.schema.0.10.0-alpha.json (exact closed development contract) ``` -Retired stable schema files are **kept as immutable historical artifacts** — the -parser simply stops accepting those versions (the supported floor is -`0.6.0-alpha`). Released schemas are never edited or deleted. +Retired stable schema files are **kept as revision-locked historical +artifacts** — the parser simply stops accepting those versions (the supported +floor is `0.6.0-alpha`). Published schemas are never edited by hand or deleted. +An exceptional contract amendment requires an intentional one-step publication +revision bump in `schemas/schema-version.json`, and CI rejects either side of +that change without the other. The development artifact is generated from the exact `mxc_config_contract::dev` model. It describes all eight closed one-shot and @@ -106,9 +109,11 @@ contract registered for the declared version. Corpus validation selects the exact registered schema from each document's `version`. Only the v0.10 file under `schemas/dev/` is a generated development artifact. -Published v0.9 is represented by its exact Rust contract and immutable stable -schema. Exact fixtures and adapter/runtime tests remain ordinary mutable tests -so they can gain regression coverage as implementations evolve. See +Published v0.9 is represented by its exact Rust contract and revision-locked +stable schema. Its second publication revision adds the experimental one-shot +`microvm` value while preserving NVX as an internal implementation detail. +Exact fixtures and adapter/runtime tests remain ordinary mutable tests so they +can gain regression coverage as implementations evolve. See [Schema Code Generation](schema-codegen.md) for the regeneration commands and independent drift/history gates. @@ -188,10 +193,11 @@ published JSON contract. ### Trust boundary vs schema defaults -Schemas in `stable/` are immutable: they document the input shape that was -promised at release. They are **not** authoritative for runtime security -defaults. `wxc-exec` is the trust boundary and may apply stricter defaults -than a stable schema declares when a security issue requires it. +Schemas in `stable/` are revision-locked: they document the input shape +promised by a specific publication revision. They are **not** authoritative +for runtime security defaults. `wxc-exec` is the trust boundary and may apply +stricter defaults than a stable schema declares when a security issue requires +it. For example, an older stable schema may declare `network.defaultPolicy` defaulting to `"allow"`. The runtime may treat an @@ -212,7 +218,8 @@ features which still require authorization; per-feature gating is under consideration. **Rules:** -- **Published contract contents** — shipped, stable, and immutable. +- **Published contract contents** — shipped, stable, and revision-locked. + Exceptional amendments require an explicit publication revision increment. - **Development contract contents** — mutable fields and roots at their permanent locations. Inclusion does not imply runtime authorization. - **Promotion:** When a feature is ready to ship, include it in the published @@ -223,8 +230,10 @@ consideration. `scripts/versioning/check-contract-codegen.js` compares every stable schema present at the merge base with the current tree. A published schema cannot be -changed or removed. New stable schemas are allowed because they have no -merge-base predecessor. +removed. It can change only when its positive integer in +`schema-version.json::stableRevisions` increments by exactly one in the same +change; a revision-only bump also fails. New stable schemas are allowed because +they have no merge-base predecessor. The same gate requires exactly one development contract and verifies that every supported stable schema has a published registry entry with the same @@ -337,8 +346,9 @@ fn run(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResp `normalize_common_request_ir` domain normalization 3. Remove the `if request.experimental_enabled` guard 4. Bump the minor version -5. Preserve every published contract unchanged; older contracts continue to - reject the field structurally. +5. Preserve every published contract unchanged unless an exceptional amendment + follows the explicit publication-revision process; older contracts continue + to reject the field structurally. Backend-section validation does not move during promotion: an experimental backend's exact-contract section already occupies its permanent top-level diff --git a/schemas/schema-version.json b/schemas/schema-version.json index da9982021..4cf1cf0d9 100644 --- a/schemas/schema-version.json +++ b/schemas/schema-version.json @@ -1,9 +1,17 @@ { - "$comment": "Canonical source of truth for MXC CONFIG SCHEMA versions. min/maxSupported define exact parser and SDK support; stableLatest is the newest immutable schema; stateAware is the IsolationSession lifecycle version; stateAwareWindowsSandbox and stateAwareWslc are the independently versioned backend lifecycle defaults.", + "$comment": "Canonical source of truth for MXC CONFIG SCHEMA versions. min/maxSupported define exact parser and SDK support; stableLatest is the newest published schema; stableRevisions makes deliberate published-contract amendments auditable while preserving history checks; stateAware is the IsolationSession lifecycle version; stateAwareWindowsSandbox and stateAwareWslc are the independently versioned backend lifecycle defaults.", "min": "0.6.0-alpha", "maxSupported": "0.10.0-alpha", "stateAware": "0.9.0-alpha", "stateAwareWindowsSandbox": "0.10.0-alpha", "stateAwareWslc": "0.9.0-alpha", - "stableLatest": "0.9.0-alpha" + "stableLatest": "0.9.0-alpha", + "stableRevisions": { + "0.4.0-alpha": 1, + "0.5.0-alpha": 1, + "0.6.0-alpha": 1, + "0.7.0-alpha": 1, + "0.8.0-alpha": 1, + "0.9.0-alpha": 2 + } } diff --git a/schemas/stable/mxc-config.schema.0.9.0-alpha.json b/schemas/stable/mxc-config.schema.0.9.0-alpha.json index 9e4455748..18d6833c2 100644 --- a/schemas/stable/mxc-config.schema.0.9.0-alpha.json +++ b/schemas/stable/mxc-config.schema.0.9.0-alpha.json @@ -741,6 +741,13 @@ ], "type": "string" }, + { + "description": "Microvm", + "enum": [ + "microvm" + ], + "type": "string" + }, { "description": "IsolationSession", "enum": [ diff --git a/scripts/ci/prepare-linux-host.sh b/scripts/ci/prepare-linux-host.sh index b9fbe6b6f..b6519f78a 100644 --- a/scripts/ci/prepare-linux-host.sh +++ b/scripts/ci/prepare-linux-host.sh @@ -6,7 +6,7 @@ set -euo pipefail # matrix entry works on Ubuntu, Debian, and RHEL images. usage() { - echo "Usage: $0 " >&2 + echo "Usage: $0 " >&2 } if [[ $# -ne 2 ]]; then @@ -316,11 +316,6 @@ case "$backend" in start_lxc_bridge ensure_bridge_nat ;; - microvm) - for file in nanvixd.elf nanvix_rootfs.img python3.initrd bin/kernel.elf; do - test -f "$binary_directory/$file" - done - ;; hyperlight) echo "Hyperlight has no artifact-only Linux test prerequisites yet." ;; diff --git a/scripts/ci/prepare-windows-host.ps1 b/scripts/ci/prepare-windows-host.ps1 index f440fe5fc..fba79e099 100644 --- a/scripts/ci/prepare-windows-host.ps1 +++ b/scripts/ci/prepare-windows-host.ps1 @@ -23,7 +23,6 @@ param( 'isolation-session', 'wslc', 'windows-sandbox', - 'microvm', 'hyperlight' )] [string]$Backend, @@ -461,27 +460,6 @@ function Install-PackagedTooling { $global:LASTEXITCODE = 0 } -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' - ) - - # NanVix boots a VM from these images on every invocation; Defender scanning - # them can push boot past its timeout. - Add-MpPreference -ExclusionPath $BinaryDirectory - Write-Host "Added Defender exclusion for $BinaryDirectory" - - Write-HypervisorDiagnostic - Assert-HypervisorPlatform -} - # The optional features must be baked into the pool image (enabling one needs a # reboot this job cannot take), but the WSL runtime package is installed here if # missing. Container images are pulled by the suite itself @@ -640,7 +618,6 @@ Assert-WorkloadInterpreters switch ($Backend) { 'process-t1' { Initialize-ProcessContainerHost } 'process-t3' { Initialize-ProcessContainerHost } - 'microvm' { Initialize-MicroVmHost } 'wslc' { Initialize-WslcHost } default { Write-Host "$Backend has no artifact-only Windows test prerequisites yet." } } diff --git a/scripts/ci/print-perf-summary.ps1 b/scripts/ci/print-perf-summary.ps1 deleted file mode 100644 index eb5ec52c6..000000000 --- a/scripts/ci/print-perf-summary.ps1 +++ /dev/null @@ -1,19 +0,0 @@ -param( - [Parameter(Mandatory = $true)] - [string]$JsonPath -) - -if (Test-Path $JsonPath) { - $data = Get-Content $JsonPath -Raw | ConvertFrom-Json - Write-Host "`n=== MicroVM Performance Summary ===" - Write-Host "Commit: $($data.commit)" - Write-Host "Timestamp: $($data.timestamp)" - Write-Host "" - Write-Host ("{0,-35} {1,10} {2,8}" -f "Test", "Time (ms)", "Status") - Write-Host ("{0,-35} {1,10} {2,8}" -f "----", "---------", "------") - foreach ($r in $data.results) { - Write-Host ("{0,-35} {1,10} {2,8}" -f $r.description, $r.wall_time_ms, $r.status) - } -} else { - Write-Host "::warning::No performance results found — perf JSON was not generated." -} diff --git a/scripts/ci/resolve-validation-test-matrix.mjs b/scripts/ci/resolve-validation-test-matrix.mjs index 565fcec1d..0b16b0ca8 100644 --- a/scripts/ci/resolve-validation-test-matrix.mjs +++ b/scripts/ci/resolve-validation-test-matrix.mjs @@ -10,7 +10,7 @@ import process from 'node:process'; import { fileURLToPath } from 'node:url'; const FAMILIES = ['windows', 'linux', 'macos']; -const ARM64_UNSUPPORTED_BACKENDS = new Set(['hyperlight', 'microvm']); +const ARM64_UNSUPPORTED_BACKENDS = new Set(['hyperlight']); function assertNonEmptyString(value, label) { if (typeof value !== 'string' || value.trim() === '') { diff --git a/scripts/ci/run_backend_validation_tests.ps1 b/scripts/ci/run_backend_validation_tests.ps1 index 679af7b40..838351d7b 100644 --- a/scripts/ci/run_backend_validation_tests.ps1 +++ b/scripts/ci/run_backend_validation_tests.ps1 @@ -15,7 +15,6 @@ param( 'isolation-session', 'windows-sandbox', 'wslc', - 'microvm', 'hyperlight' )] [string]$Backend, @@ -204,11 +203,6 @@ switch ($Backend) { WxcExecPath = $wxc } } - 'microvm' { - Invoke-TestScript -Path (Join-Path $testScriptRoot 'run_microvm_tests.ps1') -Arguments @{ - BinDir = $binaryDirectoryPath - } - } 'hyperlight' { # Keep unwired backends explicit so accidental activation fails loudly. throw 'The Hyperlight CI backend is not wired to an existing test entry point yet.' diff --git a/scripts/ci/run_backend_validation_tests.sh b/scripts/ci/run_backend_validation_tests.sh index f1d631be1..3ac1c0b49 100644 --- a/scripts/ci/run_backend_validation_tests.sh +++ b/scripts/ci/run_backend_validation_tests.sh @@ -6,7 +6,7 @@ set -euo pipefail # explicitly rather than reporting a false-success placeholder job. usage() { - echo "Usage: $0 " >&2 + echo "Usage: $0 " >&2 } if [[ $# -ne 2 ]]; then @@ -22,12 +22,6 @@ test_script_root="$repo_root/tests/scripts" release_directory="$repo_root/src/target/release" case "$backend" in - microvm) - # Keep unwired commands explicit so accidental activation fails loudly. - # Future test script: run_microvm_tests.sh - echo "The MicroVM CI backend is not wired to an artifact-only Linux test entry point yet." >&2 - exit 2 - ;; hyperlight) # Keep unwired commands explicit so accidental activation fails loudly. # Future test script: run_hyperlight_tests.sh diff --git a/scripts/ci/validation-test-matrix.json b/scripts/ci/validation-test-matrix.json index 2c829af62..c91b1689e 100644 --- a/scripts/ci/validation-test-matrix.json +++ b/scripts/ci/validation-test-matrix.json @@ -17,7 +17,6 @@ "isolation-session", "wslc", "windows-sandbox", - "microvm", "hyperlight" ] }, @@ -51,7 +50,6 @@ "isolation-session", "wslc", "windows-sandbox", - "microvm", "hyperlight" ] }, @@ -83,7 +81,6 @@ "process-t3", "wslc", "windows-sandbox", - "microvm", "hyperlight" ] }, @@ -114,7 +111,6 @@ "process-t3", "wslc", "windows-sandbox", - "microvm", "hyperlight" ] }, @@ -145,7 +141,6 @@ "process-t3", "wslc", "windows-sandbox", - "microvm", "hyperlight" ] }, @@ -175,7 +170,6 @@ "process-t3", "wslc", "windows-sandbox", - "microvm", "hyperlight" ] }, @@ -228,7 +222,6 @@ "pool": "1es-mxc-e2e-ubuntu-24.04-x64", "backends": [ "bubblewrap", - "microvm", "hyperlight", "lxc" ] diff --git a/scripts/versioning/check-contract-codegen.js b/scripts/versioning/check-contract-codegen.js index e0e318546..42da11a5d 100644 --- a/scripts/versioning/check-contract-codegen.js +++ b/scripts/versioning/check-contract-codegen.js @@ -79,14 +79,81 @@ function stableSchemaVersion(path) { null; } -function validateStableHistory(baseSchemas, currentSchemas) { +function stableRevision(revisions, version) { + const revision = revisions?.[version] ?? 1; + return Number.isSafeInteger(revision) && revision > 0 ? revision : null; +} + +function validateStableHistory( + baseSchemas, + currentSchemas, + baseRevisions = {}, + currentRevisions = {} +) { const errors = []; + const changedVersions = new Set(); for (const [path, before] of baseSchemas) { const after = currentSchemas.get(path); if (after === undefined) { errors.push(`stable schema was removed: ${path}`); } else if (normalize(before) !== normalize(after)) { - errors.push(`stable schema changed after publication: ${path}`); + const version = stableSchemaVersion(path); + const beforeRevision = stableRevision(baseRevisions, version); + const afterRevision = stableRevision(currentRevisions, version); + if (beforeRevision === null || afterRevision === null) { + errors.push(`stable schema ${path} has an invalid publication revision`); + } else if (afterRevision !== beforeRevision + 1) { + errors.push( + `stable schema changed after publication without incrementing its ` + + `publication revision exactly once: ${path} ` + + `(${beforeRevision} -> ${afterRevision})` + ); + } else { + changedVersions.add(version); + } + } + } + + for (const [version, after] of Object.entries(currentRevisions ?? {})) { + const before = stableRevision(baseRevisions, version); + const current = stableRevision(currentRevisions, version); + if (before === null || current === null) { + errors.push(`stable schema ${version} has an invalid publication revision`); + } else if (current !== before && !changedVersions.has(version)) { + errors.push( + `stable schema publication revision changed without an artifact ` + + `amendment: ${version} (${before} -> ${after})` + ); + } + } + return errors; +} + +function validateStableRevisions(stableSchemas, revisions) { + const errors = []; + if ( + revisions === null || + typeof revisions !== "object" || + Array.isArray(revisions) + ) { + return ["schema-version.json stableRevisions must be an object"]; + } + + const schemaVersions = new Set( + [...stableSchemas.keys()].map(stableSchemaVersion).filter(Boolean) + ); + for (const version of schemaVersions) { + if (stableRevision(revisions, version) === null || !(version in revisions)) { + errors.push( + `stable schema ${version} has no positive integer publication revision` + ); + } + } + for (const version of Object.keys(revisions)) { + if (!schemaVersions.has(version)) { + errors.push( + `publication revision ${version} has no matching stable schema` + ); } } return errors; @@ -185,11 +252,26 @@ function validatePublishedHistory(registry) { }) ); const currentSchemas = currentStableSchemas(); + const baseSchemaVersionsText = readFileAtCommit( + repoRoot, + commit, + "schemas/schema-version.json" + ); + if (baseSchemaVersionsText === null) { + fail(`could not read schemas/schema-version.json at ${commit}`); + } + const baseSchemaVersions = JSON.parse(baseSchemaVersionsText); const schemaVersions = JSON.parse( readFileSync(join(repoRoot, "schemas", "schema-version.json"), "utf8") ); const errors = [ - ...validateStableHistory(baseSchemas, currentSchemas), + ...validateStableRevisions(currentSchemas, schemaVersions.stableRevisions), + ...validateStableHistory( + baseSchemas, + currentSchemas, + baseSchemaVersions.stableRevisions, + schemaVersions.stableRevisions + ), ...validatePublishedRegistry(registry, currentSchemas, schemaVersions.min), ]; if (errors.length > 0) { @@ -469,6 +551,7 @@ module.exports = { validateFixtures, validatePublishedRegistry, validateStableHistory, + validateStableRevisions, }; if (require.main === module) { try { diff --git a/scripts/versioning/tests/check-contract-codegen.test.js b/scripts/versioning/tests/check-contract-codegen.test.js index ae8ec4596..6f41c4793 100644 --- a/scripts/versioning/tests/check-contract-codegen.test.js +++ b/scripts/versioning/tests/check-contract-codegen.test.js @@ -19,6 +19,7 @@ const { validateFixtures, validatePublishedRegistry, validateStableHistory, + validateStableRevisions, } = require("../check-contract-codegen.js"); const roots = ["OneShotRequest", "WindowsSandboxProvisionRequest", @@ -200,7 +201,7 @@ test("stable schema history allows initial publication", () => { assert.deepEqual(validateStableHistory(base, unchanged), []); }); -test("stable schema history rejects mutation and removal", () => { +test("stable schema history requires an explicit publication revision", () => { const base = new Map([ ["schemas/stable/mxc-config.schema.0.8.0-alpha.json", "{\"v\":1}\r\n"], ]); @@ -212,7 +213,27 @@ test("stable schema history rejects mutation and removal", () => { ["schemas/stable/mxc-config.schema.0.8.0-alpha.json", "{\"v\":2}\n"], ]) ).join("\n"), - /changed after publication/ + /changed after publication without incrementing its publication revision/ + ); + assert.deepEqual( + validateStableHistory( + base, + new Map([ + ["schemas/stable/mxc-config.schema.0.8.0-alpha.json", "{\"v\":2}\n"], + ]), + { "0.8.0-alpha": 1 }, + { "0.8.0-alpha": 2 } + ), + [] + ); + assert.match( + validateStableHistory( + base, + base, + { "0.8.0-alpha": 1 }, + { "0.8.0-alpha": 2 } + ).join("\n"), + /publication revision changed without an artifact amendment/ ); assert.match( validateStableHistory(base, new Map()).join("\n"), @@ -220,6 +241,32 @@ test("stable schema history rejects mutation and removal", () => { ); }); +test("stable publication revisions cover exactly the stable artifacts", () => { + const stable = new Map([ + ["schemas/stable/mxc-config.schema.0.8.0-alpha.json", "{}"], + ["schemas/stable/mxc-config.schema.0.9.0-alpha.json", "{}"], + ]); + assert.deepEqual( + validateStableRevisions(stable, { + "0.8.0-alpha": 1, + "0.9.0-alpha": 2, + }), + [] + ); + assert.match( + validateStableRevisions(stable, { "0.8.0-alpha": 1 }).join("\n"), + /0\.9\.0-alpha has no positive integer publication revision/ + ); + assert.match( + validateStableRevisions(stable, { + "0.8.0-alpha": 1, + "0.9.0-alpha": 2, + "1.0.0-alpha": 1, + }).join("\n"), + /1\.0\.0-alpha has no matching stable schema/ + ); +}); + test("CLI failures use the normal contract-codegen diagnostic", () => { const output = formatFailure(new Error("could not parse stable schema")); assert.equal( diff --git a/sdk/node/README.md b/sdk/node/README.md index 4b146ec82..94d23c6a2 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -65,14 +65,20 @@ Node.js 26.8.0 or later is recommended. | `0.6.0-alpha` | Stable (minimum supported) | [`schemas/stable/mxc-config.schema.0.6.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/stable/mxc-config.schema.0.6.0-alpha.json) | | `0.7.0-alpha` | Stable | [`schemas/stable/mxc-config.schema.0.7.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/stable/mxc-config.schema.0.7.0-alpha.json) | | `0.8.0-alpha` | Stable | [`schemas/stable/mxc-config.schema.0.8.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/stable/mxc-config.schema.0.8.0-alpha.json) | -| `0.9.0-alpha` | Stable (includes IsolationSession and WSLC one-shot and state-aware lifecycle) | [`schemas/stable/mxc-config.schema.0.9.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/stable/mxc-config.schema.0.9.0-alpha.json) | +| `0.9.0-alpha` | Stable (includes MicroVM one-shot plus IsolationSession and WSLC one-shot and state-aware lifecycle) | [`schemas/stable/mxc-config.schema.0.9.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/stable/mxc-config.schema.0.9.0-alpha.json) | | `0.10.0-alpha` | Dev (remaining experimental backends and development fields) | [`schemas/dev/mxc-config.schema.0.10.0-alpha.json`](https://github.com/microsoft/mxc/blob/main/schemas/dev/mxc-config.schema.0.10.0-alpha.json) | -Pick `0.9.0-alpha` for new code using current stable backends. Windows Sandbox, -MicroVM, and Hyperlight require `0.10.0-alpha`; Seatbelt requires `0.7.0-alpha` -or later. +Pick `0.9.0-alpha` for new code using current stable backends or the +experimental MicroVM one-shot backend. Windows Sandbox and Hyperlight require +`0.10.0-alpha`; Seatbelt requires `0.7.0-alpha` or later. -> **Stable schemas document only the non-experimental surface.** Experimental backends (`windows_sandbox`, `microvm`, `hyperlight`) and their permanent backend sections are defined by the mutable development contract. IsolationSession and WSLC, including their state-aware lifecycles, are part of exact v0.9 and do not require `--experimental`. Production executors dispatch through the exact contract selected by the declared version, whose adapter normalizes it into the private runtime input. +> **Contract publication and runtime authorization are separate.** The +> published v0.9 contract includes the experimental `microvm` one-shot wire +> value, while `windows_sandbox`, `vm`, and `hyperlight` remain in the mutable +> v0.10 development contract. MicroVM still requires `--experimental`; +> IsolationSession and WSLC, including their state-aware lifecycles, do not. +> Production executors dispatch through the exact contract selected by the +> declared version, whose adapter normalizes it into the private runtime input. > **Network host allow/block lists are not implemented on Windows.** Exact > v0.9/v0.10 requests use `network.egress` / `network.ingress` for directional @@ -293,7 +299,7 @@ console.log(result.stdout);
Table of all backends and links to per-backend guides — click to expand. -`SandboxPolicy` is cross-platform. The backend is selected by the second argument to `createConfigFromPolicy(policy, containment)`. Pass an **abstract intent** (`"process"`, `"vm"`, `"microvm"`) whenever possible — the SDK and native binary resolve it to the right concrete backend for the host. Pass a **concrete backend name** when you need a specific runner. +`SandboxPolicy` is cross-platform. The backend is selected by the second argument to `createConfigFromPolicy(policy, containment)`. Pass an **abstract intent** (`"process"`, `"vm"`, or the compatibility-preserved `"microvm"` public identity) whenever possible — the SDK and native binary resolve it to the right concrete backend for the host. Pass a **concrete backend name** when you need a specific runner. | Backend | Intent | Platforms | Minimum schema | Stable? | Guide | | --- | --- | --- | --- | --- | --- | @@ -302,14 +308,15 @@ console.log(result.stdout); | `lxc` | (concrete only) | Linux | `0.6.0-alpha` | ✅ | [`docs/lxc-support/lxc-backend.md`](https://github.com/microsoft/mxc/blob/main/docs/lxc-support/lxc-backend.md) | | `seatbelt` | `process` | macOS | `0.7.0-alpha` | ✅ | [`docs/seatbelt/seatbelt-backend.md`](https://github.com/microsoft/mxc/blob/main/docs/seatbelt/seatbelt-backend.md) | | `windows_sandbox` | `vm` | Windows | `0.10.0-alpha` | Experimental | [`docs/windows-sandbox/windows-sandbox.md`](https://github.com/microsoft/mxc/blob/main/docs/windows-sandbox/windows-sandbox.md) | -| `microvm` | `microvm` | Windows | `0.10.0-alpha` | Experimental | [`docs/nanvix-microvm/nanvix.md`](https://github.com/microsoft/mxc/blob/main/docs/nanvix-microvm/nanvix.md) — MicroVM via NanVix on Windows Hypervisor Platform | +| `microvm` | `microvm` | Windows x64 | `0.9.0-alpha` | Experimental | [`docs/nvx-backend-gaps.md`](https://github.com/microsoft/mxc/blob/main/docs/nvx-backend-gaps.md) — public MicroVM identity implemented internally by NVX; structural foundation only; runtime unavailable in Phase 1 | | `hyperlight` | (concrete only) | Windows x64 / Linux x64 | `0.10.0-alpha` | Experimental | No dedicated guide | | `wslc` | (concrete only) | Windows | `0.9.0-alpha` | Stable | [`docs/wsl/wsl-container-getting-started.md`](https://github.com/microsoft/mxc/blob/main/docs/wsl/wsl-container-getting-started.md) | | `isolation_session` | (concrete only) | Windows | `0.9.0-alpha` | Stable | [`docs/isolation-session/oneshot.md`](https://github.com/microsoft/mxc/blob/main/docs/isolation-session/oneshot.md) | The abstract `process` intent therefore requires `0.7.0-alpha` on macOS, where it resolves to Seatbelt, but retains the `0.6.0-alpha` floor on Windows -and Linux. The abstract `vm` intent requires `0.10.0-alpha`. +and Linux. The compatibility-preserved `microvm` identity requires +`0.9.0-alpha`; the abstract `vm` intent requires `0.10.0-alpha`. Experimental backends require `{ experimental: true }` in `SandboxSpawnOptions`: @@ -546,14 +553,14 @@ granting file content reads. It requires a BaseContainer host with PSEC 1.1 | --- | --- | --- | | `MXC is not supported on this platform` | `getPlatformSupport()` returned `isSupported: false`. On Linux, neither LXC nor a usable Bubblewrap 0.5.0+ installation is available. On macOS, the Seatbelt platform probe could not find `/usr/bin/sandbox-exec`. | Inspect `support.reason`. On Linux, also inspect `support.unavailableReasons` and install LXC or Bubblewrap 0.5.0+. On macOS, verify that `/usr/bin/sandbox-exec` exists; its absence indicates an incomplete or unsupported macOS installation. | | `wxc-exec.exe not found` / `lxc-exec not found` | The SDK couldn't locate the native binary. | Set `MXC_BIN_DIR=` so `//wxc-exec.exe` (or `lxc-exec`) exists, or pass `options.executablePath` explicitly. | -| `Invalid containment value ''` | `containment` field doesn't match the parser's accepted values. | Use one of the abstract intents (`process`, `vm`, `microvm`) or a concrete backend listed in [Choosing a Backend](#choosing-a-backend). | +| `Invalid containment value ''` | `containment` field doesn't match the parser's accepted values. | Use one of the public intents (`process`, `vm`, `microvm`) or a concrete backend listed in [Choosing a Backend](#choosing-a-backend). | | `'' containment requires experimental mode` | A `windows_sandbox` / `microvm` / `hyperlight` backend was selected without the flag. | Pass `{ experimental: true }` in `SandboxSpawnOptions`. | | `process.commandLine starts with an unquoted Windows path containing a space` | `wxc-exec` rejects unquoted paths with spaces at parse time. | Quote the executable: `'"C:\\Program Files\\…\\foo.exe" args'`. | | `CreateProcessW(PROC_THREAD_ATTRIBUTE_SECURITY_ENVIRONMENT) failed: ...` | The process security environment launch returned an OS-level error. Backend-unavailable failures automatically fall through to an AppContainer tier during selection. | Check the Windows build requirements for the backend you selected. | | Process exits `-1` / `4294967295` with no stdout | Native binary terminated abnormally. | Re-run with `options.debug: true` (or `options.logDir: ''`) to capture diagnostic logs. | | `Policy version '' is older than supported` / `newer than supported` | Version is outside the supported version lines. | Use an exact registered version: `0.6.0-alpha`, `0.7.0-alpha`, `0.8.0-alpha`, `0.9.0-alpha`, or `0.10.0-alpha`. See [Compatibility](#compatibility). | | `Policy version '' is not a registered schema contract` / `Unsupported contract version` | The declaration is not registered, even if it falls between supported versions (for example, `0.6.1-alpha`). | Use an exact version from [Compatibility](#compatibility); IsolationSession and WSLC state-aware requests use `0.9.0-alpha`, while Windows Sandbox uses `0.10.0-alpha`. | -| `Schema does not support containment ''` | The selected backend was introduced after the declared schema version. | Use the backend's minimum version from [Choosing a Backend](#choosing-a-backend). Seatbelt requires `0.7.0-alpha`; IsolationSession and WSLC require `0.9.0-alpha`; Windows Sandbox, MicroVM, and Hyperlight require `0.10.0-alpha`. | +| `Schema does not support containment ''` | The selected backend was introduced after the declared schema version. | Use the backend's minimum version from [Choosing a Backend](#choosing-a-backend). Seatbelt requires `0.7.0-alpha`; IsolationSession, WSLC, and MicroVM require `0.9.0-alpha`; Windows Sandbox and Hyperlight require `0.10.0-alpha`. | For backend-specific errors, see the per-backend guide linked from the [Choosing a Backend](#choosing-a-backend) table. diff --git a/sdk/node/src/generated/v0_9_0_alpha/wire.ts b/sdk/node/src/generated/v0_9_0_alpha/wire.ts index e32d76aed..5da863275 100644 --- a/sdk/node/src/generated/v0_9_0_alpha/wire.ts +++ b/sdk/node/src/generated/v0_9_0_alpha/wire.ts @@ -354,7 +354,7 @@ export interface NetworkRule { export type NonEmptyString = string; -export type OneShotContainment = "process" | "processcontainer" | "appcontainer" | "lxc" | "bubblewrap" | "seatbelt" | "macos_sandbox" | "isolation_session" | "wslc"; +export type OneShotContainment = "process" | "processcontainer" | "appcontainer" | "lxc" | "bubblewrap" | "seatbelt" | "macos_sandbox" | "microvm" | "isolation_session" | "wslc"; /** * A complete one-shot `0.9.0-alpha` configuration request. diff --git a/sdk/node/src/helper.ts b/sdk/node/src/helper.ts index 951e86613..21ae1dc4f 100644 --- a/sdk/node/src/helper.ts +++ b/sdk/node/src/helper.ts @@ -242,7 +242,7 @@ export function resolveExecutableAndArgs( // bypass the experimental-mode gate (because they are not in // ExperimentalBackends under their alias) and produce a confusing // "not available on this platform" error instead. - const rawContainment = config.containment; + const rawContainment: string | undefined = config.containment; const effectiveContainment = rawContainment ? (LegacyContainmentAliases[rawContainment] ?? rawContainment) : undefined; @@ -252,7 +252,9 @@ export function resolveExecutableAndArgs( // Check experimental mode before anything else so the caller gets a clear // message about the missing flag rather than a platform/binary error. - if (effectiveContainment && ExperimentalBackends.includes(effectiveContainment) && !options.experimental) { + if (effectiveContainment && + (ExperimentalBackends as readonly string[]).includes(effectiveContainment) && + !options.experimental) { throw new Error( `'${rawContainment}' containment requires experimental mode. Set 'experimental: true' in SandboxSpawnOptions.` ); @@ -265,16 +267,17 @@ export function resolveExecutableAndArgs( throw new Error(`MXC is not supported on this platform: ${platformSupport.reason}`); } - // Hard platform requirement: microvm needs WHP/Hyper-V on Windows. This guard + // Hard platform requirement: MicroVM (NVX) needs OpenVMM/WHP on Windows x64. This guard // runs even when `skipPlatformCheck` is set because it's not a build-version // check — the backend literally cannot run on non-Windows hosts. - if (effectiveContainment === 'microvm' && os.platform() !== 'win32') { - throw new Error('The microvm backend is only supported on Windows (requires WHP/Hyper-V).'); + if (effectiveContainment === 'microvm' && + (os.platform() !== 'win32' || os.arch() !== 'x64')) { + throw new Error('The microvm backend is only supported on Windows x64.'); } // Validate containment against platform if (effectiveContainment && !options.skipPlatformCheck) { - // Abstract intents (process, microvm) are resolved by the native binary + // Abstract intents are resolved by the native binary // at run time, so the SDK accepts them without checking against the // host's concrete backend list. const isIntent = (ContainmentTypes as readonly string[]).includes(effectiveContainment); diff --git a/sdk/node/src/sandbox.ts b/sdk/node/src/sandbox.ts index 8b4de2e20..ceaa10851 100644 --- a/sdk/node/src/sandbox.ts +++ b/sdk/node/src/sandbox.ts @@ -119,9 +119,9 @@ function validateContainmentVersion( ? '0.7.0-alpha' : effectiveContainment === 'isolation_session' || effectiveContainment === 'wslc' + || effectiveContainment === 'microvm' ? '0.9.0-alpha' : effectiveContainment === 'vm' || - effectiveContainment === 'microvm' || effectiveContainment === 'windows_sandbox' || effectiveContainment === 'hyperlight' ? '0.10.0-alpha' @@ -341,66 +341,6 @@ function buildProcessBaseContainerConfig( return config; } -/** - * Builds the MicroVM (NanVix) portion of a ContainerConfig. - * MicroVM is Windows-only and supports isolated or unrestricted networking. - */ -function buildMicroVmConfig( - config: ContainerConfig, - policy: SandboxPolicy, -): ContainerConfig { - if (os.platform() !== 'win32') { - throw new Error('The microvm backend is only supported on Windows (requires WHP/Hyper-V).'); - } - if (policy.network && hasLegacyNetworkFields(policy.network)) { - throw new Error( - 'The microvm backend supports only directional network.egress/network.ingress configuration.' - ); - } - if (policy.runtimeConfig?.networkProxy !== undefined || - policy.processContainer?.network?.allowedProxyPeer !== undefined) { - throw new Error('The microvm backend does not support network proxy configuration.'); - } - if (policy.network?.egress?.allow?.length || policy.network?.egress?.deny?.length) { - throw new Error( - 'The microvm backend does not support directional network rules. ' + - 'Use fully isolated or explicitly unrestricted networking without rules.' - ); - } - if (policy.network !== undefined) { - const egressDefault = policy.network.egress?.default ?? 'deny'; - const ingressDefault = policy.network.ingress?.default ?? 'deny'; - const hostLoopback = policy.network.ingress?.hostLoopback ?? 'deny'; - if (egressDefault !== ingressDefault || ingressDefault !== hostLoopback) { - throw new Error( - 'The microvm backend requires network.egress.default, network.ingress.default, ' + - 'and network.ingress.hostLoopback to be all deny or all allow.' - ); - } - config.network = { - egress: policy.network.egress, - ingress: policy.network.ingress, - }; - } - if (policy.filesystem?.readwritePaths?.length || - policy.filesystem?.readonlyPaths?.length || - policy.filesystem?.deniedPaths?.length) { - config.filesystem = { - readwritePaths: policy.filesystem?.readwritePaths, - readonlyPaths: policy.filesystem?.readonlyPaths, - deniedPaths: policy.filesystem?.deniedPaths, - }; - } - if (policy.processContainer?.filesystem?.enumeratePaths?.length) { - throw new Error( - 'The microvm backend does not support processContainer.filesystem.enumeratePaths. ' + - 'Remove it or use the Windows ProcessContainer backend.' - ); - } - config.containment = 'microvm'; - return config; -} - /** * Creates a ContainerConfig from a SandboxPolicy and optional containment type. * @@ -441,6 +381,7 @@ export function createConfigFromPolicy( validateTelemetryVersion(policy); const directionalNetwork = selectDirectionalNetwork(policy); const enumeratePaths = policy.processContainer?.filesystem?.enumeratePaths; + const allowedProxyPeer = policy.processContainer?.network?.allowedProxyPeer; const containerId = containerName ?? generateRandomContainerName(); @@ -459,12 +400,6 @@ export function createConfigFromPolicy( telemetry: policy.telemetry === undefined ? undefined : { ...policy.telemetry }, }; - // Microvm: delegate to dedicated builder - if (containment === 'microvm') { - diagLog(`createConfigFromPolicy: containment=microvm, id=${containerId}`); - return buildMicroVmConfig(config, policy); - } - if (enumeratePaths?.length) { if (policy.version !== '0.9.0-alpha' && policy.version !== '0.10.0-alpha') { throw new Error( @@ -480,6 +415,15 @@ export function createConfigFromPolicy( ); } } + if (containment === 'microvm' && allowedProxyPeer !== undefined) { + throw new Error( + 'processContainer.network.allowedProxyPeer is supported only by the Windows ' + + 'ProcessContainer backend.' + ); + } + if (containment === 'microvm' && policy.ui !== undefined) { + throw new Error('SandboxPolicy.ui is not supported by the MicroVM backend.'); + } config.filesystem = { readwritePaths: [...(policy.filesystem?.readwritePaths ?? [])], @@ -494,12 +438,14 @@ export function createConfigFromPolicy( }; } - // SandboxPolicy defaults are fail-closed, so omission still emits lockdown. - config.ui = { - disable: !(policy.ui?.allowWindows ?? false), - clipboard: policy.ui?.clipboard ?? "none", - injection: policy.ui?.allowInputInjection ?? false, - }; + if (containment !== 'microvm') { + // SandboxPolicy defaults are fail-closed, so omission still emits lockdown. + config.ui = { + disable: !(policy.ui?.allowWindows ?? false), + clipboard: policy.ui?.clipboard ?? "none", + injection: policy.ui?.allowInputInjection ?? false, + }; + } if (directionalNetwork) { if ((requiresDirectionalNetwork(policy.version) && @@ -515,11 +461,11 @@ export function createConfigFromPolicy( networkProxy: policy.runtimeConfig.networkProxy, }; } - if (policy.processContainer?.network?.allowedProxyPeer !== undefined) { + if (allowedProxyPeer !== undefined) { config.processContainer = { ...config.processContainer, network: { - allowedProxyPeer: policy.processContainer.network.allowedProxyPeer, + allowedProxyPeer, }, }; } @@ -571,6 +517,12 @@ export function createConfigFromPolicy( } // Backend-specific config based on containment type + if (containment === 'microvm') { + diagLog(`createConfigFromPolicy: containment=microvm, id=${containerId}`); + config.containment = 'microvm'; + return config; + } + if (containment === 'wslc') { return buildWslcContainerConfig(config, policy, containerId); } diff --git a/sdk/node/src/types.ts b/sdk/node/src/types.ts index b589de2d9..01e5c7858 100644 --- a/sdk/node/src/types.ts +++ b/sdk/node/src/types.ts @@ -64,15 +64,15 @@ export interface LifecycleConfig { * - "vm": full hardware-virtualised VM isolation. Resolves to * `windows_sandbox` on Windows; no concrete VM backend exists on other * platforms today. - * - "microvm": lightweight-VM isolation. Resolves to the current MicroVM - * runner (Windows only, experimental); intended to expand as additional - * microvm backends (e.g. NanVix) are added. - * + * - "microvm": the public MicroVM identity. It is retained as an intent for + * source and runtime compatibility while NVX remains its internal + * implementation. The same wire value also appears in + * {@link ContainmentBackend} because it directly selects that backend. * Concrete-only backends (such as `"wslc"`) live on * {@link ContainmentBackend} until there is a meaningful abstraction over * multiple implementations of the same kind. */ -export type ContainmentType = "process" | "vm" | "microvm"; +export type ContainmentType = 'process' | 'vm' | 'microvm'; /** * Runtime list of {@link ContainmentType} values. Kept in sync with the @@ -126,7 +126,11 @@ export type ContainmentBackend = * Containment values (abstract intent or concrete backend) that require * the `--experimental` flag. */ -export const ExperimentalBackends: readonly (ContainmentType | ContainmentBackend)[] = ['microvm', 'windows_sandbox', 'hyperlight']; +export const ExperimentalBackends: readonly (ContainmentType | ContainmentBackend)[] = [ + 'microvm', + 'windows_sandbox', + 'hyperlight', +]; /** * Clipboard access policy levels diff --git a/sdk/node/tests/integration/microvm-filesystem.test.ts b/sdk/node/tests/integration/microvm-filesystem.test.ts deleted file mode 100644 index 836c690eb..000000000 --- a/sdk/node/tests/integration/microvm-filesystem.test.ts +++ /dev/null @@ -1,468 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// MicroVM SDK end-to-end tests — these tests spawn NanVix VMs via wxc-exec.exe. -// -// Requirements: -// - Windows with WHP enabled (bcdedit /set hypervisorlaunchtype auto) -// - wxc-exec.exe built (in src/target/debug/ or src/target/x86_64-pc-windows-msvc/debug/) -// - NanVix binaries next to wxc-exec.exe: nanvixd.exe, kernel.elf, python3.12, nanvix_rootfs.img -// -// Run: cd sdk/tests/integration && npx tsc -p tsconfig.json && node --test dist/microvm-filesystem.test.js -// -// All tests use spawnSandboxFromConfig with usePty:false (non-PTY mode). -// PTY mode is not supported for the MicroVM backend. - -import { describe, it } from 'node:test'; -import assert from 'node:assert'; -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'os'; -import { execSync } from 'child_process'; -import { ChildProcess } from 'child_process'; -import { sdk } from './test-helpers.js'; -import type { ContainerConfig } from '@microsoft/mxc-sdk'; - -function isWhpAvailable(): boolean { - if (os.platform() !== 'win32') return false; - // CI sets this when wxc-exec/nanvix binaries aren't available. - if (process.env.MXC_SKIP_OS_BUILD_DEPENDENT_TESTS === '1') return false; - try { - const result = execSync( - 'powershell -NoProfile -Command "(Get-CimInstance Win32_ComputerSystem).HypervisorPresent"', - { encoding: 'utf8', timeout: 5000 } - ).trim(); - return result === 'True'; - } catch { - return false; - } -} - -const isMicrovmAvailable = isWhpAvailable(); - -/** Escape backslashes for embedding a Windows path in a Python string literal. */ -function pyEscape(p: string): string { - return p.replace(/\\/g, '\\\\'); -} - -/** - * Spawn a microvm sandbox using spawnSandboxFromConfig with usePty:false. - * Returns stdout, stderr, and exit code. - */ -function runMicrovm( - config: ContainerConfig, - options: { timeoutMs?: number } = {}, -): Promise<{ stdout: string; stderr: string; exitCode: number }> { - return new Promise((resolve, reject) => { - const timeout = options.timeoutMs ?? 120_000; - - try { - const child: ChildProcess = sdk.spawnSandboxFromConfig(config, { - experimental: true, - debug: true, - usePty: false, - }); - - let stdout = ''; - let stderr = ''; - - child.stdout?.on('data', (data: Buffer) => { stdout += data.toString(); }); - child.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); }); - - const timer = setTimeout(() => { - child.kill(); - reject(new Error(`MicroVM test timed out after ${timeout}ms.\nstdout: ${stdout}\nstderr: ${stderr}`)); - }, timeout); - - child.on('error', (error: Error) => { - clearTimeout(timer); - reject(new Error(`Failed to spawn wxc-exec: ${error.message}`)); - }); - - child.on('close', (code: number | null) => { - clearTimeout(timer); - resolve({ stdout, stderr, exitCode: code ?? -1 }); - }); - } catch (error) { - reject(error); - } - }); -} - -describe('MicroVM SDK E2E — spawnSandboxFromConfig with containment: microvm', { - skip: !isMicrovmAvailable ? 'MicroVM tests require Windows with WHP' : undefined, -}, () => { - - it('should run a simple Python script and capture output', async () => { - const config = { - version: '0.10.0-alpha', - containment: 'microvm' as const, - process: { - commandLine: "print('Hello from MicroVM SDK E2E!')", - timeout: 30000, - }, - }; - - const { stdout, stderr, exitCode } = await runMicrovm(config); - const combined = stdout + stderr; - assert.strictEqual(exitCode, 0, `Expected exit code 0, got ${exitCode}.\nstdout: ${stdout}\nstderr: ${stderr}`); - assert.ok(combined.includes('Hello from MicroVM SDK E2E!'), `Expected greeting in output:\n${combined}`); - }); - - it('should propagate non-zero exit codes', async () => { - const config = { - version: '0.10.0-alpha', - containment: 'microvm' as const, - process: { - commandLine: "import sys; sys.exit(42)", - timeout: 30000, - }, - }; - - const { exitCode } = await runMicrovm(config); - assert.strictEqual(exitCode, 42, `Expected exit code 42, got ${exitCode}`); - }); - - it('should run multiline scripts with imports', async () => { - const config = { - version: '0.10.0-alpha', - containment: 'microvm' as const, - process: { - commandLine: [ - "import sys", - "import json", - "result = {'python': f'{sys.version_info.major}.{sys.version_info.minor}', 'platform': sys.platform}", - "print(json.dumps(result))", - ].join('\n'), - timeout: 30000, - }, - }; - - const { stdout, stderr, exitCode } = await runMicrovm(config); - const combined = stdout + stderr; - assert.strictEqual(exitCode, 0, `Expected exit code 0.\nstdout: ${stdout}\nstderr: ${stderr}`); - assert.ok(combined.includes('"platform": "nanvix"'), `Expected nanvix platform in output:\n${combined}`); - }); - - it('should support readwritePaths with transparent path translation', async () => { - const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-microvm-e2e-')); - const rwDir = path.join(testDir, 'work'); - fs.mkdirSync(rwDir); - fs.writeFileSync(path.join(rwDir, 'input.txt'), 'data from host'); - - try { - // Use the host path directly in the script — the staging layer rewrites - // it to the guest mount path before the script reaches the VM. - const config = { - version: '0.10.0-alpha', - containment: 'microvm' as const, - process: { - commandLine: [ - "import os", - `path = '${pyEscape(rwDir)}'`, - "print(f'Guest path: {path}')", - "with open(os.path.join(path, 'input.txt')) as f:", - " print(f'Read: {f.read().strip()}')", - ].join('\n'), - timeout: 30000, - }, - filesystem: { - readwritePaths: [rwDir], - }, - }; - - const { stdout, stderr, exitCode } = await runMicrovm(config); - const combined = stdout + stderr; - assert.strictEqual(exitCode, 0, `Expected exit code 0.\nstdout: ${stdout}\nstderr: ${stderr}`); - assert.ok(combined.includes('Guest path: /mnt/rw/'), `Expected guest path starting with /mnt/rw/ in output:\n${combined}`); - assert.ok(combined.includes('Read: data from host'), `Expected host data in output:\n${combined}`); - } finally { - fs.rmSync(testDir, { recursive: true, force: true }); - } - }); - - it('should reject denied_paths with an error', async () => { - const config = { - version: '0.10.0-alpha', - containment: 'microvm' as const, - process: { - commandLine: "print('should not run')", - timeout: 30000, - }, - filesystem: { - deniedPaths: ['/secret'], - }, - }; - - const { stdout, stderr, exitCode } = await runMicrovm(config); - const combined = stdout + stderr; - assert.notStrictEqual(exitCode, 0, `Expected non-zero exit code for denied_paths`); - assert.ok(combined.includes('denied_paths'), `Expected denied_paths error in output:\n${combined}`); - }); - - it('should copy readwritePaths changes back to the host on clean exit', async () => { - const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-microvm-copyback-')); - const rwDir = path.join(testDir, 'work'); - fs.mkdirSync(rwDir); - fs.writeFileSync(path.join(rwDir, 'input.txt'), 'before'); - - try { - const config = { - version: '0.10.0-alpha', - containment: 'microvm' as const, - process: { - commandLine: [ - "import os", - `path = '${pyEscape(rwDir)}'`, - "with open(os.path.join(path, 'input.txt'), 'w') as f:", - " f.write('after')", - "with open(os.path.join(path, 'created.txt'), 'w') as f:", - " f.write('created by guest')", - ].join('\n'), - timeout: 30000, - }, - filesystem: { - readwritePaths: [rwDir], - }, - }; - - const { stdout, stderr, exitCode } = await runMicrovm(config); - assert.strictEqual(exitCode, 0, `Expected exit code 0.\nstdout: ${stdout}\nstderr: ${stderr}`); - assert.strictEqual(fs.readFileSync(path.join(rwDir, 'input.txt'), 'utf8'), 'after'); - assert.strictEqual(fs.readFileSync(path.join(rwDir, 'created.txt'), 'utf8'), 'created by guest'); - } finally { - fs.rmSync(testDir, { recursive: true, force: true }); - } - }); - - it('should copy readwritePaths changes back after a normal non-zero guest exit', async () => { - const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-microvm-copyback-nonzero-')); - const rwDir = path.join(testDir, 'work'); - fs.mkdirSync(rwDir); - - try { - const config = { - version: '0.10.0-alpha', - containment: 'microvm' as const, - process: { - commandLine: [ - "import os, sys", - `path = '${pyEscape(rwDir)}'`, - "with open(os.path.join(path, 'nonzero.txt'), 'w') as f:", - " f.write('persisted before non-zero exit')", - "sys.exit(7)", - ].join('\n'), - timeout: 30000, - }, - filesystem: { - readwritePaths: [rwDir], - }, - }; - - const { exitCode } = await runMicrovm(config); - assert.strictEqual(exitCode, 7, `Expected exit code 7, got ${exitCode}`); - assert.strictEqual( - fs.readFileSync(path.join(rwDir, 'nonzero.txt'), 'utf8'), - 'persisted before non-zero exit' - ); - } finally { - fs.rmSync(testDir, { recursive: true, force: true }); - } - }); - - it('should generate a PPTX file in a readwritePath and copy it back to the host', async () => { - const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-microvm-pptx-')); - const rwDir = path.join(testDir, 'output'); - fs.mkdirSync(rwDir); - - try { - const rwDirPy = pyEscape(rwDir); - const config = { - version: '0.10.0-alpha', - containment: 'microvm' as const, - process: { - // Generate a minimal valid PPTX using only stdlib (zipfile + xml). - // A PPTX is an Office Open XML package — a zip with specific XML parts. - commandLine: [ - "import zipfile, os", - `outdir = '${rwDirPy}'`, - "pptx_path = os.path.join(outdir, 'test.pptx')", - "ct = ''", - "rels = ''", - "pres = ''", - "with zipfile.ZipFile(pptx_path, 'w', zipfile.ZIP_DEFLATED) as z:", - " z.writestr('[Content_Types].xml', ct)", - " z.writestr('_rels/.rels', rels)", - " z.writestr('ppt/presentation.xml', pres)", - "print(f'PPTX size: {os.path.getsize(pptx_path)} bytes')", - ].join('\n'), - timeout: 30000, - }, - filesystem: { - readwritePaths: [rwDir], - }, - }; - - const { stdout, stderr, exitCode } = await runMicrovm(config); - const combined = stdout + stderr; - assert.strictEqual(exitCode, 0, `Expected exit code 0.\nstdout: ${stdout}\nstderr: ${stderr}`); - assert.ok(combined.includes('PPTX size:'), `Expected PPTX size in output:\n${combined}`); - - // Verify the PPTX was copied back to the host. - const pptxPath = path.join(rwDir, 'test.pptx'); - assert.ok(fs.existsSync(pptxPath), `Expected test.pptx at ${pptxPath}`); - const size = fs.statSync(pptxPath).size; - assert.ok(size > 0, `Expected non-empty PPTX, got ${size} bytes`); - - // Verify it's a valid zip (PPTX is zip-based). - const header = Buffer.alloc(4); - const fd = fs.openSync(pptxPath, 'r'); - fs.readSync(fd, header, 0, 4, 0); - fs.closeSync(fd); - assert.strictEqual(header[0], 0x50, 'Expected PK zip header byte 1'); - assert.strictEqual(header[1], 0x4B, 'Expected PK zip header byte 2'); - } finally { - fs.rmSync(testDir, { recursive: true, force: true }); - } - }); - - it('should generate a dark-themed 3-slide PPTX about MXC and NanVix using python-pptx', async () => { - const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-microvm-pptx-dark-')); - const rwDir = path.join(testDir, 'output'); - fs.mkdirSync(rwDir); - - try { - const rwDirPy = pyEscape(rwDir); - const config = { - version: '0.10.0-alpha', - containment: 'microvm' as const, - process: { - commandLine: [ - // site-packages isn't on sys.path by default in this NanVix build - "import sys; sys.path.insert(0, '/sysroot/lib/python3.12/site-packages')", - "import os", - "from pptx import Presentation", - "from pptx.util import Inches, Pt, Emu", - "from pptx.dml.color import RGBColor", - "from pptx.enum.text import PP_ALIGN", - "", - "prs = Presentation()", - "prs.slide_width = Emu(12192000)", - "prs.slide_height = Emu(6858000)", - "", - "BG = RGBColor(0x1B, 0x1B, 0x2F)", - "WHITE = RGBColor(0xFF, 0xFF, 0xFF)", - "BLUE = RGBColor(0x56, 0x9C, 0xD6)", - "TEAL = RGBColor(0x4E, 0xC9, 0xB0)", - "GRAY = RGBColor(0xD4, 0xD4, 0xD4)", - "", - "def set_bg(slide):", - " bg = slide.background", - " fill = bg.fill", - " fill.solid()", - " fill.fore_color.rgb = BG", - "", - "def add_text(slide, left, top, width, height, text, font_size, color, bold=False, alignment=PP_ALIGN.LEFT):", - " txBox = slide.shapes.add_textbox(left, top, width, height)", - " tf = txBox.text_frame", - " tf.word_wrap = True", - " p = tf.paragraphs[0]", - " p.text = text", - " p.font.size = Pt(font_size)", - " p.font.color.rgb = color", - " p.font.bold = bold", - " p.alignment = alignment", - " return tf", - "", - "def add_para(tf, text, font_size, color, bold=False):", - " p = tf.add_paragraph()", - " p.text = text", - " p.font.size = Pt(font_size)", - " p.font.color.rgb = color", - " p.font.bold = bold", - " return tf", - "", - "# --- Slide 1: Title ---", - "blank = prs.slide_layouts[6]", - "s1 = prs.slides.add_slide(blank)", - "set_bg(s1)", - "add_text(s1, Inches(0.8), Inches(1.8), Inches(10), Inches(1.2),", - " '\\U0001f680 MXC \\u00d7 NanVix', 44, WHITE, bold=True, alignment=PP_ALIGN.CENTER)", - "tf1 = add_text(s1, Inches(0.8), Inches(3.2), Inches(10), Inches(1.5),", - " 'Sandboxed Code Execution with Micro-VM Isolation', 24, BLUE, alignment=PP_ALIGN.CENTER)", - "add_para(tf1, '', 12, WHITE)", - "add_para(tf1, '\\U0001f512 Secure \\u2022 \\u26a1 Fast \\u2022 \\U0001f30d Cross-Platform', 20, TEAL)", - "tf1.paragraphs[-1].alignment = PP_ALIGN.CENTER", - "", - "# --- Slide 2: Architecture ---", - "s2 = prs.slides.add_slide(blank)", - "set_bg(s2)", - "add_text(s2, Inches(0.8), Inches(0.4), Inches(10), Inches(0.8),", - " '\\U0001f527 How It Works', 32, WHITE, bold=True)", - "tf2 = add_text(s2, Inches(0.8), Inches(1.4), Inches(10), Inches(5),", - " '\\U0001f4e6 MXC (Microsoft eXecution Container)', 20, BLUE, bold=True)", - "add_para(tf2, 'Orchestrates sandboxed execution across backends', 16, GRAY)", - "add_para(tf2, '', 12, GRAY)", - "add_para(tf2, '\\U0001f5a5 NanVix Micro-VM Backend', 20, BLUE, bold=True)", - "add_para(tf2, 'Lightweight hypervisor isolation via WHP', 16, GRAY)", - "add_para(tf2, 'CPython 3.12 inside a minimal microkernel', 16, GRAY)", - "add_para(tf2, '', 12, GRAY)", - "add_para(tf2, '\\U0001f504 The Flow', 20, BLUE, bold=True)", - "add_para(tf2, 'SDK \\u2192 wxc-exec \\u2192 nanvixd \\u2192 kernel.elf \\u2192 Python \\U0001f40d', 16, TEAL)", - "", - "# --- Slide 3: What's Next ---", - "s3 = prs.slides.add_slide(blank)", - "set_bg(s3)", - "add_text(s3, Inches(0.8), Inches(0.4), Inches(10), Inches(0.8),", - " '\\u2728 What\\'s Next', 32, WHITE, bold=True)", - "tf3 = add_text(s3, Inches(0.8), Inches(1.4), Inches(10), Inches(5),", - " '\\u2705 Filesystem sharing via readwritePaths', 18, GRAY)", - "add_para(tf3, '\\u2705 Stdout/stderr streaming back to host', 18, GRAY)", - "add_para(tf3, '\\u2705 Exit code propagation', 18, GRAY)", - "add_para(tf3, '\\U0001f6a7 Network isolation & proxy support', 18, GRAY)", - "add_para(tf3, '\\U0001f6a7 Multi-language guest support', 18, GRAY)", - "add_para(tf3, '\\U0001f6a7 GPU passthrough for AI workloads', 18, GRAY)", - "add_para(tf3, '', 12, GRAY)", - "add_para(tf3, '\\U0001f4ac \"Run untrusted code safely, at VM speed\"', 20, TEAL, bold=True)", - "", - `pptx_path = os.path.join('${rwDirPy}', 'mxc-nanvix.pptx')`, - "prs.save(pptx_path)", - "size = os.path.getsize(pptx_path)", - "print(f'PPTX created: {size} bytes, 3 slides')", - "print(f'Output: {pptx_path}')", - ].join('\n'), - timeout: 60000, - }, - filesystem: { - readwritePaths: [rwDir], - }, - }; - - const { stdout, stderr, exitCode } = await runMicrovm(config); - const combined = stdout + stderr; - - assert.strictEqual(exitCode, 0, `Expected exit code 0.\nstdout: ${stdout}\nstderr: ${stderr}`); - assert.ok(combined.includes('PPTX created:'), `Expected creation message in output:\n${combined}`); - assert.ok(combined.includes('3 slides'), `Expected 3 slides in output:\n${combined}`); - - // Verify the PPTX was copied back to the host. - const pptxPath = path.join(rwDir, 'mxc-nanvix.pptx'); - assert.ok(fs.existsSync(pptxPath), `Expected mxc-nanvix.pptx at ${pptxPath}`); - const size = fs.statSync(pptxPath).size; - assert.ok(size > 10000, `Expected substantial PPTX from python-pptx, got ${size} bytes`); - - // Verify valid zip with PK header. - const header = Buffer.alloc(4); - const fd = fs.openSync(pptxPath, 'r'); - fs.readSync(fd, header, 0, 4, 0); - fs.closeSync(fd); - assert.strictEqual(header[0], 0x50, 'Expected PK zip header byte 1'); - assert.strictEqual(header[1], 0x4B, 'Expected PK zip header byte 2'); - - console.log(`Dark PPTX output: ${pptxPath} (${size} bytes)`); - } finally { - // Keep output for manual inspection — open in PowerPoint to verify. - console.log(`Dark PPTX test dir persisted at: ${testDir}`); - } - }); -}); diff --git a/sdk/node/tests/integration/test-helpers.ts b/sdk/node/tests/integration/test-helpers.ts index 4e10aac74..751faf4ed 100644 --- a/sdk/node/tests/integration/test-helpers.ts +++ b/sdk/node/tests/integration/test-helpers.ts @@ -79,9 +79,6 @@ export const EXPECTED_MACOS_BINARIES = [ const OPTIONAL_BINARIES = [ 'wslcsdk.dll', // Only built with --with-wslc 'wxc-wslc-daemon.exe', // Only built with --with-wslc - 'nanvixd.exe', // Only built with --with-microvm - 'nanvix_rootfs.img', // Only built with --with-microvm - 'python3.initrd', // Only built with --with-microvm 'plm.exe', // Permissive Learning Mode helper (Windows-only); staged // only when the plm crate is included in the build. // Test-only binaries. The GitHub build artifact carries them so the diff --git a/sdk/node/tests/unit/sandbox.test.ts b/sdk/node/tests/unit/sandbox.test.ts index 4960fbca7..953d6d655 100644 --- a/sdk/node/tests/unit/sandbox.test.ts +++ b/sdk/node/tests/unit/sandbox.test.ts @@ -10,7 +10,13 @@ import { _setBwrapVersionRunner, _setLxcAvailabilityProbe, } from '../../src/platform.js'; -import { ContainerConfig, SandboxPolicy, SandboxingMethod } from '../../src/types.js'; +import { + ContainerConfig, + ContainmentTypes, + ExperimentalBackends, + SandboxPolicy, + SandboxingMethod, +} from '../../src/types.js'; import { MxcError } from '../../src/errors.js'; import { platformSkip } from './test-helpers.js'; @@ -72,6 +78,13 @@ describe('exact-version network authoring', () => { }); }); +describe('containment exports', () => { + it('exposes microvm and not the internal nvx implementation name', () => { + assert.deepStrictEqual(ContainmentTypes, ['process', 'vm', 'microvm']); + assert.deepStrictEqual(ExperimentalBackends, ['microvm', 'windows_sandbox', 'hyperlight']); + }); +}); + describe('buildSandboxPayload', () => { const defaultPolicy: SandboxPolicy = { version: '0.6.0-alpha' }; @@ -236,7 +249,7 @@ describe('buildSandboxPayload', () => { } }); - it('should enforce the minimum schema for every development-only containment', () => { + it('should enforce the minimum schema for each versioned containment', () => { mockWindows(); try { for (const containment of [ @@ -248,7 +261,9 @@ describe('buildSandboxPayload', () => { 'isolation_session', ] as const) { const minimumVersion = - containment === 'isolation_session' || containment === 'wslc' + containment === 'isolation_session' || + containment === 'wslc' || + containment === 'microvm' ? '0.9.0-alpha' : '0.10.0-alpha'; assert.throws( @@ -265,7 +280,7 @@ describe('buildSandboxPayload', () => { } }); - it('should accept each development containment at its exact boundary', () => { + it('should accept each versioned containment at its exact boundary', () => { mockWindows(); try { for (const containment of [ @@ -277,7 +292,9 @@ describe('buildSandboxPayload', () => { 'isolation_session', ] as const) { const version = - containment === 'isolation_session' || containment === 'wslc' + containment === 'isolation_session' || + containment === 'wslc' || + containment === 'microvm' ? '0.9.0-alpha' : '0.10.0-alpha'; try { @@ -286,7 +303,7 @@ describe('buildSandboxPayload', () => { assert.doesNotMatch( (error as Error).message, /Schema .* does not support containment/, - `${containment} must pass the 0.9 schema floor before backend-specific validation`, + `${containment} must pass its ${version} schema floor before backend-specific validation`, ); } } @@ -517,50 +534,6 @@ describe('buildSandboxPayload', () => { } }; - it('should return minimal config for microvm without filesystem', () => { - mockWindows(); - try { - const payload = buildSandboxPayload('print(42)', developmentPolicy, undefined, undefined, 'microvm'); - assert.strictEqual(payload.containment, 'microvm'); - assert.strictEqual(payload.filesystem, undefined); - assert.strictEqual(payload.processContainer, undefined); - } finally { - restore(); - } - }); - - it('should map clearPolicyOnExit to lifecycle.preservePolicy for microvm when policy has paths', () => { - mockWindows(); - try { - const policy: SandboxPolicy = { - version: '0.10.0-alpha', - filesystem: { readwritePaths: ['/tmp'] }, - }; - const payload = buildSandboxPayload('print(42)', policy, undefined, undefined, 'microvm'); - assert.strictEqual(payload.containment, 'microvm'); - assert.deepStrictEqual(payload.filesystem!.readwritePaths, ['/tmp']); - // clearPolicyOnExit is not a wire `filesystem` field; the intent is - // carried canonically by lifecycle.preservePolicy (default clear => not preserved). - assert.strictEqual(payload.lifecycle!.preservePolicy, false); - } finally { - restore(); - } - }); - - it('should honor clearPolicyOnExit false for microvm (via lifecycle.preservePolicy)', () => { - mockWindows(); - try { - const policy: SandboxPolicy = { - version: '0.10.0-alpha', - filesystem: { readwritePaths: ['/tmp'], clearPolicyOnExit: false }, - }; - const payload = buildSandboxPayload('print(42)', policy, undefined, undefined, 'microvm'); - assert.strictEqual(payload.lifecycle!.preservePolicy, true); - } finally { - restore(); - } - }); - it('should build processcontainer config on Windows with default process containment', () => { mockWindows(); try { @@ -576,159 +549,102 @@ describe('buildSandboxPayload', () => { } }); - it('should forward coherent directional network policies for microvm', () => { + it('should build an NVX payload for the native backend', () => { mockWindows(); try { - for (const action of ['allow', 'deny'] as const) { - const policy: SandboxPolicy = { - version: '0.10.0-alpha', - network: { - egress: { default: action }, - ingress: { default: action, hostLoopback: action }, - }, - }; - const payload = buildSandboxPayload( - 'print(42)', - policy, - undefined, - undefined, - 'microvm', - ); - assert.deepStrictEqual(payload.network, policy.network); - } + const policy: SandboxPolicy = { + version: '0.9.0-alpha', + filesystem: { + readonlyPaths: ['C:\\workspace\\source'], + readwritePaths: ['C:\\workspace\\output'], + }, + network: { + egress: { default: 'deny' }, + ingress: { default: 'deny', hostLoopback: 'deny' }, + }, + }; + + const payload = buildSandboxPayload( + 'echo hello', + policy, + '/', + 'microvm-test', + 'microvm', + ); + + assert.strictEqual(payload.containment, 'microvm'); + assert.strictEqual(payload.containerId, 'microvm-test'); + assert.deepStrictEqual(payload.process, { + commandLine: 'echo hello', + timeout: 0, + cwd: '/', + }); + assert.deepStrictEqual(payload.filesystem, { + readonlyPaths: ['C:\\workspace\\source'], + readwritePaths: ['C:\\workspace\\output'], + deniedPaths: [], + }); + assert.deepStrictEqual(payload.network, policy.network); + assert.strictEqual(payload.ui, undefined); + assert.strictEqual(payload.processContainer, undefined); + assert.strictEqual(payload.lxc, undefined); } finally { restore(); } }); - it('should reject unsupported network policies for microvm', () => { + it('should reject ProcessContainer proxy peer policy for MicroVM', () => { mockWindows(); try { assert.throws( () => buildSandboxPayload( - 'print(42)', - { - version: '0.10.0-alpha', - runtimeConfig: { networkProxy: 'http://127.0.0.1:8080' }, - }, - undefined, - undefined, - 'microvm', - ), - { message: /does not support network proxy configuration/ }, - ); - assert.throws( - () => buildSandboxPayload( - 'print(42)', + 'echo hello', { - version: '0.10.0-alpha', - processContainer: { - network: { allowedProxyPeer: 'Contoso.Proxy_123' }, + version: '0.9.0-alpha', + runtimeConfig: { + networkProxy: 'http://127.0.0.1:8080', }, - }, - undefined, - undefined, - 'microvm', - ), - { message: /does not support network proxy configuration/ }, - ); - assert.throws( - () => buildSandboxPayload( - 'print(42)', - { - version: '0.10.0-alpha', - network: { - egress: { - default: 'allow', - allow: [{ to: [{ cidr: '203.0.113.0/24' }] }], + processContainer: { + network: { + allowedProxyPeer: 'Contoso.Proxy_1234567890abc', }, - ingress: { default: 'allow', hostLoopback: 'allow' }, }, }, undefined, undefined, 'microvm', ), - { message: /does not support directional network rules/ }, - ); - assert.throws( - () => buildSandboxPayload( - 'print(42)', - { - version: '0.10.0-alpha', - network: { - egress: { default: 'allow' }, - ingress: { default: 'deny', hostLoopback: 'deny' }, - }, - }, - undefined, - undefined, - 'microvm', - ), - { message: /to be all deny or all allow/ }, + { + message: /processContainer\.network\.allowedProxyPeer is supported only by the Windows ProcessContainer backend/, + }, ); } finally { restore(); } }); - it('should reject ProcessContainer enumeration policy for microvm', () => { + it('should reject UI policy for MicroVM', () => { mockWindows(); try { assert.throws( () => buildSandboxPayload( - 'print(42)', + 'echo hello', { - version: '0.10.0-alpha', - processContainer: { - filesystem: { enumeratePaths: ['C:\\tools'] }, + version: '0.9.0-alpha', + ui: { + allowWindows: false, + clipboard: 'none', + allowInputInjection: false, }, }, undefined, undefined, 'microvm', ), - { message: /does not support processContainer\.filesystem\.enumeratePaths/ }, - ); - } finally { - restore(); - } - }); - - it('should reject microvm on non-Windows platforms', () => { - const orig = Object.getOwnPropertyDescriptor(process, 'platform'); - Object.defineProperty(process, 'platform', { value: 'linux' }); - try { - assert.throws( - () => buildSandboxPayload('print(42)', developmentPolicy, undefined, undefined, 'microvm'), - { message: /only supported on Windows/ }, + { + message: /SandboxPolicy\.ui is not supported by the MicroVM backend/, + }, ); - } finally { - if (orig) Object.defineProperty(process, 'platform', orig); - } - }); - - it('should preserve lifecycle config for microvm', () => { - mockWindows(); - try { - const policy: SandboxPolicy = { - version: '0.10.0-alpha', - filesystem: { clearPolicyOnExit: false }, - }; - const payload = buildSandboxPayload('print(42)', policy, undefined, undefined, 'microvm'); - assert.strictEqual(payload.lifecycle!.destroyOnExit, true); - assert.strictEqual(payload.lifecycle!.preservePolicy, true); - } finally { - restore(); - } - }); - - it('should set process commandLine and containerId for microvm', () => { - mockWindows(); - try { - const payload = buildSandboxPayload('print(42)', developmentPolicy, undefined, 'my-container', 'microvm'); - assert.strictEqual(payload.process!.commandLine, 'print(42)'); - assert.strictEqual(payload.containerId, 'my-container'); } finally { restore(); } @@ -2104,9 +2020,11 @@ describe('resolveExecutableAndArgs (containment validation)', { skip: platformSk function makeConfig(containment: string): ContainerConfig { const version = - containment === 'isolation_session' || containment === 'wslc' + containment === 'isolation_session' || + containment === 'wslc' || + containment === 'microvm' ? '0.9.0-alpha' - : ['microvm', 'vm', 'hyperlight', 'windows_sandbox'].includes(containment) + : ['vm', 'hyperlight', 'windows_sandbox'].includes(containment) ? '0.10.0-alpha' : ['seatbelt', 'macos_sandbox'].includes(containment) ? '0.7.0-alpha' @@ -2127,9 +2045,19 @@ describe('resolveExecutableAndArgs (containment validation)', { skip: platformSk ); }); - it('should accept the abstract intent "microvm" with experimental flag (Windows only)', function (this: { skip: (reason?: string) => void }) { - if (process.platform !== 'win32') { - this.skip('microvm is Windows-only'); + it('should reject the internal nvx implementation name', () => { + assert.throws( + () => resolveExecutableAndArgs(makeConfig('nvx'), { + executablePath: fakeExe, + experimental: true, + }), + { message: /nvx.*not available/i }, + ); + }); + + it('should accept microvm with experimental mode on Windows x64', function (this: { skip: (reason?: string) => void }) { + if (process.platform !== 'win32' || process.arch !== 'x64') { + this.skip('microvm is Windows x64-only'); return; } assert.doesNotThrow(() => diff --git a/src/Cargo.lock b/src/Cargo.lock index 9bc918aee..a200f5960 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1374,8 +1374,6 @@ dependencies = [ "lxc_common", "mxc_build_common", "mxc_engine", - "nanvix_binaries", - "nanvix_build_common", "wxc_common", ] @@ -1526,7 +1524,7 @@ dependencies = [ "learning_mode_core", "lxc_common", "mxc_config_contract", - "nanvix_runner", + "nvx_runner", "plm", "process_container_common", "seatbelt_common", @@ -1587,40 +1585,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "nanvix_binaries" -version = "0.8.0" -dependencies = [ - "nanvix_build_common", - "nanvix_common", -] - -[[package]] -name = "nanvix_build_common" -version = "0.8.0" -dependencies = [ - "nanvix_common", -] - -[[package]] -name = "nanvix_common" -version = "0.8.0" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "nanvix_runner" -version = "0.8.0" -dependencies = [ - "libc", - "nanvix_common", - "uuid", - "windows", - "wxc_common", -] - [[package]] name = "nix" version = "0.29.0" @@ -1664,6 +1628,40 @@ dependencies = [ "syn", ] +[[package]] +name = "nvx_binaries" +version = "0.8.0" +dependencies = [ + "nvx_build_common", + "nvx_common", + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "nvx_build_common" +version = "0.8.0" +dependencies = [ + "nvx_common", + "tempfile", +] + +[[package]] +name = "nvx_common" +version = "0.8.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "nvx_runner" +version = "0.8.0" +dependencies = [ + "nvx_binaries", + "wxc_common", +] + [[package]] name = "oci-spec" version = "0.10.0" @@ -3191,8 +3189,8 @@ dependencies = [ "learning_mode_core", "mxc_build_common", "mxc_engine", - "nanvix_binaries", - "nanvix_build_common", + "nvx_binaries", + "nvx_build_common", "plm", "process_container_common", "serde_json", @@ -3213,7 +3211,6 @@ dependencies = [ "libc", "mxc_config_contract", "mxc_telemetry", - "nanvix_common", "serde", "serde_json", "serde_path_to_error", @@ -3222,7 +3219,6 @@ dependencies = [ "thiserror", "unicode-general-category", "url", - "uuid", "widestring", "windows", "windows-core", diff --git a/src/Cargo.toml b/src/Cargo.toml index ab5b51662..b0afe9546 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -6,10 +6,10 @@ members = [ "backends/isolation_session/bindings", "backends/isolation_session/common", "backends/lxc/common", - "backends/nanvix/binaries", - "backends/nanvix/build_common", - "backends/nanvix/common", - "backends/nanvix/runner", + "backends/nvx/binaries", + "backends/nvx/build_common", + "backends/nvx/common", + "backends/nvx/runner", "backends/seatbelt/common", "backends/windows_sandbox/common", "backends/windows_sandbox/daemon", @@ -85,8 +85,11 @@ mxc_pty = { path = "core/mxc_pty" } mxc_schema_support = { path = "core/mxc_schema_support" } mxc-sdk = { path = "core/mxc-sdk" } mxc_telemetry = { path = "mxc_telemetry" } -nanvix_runner = { path = "backends/nanvix/runner" } nix = { version = "0.29", features = ["fs", "mount", "sched", "signal", "net", "process", "user", "term"] } +nvx_binaries = { path = "backends/nvx/binaries" } +nvx_build_common = { path = "backends/nvx/build_common" } +nvx_common = { path = "backends/nvx/common" } +nvx_runner = { path = "backends/nvx/runner" } process_security_environment_spec = { path = "core/generated/process_security_environment_specification" } plm = { path = "host/plm" } quick-xml = "0.41" diff --git a/src/backends/nanvix/binaries/Cargo.toml b/src/backends/nanvix/binaries/Cargo.toml deleted file mode 100644 index 8b71c06e3..000000000 --- a/src/backends/nanvix/binaries/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "nanvix_binaries" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Build-time download of NanVix binaries from GitHub releases" -links = "nanvix_binaries" - -[lib] -path = "src/lib.rs" - -[features] -# Gates the build script's expensive work (downloading NanVix release assets -# and verifying their checksums). OFF by default so a plain `cargo build` — -# which still compiles this crate as a workspace member — performs no network -# or hashing work. Enabled transitively via the `microvm` feature of `wxc` and -# `lxc`. -microvm = [] - -[dependencies] -nanvix_common = { path = "../common" } - -[build-dependencies] -nanvix_common = { path = "../common" } -nanvix_build_common = { path = "../build_common" } diff --git a/src/backends/nanvix/binaries/build.rs b/src/backends/nanvix/binaries/build.rs deleted file mode 100644 index c1bb66955..000000000 --- a/src/backends/nanvix/binaries/build.rs +++ /dev/null @@ -1,837 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Build script that downloads NanVix binaries from GitHub releases. -//! -//! Uses system tools (`curl.exe`, `tar.exe`, `certutil`) instead of Rust -//! crates. Zero HTTP/zip/crypto build-dependencies — only `nanvix_common` -//! for shared constants and serde-based config parsing. -//! -//! ## Configuration files -//! -//! - `versions.json` — pinned release tags and exact asset names -//! - `checksums.json` — SHA256 hashes for integrity verification -//! -//! ## Environment variables -//! -//! - `GITHUB_TOKEN` / `GH_TOKEN` — optional; increases API rate limit -//! - `NANVIX_BIN` — optional; path to a directory of pre-fetched NanVix -//! binaries. When set, the build uses that directory directly and performs -//! no network downloads, enabling fully offline builds where all dynamic -//! build inputs are pre-fetched. The directory must already contain the -//! required binaries (flat files plus the `bin/` subdirectory); checksums -//! are still verified against `checksums.json`. -//! -//! ## Caching -//! -//! Binaries are cached in OUT_DIR (or read from `NANVIX_BIN` when set). -//! Checksums are verified whenever this build script runs (triggered by -//! changes to build.rs, versions.json, or checksums.json) to catch corrupted -//! or truncated files. -//! -//! # TODO(security): NanVix binaries are not ESRP-signed. Before shipping in -//! # official MXC releases, either extend ESRP to cover these binaries or -//! # establish an internal mirror with supply-chain controls. - -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use nanvix_common::{github_download_url, load_checksums, load_json, ReleaseConfig, RepoConfig}; - -fn main() { - // The build script's output (`NANVIX_BIN_DIR` and whether the download / - // verify path runs) depends on the `microvm` feature, surfaced here as the - // `CARGO_FEATURE_MICROVM` env var. Declare it as a rerun trigger so toggling - // the feature between builds re-runs this script instead of reusing stale - // output. - println!("cargo:rerun-if-env-changed=CARGO_FEATURE_MICROVM"); - - // The expensive work in this build script — downloading NanVix release - // assets and verifying their checksums via `certutil` — is only needed - // when the micro-VM backend is actually being built. Gate it behind this - // crate's `microvm` feature so that a default `cargo build` (which still - // compiles this crate as a workspace member) performs no network or hashing - // work. `wxc` and `lxc` enable `nanvix_binaries/microvm` through their own - // `microvm` features. - // - // `NANVIX_BIN_DIR` must still be emitted in every configuration because - // `lib.rs` references it via `env!`. - if std::env::var_os("CARGO_FEATURE_MICROVM").is_none() { - let out_dir = std::env::var("OUT_DIR").unwrap(); - println!("cargo:rustc-env=NANVIX_BIN_DIR={}", out_dir); - println!("cargo:rerun-if-changed=build.rs"); - return; - } - - // Check the TARGET platform (not host). NanVix binaries are only needed when - // the output binary will run on Windows or Linux with KVM. - let target = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); - if target != "windows" && target != "linux" { - let out_dir = std::env::var("OUT_DIR").unwrap(); - println!("cargo:rustc-env=NANVIX_BIN_DIR={}", out_dir); - println!("cargo:rerun-if-changed=build.rs"); - return; - } - - let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); - - // When `NANVIX_BIN` is set, use the caller-provided directory of - // pre-fetched binaries and skip all network downloads (offline builds); - // otherwise download into a subdirectory of OUT_DIR as before. The - // resolution lives in the build-only `nanvix_build_common` crate. - let (bin_dir, use_prefetched_binaries) = nanvix_build_common::resolve_bin_dir(&out_dir); - - let versions: ReleaseConfig = load_json("versions.json"); - let checksums: HashMap = load_checksums("checksums.json", &target); - - if target == "linux" { - // Linux: download tar.gz and extract Linux binaries. - let asset = versions - .nanvix_python - .asset_linux - .as_deref() - .unwrap_or("microvm-standalone-256mb.tar.gz"); - let binaries: Vec<&str> = versions - .nanvix_python - .binaries_linux - .as_ref() - .map(|v| v.iter().map(|s| s.as_str()).collect()) - .unwrap_or_else(|| nanvix_common::REQUIRED_BINARIES.to_vec()); - - let needs_download = - !use_prefetched_binaries && needs_download_linux(&binaries, &bin_dir, &checksums); - if needs_download { - eprintln!( - "nanvix_binaries: downloading nanvix/nanvix-python {} (Linux)...", - versions.nanvix_python.tag - ); - download_and_extract_linux(&versions.nanvix_python.tag, asset, &binaries, &bin_dir); - } else if use_prefetched_binaries { - eprintln!( - "nanvix_binaries: offline — verifying pre-fetched Linux binaries in '{}'", - bin_dir.display() - ); - } else { - eprintln!("nanvix_binaries: all Linux binaries cached and verified"); - } - - verify_checksums_linux(&binaries, &bin_dir, &checksums); - verify_bin_subdir_checksums_linux(&bin_dir, &checksums); - - if use_prefetched_binaries { - nanvix_build_common::emit_rerun_for_copied_artifacts(&bin_dir); - } - } else { - // Windows: original logic - let all_binaries: Vec<&str> = versions - .nanvix_python - .binaries - .iter() - .map(|s| s.as_str()) - .collect(); - - let needs_nanvix_python = !use_prefetched_binaries - && needs_download(&versions.nanvix_python, &bin_dir, &checksums); - - if needs_nanvix_python { - eprintln!( - "nanvix_binaries: downloading nanvix/nanvix-python {}...", - versions.nanvix_python.tag - ); - download_and_extract(&versions.nanvix_python, "nanvix/nanvix-python", &bin_dir); - } else if use_prefetched_binaries { - eprintln!( - "nanvix_binaries: offline — verifying pre-fetched binaries in '{}'", - bin_dir.display() - ); - } else { - eprintln!("nanvix_binaries: all binaries cached and verified"); - } - - verify_checksums(&all_binaries, &bin_dir, &checksums); - verify_bin_subdir_checksums(&bin_dir, &checksums); - - if use_prefetched_binaries { - nanvix_build_common::emit_rerun_for_copied_artifacts(&bin_dir); - } - - // Generate host-local WHP snapshots at build time so even the first - // runtime execution uses warm start. The runtime fallback in - // nanvix_runner.rs handles the case where snapshots are missing. - // - // Skip on non-x86_64 hosts: `nanvixd.exe` is an x86_64 Windows binary - // and launching it on (e.g.) ARM64 Windows fails with - // STATUS_INVALID_IMAGE_FORMAT (0xc000007b). Snapshot pre-generation is - // a warm-start cache only — the runtime fallback covers cold boot on - // hosts where this build step is skipped. - let host = std::env::var("HOST").unwrap_or_default(); - let host_is_x86_64 = host.starts_with("x86_64-"); - if !host_is_x86_64 { - eprintln!( - "nanvix_binaries: skipping host-local snapshot generation \ - (host '{}' is not x86_64; nanvixd.exe cannot run here). \ - Runtime will cold-boot on first use.", - host - ); - } else { - let snapshots_dir = bin_dir.join(nanvix_common::SNAPSHOTS_SUBDIR); - let snapshots_present = nanvix_common::SNAPSHOT_FILES - .iter() - .all(|name| snapshots_dir.join(name).exists()); - if snapshots_present { - eprintln!("nanvix_binaries: host-local snapshots already present"); - } else if use_prefetched_binaries { - // In offline mode the NANVIX_BIN directory is treated as an - // immutable, pre-fetched input — it may be a read-only or - // shared cache. Do not run nanvixd.exe to generate snapshots - // into it. The runtime fallback in nanvix_runner.rs cold-boots - // when snapshots are absent. - eprintln!( - "nanvix_binaries: offline — snapshots absent in NANVIX_BIN; \ - skipping generation (runtime will cold-boot on first use)." - ); - } else { - fs::create_dir_all(&snapshots_dir).expect("failed to create snapshots dir"); - eprintln!("nanvix_binaries: generating host-local snapshots (cold boot)..."); - generate_snapshots_locally(&bin_dir); - } - } - } - - println!("cargo:rustc-env=NANVIX_BIN_DIR={}", bin_dir.display()); - println!("cargo:BIN_DIR={}", bin_dir.display()); - // Propagate whether these binaries came from an externally supplied - // (prefetched) directory. Consumers read this as - // `DEP_NANVIX_BINARIES_PREFETCHED` and must not trust prefetched WHP - // snapshots (they are not covered by checksums.json). - println!( - "cargo:PREFETCHED={}", - if use_prefetched_binaries { "1" } else { "0" } - ); - println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-changed=versions.json"); - println!("cargo:rerun-if-changed=checksums.json"); - println!("cargo:rerun-if-env-changed=GITHUB_TOKEN"); - println!("cargo:rerun-if-env-changed=GH_TOKEN"); - println!("cargo:rerun-if-env-changed=NANVIX_BIN"); -} - -// -- Download logic ---------------------------------------------------------- - -fn needs_download( - config: &RepoConfig, - bin_dir: &Path, - checksums: &HashMap, -) -> bool { - // Check flat binaries. - let flat_missing = config.binaries.iter().any(|name| { - let path = bin_dir.join(name); - if !path.exists() { - return true; - } - if let Some(expected) = checksums.get(name.as_str()) { - certutil_sha256(&path) != *expected - } else { - false - } - }); - if flat_missing { - return true; - } - - // Check bin/ subdir files. - let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR); - for name in nanvix_common::BIN_SUBDIR_FILES { - let path = bin_subdir.join(name); - if !path.exists() { - return true; - } - if let Some(expected) = checksums.get(*name) { - if certutil_sha256(&path) != *expected { - return true; - } - } - } - - false -} - -fn download_and_extract(config: &RepoConfig, repo: &str, bin_dir: &Path) { - let url = github_download_url(repo, &config.tag, &config.asset); - let zip_path = bin_dir.join(&config.asset); - - // Cleanup helper: remove zip on failure. - let cleanup = |zip: &Path| { - let _ = fs::remove_file(zip); - }; - - eprintln!(" downloading {}...", config.asset); - if let Err(msg) = try_curl_download(&url, &zip_path) { - cleanup(&zip_path); - panic!("nanvix_binaries: {}", msg); - } - - let size = zip_path.metadata().map(|m| m.len()).unwrap_or(0); - eprintln!(" downloaded {} bytes, extracting...", size); - - let binaries: Vec<&str> = config.binaries.iter().map(|s| s.as_str()).collect(); - - // Extract flat binaries (nanvixd.exe from bin/, rootfs + initrd from root). - if let Err(msg) = try_tar_extract(&zip_path, bin_dir, &binaries) { - cleanup(&zip_path); - panic!("nanvix_binaries: {}", msg); - } - - // Extract bin/ subdir files (kernel.elf stays in bin/ as nanvixd expects). - let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR); - fs::create_dir_all(&bin_subdir).expect("failed to create bin subdir"); - if let Err(msg) = try_tar_extract_bin_subdir(&zip_path, &bin_subdir) { - cleanup(&zip_path); - panic!("nanvix_binaries: {}", msg); - } - - let _ = fs::remove_file(&zip_path); -} - -// -- Snapshot generation ----------------------------------------------------- - -fn generate_snapshots_locally(bin_dir: &Path) { - let nanvixd = bin_dir.join("nanvixd.exe"); - let ramfs = bin_dir.join("nanvix_rootfs.img"); - let initrd = bin_dir.join("python3.initrd"); - let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR); - - if !nanvixd.exists() || !ramfs.exists() || !initrd.exists() { - panic!( - "nanvix_binaries: cannot generate snapshots — required binaries missing:\n\ - \x20 nanvixd.exe: {}\n\ - \x20 nanvix_rootfs.img: {}\n\ - \x20 python3.initrd: {}", - nanvixd.exists(), - ramfs.exists(), - initrd.exists() - ); - } - - nanvix_common::generate_snapshot(bin_dir, &nanvixd, &bin_subdir, &ramfs, &initrd) - .unwrap_or_else(|e| panic!("nanvix_binaries: {}", e)); - - // Log generated file sizes. - let snapshots_dir = bin_dir.join(nanvix_common::SNAPSHOTS_SUBDIR); - for name in nanvix_common::SNAPSHOT_FILES { - let path = snapshots_dir.join(name); - let size = path.metadata().map(|m| m.len()).unwrap_or(0); - eprintln!(" snapshots/{} -- generated ({} bytes)", name, size); - } -} - -// -- curl.exe ---------------------------------------------------------------- - -fn try_curl_download(url: &str, dest: &Path) -> Result<(), String> { - let mut cmd = Command::new("curl"); - cmd.args([ - "--silent", - "--show-error", - "--fail", - "--location", - "--retry", - "5", - "--retry-delay", - "5", - "--retry-all-errors", - "--output", - ]); - cmd.arg(dest); - cmd.args(["--header", "User-Agent: mxc-nanvix-build/0.1"]); - - if let Some(token) = github_token() { - cmd.arg("--header"); - cmd.arg(format!("Authorization: Bearer {}", token)); - } - - cmd.arg(url); - - let output = cmd.output().map_err(|e| { - format!( - "curl not found: {}\n\ - Ensure curl is in PATH.", - e - ) - })?; - - if !output.status.success() { - return Err(format!( - "curl failed for {}\n exit code: {}\n stderr: {}", - url, - output.status, - String::from_utf8_lossy(&output.stderr) - )); - } - - Ok(()) -} - -// -- tar.exe ----------------------------------------------------------------- - -fn try_tar_extract(zip_path: &Path, dest_dir: &Path, files: &[&str]) -> Result<(), String> { - // The nanvix-python zip has a top-level directory with two sub-layouts: - // bin/nanvixd.exe → strip 2 components - // nanvix_rootfs.img, python3.initrd → strip 1 component - - const ARCHIVE_PREFIX: &str = "microvm-standalone-256mb"; - const BIN_DIR_FILES: &[&str] = &["nanvixd.exe"]; - - let (bin_files, root_files): (Vec<&&str>, Vec<&&str>) = - files.iter().partition(|f| BIN_DIR_FILES.contains(f)); - - // Pass 1: files under /bin/ — strip 2 path components. - if !bin_files.is_empty() { - let mut cmd = Command::new("tar"); - cmd.arg("-xf").arg(zip_path).arg("-C").arg(dest_dir); - cmd.args(["--strip-components", "2"]); - for f in &bin_files { - cmd.arg(format!("{}/bin/{}", ARCHIVE_PREFIX, f)); - } - let output = cmd.output().map_err(|e| { - format!( - "tar.exe not found: {}\n\ - tar.exe ships with Windows 10 1803+. Ensure it's in PATH.", - e - ) - })?; - if !output.status.success() { - return Err(format!( - "tar extraction failed (bin files)\n exit code: {}\n stderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr) - )); - } - } - - // Pass 2: files at / root — strip 1 path component. - if !root_files.is_empty() { - let mut cmd = Command::new("tar"); - cmd.arg("-xf").arg(zip_path).arg("-C").arg(dest_dir); - cmd.args(["--strip-components", "1"]); - for f in &root_files { - cmd.arg(format!("{}/{}", ARCHIVE_PREFIX, f)); - } - let output = cmd.output().map_err(|e| { - format!( - "tar.exe not found: {}\n\ - tar.exe ships with Windows 10 1803+. Ensure it's in PATH.", - e - ) - })?; - if !output.status.success() { - return Err(format!( - "tar extraction failed (root files)\n exit code: {}\n stderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr) - )); - } - } - - for f in files { - let path = dest_dir.join(f); - if path.exists() { - let size = path.metadata().map(|m| m.len()).unwrap_or(0); - eprintln!(" {} -- extracted ({} bytes)", f, size); - } else { - return Err(format!("'{}' not found in zip after extraction", f)); - } - } - - Ok(()) -} - -fn try_tar_extract_bin_subdir(zip_path: &Path, dest_dir: &Path) -> Result<(), String> { - const ARCHIVE_PREFIX: &str = "microvm-standalone-256mb"; - - for name in nanvix_common::BIN_SUBDIR_FILES { - let mut cmd = Command::new("tar"); - cmd.arg("-xf").arg(zip_path).arg("-C").arg(dest_dir); - cmd.args(["--strip-components", "2"]); - cmd.arg(format!("{}/bin/{}", ARCHIVE_PREFIX, name)); - let output = cmd - .output() - .map_err(|e| format!("tar.exe not found: {}", e))?; - if !output.status.success() { - return Err(format!( - "tar extraction failed (bin/{})\n exit code: {}\n stderr: {}", - name, - output.status, - String::from_utf8_lossy(&output.stderr) - )); - } - let path = dest_dir.join(name); - if path.exists() { - let size = path.metadata().map(|m| m.len()).unwrap_or(0); - eprintln!(" bin/{} -- extracted ({} bytes)", name, size); - } else { - return Err(format!("'bin/{}' not found in zip after extraction", name)); - } - } - - Ok(()) -} - -// -- Helpers ----------------------------------------------------------------- - -fn github_token() -> Option { - std::env::var("GITHUB_TOKEN") - .or_else(|_| std::env::var("GH_TOKEN")) - .ok() -} - -// -- certutil SHA256 --------------------------------------------------------- - -fn certutil_sha256(path: &Path) -> String { - let output = Command::new("certutil") - .args(["-hashfile"]) - .arg(path) - .arg("SHA256") - .output() - .unwrap_or_else(|e| { - panic!("nanvix_binaries: failed to run certutil: {}", e); - }); - - if !output.status.success() { - panic!( - "nanvix_binaries: certutil -hashfile failed for {}: {}", - path.display(), - String::from_utf8_lossy(&output.stderr) - ); - } - - // certutil output format: - // SHA256 hash of : - // - // CertUtil: -hashfile command completed successfully. - // - // Use a lossy conversion because the localized header/footer lines are - // emitted in the console's OEM code page (e.g. CP850 on French Windows), - // not UTF-8 -- a strict `from_utf8` would panic on those bytes. The hash - // line itself is pure ASCII hex, so it survives the lossy conversion. We - // locate the hash by scanning for a 64-character hex line rather than - // relying on a fixed line index, which keeps this locale-independent. - let stdout = String::from_utf8_lossy(&output.stdout); - stdout - .lines() - .map(|line| line.trim().replace(' ', "").to_lowercase()) - .find(|line| line.len() == 64 && line.bytes().all(|b| b.is_ascii_hexdigit())) - .unwrap_or_else(|| panic!("nanvix_binaries: unexpected certutil output: {}", stdout)) -} - -fn verify_checksums(binaries: &[&str], bin_dir: &Path, checksums: &HashMap) { - for name in binaries { - let path = bin_dir.join(name); - if !path.exists() { - panic!("nanvix_binaries: {} not found after download/extract", name); - } - - if let Some(expected) = checksums.get(*name) { - let actual = certutil_sha256(&path); - if actual != *expected { - panic!( - "nanvix_binaries: SHA256 mismatch for '{}'!\n\ - \x20 expected: {}\n\ - \x20 actual: {}\n\ - This may indicate a corrupted download or a NanVix version update.\n\ - Update checksums.json with the new hashes.", - name, expected, actual - ); - } - eprintln!(" {} -- checksum OK", name); - } else { - panic!( - "nanvix_binaries: '{}' has no entry in checksums.json — \ - every binary must be hash-verified", - name - ); - } - } -} - -fn verify_bin_subdir_checksums(bin_dir: &Path, checksums: &HashMap) { - let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR); - for name in nanvix_common::BIN_SUBDIR_FILES { - let path = bin_subdir.join(name); - if !path.exists() { - panic!( - "nanvix_binaries: bin/{} not found after download/extract", - name - ); - } - - if let Some(expected) = checksums.get(*name) { - let actual = certutil_sha256(&path); - if actual != *expected { - panic!( - "nanvix_binaries: SHA256 mismatch for 'bin/{}'!\n\ - \x20 expected: {}\n\ - \x20 actual: {}\n\ - Update checksums.json with the new hashes.", - name, expected, actual - ); - } - eprintln!(" bin/{} -- checksum OK", name); - } else { - panic!( - "nanvix_binaries: 'bin/{}' has no entry in checksums.json — \ - every binary must be hash-verified", - name - ); - } - } -} - -// -- Linux-specific functions ------------------------------------------------ - -fn needs_download_linux( - binaries: &[&str], - bin_dir: &Path, - checksums: &HashMap, -) -> bool { - // Check flat binaries. - for name in binaries { - let path = bin_dir.join(name); - if !path.exists() { - return true; - } - if let Some(expected) = checksums.get(*name) { - if sha256sum(&path) != *expected { - return true; - } - } - } - - // Check bin/ subdir files. - let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR); - for name in nanvix_common::BIN_SUBDIR_FILES { - let path = bin_subdir.join(name); - if !path.exists() { - return true; - } - if let Some(expected) = checksums.get(*name) { - if sha256sum(&path) != *expected { - return true; - } - } - } - - false -} - -fn download_and_extract_linux(tag: &str, asset: &str, binaries: &[&str], bin_dir: &Path) { - let url = github_download_url("nanvix/nanvix-python", tag, asset); - let tar_path = bin_dir.join(asset); - - let cleanup = |p: &Path| { - let _ = fs::remove_file(p); - }; - - eprintln!(" downloading {}...", asset); - if let Err(msg) = try_curl_download(&url, &tar_path) { - cleanup(&tar_path); - panic!("nanvix_binaries: {}", msg); - } - - let size = tar_path.metadata().map(|m| m.len()).unwrap_or(0); - eprintln!(" downloaded {} bytes, extracting...", size); - - // Extract flat binaries from / using tar on Linux. - if let Err(msg) = try_tar_extract_linux(&tar_path, bin_dir, binaries) { - cleanup(&tar_path); - panic!("nanvix_binaries: {}", msg); - } - - // Extract bin/ subdir files (kernel.elf, nanvixd.elf). - let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR); - fs::create_dir_all(&bin_subdir).expect("failed to create bin subdir"); - if let Err(msg) = try_tar_extract_bin_subdir_linux(&tar_path, &bin_subdir) { - cleanup(&tar_path); - panic!("nanvix_binaries: {}", msg); - } - - let _ = fs::remove_file(&tar_path); -} - -fn try_tar_extract_linux(tar_path: &Path, dest_dir: &Path, files: &[&str]) -> Result<(), String> { - const ARCHIVE_PREFIX: &str = "microvm-standalone-256mb"; - - // nanvixd.elf lives under /bin/, other flat binaries under /. - let bin_dir_files: &[&str] = &["nanvixd.elf"]; - let (bin_files, root_files): (Vec<&&str>, Vec<&&str>) = - files.iter().partition(|f| bin_dir_files.contains(f)); - - // Pass 1: files under /bin/ — strip 2 path components. - if !bin_files.is_empty() { - let mut cmd = Command::new("tar"); - cmd.arg("-xzf").arg(tar_path).arg("-C").arg(dest_dir); - cmd.args(["--strip-components", "2"]); - for f in &bin_files { - cmd.arg(format!("{}/bin/{}", ARCHIVE_PREFIX, f)); - } - let output = cmd.output().map_err(|e| format!("tar not found: {}", e))?; - if !output.status.success() { - return Err(format!( - "tar extraction failed (bin files)\n exit code: {}\n stderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr) - )); - } - } - - // Pass 2: files at / root — strip 1 path component. - if !root_files.is_empty() { - let mut cmd = Command::new("tar"); - cmd.arg("-xzf").arg(tar_path).arg("-C").arg(dest_dir); - cmd.args(["--strip-components", "1"]); - for f in &root_files { - cmd.arg(format!("{}/{}", ARCHIVE_PREFIX, f)); - } - let output = cmd.output().map_err(|e| format!("tar not found: {}", e))?; - if !output.status.success() { - return Err(format!( - "tar extraction failed (root files)\n exit code: {}\n stderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr) - )); - } - } - - for f in files { - let path = dest_dir.join(f); - if path.exists() { - let size = path.metadata().map(|m| m.len()).unwrap_or(0); - eprintln!(" {} -- extracted ({} bytes)", f, size); - } else { - return Err(format!("'{}' not found in archive after extraction", f)); - } - } - - Ok(()) -} - -fn try_tar_extract_bin_subdir_linux(tar_path: &Path, dest_dir: &Path) -> Result<(), String> { - const ARCHIVE_PREFIX: &str = "microvm-standalone-256mb"; - - for name in nanvix_common::BIN_SUBDIR_FILES { - let mut cmd = Command::new("tar"); - cmd.arg("-xzf").arg(tar_path).arg("-C").arg(dest_dir); - cmd.args(["--strip-components", "2"]); - cmd.arg(format!("{}/bin/{}", ARCHIVE_PREFIX, name)); - let output = cmd.output().map_err(|e| format!("tar not found: {}", e))?; - if !output.status.success() { - return Err(format!( - "tar extraction failed (bin/{})\n exit code: {}\n stderr: {}", - name, - output.status, - String::from_utf8_lossy(&output.stderr) - )); - } - let path = dest_dir.join(name); - if path.exists() { - let size = path.metadata().map(|m| m.len()).unwrap_or(0); - eprintln!(" bin/{} -- extracted ({} bytes)", name, size); - } else { - return Err(format!( - "'bin/{}' not found in archive after extraction", - name - )); - } - } - - Ok(()) -} - -/// Compute SHA256 hash using the `sha256sum` command (Linux). -fn sha256sum(path: &Path) -> String { - let output = Command::new("sha256sum") - .arg(path) - .output() - .unwrap_or_else(|e| { - panic!("nanvix_binaries: failed to run sha256sum: {}", e); - }); - - if !output.status.success() { - panic!( - "nanvix_binaries: sha256sum failed for {}: {}", - path.display(), - String::from_utf8_lossy(&output.stderr) - ); - } - - let stdout = String::from_utf8(output.stdout).expect("sha256sum output not UTF-8"); - stdout - .split_whitespace() - .next() - .unwrap_or_else(|| panic!("nanvix_binaries: unexpected sha256sum output: {}", stdout)) - .to_lowercase() -} - -fn verify_checksums_linux(binaries: &[&str], bin_dir: &Path, checksums: &HashMap) { - for name in binaries { - let path = bin_dir.join(name); - if !path.exists() { - panic!("nanvix_binaries: {} not found after download/extract", name); - } - - if let Some(expected) = checksums.get(*name) { - let actual = sha256sum(&path); - if actual != *expected { - panic!( - "nanvix_binaries: SHA256 mismatch for '{}'!\n\ - \x20 expected: {}\n\ - \x20 actual: {}\n\ - This may indicate a corrupted download or a NanVix version update.\n\ - Update checksums.json with the new hashes.", - name, expected, actual - ); - } - eprintln!(" {} -- checksum OK", name); - } else { - panic!( - "nanvix_binaries: '{}' has no entry in checksums.json — \ - every binary must be hash-verified", - name - ); - } - } -} - -fn verify_bin_subdir_checksums_linux(bin_dir: &Path, checksums: &HashMap) { - let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR); - for name in nanvix_common::BIN_SUBDIR_FILES { - let path = bin_subdir.join(name); - if !path.exists() { - panic!( - "nanvix_binaries: bin/{} not found after download/extract", - name - ); - } - - if let Some(expected) = checksums.get(*name) { - let actual = sha256sum(&path); - if actual != *expected { - panic!( - "nanvix_binaries: SHA256 mismatch for 'bin/{}'!\n\ - \x20 expected: {}\n\ - \x20 actual: {}\n\ - Update checksums.json with the new hashes.", - name, expected, actual - ); - } - eprintln!(" bin/{} -- checksum OK", name); - } else { - panic!( - "nanvix_binaries: 'bin/{}' has no entry in checksums.json — \ - every binary must be hash-verified", - name - ); - } - } -} diff --git a/src/backends/nanvix/binaries/checksums.json b/src/backends/nanvix/binaries/checksums.json deleted file mode 100644 index 68d5fb082..000000000 --- a/src/backends/nanvix/binaries/checksums.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "windows": { - "nanvixd.exe": "1c6f985da5f7bd914e737a7bff01d3589bdf23912258a5d27284c2ccf36bbe89", - "nanvix_rootfs.img": "a1e34f4658c4740351fca0147d6061d75ff67e29a586be1accf3bde96753b036", - "python3.initrd": "b5f545510e3a5f94b39632f38493e20fa938a7a649a8d9d18b62eabc16386686", - "kernel.elf": "49c9a1a184b1fec4a437488b3e8145ed1247fa5692799c8eb8499531d6f98c7a" - }, - "linux": { - "nanvixd.elf": "49de37a6002fd88acf890eaef1b7a5fc8259ff1469a635261948e386b4012a8e", - "nanvix_rootfs.img": "a1e34f4658c4740351fca0147d6061d75ff67e29a586be1accf3bde96753b036", - "python3.initrd": "b5f545510e3a5f94b39632f38493e20fa938a7a649a8d9d18b62eabc16386686", - "kernel.elf": "dff28c4668083e7b0148d8d66b8e2a72ba6d9d2515562931bb062f0a74c32882" - } -} diff --git a/src/backends/nanvix/binaries/src/lib.rs b/src/backends/nanvix/binaries/src/lib.rs deleted file mode 100644 index 50b725f6b..000000000 --- a/src/backends/nanvix/binaries/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -/// Path to the directory containing downloaded NanVix binaries. -/// -/// Set by build.rs via `cargo:rustc-env`. This points to the build-time -/// OUT_DIR and is used by wxc/build.rs to copy binaries next to the final -/// executable. At runtime, the NanVix runner discovers binaries via -/// `std::env::current_exe()` — it does NOT use this constant. -pub const NANVIX_BIN_DIR: &str = env!("NANVIX_BIN_DIR"); - -// Re-export shared constants from nanvix_common. -pub use nanvix_common::REQUIRED_BINARIES; diff --git a/src/backends/nanvix/binaries/versions.json b/src/backends/nanvix/binaries/versions.json deleted file mode 100644 index bead419bd..000000000 --- a/src/backends/nanvix/binaries/versions.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "nanvix_python": { - "tag": "3.12.3-nanvix-0.16.65-fce3620", - "asset": "microvm-standalone-256mb.zip", - "asset_linux": "microvm-standalone-256mb.tar.gz", - "binaries": [ - "nanvixd.exe", - "nanvix_rootfs.img", - "python3.initrd" - ], - "binaries_linux": [ - "nanvixd.elf", - "nanvix_rootfs.img", - "python3.initrd" - ] - } -} diff --git a/src/backends/nanvix/build_common/Cargo.toml b/src/backends/nanvix/build_common/Cargo.toml deleted file mode 100644 index 30f31a2bd..000000000 --- a/src/backends/nanvix/build_common/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "nanvix_build_common" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Build-time helpers for staging NanVix micro-VM binaries (build-only; never linked into runtime)" - -[dependencies] -nanvix_common = { path = "../common" } diff --git a/src/backends/nanvix/build_common/src/lib.rs b/src/backends/nanvix/build_common/src/lib.rs deleted file mode 100644 index 52f190657..000000000 --- a/src/backends/nanvix/build_common/src/lib.rs +++ /dev/null @@ -1,363 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Build-time helpers for staging NanVix micro-VM binaries next to the -//! consuming executable. -//! -//! This crate is **build-only**: it is consumed exclusively as a -//! `[build-dependencies]` entry by the `nanvix_binaries`, `wxc`, and `lxc` -//! build scripts and is never linked into the shipping runtime binary. The -//! file-staging logic lives here (rather than in the runtime `nanvix_common` -//! crate) so it adds no weight to mainline code. - -use std::io; -use std::path::{Path, PathBuf}; - -use nanvix_common::{ - BIN_SUBDIR, BIN_SUBDIR_FILES, REQUIRED_BINARIES, SNAPSHOTS_SUBDIR, SNAPSHOT_FILES, -}; - -/// Category of a staged NanVix artifact. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum ArtifactKind { - /// Flat binary next to the executable (e.g. `nanvixd.exe`). - Binary, - /// File under the `bin/` subdirectory (e.g. `bin/kernel.elf`). - BinFile, - /// WHP warm-start snapshot under `snapshots/` (used by the Windows runner). - Snapshot, -} - -/// Relative paths (from the nanvix binary directory) of every artifact the -/// backend stages, paired with its [`ArtifactKind`]. -/// -/// This is the single source of truth that drives both copying -/// ([`copy_artifacts_to_target`]) and `cargo:rerun-if-changed` emission -/// ([`emit_rerun_for_copied_artifacts`]) so the two can never drift apart. -pub fn artifact_rel_paths() -> Vec<(ArtifactKind, PathBuf)> { - let mut paths = Vec::new(); - for name in REQUIRED_BINARIES { - paths.push((ArtifactKind::Binary, PathBuf::from(name))); - } - for name in BIN_SUBDIR_FILES { - paths.push((ArtifactKind::BinFile, Path::new(BIN_SUBDIR).join(name))); - } - for name in SNAPSHOT_FILES { - paths.push(( - ArtifactKind::Snapshot, - Path::new(SNAPSHOTS_SUBDIR).join(name), - )); - } - paths -} - -/// Resolve the directory of NanVix binaries to use for a build, and report -/// whether it came from an externally supplied (prefetched) location. -/// -/// When `NANVIX_BIN` is set to a non-empty value, that directory is used -/// directly (no network downloads) and the returned boolean is `true`. An -/// empty `NANVIX_BIN` is treated as unset, matching the online/cached default. -/// Otherwise a `nanvix-binaries` subdirectory of `out_dir` is created and -/// returned with `false`. -/// -/// The prefetched directory is made absolute via [`std::path::absolute`] (not -/// `fs::canonicalize`) so it is stable regardless of the build script's working -/// directory while still letting an atomic symlink swap of the cache be noticed -/// by Cargo. -pub fn resolve_bin_dir(out_dir: &Path) -> (PathBuf, bool) { - let prefetched = std::env::var_os("NANVIX_BIN") - .filter(|v| !v.is_empty()) - .map(PathBuf::from); - - match prefetched { - Some(dir) => { - if !dir.is_dir() { - panic!( - "nanvix_binaries: NANVIX_BIN is set to '{}', but that directory \ - does not exist. Point NANVIX_BIN at a directory containing the \ - pre-fetched NanVix binaries.", - dir.display() - ); - } - let dir = std::path::absolute(&dir).unwrap_or_else(|e| { - panic!( - "nanvix_binaries: failed to resolve NANVIX_BIN '{}' to an \ - absolute path: {}", - dir.display(), - e - ) - }); - eprintln!( - "nanvix_binaries: NANVIX_BIN set — using pre-fetched binaries from '{}' (offline)", - dir.display() - ); - (dir, true) - } - None => { - let dir = out_dir.join("nanvix-binaries"); - std::fs::create_dir_all(&dir).expect("failed to create nanvix-binaries dir"); - (dir, false) - } - } -} - -/// Copy NanVix artifacts from the build cache (`src_dir`) to the target -/// directory next to the output executable. -/// -/// Binaries and `bin/` files are copied whenever the source exists (the build -/// script only re-runs when a tracked input changed, so this is not a -/// per-build cost — and a modification-time comparison would silently skip a -/// legitimate override or rollback to an older cache). A failure to copy a -/// binary is logged as a warning and does not abort the build (preserving the -/// historical behavior). -/// -/// `trust_snapshots` governs WHP warm-start snapshots, which are **not** -/// covered by `checksums.json` (they are normally host-generated, not pinned -/// release artifacts): -/// - `true` (normal online build, snapshots produced locally): snapshots are -/// mirrored — present ones are copied and target snapshots absent from the -/// source are purged, so a stale warm-start image is never used against -/// mismatched binaries. -/// - `false` (source is an externally supplied `NANVIX_BIN` prefetch dir): -/// snapshots are never copied and any stale target snapshot is removed, so -/// the runtime falls back to a verified cold boot instead of warm-booting an -/// unverified VM memory image. -/// -/// Returns `Err` on any snapshot integrity failure (a snapshot could not be -/// copied or a stale snapshot could not be removed) so the caller can decide -/// how to react. Build scripts should treat such an error as fatal — leaving a -/// mismatched/unverified snapshot next to the executable is unsafe. -pub fn copy_artifacts_to_target( - src_dir: &Path, - target_dir: &Path, - trust_snapshots: bool, -) -> io::Result<()> { - use std::fs; - - for (kind, rel) in artifact_rel_paths() { - let src = src_dir.join(&rel); - let dst = target_dir.join(&rel); - - // Snapshots from an untrusted (prefetched) source are never copied; we - // must also guarantee no stale snapshot is left behind so the runtime - // does not warm-boot an unverified image. - if kind == ArtifactKind::Snapshot && !trust_snapshots { - remove_stale_snapshot(&dst)?; - continue; - } - - if src.exists() { - if let Some(parent) = dst.parent() { - let _ = fs::create_dir_all(parent); - } - eprintln!("nanvix: copying {} -> {}", src.display(), dst.display()); - if let Err(e) = fs::copy(&src, &dst) { - // Never leave a partial/stale file behind. - let _ = fs::remove_file(&dst); - if kind == ArtifactKind::Snapshot { - return Err(io::Error::new( - e.kind(), - format!("nanvix: failed to copy {}: {}", rel.display(), e), - )); - } - eprintln!("nanvix: WARNING: failed to copy {}: {}", rel.display(), e); - } - } else if kind == ArtifactKind::Snapshot { - // Trusted source is missing this snapshot — purge any stale target - // copy so an incomplete set forces a clean cold boot. - remove_stale_snapshot(&dst)?; - } - } - Ok(()) -} - -/// Remove a target snapshot file if present, returning an error if it cannot be -/// removed. A leftover stale snapshot would be warm-booted by the runner -/// (which trusts a complete exe-side snapshot set on presence alone) against -/// mismatched binaries, so a failure here must be surfaced rather than ignored. -fn remove_stale_snapshot(dst: &Path) -> io::Result<()> { - if dst.exists() { - eprintln!( - "nanvix: removing stale {} (absent or untrusted in source)", - dst.display() - ); - std::fs::remove_file(dst).map_err(|e| { - io::Error::new( - e.kind(), - format!( - "nanvix: failed to remove stale snapshot {}: {} — refusing to \ - leave an unverified warm-start image that would be booted \ - against mismatched binaries", - dst.display(), - e - ), - ) - })?; - } - Ok(()) -} - -/// Emit `cargo:rerun-if-changed` for every artifact that -/// [`copy_artifacts_to_target`] reads from `src_dir`. Call this from a -/// consuming crate's build script so the copy reruns when the source contents -/// change in place — for example when an offline `NANVIX_BIN` prefetch -/// directory is updated at the same path. Without it, the consumer only reruns -/// when the source *path* changes, leaving stale artifacts next to the exe. -pub fn emit_rerun_for_copied_artifacts(src_dir: &Path) { - for (_, rel) in artifact_rel_paths() { - println!("cargo:rerun-if-changed={}", src_dir.join(rel).display()); - } -} - -/// Stage NanVix artifacts from `nanvix_bin_dir` next to the executable being -/// built and emit the appropriate `cargo:rerun-*` triggers. -/// -/// Intended to be called from a consumer (`wxc` / `lxc`) build script. The -/// target directory is derived from `OUT_DIR` (the binary lands in -/// `target//`), and snapshot trust is read from the -/// `DEP_NANVIX_BINARIES_PREFETCHED` link var the `nanvix_binaries` build script -/// exports (defaulting to trusted when absent). Panics on a snapshot integrity -/// failure — acceptable in the build path, where leaving an unverified -/// warm-start image next to the executable must abort the build. -pub fn stage_artifacts_next_to_exe(nanvix_bin_dir: &Path) { - // Cargo puts the output binary in OUT_DIR/../../.. (target//). - let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set"); - let target_dir = Path::new(&out_dir) - .parent() - .and_then(|p| p.parent()) - .and_then(|p| p.parent()) - .expect("could not determine target dir from OUT_DIR"); - - // WHP snapshots from a prefetched (externally supplied) directory are not - // covered by checksums.json, so they must not be trusted/copied. Default to - // trusting (online build) when the flag is absent. - let trust_snapshots = std::env::var("DEP_NANVIX_BINARIES_PREFETCHED") - .map(|v| v != "1") - .unwrap_or(true); - - copy_artifacts_to_target(nanvix_bin_dir, target_dir, trust_snapshots) - .expect("nanvix: failed to stage artifacts next to the executable"); - - // Re-run when the source path changes (detected via nanvix_binaries - // rebuild) and when the source artifacts themselves change in place (e.g. - // an offline NANVIX_BIN prefetch dir updated at the same path). - emit_rerun_for_copied_artifacts(nanvix_bin_dir); - println!("cargo:rerun-if-env-changed=DEP_NANVIX_BINARIES_BIN_DIR"); - println!("cargo:rerun-if-env-changed=DEP_NANVIX_BINARIES_PREFETCHED"); -} - -#[cfg(test)] -mod tests { - use super::*; - use nanvix_common::{SNAPSHOT_CBOR, SNAPSHOT_VMEM}; - use std::fs; - use std::time::{SystemTime, UNIX_EPOCH}; - - /// Create a unique, empty scratch directory under the OS temp dir. - fn scratch(tag: &str) -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "nanvix_copy_test_{}_{}_{}", - tag, - std::process::id(), - nanos - )); - fs::create_dir_all(&dir).unwrap(); - dir - } - - fn write_snapshot(root: &Path, name: &str, contents: &[u8]) { - let dir = root.join(SNAPSHOTS_SUBDIR); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join(name), contents).unwrap(); - } - - fn snapshot_path(root: &Path, name: &str) -> PathBuf { - root.join(SNAPSHOTS_SUBDIR).join(name) - } - - #[test] - fn trusted_source_lacking_snapshot_purges_stale_target() { - let src = scratch("purge_src"); - let target = scratch("purge_tgt"); - // Target has both snapshots; source has none. - write_snapshot(&target, SNAPSHOT_VMEM, b"stale-vmem"); - write_snapshot(&target, SNAPSHOT_CBOR, b"stale-cbor"); - - copy_artifacts_to_target(&src, &target, /* trust_snapshots = */ true).unwrap(); - - assert!(!snapshot_path(&target, SNAPSHOT_VMEM).exists()); - assert!(!snapshot_path(&target, SNAPSHOT_CBOR).exists()); - - fs::remove_dir_all(&src).ok(); - fs::remove_dir_all(&target).ok(); - } - - #[test] - fn trusted_source_with_snapshots_copies_into_target() { - let src = scratch("copy_src"); - let target = scratch("copy_tgt"); - write_snapshot(&src, SNAPSHOT_VMEM, b"new-vmem"); - write_snapshot(&src, SNAPSHOT_CBOR, b"new-cbor"); - - copy_artifacts_to_target(&src, &target, true).unwrap(); - - assert_eq!( - fs::read(snapshot_path(&target, SNAPSHOT_VMEM)).unwrap(), - b"new-vmem" - ); - assert_eq!( - fs::read(snapshot_path(&target, SNAPSHOT_CBOR)).unwrap(), - b"new-cbor" - ); - - fs::remove_dir_all(&src).ok(); - fs::remove_dir_all(&target).ok(); - } - - #[test] - fn trusted_partial_source_copies_present_and_purges_absent() { - let src = scratch("partial_src"); - let target = scratch("partial_tgt"); - // Source has only vmem; target starts with both. - write_snapshot(&src, SNAPSHOT_VMEM, b"fresh-vmem"); - write_snapshot(&target, SNAPSHOT_VMEM, b"old-vmem"); - write_snapshot(&target, SNAPSHOT_CBOR, b"old-cbor"); - - copy_artifacts_to_target(&src, &target, true).unwrap(); - - // Present-in-source file is overwritten; absent-in-source file purged. - assert_eq!( - fs::read(snapshot_path(&target, SNAPSHOT_VMEM)).unwrap(), - b"fresh-vmem" - ); - assert!(!snapshot_path(&target, SNAPSHOT_CBOR).exists()); - - fs::remove_dir_all(&src).ok(); - fs::remove_dir_all(&target).ok(); - } - - #[test] - fn untrusted_source_never_copies_snapshots_and_purges_target() { - let src = scratch("untrusted_src"); - let target = scratch("untrusted_tgt"); - // Source ships snapshots, but they are untrusted (prefetched). - write_snapshot(&src, SNAPSHOT_VMEM, b"attacker-vmem"); - write_snapshot(&src, SNAPSHOT_CBOR, b"attacker-cbor"); - // Target has stale snapshots from a prior build. - write_snapshot(&target, SNAPSHOT_VMEM, b"stale-vmem"); - write_snapshot(&target, SNAPSHOT_CBOR, b"stale-cbor"); - - copy_artifacts_to_target(&src, &target, /* trust_snapshots = */ false).unwrap(); - - // Untrusted snapshots are neither copied nor left behind. - assert!(!snapshot_path(&target, SNAPSHOT_VMEM).exists()); - assert!(!snapshot_path(&target, SNAPSHOT_CBOR).exists()); - - fs::remove_dir_all(&src).ok(); - fs::remove_dir_all(&target).ok(); - } -} diff --git a/src/backends/nanvix/common/src/lib.rs b/src/backends/nanvix/common/src/lib.rs deleted file mode 100644 index 67c4ea2ce..000000000 --- a/src/backends/nanvix/common/src/lib.rs +++ /dev/null @@ -1,293 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Shared constants and configuration types for NanVix micro-VM binaries. -//! -//! This crate is the single source of truth for binary filenames, release -//! configuration, and checksum data. It is consumed as a `[build-dependency]` -//! by `nanvix_binaries` (download) and `wxc` (copy to output dir). - -use std::collections::HashMap; -use std::path::Path; - -use serde::Deserialize; - -/// All required NanVix binary filenames (flat, next to wxc-exec) — Windows. -#[cfg(target_os = "windows")] -pub const REQUIRED_BINARIES: &[&str] = &["nanvixd.exe", "nanvix_rootfs.img", "python3.initrd"]; - -/// All required NanVix binary filenames (flat, next to lxc-exec) — Linux. -#[cfg(target_os = "linux")] -pub const REQUIRED_BINARIES: &[&str] = &["nanvixd.elf", "nanvix_rootfs.img", "python3.initrd"]; - -/// NanVix daemon binary name (platform-conditional). -#[cfg(target_os = "windows")] -pub const NANVIXD_BINARY: &str = "nanvixd.exe"; - -/// NanVix daemon binary name (platform-conditional). -#[cfg(target_os = "linux")] -pub const NANVIXD_BINARY: &str = "nanvixd.elf"; - -/// Multi-binary initrd (daemons + CPython) loaded by NanVix at warm start. -pub const INITRD_BINARY: &str = "python3.initrd"; - -/// Combined rootfs image (NanVix kernel userspace + CPython stdlib). -pub const RAMFS_IMAGE: &str = "nanvix_rootfs.img"; - -/// Pre-built VM state snapshot (CBOR) for warm start (Windows/WHP only). -pub const SNAPSHOT_CBOR: &str = "kernel.whp.cbor"; - -/// Pre-built VM memory snapshot for warm start (Windows/WHP only). -pub const SNAPSHOT_VMEM: &str = "kernel.vmem"; - -/// Subdirectory holding kernel binary (nanvixd expects `./bin/kernel.elf`). -pub const BIN_SUBDIR: &str = "bin"; - -/// Subdirectory holding WHP snapshot files. -pub const SNAPSHOTS_SUBDIR: &str = "snapshots"; - -/// Files that live in a `bin/` subdirectory (nanvixd expects ./bin/kernel.elf). -pub const BIN_SUBDIR_FILES: &[&str] = &["kernel.elf"]; - -/// Snapshot files that live in a `snapshots/` subdirectory next to the exe. -pub const SNAPSHOT_FILES: &[&str] = &[SNAPSHOT_VMEM, SNAPSHOT_CBOR]; - -/// Binaries sourced from the `nanvix/nanvix-python` GitHub release. -pub const NANVIX_PYTHON_REPO_BINARIES: &[&str] = REQUIRED_BINARIES; - -/// Number of bytes to retain from the end of nanvixd stderr when capturing -/// it for diagnostics. Bounds host memory growth in the face of an -/// untrusted/verbose child (availability / DoS hardening). -pub const STDERR_TAIL_BYTES: usize = 8 * 1024; - -/// Render a bounded tail of nanvixd stderr bytes as a UTF-8 string, -/// prefixed with `...(truncated)` when truncation occurred. -/// -/// `bytes` may be the full stderr buffer (post-hoc trim) or a buffer that -/// the caller already capped while streaming. The `truncated` flag tells -/// us that streaming-time bytes were dropped even when the resulting -/// buffer length is at or below [`STDERR_TAIL_BYTES`]. -pub fn format_stderr_tail(bytes: &[u8], truncated: bool) -> String { - if bytes.len() > STDERR_TAIL_BYTES { - // Trim at the byte level *before* UTF-8 decoding. Slicing into the - // `String` produced by `from_utf8_lossy` would panic if the byte - // offset fell inside a multi-byte codepoint; `from_utf8_lossy` on - // a raw byte slice tolerates a partial leading codepoint by - // emitting U+FFFD replacement characters instead. - let start = bytes.len() - STDERR_TAIL_BYTES; - let tail = String::from_utf8_lossy(&bytes[start..]); - format!("...(truncated){}", tail) - } else if truncated { - let text = String::from_utf8_lossy(bytes); - format!("...(truncated){}", text) - } else { - String::from_utf8_lossy(bytes).into_owned() - } -} - -/// Drain `reader` to completion, retaining only the last -/// [`STDERR_TAIL_BYTES`] bytes. Returns `(tail, truncated)` where -/// `truncated` is set when any earlier bytes were dropped. -/// -/// Used to bound host memory growth when capturing nanvixd stderr from a -/// potentially untrusted / verbose child (availability / DoS hardening). -/// Read errors terminate the drain and return whatever was captured so -/// far; this mirrors `read_to_end` failure semantics for our use case -/// where stderr is best-effort diagnostic data. -pub fn drain_stderr_tail(mut reader: R) -> (Vec, bool) { - let mut tail: Vec = Vec::new(); - let mut chunk = [0u8; 8 * 1024]; - let mut truncated = false; - loop { - match reader.read(&mut chunk) { - Ok(0) => break, - Ok(n) => { - tail.extend_from_slice(&chunk[..n]); - if tail.len() > STDERR_TAIL_BYTES { - let drop = tail.len() - STDERR_TAIL_BYTES; - tail.drain(..drop); - truncated = true; - } - } - Err(_) => break, - } - } - (tail, truncated) -} - -/// Release configuration loaded from `versions.json`. -#[derive(Debug, Deserialize)] -pub struct ReleaseConfig { - /// Configuration for the `nanvix/nanvix-python` GitHub repo. - pub nanvix_python: RepoConfig, -} - -/// Configuration for a single upstream GitHub repo release. -#[derive(Debug, Deserialize)] -pub struct RepoConfig { - /// Git tag of the pinned release (e.g., "v0.12.291"). - pub tag: String, - /// Exact filename of the zip asset in the GitHub release (Windows). - pub asset: String, - /// Exact filename of the tar.gz asset in the GitHub release (Linux). - #[serde(default)] - pub asset_linux: Option, - /// List of binary filenames to extract from the zip (Windows). - pub binaries: Vec, - /// List of binary filenames to extract from the tar.gz (Linux). - #[serde(default)] - pub binaries_linux: Option>, -} - -/// Load and deserialize a JSON file. -pub fn load_json(path: &str) -> T { - let content = std::fs::read_to_string(Path::new(path)) - .unwrap_or_else(|e| panic!("nanvix_common: failed to read {}: {}", path, e)); - serde_json::from_str(&content) - .unwrap_or_else(|e| panic!("nanvix_common: failed to parse {}: {}", path, e)) -} - -/// Load checksums from `checksums.json`. -/// -/// The file is a platform-keyed map of the form -/// `{ "windows": { "name": "hash", ... }, "linux": { ... } }`; `platform` -/// selects which sub-map to return. -pub fn load_checksums(path: &str, platform: &str) -> HashMap { - let mut value: HashMap> = load_json(path); - value.remove(platform).unwrap_or_else(|| { - panic!( - "nanvix_common: {} does not contain a '{}' section", - path, platform - ) - }) -} - -/// Construct a deterministic GitHub release download URL. -/// -/// Format: `https://github.com/{repo}/releases/download/{tag}/{asset}` -pub fn github_download_url(repo: &str, tag: &str, asset: &str) -> String { - format!( - "https://github.com/{}/releases/download/{}/{}", - repo, tag, asset - ) -} - -/// Generate WHP snapshots by cold-booting nanvixd. -/// -/// `snapshot_home` is used as the process working directory. nanvixd writes -/// snapshot files to `/snapshots/`, so the resulting files end up at -/// `/snapshots/kernel.vmem` and `kernel.whp.cbor`. -/// -/// `bin_dir` is the directory containing `kernel.elf` (passed as `-bin-dir`). -/// -/// Returns `Ok(())` on success. On failure, returns a human-readable error -/// message suitable for both build scripts (which panic) and runtime callers -/// (which wrap in their own error type). -pub fn generate_snapshot( - snapshot_home: &Path, - nanvixd: &Path, - bin_dir: &Path, - ramfs: &Path, - initrd: &Path, -) -> Result<(), String> { - use std::process::{Command, Stdio}; - - let output = Command::new(nanvixd) - .current_dir(snapshot_home) - .arg("-bin-dir") - .arg(bin_dir) - .arg("-ramfs") - .arg(ramfs) - .arg("-kernel-args") - .arg("snapshot") - .arg("--") - .arg(initrd) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .output() - .map_err(|e| format!("failed to run nanvixd for snapshot generation: {}", e))?; - - if !output.status.success() { - // Include a bounded tail of stderr so callers (build script panic - // or runtime preflight error) can surface actionable diagnostics - // without growing host memory unboundedly. - let tail = format_stderr_tail(&output.stderr, false); - let trimmed = tail.trim_end(); - if trimmed.is_empty() { - return Err(format!( - "snapshot generation failed (exit code: {})", - output.status - )); - } - return Err(format!( - "snapshot generation failed (exit code: {})\nnanvixd stderr:\n{}", - output.status, trimmed - )); - } - - let snap_dir = snapshot_home.join(SNAPSHOTS_SUBDIR); - for name in SNAPSHOT_FILES { - if !snap_dir.join(name).exists() { - return Err(format!( - "snapshot generation completed but '{}' not found in {:?}", - name, snap_dir - )); - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn format_stderr_tail_short_buffer_no_prefix() { - let out = format_stderr_tail(b"hello", false); - assert_eq!(out, "hello"); - } - - #[test] - fn format_stderr_tail_short_buffer_with_truncated_flag() { - let out = format_stderr_tail(b"hello", true); - assert_eq!(out, "...(truncated)hello"); - } - - #[test] - fn format_stderr_tail_trims_oversized_ascii_buffer() { - let big = vec![b'A'; STDERR_TAIL_BYTES + 16]; - let out = format_stderr_tail(&big, false); - assert!(out.starts_with("...(truncated)")); - // 14 chars for the prefix + STDERR_TAIL_BYTES trailing ASCII bytes. - assert_eq!(out.len(), "...(truncated)".len() + STDERR_TAIL_BYTES); - } - - /// Regression test for PR review comment r3283559877: an oversized - /// buffer containing multi-byte UTF-8 must not panic when the - /// truncation byte offset falls inside a codepoint. - #[test] - fn format_stderr_tail_oversized_multibyte_does_not_panic() { - // 4-byte UTF-8 emoji repeated until well past the cap. - let unit = "🦀"; // 4 bytes - let repeats = (STDERR_TAIL_BYTES / unit.len()) + 64; - let big = unit.repeat(repeats).into_bytes(); - assert!(big.len() > STDERR_TAIL_BYTES); - let out = format_stderr_tail(&big, false); - assert!(out.starts_with("...(truncated)")); - // Ensure the result is still valid UTF-8 (String guarantees this); - // partial leading codepoints become U+FFFD via `from_utf8_lossy`. - assert!(out.len() >= "...(truncated)".len()); - } - - #[test] - fn format_stderr_tail_oversized_with_truncated_flag_uses_byte_trim() { - // When the buffer is oversized, the explicit `truncated` flag - // should not change behavior — byte-level trim still applies. - let big = vec![b'B'; STDERR_TAIL_BYTES + 1]; - let out = format_stderr_tail(&big, true); - assert!(out.starts_with("...(truncated)")); - assert_eq!(out.len(), "...(truncated)".len() + STDERR_TAIL_BYTES); - } -} diff --git a/src/backends/nanvix/runner/Cargo.toml b/src/backends/nanvix/runner/Cargo.toml deleted file mode 100644 index 081942e21..000000000 --- a/src/backends/nanvix/runner/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "nanvix_runner" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -wxc_common = { workspace = true, features = ["microvm"] } -nanvix_common = { path = "../common" } -uuid = { workspace = true } - -[target.'cfg(target_os = "windows")'.dependencies] -windows = { workspace = true } - -[target.'cfg(target_os = "linux")'.dependencies] -libc = { workspace = true } diff --git a/src/backends/nanvix/runner/src/lib.rs b/src/backends/nanvix/runner/src/lib.rs deleted file mode 100644 index 1491893bf..000000000 --- a/src/backends/nanvix/runner/src/lib.rs +++ /dev/null @@ -1,1845 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! `NanVixScriptRunner` -- executes code inside a NanVix micro-VM. -//! -//! The initial runtime is CPython 3.12 with a trimmed FAT32 stdlib filesystem. -//! The architecture supports additional runtimes (QuickJS, C, C++, Rust binaries). -//! -//! ## I/O model -//! -//! - **stdin**: set to `Stdio::null()` (NanVix guest does not read host stdin) -//! - **stdout**: inherited from parent via `Stdio::inherit()` (not captured) -//! - **stderr**: inherited from parent by default (kernel traces stream -//! straight to the parent terminal). When the `MXC_NANVIX_TRACE` env var -//! is truthy, stderr is piped and captured so it can be embedded in the -//! wxc-exec log on non-zero exit. -//! -//! **Note for SDK consumers:** Use `usePty: false` (non-PTY mode) for the MicroVM -//! backend. PTY mode is not supported. Because stdout/stderr are inherited, -//! `ScriptResponse.standard_out` and `standard_err` are always empty strings. -//! Output is streamed directly to the parent's pipes. -//! -//! ## Diagnostics -//! -//! By default the runner sets `RUST_LOG=off` in nanvixd's environment, which -//! suppresses the per-run `%LOCALAPPDATA%\nanvix\logs\nanvixd_*.log` trace -//! file and noticeably reduces warm-start latency. Set `MXC_NANVIX_TRACE=1` -//! (or `true`/`yes`, case-insensitive) before invoking wxc-exec to let -//! nanvixd use its own `RUST_LOG` default and to capture nanvixd's stderr -//! for inclusion in the wxc-exec log. -//! -//! ## Exit codes -//! -//! `nanvixd` propagates the guest process exit code directly. -//! -//! ## Networking -//! -//! Host networking is **off by default** and is enabled per-run by passing -//! `-allow-host-networking` to `nanvixd`. The runner adds that flag when the -//! request sets `network.defaultPolicy = "allow"`, or when `allowedHosts` / -//! `blockedHosts` is present (forwarded as `-allow-host` / `-block-host`). -//! Network proxies are not supported and are rejected at validation time. -//! -//! Auto-discovery -//! -//! All required binaries (`nanvixd.exe`, `python3.initrd`, `nanvix_rootfs.img`) -//! are discovered next to the running executable. No configuration is needed. - -use std::fmt::Write; -use std::net::ToSocketAddrs; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Condvar, Mutex}; -use std::thread; -use std::thread::JoinHandle; -use std::time::{Duration, Instant}; - -use wxc_common::logger::Logger; -use wxc_common::models::{ExecutionRequest, NetworkAction, NetworkPolicy, ScriptResponse}; -use wxc_common::script_runner::ScriptRunner; -use wxc_common::validator::{validate_network_policy_support, NetworkPolicySupport}; - -/// Multi-binary initrd (daemons + CPython) loaded by NanVix at warm start. -const INITRD_BINARY: &str = nanvix_common::INITRD_BINARY; -/// NanVix daemon binary launched by the host runner (platform-conditional). -const NANVIXD_BINARY: &str = nanvix_common::NANVIXD_BINARY; -/// Combined rootfs image (NanVix kernel userspace + CPython stdlib). -const RAMFS_IMAGE: &str = nanvix_common::RAMFS_IMAGE; -/// Pre-built VM state snapshot (CBOR) for warm start (Windows/WHP only). -#[cfg(target_os = "windows")] -const SNAPSHOT_CBOR: &str = nanvix_common::SNAPSHOT_CBOR; -/// Subdirectory holding snapshot files next to the exe (Windows/WHP only). -#[cfg(target_os = "windows")] -const SNAPSHOTS_DIR: &str = nanvix_common::SNAPSHOTS_SUBDIR; -/// Subdirectory holding kernel binary. -const BIN_DIR: &str = nanvix_common::BIN_SUBDIR; -/// Env var override for the NanVix snapshot home directory. Set this to -/// force a specific location; otherwise the runner uses a standard -/// OS-local data path or falls back to `/snapshots/`. -#[cfg(target_os = "windows")] -const NANVIX_HOME_ENV: &str = "NANVIX_HOME"; -/// Env var that opts in to nanvixd's verbose tracing (and captured stderr). -/// When unset (the default), the runner forces `RUST_LOG=off` for nanvixd -/// and inherits stderr, which saves ~25–30 ms per warm execution by -/// avoiding nanvixd's per-run log file and the host-side stderr drain. -const NANVIX_TRACE_ENV: &str = "MXC_NANVIX_TRACE"; -/// Final component of the default OS-local data path (Windows only). -#[cfg(target_os = "windows")] -const DEFAULT_HOME_LEAF: &str = "nanvix"; -/// Boot grace period that is always enforced. -const BOOT_TIMEOUT_MS: u64 = 60_000; -/// Generic error exit code returned to host callers. -const ERROR_EXIT_CODE: i32 = -1; -/// Maximum age of orphaned staging dirs before cleanup (1 hour). -const ORPHAN_SWEEP_MAX_AGE_SECS: u64 = 3600; -const ERR_DENIED_PATHS: &str = concat!( - "denied_paths is not meaningful for the microvm backend ", - "-- the guest has no host filesystem visibility. ", - "Only readwrite_paths and readonly_paths are supported", -); -const ERR_NETWORK_HOSTS: &str = concat!( - "allowedHosts and blockedHosts are mutually exclusive for the NanVix backend -- ", - "the guest egress filter is allow-XOR-block. Specify an allowlist (allowedHosts) ", - "or a blocklist (blockedHosts), not both", -); -const ERR_HOSTS_UNRESOLVED: &str = concat!( - "none of the specified allowedHosts/blockedHosts resolved to an IPv4 address -- ", - "the NanVix guest filter is IPv4-only; use IPv4 literals/CIDR or hosts with A records", -); -const ERR_BLOCKED_HOST_UNRESOLVED: &str = concat!( - "a blockedHosts entry did not resolve to any IPv4 address -- ", - "the NanVix guest egress filter is static (resolved once at preflight) and IPv4-only, ", - "so an unresolvable blocked host cannot be enforced. Silently dropping it would let ", - "traffic the policy explicitly blocks flow freely, so the run is rejected (fail-closed). ", - "Use an IPv4 literal/CIDR or a host with A records", -); -const ERR_PROXY_POLICY: &str = "network proxy is not supported by the NanVix backend"; -const ERR_DIRECTIONAL_NETWORK: &str = "NanVix supports only fully isolated or explicitly \ - unrestricted directional networking: egress.default, ingress.default and ingress.hostLoopback \ - must all be deny or all be allow; independent ingress or host-loopback restrictions are not supported"; -const ERR_DIRECTIONAL_FILTERS: &str = "NanVix cannot enforce directional egress rules: its legacy \ - IPv4 filter has implicit exceptions and does not implement the directional rule contract; \ - use fully isolated networking or explicitly unrestricted networking without rules"; -const ERR_WORKDIR: &str = "workingDirectory is not supported by the NanVix backend -- guest has its own filesystem namespace"; - -/// Outcome of resolving a request's egress host lists. -/// -/// `allow`/`block` are the IPv4/CIDR literals handed to nanvixd; at most one is -/// non-empty. `warnings` carries human-readable notices for allowlist entries -/// that were dropped during resolution (blocklist drops are a hard error and -/// never reach here). -#[derive(Debug)] -struct ResolvedHostLists { - allow: Vec, - block: Vec, - warnings: Vec, -} - -/// Maps a finished child's [`ExitStatus`] to a host-visible exit code. -/// -/// On Unix, processes terminated by a signal have no exit code (`status.code()` -/// returns `None`); we surface them as the negated signal number (e.g. SIGKILL -/// → `-9`) so callers can distinguish them from normal exits and from the -/// generic [`ERROR_EXIT_CODE`] sentinel. On Windows, `status.code()` always -/// returns `Some(_)`. -fn exit_code_from_status(status: &std::process::ExitStatus) -> i32 { - if let Some(code) = status.code() { - return code; - } - #[cfg(unix)] - { - use std::os::unix::process::ExitStatusExt; - if let Some(signal) = status.signal() { - return -signal; - } - } - ERROR_EXIT_CODE -} - -// -- NanVix error classification --------------------------------------------- - -/// Classifies NanVix runner errors for structured error handling. -#[derive(Debug)] -enum NanVixError { - /// Pre-spawn validation failures (missing artifacts, invalid config, unsupported policy). - Preflight(String), - /// OS/platform failures while spawning/managing the NanVix process (WHP/spawn/handles). - Platform(String), - /// Stdin broken pipe, VM crash. - Runtime(String), - /// Watchdog killed the process. - Timeout { - script_timeout_ms: u32, - total_ms: u64, - }, -} - -impl std::fmt::Display for NanVixError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - NanVixError::Preflight(msg) => write!(f, "NanVix preflight error: {}", msg), - NanVixError::Platform(msg) => write!(f, "NanVix platform error: {}", msg), - NanVixError::Runtime(msg) => write!(f, "NanVix runtime error: {}", msg), - NanVixError::Timeout { - script_timeout_ms, - total_ms, - } => write!( - f, - "NanVix execution timed out after {}ms \ - (boot_timeout={}ms, script_timeout={}ms)", - total_ms, BOOT_TIMEOUT_MS, script_timeout_ms - ), - } - } -} - -impl NanVixError { - fn to_response(&self) -> ScriptResponse { - ScriptResponse { - exit_code: ERROR_EXIT_CODE, - error_message: self.to_string(), - ..Default::default() - } - } -} - -/// Returns the directory containing the current executable. -/// -/// Inlined (rather than reusing `wxc_common::process_util::exe_dir`) because -/// `process_util` is gated to `target_os = "windows"`. -fn exe_dir() -> Result { - std::env::current_exe() - .map_err(|e| NanVixError::Preflight(format!("cannot determine exe path: {}", e))) - .and_then(|exe| { - exe.parent() - .map(|p| p.to_path_buf()) - .ok_or_else(|| NanVixError::Preflight("exe has no parent directory".to_string())) - }) -} - -/// Returns `true` when [`NANVIX_TRACE_ENV`] is set to a truthy value -/// (`"1"`, `"true"`, or `"yes"`, case-insensitive). Any other value -/// (including unset, empty, or `"0"`/`"false"`/`"no"`) means trace is off. -fn nanvix_trace_enabled() -> bool { - match std::env::var(NANVIX_TRACE_ENV) { - Ok(v) => matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"), - Err(_) => false, - } -} - -/// Watchdog thread: waits for timeout or cancellation, then terminates the process. -/// -/// On Windows, `process_id_or_handle` is a duplicated process HANDLE (as usize). -/// On Linux, `process_id_or_handle` is the child PID (as usize). -fn watchdog_thread_fn( - process_id_or_handle: usize, - duration: Duration, - cancel_pair: Arc<(Mutex, Condvar)>, - timed_out: Arc, -) { - let (lock, cvar) = &*cancel_pair; - let mut cancelled = lock.lock().unwrap_or_else(|e| e.into_inner()); - let start = Instant::now(); - let mut remaining = duration; - loop { - let result = cvar - .wait_timeout(cancelled, remaining) - .unwrap_or_else(|e| e.into_inner()); - cancelled = result.0; - if *cancelled || result.1.timed_out() { - break; - } - let elapsed = start.elapsed(); - if elapsed >= duration { - break; - } - remaining = duration.saturating_sub(elapsed); - } - - #[cfg(target_os = "windows")] - { - // Always close the duplicated handle to avoid leaks. - let close_handle = |handle_raw: usize| { - use windows::Win32::Foundation::{CloseHandle, HANDLE}; - let handle = HANDLE(handle_raw as *mut std::ffi::c_void); - // SAFETY: `handle` was returned by `DuplicateHandle` in this - // process and is closed exactly once by this watchdog thread. - let _ = unsafe { CloseHandle(handle) }; - }; - - if *cancelled { - close_handle(process_id_or_handle); - return; - } - - timed_out.store(true, Ordering::SeqCst); - - use windows::Win32::Foundation::HANDLE; - use windows::Win32::System::Threading::TerminateProcess; - - let handle = HANDLE(process_id_or_handle as *mut std::ffi::c_void); - // SAFETY: `handle` is a valid duplicated process handle owned by - // this thread, and passing exit code 1 is valid for termination. - let _ = unsafe { TerminateProcess(handle, 1) }; - close_handle(process_id_or_handle); - } - - #[cfg(target_os = "linux")] - { - if *cancelled { - return; - } - - timed_out.store(true, Ordering::SeqCst); - - // Kill the child process by PID using SIGKILL. - let pid = process_id_or_handle as i32; - // SAFETY: sending SIGKILL to a known child PID is always valid. - // If the process already exited, `kill()` returns ESRCH which we ignore. - unsafe { - libc::kill(pid, libc::SIGKILL); - } - } -} - -/// Components returned by [`NanVixScriptRunner::setup_watchdog`]: the watchdog -/// thread handle (if a finite timeout was requested), the cancellation pair -/// used to signal early completion, and the `timed_out` flag. -type WatchdogState = ( - Option>, - Arc<(Mutex, Condvar)>, - Arc, -); - -// -- NanVixScriptRunner ------------------------------------------------------ - -/// Script runner that executes Python code inside a NanVix microkernel VM. -/// -/// All binaries are auto-discovered next to the running executable. -pub struct NanVixScriptRunner { - _private: (), -} - -impl Default for NanVixScriptRunner { - fn default() -> Self { - Self::new() - } -} - -/// Resolved paths for NanVix invocation. -#[derive(Debug)] -struct ResolvedPaths { - nanvixd: PathBuf, - ramfs: PathBuf, - initrd: PathBuf, - /// Directory holding the `bin/` subdir next to the exe. - exe_dir: PathBuf, - /// Snapshot home directory — used as cwd for nanvixd so it can locate - /// `snapshots/kernel.vmem` relative to cwd (nanvixd constraint). - snapshot_home: PathBuf, -} - -impl NanVixScriptRunner { - pub fn new() -> Self { - Self { _private: () } - } - - /// Resolve and validate all required paths next to the running executable. - fn resolve_paths(&self) -> Result { - let dir = exe_dir()?; - - let nanvixd = dir.join(NANVIXD_BINARY); - if !nanvixd.exists() { - return Err(NanVixError::Preflight(format!( - "{} not found in {:?}", - NANVIXD_BINARY, dir - ))); - } - - let ramfs = dir.join(RAMFS_IMAGE); - if !ramfs.exists() { - return Err(NanVixError::Preflight(format!( - "{} not found in {:?}", - RAMFS_IMAGE, dir - ))); - } - - let initrd = dir.join(INITRD_BINARY); - if !initrd.exists() { - return Err(NanVixError::Preflight(format!( - "{} not found in {:?}", - INITRD_BINARY, dir - ))); - } - - // Preflight-check bin/ subdir contents (nanvixd loads `./bin/kernel.elf` - // via `-bin-dir`; missing the file here yields a clearer error than - // letting nanvixd fail at boot time). - let bin_subdir = dir.join(BIN_DIR); - for name in nanvix_common::BIN_SUBDIR_FILES { - let path = bin_subdir.join(name); - if !path.exists() { - return Err(NanVixError::Preflight(format!( - "{}/{} not found in {:?}", - BIN_DIR, name, dir - ))); - } - } - - // Snapshot resolution — Windows only (WHP snapshots for warm start). - // Linux uses cold boot via KVM every time. - #[cfg(target_os = "windows")] - let snapshot_home = { - let home = Self::resolve_snapshot_home(&dir)?; - // Warm start requires *all* snapshot files (kernel.vmem + kernel.whp.cbor); - // a partial/corrupt set must trigger regeneration instead of a late failure - // inside nanvixd. - let snapshots_present = nanvix_common::SNAPSHOT_FILES - .iter() - .all(|name| home.join(SNAPSHOTS_DIR).join(name).exists()); - if !snapshots_present { - // No (complete) snapshot yet — generate one via cold boot - // (one-time cost, ~400–500 ms). Subsequent runs restore directly. - Self::generate_snapshot(&dir, &home, &nanvixd, &ramfs, &initrd)?; - } - home - }; - - #[cfg(target_os = "linux")] - let snapshot_home = dir.clone(); - - Ok(ResolvedPaths { - nanvixd, - ramfs, - initrd, - exe_dir: dir, - snapshot_home, - }) - } - - /// Resolve the snapshot home directory (Windows only — WHP snapshots). - /// - /// Discovery chain (first match wins): - /// 1. `$NANVIX_HOME` env var (if set and non-empty) - /// 2. `` directory itself, when a complete set of pre-generated - /// snapshots already lives in `/snapshots/` (build-time output - /// or shipped artifacts) — using it avoids a redundant cold boot. - /// 3. OS-local data path (`%LOCALAPPDATA%\nanvix` on Windows) - /// 4. `` directory itself as a last-resort fallback (dev builds — - /// nanvixd will write snapshots into `/snapshots/`). - #[cfg(target_os = "windows")] - fn resolve_snapshot_home(exe_dir: &Path) -> Result { - // 1. Env var override. - if let Some(val) = std::env::var_os(NANVIX_HOME_ENV) { - let p = PathBuf::from(val); - if !p.as_os_str().is_empty() { - std::fs::create_dir_all(&p).map_err(|e| { - NanVixError::Preflight(format!( - "cannot create ${} directory {:?}: {}", - NANVIX_HOME_ENV, p, e - )) - })?; - return Ok(p); - } - } - - // 2. Prefer exe-side snapshots when they're already complete, so - // build-time-generated artifacts shipped next to wxc-exec are - // actually used instead of triggering a fresh cold boot in - // %LOCALAPPDATA%. - let exe_snapshots = exe_dir.join(SNAPSHOTS_DIR); - let exe_snapshots_complete = nanvix_common::SNAPSHOT_FILES - .iter() - .all(|name| exe_snapshots.join(name).exists()); - if exe_snapshots_complete { - return Ok(exe_dir.to_path_buf()); - } - - // 3. OS-local data path. - if let Some(home) = Self::default_home() { - if home.exists() || std::fs::create_dir_all(&home).is_ok() { - return Ok(home); - } - } - - // 4. Fallback: exe directory itself (nanvixd writes to /snapshots/). - Ok(exe_dir.to_path_buf()) - } - - /// Default OS-local snapshot home path. - #[cfg(target_os = "windows")] - fn default_home() -> Option { - std::env::var_os("LOCALAPPDATA").map(|d| PathBuf::from(d).join(DEFAULT_HOME_LEAF)) - } - - /// Generate a WHP snapshot via cold boot (one-time cost, Windows only). - /// - /// Delegates to `nanvix_common::generate_snapshot` which runs nanvixd with - /// `-kernel-args snapshot` and cwd set to `snapshot_home`. nanvixd writes - /// snapshot files to `/snapshots/` directly. Subsequent runs - /// restore from the snapshot (~20 ms vs ~430 ms cold boot). - #[cfg(target_os = "windows")] - fn generate_snapshot( - exe_dir: &Path, - snapshot_home: &Path, - nanvixd: &Path, - ramfs: &Path, - initrd: &Path, - ) -> Result<(), NanVixError> { - std::fs::create_dir_all(snapshot_home).map_err(|e| { - NanVixError::Preflight(format!("failed to create snapshot home: {}", e)) - })?; - - eprintln!("nanvix: no snapshot found — generating via cold boot (one-time cost)..."); - - let start = Instant::now(); - nanvix_common::generate_snapshot( - snapshot_home, - nanvixd, - &exe_dir.join(BIN_DIR), - ramfs, - initrd, - ) - .map_err(NanVixError::Preflight)?; - - eprintln!( - "nanvix: snapshot generated in {:.0?} — subsequent runs will use warm start", - start.elapsed() - ); - Ok(()) - } - - /// Compute total timeout: boot grace + staging overhead + script timeout. - /// When `script_timeout == 0` the caller intends "no limit", so the watchdog - /// is disabled entirely (returns `u64::MAX`). Boot and staging time are - /// unbounded in this case — this is by design for interactive/exploratory use. - fn total_timeout_ms(script_timeout: u32, staging_overhead_ms: u64) -> u64 { - if script_timeout == 0 { - u64::MAX - } else { - BOOT_TIMEOUT_MS - .saturating_add(staging_overhead_ms) - .saturating_add(script_timeout as u64) - } - } - - /// Compatibility lowering for legacy requests without directional policy. - /// - /// Host networking is enabled when `network.defaultPolicy = "allow"` OR when - /// a per-host allow/block list is present (a list always implies networking, - /// regardless of `defaultPolicy`). When enabled, the runner passes - /// `-allow-host-networking` to nanvixd; per-host lists are additionally - /// forwarded as `-allow-host`/`-block-host` (see [`Self::spawn_nanvixd`]). - fn legacy_host_networking_enabled(request: &ExecutionRequest) -> bool { - request.policy.default_network_policy == NetworkPolicy::Allow - || !request.policy.allowed_hosts.is_empty() - || !request.policy.blocked_hosts.is_empty() - } - - fn resolve_networking_mode(request: &ExecutionRequest) -> Result { - let policy = &request.policy; - if policy.network_egress.is_none() && policy.network_ingress.is_none() { - return Ok(Self::legacy_host_networking_enabled(request)); - } - if !policy.allowed_hosts.is_empty() - || !policy.blocked_hosts.is_empty() - || policy - .network_egress - .as_ref() - .is_some_and(|egress| !egress.allow.is_empty() || !egress.deny.is_empty()) - { - return Err(NanVixError::Preflight(ERR_DIRECTIONAL_FILTERS.to_string())); - } - let egress = policy - .network_egress - .as_ref() - .map(|egress| egress.default) - .unwrap_or(NetworkAction::Deny); - let ingress = policy - .network_ingress - .as_ref() - .map(|ingress| ingress.default) - .unwrap_or(NetworkAction::Deny); - let host_loopback = policy - .network_ingress - .as_ref() - .map(|ingress| ingress.host_loopback) - .unwrap_or(NetworkAction::Deny); - if egress != ingress || ingress != host_loopback { - return Err(NanVixError::Preflight(ERR_DIRECTIONAL_NETWORK.to_string())); - } - Ok(egress == NetworkAction::Allow) - } - - /// Resolves a host entry list into IPv4/CIDR literals for nanvixd's - /// `-allow-host`/`-block-host` flags, alongside the entries that resolved - /// to nothing. - /// - /// - `a.b.c.d` and `a.b.c.d/n` literals pass through unchanged. - /// - Hostnames resolve to their IPv4 (A-record) addresses; AAAA results are - /// dropped because the guest filter is IPv4-only. - /// - Entries that fail to parse or resolve to any IPv4 address contribute - /// nothing to the resolved list and are collected into the second return - /// value so callers can warn (allowlist) or reject (blocklist). - /// - /// Mirrors `lxc::network_iptables::resolve_host` for the hostname-to-IPv4 - /// mapping; unlike that helper this also preserves IPv4/CIDR literals. - /// - /// Returns `(resolved_ips, unresolved_entries)`. Empty/whitespace entries - /// are ignored entirely and appear in neither list. - fn resolve_hosts_detailed(hosts: &[String]) -> (Vec, Vec) { - let mut out: Vec = Vec::new(); - let mut unresolved: Vec = Vec::new(); - for host in hosts { - let entry = host.trim(); - if entry.is_empty() { - continue; - } - let before = out.len(); - // CIDR literal: pass through only when the address is IPv4 and the - // prefix is in range. nanvixd parses CIDR directly. - if let Some((addr, prefix)) = entry.split_once('/') { - let addr_ok = addr.trim().parse::().is_ok(); - let prefix_ok = prefix - .trim() - .parse::() - .map(|p| p <= 32) - .unwrap_or(false); - if addr_ok && prefix_ok { - out.push(entry.to_string()); - } - } else if let Ok(addr) = entry.parse::() { - // Bare IP literal: keep IPv4, drop IPv6. - if addr.is_ipv4() { - out.push(entry.to_string()); - } - } else if let Ok(addrs) = format!("{}:0", entry).to_socket_addrs() { - // Hostname: resolve to IPv4 A records. - for ip in addrs.map(|a| a.ip()).filter(|ip| ip.is_ipv4()) { - out.push(ip.to_string()); - } - } - if out.len() == before { - unresolved.push(entry.to_string()); - } - } - (out, unresolved) - } - - /// Resolves the request's allow/block host lists, failing closed. - /// - /// Returns the resolved allow/block IPv4 lists plus human-readable - /// warnings for any allowlist entries that were dropped. At most one list - /// is non-empty (the mutual-exclusion check in [`Self::validate_policies`] - /// runs first). - /// - /// Fail-closed semantics differ by list direction: - /// - **Allowlist** (deny-by-default): a fully unresolvable allowlist is an - /// error, because emitting `-allow-host-networking` with no filter would - /// fail open (nanvixd treats no list as allow-all). Partially dropped - /// entries only narrow access, so they are reported as warnings and the - /// run continues. - /// - **Blocklist** (allow-by-default): *any* unresolvable entry is an - /// error. Silently dropping a blocked host would let traffic the policy - /// explicitly blocks flow freely (fail-open), and the static preflight - /// filter cannot enforce a name that does not resolve. - fn resolve_host_lists(request: &ExecutionRequest) -> Result { - let (allow, allow_unresolved) = Self::resolve_hosts_detailed(&request.policy.allowed_hosts); - if !request.policy.allowed_hosts.is_empty() && allow.is_empty() { - return Err(NanVixError::Preflight(ERR_HOSTS_UNRESOLVED.to_string())); - } - - let (block, block_unresolved) = Self::resolve_hosts_detailed(&request.policy.blocked_hosts); - if let Some(first) = block_unresolved.first() { - return Err(NanVixError::Preflight(format!( - "{} (entry: '{}')", - ERR_BLOCKED_HOST_UNRESOLVED, first - ))); - } - - let warnings = allow_unresolved - .iter() - .map(|h| { - format!( - "Warning: could not resolve allowedHosts entry '{}' to an IPv4 address; skipping", - h - ) - }) - .collect(); - - Ok(ResolvedHostLists { - allow, - block, - warnings, - }) - } - - fn validate_policies(request: &ExecutionRequest) -> Result<(), NanVixError> { - // denied_paths is explicitly rejected — microvm has no host visibility. - if !request.policy.denied_paths.is_empty() { - return Err(NanVixError::Preflight(ERR_DENIED_PATHS.to_string())); - } - // Per-host filtering is supported (forwarded to nanvixd as - // -allow-host/-block-host). The guest egress filter is allow-XOR-block, - // so the two lists are mutually exclusive; defaultPolicy is ignored when - // either list is present. - if !request.policy.allowed_hosts.is_empty() && !request.policy.blocked_hosts.is_empty() { - return Err(NanVixError::Preflight(ERR_NETWORK_HOSTS.to_string())); - } - if request.policy.network_proxy.is_enabled() { - return Err(NanVixError::Preflight(ERR_PROXY_POLICY.to_string())); - } - if !request.working_directory.is_empty() { - return Err(NanVixError::Preflight(ERR_WORKDIR.to_string())); - } - Self::resolve_networking_mode(request)?; - - Ok(()) - } - - fn nanvixd_command( - paths: &ResolvedPaths, - staging_dir: &Path, - request: &ExecutionRequest, - allow_hosts: &[String], - block_hosts: &[String], - ) -> Result { - let host_networking = Self::resolve_networking_mode(request)?; - let trace = nanvix_trace_enabled(); - // Default: silence nanvixd and inherit stderr so kernel traces (if - // any) stream straight to the parent terminal without a per-run - // host-side drain. Diagnostic mode pipes stderr so the runner can - // attach it to the wxc-exec log on failure. - let stderr = if trace { - Stdio::piped() - } else { - Stdio::inherit() - }; - - let mut cmd = Command::new(&paths.nanvixd); - - // Host networking is opt-in. When enabled, attach the host network - // backend; nanvixd parses this flag regardless of argument order, so - // it is added up front for both the Windows (snapshot) and Linux - // (cold-boot) invocations below. Verified to work on warm-start - // snapshot restore as well as cold boot. - if host_networking { - cmd.arg("-allow-host-networking"); - } - - // Per-host egress filtering. The two lists are mutually exclusive - // (validated upstream), so at most one of these loops emits flags. - // nanvixd requires `-allow-host-networking` for these to take effect, - // which is guaranteed because a non-empty list forces host_networking - // on (see `legacy_host_networking_enabled`). The guest daemon auto-exempts the - // DNS port in allowlist mode, so no resolver IPs are added here. - for host in allow_hosts { - cmd.arg("-allow-host").arg(host); - } - for host in block_hosts { - cmd.arg("-block-host").arg(host); - } - - #[cfg(target_os = "windows")] - { - // nanvixd loads kernel.vmem from /snapshots/ so cwd must be - // the snapshot home. All other paths are passed as absolute. - // nanvixd.exe [-allow-host-networking] -snapshot snapshots/kernel.whp.cbor - // -bin-dir /bin -ramfs -mount -- python3.initrd - let snapshot_rel = Path::new(SNAPSHOTS_DIR).join(SNAPSHOT_CBOR); - cmd.current_dir(&paths.snapshot_home) - .arg("-snapshot") - .arg(&snapshot_rel) - .arg("-bin-dir") - .arg(paths.exe_dir.join(BIN_DIR)) - .arg("-ramfs") - .arg(&paths.ramfs) - .arg("-mount") - .arg(staging_dir) - .arg("--") - .arg(&paths.initrd); - } - - #[cfg(target_os = "linux")] - { - // Linux invocation (cold boot via KVM): - // nanvixd.elf [-allow-host-networking] -ramfs -mount -- python3.initrd - cmd.current_dir(&paths.exe_dir) - .arg("-ramfs") - .arg(&paths.ramfs) - .arg("-mount") - .arg(staging_dir) - .arg("--") - .arg(&paths.initrd); - } - - cmd.stdin(Stdio::null()) - .stdout(Stdio::inherit()) - .stderr(stderr); - if !trace { - // Suppress nanvixd's env_logger output and per-run log file. - cmd.env("RUST_LOG", "off"); - } - Ok(cmd) - } - - fn spawn_nanvixd( - paths: &ResolvedPaths, - staging_dir: &Path, - request: &ExecutionRequest, - allow_hosts: &[String], - block_hosts: &[String], - ) -> Result { - Self::nanvixd_command(paths, staging_dir, request, allow_hosts, block_hosts)? - .spawn() - .map_err(|e| { - NanVixError::Platform(format!("failed to spawn {}: {}", NANVIXD_BINARY, e)) - }) - } - - fn start_watchdog( - child: &std::process::Child, - timeout_ms: u64, - cancel_pair: Arc<(Mutex, Condvar)>, - timed_out: Arc, - ) -> Option> { - if timeout_ms == u64::MAX { - return None; - } - - let duration = Duration::from_millis(timeout_ms); - - #[cfg(target_os = "windows")] - { - // Duplicate the process handle at spawn time (safe against PID reuse). - use std::os::windows::io::AsRawHandle; - use windows::Win32::Foundation::{DuplicateHandle, DUPLICATE_SAME_ACCESS, HANDLE}; - use windows::Win32::System::Threading::GetCurrentProcess; - - let raw = child.as_raw_handle(); - let mut dup_handle = HANDLE::default(); - let dup_ok = unsafe { - // SAFETY: `raw` is the live process HANDLE from `std::process::Child`. - // We duplicate it into the current process with same access rights so - // the watchdog thread can safely terminate/close it independently. - DuplicateHandle( - GetCurrentProcess(), - HANDLE(raw), - GetCurrentProcess(), - &mut dup_handle, - 0, - false, - DUPLICATE_SAME_ACCESS, - ) - }; - if dup_ok.is_err() { - return None; - } - let process_id_or_handle = dup_handle.0 as usize; - - Some(thread::spawn(move || { - watchdog_thread_fn(process_id_or_handle, duration, cancel_pair, timed_out); - })) - } - - #[cfg(target_os = "linux")] - { - // On Linux, use the child PID directly for kill-based termination. - let pid = child.id() as usize; - - Some(thread::spawn(move || { - watchdog_thread_fn(pid, duration, cancel_pair, timed_out); - })) - } - } - - fn setup_watchdog( - child: &mut std::process::Child, - timeout_ms: u64, - logger: &mut Logger, - ) -> Result { - let timed_out = Arc::new(AtomicBool::new(false)); - let cancel_pair = Arc::new((Mutex::new(false), Condvar::new())); - - let watchdog = if timeout_ms < u64::MAX { - match Self::start_watchdog( - child, - timeout_ms, - Arc::clone(&cancel_pair), - Arc::clone(&timed_out), - ) { - Some(handle) => Some(handle), - None => { - let err = NanVixError::Platform(format!( - "failed to duplicate {} process handle", - NANVIXD_BINARY - )); - let _ = writeln!(logger, "{}", err); - if let Err(e) = child.kill() { - let _ = writeln!( - logger, - "NanVix: failed to kill child after handle dup failure: {}", - e - ); - } - if let Err(e) = child.wait() { - let _ = writeln!( - logger, - "NanVix: failed to wait for child after handle dup failure: {}", - e - ); - } - return Err(err.to_response()); - } - } - } else { - None - }; - - Ok((watchdog, cancel_pair, timed_out)) - } - - fn log_resolved_paths(logger: &mut Logger, paths: &ResolvedPaths) { - let _ = writeln!(logger, "NanVix: nanvixd={:?}", paths.nanvixd); - let _ = writeln!(logger, "NanVix: ramfs={:?}", paths.ramfs); - let _ = writeln!(logger, "NanVix: initrd={:?}", paths.initrd); - let _ = writeln!(logger, "NanVix: snapshot_home={:?}", paths.snapshot_home); - } - - fn wait_and_respond( - child: &mut Child, - watchdog: Option>, - cancel_pair: &Arc<(Mutex, Condvar)>, - timed_out: &AtomicBool, - timeout_ms: u64, - script_timeout: u32, - logger: &mut Logger, - ) -> ScriptResponse { - // Drain stderr concurrently with `wait()` so a verbose child cannot - // block on a full pipe buffer. We retain only the last - // [`nanvix_common::STDERR_TAIL_BYTES`] bytes so an untrusted guest - // emitting unbounded stderr cannot cause host memory growth - // (availability / DoS hardening). In the default (non-trace) mode - // stderr is inherited and `child.stderr` is `None`, so the join - // returns the empty string immediately. - let stderr_handle = child - .stderr - .take() - .map(|s| thread::spawn(move || nanvix_common::drain_stderr_tail(s))); - - let exit_status = child.wait(); - - let stderr_output = stderr_handle - .and_then(|h| h.join().ok()) - .map(|(bytes, truncated)| nanvix_common::format_stderr_tail(&bytes, truncated)) - .unwrap_or_default(); - - { - let (lock, cvar) = &**cancel_pair; - let mut cancelled = lock.lock().unwrap_or_else(|e| e.into_inner()); - *cancelled = true; - cvar.notify_one(); - } - - if let Some(handle) = watchdog { - let _ = handle.join(); - } - - if timed_out.load(Ordering::SeqCst) { - let _ = child.kill(); - let err = NanVixError::Timeout { - script_timeout_ms: script_timeout, - total_ms: timeout_ms, - }; - let _ = writeln!(logger, "{}", err); - return err.to_response(); - } - - match exit_status { - Ok(status) => { - let exit_code = exit_code_from_status(&status); - let _ = writeln!(logger, "NanVix: process exited with code {}", exit_code); - if exit_code != 0 && !stderr_output.is_empty() { - let _ = writeln!(logger, "NanVix stderr:\n{}", stderr_output); - } - ScriptResponse { - exit_code, - ..Default::default() - } - } - Err(e) => { - if !stderr_output.is_empty() { - let _ = writeln!(logger, "NanVix stderr:\n{}", stderr_output); - } - let err = - NanVixError::Runtime(format!("failed to wait for {}: {}", NANVIXD_BINARY, e)); - let _ = writeln!(logger, "{}", err); - err.to_response() - } - } - } - /// Returns `true` when filesystem copyback should run. - /// Copyback runs on any normal process exit (including non-zero exit codes). - /// It is skipped for preflight, spawn, runtime, and timeout errors, and for - /// OS crashes (negative exit codes from NTSTATUS values). - fn should_copy_back(response: &ScriptResponse) -> bool { - response.error_message.is_empty() && response.exit_code >= 0 - } -} - -impl ScriptRunner for NanVixScriptRunner { - fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { - Self::validate_policies(request).map_err(|e| e.to_response())?; - validate_network_policy_support( - request, - NetworkPolicySupport::EGRESS_DEFAULT - | NetworkPolicySupport::INGRESS_DEFAULT - | NetworkPolicySupport::HOST_LOOPBACK, - )?; - Ok(()) - } - - fn execute(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { - let host_networking = match Self::resolve_networking_mode(request) { - Ok(enabled) => enabled, - Err(error) => return error.to_response(), - }; - let paths = match self.resolve_paths() { - Ok(p) => p, - Err(e) => return e.to_response(), - }; - - // Build staging directory with script and filesystem policy paths. - let staging_root = std::env::temp_dir().join("mxc-microvm"); - // Sweep orphaned staging dirs from previous crashed runs (older than 1 hour). - wxc_common::microvm_staging::sweep_orphaned_staging_dirs( - &staging_root, - std::time::Duration::from_secs(ORPHAN_SWEEP_MAX_AGE_SECS), - ); - let mut staging = match wxc_common::microvm_staging::StagingDir::new( - staging_root, - &request.script_code, - &request.policy.readwrite_paths, - &request.policy.readonly_paths, - ) { - Ok(s) => s, - Err(e) => { - let err = NanVixError::Preflight(e.to_string()); - let _ = writeln!(logger, "{}", err); - return err.to_response(); - } - }; - - Self::log_resolved_paths(logger, &paths); - let _ = writeln!(logger, "NanVix: staging_dir={:?}", staging.path()); - - if host_networking { - let _ = writeln!(logger, "NanVix: host networking enabled"); - } - let (allow_hosts, block_hosts) = match Self::resolve_host_lists(request) { - Ok(resolved) => { - for warning in &resolved.warnings { - let _ = writeln!(logger, "NanVix: {}", warning); - } - (resolved.allow, resolved.block) - } - Err(e) => { - let _ = writeln!(logger, "{}", e); - return e.to_response(); - } - }; - if !allow_hosts.is_empty() { - let _ = writeln!(logger, "NanVix: egress allowlist={:?}", allow_hosts); - } - if !block_hosts.is_empty() { - let _ = writeln!(logger, "NanVix: egress blocklist={:?}", block_hosts); - } - let mut child = match Self::spawn_nanvixd( - &paths, - staging.path(), - request, - &allow_hosts, - &block_hosts, - ) { - Ok(c) => c, - Err(e) => { - let _ = writeln!(logger, "{}", e); - return e.to_response(); - } - }; - - let staging_overhead = staging.staging_overhead_ms(); - let timeout_ms = Self::total_timeout_ms(request.script_timeout, staging_overhead); - let (watchdog, cancel_pair, timed_out) = - match Self::setup_watchdog(&mut child, timeout_ms, logger) { - Ok(v) => v, - Err(resp) => return resp, - }; - - let response = Self::wait_and_respond( - &mut child, - watchdog, - &cancel_pair, - timed_out.as_ref(), - timeout_ms, - request.script_timeout, - logger, - ); - - // Copy back RW filesystem changes on normal process exit. - if Self::should_copy_back(&response) { - if let Err(e) = staging.copy_back_to_host() { - let preserved = staging - .preserved_path() - .map(|p| p.display().to_string()) - .unwrap_or_default(); - let err = NanVixError::Runtime(format!( - "failed to copy back microvm filesystem changes: {}. \ - Staged files preserved at: {}", - e, preserved - )); - let _ = writeln!(logger, "{}", err); - return err.to_response(); - } - } - - response - // staging is dropped here → cleanup - } -} - -#[cfg(test)] -mod tests { - use super::*; - use wxc_common::logger::{Logger, Mode}; - use wxc_common::models::{ContainerPolicy, NetworkPolicy}; - - #[test] - fn total_timeout_adds_boot_staging_and_script() { - // script_timeout=0 => infinite script timeout sentinel. - assert_eq!(NanVixScriptRunner::total_timeout_ms(0, 0), u64::MAX); - // script_timeout=30000, staging_overhead=500 -> 30s + 500ms + 60s boot = 90.5s - assert_eq!(NanVixScriptRunner::total_timeout_ms(30_000, 500), 90_500); - // script_timeout=30000, no staging -> 30s + 60s boot = 90s - assert_eq!(NanVixScriptRunner::total_timeout_ms(30_000, 0), 90_000); - } - - #[test] - fn resolve_paths_fails_when_exe_dir_has_no_binaries() { - let runner = NanVixScriptRunner::new(); - let err = runner.resolve_paths().unwrap_err(); - assert!(err.to_string().contains("not found"), "got: {}", err); - } - - // -- Policy validation tests ------------------------------------------------- - - fn directional_request( - egress: NetworkAction, - ingress: NetworkAction, - host_loopback: NetworkAction, - ) -> ExecutionRequest { - ExecutionRequest { - policy: ContainerPolicy { - network_egress: Some(wxc_common::models::NetworkEgressPolicy { - default: egress, - allow: Vec::new(), - deny: Vec::new(), - }), - network_ingress: Some(wxc_common::models::NetworkIngressPolicy { - default: ingress, - host_loopback, - }), - network_specified: true, - network_mode_specified: true, - ..Default::default() - }, - ..Default::default() - } - } - - fn command_arguments( - request: &ExecutionRequest, - allow_hosts: &[String], - block_hosts: &[String], - ) -> Result, NanVixError> { - let root = PathBuf::from("nanvix-command-test"); - let paths = ResolvedPaths { - nanvixd: root.join(NANVIXD_BINARY), - ramfs: root.join("rootfs.img"), - initrd: root.join("python3.initrd"), - exe_dir: root.clone(), - snapshot_home: root, - }; - let command = NanVixScriptRunner::nanvixd_command( - &paths, - Path::new("staging"), - request, - allow_hosts, - block_hosts, - )?; - Ok(command - .get_args() - .map(|argument| argument.to_string_lossy().into_owned()) - .collect()) - } - - #[test] - fn directional_networking_requires_a_coherent_explicit_posture() { - let runner = NanVixScriptRunner::new(); - for egress in [NetworkAction::Deny, NetworkAction::Allow] { - for ingress in [NetworkAction::Deny, NetworkAction::Allow] { - for host_loopback in [NetworkAction::Deny, NetworkAction::Allow] { - let request = directional_request(egress, ingress, host_loopback); - if egress == ingress && ingress == host_loopback { - runner.validate_runner(&request).unwrap(); - assert_eq!( - NanVixScriptRunner::resolve_networking_mode(&request).unwrap(), - egress == NetworkAction::Allow - ); - let arguments = command_arguments(&request, &[], &[]).unwrap(); - assert_eq!( - arguments - .iter() - .filter(|arg| *arg == "-allow-host-networking") - .count(), - usize::from(egress == NetworkAction::Allow) - ); - } else { - let error = runner.validate_runner(&request).unwrap_err(); - assert!(error.error_message.contains(ERR_DIRECTIONAL_NETWORK)); - assert!(command_arguments(&request, &[], &[]).is_err()); - } - } - } - } - } - - #[test] - fn directional_egress_allow_does_not_implicitly_allow_ingress() { - let mut request = directional_request( - NetworkAction::Allow, - NetworkAction::Allow, - NetworkAction::Allow, - ); - request.policy.network_ingress = None; - assert!(NanVixScriptRunner::resolve_networking_mode(&request).is_err()); - } - - #[test] - fn directional_filter_requests_are_rejected_before_execution() { - for rules in ["allow", "deny"] { - let source = format!( - r#"{{"version":"0.10.0-alpha","containment":"microvm", - "process":{{"commandLine":"print(1)"}}, - "network":{{"egress":{{"default":"allow","{rules}":[{{"to":[{{"cidr":"203.0.113.0/24"}}]}}]}}, - "ingress":{{"default":"allow","hostLoopback":"allow"}}}}}}"# - ); - let mut logger = Logger::new(Mode::Buffer); - let parsed = - wxc_common::config_parser::load_mxc_request_from_json(&source, &mut logger) - .unwrap(); - let wxc_common::state_aware_request::MxcRequest::OneShot(request) = parsed else { - panic!("expected one-shot"); - }; - let error = NanVixScriptRunner::new() - .validate_runner(&request) - .unwrap_err(); - assert!(error.error_message.contains(ERR_DIRECTIONAL_FILTERS)); - assert!(command_arguments(&request, &[], &[]).is_err()); - } - } - - #[test] - fn public_directional_fixtures_enable_host_networking() { - for source in [ - include_str!("../../../../../tests/configs/microvm_network.json"), - include_str!("../../../../../tests/configs/microvm_network_linux.json"), - ] { - let mut logger = Logger::new(Mode::Buffer); - let parsed = - wxc_common::config_parser::load_mxc_request_from_json(source, &mut logger).unwrap(); - let wxc_common::state_aware_request::MxcRequest::OneShot(request) = parsed else { - panic!("expected one-shot"); - }; - NanVixScriptRunner::new().validate_runner(&request).unwrap(); - assert!(NanVixScriptRunner::resolve_networking_mode(&request).unwrap()); - assert!(!NanVixScriptRunner::legacy_host_networking_enabled( - &request - )); - let arguments = command_arguments(&request, &[], &[]).unwrap(); - assert_eq!( - arguments.first().map(String::as_str), - Some("-allow-host-networking") - ); - assert!(!arguments - .iter() - .any(|arg| arg == "-allow-host" || arg == "-block-host")); - } - } - - #[test] - fn legacy_launch_arguments_preserve_network_defaults_and_host_filters() { - for compatibility in [ - wxc_common::models::NetworkEnforcementCompatibility::LegacyCompatible, - wxc_common::models::NetworkEnforcementCompatibility::Strict, - ] { - for (default, allow, block, expected_prefix) in [ - (NetworkPolicy::Block, vec![], vec![], vec![]), - ( - NetworkPolicy::Allow, - vec![], - vec![], - vec!["-allow-host-networking"], - ), - ( - NetworkPolicy::Block, - vec!["192.0.2.1"], - vec![], - vec!["-allow-host-networking", "-allow-host", "192.0.2.1"], - ), - ( - NetworkPolicy::Block, - vec![], - vec!["192.0.2.0/24"], - vec!["-allow-host-networking", "-block-host", "192.0.2.0/24"], - ), - ] { - let request = ExecutionRequest { - network_enforcement_compatibility: compatibility, - policy: ContainerPolicy { - default_network_policy: default, - allowed_hosts: allow.into_iter().map(str::to_owned).collect(), - blocked_hosts: block.into_iter().map(str::to_owned).collect(), - ..Default::default() - }, - ..Default::default() - }; - let arguments = command_arguments( - &request, - &request.policy.allowed_hosts, - &request.policy.blocked_hosts, - ) - .unwrap(); - let expected: Vec = - expected_prefix.into_iter().map(str::to_owned).collect(); - assert!( - arguments.starts_with(&expected), - "{compatibility:?}: {arguments:?}" - ); - assert_eq!( - arguments.iter().any(|arg| arg == "-allow-host-networking"), - !expected.is_empty(), - "{compatibility:?}: {arguments:?}" - ); - } - } - } - - #[test] - fn policy_accepts_readwrite_paths() { - // Validation passes; the runner fails later on path resolution. - let request = ExecutionRequest { - script_code: "echo test".to_string(), - policy: ContainerPolicy { - readwrite_paths: vec!["/tmp".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let result = NanVixScriptRunner::validate_policies(&request); - assert!(result.is_ok(), "readwrite_paths accepted"); - } - - #[test] - fn policy_accepts_readonly_paths() { - let request = ExecutionRequest { - script_code: "echo test".to_string(), - policy: ContainerPolicy { - readonly_paths: vec!["/data".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let result = NanVixScriptRunner::validate_policies(&request); - assert!(result.is_ok(), "readonly_paths accepted"); - } - - #[test] - fn policy_rejects_denied_paths() { - let request = ExecutionRequest { - policy: ContainerPolicy { - denied_paths: vec!["/secret".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let result = NanVixScriptRunner::validate_policies(&request); - assert!(result.is_err(), "denied_paths should be rejected"); - let err = result.unwrap_err().to_string(); - assert!( - err.contains(ERR_DENIED_PATHS), - "expected denied_paths error, got: {}", - err - ); - } - - #[test] - fn policy_accepts_allowlist_only() { - // A bare allowlist is now supported (forwarded as -allow-host). - let request = ExecutionRequest { - script_code: "echo test".to_string(), - policy: ContainerPolicy { - allowed_hosts: vec!["93.184.216.34".to_string()], - ..Default::default() - }, - ..Default::default() - }; - assert!( - NanVixScriptRunner::validate_policies(&request).is_ok(), - "a bare allowlist should pass validation" - ); - // A list implies host networking regardless of defaultPolicy (Block). - assert!(NanVixScriptRunner::legacy_host_networking_enabled(&request)); - } - - #[test] - fn policy_accepts_blocklist_only() { - // A bare blocklist is now supported (forwarded as -block-host). - let request = ExecutionRequest { - script_code: "echo test".to_string(), - policy: ContainerPolicy { - blocked_hosts: vec!["93.184.216.34".to_string()], - ..Default::default() - }, - ..Default::default() - }; - assert!( - NanVixScriptRunner::validate_policies(&request).is_ok(), - "a bare blocklist should pass validation" - ); - assert!(NanVixScriptRunner::legacy_host_networking_enabled(&request)); - } - - #[test] - fn policy_rejects_both_host_lists() { - // allow + block are mutually exclusive (the guest filter is allow-XOR-block). - let request = ExecutionRequest { - script_code: "echo test".to_string(), - policy: ContainerPolicy { - allowed_hosts: vec!["10.0.0.1".to_string()], - blocked_hosts: vec!["10.0.0.2".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let err = NanVixScriptRunner::validate_policies(&request).unwrap_err(); - assert!( - err.to_string().contains(ERR_NETWORK_HOSTS), - "both lists should be rejected, got: {}", - err - ); - } - - // -- Host resolution / decision-matrix tests -------------------------------- - - #[test] - fn resolve_hosts_passes_ipv4_and_cidr_literals() { - let hosts = vec![ - "1.2.3.4".to_string(), - "10.0.0.0/8".to_string(), - "192.168.1.1/32".to_string(), - ]; - let (resolved, unresolved) = NanVixScriptRunner::resolve_hosts_detailed(&hosts); - assert_eq!(resolved, vec!["1.2.3.4", "10.0.0.0/8", "192.168.1.1/32"]); - assert!(unresolved.is_empty()); - } - - #[test] - fn resolve_hosts_drops_ipv6_and_bad_entries() { - let hosts = vec![ - "::1".to_string(), // IPv6 literal -> dropped - "2001:db8::/32".to_string(), // IPv6 CIDR -> dropped (addr not IPv4) - "1.2.3.4/33".to_string(), // out-of-range prefix -> dropped - " ".to_string(), // blank -> skipped - "5.6.7.8".to_string(), // valid -> kept - ]; - let (resolved, _) = NanVixScriptRunner::resolve_hosts_detailed(&hosts); - assert_eq!(resolved, vec!["5.6.7.8"]); - } - - #[test] - fn resolve_host_lists_returns_resolved_allow() { - let request = ExecutionRequest { - policy: ContainerPolicy { - allowed_hosts: vec!["1.1.1.1".to_string(), "8.8.8.8/32".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let resolved = NanVixScriptRunner::resolve_host_lists(&request).unwrap(); - assert_eq!(resolved.allow, vec!["1.1.1.1", "8.8.8.8/32"]); - assert!(resolved.block.is_empty()); - assert!(resolved.warnings.is_empty()); - } - - #[test] - fn resolve_host_lists_fails_closed_when_allowlist_unresolvable() { - // A non-empty allowlist that resolves to nothing must error rather than - // silently fall through to allow-all. - let request = ExecutionRequest { - policy: ContainerPolicy { - // IPv6-only literal resolves to no IPv4 entry. - allowed_hosts: vec!["::1".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let err = NanVixScriptRunner::resolve_host_lists(&request).unwrap_err(); - assert!( - err.to_string().contains(ERR_HOSTS_UNRESOLVED), - "unresolvable allowlist should fail closed, got: {}", - err - ); - } - - #[test] - fn resolve_hosts_detailed_reports_unresolved_entries() { - // IPv4/CIDR pass through; IPv6 + malformed entries are reported as - // unresolved; blanks are ignored entirely. - let hosts = vec![ - "5.6.7.8".to_string(), // valid -> kept - "::1".to_string(), // IPv6 literal -> unresolved - "2001:db8::/32".to_string(), // IPv6 CIDR -> unresolved - "1.2.3.4/33".to_string(), // out-of-range prefix -> unresolved - "not_a_host.invalid".to_string(), // no A record -> unresolved - " ".to_string(), // blank -> ignored (neither list) - ]; - let (resolved, unresolved) = NanVixScriptRunner::resolve_hosts_detailed(&hosts); - assert_eq!(resolved, vec!["5.6.7.8"]); - assert_eq!( - unresolved, - vec!["::1", "2001:db8::/32", "1.2.3.4/33", "not_a_host.invalid"] - ); - } - - #[test] - fn resolve_host_lists_warns_on_dropped_allowlist_entries() { - // A partially-resolvable allowlist narrows access (fail-safe): the run - // continues and each dropped entry produces a warning. - let request = ExecutionRequest { - policy: ContainerPolicy { - allowed_hosts: vec![ - "9.9.9.9".to_string(), - "::1".to_string(), - "dropme.invalid".to_string(), - ], - ..Default::default() - }, - ..Default::default() - }; - let resolved = NanVixScriptRunner::resolve_host_lists(&request).unwrap(); - assert_eq!(resolved.allow, vec!["9.9.9.9"]); - assert!(resolved.block.is_empty()); - assert_eq!(resolved.warnings.len(), 2, "two entries were dropped"); - assert!(resolved.warnings.iter().any(|w| w.contains("::1"))); - assert!(resolved - .warnings - .iter() - .any(|w| w.contains("dropme.invalid"))); - } - - #[test] - fn resolve_host_lists_fails_closed_on_unresolvable_blocklist_entry() { - // A blocklist (allow-by-default) must fail closed if ANY entry cannot - // be resolved -- silently dropping it would let blocked traffic flow. - let request = ExecutionRequest { - policy: ContainerPolicy { - blocked_hosts: vec!["10.0.0.1".to_string(), "::1".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let err = NanVixScriptRunner::resolve_host_lists(&request).unwrap_err(); - assert!( - err.to_string().contains(ERR_BLOCKED_HOST_UNRESOLVED), - "unresolvable blocklist entry should fail closed, got: {}", - err - ); - assert!( - err.to_string().contains("::1"), - "error should name the offending entry, got: {}", - err - ); - } - - #[test] - fn resolve_host_lists_accepts_fully_resolved_blocklist() { - let request = ExecutionRequest { - policy: ContainerPolicy { - blocked_hosts: vec!["10.0.0.1".to_string(), "192.168.0.0/16".to_string()], - ..Default::default() - }, - ..Default::default() - }; - let resolved = NanVixScriptRunner::resolve_host_lists(&request).unwrap(); - assert!(resolved.allow.is_empty()); - assert_eq!(resolved.block, vec!["10.0.0.1", "192.168.0.0/16"]); - assert!(resolved.warnings.is_empty()); - } - - #[test] - fn default_block_no_lists_disables_host_networking() { - // The default posture (block, no lists) keeps networking off. - let request = ExecutionRequest::default(); - assert!(!NanVixScriptRunner::legacy_host_networking_enabled( - &request - )); - let resolved = NanVixScriptRunner::resolve_host_lists(&request).unwrap(); - assert!(resolved.allow.is_empty() && resolved.block.is_empty()); - assert!(resolved.warnings.is_empty()); - } - - #[test] - fn allow_policy_enables_host_networking() { - // `network.defaultPolicy = "allow"` maps to host networking and must - // pass validation (the run later fails on missing nanvixd binaries, - // not on policy). Per-host filtering is absent, so it is accepted. - let request = ExecutionRequest { - script_code: "echo test".to_string(), - policy: ContainerPolicy { - default_network_policy: NetworkPolicy::Allow, - ..Default::default() - }, - ..Default::default() - }; - assert!(NanVixScriptRunner::legacy_host_networking_enabled(&request)); - assert!( - NanVixScriptRunner::validate_policies(&request).is_ok(), - "allow posture without per-host filtering should pass validation" - ); - - let mut runner = NanVixScriptRunner::new(); - let mut logger = Logger::new(Mode::Buffer); - let resp = runner.run(&request, &mut logger); - assert_eq!(resp.exit_code, ERROR_EXIT_CODE); - assert!( - !resp.error_message.contains(ERR_NETWORK_HOSTS), - "allow posture must not trigger a network policy rejection, got: {}", - resp.error_message - ); - } - - #[test] - fn policy_rejects_working_directory() { - let mut runner = NanVixScriptRunner::new(); - let request = ExecutionRequest { - script_code: "echo test".to_string(), - working_directory: "/home/user".to_string(), - ..Default::default() - }; - let mut logger = Logger::new(Mode::Buffer); - let resp = runner.run(&request, &mut logger); - assert_eq!(resp.exit_code, ERROR_EXIT_CODE); - assert!(resp.error_message.contains(ERR_WORKDIR)); - } - - #[test] - fn policy_allows_defaults() { - // NanVix accepts a default (deny-by-default) policy. With no host - // networking requested, the run later fails on missing nanvixd - // binaries, not on policy. - let mut runner = NanVixScriptRunner::new(); - let request = ExecutionRequest { - script_code: "echo test".to_string(), - ..Default::default() - }; - assert!(!NanVixScriptRunner::legacy_host_networking_enabled( - &request - )); - let mut logger = Logger::new(Mode::Buffer); - let resp = runner.run(&request, &mut logger); - assert_eq!(resp.exit_code, ERROR_EXIT_CODE); - assert!( - !resp.error_message.contains(ERR_NETWORK_HOSTS), - "default request should not trigger network policy rejection" - ); - assert!( - !resp.error_message.contains(ERR_WORKDIR), - "default request should not trigger workingDirectory rejection" - ); - } - - #[test] - fn policy_rejects_network_proxy() { - let mut runner = NanVixScriptRunner::new(); - let request = ExecutionRequest { - script_code: "echo test".to_string(), - policy: ContainerPolicy { - network_proxy: wxc_common::models::ProxyConfig { - address: Some(wxc_common::models::ProxyAddress::new( - "127.0.0.1".to_string(), - 8080, - )), - builtin_test_server: false, - }, - ..Default::default() - }, - ..Default::default() - }; - let mut logger = Logger::new(Mode::Buffer); - let resp = runner.run(&request, &mut logger); - assert_eq!(resp.exit_code, ERROR_EXIT_CODE); - assert!(resp.error_message.contains(ERR_PROXY_POLICY)); - } - - #[test] - fn resolve_paths_checks_for_snapshot() { - let runner = NanVixScriptRunner::new(); - let err = runner.resolve_paths().unwrap_err(); - // Should fail on missing binaries (not on snapshot specifically, - // since nanvixd.exe is checked first). - assert!(err.to_string().contains("not found"), "got: {}", err); - } - - // -- Copyback decision tests ------------------------------------------------ - - #[test] - fn copyback_allowed_for_zero_exit() { - let response = ScriptResponse { - exit_code: 0, - ..Default::default() - }; - assert!(NanVixScriptRunner::should_copy_back(&response)); - } - - #[test] - fn copyback_allowed_for_nonzero_normal_exit() { - let response = ScriptResponse { - exit_code: 42, - ..Default::default() - }; - assert!(NanVixScriptRunner::should_copy_back(&response)); - } - - #[test] - fn copyback_skipped_for_runner_error() { - let response = ScriptResponse { - exit_code: ERROR_EXIT_CODE, - error_message: "NanVix execution timed out after 90000ms".to_string(), - ..Default::default() - }; - assert!(!NanVixScriptRunner::should_copy_back(&response)); - } - - #[test] - fn copyback_skipped_for_os_crash() { - // STATUS_ACCESS_VIOLATION = 0xC0000005 → interpreted as i32 = -1073741819. - // This is a nanvixd OS crash — copyback must be suppressed. - let response = ScriptResponse { - exit_code: -1073741819_i32, - error_message: String::new(), - ..Default::default() - }; - assert!( - !NanVixScriptRunner::should_copy_back(&response), - "copyback must be skipped for NTSTATUS crash exit codes" - ); - } - - #[test] - fn copyback_skipped_for_signal_killed() { - // On Linux, SIGKILL results in exit code -9 (negative signal number). - let response = ScriptResponse { - exit_code: -9, - error_message: String::new(), - ..Default::default() - }; - assert!( - !NanVixScriptRunner::should_copy_back(&response), - "copyback must be skipped for signal-killed processes" - ); - } - - // -- Platform-specific constant tests --------------------------------------- - - #[test] - fn nanvixd_binary_matches_platform() { - #[cfg(target_os = "linux")] - assert_eq!(NANVIXD_BINARY, "nanvixd.elf"); - #[cfg(target_os = "windows")] - assert_eq!(NANVIXD_BINARY, "nanvixd.exe"); - } - - #[test] - fn total_timeout_infinite_when_zero() { - assert_eq!(NanVixScriptRunner::total_timeout_ms(0, 0), u64::MAX); - assert_eq!(NanVixScriptRunner::total_timeout_ms(0, 500), u64::MAX); - } - - #[test] - fn total_timeout_saturates_on_overflow() { - // With values that would cause u64 overflow, should saturate at u64::MAX. - let result = NanVixScriptRunner::total_timeout_ms(u32::MAX, u64::MAX - 1); - assert_eq!(result, u64::MAX); - } - - // -- Watchdog timeout state tests ------------------------------------------ - - #[test] - fn watchdog_state_no_thread_when_infinite_timeout() { - // When timeout is u64::MAX, start_watchdog should return None. - // We can't test this directly without a real child process, but we can - // verify the total_timeout_ms sentinel logic. - let timeout = NanVixScriptRunner::total_timeout_ms(0, 0); - assert_eq!( - timeout, - u64::MAX, - "zero script_timeout should yield infinite" - ); - } - - // -- NanVixError display tests --------------------------------------------- - - #[test] - fn error_display_preflight() { - let err = NanVixError::Preflight("missing binary".to_string()); - assert!(err.to_string().contains("preflight")); - assert!(err.to_string().contains("missing binary")); - } - - #[test] - fn error_display_platform() { - let err = NanVixError::Platform("spawn failed".to_string()); - assert!(err.to_string().contains("platform")); - assert!(err.to_string().contains("spawn failed")); - } - - #[test] - fn error_display_runtime() { - let err = NanVixError::Runtime("VM crashed".to_string()); - assert!(err.to_string().contains("runtime")); - assert!(err.to_string().contains("VM crashed")); - } - - #[test] - fn error_display_timeout() { - let err = NanVixError::Timeout { - script_timeout_ms: 5000, - total_ms: 65000, - }; - let msg = err.to_string(); - assert!(msg.contains("timed out")); - assert!(msg.contains("65000")); - assert!(msg.contains("5000")); - } - - #[test] - fn error_to_response_has_error_exit_code() { - let err = NanVixError::Preflight("test".to_string()); - let resp = err.to_response(); - assert_eq!(resp.exit_code, ERROR_EXIT_CODE); - assert!(!resp.error_message.is_empty()); - } -} diff --git a/src/backends/nvx/binaries/Cargo.toml b/src/backends/nvx/binaries/Cargo.toml new file mode 100644 index 000000000..378684319 --- /dev/null +++ b/src/backends/nvx/binaries/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "nvx_binaries" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Build-time acquisition of pinned NVX release artifacts" +links = "nvx_binaries" + +[lib] +path = "src/lib.rs" + +[features] +# Expensive download and verification work is disabled for default workspace +# builds and enabled only by future NVX backend consumers. +nvx = [] + +[dependencies] +nvx_common = { workspace = true } + +[build-dependencies] +nvx_build_common = { workspace = true } +nvx_common = { workspace = true } +sha2 = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/src/backends/nvx/binaries/build.rs b/src/backends/nvx/binaries/build.rs new file mode 100644 index 000000000..08bc82bca --- /dev/null +++ b/src/backends/nvx/binaries/build.rs @@ -0,0 +1,280 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Downloads and verifies pinned NVX artifacts at build time. + +use std::collections::HashMap; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use nvx_common::{ + github_download_url, load_checksums, load_json, ReleaseConfig, WINDOWS_PLATFORM_ARTIFACTS, + WORKLOAD_IMAGE_ARTIFACTS, +}; +use sha2::{Digest, Sha256}; + +fn main() { + println!("cargo:rerun-if-env-changed=CARGO_FEATURE_NVX"); + + if std::env::var_os("CARGO_FEATURE_NVX").is_none() { + emit_disabled_metadata(); + return; + } + + ensure_supported_target(); + + let out_dir = PathBuf::from( + std::env::var_os("OUT_DIR").expect("nvx_binaries: OUT_DIR is not set by Cargo"), + ); + let (bin_dir, prefetched) = + nvx_build_common::resolve_bin_dir(&out_dir).unwrap_or_else(|error| { + panic!("nvx_binaries: failed to resolve artifact directory: {error}") + }); + let release: ReleaseConfig = load_json("versions.json"); + let checksums = load_checksums("checksums.json"); + let workload_images_available = release.workload_image_asset.is_some(); + + if prefetched { + eprintln!( + "nvx_binaries: NVX_BIN set; using pre-fetched artifacts from '{}' (offline)", + bin_dir.display() + ); + } else { + ensure_asset( + &release, + &release.windows_whp_asset, + &WINDOWS_PLATFORM_ARTIFACTS, + &bin_dir, + &checksums, + ); + + if let Some(asset) = release.workload_image_asset.as_deref() { + ensure_asset( + &release, + asset, + &WORKLOAD_IMAGE_ARTIFACTS, + &bin_dir, + &checksums, + ); + } + } + + validate_artifacts( + &bin_dir, + nvx_build_common::available_artifact_rel_paths(workload_images_available), + &checksums, + ) + .unwrap_or_else(|error| panic!("nvx_binaries: {error}")); + + nvx_build_common::emit_rerun_for_artifacts(&bin_dir, workload_images_available); + + emit_metadata(&bin_dir, workload_images_available); +} + +fn ensure_supported_target() { + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + let target = std::env::var("TARGET").expect("nvx_binaries: TARGET is not set by Cargo"); + nvx_build_common::validate_nvx_target(&target, &target_os, &target_arch, &target_env) + .unwrap_or_else(|error| panic!("nvx_binaries: {error}")); +} + +fn emit_disabled_metadata() { + let out_dir = std::env::var("OUT_DIR").expect("nvx_binaries: OUT_DIR is not set by Cargo"); + println!("cargo:rustc-env=NVX_BIN_DIR={out_dir}"); + println!("cargo:rustc-env=NVX_WORKLOAD_IMAGES_AVAILABLE=0"); + println!("cargo:BIN_DIR={out_dir}"); + println!("cargo:WORKLOAD_IMAGES_AVAILABLE=0"); + println!("cargo:rerun-if-changed=build.rs"); +} + +fn emit_metadata(bin_dir: &Path, workload_images_available: bool) { + let available = if workload_images_available { "1" } else { "0" }; + println!("cargo:rustc-env=NVX_BIN_DIR={}", bin_dir.display()); + println!("cargo:rustc-env=NVX_WORKLOAD_IMAGES_AVAILABLE={available}"); + println!("cargo:BIN_DIR={}", bin_dir.display()); + println!("cargo:WORKLOAD_IMAGES_AVAILABLE={available}"); + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=versions.json"); + println!("cargo:rerun-if-changed=checksums.json"); + println!("cargo:rerun-if-env-changed=NVX_BIN"); +} + +fn ensure_asset( + release: &ReleaseConfig, + asset: &str, + relative_paths: &[&str], + bin_dir: &Path, + checksums: &HashMap, +) { + if validate_artifacts(bin_dir, relative_paths.iter().copied(), checksums).is_ok() { + eprintln!("nvx_binaries: '{asset}' artifacts are cached and verified"); + return; + } + + let url = github_download_url(&release.repository, &release.tag, asset); + let archive = bin_dir.join(asset); + download(&url, &archive).unwrap_or_else(|error| { + let _ = fs::remove_file(&archive); + panic!("nvx_binaries: failed to download '{asset}': {error}"); + }); + + extract_and_stage(&archive, asset, relative_paths, bin_dir, checksums).unwrap_or_else( + |error| { + let _ = fs::remove_file(&archive); + panic!("nvx_binaries: failed to extract '{asset}': {error}"); + }, + ); + let _ = fs::remove_file(archive); +} + +fn download(url: &str, destination: &Path) -> Result<(), String> { + let mut command = Command::new("curl"); + command.args([ + "--silent", + "--show-error", + "--fail", + "--location", + "--retry", + "5", + "--retry-delay", + "5", + "--retry-all-errors", + "--output", + ]); + command.arg(destination); + command.args(["--header", "User-Agent: mxc-nvx-build/0.1"]); + + command.arg(url); + + let output = command + .output() + .map_err(|error| format!("could not start curl: {error}"))?; + if !output.status.success() { + return Err(format!( + "curl exited with {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + )); + } + Ok(()) +} + +fn extract_and_stage( + archive: &Path, + asset: &str, + relative_paths: &[&str], + bin_dir: &Path, + checksums: &HashMap, +) -> Result<(), String> { + let archive_root = archive_root(asset)?; + let extraction_dir = bin_dir.join(".nvx-extract"); + if extraction_dir.exists() { + fs::remove_dir_all(&extraction_dir).map_err(|error| { + format!( + "failed to clean extraction directory '{}': {error}", + extraction_dir.display() + ) + })?; + } + fs::create_dir_all(&extraction_dir).map_err(|error| { + format!( + "failed to create extraction directory '{}': {error}", + extraction_dir.display() + ) + })?; + + let archive_paths: Vec = relative_paths + .iter() + .map(|path| format!("{archive_root}/{path}")) + .collect(); + let mut command = Command::new("tar"); + command + .arg("-xf") + .arg(archive) + .arg("-C") + .arg(&extraction_dir); + command.args(&archive_paths); + let output = command + .output() + .map_err(|error| format!("could not start tar: {error}"))?; + if !output.status.success() { + let _ = fs::remove_dir_all(&extraction_dir); + return Err(format!( + "tar exited with {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + )); + } + + let extracted_root = extraction_dir.join(archive_root); + let result = validate_artifacts(&extracted_root, relative_paths.iter().copied(), checksums) + .and_then(|()| { + nvx_build_common::copy_artifact_paths( + &extracted_root, + bin_dir, + relative_paths.iter().copied(), + ) + .map_err(|error| error.to_string()) + }); + let cleanup_result = fs::remove_dir_all(&extraction_dir); + + result?; + cleanup_result.map_err(|error| { + format!( + "failed to remove extraction directory '{}': {error}", + extraction_dir.display() + ) + }) +} + +fn archive_root(asset: &str) -> Result<&str, String> { + asset + .strip_suffix(".zip") + .or_else(|| asset.strip_suffix(".tar.gz")) + .ok_or_else(|| format!("unsupported NVX archive name '{asset}'")) +} + +fn validate_artifacts<'a>( + root: &Path, + relative_paths: impl IntoIterator, + checksums: &HashMap, +) -> Result<(), String> { + for relative_path in relative_paths { + let artifact = root.join(relative_path); + if !artifact.is_file() { + return Err(format!( + "required artifact '{}' is missing", + artifact.display() + )); + } + let expected = checksums.get(relative_path).ok_or_else(|| { + format!("artifact '{relative_path}' has no checksum entry; refusing unverified input") + })?; + let actual = sha256(&artifact) + .map_err(|error| format!("failed to hash '{}': {error}", artifact.display()))?; + if actual != *expected { + return Err(format!( + "SHA-256 mismatch for '{relative_path}': expected {expected}, actual {actual}" + )); + } + } + Ok(()) +} + +fn sha256(path: &Path) -> io::Result { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let bytes_read = file.read(&mut buffer)?; + if bytes_read == 0 { + break; + } + hasher.update(&buffer[..bytes_read]); + } + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/src/backends/nvx/binaries/checksums.json b/src/backends/nvx/binaries/checksums.json new file mode 100644 index 000000000..888b8d4a5 --- /dev/null +++ b/src/backends/nvx/binaries/checksums.json @@ -0,0 +1,5 @@ +{ + "bin/openvmm.exe": "9ded05b56389ebd9fd2c7fc245779ecea203ce1b1e16488fd275cf31bc442913", + "guest/vmlinux": "1678a788ce5329db3e0e97ebc8703c1263da553dd2c682b502d9d5e6fbd0f3bb", + "guest/initramfs.cpio.gz": "0f0324d6a1a959f5156f2e8ab94195b3befe34523892d31933bf6e3d7bf7bae4" +} diff --git a/src/backends/nvx/binaries/src/lib.rs b/src/backends/nvx/binaries/src/lib.rs new file mode 100644 index 000000000..a95df2700 --- /dev/null +++ b/src/backends/nvx/binaries/src/lib.rs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Build-time location and availability metadata for pinned NVX artifacts. + +/// Directory containing the acquired NVX artifacts. +pub const NVX_BIN_DIR: &str = env!("NVX_BIN_DIR"); + +/// `"1"` when workload images are included in the configured release. +pub const NVX_WORKLOAD_IMAGES_AVAILABLE: &str = env!("NVX_WORKLOAD_IMAGES_AVAILABLE"); + +pub use nvx_common::{WINDOWS_PLATFORM_ARTIFACTS, WORKLOAD_IMAGE_ARTIFACTS}; + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use nvx_common::ReleaseConfig; + + const VERSIONS_JSON: &str = include_str!("../versions.json"); + const CHECKSUMS_JSON: &str = include_str!("../checksums.json"); + + #[test] + fn pinned_release_manifest_is_complete_for_platform_archive() { + let release: ReleaseConfig = + serde_json::from_str(VERSIONS_JSON).expect("versions.json must be valid"); + + assert_eq!(release.repository, "microsoft/nvx"); + assert_eq!(release.tag, "v0.1.0-dev.5c86da3dff02"); + assert_eq!(release.windows_whp_asset, "nvx-0.1.0-windows-whp.zip"); + assert_eq!(release.workload_image_asset, None); + } + + #[test] + fn checksums_cover_only_published_platform_artifacts() { + let checksums: HashMap = + serde_json::from_str(CHECKSUMS_JSON).expect("checksums.json must be valid"); + + assert_eq!(checksums.len(), WINDOWS_PLATFORM_ARTIFACTS.len()); + for relative_path in WINDOWS_PLATFORM_ARTIFACTS { + let checksum = checksums + .get(relative_path) + .expect("platform artifact must have a checksum"); + assert_eq!(checksum.len(), 64); + assert!(checksum + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))); + } + for relative_path in WORKLOAD_IMAGE_ARTIFACTS { + assert!(!checksums.contains_key(relative_path)); + } + } + + #[test] + fn release_without_workload_asset_reports_images_unavailable() { + assert_eq!(NVX_WORKLOAD_IMAGES_AVAILABLE, "0"); + } +} diff --git a/src/backends/nvx/binaries/versions.json b/src/backends/nvx/binaries/versions.json new file mode 100644 index 000000000..d404445b6 --- /dev/null +++ b/src/backends/nvx/binaries/versions.json @@ -0,0 +1,6 @@ +{ + "repository": "microsoft/nvx", + "tag": "v0.1.0-dev.5c86da3dff02", + "windows_whp_asset": "nvx-0.1.0-windows-whp.zip", + "workload_image_asset": null +} diff --git a/src/backends/nvx/build_common/Cargo.toml b/src/backends/nvx/build_common/Cargo.toml new file mode 100644 index 000000000..b06fe6ebe --- /dev/null +++ b/src/backends/nvx/build_common/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "nvx_build_common" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Build-only helpers for staging NVX artifacts" + +[dependencies] +nvx_common = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/src/backends/nvx/build_common/src/lib.rs b/src/backends/nvx/build_common/src/lib.rs new file mode 100644 index 000000000..8fde0214d --- /dev/null +++ b/src/backends/nvx/build_common/src/lib.rs @@ -0,0 +1,429 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Build-only helpers for locating and staging NVX artifacts. + +use std::io; +use std::path::{Path, PathBuf}; + +use nvx_common::{WINDOWS_PLATFORM_ARTIFACTS, WORKLOAD_IMAGE_ARTIFACTS}; + +/// The only supported NVX package target triple. +pub const SUPPORTED_NVX_TARGET_TRIPLE: &str = "x86_64-pc-windows-msvc"; + +/// Category of an NVX release artifact. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ArtifactKind { + /// OpenVMM and guest boot artifacts. + Platform, + /// Optional workload filesystem images. + WorkloadImage, +} + +/// Returns every relative path in a complete NVX artifact bundle. +pub fn artifact_rel_paths() -> impl Iterator { + WINDOWS_PLATFORM_ARTIFACTS + .into_iter() + .map(|path| (ArtifactKind::Platform, path)) + .chain( + WORKLOAD_IMAGE_ARTIFACTS + .into_iter() + .map(|path| (ArtifactKind::WorkloadImage, path)), + ) +} + +/// Returns whether the NVX artifact pipeline supports the given target. +/// +/// NVX packaging is intentionally restricted to the Windows x86_64 MSVC +/// target. That is the only target that is allowed to download, verify, and +/// stage the pinned WHP artifacts. +pub fn target_supports_nvx(target_os: &str, target_arch: &str, target_env: &str) -> bool { + target_os == "windows" && target_arch == "x86_64" && target_env == "msvc" +} + +/// Returns `Ok(())` for the only supported NVX target, or a clear error +/// message for anything else. +pub fn validate_nvx_target( + target: &str, + target_os: &str, + target_arch: &str, + target_env: &str, +) -> Result<(), String> { + if target == SUPPORTED_NVX_TARGET_TRIPLE + && target_supports_nvx(target_os, target_arch, target_env) + { + Ok(()) + } else { + Err(unsupported_target_message(target)) + } +} + +/// Determines whether an executor build should stage NVX artifacts. +/// +/// Non-Windows targets do not package NVX. Windows targets must be the exact +/// supported x64 MSVC triple; unsupported Windows targets fail instead of +/// silently producing an incomplete NVX-enabled executor. +pub fn should_stage_nvx( + target: &str, + target_os: &str, + target_arch: &str, + target_env: &str, +) -> Result { + if target_os != "windows" { + return Ok(false); + } + + validate_nvx_target(target, target_os, target_arch, target_env)?; + Ok(true) +} + +/// Formats the explicit error message used when NVX is requested for an +/// unsupported target. +pub fn unsupported_target_message(target: &str) -> String { + format!( + "nvx packaging is only supported for target {SUPPORTED_NVX_TARGET_TRIPLE}; current target is {target}" + ) +} + +/// Returns the artifacts required by the currently configured release. +pub fn available_artifact_rel_paths( + workload_images_available: bool, +) -> impl Iterator { + artifact_rel_paths().filter_map(move |(kind, path)| { + (kind == ArtifactKind::Platform + || (kind == ArtifactKind::WorkloadImage && workload_images_available)) + .then_some(path) + }) +} + +/// Resolves the artifact cache, honouring the `NVX_BIN` offline override. +/// +/// The returned boolean is `true` when the caller supplied `NVX_BIN`. +pub fn resolve_bin_dir(out_dir: &Path) -> io::Result<(PathBuf, bool)> { + let prefetched = std::env::var_os("NVX_BIN") + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + + if let Some(dir) = prefetched { + if !dir.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!( + "NVX_BIN is set to '{}', but that directory does not exist", + dir.display() + ), + )); + } + return std::path::absolute(&dir).map(|absolute| (absolute, true)); + } + + let dir = out_dir.join("nvx-binaries"); + std::fs::create_dir_all(&dir)?; + Ok((dir, false)) +} + +/// Copies selected artifacts while preserving their release-relative paths. +/// +/// Missing artifacts and copy failures are returned as explicit errors. The +/// destination file is removed after a failed copy so a partial artifact is +/// never left staged. +pub fn copy_artifact_paths<'a>( + src_dir: &Path, + target_dir: &Path, + relative_paths: impl IntoIterator, +) -> io::Result<()> { + let relative_paths: Vec<&str> = relative_paths.into_iter().collect(); + validate_artifact_paths(src_dir, relative_paths.iter().copied())?; + copy_validated_artifact_paths(src_dir, target_dir, relative_paths) +} + +fn validate_artifact_paths<'a>( + src_dir: &Path, + relative_paths: impl IntoIterator, +) -> io::Result<()> { + for relative_path in relative_paths { + let source = src_dir.join(relative_path); + if !source.is_file() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("required NVX artifact '{}' is missing", source.display()), + )); + } + } + Ok(()) +} + +fn copy_validated_artifact_paths<'a>( + src_dir: &Path, + target_dir: &Path, + relative_paths: impl IntoIterator, +) -> io::Result<()> { + for relative_path in relative_paths { + let source = src_dir.join(relative_path); + let destination = target_dir.join(relative_path); + let parent = destination.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "NVX artifact '{}' has no destination parent", + destination.display() + ), + ) + })?; + std::fs::create_dir_all(parent)?; + + if let Err(error) = std::fs::copy(&source, &destination) { + let _ = std::fs::remove_file(&destination); + return Err(io::Error::new( + error.kind(), + format!( + "failed to copy NVX artifact '{}' to '{}': {error}", + source.display(), + destination.display() + ), + )); + } + } + Ok(()) +} + +fn remove_artifact_paths<'a>( + target_dir: &Path, + relative_paths: impl IntoIterator, +) -> io::Result<()> { + for relative_path in relative_paths { + let destination = target_dir.join(relative_path); + match std::fs::remove_file(&destination) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(io::Error::new( + error.kind(), + format!( + "failed to remove stale NVX artifact '{}': {error}", + destination.display() + ), + )) + } + } + } + Ok(()) +} + +/// Stages all artifacts available in the configured release. +pub fn copy_artifacts_to_target( + src_dir: &Path, + target_dir: &Path, + workload_images_available: bool, +) -> io::Result<()> { + let relative_paths: Vec<&str> = + available_artifact_rel_paths(workload_images_available).collect(); + validate_artifact_paths(src_dir, relative_paths.iter().copied())?; + + if !workload_images_available { + remove_artifact_paths(target_dir, WORKLOAD_IMAGE_ARTIFACTS)?; + } + + copy_validated_artifact_paths(src_dir, target_dir, relative_paths) +} + +/// Emits Cargo change tracking for every artifact available in this release. +pub fn emit_rerun_for_artifacts(src_dir: &Path, workload_images_available: bool) { + for relative_path in available_artifact_rel_paths(workload_images_available) { + println!( + "cargo:rerun-if-changed={}", + src_dir.join(relative_path).display() + ); + } +} + +/// Stages NVX artifacts beside the consuming executable. +pub fn stage_artifacts_next_to_exe(nvx_bin_dir: &Path) -> io::Result<()> { + let workload_images_available = + match std::env::var("DEP_NVX_BINARIES_WORKLOAD_IMAGES_AVAILABLE").as_deref() { + Ok("1") => true, + Ok("0") => false, + Ok(value) => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "DEP_NVX_BINARIES_WORKLOAD_IMAGES_AVAILABLE has invalid value '{value}'" + ), + )) + } + Err(std::env::VarError::NotPresent) => { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "DEP_NVX_BINARIES_WORKLOAD_IMAGES_AVAILABLE is not set", + )) + } + Err(error) => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("cannot read DEP_NVX_BINARIES_WORKLOAD_IMAGES_AVAILABLE: {error}"), + )) + } + }; + let out_dir = PathBuf::from( + std::env::var_os("OUT_DIR") + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "OUT_DIR is not set"))?, + ); + let target_dir = out_dir + .parent() + .and_then(Path::parent) + .and_then(Path::parent) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "cannot determine target directory from '{}'", + out_dir.display() + ), + ) + })?; + + copy_artifacts_to_target(nvx_bin_dir, target_dir, workload_images_available)?; + emit_rerun_for_artifacts(nvx_bin_dir, workload_images_available); + println!("cargo:rerun-if-env-changed=DEP_NVX_BINARIES_BIN_DIR"); + println!("cargo:rerun-if-env-changed=DEP_NVX_BINARIES_WORKLOAD_IMAGES_AVAILABLE"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_artifact(root: &Path, relative_path: &str, contents: &[u8]) { + let path = root.join(relative_path); + std::fs::create_dir_all(path.parent().expect("artifact must have a parent")) + .expect("failed to create artifact parent"); + std::fs::write(path, contents).expect("failed to write artifact"); + } + + #[test] + fn artifact_paths_cover_complete_bundle_in_release_order() { + assert_eq!( + artifact_rel_paths().collect::>(), + vec![ + (ArtifactKind::Platform, "bin/openvmm.exe"), + (ArtifactKind::Platform, "guest/vmlinux"), + (ArtifactKind::Platform, "guest/initramfs.cpio.gz"), + (ArtifactKind::WorkloadImage, "images/distro.erofs"), + (ArtifactKind::WorkloadImage, "images/runtime.erofs"), + (ArtifactKind::WorkloadImage, "images/scratch.ext4"), + ] + ); + } + + #[test] + fn copy_artifacts_preserves_release_layout() { + let source = tempfile::tempdir().expect("failed to create source directory"); + let target = tempfile::tempdir().expect("failed to create target directory"); + for relative_path in WINDOWS_PLATFORM_ARTIFACTS { + write_artifact(source.path(), relative_path, relative_path.as_bytes()); + } + for relative_path in WORKLOAD_IMAGE_ARTIFACTS { + write_artifact(target.path(), relative_path, b"stale"); + } + + copy_artifacts_to_target(source.path(), target.path(), false) + .expect("platform-only staging failed"); + + for relative_path in WINDOWS_PLATFORM_ARTIFACTS { + assert_eq!( + std::fs::read(target.path().join(relative_path)) + .expect("staged artifact is missing"), + relative_path.as_bytes() + ); + } + for relative_path in WORKLOAD_IMAGE_ARTIFACTS { + assert!(!target.path().join(relative_path).exists()); + } + } + + #[test] + fn complete_bundle_requires_every_workload_image() { + let source = tempfile::tempdir().expect("failed to create source directory"); + let target = tempfile::tempdir().expect("failed to create target directory"); + for relative_path in WINDOWS_PLATFORM_ARTIFACTS { + write_artifact(source.path(), relative_path, b"platform"); + } + for relative_path in WORKLOAD_IMAGE_ARTIFACTS { + write_artifact(target.path(), relative_path, b"existing"); + } + + let error = copy_artifacts_to_target(source.path(), target.path(), true) + .expect_err("missing workload images must fail"); + + assert_eq!(error.kind(), io::ErrorKind::NotFound); + assert!(error.to_string().contains("images/distro.erofs")); + for relative_path in WINDOWS_PLATFORM_ARTIFACTS { + assert!(!target.path().join(relative_path).exists()); + } + for relative_path in WORKLOAD_IMAGE_ARTIFACTS { + assert_eq!( + std::fs::read(target.path().join(relative_path)) + .expect("failed staging must preserve existing artifacts"), + b"existing" + ); + } + } + + #[test] + fn target_support_only_allows_windows_x86_64_msvc() { + assert!(target_supports_nvx("windows", "x86_64", "msvc")); + assert!(!target_supports_nvx("windows", "x86_64", "gnu")); + assert!(!target_supports_nvx("windows", "aarch64", "msvc")); + assert!(!target_supports_nvx("linux", "x86_64", "gnu")); + } + + #[test] + fn unsupported_target_message_names_supported_triple() { + let message = unsupported_target_message("aarch64-pc-windows-msvc"); + + assert!(message.contains(SUPPORTED_NVX_TARGET_TRIPLE)); + assert!(message.contains("aarch64-pc-windows-msvc")); + } + + #[test] + fn validate_nvx_target_rejects_unsupported_target() { + let error = validate_nvx_target("aarch64-pc-windows-msvc", "windows", "aarch64", "msvc") + .expect_err("unsupported target must fail closed"); + + assert!(error.contains(SUPPORTED_NVX_TARGET_TRIPLE)); + assert!(error.contains("aarch64-pc-windows-msvc")); + } + + #[test] + fn validate_nvx_target_rejects_custom_target_with_matching_cfg_values() { + let target = "custom-windows-x64-msvc"; + let error = validate_nvx_target(target, "windows", "x86_64", "msvc") + .expect_err("only the exact supported target triple may stage NVX"); + + assert!(error.contains(SUPPORTED_NVX_TARGET_TRIPLE)); + assert!(error.contains(target)); + } + + #[test] + fn staging_decision_uses_target_not_build_host() { + assert_eq!( + should_stage_nvx("x86_64-pc-windows-msvc", "windows", "x86_64", "msvc"), + Ok(true) + ); + assert_eq!( + should_stage_nvx("x86_64-unknown-linux-gnu", "linux", "x86_64", "gnu"), + Ok(false) + ); + + for (target, arch, env) in [ + ("aarch64-pc-windows-msvc", "aarch64", "msvc"), + ("x86_64-pc-windows-gnu", "x86_64", "gnu"), + ("custom-windows-x64-msvc", "x86_64", "msvc"), + ] { + let error = should_stage_nvx(target, "windows", arch, env) + .expect_err("unsupported Windows targets must fail closed"); + assert!(error.contains(SUPPORTED_NVX_TARGET_TRIPLE)); + assert!(error.contains(target)); + } + } +} diff --git a/src/backends/nanvix/common/Cargo.toml b/src/backends/nvx/common/Cargo.toml similarity index 60% rename from src/backends/nanvix/common/Cargo.toml rename to src/backends/nvx/common/Cargo.toml index 264a1c287..3eebd587f 100644 --- a/src/backends/nanvix/common/Cargo.toml +++ b/src/backends/nvx/common/Cargo.toml @@ -1,9 +1,9 @@ [package] -name = "nanvix_common" +name = "nvx_common" version.workspace = true edition.workspace = true license.workspace = true -description = "Shared constants and configuration types for NanVix micro-VM binaries" +description = "Shared constants and release configuration for NVX artifacts" [dependencies] serde = { workspace = true } diff --git a/src/backends/nvx/common/src/lib.rs b/src/backends/nvx/common/src/lib.rs new file mode 100644 index 000000000..3c162af7f --- /dev/null +++ b/src/backends/nvx/common/src/lib.rs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Shared artifact paths and release configuration for the NVX backend. + +use std::collections::HashMap; +use std::path::Path; + +use serde::Deserialize; + +/// Platform artifacts required to launch NVX with OpenVMM on Windows/WHP. +pub const WINDOWS_PLATFORM_ARTIFACTS: [&str; 3] = [ + "bin/openvmm.exe", + "guest/vmlinux", + "guest/initramfs.cpio.gz", +]; + +/// Optional workload image artifacts used by a complete NVX bundle. +pub const WORKLOAD_IMAGE_ARTIFACTS: [&str; 3] = [ + "images/distro.erofs", + "images/runtime.erofs", + "images/scratch.ext4", +]; + +/// Pinned upstream release configuration loaded from `versions.json`. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub struct ReleaseConfig { + /// GitHub repository in `owner/name` form. + pub repository: String, + /// Published release tag. + pub tag: String, + /// Windows/WHP platform archive name. + pub windows_whp_asset: String, + /// Optional workload image archive name. + #[serde(default)] + pub workload_image_asset: Option, +} + +/// Loads and deserializes a JSON file, failing with path-specific context. +pub fn load_json(path: &str) -> T { + let content = std::fs::read_to_string(Path::new(path)) + .unwrap_or_else(|error| panic!("nvx_common: failed to read {path}: {error}")); + serde_json::from_str(&content) + .unwrap_or_else(|error| panic!("nvx_common: failed to parse {path}: {error}")) +} + +/// Loads the artifact checksum map from `checksums.json`. +pub fn load_checksums(path: &str) -> HashMap { + load_json(path) +} + +/// Constructs a deterministic GitHub release asset URL. +pub fn github_download_url(repository: &str, tag: &str, asset: &str) -> String { + format!("https://github.com/{repository}/releases/download/{tag}/{asset}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn windows_platform_artifacts_match_release_layout() { + assert_eq!( + WINDOWS_PLATFORM_ARTIFACTS, + [ + "bin/openvmm.exe", + "guest/vmlinux", + "guest/initramfs.cpio.gz", + ] + ); + } + + #[test] + fn workload_image_artifacts_match_staged_layout() { + assert_eq!( + WORKLOAD_IMAGE_ARTIFACTS, + [ + "images/distro.erofs", + "images/runtime.erofs", + "images/scratch.ext4", + ] + ); + } +} diff --git a/src/backends/nvx/runner/Cargo.toml b/src/backends/nvx/runner/Cargo.toml new file mode 100644 index 000000000..59339f05e --- /dev/null +++ b/src/backends/nvx/runner/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "nvx_runner" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "NVX backend preflight checks for incomplete runtime builds" + +[features] +default = [] +nvx = ["nvx_binaries/nvx"] + +[dependencies] +nvx_binaries = { workspace = true } +wxc_common = { workspace = true } diff --git a/src/backends/nvx/runner/src/lib.rs b/src/backends/nvx/runner/src/lib.rs new file mode 100644 index 000000000..1cad495f6 --- /dev/null +++ b/src/backends/nvx/runner/src/lib.rs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! NVX backend preflight checks. +//! +//! PR1 intentionally does not include an executable NVX runtime runner. This +//! crate currently provides only typed availability checks that the engine can +//! call before dispatch. + +use wxc_common::mxc_error::MxcError; + +/// Typed backend-unavailable message for pinned releases without the NVX +/// workload-image archive. +pub const ERR_WORKLOAD_IMAGE_ASSET_UNAVAILABLE: &str = + "NVX workload image asset is not available in the pinned release"; + +/// Verifies whether the pinned NVX release includes workload images required +/// by the runtime implementation. +/// +/// The current pinned release does not ship those images, so preflight returns +/// a typed backend-unavailable error. +pub fn preflight() -> Result<(), MxcError> { + if nvx_binaries::NVX_WORKLOAD_IMAGES_AVAILABLE != "1" { + return Err(MxcError::backend_unavailable( + ERR_WORKLOAD_IMAGE_ASSET_UNAVAILABLE, + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::preflight; + use super::ERR_WORKLOAD_IMAGE_ASSET_UNAVAILABLE; + use wxc_common::mxc_error::MxcErrorCode; + + #[test] + fn pinned_release_marks_workload_images_unavailable() { + assert_eq!(nvx_binaries::NVX_WORKLOAD_IMAGES_AVAILABLE, "0"); + } + + #[test] + fn preflight_returns_typed_backend_unavailable_error() { + let err = preflight().expect_err("preflight must fail in PR1"); + assert_eq!(err.code, MxcErrorCode::BackendUnavailable); + assert_eq!(err.message, ERR_WORKLOAD_IMAGE_ASSET_UNAVAILABLE); + } +} diff --git a/src/core/lxc/Cargo.toml b/src/core/lxc/Cargo.toml index 6b0b692d7..05bcb33ae 100644 --- a/src/core/lxc/Cargo.toml +++ b/src/core/lxc/Cargo.toml @@ -10,11 +10,9 @@ path = "src/main.rs" [features] hyperlight = ["dep:hyperlight_common", "hyperlight_common/hyperlight", "mxc_engine/hyperlight"] -microvm = ["dep:nanvix_binaries", "nanvix_binaries/microvm", "mxc_engine/microvm"] [build-dependencies] mxc_build_common.workspace = true -nanvix_build_common = { path = "../../backends/nanvix/build_common" } [dependencies] wxc_common = { workspace = true } @@ -22,7 +20,5 @@ mxc_engine = { workspace = true } lxc_common = { workspace = true } anyhow = { workspace = true } clap = { workspace = true } -nanvix_binaries = { path = "../../backends/nanvix/binaries", optional = true } - [target.'cfg(target_arch = "x86_64")'.dependencies] hyperlight_common = { workspace = true, optional = true } diff --git a/src/core/lxc/build.rs b/src/core/lxc/build.rs index 1d5d5da99..d0f33bcf2 100644 --- a/src/core/lxc/build.rs +++ b/src/core/lxc/build.rs @@ -1,16 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Build script for lxc — embeds Windows VersionInfo (no-op on non-Windows) -//! and copies NanVix binaries next to the output executable when the -//! `microvm` feature is enabled. +//! Build script for lxc — embeds Windows VersionInfo (no-op on non-Windows). fn main() { mxc_build_common::embed_version_info("LXC container executor (Linux stub)", "lxc-exec.exe"); - #[cfg(all(target_os = "linux", feature = "microvm"))] - copy_nanvix_binaries(); - // Delay-load winhvplatform.dll so WHP-less hosts don't crash before main(). // CARGO_CFG_TARGET_* (not #[cfg]) because build.rs cfg gates are host, not target. #[cfg(feature = "hyperlight")] @@ -25,21 +20,3 @@ fn main() { println!("cargo:rerun-if-changed=build.rs"); } - -#[cfg(all(target_os = "linux", feature = "microvm"))] -fn copy_nanvix_binaries() { - use std::path::Path; - - let nanvix_bin_dir = match std::env::var("DEP_NANVIX_BINARIES_BIN_DIR") { - Ok(dir) => dir, - Err(_) => { - eprintln!("lxc build.rs: DEP_NANVIX_BINARIES_BIN_DIR not set, skipping copy"); - return; - } - }; - - // Stage the artifacts next to the executable and emit rerun triggers. All - // of the staging logic (target-dir derivation, snapshot trust, copy/purge, - // rerun emission) lives in the build-only `nanvix_build_common` crate. - nanvix_build_common::stage_artifacts_next_to_exe(Path::new(&nanvix_bin_dir)); -} diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index 182857a3c..27bada0e1 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -272,7 +272,7 @@ fn main() { // Dispatch by containment backend. Backend selection and runner // construction — Bubblewrap (the Linux default for abstract intents), LXC // (explicit `containment: "lxc"`, plus the catch-all for anything else such - // as `processcontainer`), and the experimental Hyperlight / MicroVM + // as `processcontainer`), and the experimental Hyperlight // backends — live in `mxc_engine::run`, the single home for one-shot backend // dispatch. It runs the selected backend to completion and returns the // response; experimental backends that require `--experimental` (or that diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 6e6f01594..338156587 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -517,9 +517,10 @@ dropping the handle tears the session down synchronously rather than in the background. Reach its multi-call lifecycle through `run_state_aware_json` plus `exec_attached` or `exec_sandbox`. -Backends with no variant at all — Windows Sandbox, MicroVM, and Hyperlight — -cannot be named from this crate; use the executor binaries. Windows Sandbox is -still reachable here through the state-aware lifecycle. +Backends with no variant at all — Windows Sandbox, MicroVM (implemented by +NVX), and Hyperlight — cannot be named from this crate; use the executor +binaries. Windows Sandbox is still reachable here through the state-aware +lifecycle. `Containment::Lxc` models explicit LXC distribution settings, but `run` and `spawn_sandbox` reject it because the LXC backend does not expose captured diff --git a/src/core/mxc_config_contract/src/dev/one_shot.rs b/src/core/mxc_config_contract/src/dev/one_shot.rs index e1d9d96ed..af045b560 100644 --- a/src/core/mxc_config_contract/src/dev/one_shot.rs +++ b/src/core/mxc_config_contract/src/dev/one_shot.rs @@ -26,12 +26,12 @@ string_enum! { /// Select the macOS Seatbelt backend. Seatbelt => ["seatbelt", "macos_sandbox"], - // Development-only values. + // Additional values carried by the development contract. /// Select the host's VM-class containment backend. Vm => ["vm"], /// Select the Windows Sandbox backend. WindowsSandbox => ["windows_sandbox"], - /// Select the NanVix micro-VM backend. + /// Select the MicroVM backend implemented by NVX. Microvm => ["microvm"], /// Select the Hyperlight micro-VM backend. Hyperlight => ["hyperlight"], diff --git a/src/core/mxc_config_contract/src/published/v0_9_0_alpha/one_shot.rs b/src/core/mxc_config_contract/src/published/v0_9_0_alpha/one_shot.rs index 2e2b1838a..10172163e 100644 --- a/src/core/mxc_config_contract/src/published/v0_9_0_alpha/one_shot.rs +++ b/src/core/mxc_config_contract/src/published/v0_9_0_alpha/one_shot.rs @@ -25,6 +25,8 @@ string_enum! { Bubblewrap => ["bubblewrap"], /// Select the macOS Seatbelt backend. Seatbelt => ["seatbelt", "macos_sandbox"], + /// Select the MicroVM backend implemented by NVX. + Microvm => ["microvm"], /// Select the Windows IsolationSession backend. IsolationSession => ["isolation_session"], diff --git a/src/core/mxc_config_contract/tests/v0_10_0_alpha/enums.rs b/src/core/mxc_config_contract/tests/v0_10_0_alpha/enums.rs index 586725221..f3cc8a027 100644 --- a/src/core/mxc_config_contract/tests/v0_10_0_alpha/enums.rs +++ b/src/core/mxc_config_contract/tests/v0_10_0_alpha/enums.rs @@ -42,6 +42,17 @@ fn rejects_invalid_containment_value() { ); } +#[test] +fn rejects_internal_nvx_containment_value() { + assert_invalid( + r#"{ + "version": "0.10.0-alpha", + "containment": "nvx", + "process": {"commandLine": "echo"} + }"#, + ); +} + #[test] fn rejects_every_removed_default_network_policy_value() { for default_network_policy in ["allow", "block"] { diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/enums.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/enums.rs index 8c392c0c1..3c9b58762 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/enums.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/enums.rs @@ -12,6 +12,7 @@ fn accepts_every_containment_value() { "lxc", "bubblewrap", "seatbelt", + "microvm", "isolation_session", "wslc", ] { @@ -38,6 +39,17 @@ fn rejects_invalid_containment_value() { ); } +#[test] +fn rejects_internal_nvx_containment_value() { + assert_invalid( + r#"{ + "version": "0.9.0-alpha", + "containment": "nvx", + "process": {"commandLine": "echo"} + }"#, + ); +} + #[test] fn rejects_every_removed_default_network_policy_value() { for default_network_policy in ["allow", "block"] { diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/invalid/nvx_internal_name.json b/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/invalid/nvx_internal_name.json new file mode 100644 index 000000000..12b59e65c --- /dev/null +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/invalid/nvx_internal_name.json @@ -0,0 +1,7 @@ +{ + "version": "0.9.0-alpha", + "containment": "nvx", + "process": { + "commandLine": "echo nvx" + } +} diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/valid/microvm.json b/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/valid/microvm.json new file mode 100644 index 000000000..d1eb04778 --- /dev/null +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/fixtures/one_shot/valid/microvm.json @@ -0,0 +1,7 @@ +{ + "version": "0.9.0-alpha", + "containment": "microvm", + "process": { + "commandLine": "echo microvm" + } +} diff --git a/src/core/mxc_config_contract/tests/version_boundaries/containment.rs b/src/core/mxc_config_contract/tests/version_boundaries/containment.rs index fee1fa3c2..e98f4f593 100644 --- a/src/core/mxc_config_contract/tests/version_boundaries/containment.rs +++ b/src/core/mxc_config_contract/tests/version_boundaries/containment.rs @@ -13,9 +13,14 @@ fn wslc_containment_is_introduced_in_v09() { assert_v09_introduces(r#""containment": "wslc""#); } +#[test] +fn microvm_containment_is_introduced_in_v09() { + assert_v09_introduces(r#""containment": "microvm""#); +} + #[test] fn development_containment_values_are_introduced_in_v010() { - for containment in ["vm", "windows_sandbox", "microvm", "hyperlight"] { + for containment in ["vm", "windows_sandbox", "hyperlight"] { assert_v10_introduces(&format!(r#""containment": "{containment}""#)); } } diff --git a/src/core/mxc_engine/Cargo.toml b/src/core/mxc_engine/Cargo.toml index 7a46d6a79..0b1da9684 100644 --- a/src/core/mxc_engine/Cargo.toml +++ b/src/core/mxc_engine/Cargo.toml @@ -14,13 +14,13 @@ wxc_common.workspace = true mxc_config_contract.workspace = true serde = { workspace = true } serde_json = { workspace = true } -# MicroVM runner — used by the Windows and Linux run-to-completion bodies under -# the `microvm` feature. Never built on hosts/features that don't select it. -nanvix_runner = { workspace = true, optional = true } [target.'cfg(target_arch = "x86_64")'.dependencies] hyperlight_common = { workspace = true, optional = true } +[target.'cfg(all(target_os = "windows", target_arch = "x86_64"))'.dependencies] +nvx_runner = { workspace = true, optional = true } + [target.'cfg(target_os = "windows")'.dependencies] process_container_common = { workspace = true } windows_sandbox_lifecycle = { workspace = true } @@ -50,9 +50,8 @@ seatbelt_common = { workspace = true } default = [] # Enables constructing the run-to-completion runner for optional backends. # Mirrors the executor binaries' feature set so backend selection can live in -# one place. The build-time binary-staging that some backends also need (e.g. -# NanVix's `nanvix_binaries`) stays with the executor crates. -microvm = ["dep:nanvix_runner"] +# one place. Build-time binary staging remains with the executor crates. +microvm = ["dep:nvx_runner", "nvx_runner/nvx"] wslc = ["dep:wslc_common", "wslc_common/link-wslcsdk"] hyperlight = ["dep:hyperlight_common", "hyperlight_common/hyperlight"] isolation_session = [ diff --git a/src/core/mxc_engine/src/dispatch.rs b/src/core/mxc_engine/src/dispatch.rs index 99ea31761..84c9a0059 100644 --- a/src/core/mxc_engine/src/dispatch.rs +++ b/src/core/mxc_engine/src/dispatch.rs @@ -15,11 +15,12 @@ //! run-to-completion path via `process_container_common::dispatcher`), Bubblewrap //! (Linux), Seatbelt (macOS), WSLC, and IsolationSession (Windows, //! behind the `wslc` and `isolation_session` features). Every other backend — -//! including the remaining experimental ones (Windows Sandbox, MicroVM, -//! Hyperlight) and LXC (no streaming path suitable for the library) — returns -//! [`MxcError::unsupported_containment`]; callers that need those must drive the -//! standalone executor binaries (whose run-to-completion path will, in a later -//! increment, also route through this engine). +//! including the remaining experimental ones (Windows Sandbox, MicroVM +//! implemented by NVX, and Hyperlight) and LXC (no streaming path suitable for +//! the library) — returns [`MxcError::unsupported_containment`]; callers that +//! need those must drive the standalone executor binaries (whose +//! run-to-completion path will, in a later increment, also route through this +//! engine). use wxc_common::logger::Logger; use wxc_common::models::{ContainmentBackend, ExecutionRequest, ScriptResponse}; diff --git a/src/core/mxc_engine/src/platform.rs b/src/core/mxc_engine/src/platform.rs index 4b9a029f6..9b86de018 100644 --- a/src/core/mxc_engine/src/platform.rs +++ b/src/core/mxc_engine/src/platform.rs @@ -237,7 +237,7 @@ mod tests { (ContainmentBackend::Wslc, "wslc"), (ContainmentBackend::Lxc, "lxc"), (ContainmentBackend::Vm, "vm"), - (ContainmentBackend::MicroVm, "microvm"), + (ContainmentBackend::Microvm, "microvm"), (ContainmentBackend::Hyperlight, "hyperlight"), (ContainmentBackend::WindowsSandbox, "windows_sandbox"), (ContainmentBackend::IsolationSession, "isolation_session"), @@ -310,6 +310,29 @@ mod tests { } } + fn assert_microvm_method_is_omitted() { + let support = platform_support(); + assert!( + support + .available_methods + .iter() + .all(|method| method != "microvm"), + "platform_support must not advertise MicroVM while runtime is incomplete" + ); + } + + #[cfg(not(feature = "microvm"))] + #[test] + fn microvm_is_not_advertised_without_feature() { + assert_microvm_method_is_omitted(); + } + + #[cfg(feature = "microvm")] + #[test] + fn microvm_is_not_advertised_with_feature_enabled() { + assert_microvm_method_is_omitted(); + } + #[cfg(target_os = "linux")] #[test] fn linux_support_reports_bubblewrap_when_probe_succeeds() { diff --git a/src/core/mxc_engine/src/probe.rs b/src/core/mxc_engine/src/probe.rs index 7fab5565f..1781a72bb 100644 --- a/src/core/mxc_engine/src/probe.rs +++ b/src/core/mxc_engine/src/probe.rs @@ -276,7 +276,7 @@ mod tests { (ContainmentBackend::Wslc, "wslc"), (ContainmentBackend::Lxc, "lxc"), (ContainmentBackend::Vm, "vm"), - (ContainmentBackend::MicroVm, "microvm"), + (ContainmentBackend::Microvm, "microvm"), (ContainmentBackend::Hyperlight, "hyperlight"), (ContainmentBackend::WindowsSandbox, "windows_sandbox"), (ContainmentBackend::IsolationSession, "isolation_session"), @@ -408,6 +408,26 @@ mod tests { } } + fn assert_microvm_backend_is_omitted() { + assert!( + available_backends() + .iter() + .all(|backend| backend.backend != "microvm"), + "available_backends must not advertise MicroVM while runtime is incomplete" + ); + } + + #[cfg(not(feature = "microvm"))] + #[test] + fn microvm_is_not_advertised_without_feature() { + assert_microvm_backend_is_omitted(); + } + + #[cfg(feature = "microvm")] + #[test] + fn microvm_is_not_advertised_with_feature_enabled() { + assert_microvm_backend_is_omitted(); + } #[test] fn every_reported_tier_is_a_canonical_tier_string() { for entry in available_backends() { diff --git a/src/core/mxc_engine/src/run.rs b/src/core/mxc_engine/src/run.rs index baf8ed5c7..ab9dca78e 100644 --- a/src/core/mxc_engine/src/run.rs +++ b/src/core/mxc_engine/src/run.rs @@ -35,6 +35,23 @@ use wxc_common::script_runner::ScriptRunner; use crate::error::Error; +#[cfg(target_os = "windows")] +const ERR_MICROVM_EXPERIMENTAL_OPT_IN_REQUIRED: &str = + "MicroVM (NVX) is an experimental feature. Use --experimental flag."; +#[cfg(all( + not(feature = "microvm"), + target_os = "windows", + target_arch = "x86_64" +))] +const ERR_MICROVM_FEATURE_REQUIRED: &str = + "MicroVM backend not compiled in (build with --features microvm)"; +#[cfg(all(feature = "microvm", target_os = "windows", target_arch = "x86_64"))] +const ERR_MICROVM_RUNTIME_IMPLEMENTATION_MISSING: &str = + "MicroVM (NVX) runtime implementation is not present in this build"; +#[cfg(all(target_os = "windows", not(target_arch = "x86_64")))] +const ERR_MICROVM_ARCHITECTURE_UNSUPPORTED: &str = + "MicroVM (NVX) requires Windows x64; this Windows architecture is unsupported"; + /// A backend runner resolved for an [`ExecutionRequest`], ready to run. /// /// On Windows, `dacl_manager` — when present — is the guard for the @@ -289,25 +306,7 @@ fn resolve_runner_inner_windows( ContainmentBackend::Vm => Err(MxcError::unsupported_containment( "VM backend not yet implemented", )), - ContainmentBackend::MicroVm => { - if !request.experimental_enabled { - return Err(MxcError::malformed_request( - "MicroVM is an experimental feature. Use --experimental flag.", - )); - } - #[cfg(feature = "microvm")] - { - Ok(ResolvedRunner::without_guard(Box::new( - nanvix_runner::NanVixScriptRunner::new(), - ))) - } - #[cfg(not(feature = "microvm"))] - { - Err(MxcError::unsupported_containment( - "MicroVM backend not compiled in (build with --features microvm)", - )) - } - } + ContainmentBackend::Microvm => resolve_microvm_backend(request), ContainmentBackend::Hyperlight => resolve_hyperlight(request), ContainmentBackend::WindowsSandbox => { if !request.experimental_enabled { @@ -350,7 +349,7 @@ fn resolve_runner_inner_windows( } // --------------------------------------------------------------------------- -// Linux: Bubblewrap, LXC, and the experimental Hyperlight / MicroVM backends. +// Linux: Bubblewrap, LXC, and the experimental Hyperlight backend. // A concrete backend selected for another host must fail closed. // --------------------------------------------------------------------------- @@ -363,25 +362,6 @@ fn resolve_runner_inner( match request.containment { ContainmentBackend::Hyperlight => resolve_hyperlight(request), - ContainmentBackend::MicroVm => { - if !request.experimental_enabled { - return Err(MxcError::malformed_request( - "MicroVM is an experimental feature. Use --experimental flag.", - )); - } - #[cfg(feature = "microvm")] - { - Ok(ResolvedRunner::without_guard(Box::new( - nanvix_runner::NanVixScriptRunner::new(), - ))) - } - #[cfg(not(feature = "microvm"))] - { - Err(MxcError::unsupported_containment( - "MicroVM backend not compiled in (build with --features microvm)", - )) - } - } ContainmentBackend::Bubblewrap => Ok(ResolvedRunner::without_guard(Box::new(Runner::new( bwrap_common::bwrap_runner::BubblewrapScriptRunner::new(), )))), @@ -446,6 +426,30 @@ mod linux_tests { ); assert!(error.message.contains("seatbelt")); } + + #[test] + fn microvm_is_rejected_as_unavailable_on_linux() { + let request = ExecutionRequest { + containment: ContainmentBackend::Microvm, + experimental_enabled: true, + ..Default::default() + }; + let mut logger = Logger::new(Mode::Buffer); + + let error = match resolve_runner_inner(&request, &mut logger) { + Ok(_) => panic!("NVX-backed MicroVM must remain Windows-only"), + Err(error) => error, + }; + + assert_eq!( + error.code, + wxc_common::mxc_error::MxcErrorCode::UnsupportedContainment + ); + assert_eq!( + error.message, + "the 'microvm' backend is not available on Linux" + ); + } } #[cfg(all(test, target_os = "macos"))] @@ -472,6 +476,30 @@ mod macos_tests { ); assert!(error.message.contains("lxc")); } + + #[test] + fn microvm_is_rejected_as_unavailable_on_macos() { + let request = ExecutionRequest { + containment: ContainmentBackend::Microvm, + experimental_enabled: true, + ..Default::default() + }; + let mut logger = Logger::new(Mode::Buffer); + + let error = match resolve_runner_inner(&request, &mut logger) { + Ok(_) => panic!("NVX-backed MicroVM must remain Windows-only"), + Err(error) => error, + }; + + assert_eq!( + error.code, + wxc_common::mxc_error::MxcErrorCode::UnsupportedContainment + ); + assert_eq!( + error.message, + "the 'microvm' backend is not available on macOS" + ); + } } // --------------------------------------------------------------------------- @@ -489,6 +517,45 @@ fn resolve_runner_inner( )) } +#[cfg(target_os = "windows")] +fn resolve_microvm_backend(request: &ExecutionRequest) -> Result { + if !request.experimental_enabled { + return Err(MxcError::malformed_request( + ERR_MICROVM_EXPERIMENTAL_OPT_IN_REQUIRED, + )); + } + + #[cfg(all(feature = "microvm", target_arch = "x86_64"))] + { + resolve_microvm_backend_with_preflight(nvx_runner::preflight) + } + + #[cfg(all(not(feature = "microvm"), target_arch = "x86_64"))] + { + Err(MxcError::unsupported_containment( + ERR_MICROVM_FEATURE_REQUIRED, + )) + } + + #[cfg(not(target_arch = "x86_64"))] + { + Err(MxcError::unsupported_containment( + ERR_MICROVM_ARCHITECTURE_UNSUPPORTED, + )) + } +} + +#[cfg(all(feature = "microvm", target_os = "windows", target_arch = "x86_64"))] +fn resolve_microvm_backend_with_preflight(preflight: F) -> Result +where + F: FnOnce() -> Result<(), MxcError>, +{ + preflight()?; + Err(MxcError::backend_unavailable( + ERR_MICROVM_RUNTIME_IMPLEMENTATION_MISSING, + )) +} + /// Construct the Hyperlight runner, shared by the Windows and Linux bodies. /// Requires x86_64 (Hyperlight needs KVM or WHP) and the `hyperlight` feature. /// On Windows, pre-checks that `winhvplatform.dll` is loadable so a missing @@ -529,6 +596,7 @@ mod tests { use super::*; use wxc_common::logger::Mode; use wxc_common::models::WindowsSandboxConfig; + use wxc_common::mxc_error::MxcErrorCode; fn windows_sandbox_request(config: Option) -> ExecutionRequest { ExecutionRequest { @@ -539,6 +607,14 @@ mod tests { } } + fn microvm_request(experimental_enabled: bool) -> ExecutionRequest { + ExecutionRequest { + containment: ContainmentBackend::Microvm, + experimental_enabled, + ..Default::default() + } + } + #[test] fn policy_hash_identity_matches_runner_identity_rules() { assert_eq!(policy_hash_identity(""), "CLI"); @@ -591,4 +667,78 @@ mod tests { assert!(!logger.get_buffer().contains("experimental")); } + + #[test] + fn microvm_without_experimental_opt_in_is_rejected_as_malformed_request() { + let request = microvm_request(false); + let mut logger = Logger::new(Mode::Buffer); + + let err = match resolve_runner_inner_windows(&request, &mut logger) { + Ok(_) => panic!("expected malformed_request"), + Err(err) => err, + }; + + assert_eq!(err.code, MxcErrorCode::MalformedRequest); + assert_eq!(err.message, ERR_MICROVM_EXPERIMENTAL_OPT_IN_REQUIRED); + } + + #[cfg(all(not(feature = "microvm"), target_arch = "x86_64"))] + #[test] + fn microvm_without_feature_returns_typed_unsupported_containment() { + let request = microvm_request(true); + let mut logger = Logger::new(Mode::Buffer); + + let err = match resolve_runner_inner_windows(&request, &mut logger) { + Ok(_) => panic!("expected unsupported_containment"), + Err(err) => err, + }; + + assert_eq!(err.code, MxcErrorCode::UnsupportedContainment); + assert_eq!(err.message, ERR_MICROVM_FEATURE_REQUIRED); + } + + #[cfg(all(feature = "microvm", target_arch = "x86_64"))] + #[test] + fn microvm_with_feature_propagates_preflight_backend_unavailable() { + let request = microvm_request(true); + let mut logger = Logger::new(Mode::Buffer); + + let err = match resolve_runner_inner_windows(&request, &mut logger) { + Ok(_) => panic!("expected backend_unavailable"), + Err(err) => err, + }; + + assert_eq!(err.code, MxcErrorCode::BackendUnavailable); + assert_eq!( + err.message, + nvx_runner::ERR_WORKLOAD_IMAGE_ASSET_UNAVAILABLE + ); + } + + #[cfg(all(feature = "microvm", target_arch = "x86_64"))] + #[test] + fn microvm_without_runtime_returns_backend_unavailable_even_if_preflight_succeeds() { + let err = match resolve_microvm_backend_with_preflight(|| Ok(())) { + Ok(_) => panic!("runtime stub must not resolve a runner"), + Err(err) => err, + }; + + assert_eq!(err.code, MxcErrorCode::BackendUnavailable); + assert_eq!(err.message, ERR_MICROVM_RUNTIME_IMPLEMENTATION_MISSING); + } + + #[cfg(not(target_arch = "x86_64"))] + #[test] + fn microvm_is_rejected_on_unsupported_windows_architecture() { + let request = microvm_request(true); + let mut logger = Logger::new(Mode::Buffer); + + let err = match resolve_runner_inner_windows(&request, &mut logger) { + Ok(_) => panic!("expected unsupported_containment"), + Err(err) => err, + }; + + assert_eq!(err.code, MxcErrorCode::UnsupportedContainment); + assert_eq!(err.message, ERR_MICROVM_ARCHITECTURE_UNSUPPORTED); + } } diff --git a/src/core/wxc/Cargo.toml b/src/core/wxc/Cargo.toml index a3beaea0d..56c7b35e9 100644 --- a/src/core/wxc/Cargo.toml +++ b/src/core/wxc/Cargo.toml @@ -21,7 +21,7 @@ hyperlight_common = { workspace = true, optional = true } [target.'cfg(windows)'.dependencies] process_container_common = { workspace = true } -nanvix_binaries = { path = "../../backends/nanvix/binaries", optional = true } +nvx_binaries = { workspace = true, optional = true } wslc_common = { workspace = true, optional = true } # Shared PLM dep: coordination, guarded lifecycle, and restricted WPR control. # wxc-exec never bypasses PLM's public singleton. @@ -31,14 +31,17 @@ tempfile.workspace = true [build-dependencies] mxc_build_common.workspace = true - -[target.'cfg(windows)'.build-dependencies] -nanvix_build_common = { path = "../../backends/nanvix/build_common" } +nvx_build_common = { workspace = true, optional = true } [features] default = [] test-support = ["wxc_common/test-support"] -microvm = ["dep:nanvix_binaries", "nanvix_binaries/microvm", "mxc_engine/microvm"] +microvm = [ + "dep:nvx_binaries", + "nvx_binaries/nvx", + "dep:nvx_build_common", + "mxc_engine/microvm", +] wslc = ["dep:wslc_common", "wslc_common/link-wslcsdk", "mxc_engine/wslc"] hyperlight = ["dep:hyperlight_common", "hyperlight_common/hyperlight", "mxc_engine/hyperlight"] isolation_session = ["mxc_engine/isolation_session"] diff --git a/src/core/wxc/build.rs b/src/core/wxc/build.rs index 28cc26f04..685abd089 100644 --- a/src/core/wxc/build.rs +++ b/src/core/wxc/build.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Build script for wxc — embeds Windows VersionInfo and copies NanVix binaries. +//! Build script for wxc — embeds Windows VersionInfo and stages backend artifacts. fn main() { mxc_build_common::embed_version_info("MXC sandbox executor", "wxc-exec.exe"); @@ -9,8 +9,8 @@ fn main() { #[cfg(windows)] check_test_prerequisites(); - #[cfg(all(windows, feature = "microvm"))] - copy_nanvix_binaries(); + #[cfg(feature = "microvm")] + stage_nvx_for_target(); // Delay-load winhvplatform.dll so WHP-less hosts don't crash before main(). // CARGO_CFG_TARGET_* (not #[cfg]) because build.rs cfg gates are host, not target. @@ -79,20 +79,31 @@ fn check_test_prerequisites() { } } -#[cfg(all(windows, feature = "microvm"))] -fn copy_nanvix_binaries() { +#[cfg(feature = "microvm")] +fn stage_nvx_for_target() { + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + + let target = std::env::var("TARGET").expect("wxc build.rs: TARGET is not set by Cargo"); + let should_stage = + nvx_build_common::should_stage_nvx(&target, &target_os, &target_arch, &target_env) + .unwrap_or_else(|error| panic!("wxc build.rs: {error}")); + if should_stage { + copy_nvx_binaries(); + } +} + +#[cfg(feature = "microvm")] +fn copy_nvx_binaries() { use std::path::Path; - let nanvix_bin_dir = match std::env::var("DEP_NANVIX_BINARIES_BIN_DIR") { - Ok(dir) => dir, - Err(_) => { - eprintln!("wxc build.rs: DEP_NANVIX_BINARIES_BIN_DIR not set, skipping copy"); - return; - } - }; + let nvx_bin_dir = std::env::var("DEP_NVX_BINARIES_BIN_DIR").unwrap_or_else(|error| { + panic!( + "wxc build.rs: DEP_NVX_BINARIES_BIN_DIR is required for the microvm feature: {error}" + ) + }); - // Stage the artifacts next to the executable and emit rerun triggers. All - // of the staging logic (target-dir derivation, snapshot trust, copy/purge, - // rerun emission) lives in the build-only `nanvix_build_common` crate. - nanvix_build_common::stage_artifacts_next_to_exe(Path::new(&nanvix_bin_dir)); + nvx_build_common::stage_artifacts_next_to_exe(Path::new(&nvx_bin_dir)) + .unwrap_or_else(|error| panic!("wxc build.rs: failed to stage NVX artifacts: {error}")); } diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index 47a48cc1a..43961d0e3 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -2022,7 +2022,7 @@ mod tests { ContainmentBackend::WindowsSandbox, ContainmentBackend::Wslc, ContainmentBackend::IsolationSession, - ContainmentBackend::MicroVm, + ContainmentBackend::Microvm, ] { let containment_name = containment.wire_name(); request.containment = containment; @@ -2578,6 +2578,18 @@ mod tests { assert_eq!(command_override, "echo 'safe&whoami'"); } + #[test] + fn microvm_cli_command_uses_posix_shell_quoting() { + let cli = parse_cli(&["wxc-exec", "policy.json", "--", "echo", "safe&whoami"]); + let command_override = cmdline_from_argv_for_context( + &cli.command, + CommandLineContext::for_backend(&ContainmentBackend::Microvm), + ) + .unwrap(); + + assert_eq!(command_override, "echo 'safe&whoami'"); + } + #[test] fn state_aware_exec_cli_command_overrides_policy_command_line() { let argv = &["wxc-exec", "policy.json", "--", "echo", "hi"]; diff --git a/src/core/wxc_common/Cargo.toml b/src/core/wxc_common/Cargo.toml index f06efc1e6..ea91ac535 100644 --- a/src/core/wxc_common/Cargo.toml +++ b/src/core/wxc_common/Cargo.toml @@ -5,7 +5,6 @@ edition.workspace = true license.workspace = true [features] -microvm = ["dep:nanvix_common", "dep:uuid"] # Exposes telemetry test harnesses to downstream integration tests. test-support = [] @@ -20,8 +19,6 @@ base64 = { workspace = true } cidr = { workspace = true } url = { workspace = true } getrandom = { workspace = true } -nanvix_common = { path = "../../backends/nanvix/common", optional = true } -uuid = { workspace = true, optional = true } mxc_config_contract = { workspace = true } mxc_telemetry = { workspace = true } diff --git a/src/core/wxc_common/src/cmdline.rs b/src/core/wxc_common/src/cmdline.rs index 4eeb79d76..456c8aed5 100644 --- a/src/core/wxc_common/src/cmdline.rs +++ b/src/core/wxc_common/src/cmdline.rs @@ -45,8 +45,8 @@ impl CommandLineContext { | ContainmentBackend::Bubblewrap => Self::PosixShell, ContainmentBackend::ProcessContainer | ContainmentBackend::Vm - | ContainmentBackend::MicroVm | ContainmentBackend::Hyperlight => Self::WindowsCreateProcess, + ContainmentBackend::Microvm => Self::PosixShell, } } } @@ -381,6 +381,14 @@ mod tests { ); } + #[test] + fn microvm_backend_uses_posix_shell_context() { + 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/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs index 7b2918b29..06d2a2e67 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs @@ -151,14 +151,14 @@ const DEVELOPMENT_CONTAINMENT_CASES: &[DevelopmentContainmentCase] = &[ input: "vm", expected: "vm", }, - DevelopmentContainmentCase { - input: "windows_sandbox", - expected: "windows_sandbox", - }, DevelopmentContainmentCase { input: "microvm", expected: "microvm", }, + DevelopmentContainmentCase { + input: "windows_sandbox", + expected: "windows_sandbox", + }, DevelopmentContainmentCase { input: "hyperlight", expected: "hyperlight", diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot.rs b/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot.rs index b0f09d25d..3189416af 100644 --- a/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot.rs +++ b/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot.rs @@ -14,6 +14,7 @@ fn convert_containment(value: contract::OneShotContainment) -> wire::Containment contract::OneShotContainment::Lxc => wire::Containment::Lxc, contract::OneShotContainment::Bubblewrap => wire::Containment::Bubblewrap, contract::OneShotContainment::Seatbelt => wire::Containment::Seatbelt, + contract::OneShotContainment::Microvm => wire::Containment::Microvm, contract::OneShotContainment::IsolationSession => wire::Containment::IsolationSession, contract::OneShotContainment::Wslc => wire::Containment::Wslc, } diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot_tests/microvm.rs b/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot_tests/microvm.rs new file mode 100644 index 000000000..52eeadb66 --- /dev/null +++ b/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot_tests/microvm.rs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::{contract, into_common_request_ir, wire}; + +#[test] +fn microvm_maps_to_the_public_wire_identity() { + let request = serde_json::from_str::( + r#"{ + "version": "0.9.0-alpha", + "containment": "microvm", + "process": {"commandLine": "echo hello"} + }"#, + ) + .unwrap(); + + let adapted = into_common_request_ir(request); + assert!(matches!( + adapted.containment, + Some(wire::Containment::Microvm) + )); +} diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot_tests/mod.rs b/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot_tests/mod.rs index c92c2eae2..8d8e99983 100644 --- a/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot_tests/mod.rs +++ b/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot_tests/mod.rs @@ -5,5 +5,6 @@ use super::{contract, into_common_request_ir, wire}; mod common; mod isolation_session; +mod microvm; mod stable_candidate; mod wslc; diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index a16fd25eb..ac36849f8 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -2272,6 +2272,36 @@ mod tests { } } + #[test] + fn containment_microvm_accepted() { + let json = r#"{ + "version": "0.9.0-alpha", + "process": {"commandLine": "/bin/true"}, + "containment": "microvm" + }"#; + + let request = parse_exact_for_test(json).unwrap(); + let MxcRequest::OneShot(request) = request else { + panic!("expected one-shot MicroVM request"); + }; + assert_eq!(request.containment, ContainmentBackend::Microvm); + } + + #[test] + fn containment_nvx_gets_normal_unknown_enum_rejection() { + let json = r#"{ + "version": "0.9.0-alpha", + "process": {"commandLine": "/bin/true"}, + "containment": "nvx" + }"#; + + let error = parse_exact_for_test(json) + .expect_err("internal NVX implementation name must not be accepted"); + assert!(matches!(error, ParseError::OneShot(_))); + assert!(error.message().contains("unknown variant `nvx`")); + assert!(!error.message().contains("use")); + } + struct DevelopmentStateAwareRootCase { name: &'static str, json: &'static str, diff --git a/src/core/wxc_common/src/lib.rs b/src/core/wxc_common/src/lib.rs index adf4e7a3d..0594b33d5 100644 --- a/src/core/wxc_common/src/lib.rs +++ b/src/core/wxc_common/src/lib.rs @@ -18,8 +18,6 @@ pub mod filesystem_resolve; pub mod id; pub mod log_symbols; pub mod logger; -#[cfg(all(feature = "microvm", any(target_os = "windows", target_os = "linux")))] -pub mod microvm_staging; pub mod models; pub mod mxc_error; pub mod network_blocks; diff --git a/src/core/wxc_common/src/microvm_staging.rs b/src/core/wxc_common/src/microvm_staging.rs deleted file mode 100644 index 48618fc30..000000000 --- a/src/core/wxc_common/src/microvm_staging.rs +++ /dev/null @@ -1,1102 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! MicroVM staging directory builder for mount-based script delivery. - -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{Duration, SystemTime}; - -use thiserror::Error; -use uuid::Uuid; - -/// Entry point filename (warm-start protocol executes this automatically). -pub const BOOTSTRAP_FILENAME: &str = "bootstrap.py"; -/// Subdirectory for read-write staged host paths. -pub const RW_DIR: &str = "rw"; -/// Subdirectory for read-only staged host paths. -pub const RO_DIR: &str = "ro"; -/// Guest mount root inside the NanVix VM. -const GUEST_MOUNT_ROOT: &str = "/mnt"; - -/// Builds the guest-visible path for a staged host directory. -fn build_guest_path(category: &str, name: &str) -> String { - format!("{}/{}/{}", GUEST_MOUNT_ROOT, category, name) -} - -/// Preamble prepended to the user script in bootstrap.py. -/// -/// Composed from [`GUEST_MOUNT_ROOT`] and [`BOOTSTRAP_FILENAME`] so that -/// any future change to either constant flows through automatically. -fn bootstrap_preamble() -> String { - format!( - "import sys\nsys.argv = ['{}/{}']\n", - GUEST_MOUNT_ROOT, BOOTSTRAP_FILENAME - ) -} - -/// Errors produced while creating or validating a staging directory. -#[derive(Debug, Error)] -pub enum StagingError { - /// A requested host path does not exist. - #[error("host path does not exist: {0}")] - PathNotFound(String), - /// A symlink was found in a source path. - #[error("symlink found in source path: {0}")] - SymlinkFound(String), - /// I/O failure. - #[error("staging I/O error: {0}")] - Io(#[from] std::io::Error), -} - -/// Intermediate result from the staging build closure. -struct StagingBuildOutput { - /// Read-write directory mappings for copyback. - rw_mappings: Vec, - /// Total bytes written to the staging directory. - size_bytes: u64, -} - -/// Return type of the staging build closure. -type StagingBuildResult = Result; - -/// Whether the original host path was a file or directory. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum HostPathKind { - File, - Directory, -} - -/// Tracks the relationship between a host path, its staged copy, and its type. -#[derive(Debug)] -struct RwMapping { - host_path: PathBuf, - staged_path: PathBuf, - kind: HostPathKind, -} - -/// RAII wrapper over a temporary staging directory. -#[derive(Debug)] -pub struct StagingDir { - path: PathBuf, - rw_mappings: Vec, - size_bytes: u64, - /// When true, `Drop` skips cleanup so the staging dir can be recovered. - preserve: bool, -} - -impl StagingDir { - /// Creates and populates a staging directory under `root`. - pub fn new( - root: PathBuf, - script: &str, - readwrite_paths: &[String], - readonly_paths: &[String], - ) -> Result { - // Validate source paths up front (existence, no symlinks/reparse points, - // no `..` components). Validation is done once here; per-entry - // symlink/reparse rejection in `copy_dir_recursive` closes the TOCTOU - // window between validation and copy, so the per-source loops below - // don't need to re-validate. - for source in readwrite_paths.iter().chain(readonly_paths.iter()) { - validate_source_path(Path::new(source), source)?; - } - - fs::create_dir_all(&root)?; - let path = root.join(format!("mxc-staging-{}", Uuid::new_v4().simple())); - fs::create_dir_all(&path)?; - - let build_result = || -> StagingBuildResult { - // Collect host→guest path mappings for script rewriting. - let mut rewrite_map: Vec<(String, String)> = Vec::new(); - let mut rw_mappings: Vec = Vec::new(); - // Track staged bytes incrementally to avoid a full directory walk - // at the end (which scaled with total staged content size). - let mut size_bytes: u64 = 0; - - if !readwrite_paths.is_empty() { - fs::create_dir_all(path.join(RW_DIR))?; - } - for source in readwrite_paths { - let host_path = PathBuf::from(source); - - let relative = host_path_to_guest_relative(&host_path); - let slot_dir = path.join(RW_DIR).join(&relative); - let (kind, bytes) = stage_host_path(&host_path, &slot_dir)?; - size_bytes = size_bytes.saturating_add(bytes); - let guest_path = build_guest_path(RW_DIR, &relative); - rewrite_map.push((source.clone(), guest_path)); - rw_mappings.push(RwMapping { - host_path, - staged_path: slot_dir, - kind, - }); - } - - if !readonly_paths.is_empty() { - fs::create_dir_all(path.join(RO_DIR))?; - } - for source in readonly_paths { - let host_path = PathBuf::from(source); - - let relative = host_path_to_guest_relative(&host_path); - let slot_dir = path.join(RO_DIR).join(&relative); - let bytes = if host_path.is_dir() { - copy_dir_recursive(&host_path, &slot_dir)? - } else { - fs::create_dir_all(&slot_dir)?; - let file_name = host_path - .file_name() - .ok_or_else(|| StagingError::PathNotFound(source.clone()))?; - fs::copy(&host_path, slot_dir.join(file_name))? - }; - size_bytes = size_bytes.saturating_add(bytes); - set_readonly_recursive(&slot_dir)?; - let guest_path = build_guest_path(RO_DIR, &relative); - rewrite_map.push((source.clone(), guest_path)); - } - - // Rewrite host paths in the user script so callers don't need to - // know about the guest mount layout. Both backslash and forward-slash - // variants of each host path are replaced. - let rewritten_script = rewrite_paths_in_script(script, &rewrite_map); - let bootstrap_content = format!("{}{}", bootstrap_preamble(), rewritten_script); - fs::write(path.join(BOOTSTRAP_FILENAME), &bootstrap_content)?; - size_bytes = size_bytes.saturating_add(bootstrap_content.len() as u64); - - Ok(StagingBuildOutput { - rw_mappings, - size_bytes, - }) - }(); - - let StagingBuildOutput { - rw_mappings, - size_bytes, - } = match build_result { - Ok(result) => result, - Err(err) => { - let _ = remove_dir_all_force(&path); - return Err(err); - } - }; - - Ok(Self { - path, - rw_mappings, - size_bytes, - preserve: false, - }) - } - - /// Returns the host path to the staging directory. - pub fn path(&self) -> &Path { - &self.path - } - - /// Copies all read-write staged paths back to their original host locations. - /// Attempts all mappings even if one fails; returns the first error encountered. - /// On any failure, marks the staging dir as preserved so `Drop` won't delete it. - pub fn copy_back_to_host(&mut self) -> Result<(), StagingError> { - let mut first_err: Option = None; - // Preserve before starting — if any copy fails, staging must survive Drop. - self.preserve = true; - for mapping in &self.rw_mappings { - if let Err(e) = copy_back_mapping(mapping) { - if first_err.is_none() { - first_err = Some(e); - } - } - } - if first_err.is_none() { - // All copies succeeded — allow normal cleanup on Drop. - self.preserve = false; - } - first_err.map_or(Ok(()), Err) - } - - /// Returns the staging directory path (useful for recovery messages). - pub fn preserved_path(&self) -> Option<&Path> { - if self.preserve { - Some(&self.path) - } else { - None - } - } - - /// Returns additional staging overhead in milliseconds. - pub fn staging_overhead_ms(&self) -> u64 { - let ms = ((self.size_bytes as f64 / (1024.0 * 1024.0)) * 100.0) as u64; - ms.min(30_000) - } -} - -impl Drop for StagingDir { - fn drop(&mut self) { - if self.preserve { - return; - } - let _ = remove_dir_all_force(&self.path); - } -} - -/// Converts a host path to a guest-relative path by stripping the drive letter prefix -/// and normalizing separators. E.g. `C:\Users\me\work` → `c/Users/me/work`. -fn host_path_to_guest_relative(host_path: &Path) -> String { - let s = host_path.to_string_lossy(); - // Strip drive letter prefix (e.g. "C:\") and normalize to forward slashes. - let stripped = if s.len() >= 3 - && s.as_bytes()[1] == b':' - && (s.as_bytes()[2] == b'\\' || s.as_bytes()[2] == b'/') - { - let drive = s.as_bytes()[0].to_ascii_lowercase() as char; - format!("{}/{}", drive, s[3..].replace('\\', "/")) - } else { - // UNC or relative path — just normalize slashes. - s.replace('\\', "/") - }; - // Trim leading and trailing slashes to ensure the result is always relative. - // On Linux, absolute paths like `/tmp/xyz` become `tmp/xyz`. - stripped - .trim_start_matches('/') - .trim_end_matches('/') - .to_string() -} - -/// Replaces host paths in the script source with their guest mount equivalents. -/// Both backslash (`C:\Users\work`) and forward-slash (`C:/Users/work`) variants -/// are replaced. Longer paths are replaced first to avoid partial prefix matches. -fn rewrite_paths_in_script(script: &str, mappings: &[(String, String)]) -> String { - let mut result = script.to_string(); - // Sort by host path length descending so longer prefixes match first. - let mut sorted: Vec<_> = mappings.to_vec(); - sorted.sort_by_key(|b| std::cmp::Reverse(b.0.len())); - for (host_path, guest_path) in &sorted { - // Replace escaped backslash variant first (Python string literals: C:\\Users\\work). - let escaped = host_path.replace('\\', "\\\\"); - if escaped != *host_path { - result = result.replace(&escaped, guest_path); - } - // Replace native backslash variant (C:\Users\work). - result = result.replace(host_path, guest_path); - // Replace forward-slash variant (C:/Users/work). - let forward = host_path.replace('\\', "/"); - if forward != *host_path { - result = result.replace(&forward, guest_path); - } - } - result -} - -/// Stages a single host path in a target slot directory using a private copy. -/// Returns the host-path kind and the total number of bytes copied. -fn stage_host_path(host_path: &Path, slot_dir: &Path) -> Result<(HostPathKind, u64), StagingError> { - if host_path.is_dir() { - let bytes = copy_dir_recursive(host_path, slot_dir)?; - return Ok((HostPathKind::Directory, bytes)); - } - - fs::create_dir_all(slot_dir)?; - let file_name = host_path - .file_name() - .ok_or_else(|| StagingError::PathNotFound(host_path.display().to_string()))?; - let bytes = fs::copy(host_path, slot_dir.join(file_name))?; - Ok((HostPathKind::File, bytes)) -} - -/// Copies staged RW content back to the original host path. -fn copy_back_mapping(mapping: &RwMapping) -> Result<(), StagingError> { - match mapping.kind { - HostPathKind::Directory => mirror_directory(&mapping.staged_path, &mapping.host_path), - HostPathKind::File => { - let file_name = mapping.host_path.file_name().ok_or_else(|| { - StagingError::PathNotFound(mapping.host_path.display().to_string()) - })?; - let staged_file = mapping.staged_path.join(file_name); - fs::copy(staged_file, &mapping.host_path)?; - Ok(()) - } - } -} - -/// Replaces the destination directory with the source directory contents. -/// Uses a rename-based backup to avoid permanent data loss if the copy fails mid-way. -fn mirror_directory(src: &Path, dst: &Path) -> Result<(), StagingError> { - // Build a sibling backup path on the same volume as dst — rename is atomic. - // Include the PID to avoid collision with stale backups from prior interrupted runs. - let backup = dst.with_extension(format!("__mxc_bak_{}", std::process::id())); - // Clean up any pre-existing backup with the same name (e.g. from a previous run - // of this process that was interrupted between the rename and cleanup). - if backup.exists() { - let _ = remove_dir_all_force(&backup); - } - if dst.exists() { - fs::rename(dst, &backup)?; - } - match copy_dir_recursive(src, dst) { - Ok(_) => { - // Copy succeeded — best-effort removal of the backup. - if backup.exists() { - let _ = remove_dir_all_force(&backup); - } - Ok(()) - } - Err(e) => { - // Copy failed — attempt to restore the backup to the original path. - if backup.exists() { - if dst.exists() { - let _ = remove_dir_all_force(dst); - } - let _ = fs::rename(&backup, dst); - } - Err(e) - } - } -} - -/// Copies a directory recursively. Returns the total number of bytes copied so -/// callers can track staging size without an extra full-tree walk afterward. -/// Uses `symlink_metadata` per entry to reject symlinks/reparse points during -/// traversal, closing the TOCTOU window between upfront validation and the actual copy. -fn copy_dir_recursive(src: &Path, dst: &Path) -> Result { - fs::create_dir_all(dst)?; - let mut total: u64 = 0; - for entry in fs::read_dir(src)? { - let entry = entry?; - let metadata = fs::symlink_metadata(entry.path())?; - if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { - return Err(StagingError::SymlinkFound( - entry.path().display().to_string(), - )); - } - let target = dst.join(entry.file_name()); - if metadata.file_type().is_dir() { - total = total.saturating_add(copy_dir_recursive(&entry.path(), &target)?); - } else { - total = total.saturating_add(fs::copy(entry.path(), target)?); - } - } - Ok(total) -} - -/// Sets the read-only attribute recursively for all files in `dir`. -fn set_readonly_recursive(dir: &Path) -> Result<(), StagingError> { - if dir.is_file() { - let mut perms = fs::metadata(dir)?.permissions(); - perms.set_readonly(true); - fs::set_permissions(dir, perms)?; - return Ok(()); - } - - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - set_readonly_recursive(&path)?; - } else { - let mut perms = fs::metadata(&path)?.permissions(); - perms.set_readonly(true); - fs::set_permissions(&path, perms)?; - } - } - Ok(()) -} - -/// Validates that a source host path exists, has a filename, and contains no reparse points. -fn validate_source_path(path: &Path, original: &str) -> Result<(), StagingError> { - if !path.exists() { - return Err(StagingError::PathNotFound(original.to_string())); - } - if path.file_name().is_none() { - return Err(StagingError::PathNotFound(format!( - "root paths are not supported for microvm filesystem staging: {}", - original - ))); - } - // Reject paths with `..` components to prevent path-traversal attacks that - // could write outside the staging directory (e.g., `C:\a\..\b`). - for component in path.components() { - if matches!(component, std::path::Component::ParentDir) { - return Err(StagingError::PathNotFound(format!( - "paths with '..' components are not supported: {}", - original - ))); - } - } - check_no_reparse_points(path) -} - -/// Ensures no symlink or Windows reparse point is present in `path` or descendants. -fn check_no_reparse_points(path: &Path) -> Result<(), StagingError> { - let metadata = fs::symlink_metadata(path)?; - if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { - return Err(StagingError::SymlinkFound(path.display().to_string())); - } - - if metadata.is_dir() { - for entry in fs::read_dir(path)? { - check_no_reparse_points(&entry?.path())?; - } - } - - Ok(()) -} - -#[cfg(target_os = "windows")] -fn is_reparse_point(metadata: &fs::Metadata) -> bool { - use std::os::windows::fs::MetadataExt; - use windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT; - metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 -} - -#[cfg(not(target_os = "windows"))] -fn is_reparse_point(_metadata: &fs::Metadata) -> bool { - false -} - -/// Removes a directory tree, clearing read-only attributes first. -#[allow(clippy::permissions_set_readonly_false)] -fn remove_dir_all_force(path: &Path) -> Result<(), StagingError> { - if !path.exists() { - return Ok(()); - } - clear_readonly_recursive(path)?; - fs::remove_dir_all(path)?; - Ok(()) -} - -#[allow(clippy::permissions_set_readonly_false)] -fn clear_readonly_recursive(path: &Path) -> Result<(), StagingError> { - if path.is_file() { - let mut perms = fs::metadata(path)?.permissions(); - if perms.readonly() { - perms.set_readonly(false); - fs::set_permissions(path, perms)?; - } - return Ok(()); - } - - for entry in fs::read_dir(path)? { - clear_readonly_recursive(&entry?.path())?; - } - - let mut perms = fs::metadata(path)?.permissions(); - if perms.readonly() { - perms.set_readonly(false); - fs::set_permissions(path, perms)?; - } - Ok(()) -} - -/// Removes orphaned `mxc-staging-*` directories under `root` that are older than `max_age`. -/// Called at the start of each run to prevent temp dir accumulation on process crash. -pub fn sweep_orphaned_staging_dirs(root: &Path, max_age: Duration) { - let Ok(entries) = fs::read_dir(root) else { - return; - }; - let now = SystemTime::now(); - for entry in entries.flatten() { - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - if !name_str.starts_with("mxc-staging-") || !entry.path().is_dir() { - continue; - } - let is_old = entry - .metadata() - .ok() - .and_then(|m| m.modified().ok()) - .and_then(|modified| now.duration_since(modified).ok()) - .map(|age| age >= max_age) - .unwrap_or(false); - if is_old { - let _ = remove_dir_all_force(&entry.path()); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - fn write_file(path: &Path, content: &str) { - fs::write(path, content).unwrap(); - } - - /// Helper: compute the staged directory path for a host path. - fn staged_rw(staging: &StagingDir, host_path: &Path) -> PathBuf { - staging - .path() - .join(RW_DIR) - .join(host_path_to_guest_relative(host_path)) - } - - #[test] - fn staging_creates_bootstrap() { - let root = tempdir().unwrap(); - let script = "print('hello')"; - let staging = StagingDir::new(root.path().to_path_buf(), script, &[], &[]).unwrap(); - - let bootstrap = staging.path().join(BOOTSTRAP_FILENAME); - assert!(bootstrap.exists()); - let content = fs::read_to_string(bootstrap).unwrap(); - assert!(content.starts_with(&bootstrap_preamble())); - assert!(content.contains(script)); - } - - #[test] - fn staging_empty_policy() { - let root = tempdir().unwrap(); - let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap(); - assert!(!staging.path().join(RW_DIR).exists()); - assert!(!staging.path().join(RO_DIR).exists()); - } - - #[test] - fn staging_single_rw_path() { - let root = tempdir().unwrap(); - let source_root = tempdir().unwrap(); - let source = source_root.path().join("sample"); - fs::create_dir_all(&source).unwrap(); - write_file(&source.join("data.txt"), "abc"); - - let host_path = source.display().to_string(); - let script = format!("open('{}')", host_path); - let rw = vec![host_path.clone()]; - let staging = StagingDir::new(root.path().to_path_buf(), &script, &rw, &[]).unwrap(); - let guest_rel = host_path_to_guest_relative(&PathBuf::from(&host_path)); - assert!(staging.path().join(RW_DIR).join(&guest_rel).exists()); - // Verify the script was rewritten with the guest path. - let rewritten = fs::read_to_string(staging.path().join(BOOTSTRAP_FILENAME)).unwrap(); - let expected_guest = build_guest_path(RW_DIR, &guest_rel); - assert!( - rewritten.contains(&expected_guest), - "expected guest path in rewritten script, got: {}", - rewritten - ); - } - - #[test] - fn staging_ro_path_has_readonly_attribute() { - let root = tempdir().unwrap(); - let source_root = tempdir().unwrap(); - let source = source_root.path().join("readonly"); - fs::create_dir_all(&source).unwrap(); - write_file(&source.join("data.txt"), "abc"); - - let ro = vec![source.display().to_string()]; - let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &ro).unwrap(); - let guest_rel = host_path_to_guest_relative(&source); - let staged_file = staging - .path() - .join(RO_DIR) - .join(&guest_rel) - .join("data.txt"); - let metadata = fs::metadata(staged_file).unwrap(); - assert!(metadata.permissions().readonly()); - } - - #[test] - fn staging_two_rw_paths_get_distinct_guest_dirs() { - let root = tempdir().unwrap(); - let source_root = tempdir().unwrap(); - let first = source_root.path().join("input"); - let second_parent = source_root.path().join("other"); - let second = second_parent.join("input"); - fs::create_dir_all(&first).unwrap(); - fs::create_dir_all(&second).unwrap(); - - let rw = vec![first.display().to_string(), second.display().to_string()]; - let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap(); - // Full path mirroring means both exist at distinct paths. - let rel1 = host_path_to_guest_relative(&first); - let rel2 = host_path_to_guest_relative(&second); - assert!(staging.path().join(RW_DIR).join(&rel1).exists()); - assert!(staging.path().join(RW_DIR).join(&rel2).exists()); - assert_ne!(rel1, rel2); - } - - #[test] - fn staging_script_rewrite_replaces_host_paths() { - let root = tempdir().unwrap(); - let source_root = tempdir().unwrap(); - let source = source_root.path().join("sample"); - fs::create_dir_all(&source).unwrap(); - - let host_path = source.display().to_string(); - let forward_path = host_path.replace('\\', "/"); - let script = format!("a = '{}'\nb = '{}'", host_path, forward_path); - let rw = vec![host_path.clone()]; - let staging = StagingDir::new(root.path().to_path_buf(), &script, &rw, &[]).unwrap(); - - let rewritten = fs::read_to_string(staging.path().join(BOOTSTRAP_FILENAME)).unwrap(); - let guest_rel = host_path_to_guest_relative(&PathBuf::from(&host_path)); - let expected_guest = build_guest_path(RW_DIR, &guest_rel); - assert!( - rewritten.contains(&expected_guest), - "expected guest path in rewritten script, got: {}", - rewritten - ); - // On Windows, verify the forward-slash variant was replaced too. - // On Linux, host_path == forward_path and the guest path contains - // the original as a substring (/mnt/rw/tmp/xyz contains /tmp/xyz), - // so we verify replacement by counting occurrences of the guest path. - #[cfg(target_os = "windows")] - assert!( - !rewritten.contains(&forward_path), - "forward-slash host path should have been replaced" - ); - #[cfg(target_os = "linux")] - { - // Both occurrences in the script (a='...' and b='...') should - // have been replaced with the guest path. - let count = rewritten.matches(&expected_guest).count(); - assert!( - count >= 2, - "expected at least 2 occurrences of guest path, got {}: {}", - count, - rewritten - ); - } - } - - #[test] - fn staging_bootstrap_is_stable() { - let root = tempdir().unwrap(); - let a = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap(); - let b = StagingDir::new(root.path().to_path_buf(), "print(2)", &[], &[]).unwrap(); - - let left = fs::read_to_string(a.path().join(BOOTSTRAP_FILENAME)).unwrap(); - let right = fs::read_to_string(b.path().join(BOOTSTRAP_FILENAME)).unwrap(); - // The preamble (loader boilerplate) must be identical regardless of script content. - let preamble = bootstrap_preamble(); - assert!(left.starts_with(&preamble)); - assert!(right.starts_with(&preamble)); - let left_preamble = &left[..preamble.len()]; - let right_preamble = &right[..preamble.len()]; - assert_eq!(left_preamble, right_preamble); - } - - #[test] - fn staging_cleanup_on_drop() { - let root = tempdir().unwrap(); - let path_to_check = { - let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap(); - let p = staging.path().to_path_buf(); - assert!(p.exists()); - p - }; - assert!(!path_to_check.exists()); - } - - #[test] - fn staging_missing_path_returns_error() { - let root = tempdir().unwrap(); - let rw = vec![root.path().join("missing").display().to_string()]; - let err = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap_err(); - assert!(matches!(err, StagingError::PathNotFound(_))); - } - - #[test] - fn staging_single_file_rw_wrapped_in_slot() { - let root = tempdir().unwrap(); - let source_root = tempdir().unwrap(); - let source_file = source_root.path().join("payload"); - write_file(&source_file, "data"); - - let rw = vec![source_file.display().to_string()]; - let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap(); - let staged_file = staged_rw(&staging, &source_file).join("payload"); - assert!(staged_file.exists()); - } - - #[test] - fn host_path_to_guest_relative_strips_drive() { - let p = PathBuf::from(r"C:\Users\me\work"); - assert_eq!(host_path_to_guest_relative(&p), "c/Users/me/work"); - } - - #[test] - fn host_path_to_guest_relative_normalizes_slashes() { - let p = PathBuf::from(r"D:\data\ref-data"); - assert_eq!(host_path_to_guest_relative(&p), "d/data/ref-data"); - } - - #[test] - fn staging_rw_directory_is_private_until_copyback() { - let root = tempdir().unwrap(); - let source_root = tempdir().unwrap(); - let source = source_root.path().join("work"); - fs::create_dir_all(&source).unwrap(); - write_file(&source.join("data.txt"), "before"); - - let rw = vec![source.display().to_string()]; - let mut staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap(); - let staged_file = staged_rw(&staging, &source).join("data.txt"); - - // Mutate the staged copy — original must remain unchanged. - fs::write(&staged_file, "after").unwrap(); - assert_eq!( - fs::read_to_string(source.join("data.txt")).unwrap(), - "before" - ); - - // After explicit copyback, original should reflect the staged changes. - staging.copy_back_to_host().unwrap(); - assert_eq!( - fs::read_to_string(source.join("data.txt")).unwrap(), - "after" - ); - } - - #[test] - fn staging_rw_file_copyback_updates_original_file() { - let root = tempdir().unwrap(); - let source_root = tempdir().unwrap(); - let source_file = source_root.path().join("payload.txt"); - write_file(&source_file, "before"); - - let rw = vec![source_file.display().to_string()]; - let mut staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap(); - let staged_file = staged_rw(&staging, &source_file).join("payload.txt"); - - fs::write(&staged_file, "after").unwrap(); - assert_eq!(fs::read_to_string(&source_file).unwrap(), "before"); - - staging.copy_back_to_host().unwrap(); - assert_eq!(fs::read_to_string(&source_file).unwrap(), "after"); - } - - #[test] - fn staging_rw_directory_copyback_mirrors_deletions() { - let root = tempdir().unwrap(); - let source_root = tempdir().unwrap(); - let source = source_root.path().join("work"); - fs::create_dir_all(&source).unwrap(); - write_file(&source.join("kept.txt"), "before"); - write_file(&source.join("deleted.txt"), "remove me"); - - let rw = vec![source.display().to_string()]; - let mut staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap(); - let staged_dir = staged_rw(&staging, &source); - - fs::remove_file(staged_dir.join("deleted.txt")).unwrap(); - fs::write(staged_dir.join("kept.txt"), "after").unwrap(); - fs::write(staged_dir.join("created.txt"), "new").unwrap(); - - staging.copy_back_to_host().unwrap(); - - assert_eq!( - fs::read_to_string(source.join("kept.txt")).unwrap(), - "after" - ); - assert_eq!( - fs::read_to_string(source.join("created.txt")).unwrap(), - "new" - ); - assert!(!source.join("deleted.txt").exists()); - } - - #[test] - fn staging_preserve_on_copyback_failure() { - let root = tempdir().unwrap(); - let source_root = tempdir().unwrap(); - let source = source_root.path().join("work"); - fs::create_dir_all(&source).unwrap(); - write_file(&source.join("data.txt"), "original"); - - let rw = vec![source.display().to_string()]; - let mut staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap(); - let staging_path = staging.path().to_path_buf(); - - // Delete the staged directory to make copyback fail. - fs::remove_dir_all(staging.path().join(RW_DIR)).unwrap(); - - let result = staging.copy_back_to_host(); - assert!(result.is_err(), "expected copyback error"); - - // The staging dir must still exist (preserve=true) so the user can inspect it. - assert!( - staging_path.exists(), - "staging dir must be preserved on copyback failure" - ); - - // The original host directory is unchanged. - assert_eq!( - fs::read_to_string(source.join("data.txt")).unwrap(), - "original" - ); - } - - #[test] - fn staging_allows_large_content() { - // Verify that staging succeeds for large source content (~64 MB). - let root = tempdir().unwrap(); - let source_root = tempdir().unwrap(); - let source = source_root.path().join("big"); - fs::create_dir_all(&source).unwrap(); - // Sparse file ~64 MB. Sparse so we don't actually consume the disk - // space on test runners. - let big_file = source.join("large.bin"); - let big_size: u64 = 64 * 1024 * 1024; - { - let f = fs::File::create(&big_file).unwrap(); - f.set_len(big_size).unwrap(); - } - - let rw = vec![source.display().to_string()]; - let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]) - .expect("staging should succeed for large content"); - - // The large file was staged into the RW slot. - let rel = host_path_to_guest_relative(&source); - let staged = staging.path().join(RW_DIR).join(&rel).join("large.bin"); - assert!(staged.exists(), "expected staged file at {staged:?}"); - assert_eq!(fs::metadata(&staged).unwrap().len(), big_size); - } - - #[test] - #[allow(clippy::permissions_set_readonly_false)] - fn staging_readonly_paths_not_copied_back() { - let root = tempdir().unwrap(); - let source_root = tempdir().unwrap(); - let source = source_root.path().join("reference"); - fs::create_dir_all(&source).unwrap(); - write_file(&source.join("data.txt"), "original"); - - let ro = vec![source.display().to_string()]; - let mut staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &ro).unwrap(); - - // Mutate the staged read-only copy. - let guest_rel = host_path_to_guest_relative(&source); - let staged_file = staging - .path() - .join(RO_DIR) - .join(&guest_rel) - .join("data.txt"); - // Clear read-only flag so we can write to the staged copy. - let mut perms = fs::metadata(&staged_file).unwrap().permissions(); - perms.set_readonly(false); - fs::set_permissions(&staged_file, perms).unwrap(); - fs::write(&staged_file, "mutated").unwrap(); - - // copy_back_to_host only copies RW mappings — RO should NOT be copied back. - staging.copy_back_to_host().unwrap(); - assert_eq!( - fs::read_to_string(source.join("data.txt")).unwrap(), - "original", - "read-only paths must not be copied back to host" - ); - } - - #[test] - fn sweep_removes_old_staging_dirs() { - let root = tempdir().unwrap(); - let old_dir = root.path().join("mxc-staging-aabbccdd"); - let fresh_dir = root.path().join("mxc-staging-11223344"); - let unrelated = root.path().join("other-dir"); - fs::create_dir_all(&old_dir).unwrap(); - fs::create_dir_all(&fresh_dir).unwrap(); - fs::create_dir_all(&unrelated).unwrap(); - - // Backdate the old_dir modification time via a workaround: zero-age threshold - // sweeps everything older than 0 seconds (all of them qualify in CI). - sweep_orphaned_staging_dirs(root.path(), Duration::from_secs(0)); - - // Both staging dirs should be removed; unrelated must stay. - assert!(!old_dir.exists(), "old staging dir should be swept"); - assert!(!fresh_dir.exists(), "staging dir should be swept at age 0"); - assert!(unrelated.exists(), "unrelated dir must not be swept"); - } - - #[test] - fn staging_rejects_path_with_parent_dir_component() { - let root = tempdir().unwrap(); - let source = root.path().join("legit"); - fs::create_dir_all(&source).unwrap(); - // Construct a path with `..` to attempt traversal. - let traversal = source.join("..").join("legit"); - let rw = vec![traversal.display().to_string()]; - let err = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap_err(); - assert!( - matches!(err, StagingError::PathNotFound(ref msg) if msg.contains("..")), - "expected PathNotFound with '..' mention, got: {err}" - ); - } - - #[test] - fn staging_mixed_rw_and_ro_paths() { - let root = tempdir().unwrap(); - let source_root = tempdir().unwrap(); - let rw_dir = source_root.path().join("editable"); - let ro_dir = source_root.path().join("reference"); - fs::create_dir_all(&rw_dir).unwrap(); - fs::create_dir_all(&ro_dir).unwrap(); - write_file(&rw_dir.join("a.txt"), "rw-content"); - write_file(&ro_dir.join("b.txt"), "ro-content"); - - let rw = vec![rw_dir.display().to_string()]; - let ro = vec![ro_dir.display().to_string()]; - let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &ro).unwrap(); - - // Both subdirectories must exist. - assert!(staging.path().join(RW_DIR).exists()); - assert!(staging.path().join(RO_DIR).exists()); - // Verify file content was staged. - let rw_rel = host_path_to_guest_relative(&rw_dir); - let ro_rel = host_path_to_guest_relative(&ro_dir); - let staged_rw_file = staging.path().join(RW_DIR).join(&rw_rel).join("a.txt"); - let staged_ro_file = staging.path().join(RO_DIR).join(&ro_rel).join("b.txt"); - assert_eq!(fs::read_to_string(staged_rw_file).unwrap(), "rw-content"); - assert_eq!(fs::read_to_string(staged_ro_file).unwrap(), "ro-content"); - } - - #[test] - fn staging_overhead_ms_scales_with_size() { - let root = tempdir().unwrap(); - let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap(); - // A minimal staging dir should have near-zero overhead. - assert!(staging.staging_overhead_ms() < 5); - } - - #[test] - fn staging_overhead_ms_capped_at_30s() { - let root = tempdir().unwrap(); - let mut staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap(); - // Simulate a huge staging size (won't actually allocate). - staging.size_bytes = 500 * 1024 * 1024; // 500 MB - assert_eq!(staging.staging_overhead_ms(), 30_000); - } - - #[test] - fn preserved_path_none_when_not_preserved() { - let root = tempdir().unwrap(); - let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap(); - assert!(staging.preserved_path().is_none()); - } - - #[test] - fn host_path_to_guest_relative_handles_trailing_slash() { - let p = PathBuf::from(r"C:\Users\me\work\"); - assert_eq!(host_path_to_guest_relative(&p), "c/Users/me/work"); - } - - #[test] - fn host_path_to_guest_relative_lowercase_drive() { - let p = PathBuf::from(r"E:\Projects\src"); - let result = host_path_to_guest_relative(&p); - assert!(result.starts_with('e'), "drive letter should be lowercase"); - assert_eq!(result, "e/Projects/src"); - } - - #[test] - fn host_path_to_guest_relative_strips_leading_slash() { - // On Linux, absolute paths like /tmp/xyz should become relative (tmp/xyz). - let p = PathBuf::from("/tmp/my-dir/payload"); - let result = host_path_to_guest_relative(&p); - assert_eq!(result, "tmp/my-dir/payload"); - assert!( - !result.starts_with('/'), - "result must be relative: {}", - result - ); - } - - #[test] - fn host_path_to_guest_relative_linux_root_path() { - // A path like /home/user/project → home/user/project. - let p = PathBuf::from("/home/user/project"); - assert_eq!(host_path_to_guest_relative(&p), "home/user/project"); - } - - #[test] - fn rewrite_paths_handles_escaped_backslashes() { - let host = r"C:\Users\me\work".to_string(); - let guest = "/mnt/rw/c/Users/me/work".to_string(); - let script = r#"path = "C:\\Users\\me\\work""#; - let result = rewrite_paths_in_script(script, &[(host, guest.clone())]); - assert!( - result.contains(&guest), - "escaped backslashes not rewritten: {result}" - ); - } - - #[test] - fn rewrite_paths_longer_prefix_first() { - let short_host = r"C:\data".to_string(); - let short_guest = "/mnt/rw/c/data".to_string(); - let long_host = r"C:\data\subdir".to_string(); - let long_guest = "/mnt/rw/c/data/subdir".to_string(); - let script = r"C:\data\subdir\file.txt"; - let mappings = vec![ - (short_host, short_guest.clone()), - (long_host, long_guest.clone()), - ]; - let result = rewrite_paths_in_script(script, &mappings); - // The longer path must match first so we don't get a partial replacement. - assert!( - result.contains("/mnt/rw/c/data/subdir"), - "longer prefix should match first: {result}" - ); - } - - #[test] - fn build_guest_path_format() { - assert_eq!(build_guest_path("rw", "c/Users/me"), "/mnt/rw/c/Users/me"); - assert_eq!(build_guest_path("ro", "d/ref"), "/mnt/ro/d/ref"); - } - - #[test] - fn staging_empty_script() { - let root = tempdir().unwrap(); - let staging = StagingDir::new(root.path().to_path_buf(), "", &[], &[]).unwrap(); - let content = fs::read_to_string(staging.path().join(BOOTSTRAP_FILENAME)).unwrap(); - // Should only contain the preamble. - assert_eq!(content, bootstrap_preamble()); - } - - #[test] - fn staging_nested_directory_rw_copyback() { - let root = tempdir().unwrap(); - let source_root = tempdir().unwrap(); - let source = source_root.path().join("nested"); - let sub = source.join("sub").join("deep"); - fs::create_dir_all(&sub).unwrap(); - write_file(&sub.join("deep.txt"), "original"); - write_file(&source.join("top.txt"), "top-original"); - - let rw = vec![source.display().to_string()]; - let mut staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap(); - let staged_dir = staged_rw(&staging, &source); - - // Modify deep file and add a new file. - fs::write( - staged_dir.join("sub").join("deep").join("deep.txt"), - "modified", - ) - .unwrap(); - fs::write(staged_dir.join("new.txt"), "added").unwrap(); - - staging.copy_back_to_host().unwrap(); - assert_eq!( - fs::read_to_string(sub.join("deep.txt")).unwrap(), - "modified" - ); - assert_eq!(fs::read_to_string(source.join("new.txt")).unwrap(), "added"); - } - - #[test] - fn sweep_ignores_nonexistent_root() { - let nonexistent = PathBuf::from(r"C:\nonexistent_mxc_test_dir_12345"); - // Should not panic or error. - sweep_orphaned_staging_dirs(&nonexistent, Duration::from_secs(0)); - } - - #[test] - fn staging_dir_has_unique_names() { - let root = tempdir().unwrap(); - let a = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap(); - let b = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap(); - assert_ne!(a.path(), b.path()); - } -} diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 4400bdda9..3aa6aa663 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -25,9 +25,8 @@ pub enum ContainmentBackend { Lxc, /// VM-based isolation. Vm, - /// MicroVM isolation via Windows Hypervisor Platform (internally powered by NanVix). - #[serde(rename = "microvm")] - MicroVm, + /// MicroVM isolation implemented by NVX and hosted by OpenVMM. + Microvm, /// MicroVM isolation via Hyperlight + Unikraft, using an embedded /// warmed-up CPython snapshot. ~100 ms cold start per invocation, /// hermetic via snapshot restore. Experimental — requires @@ -57,7 +56,7 @@ impl ContainmentBackend { ContainmentBackend::Wslc => "wslc", ContainmentBackend::Lxc => "lxc", ContainmentBackend::Vm => "vm", - ContainmentBackend::MicroVm => "microvm", + ContainmentBackend::Microvm => "microvm", ContainmentBackend::Hyperlight => "hyperlight", ContainmentBackend::WindowsSandbox => "windows_sandbox", ContainmentBackend::IsolationSession => "isolation_session", @@ -79,7 +78,7 @@ impl ContainmentBackend { ContainmentBackend::IsolationSession => Some("isolationSession"), ContainmentBackend::Bubblewrap | ContainmentBackend::Hyperlight - | ContainmentBackend::MicroVm + | ContainmentBackend::Microvm | ContainmentBackend::Vm => None, } } @@ -124,7 +123,7 @@ impl From for ContainmentBackend { } W::WindowsSandbox => Self::WindowsSandbox, W::Lxc => Self::Lxc, - W::Microvm => Self::MicroVm, + W::Microvm => Self::Microvm, W::Hyperlight => Self::Hyperlight, W::Wslc => Self::Wslc, W::Seatbelt => Self::Seatbelt, diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index 329484cd2..0b36bf438 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -33,7 +33,7 @@ pub enum Containment { WindowsSandbox, /// Full Linux container. Lxc, - /// NanVix micro-VM (experimental). + /// MicroVM isolation implemented by NVX (experimental). Microvm, /// Hyperlight micro-VM (experimental). Hyperlight, @@ -78,8 +78,9 @@ pub struct Process { /// Windows ProcessContainer picks the first `readwritePaths` entry that is /// an existing directory, else the first such `readonlyPaths` entry, else /// the system drive root; Seatbelt applies the same precedence with a `/` - /// fallback; LXC/WSL use the container root; NanVix and Hyperlight reject a - /// working directory outright. See `docs/schema.md` ("Working Directory"). + /// fallback; LXC/WSL use the container root; NVX uses the guest root and + /// preserves a caller-supplied guest path; Hyperlight rejects a working + /// directory outright. See `docs/schema.md` ("Working Directory"). pub cwd: Option, /// Environment variables as `"KEY=VALUE"` strings. /// diff --git a/src/testing/fuzz/Cargo.toml b/src/testing/fuzz/Cargo.toml index bcdda08b9..036c04ebd 100644 --- a/src/testing/fuzz/Cargo.toml +++ b/src/testing/fuzz/Cargo.toml @@ -18,15 +18,17 @@ cargo-fuzz = true # These pull in heavier deps so they're opt-in for local dev. hyperlight = ["dep:hyperlight_common", "hyperlight_common/hyperlight"] isolation_session = ["dep:isolation_session_common"] -microvm = ["dep:nanvix_runner", "wxc_common/microvm"] +microvm = ["dep:nvx_runner", "nvx_runner/nvx"] [dependencies] libfuzzer-sys = "0.4" wxc_common = { path = "../../core/wxc_common" } -nanvix_runner = { path = "../../backends/nanvix/runner", optional = true } hyperlight_common = { path = "../../backends/hyperlight/common", optional = true } isolation_session_common = { path = "../../backends/isolation_session/common", optional = true } +[target.'cfg(all(target_os = "windows", target_arch = "x86_64"))'.dependencies] +nvx_runner = { path = "../../backends/nvx/runner", optional = true } + [[bin]] name = "config_parser" path = "fuzz_targets/config_parser.rs" diff --git a/src/testing/fuzz/corpus/base64_decode/microvm_hello.b64.txt b/src/testing/fuzz/corpus/base64_decode/microvm_hello.b64.txt deleted file mode 100644 index ec4a40528..000000000 --- a/src/testing/fuzz/corpus/base64_decode/microvm_hello.b64.txt +++ /dev/null @@ -1 +0,0 @@ -ew0KICAgICJwcm9jZXNzIjogew0KICAgICAgICAiY29tbWFuZExpbmUiOiAieCA9IDQyXG55ID0gNThcbnByaW50KCdIZWxsbyBmcm9tIE1pY3JvVk0hIHN1bT0lZCcgJSAoeCArIHkpKSIsDQogICAgICAgICJ0aW1lb3V0IjogMzAwMDANCiAgICB9LA0KICAgICJjb250YWlubWVudCI6ICJtaWNyb3ZtIg0KfQ0K \ No newline at end of file diff --git a/src/testing/fuzz/fuzz_targets/validator.rs b/src/testing/fuzz/fuzz_targets/validator.rs index c8ec1872b..7aac4935b 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) +// - MicroVM (NVX): requires `--features microvm` // - Hyperlight: requires `--features hyperlight` // - IsolationSession: requires `--features isolation_session` // - Seatbelt: macOS-only, not available in Windows fuzz builds @@ -19,6 +19,10 @@ use libfuzzer_sys::fuzz_target; use wxc_common::config_parser::load_mxc_request; use wxc_common::logger::{Logger, Mode}; use wxc_common::models::ContainmentBackend; +#[cfg(all( + target_os = "windows", + any(feature = "hyperlight", feature = "isolation_session") +))] use wxc_common::script_runner::ScriptRunner; use wxc_common::state_aware_request::MxcRequest; use wxc_common::validator::validate_common; @@ -34,10 +38,9 @@ fuzz_target!(|data: &[u8]| { // Dispatch to runner-specific validation based on backend. #[cfg(target_os = "windows")] match req.containment { - #[cfg(feature = "microvm")] - ContainmentBackend::MicroVm => { - let runner = nanvix_runner::NanVixScriptRunner::new(); - let _ = runner.validate_runner(&req); + #[cfg(all(feature = "microvm", target_arch = "x86_64"))] + ContainmentBackend::Microvm => { + let _ = nvx_runner::preflight(); } #[cfg(feature = "hyperlight")] ContainmentBackend::Hyperlight => { diff --git a/src/testing/wxc_e2e_tests/src/lib.rs b/src/testing/wxc_e2e_tests/src/lib.rs index f8a42776d..51e7d721c 100644 --- a/src/testing/wxc_e2e_tests/src/lib.rs +++ b/src/testing/wxc_e2e_tests/src/lib.rs @@ -158,27 +158,6 @@ pub fn has_daemon() -> bool { } } -/// Return whether the NanVix runtime binaries are available next to wxc-exec. -pub fn has_nanvix_binaries() -> bool { - let Some(exe) = find_binary("wxc-exec.exe") else { - return false; - }; - let exe_dir = exe.parent().unwrap_or(Path::new(".")); - // Flat binaries staged next to wxc-exec.exe by `nanvix_binaries`. - let flat_present = ["nanvixd.exe", "nanvix_rootfs.img", "python3.initrd"] - .iter() - .all(|name| exe_dir.join(name).exists()); - // Kernel binary now lives under `bin/` (nanvixd locates it via -bin-dir). - let bin_present = ["kernel.elf"] - .iter() - .all(|name| exe_dir.join("bin").join(name).exists()); - let present = flat_present && bin_present; - if !present { - println!("SKIPPED: NanVix binaries not found next to wxc-exec.exe"); - } - present -} - /// Return whether `lxc-exec` is available for direct E2E execution. pub fn has_lxc_exe() -> bool { match find_binary("lxc-exec") { @@ -187,7 +166,7 @@ pub fn has_lxc_exe() -> bool { true } None => { - println!("SKIPPED: lxc-exec not found — build with `cargo build -p lxc --features microvm` first"); + println!("SKIPPED: lxc-exec not found — build with `cargo build -p lxc` first"); false } } @@ -214,37 +193,6 @@ pub fn has_lxc_host() -> bool { } } } - -/// Return whether the NanVix runtime binaries are available next to lxc-exec (Linux). -pub fn has_lxc_nanvix_binaries() -> bool { - let Some(exe) = find_binary("lxc-exec") else { - return false; - }; - let exe_dir = exe.parent().unwrap_or(Path::new(".")); - // Flat binaries staged next to lxc-exec by `nanvix_binaries`. - let flat_present = ["nanvixd.elf", "nanvix_rootfs.img", "python3.initrd"] - .iter() - .all(|name| exe_dir.join(name).exists()); - // Kernel binary under `bin/` (nanvixd locates it relative to cwd). - let bin_present = ["kernel.elf"] - .iter() - .all(|name| exe_dir.join("bin").join(name).exists()); - let present = flat_present && bin_present; - if !present { - println!("SKIPPED: NanVix binaries not found next to lxc-exec — build with `cargo build -p lxc --features microvm`"); - } - present -} - -/// Return whether `/dev/kvm` is available for KVM-based execution. -pub fn has_kvm() -> bool { - let available = Path::new("/dev/kvm").exists(); - if !available { - println!("SKIPPED: /dev/kvm not available — KVM required for NanVix on Linux"); - } - available -} - /// Run `lxc-exec` with the supplied config file and extra arguments. pub fn run_lxc_config(config_file: &str, extra_args: &[&str]) -> CommandResult { let exe = find_binary("lxc-exec").expect("lxc-exec should be available"); diff --git a/src/testing/wxc_e2e_tests/tests/e2e_linux_microvm.rs b/src/testing/wxc_e2e_tests/tests/e2e_linux_microvm.rs deleted file mode 100644 index ac67c9aa3..000000000 --- a/src/testing/wxc_e2e_tests/tests/e2e_linux_microvm.rs +++ /dev/null @@ -1,318 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Linux MicroVM (NanVix/KVM) E2E integration tests. -//! -//! These tests mirror the Windows MicroVM E2E suite in `e2e_windows.rs` and -//! invoke `lxc-exec` directly with the `microvm` containment backend. -//! Tests skip gracefully when prerequisites (binaries, KVM) are missing. - -use std::sync::OnceLock; -use std::time::{SystemTime, UNIX_EPOCH}; - -use serde::Serialize; -use wxc_e2e_tests::{ - has_kvm, has_lxc_exe, has_lxc_nanvix_binaries, repo_root, run_lxc_config, test_configs_dir, - CommandResult, -}; - -static HAS_LXC_EXE: OnceLock = OnceLock::new(); -static HAS_LXC_NANVIX: OnceLock = OnceLock::new(); -static HAS_KVM: OnceLock = OnceLock::new(); - -fn cached_has_lxc_exe() -> bool { - *HAS_LXC_EXE.get_or_init(has_lxc_exe) -} - -fn cached_has_lxc_nanvix() -> bool { - *HAS_LXC_NANVIX.get_or_init(has_lxc_nanvix_binaries) -} - -fn cached_has_kvm() -> bool { - *HAS_KVM.get_or_init(has_kvm) -} - -/// Guard: skip test if prerequisites are missing. -fn skip_unless_ready() -> bool { - if !cached_has_lxc_exe() { - return false; - } - if !cached_has_lxc_nanvix() { - return false; - } - if !cached_has_kvm() { - return false; - } - true -} - -// --------------------------------------------------------------------------- -// Individual tests (mirrors microvm_basic on Windows) -// --------------------------------------------------------------------------- - -#[test] -fn test_microvm_hello() { - if !skip_unless_ready() { - return; - } - let result = run_lxc_config("microvm_hello_linux.json", &["--debug", "--experimental"]); - assert_eq!( - result.code, - Some(0), - "expected exit 0, got {:?}\nstdout: {}\nstderr: {}", - result.code, - result.stdout, - result.stderr - ); - let combined = format!("{}\n{}", result.stdout, result.stderr); - assert!( - combined.contains("sum=100"), - "output missing 'sum=100'\ncombined: {}", - combined - ); -} - -#[test] -fn test_microvm_network() { - if !skip_unless_ready() { - return; - } - // Exercises the `-allow-host-networking` path on Linux/KVM: the guest runs - // a loopback TCP ping/pong round-trip and prints NET_OK. Without host - // networking the guest socket() fails (errno 134), so a clean exit plus - // the marker confirms the flag is wired through for the cold-boot path. - let result = run_lxc_config("microvm_network_linux.json", &["--debug", "--experimental"]); - assert_eq!( - result.code, - Some(0), - "expected exit 0, got {:?}\nstdout: {}\nstderr: {}", - result.code, - result.stdout, - result.stderr - ); - assert!( - result - .combined_output_with_decoded_base64() - .contains("NET_OK"), - "guest network round-trip marker missing\nstdout: {}\nstderr: {}", - result.stdout, - result.stderr - ); -} - -// --------------------------------------------------------------------------- -// Full microvm suite (mirrors test_microvm_suite on Windows) -// --------------------------------------------------------------------------- - -#[derive(Debug)] -struct MicrovmCase { - config: &'static str, - expected_exit: Option, - description: &'static str, - output_contains: Option<&'static str>, - expect_non_zero: bool, -} - -#[derive(Debug, Serialize)] -struct MicrovmPerfOutput { - commit: String, - timestamp: String, - results: Vec, -} - -#[derive(Debug, Serialize)] -struct MicrovmPerfEntry { - test: String, - description: String, - wall_time_ms: u128, - exit_code: Option, - status: String, -} - -#[test] -fn test_microvm_suite() { - if !skip_unless_ready() { - return; - } - microvm_suite(); -} - -fn microvm_suite() { - let cases = [ - MicrovmCase { - config: "microvm_hello_linux.json", - expected_exit: Some(0), - description: "Hello world", - output_contains: Some("sum=100"), - expect_non_zero: false, - }, - MicrovmCase { - config: "microvm_exit_code_linux.json", - expected_exit: Some(42), - description: "Exit code propagation", - output_contains: None, - expect_non_zero: false, - }, - MicrovmCase { - config: "microvm_multiline_linux.json", - expected_exit: Some(0), - description: "Multi-line script (fibonacci)", - output_contains: Some("fib("), - expect_non_zero: false, - }, - MicrovmCase { - config: "microvm_stdlib_linux.json", - expected_exit: Some(0), - description: "Stdlib (json, math, hashlib)", - output_contains: Some("pi"), - expect_non_zero: false, - }, - MicrovmCase { - config: "microvm_large_output_linux.json", - expected_exit: Some(0), - description: "Large stdout (1000 lines)", - output_contains: Some("line 999"), - expect_non_zero: false, - }, - MicrovmCase { - config: "microvm_error_linux.json", - expected_exit: Some(1), - description: "Python exception", - output_contains: Some("ValueError"), - expect_non_zero: false, - }, - MicrovmCase { - config: "microvm_timeout_linux.json", - expected_exit: None, - description: "Timeout kills VM", - output_contains: None, - expect_non_zero: true, - }, - ]; - - let mut perf_entries = Vec::new(); - 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; - } - - println!("--- {} ({}) ---", case.description, case.config); - let result = run_lxc_config(case.config, &["--debug", "--experimental"]); - let status = if command_matches(&result, &case) { - "PASS" - } else { - failures.push(format!( - "{} expected {}, got {:?}", - case.config, - expected_exit_description(&case), - result.code - )); - "FAIL" - }; - - perf_entries.push(MicrovmPerfEntry { - test: case.config.to_string(), - description: case.description.to_string(), - wall_time_ms: result.wall_time_ms, - exit_code: result.code, - status: status.to_string(), - }); - - if status == "FAIL" { - println!( - "--- stdout ---\n{}\n--- stderr ---\n{}", - result.stdout, result.stderr - ); - } else { - println!(" PASS ({} ms)", result.wall_time_ms); - } - } - - write_microvm_perf_results(perf_entries); - - if !failures.is_empty() { - panic!("MicroVM Linux E2E failures:\n{}", failures.join("\n")); - } -} - -fn command_matches(result: &CommandResult, case: &MicrovmCase) -> bool { - if case.expect_non_zero { - if result.code == Some(0) { - return false; - } - } else if result.code != case.expected_exit { - return false; - } - - let Some(expected) = case.output_contains else { - return true; - }; - - result - .combined_output_with_decoded_base64() - .contains(expected) -} - -fn expected_exit_description(case: &MicrovmCase) -> String { - if case.expect_non_zero { - "non-zero exit".to_string() - } else { - format!("exit {}", case.expected_exit.unwrap_or(0)) - } -} - -// --------------------------------------------------------------------------- -// Perf results output -// --------------------------------------------------------------------------- - -fn write_microvm_perf_results(results: Vec) { - let output = MicrovmPerfOutput { - commit: std::env::var("GITHUB_SHA").unwrap_or_else(|_| "local".to_string()), - timestamp: SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs().to_string()) - .unwrap_or_else(|_| "unknown".to_string()), - results, - }; - let json = serde_json::to_string_pretty(&output) - .expect("microvm performance results should serialize"); - let path = repo_root().join("microvm-perf-results-linux.json"); - std::fs::write(&path, json) - .unwrap_or_else(|error| panic!("failed to write {}: {error}", path.display())); - println!("Performance results written to {}", path.display()); -} - -// --------------------------------------------------------------------------- -// Stress tests (run_on_repeat — ignored by default) -// --------------------------------------------------------------------------- - -#[test] -#[ignore] -fn test_microvm_run_on_repeat() { - if !skip_unless_ready() { - return; - } - - const ITERATIONS: u32 = 10; - let mut failures = Vec::new(); - - for i in 0..ITERATIONS { - let result = run_lxc_config("microvm_hello_linux.json", &["--experimental"]); - if result.code != Some(0) { - failures.push(format!("iteration {}: exit {:?}", i, result.code)); - } - } - - if !failures.is_empty() { - panic!( - "MicroVM repeat test: {}/{} failures:\n{}", - failures.len(), - ITERATIONS, - failures.join("\n") - ); - } -} diff --git a/src/testing/wxc_e2e_tests/tests/e2e_windows.rs b/src/testing/wxc_e2e_tests/tests/e2e_windows.rs index eb0c18d4c..08d53bfd4 100644 --- a/src/testing/wxc_e2e_tests/tests/e2e_windows.rs +++ b/src/testing/wxc_e2e_tests/tests/e2e_windows.rs @@ -10,18 +10,15 @@ use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; -use serde::Serialize; use wxc_e2e_tests::{ assert_exit, assert_pwsh, assert_python, assert_success, assert_success_or_skip_missing_prerequisite, examples_dir, 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, run_wxc_state_aware, - test_configs_dir, TempDirs, + has_test_driver, has_windows_sandbox_feature, has_wxc_exe, run_test_driver, run_wxc_config, + run_wxc_config_value, run_wxc_example, run_wxc_state_aware, 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(); @@ -37,11 +34,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) @@ -122,99 +114,6 @@ fn examples() { assert_success(&result); } -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": "0.10.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": "0.10.0-alpha", - "process": { "commandLine": "print('UNEXPECTED_EXECUTION')", "timeout": 30000 }, - "containment": "microvm", - "network": { - "egress": { - "default": "allow", - "deny": [{ "to": [{ "cidr": "203.0.113.0/24" }] }] - }, - "ingress": { "default": "allow", "hostLoopback": "allow" } - } - }); - let filtered_result = run_wxc_config_value( - "microvm_directional_filter_rejected", - &filtered, - &["--debug", "--experimental"], - ); - let filtered_out = filtered_result.combined_output_with_decoded_base64(); - assert_ne!( - filtered_result.code, - Some(0), - "unsupported directional filtering must fail before execution\ncombined: {}", - filtered_out - ); - assert!( - filtered_out.contains("NanVix cannot enforce directional egress rules"), - "expected the backend's explicit unsupported-filtering error\ncombined: {}", - filtered_out - ); -} - fn processcontainer_proxy() { let config = test_configs_dir().join("processcontainer_proxy_builtin_test.json"); if !config.exists() { @@ -608,39 +507,6 @@ fn test_examples() { with_test_lock(examples); } -#[test] -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() { @@ -653,17 +519,6 @@ fn test_windows_sandbox() { with_test_lock(windows_sandbox_suite); } -#[test] -fn test_microvm_suite() { - if !cached_has_wxc_exe() { - return; - } - if !cached_has_nanvix_binaries() { - return; - } - with_test_lock(microvm_suite); -} - #[test] #[ignore] // Requires velocity key 61714527 (BFS deadlock fix) enabled and elevation fn test_processcontainer_proxy() { @@ -819,161 +674,6 @@ fn run_sandbox_case(case: &SandboxCase) { ); } -// --------------------------------------------------------------------------- -// MicroVM suite -// --------------------------------------------------------------------------- - -#[derive(Debug)] -struct MicrovmCase { - config: &'static str, - expected_exit: Option, - description: &'static str, - output_contains: Option<&'static str>, - expect_non_zero: bool, -} - -#[derive(Debug, Serialize)] -struct MicrovmPerfOutput { - commit: String, - timestamp: String, - results: Vec, -} - -#[derive(Debug, Serialize)] -struct MicrovmPerfEntry { - test: String, - description: String, - wall_time_ms: u128, - exit_code: Option, - status: String, -} - -fn microvm_suite() { - let cases = [ - MicrovmCase { - config: "microvm_hello.json", - expected_exit: Some(0), - description: "Hello world", - output_contains: Some("sum=100"), - expect_non_zero: false, - }, - MicrovmCase { - config: "microvm_exit_code.json", - expected_exit: Some(42), - description: "Exit code propagation", - output_contains: None, - expect_non_zero: false, - }, - MicrovmCase { - config: "microvm_multiline.json", - expected_exit: Some(0), - description: "Multi-line script (fibonacci)", - output_contains: Some("fib("), - expect_non_zero: false, - }, - MicrovmCase { - config: "microvm_stdlib.json", - expected_exit: Some(0), - description: "Stdlib (json, math, hashlib)", - output_contains: Some("pi"), - expect_non_zero: false, - }, - MicrovmCase { - config: "microvm_large_output.json", - expected_exit: Some(0), - description: "Large stdout (1000 lines)", - output_contains: Some("line 999"), - expect_non_zero: false, - }, - MicrovmCase { - config: "microvm_error.json", - expected_exit: Some(1), - description: "Python exception", - output_contains: Some("ValueError"), - expect_non_zero: false, - }, - MicrovmCase { - config: "microvm_timeout.json", - expected_exit: None, - description: "Timeout kills VM", - output_contains: None, - expect_non_zero: true, - }, - ]; - - let mut perf_entries = Vec::new(); - 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; - } - - println!("--- {} ({}) ---", case.description, case.config); - let result = run_wxc_config(case.config, &["--debug", "--experimental"]); - let status = if command_matches(&result, &case) { - "PASS" - } else { - failures.push(format!( - "{} expected {}, got {:?}", - case.config, - expected_exit_description(&case), - result.code - )); - "FAIL" - }; - - perf_entries.push(MicrovmPerfEntry { - test: case.config.to_string(), - description: case.description.to_string(), - wall_time_ms: result.wall_time_ms, - exit_code: result.code, - status: status.to_string(), - }); - - if status == "FAIL" { - println!( - "--- stdout ---\n{}\n--- stderr ---\n{}", - result.stdout, result.stderr - ); - } - } - - write_microvm_perf_results(perf_entries); - - if !failures.is_empty() { - panic!("MicroVM E2E failures:\n{}", failures.join("\n")); - } -} - -fn command_matches(result: &wxc_e2e_tests::CommandResult, case: &MicrovmCase) -> bool { - if case.expect_non_zero { - if result.code == Some(0) { - return false; - } - } else if result.code != case.expected_exit { - return false; - } - - let Some(expected) = case.output_contains else { - return true; - }; - - result - .combined_output_with_decoded_base64() - .contains(expected) -} - -fn expected_exit_description(case: &MicrovmCase) -> String { - if case.expect_non_zero { - "non-zero exit".to_string() - } else { - format!("exit {}", case.expected_exit.unwrap_or(0)) - } -} - // --------------------------------------------------------------------------- // Hyperlight suite // --------------------------------------------------------------------------- @@ -1188,24 +888,3 @@ fn test_hyperlight_suite() { } with_test_lock(hyperlight_suite); } - -// --------------------------------------------------------------------------- -// MicroVM perf results -// --------------------------------------------------------------------------- - -fn write_microvm_perf_results(results: Vec) { - let output = MicrovmPerfOutput { - commit: std::env::var("GITHUB_SHA").unwrap_or_else(|_| "local".to_string()), - timestamp: SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs().to_string()) - .unwrap_or_else(|_| "unknown".to_string()), - results, - }; - let json = serde_json::to_string_pretty(&output) - .expect("microvm performance results should serialize"); - let path = repo_root().join("microvm-perf-results.json"); - std::fs::write(&path, json) - .unwrap_or_else(|error| panic!("failed to write {}: {error}", path.display())); - println!("Performance results written to {}", path.display()); -} diff --git a/tests/configs/microvm_error.json b/tests/configs/microvm_error.json deleted file mode 100644 index c1aefe02d..000000000 --- a/tests/configs/microvm_error.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": "0.10.0-alpha", - "process": { - "commandLine": "raise ValueError('intentional test error')", - "timeout": 30000 - }, - "containment": "microvm" -} diff --git a/tests/configs/microvm_error_linux.json b/tests/configs/microvm_error_linux.json deleted file mode 100644 index c1aefe02d..000000000 --- a/tests/configs/microvm_error_linux.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": "0.10.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 c7d00cbc9..000000000 --- a/tests/configs/microvm_exit_code.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": "0.10.0-alpha", - "process": { - "commandLine": "import sys; sys.exit(42)", - "timeout": 30000 - }, - "containment": "microvm" -} diff --git a/tests/configs/microvm_exit_code_linux.json b/tests/configs/microvm_exit_code_linux.json deleted file mode 100644 index c7d00cbc9..000000000 --- a/tests/configs/microvm_exit_code_linux.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": "0.10.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 07354485d..000000000 --- a/tests/configs/microvm_hello.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": "0.10.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_hello_linux.json b/tests/configs/microvm_hello_linux.json deleted file mode 100644 index 4d0d6cdd0..000000000 --- a/tests/configs/microvm_hello_linux.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": "0.10.0-alpha", - "process": { - "commandLine": "x = 42\ny = 58\nprint('Hello from NanVix/KVM on Linux! 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 6f82f6298..000000000 --- a/tests/configs/microvm_large_output.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": "0.10.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_large_output_linux.json b/tests/configs/microvm_large_output_linux.json deleted file mode 100644 index 6f82f6298..000000000 --- a/tests/configs/microvm_large_output_linux.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": "0.10.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 3373fd3ec..000000000 --- a/tests/configs/microvm_multiline.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": "0.10.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_multiline_linux.json b/tests/configs/microvm_multiline_linux.json deleted file mode 100644 index 3373fd3ec..000000000 --- a/tests/configs/microvm_multiline_linux.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": "0.10.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_network.json b/tests/configs/microvm_network.json deleted file mode 100644 index 2d045acc9..000000000 --- a/tests/configs/microvm_network.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "0.10.0-alpha", - "process": { - "commandLine": "import _socket\nsrv = _socket.socket(2, 1, 0)\nsrv.bind(('127.0.0.1', 8080))\nsrv.listen(1)\ncli = _socket.socket(2, 1, 0)\ncli.connect(('127.0.0.1', 8080))\nfd, addr = srv._accept()\nconn = _socket.socket(2, 1, 0, fd)\ncli.send(b'ping')\nassert conn.recv(64) == b'ping'\nconn.send(b'pong')\nassert cli.recv(64) == b'pong'\nprint('NET_OK loopback roundtrip', flush=True)", - "timeout": 30000 - }, - "containment": "microvm", - "network": { - "egress": { "default": "allow" }, - "ingress": { "default": "allow", "hostLoopback": "allow" } - } -} diff --git a/tests/configs/microvm_network_linux.json b/tests/configs/microvm_network_linux.json deleted file mode 100644 index 2d045acc9..000000000 --- a/tests/configs/microvm_network_linux.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "0.10.0-alpha", - "process": { - "commandLine": "import _socket\nsrv = _socket.socket(2, 1, 0)\nsrv.bind(('127.0.0.1', 8080))\nsrv.listen(1)\ncli = _socket.socket(2, 1, 0)\ncli.connect(('127.0.0.1', 8080))\nfd, addr = srv._accept()\nconn = _socket.socket(2, 1, 0, fd)\ncli.send(b'ping')\nassert conn.recv(64) == b'ping'\nconn.send(b'pong')\nassert cli.recv(64) == b'pong'\nprint('NET_OK loopback roundtrip', flush=True)", - "timeout": 30000 - }, - "containment": "microvm", - "network": { - "egress": { "default": "allow" }, - "ingress": { "default": "allow", "hostLoopback": "allow" } - } -} diff --git a/tests/configs/microvm_stdlib.json b/tests/configs/microvm_stdlib.json deleted file mode 100644 index 84bb2c38b..000000000 --- a/tests/configs/microvm_stdlib.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": "0.10.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_stdlib_linux.json b/tests/configs/microvm_stdlib_linux.json deleted file mode 100644 index 84bb2c38b..000000000 --- a/tests/configs/microvm_stdlib_linux.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": "0.10.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 555988dbb..000000000 --- a/tests/configs/microvm_timeout.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "version": "0.10.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/configs/microvm_timeout_linux.json b/tests/configs/microvm_timeout_linux.json deleted file mode 100644 index 6fd675794..000000000 --- a/tests/configs/microvm_timeout_linux.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "version": "0.10.0-alpha", - "_comment": "Timeout test. NanVix 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/playground/package.json b/tests/playground/package.json index b80a6738f..7e9ad4e19 100644 --- a/tests/playground/package.json +++ b/tests/playground/package.json @@ -54,30 +54,6 @@ "from": "../src/target/x86_64-pc-windows-msvc/release/wxc-sandbox-agent.exe", "to": "bin/x64/wxc-sandbox-agent.exe" }, - { - "from": "../src/target/x86_64-pc-windows-msvc/release/nanvixd.exe", - "to": "bin/x64/nanvixd.exe" - }, - { - "from": "../src/target/x86_64-pc-windows-msvc/release/bin/kernel.elf", - "to": "bin/x64/bin/kernel.elf" - }, - { - "from": "../src/target/x86_64-pc-windows-msvc/release/python3.initrd", - "to": "bin/x64/python3.initrd" - }, - { - "from": "../src/target/x86_64-pc-windows-msvc/release/nanvix_rootfs.img", - "to": "bin/x64/nanvix_rootfs.img" - }, - { - "from": "../src/target/x86_64-pc-windows-msvc/release/snapshots/kernel.vmem", - "to": "bin/x64/snapshots/kernel.vmem" - }, - { - "from": "../src/target/x86_64-pc-windows-msvc/release/snapshots/kernel.whp.cbor", - "to": "bin/x64/snapshots/kernel.whp.cbor" - }, { "from": "../sdk/node/bin/x64/vcruntime140.dll", "to": "bin/x64/vcruntime140.dll" diff --git a/tests/playground/src/main/main.ts b/tests/playground/src/main/main.ts index 1d2af9dce..f60df509b 100644 --- a/tests/playground/src/main/main.ts +++ b/tests/playground/src/main/main.ts @@ -521,45 +521,11 @@ ipcMain.handle('run-sandbox-raw', (_event, configJson: string, debug: boolean, e const config = JSON.parse(configJson); const execPath = resolveExecutablePath(); - // MicroVM (nanvixd) requires CWD to be the binary directory - let workingDir: string | undefined; - if (config.containment === 'microvm') { - const fs = require('fs'); - // Match the SDK's binary discovery layout (sdk/src/platform.ts): - // npm-packaged binaries live under sdk/bin/, local dev builds - // under src/target//{release,debug}. - const arch = process.arch === 'arm64' ? 'arm64' : 'x64'; - const triple = process.arch === 'arm64' ? 'aarch64-pc-windows-msvc' : 'x86_64-pc-windows-msvc'; - const repoRoot = path.join(__dirname, '..', '..', '..'); - const candidates = [ - execPath, - path.join(repoRoot, 'sdk', 'bin', arch), - path.join(repoRoot, 'src', 'target', triple, 'release'), - path.join(repoRoot, 'src', 'target', triple, 'debug'), - path.join(repoRoot, 'src', 'target', 'release'), - path.join(repoRoot, 'src', 'target', 'debug'), - ].filter(Boolean); - for (const c of candidates) { - const dir = c!.endsWith('.exe') ? path.dirname(c!) : c!; - if (fs.existsSync(path.join(dir, 'nanvixd.exe'))) { - workingDir = dir; - break; - } - } - if (!workingDir) { - return { - success: false, - error: `nanvixd.exe not found for arch '${arch}'. Looked in: ${candidates.join('; ')}. ` + - `MicroVM requires nanvixd.exe to be co-located with wxc-exec (and CWD must point at it).`, - }; - } - } - const ptyProcess = sdk.spawnSandboxFromConfig(config, { debug, experimental, executablePath: execPath, skipPlatformCheck: true, - }, workingDir); + }); attachPtyListeners(ptyProcess); return { success: true, config }; diff --git a/tests/playground/src/renderer/app.ts b/tests/playground/src/renderer/app.ts index a884576d1..0b94d8053 100644 --- a/tests/playground/src/renderer/app.ts +++ b/tests/playground/src/renderer/app.ts @@ -18,7 +18,7 @@ interface Scenario { script: string; policy: any; shell: 'cmd' | 'ps51' | 'ps7' | 'python' | 'networking' | 'filesystem'; - containment?: 'appcontainer' | 'windows_sandbox' | 'microvm' | 'hyperlight'; + containment?: 'appcontainer' | 'windows_sandbox' | 'hyperlight'; requiresV05?: boolean; /** If set, output must contain this string for a PASS verdict */ successMarker?: string; @@ -377,77 +377,6 @@ var SCENARIOS: Scenario[] = [ script: 'ping -n 30 127.0.0.1', policy: { timeoutMs: 5000 } }, - // ========== MicroVM (NanVix) ========== - { id: 'mv-hello', name: 'Hello from MicroVM', category: 'Quick Tests', categoryIcon: '🎯', shell: 'python', - containment: 'microvm', - description: 'Runs a simple Python script inside the NanVix micro-VM.', - expectedOutcome: 'succeed', expectedLabel: 'Should succeed', - script: "x = 42\ny = 58\nprint('Hello from MicroVM! sum=%d' % (x + y))", - policy: {}, successMarker: 'Hello from MicroVM!' }, - { id: 'mv-stdlib', name: 'Stdlib (json, math, hashlib)', category: 'Quick Tests', categoryIcon: '🎯', shell: 'python', - containment: 'microvm', - description: 'Imports json, math, hashlib to verify the CPython stdlib is available.', - expectedOutcome: 'succeed', expectedLabel: 'Should succeed', - script: "import json, math, hashlib\ndata = {'pi': math.pi, 'e': math.e, 'hash': hashlib.sha256(b'nanvix').hexdigest()[:16]}\nprint(json.dumps(data))", - policy: {}, successMarker: 'pi' }, - { id: 'mv-multiline', name: 'Fibonacci (multiline)', category: 'Quick Tests', categoryIcon: '🎯', shell: 'python', - containment: 'microvm', - description: 'Runs a multi-line Fibonacci function to verify complex scripts work.', - expectedOutcome: 'succeed', expectedLabel: 'Should succeed', - script: "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)}')", - policy: {}, successMarker: 'fib(9) = 34' }, - { id: 'mv-large-output', name: 'Large output (1000 lines)', category: 'Quick Tests', categoryIcon: '🎯', shell: 'python', - containment: 'microvm', - description: 'Prints 1000 lines to verify large output streaming works.', - expectedOutcome: 'succeed', expectedLabel: 'Should succeed', - script: "for i in range(1000):\n print(f'line {i}: ' + 'x' * 80)", - policy: {}, successMarker: 'line 999' }, - { id: 'mv-exit-code', name: 'Exit code 42', category: 'Error Cases', categoryIcon: '⚠️', shell: 'python', - containment: 'microvm', - description: 'Exits with code 42. Verifies exit codes propagate from the micro-VM.', - expectedOutcome: 'show-error', expectedLabel: 'Should exit 42', - script: 'import sys; sys.exit(42)', - policy: {} }, - { id: 'mv-error', name: 'Python error', category: 'Error Cases', categoryIcon: '⚠️', shell: 'python', - containment: 'microvm', - description: 'Raises a ValueError. Verifies stderr capture from the micro-VM.', - expectedOutcome: 'show-error', expectedLabel: 'Should show error', - script: "raise ValueError('intentional test error')", - policy: {} }, - { id: 'mv-timeout', name: 'Timeout', category: 'Error Cases', categoryIcon: '⚠️', shell: 'python', - containment: 'microvm', - description: 'Sleeps for 120s with a 5s timeout. MicroVM adds 60s boot grace, so actual ~65s.', - expectedOutcome: 'be-blocked', expectedLabel: 'Should be terminated', - script: "import time; time.sleep(120); print('should not reach here')", - policy: { timeoutMs: 5000 } }, - - // ========== MicroVM — Filesystem ========== - { id: 'mv-fs-write-read', name: 'Write & read file (FS mount)', category: 'Filesystem', categoryIcon: '📁', shell: 'filesystem', - containment: 'microvm', - description: 'Writes a file to a readwritePaths mount and reads it back. Verifies staging dir works.', - expectedOutcome: 'succeed', expectedLabel: 'Should succeed', - script: "import os\ntest_dir = 'C:\\\\Users\\\\Public\\\\MXCPlaygroundTests'\nfpath = os.path.join(test_dir, 'mxc-mv-test.txt')\ntry:\n with open(fpath, 'w') as f:\n f.write('hello from microvm')\n with open(fpath) as f:\n data = f.read()\n print('Read back:', data)\n assert data == 'hello from microvm', 'mismatch!'\n print('MV FS write/read OK')\nfinally:\n try: os.remove(fpath)\n except OSError: pass", - policy: { filesystem: { readwritePaths: ['C:\\Users\\Public\\MXCPlaygroundTests'] } }, successMarker: 'MV FS write/read OK' }, - { id: 'mv-fs-list-dir', name: 'List directory contents', category: 'Filesystem', categoryIcon: '📁', shell: 'filesystem', - containment: 'microvm', - description: 'Creates and lists files in a readwritePaths mount. Verifies directory operations.', - expectedOutcome: 'succeed', expectedLabel: 'Should succeed', - script: "import os\ntest_dir = 'C:\\\\Users\\\\Public\\\\MXCPlaygroundTests'\nnames = ['mxc-a.txt', 'mxc-b.txt', 'mxc-c.txt']\ntry:\n for name in names:\n with open(os.path.join(test_dir, name), 'w') as f:\n f.write(name)\n entries = [e for e in os.listdir(test_dir) if e.startswith('mxc-')]\n print('MXC files:', sorted(entries))\n assert 'mxc-a.txt' in entries and 'mxc-c.txt' in entries\n print('MV dir listing OK')\nfinally:\n for name in names:\n try: os.remove(os.path.join(test_dir, name))\n except OSError: pass", - policy: { filesystem: { readwritePaths: ['C:\\Users\\Public\\MXCPlaygroundTests'] } }, successMarker: 'MV dir listing OK' }, - - // ========== MicroVM — Stdlib ========== - { id: 'mv-stdlib-broad', name: 'Stdlib (re, datetime, collections)', category: 'Quick Tests', categoryIcon: '🎯', shell: 'python', - containment: 'microvm', - description: 'Imports re, datetime, collections to verify broader stdlib availability.', - expectedOutcome: 'succeed', expectedLabel: 'Should succeed', - script: "import re, datetime, collections\nm = re.match(r'(\\w+)@(\\w+)', 'user@host')\nprint('regex:', m.group(1), m.group(2))\nnow = datetime.datetime(2025, 1, 15, 12, 0)\nprint('datetime:', now.isoformat())\nc = collections.Counter('abracadabra')\nprint('counter:', c.most_common(3))\nprint('broad stdlib OK')", - policy: {}, successMarker: 'broad stdlib OK' }, - { id: 'mv-memory', name: 'Memory stress (10MB list)', category: 'Quick Tests', categoryIcon: '🎯', shell: 'python', - containment: 'microvm', - description: 'Allocates a 10MB list to verify the VM handles non-trivial memory.', - expectedOutcome: 'succeed', expectedLabel: 'Should succeed', - script: "data = list(range(1_000_000))\nprint('Allocated', len(data), 'items')\nprint('Sum:', sum(data[:100]))\nprint('Memory OK')", - policy: {}, successMarker: 'Memory OK' }, { id: 'hl-hello', name: 'Hello from Hyperlight', category: 'Quick Tests', categoryIcon: '🎯', shell: 'python', containment: 'hyperlight', description: 'Runs a simple Python script inside the Hyperlight+Unikraft micro-VM.', @@ -753,7 +682,7 @@ function getCurrentScript(): string { // Replace bare 'python' with resolved full path for BaseContainer compatibility // (skip for Windows Sandbox — it uses mapped Python from the host) var containment = $sel('containmentSelect').value; - if (containment !== 'windows_sandbox' && containment !== 'microvm' && containment !== 'hyperlight' && shellPaths.python?.exe && script.match(/^python\s/)) { + if (containment !== 'windows_sandbox' && containment !== 'hyperlight' && shellPaths.python?.exe && script.match(/^python\s/)) { script = '"' + shellPaths.python.exe + '"' + script.substring(6); } @@ -767,7 +696,6 @@ function getCurrentScript(): string { var CONTAINMENT_LABELS: Record = { appcontainer: 'Base Process Container', windows_sandbox: 'Windows Sandbox', - microvm: 'MicroVM (NanVix)', hyperlight: 'Hyperlight', wslc: 'WSLC', lxc: 'LXC', @@ -806,8 +734,8 @@ var cachedPlatformSupport: { isSupported: boolean; reason?: string } | null = nu /** Update the platform badge based on the selected containment backend. */ function updatePlatformBadgeForContainment(containment: string): void { - if (containment === 'microvm' || containment === 'hyperlight') { - // HL/MV don't depend on a specific Windows build version + if (containment === 'hyperlight') { + // Hyperlight does not depend on a specific Windows build version. var label = CONTAINMENT_LABELS[containment] || containment; $('platformBadge').textContent = '✓ ' + label + ' (no OS version requirement)'; } else if (cachedPlatformSupport) { @@ -824,7 +752,7 @@ function updatePlatformBadgeForContainment(containment: string): void { function isVmBackend(): boolean { var c = $sel('containmentSelect').value; - return c === 'microvm' || c === 'hyperlight' || c === 'windows_sandbox'; + return c === 'hyperlight' || c === 'windows_sandbox'; } function getPermsSummary(): string { @@ -1043,7 +971,7 @@ function populateScenarios(): void { select.innerHTML = ''; var containment = $sel('containmentSelect').value; - var isRawBackend = containment === 'windows_sandbox' || containment === 'microvm' || containment === 'hyperlight'; + var isRawBackend = containment === 'windows_sandbox' || containment === 'hyperlight'; var filtered = SCENARIOS.filter(function(s) { if (s.shell !== shell) return false; if (isRawBackend) return s.containment === containment; @@ -1223,13 +1151,7 @@ function buildRawBackendConfig( } // Hyperlight has no valid v0.10 network section. Omission keeps the // default-deny posture, which the backend maps to no networking. - if (containment === 'microvm') { - var action = scenarioPolicy.network.enabled ? 'allow' : 'deny'; - config.network = { - egress: { default: action }, - ingress: { default: action, hostLoopback: action }, - }; - } else if (containment !== 'hyperlight' && scenarioPolicy.network.enabled) { + if (containment !== 'hyperlight' && scenarioPolicy.network.enabled) { config.network = { egress: { default: 'allow' } }; } } @@ -1242,6 +1164,11 @@ function buildRawBackendConfig( config.filesystem.readonlyPaths = scenarioPolicy.filesystem.readonlyPaths; } } + // Hyperlight defaults to network-allowed when `network` is omitted; explicitly + // block unless the scenario opts in. + if (containment === 'hyperlight' && !config.network) { + config.network = { defaultPolicy: 'block' }; + } if (scenarioPolicy.timeoutMs) { config.process.timeout = scenarioPolicy.timeoutMs; } @@ -1289,9 +1216,9 @@ async function runSandbox(): Promise { return; } - // Windows Sandbox / MicroVM mode — build raw wxc-exec JSON config and use runSandboxRaw + // VM backends build raw wxc-exec JSON config and use runSandboxRaw. var currentContainment = $sel('containmentSelect').value; - if (currentContainment === 'windows_sandbox' || currentContainment === 'microvm' || currentContainment === 'hyperlight') { + if (currentContainment === 'windows_sandbox' || currentContainment === 'hyperlight') { var rawScript = state.selectedScenario ? state.selectedScenario.script : (state.customScript || '').trim(); if (!rawScript) { termError('No script specified'); @@ -1305,31 +1232,6 @@ async function runSandbox(): Promise { state.timeoutSeconds, ); - // MicroVM staging requires readwritePaths to exist on the host. Pre-create - // any dirs the scenario mounts (e.g. C:\Users\Public\MXCPlaygroundTests). - if (currentContainment === 'microvm' && rawConfig.filesystem && rawConfig.filesystem.readwritePaths) { - try { - var ensureDirsResult = await mxc.ensureDirs(rawConfig.filesystem.readwritePaths); - if (ensureDirsResult && typeof ensureDirsResult === 'object') { - if ((ensureDirsResult as any).success === false) { - throw new Error((ensureDirsResult as any).error || (ensureDirsResult as any).message || 'Unknown error'); - } - if (Array.isArray(ensureDirsResult)) { - var failedEnsureDir = ensureDirsResult.find(function (entry: any) { - return entry && typeof entry === 'object' && entry.success === false; - }); - if (failedEnsureDir) { - throw new Error(failedEnsureDir.error || failedEnsureDir.message || 'Unknown error'); - } - } - } - } catch (e: any) { - termError('[Playground] Failed to pre-create RW mount dirs: ' + (e && e.message ? e.message : String(e))); - onSandboxExit(-1); - return; - } - } - state.running = true; if (!runAllInProgress) { ($('btnRun') as HTMLButtonElement).disabled = true; @@ -1349,8 +1251,6 @@ async function runSandbox(): Promise { termInfo('[MXC] API: spawnSandboxFromConfig (raw config)'); if (currentContainment === 'windows_sandbox') { termDim('[MXC] Note: First run may take 3-5 minutes while the sandbox VM boots.'); - } else if (currentContainment === 'microvm') { - termDim('[MXC] Note: MicroVM boot adds ~60s grace period to the script timeout.'); } else if (currentContainment === 'hyperlight') { termDim('[MXC] Note: First run may take longer while Hyperlight warms up the snapshot.'); } @@ -1516,9 +1416,9 @@ async function runAllScenarios(): Promise { // Filter scenarios: current shell, containment, available runtimes, version-appropriate var currentContainment = $sel('containmentSelect').value; - var isRawBackend = currentContainment === 'windows_sandbox' || currentContainment === 'microvm' || currentContainment === 'hyperlight'; + var isRawBackend = currentContainment === 'windows_sandbox' || currentContainment === 'hyperlight'; var scenariosToRun = SCENARIOS.filter(function(s) { - // When the user picks "python" on a raw backend (MicroVM/Hyperlight), also + // When the user picks "python" on a raw backend (Hyperlight), also // pull in the pseudo-shell categories (networking, filesystem) since those // scenarios are Python-backed and would otherwise be silently skipped. var shellMatch = (s.shell === currentShell); @@ -1528,7 +1428,7 @@ async function runAllScenarios(): Promise { } if (!shellMatch) { return false; } if (isRawBackend) { if (s.containment !== currentContainment) return false; } - else { if (s.containment === 'windows_sandbox' || s.containment === 'microvm' || s.containment === 'hyperlight') return false; } + else { if (s.containment === 'windows_sandbox' || s.containment === 'hyperlight') return false; } if (s.shell === 'ps7' && !shellAvailability['ps7']) { return false; } if (s.shell === 'python' && !shellAvailability['python']) { return false; } if (s.requiresV05 && version !== '0.5.0-dev') { return false; } @@ -1903,7 +1803,7 @@ function showJsonPanel(tab: string): void { if (tab === 'policy') { var containment = $sel('containmentSelect').value; - if (containment === 'windows_sandbox' || containment === 'microvm' || containment === 'hyperlight') { + if (containment === 'windows_sandbox' || containment === 'hyperlight') { var rawScript = state.selectedScenario ? state.selectedScenario.script : (state.customScript || '').trim(); var rawConfig = buildRawBackendConfig( containment, @@ -1918,7 +1818,7 @@ function showJsonPanel(tab: string): void { } } else { var containment2 = $sel('containmentSelect').value; - if (containment2 === 'windows_sandbox' || containment2 === 'microvm' || containment2 === 'hyperlight') { + if (containment2 === 'windows_sandbox' || containment2 === 'hyperlight') { var rawScript2 = state.selectedScenario ? state.selectedScenario.script : (state.customScript || '').trim(); var rawConfig2 = buildRawBackendConfig( containment2, @@ -1968,8 +1868,8 @@ function updateDevSidebar(): void { var currentShell = $sel('shellSelect').value; var currentContainment = $sel('containmentSelect').value; - // Windows Sandbox / MicroVM / Hyperlight mode — show the raw config - if ((currentContainment === 'windows_sandbox' || currentContainment === 'microvm' || currentContainment === 'hyperlight') && currentShell !== 'rawjson') { + // Windows Sandbox / Hyperlight mode — show the raw config + if ((currentContainment === 'windows_sandbox' || currentContainment === 'hyperlight') && currentShell !== 'rawjson') { var rawScript = state.selectedScenario ? state.selectedScenario.script : (state.customScript || '').trim(); var rawConfig = buildRawBackendConfig( currentContainment, @@ -2187,27 +2087,6 @@ function init(): void { } else { populateScenarios(); } - } else if (containment === 'microvm') { - // MicroVM (NanVix) — the policy-generator UI is hidden because MicroVM - // does not consume the SandboxPolicy schema; scenarios may still set - // filesystem (and other) fields in the raw config sent to wxc-exec. - $('runtimeRow').classList.remove('hidden'); - $sel('shellSelect').disabled = false; - $('experimentalCaution').classList.add('hidden'); - $('policySectionWrapper').classList.add('hidden'); - $('advancedSectionWrapper').classList.add('hidden'); - $('uiGroupWrapper').classList.add('hidden'); - ($('experimentalToggle') as HTMLInputElement).checked = true; - ($('experimentalToggle') as HTMLInputElement).disabled = true; - // MicroVM supports Python + Filesystem test categories - var mvOpts = $sel('shellSelect').options; - for (var mi = 0; mi < mvOpts.length; mi++) { - var v = mvOpts[mi].value; - (mvOpts[mi] as HTMLOptionElement).hidden = (v !== 'python' && v !== 'filesystem' && v !== 'custom' && v !== 'rawjson'); - } - $sel('shellSelect').value = 'python'; - $sel('shellSelect').dispatchEvent(new Event('change')); - updatePlatformBadgeForContainment(containment); } else if (containment === 'hyperlight') { // Hyperlight — the policy-generator UI is hidden because Hyperlight // does not consume the SandboxPolicy schema; scenarios may still set @@ -2235,7 +2114,7 @@ function init(): void { $('categoryRow').classList.add('hidden'); $('policySectionWrapper').classList.remove('hidden'); // Restore PowerShell runtimes (but keep pseudo-shells hidden — they only - // apply to MicroVM/Hyperlight, where Python is the runtime). + // apply to Hyperlight, where Python is the runtime). var shellOpts2 = $sel('shellSelect').options; for (var j = 0; j < shellOpts2.length; j++) { var sv2 = shellOpts2[j].value; @@ -2253,7 +2132,7 @@ function init(): void { $('policySectionWrapper').classList.remove('hidden'); ($('experimentalToggle') as HTMLInputElement).disabled = false; // Restore PowerShell runtimes (but keep pseudo-shells hidden — they only - // apply to MicroVM/Hyperlight, where Python is the runtime). + // apply to Hyperlight, where Python is the runtime). var shellOpts3 = $sel('shellSelect').options; for (var k = 0; k < shellOpts3.length; k++) { var sv3 = shellOpts3[k].value; @@ -2279,7 +2158,7 @@ function init(): void { $('categoryRow').classList.add('hidden'); $('scriptSection').classList.remove('hidden'); $('rawJsonSection').classList.add('hidden'); - if ($sel('containmentSelect').value !== 'windows_sandbox' && $sel('containmentSelect').value !== 'microvm' && $sel('containmentSelect').value !== 'hyperlight') { + if ($sel('containmentSelect').value !== 'windows_sandbox' && $sel('containmentSelect').value !== 'hyperlight') { $('policySectionWrapper').classList.remove('hidden'); } $('btnRun').classList.remove('hidden'); @@ -2314,7 +2193,7 @@ function init(): void { populateScenarios(); $('btnRun').classList.remove('hidden'); $('btnRunAll').classList.remove('hidden'); - if ($sel('containmentSelect').value !== 'windows_sandbox' && $sel('containmentSelect').value !== 'microvm' && $sel('containmentSelect').value !== 'hyperlight') { + if ($sel('containmentSelect').value !== 'windows_sandbox' && $sel('containmentSelect').value !== 'hyperlight') { $('policySectionWrapper').classList.remove('hidden'); } if (document.getElementById('advancedSectionWrapper')) { diff --git a/tests/playground/src/renderer/index.html b/tests/playground/src/renderer/index.html index 8bcecdf16..405244d1b 100644 --- a/tests/playground/src/renderer/index.html +++ b/tests/playground/src/renderer/index.html @@ -107,7 +107,6 @@

Scenario

@@ -450,7 +449,7 @@

Containment Types

  • 🛡️ Base Process Container — Uses the Windows process security environment API.
  • 🛡️ AppContainer — Default for 0.4.0-alpha. Standard Windows AppContainer isolation.
  • -
  • 🧪 Windows Sandbox / MicroVM / WSLC — Experimental. Select from the Containment dropdown — these only support MXC JSON Config mode (paste your own config).
  • +
  • 🧪 Windows Sandbox / Hyperlight / WSLC — Experimental. Select from the Containment dropdown — these only support MXC JSON Config mode (paste your own config).
diff --git a/tests/scripts/README.md b/tests/scripts/README.md index 0b8b9edf1..7fd3544ab 100644 --- a/tests/scripts/README.md +++ b/tests/scripts/README.md @@ -37,8 +37,6 @@ Linux / macOS (`.sh`): | `run_filesystem_bfs_spaces_test.ps1` | BFS path-with-spaces test | `wxc-exec.exe` | | `run_test_configs.ps1` | All test configs via wxc-test-driver | `wxc-test-driver.exe` | | `run_examples.ps1` | All examples via wxc-test-driver | `wxc-test-driver.exe` | -| `run_microvm_basic_test.ps1` | MicroVM smoke test | `wxc-exec.exe`, NanVix binaries | -| `run_microvm_tests.ps1` | Full MicroVM E2E suite | WHP enabled, NanVix binaries | | `run_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 | @@ -95,8 +93,8 @@ these dispatchers, which map a matrix backend id to the suites above: | Dispatcher | Platforms | Backend ids | |------------|-----------|-------------| -| `scripts/ci/run_backend_validation_tests.ps1` | Windows | `process-t1`, `process-t3`, `isolation-session`, `windows-sandbox`, `wslc`, `microvm`, `hyperlight` | -| `scripts/ci/run_backend_validation_tests.sh` | Linux, macOS | `bubblewrap`, `lxc`, `seatbelt`, `microvm`, `hyperlight` | +| `scripts/ci/run_backend_validation_tests.ps1` | Windows | `process-t1`, `process-t3`, `isolation-session`, `windows-sandbox`, `wslc`, `hyperlight` | +| `scripts/ci/run_backend_validation_tests.sh` | Linux, macOS | `bubblewrap`, `lxc`, `seatbelt`, `hyperlight` | Pass the backend id exactly as it appears in the catalog — there is no separate handler name. Ids that share a suite have their own case in the dispatcher: @@ -220,22 +218,3 @@ Run them explicitly on capable machines with | `test_examples` | Requires velocity key 61714527 (BFS deadlock fix) | | `test_processcontainer_proxy` | Requires velocity key 61714527 (BFS deadlock fix) and elevation | | `test_on_repeat` | Stress test (loops BFS tests) | - -## MicroVM E2E - -### Build - -```powershell -cd src -cargo build --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 -``` - -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). diff --git a/tests/scripts/run_microvm_basic_test.ps1 b/tests/scripts/run_microvm_basic_test.ps1 deleted file mode 100644 index 5dffe2214..000000000 --- a/tests/scripts/run_microvm_basic_test.ps1 +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# MicroVM basic smoke test runner. -# -# Usage: -# .\run_microvm_basic_test.ps1 # debug build -# .\run_microvm_basic_test.ps1 -Release # release build - -param( - [switch]$Release, - [string]$BinDir -) - -$ErrorActionPreference = "Stop" -$RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) - -if (-not $BinDir) { - if ($Release) { - $BinDir = Join-Path $RepoRoot "src\target\release" - } else { - $BinDir = Join-Path $RepoRoot "src\target\debug" - } -} - -$WxcExec = Join-Path $BinDir "wxc-exec.exe" -$TestConfig = Join-Path $RepoRoot "tests\configs\microvm_hello.json" - -if (-not (Test-Path $WxcExec)) { - Write-Host "ERROR: wxc-exec.exe not found at $WxcExec" -ForegroundColor Red - Write-Host "Run 'cargo build$(if ($Release) { ' --release' })' first." -ForegroundColor Yellow - exit 1 -} - -Write-Host "Running MicroVM basic smoke test..." -ForegroundColor Cyan -& $WxcExec --debug --experimental $TestConfig -$exitCode = $LASTEXITCODE - -if ($exitCode -ne 0) { - Write-Host "FAILED: wxc-exec exited with code $exitCode" -ForegroundColor Red - exit $exitCode -} - -Write-Host "PASSED: MicroVM basic smoke test" -ForegroundColor Green diff --git a/tests/scripts/run_microvm_tests.ps1 b/tests/scripts/run_microvm_tests.ps1 deleted file mode 100644 index dcf6002b5..000000000 --- a/tests/scripts/run_microvm_tests.ps1 +++ /dev/null @@ -1,235 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -<# -.SYNOPSIS - Runs MicroVM E2E tests. Requires WHP and Nanvix binaries next to wxc-exec.exe. - -.DESCRIPTION - - Locates wxc-exec.exe (built with --features microvm) - - Verifies Nanvix binaries are present - - 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 - -.PARAMETER Release - Use release build (default: debug) - -.PARAMETER BinDir - Explicit binary directory. Overrides -Release logic when provided. - -.PARAMETER ConfigDir - Path to test configs directory. Defaults to \tests\configs - -.EXAMPLE - .\run_microvm_tests.ps1 - .\run_microvm_tests.ps1 -Release - .\run_microvm_tests.ps1 -BinDir C:\build\output -#> - -param( - [switch]$Release, - [string]$BinDir, - [string]$ConfigDir -) - -$ErrorActionPreference = "Stop" -$RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) - -if (-not $BinDir) { - if ($Release) { - $BinDir = Join-Path $RepoRoot "src\target\release" - } else { - $BinDir = Join-Path $RepoRoot "src\target\debug" - } -} - -if (-not $ConfigDir) { - $ConfigDir = Join-Path $RepoRoot "tests\configs" -} - -$WxcExePath = Join-Path $BinDir "wxc-exec.exe" - -# -- WHP check (local runs only) --------------------------------------------- -# In CI, the workflow checks WHP and fails before reaching this script. -# For local runs, check here and skip gracefully if WHP is unavailable. - -if (-not $env:CI) { - function Test-WhpAvailable { - if (-not (Test-Path "$env:SystemRoot\System32\WinHvPlatform.dll")) { - return $false - } - try { - $cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue - return ($cs -and $cs.HypervisorPresent) - } catch { - return $false - } - } - - if (-not (Test-WhpAvailable)) { - Write-Host "SKIP: Windows Hypervisor Platform (WHP) is not available." -ForegroundColor Yellow - Write-Host " Enable it with: Enable-WindowsOptionalFeature -Online -FeatureName HypervisorPlatform" - exit 0 - } -} - -Write-Host "`n=== MicroVM E2E Tests ===" -ForegroundColor Cyan - -# -- Locate wxc-exec.exe ----------------------------------------------------- - -if (-not (Test-Path $WxcExePath)) { - Write-Host "ERROR: wxc-exec.exe not found at: $WxcExePath" -ForegroundColor Red - Write-Host " Build with: cd src && cargo build --features microvm" - exit 1 -} - -$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" - -# -- 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_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_timeout.json"; ExpectedExit = -1; Description = "Timeout kills VM" } -) - -# -- Run tests ---------------------------------------------------------------- - -$passed = 0 -$failed = 0 -$results = @() - -foreach ($test in $tests) { - $configPath = Join-Path $ConfigDir $test.Config - if (-not (Test-Path $configPath)) { - Write-Host " SKIP $($test.Config) (file not found)" -ForegroundColor Yellow - continue - } - - Write-Host "`n--- $($test.Description) ($($test.Config)) ---" -ForegroundColor White - - $sw = [System.Diagnostics.Stopwatch]::StartNew() - $stdoutFile = [System.IO.Path]::GetTempFileName() - $stderrFile = [System.IO.Path]::GetTempFileName() - $process = Start-Process -FilePath $wxcExe ` - -ArgumentList "--debug", "--experimental", $configPath ` - -PassThru -Wait ` - -RedirectStandardOutput $stdoutFile ` - -RedirectStandardError $stderrFile - $sw.Stop() - - $actualExit = $process.ExitCode - $expectedExit = $test.ExpectedExit - $elapsedMs = $sw.ElapsedMilliseconds - $stdout = Get-Content $stdoutFile -Raw -ErrorAction SilentlyContinue - $stderr = Get-Content $stderrFile -Raw -ErrorAction SilentlyContinue - Remove-Item $stdoutFile, $stderrFile -ErrorAction SilentlyContinue - - $pass = ($actualExit -eq $expectedExit) - $reason = "" - - if (-not $pass) { - $reason = "expected exit=$expectedExit, got exit=$actualExit" - } - - # Check stdout content if OutputContains is specified. Not every test - # defines the key, and StrictMode makes a missing hashtable key throw, so - # test for its presence rather than accessing it directly. - if ($pass -and $test.ContainsKey('OutputContains')) { - $combined = "$stdout`n$stderr" - if ($combined -notmatch [regex]::Escape($test.OutputContains)) { - $pass = $false - $reason = "output missing '$($test.OutputContains)'" - } - } - - if ($pass) { - Write-Host " PASS (exit=$actualExit, ${elapsedMs}ms)" -ForegroundColor Green - $passed++ - $results += @{ Test = $test.Config; Status = "PASS"; Exit = $actualExit; WallTimeMs = $elapsedMs; Description = $test.Description } - } else { - Write-Host " FAIL ($reason, ${elapsedMs}ms)" -ForegroundColor Red - $combined = "$stdout`n$stderr" - $combined -split "`n" | Where-Object { $_.Trim() } | Select-Object -Last 3 | ForEach-Object { - Write-Host " > $($_.TrimEnd())" -ForegroundColor Gray - } - $failed++ - $results += @{ Test = $test.Config; Status = "FAIL"; Exit = $actualExit; WallTimeMs = $elapsedMs; Description = $test.Description } - } -} - -# -- Performance summary ------------------------------------------------------ - -Write-Host "`n=== Performance ===" -ForegroundColor Cyan -Write-Host (" {0,-35} {1,10} {2,8}" -f "Test", "Time (ms)", "Status") -Write-Host (" {0,-35} {1,10} {2,8}" -f "----", "---------", "------") -foreach ($r in $results) { - $color = if ($r.Status -eq "PASS") { "Green" } else { "Red" } - Write-Host (" {0,-35} {1,10} {2,8}" -f $r.Description, $r.WallTimeMs, $r.Status) -ForegroundColor $color -} - -# Write JSON results for CI artifact consumption -$perfOutput = @{ - commit = if ($env:GITHUB_SHA) { $env:GITHUB_SHA } else { "local" } - timestamp = (Get-Date -Format "o") - results = $results | ForEach-Object { - @{ - test = $_.Test - description = $_.Description - wall_time_ms = $_.WallTimeMs - exit_code = $_.Exit - status = $_.Status - } - } -} -$perfJsonPath = Join-Path $ConfigDir "..\microvm-perf-results.json" -$perfOutput | ConvertTo-Json -Depth 3 | Set-Content $perfJsonPath -Encoding UTF8 -Write-Host "`n Performance results written to: $perfJsonPath" - -# -- Summary ------------------------------------------------------------------ - -$total = $passed + $failed -Write-Host "`n=== Results ===" -ForegroundColor Cyan -if ($total -eq 0) { - Write-Host " ERROR: No tests were executed. Check -ConfigDir path." -ForegroundColor Red - exit 1 -} -Write-Host " Passed: $passed / $total" -if ($failed -gt 0) { - Write-Host " Failed: $failed / $total" -ForegroundColor Red - $results | Where-Object { $_.Status -eq "FAIL" } | ForEach-Object { - Write-Host " - $($_.Test) (exit=$($_.Exit))" -ForegroundColor Red - } - exit 1 -} else { - Write-Host " All MicroVM E2E tests passed!" -ForegroundColor Green - exit 0 -}