diff --git a/.github/workflows/hyperlight-e2e.yml b/.github/workflows/hyperlight-e2e.yml new file mode 100644 index 00000000..db1a1758 --- /dev/null +++ b/.github/workflows/hyperlight-e2e.yml @@ -0,0 +1,109 @@ +name: Hyperlight E2E Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + hyperlight-e2e: + name: WXC-Exec Hyperlight + runs-on: windows-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust toolchain + run: | + rustup update stable + rustup target add x86_64-pc-windows-msvc + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: src + + - name: Ensure surrogate build consistency + shell: pwsh + run: | + $hlsExe = "src\target\x86_64-pc-windows-msvc\debug\hls\x86_64-pc-windows-msvc\debug\hyperlight_surrogate.exe" + if (-not (Test-Path $hlsExe)) { + Write-Host "Surrogate missing — clearing hyperlight-host fingerprints to force rebuild" + Get-ChildItem "src\target\x86_64-pc-windows-msvc\debug\.fingerprint" -Filter "hyperlight-host-*" -Directory -ErrorAction SilentlyContinue | + Remove-Item -Recurse -Force + } + + - name: Build with Hyperlight support + working-directory: src + run: cargo build --features hyperlight --target x86_64-pc-windows-msvc + + - 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: Install crane (OCI tool) + if: steps.whp-check.outputs.whp_available == 'true' + shell: pwsh + run: | + $url = "https://github.com/google/go-containerregistry/releases/latest/download/go-containerregistry_Windows_x86_64.tar.gz" + Invoke-WebRequest -Uri $url -OutFile crane.tar.gz -UseBasicParsing + tar -xzf crane.tar.gz crane.exe + + - name: Download Hyperlight kernel and initrd + if: steps.whp-check.outputs.whp_available == 'true' + shell: pwsh + run: | + $pyhlHome = Join-Path $env:LOCALAPPDATA "pyhl" + New-Item -ItemType Directory -Force -Path $pyhlHome | Out-Null + + .\crane.exe export ghcr.io/danbugs/hyperlight-unikraft/python-agent-driver-kernel:latest kernel.tar + tar -xf kernel.tar -C $pyhlHome kernel + + .\crane.exe export ghcr.io/danbugs/hyperlight-unikraft/python-agent-driver-initrd:latest initrd.tar + tar -xf initrd.tar -C $pyhlHome initrd.cpio + + Write-Host "Downloaded to ${pyhlHome}:" + Get-ChildItem $pyhlHome + + - name: Warm Hyperlight snapshot + if: steps.whp-check.outputs.whp_available == 'true' + shell: pwsh + run: | + $binDir = Join-Path $env:GITHUB_WORKSPACE "src\target\x86_64-pc-windows-msvc\debug" + $json = '{"process":{"commandLine":"print(\"snapshot warm\")"},"containment":"hyperlight"}' + $b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($json)) + & "$binDir\wxc-exec.exe" --experimental --config-base64 $b64 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host "::error::Hyperlight snapshot warmup failed" + exit 1 + } + Write-Host "Snapshot created:" + Get-ChildItem (Join-Path $env:LOCALAPPDATA "pyhl") + + - name: Run Hyperlight 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_hyperlight_suite -- --nocapture + + - name: Upload logs on failure + if: failure() || cancelled() + uses: actions/upload-artifact@v6 + with: + name: hyperlight-e2e-logs-${{ github.event.pull_request.number || github.run_number }} + retention-days: 7 + path: | + logs/ + **/*.log diff --git a/build.bat b/build.bat index 5ca62075..94f438d2 100644 --- a/build.bat +++ b/build.bat @@ -8,6 +8,7 @@ set "BUILD_ALL=0" set "WITH_NANVIX=0" set "WITH_WSLC=0" set "WITH_ISOLATION_SESSION=0" +set "WITH_HYPERLIGHT=0" :: Parse arguments :parse_args @@ -20,6 +21,7 @@ if /i "%~1"=="--all" ( set "BUILD_ALL=1" & shift & goto :parse_arg if /i "%~1"=="--with-microvm" ( set "WITH_NANVIX=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 ) if /i "%~1"=="--help" ( goto :usage ) if /i "%~1"=="-h" ( goto :usage ) echo Unknown argument: %~1 @@ -41,6 +43,7 @@ if "%BUILD_CONFIG%"=="release" set "CARGO_FLAGS=--release --target" if "%WITH_NANVIX%"=="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%" :: Build Rust echo. @@ -140,6 +143,7 @@ echo --all Build for both x64 and ARM64 echo --with-microvm Download and include NanVix micro-VM binaries 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) echo -h, --help Show this help echo. echo Default: builds release for the current machine architecture. diff --git a/build.sh b/build.sh index 4ddbb322..d6ae80a9 100644 --- a/build.sh +++ b/build.sh @@ -13,6 +13,8 @@ CLI_DIR="$SCRIPT_DIR/cli" BUILD_TYPE="release" BUILD_SDK=true +WITH_HYPERLIGHT=false + while [[ $# -gt 0 ]]; do case $1 in --debug) @@ -23,13 +25,18 @@ while [[ $# -gt 0 ]]; do BUILD_SDK=false shift ;; + --with-hyperlight) + WITH_HYPERLIGHT=true + shift + ;; --help|-h) echo "Usage: build.sh [OPTIONS]" echo "" echo "Options:" - echo " --debug Build in debug mode (default: release)" - echo " --rust-only Only build Rust binaries, skip SDK/CLI" - echo " -h, --help Show this help message" + echo " --debug Build in debug mode (default: release)" + echo " --rust-only Only build Rust binaries, skip SDK/CLI" + echo " --with-hyperlight Build with Hyperlight (micro-VM) backend (x86_64 only)" + echo " -h, --help Show this help message" exit 0 ;; *) @@ -57,10 +64,15 @@ echo "" echo "=== Building Rust binaries ($BUILD_TYPE) ===" cd "$SRC_DIR" +CARGO_FEATURES="" +if [ "$WITH_HYPERLIGHT" = true ]; then + CARGO_FEATURES="--features hyperlight" +fi + if [ "$BUILD_TYPE" = "release" ]; then - cargo build --release -p lxc + cargo build --release -p lxc $CARGO_FEATURES else - cargo build -p lxc + cargo build -p lxc $CARGO_FEATURES fi echo "Rust build complete." diff --git a/docs/hyperlight-integration-plan.md b/docs/hyperlight-integration-plan.md new file mode 100644 index 00000000..7b477e2c --- /dev/null +++ b/docs/hyperlight-integration-plan.md @@ -0,0 +1,191 @@ +# MXC Hyperlight Integration — Design Document + +## Problem + +MXC needs a **cross-platform micro-VM execution backend** with a good story +for agentic Python workloads that care about cold-start time. The backend +should work identically on Linux and Windows, boot in milliseconds, and +provide hardware-level isolation. + +## Proposed Solution + +Add a **Hyperlight backend** — embedded [Hyperlight](https://github.com/hyperlight-dev/hyperlight) ++ [Unikraft](https://unikraft.org/) driving a warmed-up CPython snapshot +via the [`hyperlight-unikraft-host`](https://github.com/hyperlight-dev/hyperlight-unikraft) library. + +When the JSON config specifies `"containment": "hyperlight"`, `wxc-exec` +routes to `HyperlightScriptRunner`, which instantiates a Hyperlight micro-VM +directly in-process. Every `run_code(&script)` rewinds to the snapshot and runs +hermetic. + +**Cross-platform:** KVM on Linux, WHP on Windows — same code path, same +library. + +## Performance + +> Benchmarks: bare-metal Windows (Hyper-V / WHP). +> pyhl 0.1.0 (the CLI from `hyperlight-unikraft-host` for running python-agent unikernels), CPython 3.12.0, x86_64. 15 runs. + +| Metric | Median | Avg | Min | Max | +|--------|--------|-----|-----|-----| +| Hello world (`print(42)`, end-to-end) | 139 ms | 141 ms | 133 ms | 157 ms | + +## Density + +| Metric | Value | +|--------|-------| +| Per-VM memory | 17 MB | +| Shared snapshot (one-time, mapped read-only CoW) | ~650 MiB on disk (2 GiB apparent) | + +The snapshot file is 2 GiB in apparent size but only ~650 MiB on disk +thanks to sparse-file hole-punching (`fallocate(PUNCH_HOLE)` on Linux, +`FSCTL_SET_SPARSE` on Windows). It is mmap'd read-only and shared across +all VMs — each new VM only pays for pages it actually writes. + +## Ecosystem + +**Hyperlight-Unikraft** builds on two open-source foundations: + +- **[Unikraft](https://unikraft.org/)** — Linux Foundation project with + an active community, regular releases, and commercial backing. Hyperlight + platform support has been upstreamed + ([unikraft/unikraft#1821](https://github.com/unikraft/unikraft/pull/1821), + [unikraft/app-elfloader#102](https://github.com/unikraft/app-elfloader/pull/102), + [unikraft/kraftkit#2797](https://github.com/unikraft/kraftkit/pull/2797)). +- **[Hyperlight](https://github.com/hyperlight-dev/hyperlight)** — CNCF + sandbox project. Already adopted across multiple Microsoft organizations + including Edge Actions, HorizonDB, and the Agentic Framework. + +Beyond Python, hyperlight-unikraft supports .NET, Node.js, Go, Rust, +C/C++, PowerShell, and Bash/Shell runtimes. + +## Why a separate `Hyperlight` variant + +- **Non-breaking.** Existing containment backends are unaffected. +- **Distinct semantics.** Hyperlight has: + - A pre-installed warm snapshot as a prerequisite (not just binaries). + - An in-process execution model. + - A rich stdlib (full CPython + ~20 pre-imported packages including C + extensions: numpy, pandas, Pillow, pydantic, cryptography, lxml). + - Live VFS forwarding for host filesystem access — guest POSIX calls + are forwarded to the host in real-time, limited only by host disk. +- **Different artifact provenance.** Hyperlight images come from + `hyperlight-dev/hyperlight-unikraft`'s `python-agent-driver` pipeline. + Adding new packages is a Dockerfile change + rebuild. + +## Design Decisions + +1. **In-process, not subprocess.** Hyperlight is a Rust library; wxc-exec + is a Rust binary. Linking directly avoids pipe plumbing, watchdog + threads, and process lifecycle management. + +2. **`script_code` is raw Python source.** No shell quoting, no cmdline + limit (`run_code` takes `&str` unbounded). + +3. **`--experimental` gate.** Keeps this backend off the happy path until + artifact distribution and docs catch up. + +4. **Unsupported policies are rejected.** A config specifying `network` + or `workingDirectory` with `containment: "hyperlight"` produces a preflight + error. + +5. **Image artifacts are user-provided, not bundled.** The `--setup-hyperlight` + flag populates the image home. The runner auto-discovers + `$PYHL_HOME` → `/pyhl/` → `/.pyhl/`; the first location + with all three files wins. + +6. **Exit codes.** 0 on clean completion of `run_code`; -1 on any error + (preflight, runtime, guest crash). Distinct per-error variants go + through `error_message`. + +7. **stdout/stderr are inherited.** Guest `print(...)` reaches the user's + terminal directly via Hyperlight's `host_print`. + `ScriptResponse.standard_{out,err}` stay empty — consumers who need + capture redirect wxc-exec at the process level. + +## Workspace Changes + +``` +mxc/src/wxc_common/ +├── Cargo.toml # + hyperlight-unikraft-host dependency +└── src/ + ├── lib.rs # + pub mod hyperlight_runner; + ├── models.rs # + ContainmentBackend::Hyperlight (serde "hyperlight") + ├── config_parser.rs # + Some("hyperlight") => Hyperlight match arm + └── hyperlight_runner.rs # NEW + +mxc/src/wxc/ +└── src/main.rs # + ContainmentBackend::Hyperlight dispatch arm + +mxc/test_configs/ +├── hyperlight_hello.json # NEW — hello from Python +└── hyperlight_pandas.json # NEW — exercises pre-imported numpy/pandas + +mxc/docs/ +└── hyperlight-integration-plan.md # NEW — this document +``` + +## Configuration + +### JSON + +```json +{ + "process": { + "commandLine": "import sys\nprint(f'Python {sys.version.split()[0]} on {sys.platform}')", + "timeout": 30000 + }, + "containment": "hyperlight" +} +``` + +### Field semantics + +| JSON Field | Hyperlight Behavior | +|------------|---------------| +| `process.commandLine` | ✅ Used — raw Python source | +| `process.timeout` | ✅ Used — script execution timeout (ms) | +| `containment` | ✅ Must be `"hyperlight"` | +| `filesystem.*` | ✅ `readwritePaths`/`readonlyPaths` mapped to host mounts | +| `network.*` | ❌ Rejected | +| `workingDirectory` | ❌ Rejected (guest has its own FS namespace) | +| `appContainer.*`, `sandbox.*` | ❌ N/A | + +## Security Model + +| Property | Hyperlight | +|----------|------| +| Isolation level | Micro VM (KVM/WHP) | +| Host FS access | Explicit mounts via `Preopen` | +| Network | None | +| Guest OS | Unikraft unikernel | +| Cold start | ~30ms KVM / ~140 ms WHP | +| Host platforms | Linux + Windows | + +## Supported Workloads + +### Supported out of the box (preloaded in snapshot) + +| Category | Examples | +|----------|----------| +| Stdlib | `os`, `sys`, `json`, `re`, `pathlib`, `datetime`, `hashlib`, `itertools`, `functools`, `math`, `decimal`, `fractions`, `collections`, `statistics` | +| Pre-imported 3rd-party | `numpy`, `pandas`, `pydantic`, `yaml`, `jinja2`, `bs4`, `tabulate`, `click`, `tenacity`, `tqdm`, `openpyxl`, `pypdf`, `markdown_it`, `PIL`, `lxml`, `cryptography`, `dateutil`, `dotenv` | + +### Not supported + +| Why not | Example failure | +|---------|-----------------| +| No network stack in guest | `urllib`, `socket`, `http` — `OSError: Function not implemented` | +| Read-only sysroot by default | File writes under `/` — `OSError: Read-only file system` | +| No subprocess / fork | `subprocess.run` — `OSError: Function not implemented` | + +## Testing Strategy + +### Unit tests (`cargo test -p wxc_common`) + +- `is_installed_false_on_empty_dir` — negative case for the install probe +- `resolve_home_errors_when_nothing_configured` — actionable error when no image +- `policy_rejects_filesystem_paths` — blocks readwritePaths/readonlyPaths/deniedPaths +- `policy_rejects_network_rules` — blocks allowed/blockedHosts +- `policy_rejects_block_default_network` — blocks `defaultNetworkPolicy: block` +- `policy_rejects_working_directory` — blocks non-empty `workingDirectory` diff --git a/src/Cargo.lock b/src/Cargo.lock index 9cc2381d..c628f40a 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -16,7 +16,25 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", ] [[package]] @@ -84,6 +102,18 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -104,9 +134,29 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.11.0" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq 0.4.2", + "cpufeatures 0.3.0", +] [[package]] name = "block-buffer" @@ -117,12 +167,52 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" +dependencies = [ + "chrono", + "git2", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -178,6 +268,28 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" @@ -240,6 +352,18 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -249,6 +373,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -273,6 +406,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -326,6 +468,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -382,7 +545,7 @@ version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags", + "bitflags 2.11.1", "rustc_version", ] @@ -470,10 +633,48 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core", "wasip2", "wasip3", ] +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.11.1", + "libc", + "libgit2-sys", + "log", + "url", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "goblin" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "983a6aafb3b12d4c41ea78d39e189af4298ce747353945ff5105b54a056e5cd9" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -585,6 +786,99 @@ dependencies = [ "tokio", ] +[[package]] +name = "hyperlight-common" +version = "0.15.0" +source = "git+https://github.com/hyperlight-dev/hyperlight?branch=disk_snapshot_copy#621cc03beadae3d9158e72d07d3d156abf2b21fd" +dependencies = [ + "anyhow", + "flatbuffers", + "log", + "spin", + "thiserror", + "tracing", + "tracing-core", +] + +[[package]] +name = "hyperlight-host" +version = "0.15.0" +source = "git+https://github.com/hyperlight-dev/hyperlight?branch=disk_snapshot_copy#621cc03beadae3d9158e72d07d3d156abf2b21fd" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "blake3", + "built", + "bytemuck", + "cfg-if", + "cfg_aliases", + "crossbeam-channel", + "flatbuffers", + "goblin", + "hyperlight-common", + "kvm-bindings", + "kvm-ioctls", + "lazy_static", + "libc", + "log", + "metrics", + "mshv-bindings", + "mshv-ioctls", + "page_size", + "rand", + "rust-embed", + "serde_json", + "termcolor", + "thiserror", + "tracing", + "tracing-core", + "tracing-log", + "uuid", + "vmm-sys-util", + "windows", + "windows-result", + "windows-sys", + "windows-version", +] + +[[package]] +name = "hyperlight-unikraft-host" +version = "0.1.0" +source = "git+https://github.com/hyperlight-dev/hyperlight-unikraft?branch=main#a72768132b699176ac348f5d2d1765f3ed62a061" +dependencies = [ + "anyhow", + "base64", + "clap", + "hyperlight-host", + "memmap2", + "nix", + "serde_json", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -758,6 +1052,33 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kvm-bindings" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3c06ff73c7ce03e780887ec2389d62d2a2a9ddf471ab05c2ff69207cd3f3b4" +dependencies = [ + "vmm-sys-util", +] + +[[package]] +name = "kvm-ioctls" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333f77a20344a448f3f70664918135fddeb804e938f28a99d685bd92926e0b19" +dependencies = [ + "bitflags 2.11.1", + "kvm-bindings", + "libc", + "vmm-sys-util", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -766,9 +1087,21 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libgit2-sys" +version = "0.18.3+1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] [[package]] name = "libloading" @@ -786,10 +1119,22 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", "plain", - "redox_syscall 0.7.4", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", ] [[package]] @@ -868,6 +1213,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -877,6 +1231,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "metrics" +version = "0.24.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff56c2e7dce6bd462e3b8919986a617027481b1dcc703175b58cf9dd98a2f071" +dependencies = [ + "portable-atomic", + "rapidhash", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -898,6 +1262,30 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "mshv-bindings" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94fc3871dd23738188e5bc76a1d1a5930ebcaf9308c560a7274aa62b1770594" +dependencies = [ + "libc", + "num_enum", + "vmm-sys-util", + "zerocopy", +] + +[[package]] +name = "mshv-ioctls" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1339723fe3a26baf4041459de20ad923e89d312c3bb25dbf9f60738c22a47f5e" +dependencies = [ + "libc", + "mshv-bindings", + "thiserror", + "vmm-sys-util", +] + [[package]] name = "mxc_darwin" version = "0.1.0" @@ -940,7 +1328,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -953,6 +1341,36 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -965,6 +1383,22 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1028,6 +1462,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1083,22 +1523,112 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rapidhash" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +dependencies = [ + "rustversion", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] name = "redox_syscall" -version = "0.7.4" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" dependencies = [ - "bitflags", + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "shellexpand", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "globset", + "sha2", + "walkdir", ] [[package]] @@ -1116,7 +1646,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -1129,6 +1659,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "sandbox_spec" version = "0.1.0" @@ -1142,6 +1681,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed76efe62313ab6610570951494bdaa81568026e0318eaa55f167de70eeea67d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "seatbelt_common" version = "0.1.0" @@ -1206,10 +1765,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", "digest", ] +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1248,6 +1827,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "lock_api", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1312,6 +1900,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1389,6 +1986,50 @@ dependencies = [ "syn", ] +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -1439,21 +2080,53 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vmm-sys-util" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "506c62fdf617a5176827c2f9afbcf1be155b03a9b4bf9617a60dbc07e3a1642f" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1560,7 +2233,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -1572,6 +2245,37 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" @@ -1691,6 +2395,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1749,7 +2462,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -1819,6 +2532,7 @@ dependencies = [ "base64", "flatbuffers", "getrandom 0.2.17", + "hyperlight-unikraft-host", "isolation_session_bindings", "sandbox_spec", "semver", @@ -1939,6 +2653,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.7" @@ -2022,7 +2756,7 @@ dependencies = [ "aes", "arbitrary", "bzip2", - "constant_time_eq", + "constant_time_eq 0.3.1", "crc32fast", "crossbeam-utils", "deflate64", diff --git a/src/lxc/Cargo.toml b/src/lxc/Cargo.toml index 86d674e6..408a9b26 100644 --- a/src/lxc/Cargo.toml +++ b/src/lxc/Cargo.toml @@ -7,6 +7,9 @@ edition = "2021" name = "lxc-exec" path = "src/main.rs" +[features] +hyperlight = ["wxc_common/hyperlight"] + [dependencies] wxc_common = { workspace = true } lxc_common = { workspace = true } diff --git a/src/lxc/src/main.rs b/src/lxc/src/main.rs index d72d3149..9664b865 100644 --- a/src/lxc/src/main.rs +++ b/src/lxc/src/main.rs @@ -12,6 +12,8 @@ use wxc_common::models::{CodexRequest, ContainmentBackend, ScriptResponse}; use wxc_common::script_runner::{handle_dry_run_exit, ScriptRunner}; use lxc_common::lxc_runner::LxcScriptRunner; +#[cfg(feature = "hyperlight")] +use wxc_common::hyperlight_runner::HyperlightScriptRunner; #[derive(Parser)] #[command(name = "lxc-exec", about = "Linux Container Executor")] @@ -51,6 +53,21 @@ struct Cli { /// Path to diagnostic log file (appends, creates if missing) #[arg(long = "log-file")] log_file: Option, + + /// Install the warmed Hyperlight snapshot and exit. Pulls the + /// published kernel + initrd from GHCR (via docker or podman), + /// warms them up, and writes the snapshot into the default user + /// data dir (~/.local/share/pyhl on Linux, %LOCALAPPDATA%\pyhl on + /// Windows). $PYHL_HOME overrides the destination if set. Intended + /// for tool install hooks so first-run has zero warmup cost. + #[arg(long = "setup-hyperlight")] + setup_hyperlight: bool, + + /// Rebuild the snapshot even if one already exists. Use after + /// upgrading `kernel` or `initrd.cpio` so the warm state matches + /// the new bits. Requires --setup-hyperlight. + #[arg(long, requires = "setup_hyperlight")] + force: bool, } fn log_request(request: &CodexRequest, logger: &mut Logger) { @@ -92,6 +109,35 @@ fn delete_lxc_container(name: &str, logger: &mut Logger) -> bool { fn main() { let cli = Cli::parse(); + // --setup-hyperlight: eagerly warm up the snapshot and exit. Runs + // before config parsing so the user doesn't need a JSON file on + // disk just to install. + if cli.setup_hyperlight { + #[cfg(feature = "hyperlight")] + { + let mut logger = Logger::new(if cli.debug { + Mode::Console + } else { + Mode::Buffer + }); + match wxc_common::hyperlight_runner::setup(cli.force, &mut logger) { + Ok(snap) => { + eprintln!("hyperlight setup: snapshot ready at {:?}", snap); + process::exit(0); + } + Err(msg) => { + eprintln!("hyperlight setup failed: {msg}"); + process::exit(1); + } + } + } + #[cfg(not(feature = "hyperlight"))] + { + eprintln!("Error: --setup-hyperlight requires x86_64 (Hyperlight needs KVM or WHP)"); + process::exit(1); + } + } + // Determine config input let (config_data, is_base64) = if let Some(ref b64) = cli.config_base64 { (b64.clone(), true) @@ -146,18 +192,45 @@ fn main() { log_request(&request, &mut logger); - // Verify containment backend is LXC - if request.containment != ContainmentBackend::Lxc { - // Default to LXC on Linux regardless of what was specified - logger.log_line("Note: Overriding containment backend to LXC on Linux."); - } - - // Run script in LXC container - let mut runner = LxcScriptRunner::new( - &request.lxc_config, - &request.container_id, - &request.lifecycle, - ); + // Dispatch by containment backend. LXC is the default on Linux; + // Hyperlight is the new embedded Hyperlight+Unikraft micro-VM. + let mut runner: Box = match request.containment { + ContainmentBackend::Hyperlight => { + #[cfg(feature = "hyperlight")] + { + if !request.experimental_enabled { + eprintln!( + "Error: Hyperlight (Hyperlight+Unikraft) is an experimental feature. \ + Use --experimental flag." + ); + process::exit(1); + } + Box::new(HyperlightScriptRunner::new()) + } + #[cfg(not(feature = "hyperlight"))] + { + eprintln!( + "Error: Hyperlight backend requires x86_64 (Hyperlight needs KVM or WHP)" + ); + process::exit(1); + } + } + ContainmentBackend::Lxc => Box::new(LxcScriptRunner::new( + &request.lxc_config, + &request.container_id, + &request.lifecycle, + )), + ref other => { + logger.log_line(&format!( + "Note: containment {other:?} unsupported on lxc-exec; falling back to LXC." + )); + Box::new(LxcScriptRunner::new( + &request.lxc_config, + &request.container_id, + &request.lifecycle, + )) + } + }; let run_start = Instant::now(); let response = runner.run(&request, &mut logger); let run_elapsed = run_start.elapsed(); diff --git a/src/wxc/Cargo.toml b/src/wxc/Cargo.toml index 6190f812..00984c61 100644 --- a/src/wxc/Cargo.toml +++ b/src/wxc/Cargo.toml @@ -26,4 +26,5 @@ nanvix_common = { path = "../nanvix_common" } default = [] microvm = ["nanvix_binaries"] wslc = ["dep:wslc_common", "wslc_common/link-wslcsdk"] +hyperlight = ["wxc_common/hyperlight"] isolation_session = ["dep:isolation_session_bindings", "wxc_common/isolation_session"] diff --git a/src/wxc/src/main.rs b/src/wxc/src/main.rs index a259eecf..a636a38e 100644 --- a/src/wxc/src/main.rs +++ b/src/wxc/src/main.rs @@ -13,6 +13,8 @@ use wxc_common::base_container_runner::BaseContainerRunner; use wxc_common::config_parser::{is_base_container_version, load_mxc_request, ParseError}; use wxc_common::diagnostic::DiagnosticConfig; use wxc_common::filesystem_bfs::FileSystemBfsManager; +#[cfg(feature = "hyperlight")] +use wxc_common::hyperlight_runner::HyperlightScriptRunner; #[cfg(feature = "isolation_session")] use wxc_common::isolation_session_runner::IsolationSessionRunner; use wxc_common::logger::{Logger, Mode}; @@ -62,6 +64,21 @@ struct Cli { /// Path to diagnostic log file (appends, creates if missing) #[arg(long = "log-file")] log_file: Option, + + /// Install the warmed Hyperlight snapshot and exit. Pulls the + /// published kernel + initrd from GHCR (via docker or podman), + /// warms them up, and writes the snapshot into the default user + /// data dir (~/.local/share/pyhl on Linux, %LOCALAPPDATA%\pyhl on + /// Windows). $PYHL_HOME overrides the destination if set. Intended + /// for tool install hooks so first-run has zero warmup cost. + #[arg(long = "setup-hyperlight")] + setup_hyperlight: bool, + + /// Rebuild the snapshot even if one already exists. Use after + /// upgrading `kernel` or `initrd.cpio` so the warm state matches + /// the new bits. Requires --setup-hyperlight. + #[arg(long, requires = "setup_hyperlight")] + force: bool, } fn log_request(request: &CodexRequest, logger: &mut Logger) { @@ -172,6 +189,35 @@ fn main() { let cli = Cli::parse(); + // --setup-hyperlight: warm up the snapshot and exit. Runs before + // config parsing so the user doesn't need a JSON file on disk + // just to install. + if cli.setup_hyperlight { + #[cfg(feature = "hyperlight")] + { + let mut logger = Logger::new(if cli.debug { + Mode::Console + } else { + Mode::Buffer + }); + match wxc_common::hyperlight_runner::setup(cli.force, &mut logger) { + Ok(snap) => { + eprintln!("hyperlight setup: snapshot ready at {:?}", snap); + process::exit(0); + } + Err(msg) => { + eprintln!("hyperlight setup failed: {msg}"); + process::exit(1); + } + } + } + #[cfg(not(feature = "hyperlight"))] + { + eprintln!("Error: --setup-hyperlight requires x86_64 (Hyperlight needs KVM or WHP)"); + process::exit(1); + } + } + // Determine config input and whether it's base64 let (config_data, is_base64) = if let Some(ref b64) = cli.config_base64 { (b64.clone(), true) @@ -371,6 +417,26 @@ fn main() { } Box::new(NanVixScriptRunner::new()) } + ContainmentBackend::Hyperlight => { + #[cfg(feature = "hyperlight")] + { + if !request.experimental_enabled { + eprintln!( + "Error: Hyperlight (Hyperlight+Unikraft) is an experimental feature. \ + Use --experimental flag." + ); + process::exit(1); + } + Box::new(HyperlightScriptRunner::new()) + } + #[cfg(not(feature = "hyperlight"))] + { + eprintln!( + "Error: Hyperlight backend requires x86_64 (Hyperlight needs KVM or WHP)" + ); + process::exit(1); + } + } ContainmentBackend::WindowsSandbox => { if !request.experimental_enabled { eprintln!( diff --git a/src/wxc_common/Cargo.toml b/src/wxc_common/Cargo.toml index bc538a57..cf90adb9 100644 --- a/src/wxc_common/Cargo.toml +++ b/src/wxc_common/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [features] +hyperlight = ["dep:hyperlight-unikraft-host"] isolation_session = [ "dep:isolation_session_bindings", "dep:windows-future", @@ -19,6 +20,9 @@ url = { workspace = true } getrandom = { workspace = true } semver = "1" +[target.'cfg(target_arch = "x86_64")'.dependencies] +hyperlight-unikraft-host = { git = "https://github.com/hyperlight-dev/hyperlight-unikraft", branch = "main", optional = true } + [target.'cfg(target_os = "windows")'.dependencies] windows = { workspace = true } windows-core = { workspace = true } diff --git a/src/wxc_common/src/config_parser.rs b/src/wxc_common/src/config_parser.rs index 141ec9fd..42be8f4d 100644 --- a/src/wxc_common/src/config_parser.rs +++ b/src/wxc_common/src/config_parser.rs @@ -637,9 +637,10 @@ fn convert_raw_config_inner( ); ContainmentBackend::Seatbelt } + Some("hyperlight") => ContainmentBackend::Hyperlight, Some(other) => { let msg = format!( - "Invalid containment value '{}' (must be 'process', 'processcontainer', 'windows_sandbox', 'isolation_session', 'wslc', 'lxc', 'vm', 'microvm' or 'seatbelt')", + "Invalid containment value '{}' (must be 'process', 'processcontainer', 'windows_sandbox', 'isolation_session', 'wslc', 'lxc', 'vm', 'microvm', 'seatbelt', or 'hyperlight')", other ); logger.log_line(&msg); diff --git a/src/wxc_common/src/hyperlight_runner.rs b/src/wxc_common/src/hyperlight_runner.rs new file mode 100644 index 00000000..774e0b24 --- /dev/null +++ b/src/wxc_common/src/hyperlight_runner.rs @@ -0,0 +1,749 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `HyperlightScriptRunner` — executes Python code inside a Hyperlight + Unikraft +//! micro-VM, driven by the `hyperlight-unikraft-host::pyhl` library. +//! +//! | Property | Value | +//! |---------------------|-----------------------------------------------------------| +//! | Backing micro-VM | Unikraft unikernel in a Hyperlight micro-VM | +//! | Host platform | Linux (KVM) + Windows (WHP) | +//! | Execution model | Embedded library, in-process | +//! | Script delivery | Direct `Runtime::run_code(&str)` | +//! | Cold start | Snapshot restore (~50–60 ms) | +//! | Filesystem | Host dir mounts via `Preopen` | +//! | Script I/O | Host's stdout/stderr (host_print) | +//! | stdlib coverage | Full CPython + preloaded ML stack (numpy, pandas, etc.) | +//! +//! ## Image-home resolution +//! +//! The runner looks for a warmed image in this order, first hit wins: +//! +//! 1. `$PYHL_HOME` (override — if set, must be a usable install) +//! 2. `~/.local/share/pyhl/` on Linux (XDG_DATA_HOME compliant) +//! `%LOCALAPPDATA%\pyhl\` on Windows +//! 3. `/pyhl/` (dev build next to the target binary) +//! 4. `/.pyhl/` (dev fallback, same as pyhl's own CLI) +//! +//! Path #2 is the "default". `--setup-hyperlight` installs here when nothing +//! else is already populated — so one eager install persists across +//! shell sessions, across reboots, and across `cargo install` upgrades. +//! +//! ## Setup +//! +//! `lxc-exec --setup-hyperlight` (or `wxc-exec --setup-hyperlight`) installs the +//! warm snapshot. It pulls the published kernel + initrd from GHCR +//! via docker or podman, warms them +//! up, and persists a snapshot to the default home — zero +//! configuration beyond having docker/podman on `$PATH`. +//! +//! On first `run` (if setup was skipped) the runner also does a lazy +//! auto-install if kernel+initrd are already in the resolved home +//! but no snapshot is — cheap safety net. +//! +//! ## Filesystem policy +//! +//! `policy.readwritePaths` and `policy.readonlyPaths` are translated to +//! [`Preopen`] entries — the guest sees the host directories at +//! `/host/` and can read/write through them via `lib/hostfs`. +//! There is currently no hostfs-level RO enforcement; `readonlyPaths` are +//! mounted writable and policy expectations rely on the caller not +//! writing (documented limitation until hostfs learns read-only mounts). +//! +//! `policy.deniedPaths` is honored: any path that appears in the denied +//! list is rejected at preflight — including paths that also appear in +//! the allow lists. +//! +//! ## I/O model +//! +//! The guest's `print(...)` goes through Hyperlight's host_print callback, +//! which writes to the **host process's stdout**. `ScriptResponse.standard_out` +//! and `standard_err` stay empty; consumers that need captured output +//! redirect wxc-exec's stdout/stderr at the process level. +//! +//! ## Exit codes +//! +//! 0 on clean `run_code` completion, -1 on any error (preflight, install, +//! runtime, guest crash). The specific failure mode is in `error_message`. + +use std::path::{Path, PathBuf}; + +use crate::logger::Logger; +use crate::models::{CodexRequest, NetworkPolicy, ScriptResponse}; +use crate::script_runner::ScriptRunner; + +use hyperlight_unikraft::pyhl; +use hyperlight_unikraft::Preopen; + +// -- Error classification ---------------------------------------------------- + +#[derive(Debug)] +enum PyhlError { + /// Pre-spawn validation failures (missing image, unsupported policy). + Preflight(String), + /// Runtime construction, install, or execution failure. + Runtime(String), +} + +impl std::fmt::Display for PyhlError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PyhlError::Preflight(msg) => write!(f, "hyperlight preflight error: {msg}"), + PyhlError::Runtime(msg) => write!(f, "hyperlight runtime error: {msg}"), + } + } +} + +impl PyhlError { + fn to_response(&self) -> ScriptResponse { + ScriptResponse { + exit_code: ERROR_EXIT_CODE, + error_message: self.to_string(), + ..Default::default() + } + } +} + +const ERROR_EXIT_CODE: i32 = -1; + +/// Env var override for the Hyperlight image home. Set this to force a +/// specific location; otherwise the runner uses a standard OS-local +/// data path (~/.local/share/pyhl on Linux, %LOCALAPPDATA%\pyhl on +/// Windows). +const PYHL_HOME_ENV: &str = "PYHL_HOME"; +/// Subdirectory used next to the running executable (dev builds). +const EXE_RELATIVE_HOME: &str = "pyhl"; +/// Subdirectory used in the cwd as a last resort (dev fallback). +const CWD_RELATIVE_HOME: &str = ".pyhl"; +/// Final component of the default OS-local data path. +const DEFAULT_HOME_LEAF: &str = "pyhl"; + +// The filenames the installer writes; duplicated here to avoid a +// compile-time dep on internal path constants. +const KERNEL_FILE: &str = "kernel"; +const INITRD_FILE: &str = "initrd.cpio"; +const SNAPSHOT_FILE: &str = "snapshot.hls"; + +const ERR_NETWORK_POLICY: &str = + "network policy is not supported by the hyperlight backend -- guest has no network stack"; +const ERR_PROXY_POLICY: &str = + "network proxy is not supported by the hyperlight backend -- guest has no network stack"; +const ERR_WORKDIR: &str = + "workingDirectory is not supported by the hyperlight backend -- guest has its own filesystem namespace"; +const ERR_NO_INSTALL_SOURCE: &str = + "no warmed snapshot and no kernel/initrd to install from. drop `kernel` and `initrd.cpio` \ + into the image home (or run `--setup-hyperlight`)."; + +// -- Runner ------------------------------------------------------------------ + +/// Script runner that executes Python code inside a Hyperlight+Unikraft +/// micro-VM. +/// +/// Lazily instantiates the runtime on the first call (loading the +/// persisted snapshot, auto-installing it first if needed) and reuses +/// it across subsequent calls on the same runner instance. Every +/// `run_code` rewinds the guest to the post-warmup snapshot so +/// consecutive calls are hermetic. +pub struct HyperlightScriptRunner { + runtime: Option, + active_home: Option, + active_preopens: Vec, +} + +impl Default for HyperlightScriptRunner { + fn default() -> Self { + Self::new() + } +} + +/// Eagerly install the warmed snapshot so the *first* run later +/// pays no warmup cost. Intended to be called from a tool install +/// step (npm postinstall, a `--setup-hyperlight` CLI flag, CI, etc.). +/// +/// Pulls the published `kernel` + `initrd.cpio` from GHCR +/// via docker or podman, runs warmup, and +/// persists the snapshot to disk. Zero configuration beyond having +/// docker/podman on `$PATH`. +/// +/// # Destination +/// +/// `$PYHL_HOME` if set, otherwise the OS-local default +/// (`~/.local/share/pyhl` on Linux, `%LOCALAPPDATA%\pyhl` on +/// Windows). We intentionally do NOT walk the runtime search chain +/// here — that would let a stale `/.pyhl/` from an old dev +/// session short-circuit the install and leave the default home +/// empty, which would make later runs from a different cwd fail. +/// +/// # Force +/// +/// When `force` is false, an existing snapshot is a no-op. When +/// `force` is true, the snapshot is rebuilt. +pub fn setup(force: bool, logger: &mut Logger) -> Result { + let home = match std::env::var_os(PYHL_HOME_ENV) { + Some(v) => PathBuf::from(v), + None => HyperlightScriptRunner::default_home(), + }; + + if !force && is_installed(&home) { + logger.log_line(&format!( + "hyperlight: snapshot already present at {:?}; nothing to do \ + (pass --force to rebuild)", + home.join(SNAPSHOT_FILE) + )); + return Ok(home.join(SNAPSHOT_FILE)); + } + + std::fs::create_dir_all(&home).map_err(|e| format!("create image home {home:?}: {e}"))?; + + logger.log_line("hyperlight setup: pulling image from GHCR (docker/podman)"); + let opts = pyhl::InstallOptions { + home: &home, + source: pyhl::InstallSource::Ghcr, + mounts: &[], + force, + }; + let report = pyhl::install(&opts).map_err(|e| format!("hyperlight install: {e:#}"))?; + logger.log_line(&format!( + "hyperlight: install complete (warmup={:.1}ms, snapshot at {:?})", + report.warmup_ms, report.snapshot + )); + Ok(report.snapshot) +} + +impl HyperlightScriptRunner { + pub fn new() -> Self { + Self { + runtime: None, + active_home: None, + active_preopens: Vec::new(), + } + } + + /// Resolve the image home for a normal run. Walks the + /// discovery chain (see module doc) and returns the first + /// location that has at least kernel + initrd — snapshot may be + /// missing, the runner will install it. + fn resolve_home() -> Result { + for cand in Self::search_paths() { + if has_install_source(&cand) || is_installed(&cand) { + return Ok(cand); + } + } + let default = Self::default_home(); + Err(PyhlError::Preflight(format!( + "no hyperlight image found. searched ${PYHL_HOME_ENV}, {default:?}, \ + /{EXE_RELATIVE_HOME}/, /{CWD_RELATIVE_HOME}/. \ + run `lxc-exec --setup-hyperlight` \ + (or drop `{KERNEL_FILE}` and `{INITRD_FILE}` into {default:?})." + ))) + } + + /// Candidate locations, in priority order. + fn search_paths() -> Vec { + let mut paths = Vec::with_capacity(4); + if let Some(explicit) = std::env::var_os(PYHL_HOME_ENV) { + paths.push(PathBuf::from(explicit)); + } + paths.push(Self::default_home()); + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + paths.push(dir.join(EXE_RELATIVE_HOME)); + } + } + if let Ok(cwd) = std::env::current_dir() { + paths.push(cwd.join(CWD_RELATIVE_HOME)); + } + paths + } + + /// The OS-local default data directory. Setup writes here + /// when nothing else is already populated, and it's always second + /// in the resolution chain (after $PYHL_HOME). + /// + /// - Linux: `$XDG_DATA_HOME/pyhl` (or `~/.local/share/pyhl`) + /// - Windows: `%LOCALAPPDATA%\pyhl` (or `~\AppData\Local\pyhl`) + fn default_home() -> PathBuf { + os_data_home().join(DEFAULT_HOME_LEAF) + } + + /// Reject only policies that the hyperlight backend genuinely cannot honor. + /// Filesystem mounts ARE supported — translated to Preopens below. + fn validate_policies(request: &CodexRequest) -> Result<(), PyhlError> { + if !request.policy.allowed_hosts.is_empty() + || !request.policy.blocked_hosts.is_empty() + || request.policy.default_network_policy != NetworkPolicy::Allow + { + return Err(PyhlError::Preflight(ERR_NETWORK_POLICY.to_string())); + } + if request.policy.network_proxy.is_enabled() { + return Err(PyhlError::Preflight(ERR_PROXY_POLICY.to_string())); + } + if !request.working_directory.is_empty() { + return Err(PyhlError::Preflight(ERR_WORKDIR.to_string())); + } + + // Denied paths: block early if any appears in the allow lists. + // Also reject a config that only specifies denies — there's no + // positive policy to apply and an attacker might be probing. + for denied in &request.policy.denied_paths { + if request + .policy + .readwrite_paths + .iter() + .any(|p| same_path(p, denied)) + || request + .policy + .readonly_paths + .iter() + .any(|p| same_path(p, denied)) + { + return Err(PyhlError::Preflight(format!( + "path {denied:?} appears in both deniedPaths and an allow list" + ))); + } + } + + Ok(()) + } + + /// Translate `ContainerPolicy.{readwrite,readonly}Paths` into + /// `Preopen` entries. Each host path is exposed inside the guest at + /// `/host/` — matches the `pyhl` CLI's `--mount ` default + /// shape so scripts can find mounts predictably. + /// + /// `readonlyPaths` are mounted writable today (hostfs has no RO + /// mode). Documented in the module header as a known limitation. + fn preopens_from_policy(request: &CodexRequest) -> Result, PyhlError> { + let mut preopens = Vec::new(); + let mut seen_guest_paths = std::collections::HashSet::new(); + + for host in request + .policy + .readwrite_paths + .iter() + .chain(request.policy.readonly_paths.iter()) + { + let host_path = PathBuf::from(host); + + // Auto-create the mount dir if it doesn't exist yet. + // `Preopen::new` canonicalizes the host path, which fails on + // ENOENT — so without this, a relative path like + // "../tmp/foo" fails silently just because the dir wasn't + // pre-created. The guest's hostfs still needs a real dir to + // read/write against; mkdir-ing now matches the "config is + // declaratively requesting this mount" semantics. + // + // We only create if the parent already exists — prevents + // accidentally materializing arbitrary paths on a typo. + if !host_path.exists() { + let parent_ok = host_path + .parent() + .map(|p| p.as_os_str().is_empty() || p.exists()) + .unwrap_or(false); + if !parent_ok { + return Err(PyhlError::Preflight(format!( + "mount path {host:?} does not exist and its parent doesn't either; \ + refusing to auto-create (fix the path or `mkdir -p` manually)" + ))); + } + std::fs::create_dir_all(&host_path).map_err(|e| { + PyhlError::Preflight(format!("auto-create mount dir {host:?}: {e}")) + })?; + } + + let basename = host_path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| { + PyhlError::Preflight(format!("mount path {host:?} has no filename component")) + })?; + let guest_path = format!("/host/{basename}"); + if !seen_guest_paths.insert(guest_path.clone()) { + return Err(PyhlError::Preflight(format!( + "two mount paths collide on guest path {guest_path:?}; \ + rename one of the host directories" + ))); + } + let pre = Preopen::new(&host_path, &guest_path).map_err(|e| { + PyhlError::Preflight(format!( + "build Preopen for {host:?} -> {guest_path:?}: {e:#}" + )) + })?; + preopens.push(pre); + } + + Ok(preopens) + } + + /// Lazily bring up the embedded Hyperlight runtime. + /// + /// If the persisted snapshot is missing but kernel + initrd are + /// present, run install in-line (warmup boot + persist, cost + /// ~1.5–2 s, once per image). Subsequent runners on the same home + /// go straight to restore. + /// + /// The mount set is baked into the runtime at construction time; + /// different preopens between calls force a full teardown + rebuild. + fn ensure_runtime( + &mut self, + home: &Path, + preopens: Vec, + logger: &mut Logger, + ) -> Result<&mut pyhl::Runtime, PyhlError> { + let same_home = self.active_home.as_deref() == Some(home); + let same_mounts = preopens_equal(&self.active_preopens, &preopens); + // `if let Some(rt) = self.runtime.as_mut()` trips the borrow + // checker because a later branch reassigns `self.runtime`. + #[allow(clippy::unnecessary_unwrap)] + if same_home && same_mounts && self.runtime.is_some() { + return Ok(self.runtime.as_mut().unwrap()); + } + // Drop any prior runtime before rebuilding against new state. + self.runtime = None; + + // Auto-install on first use. Install is idempotent when the + // snapshot already exists (`force: false`). + if !is_installed(home) { + if !has_install_source(home) { + return Err(PyhlError::Preflight(ERR_NO_INSTALL_SOURCE.to_string())); + } + logger.log_line(&format!( + "hyperlight: no snapshot at {:?}; auto-installing from kernel + initrd", + home.join(SNAPSHOT_FILE) + )); + // Use `Explicit` to point at the files we know are present; + // avoids a scan and works regardless of surrounding layout. + let kernel = home.join(KERNEL_FILE); + let initrd = home.join(INITRD_FILE); + let opts = pyhl::InstallOptions { + home, + source: pyhl::InstallSource::Explicit { + kernel: &kernel, + initrd: &initrd, + }, + mounts: &preopens, + force: false, + }; + let report = pyhl::install(&opts) + .map_err(|e| PyhlError::Runtime(format!("hyperlight install: {e:#}")))?; + logger.log_line(&format!( + "hyperlight: install complete (warmup={:.1}ms, snapshot at {:?})", + report.warmup_ms, report.snapshot + )); + } + + logger.log_line(&format!("hyperlight: using image home {home:?}")); + let rt = pyhl::Runtime::new(home, &preopens) + .map_err(|e| PyhlError::Runtime(format!("open hyperlight runtime: {e:#}")))?; + self.runtime = Some(rt); + self.active_home = Some(home.to_path_buf()); + self.active_preopens = preopens; + Ok(self.runtime.as_mut().unwrap()) + } +} + +impl ScriptRunner for HyperlightScriptRunner { + fn validate_runner(&self, request: &CodexRequest) -> Result<(), ScriptResponse> { + Self::validate_policies(request).map_err(|e| e.to_response()) + } + + fn execute(&mut self, request: &CodexRequest, logger: &mut Logger) -> ScriptResponse { + let home = match Self::resolve_home() { + Ok(h) => h, + Err(e) => { + logger.log_line(&e.to_string()); + return e.to_response(); + } + }; + let preopens = match Self::preopens_from_policy(request) { + Ok(p) => p, + Err(e) => { + logger.log_line(&e.to_string()); + return e.to_response(); + } + }; + + let rt = match self.ensure_runtime(&home, preopens, logger) { + Ok(rt) => rt, + Err(e) => { + logger.log_line(&e.to_string()); + return e.to_response(); + } + }; + + match rt.run_code(&request.script_code) { + Ok(timing) => { + logger.log_line(&format!( + "hyperlight: run ok (restore={:.1}ms call={:.1}ms)", + timing.restore_ms, timing.call_ms + )); + ScriptResponse { + exit_code: 0, + ..Default::default() + } + } + Err(e) => { + let err = PyhlError::Runtime(format!("run_code: {e:#}")); + logger.log_line(&err.to_string()); + err.to_response() + } + } + } +} + +// -- Helpers ----------------------------------------------------------------- + +/// A home has a warmed snapshot (plus kernel + initrd) — ready to load. +fn is_installed(home: &Path) -> bool { + home.join(KERNEL_FILE).is_file() + && home.join(INITRD_FILE).is_file() + && home.join(SNAPSHOT_FILE).is_file() +} + +/// A home has the raw inputs we need to auto-install a snapshot. +fn has_install_source(home: &Path) -> bool { + home.join(KERNEL_FILE).is_file() && home.join(INITRD_FILE).is_file() +} + +/// Paths equal after canonicalization (best-effort). +fn same_path(a: &str, b: &str) -> bool { + let ap = std::fs::canonicalize(a).unwrap_or_else(|_| PathBuf::from(a)); + let bp = std::fs::canonicalize(b).unwrap_or_else(|_| PathBuf::from(b)); + ap == bp +} + +fn preopens_equal(a: &[Preopen], b: &[Preopen]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter() + .zip(b.iter()) + .all(|(x, y)| x.host_dir == y.host_dir && x.guest_path == y.guest_path) +} + +/// OS-local data directory (the "user Application Data" root). +/// +/// - Linux: `$XDG_DATA_HOME` if set and absolute, else `$HOME/.local/share`. +/// - Windows: `%LOCALAPPDATA%` if set, else `$USERPROFILE\AppData\Local`. +/// +/// Returns `PathBuf::from(".")` if no candidate env vars are set (degrades +/// gracefully rather than panicking; caller can still override via +/// `$PYHL_HOME`). +fn os_data_home() -> PathBuf { + #[cfg(windows)] + { + if let Some(v) = std::env::var_os("LOCALAPPDATA") { + return PathBuf::from(v); + } + if let Some(v) = std::env::var_os("USERPROFILE") { + return PathBuf::from(v).join("AppData").join("Local"); + } + PathBuf::from(".") + } + #[cfg(not(windows))] + { + if let Some(v) = std::env::var_os("XDG_DATA_HOME") { + let p = PathBuf::from(v); + if p.is_absolute() { + return p; + } + } + if let Some(v) = std::env::var_os("HOME") { + return PathBuf::from(v).join(".local").join("share"); + } + PathBuf::from(".") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::logger::Mode; + use crate::models::{ContainerPolicy, NetworkPolicy}; + + fn runner() -> HyperlightScriptRunner { + HyperlightScriptRunner::new() + } + + #[test] + fn is_installed_false_on_empty_dir() { + let tmp = std::env::temp_dir().join(format!("hl-runner-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + assert!(!is_installed(&tmp)); + assert!(!has_install_source(&tmp)); + } + + #[test] + fn has_install_source_true_when_kernel_and_initrd_present() { + let tmp = + std::env::temp_dir().join(format!("hl-runner-install-src-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write(tmp.join(KERNEL_FILE), b"").unwrap(); + std::fs::write(tmp.join(INITRD_FILE), b"").unwrap(); + assert!(has_install_source(&tmp)); + assert!(!is_installed(&tmp)); // snapshot still absent + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn resolve_home_errors_when_nothing_configured() { + // Redirect every candidate away from any real install on the + // test machine: PYHL_HOME, XDG_DATA_HOME (Linux), LOCALAPPDATA + // (Windows), HOME/USERPROFILE all get pointed into an empty + // tmpdir for the duration of this test. + let empty = std::env::temp_dir().join(format!("hl-resolve-empty-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&empty); + std::fs::create_dir_all(&empty).unwrap(); + + let saved: Vec<(&str, Option)> = [ + PYHL_HOME_ENV, + "XDG_DATA_HOME", + "HOME", + "LOCALAPPDATA", + "USERPROFILE", + ] + .iter() + .map(|k| (*k, std::env::var_os(k))) + .collect(); + // SAFETY: tests are serialized by default in this crate. + unsafe { + for (k, _) in &saved { + std::env::remove_var(k); + } + std::env::set_var("HOME", &empty); + std::env::set_var("USERPROFILE", &empty); + std::env::set_var("XDG_DATA_HOME", &empty); + std::env::set_var("LOCALAPPDATA", &empty); + } + + let result = HyperlightScriptRunner::resolve_home(); + + // Restore env before asserting so a failing assert can't leak. + unsafe { + for (k, v) in &saved { + match v { + Some(val) => std::env::set_var(k, val), + None => std::env::remove_var(k), + } + } + } + let _ = std::fs::remove_dir_all(&empty); + + let err = result.unwrap_err(); + assert!( + err.to_string().contains("no hyperlight image found"), + "got: {err}" + ); + } + + #[test] + fn policy_accepts_readwrite_paths_and_builds_preopens() { + // We can't end-to-end test without a real image; just verify + // the policy→Preopen mapping. + let tmp = std::env::temp_dir().join(format!("hl-mount-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let request = CodexRequest { + policy: ContainerPolicy { + readwrite_paths: vec![tmp.to_string_lossy().to_string()], + ..Default::default() + }, + ..Default::default() + }; + let preopens = HyperlightScriptRunner::preopens_from_policy(&request).unwrap(); + assert_eq!(preopens.len(), 1); + assert_eq!( + preopens[0].guest_path, + format!("/host/{}", tmp.file_name().unwrap().to_string_lossy()) + ); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn policy_rejects_mount_collision_on_same_basename() { + let a = std::env::temp_dir().join(format!("hl-col-a-{}/same", std::process::id())); + let b = std::env::temp_dir().join(format!("hl-col-b-{}/same", std::process::id())); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + let request = CodexRequest { + policy: ContainerPolicy { + readwrite_paths: vec![ + a.to_string_lossy().to_string(), + b.to_string_lossy().to_string(), + ], + ..Default::default() + }, + ..Default::default() + }; + let err = HyperlightScriptRunner::preopens_from_policy(&request).unwrap_err(); + assert!( + err.to_string().contains("collide on guest path"), + "got: {err}" + ); + let _ = std::fs::remove_dir_all(a.parent().unwrap()); + let _ = std::fs::remove_dir_all(b.parent().unwrap()); + } + + #[test] + fn policy_rejects_denied_overlapping_allow() { + let mut r = runner(); + let request = CodexRequest { + policy: ContainerPolicy { + readwrite_paths: vec!["/tmp/x".to_string()], + denied_paths: vec!["/tmp/x".to_string()], + ..Default::default() + }, + ..Default::default() + }; + let mut logger = Logger::new(Mode::Buffer); + let resp = r.run(&request, &mut logger); + assert_eq!(resp.exit_code, ERROR_EXIT_CODE); + assert!(resp.error_message.contains("deniedPaths")); + } + + #[test] + fn policy_rejects_network_rules() { + let mut r = runner(); + let request = CodexRequest { + policy: ContainerPolicy { + allowed_hosts: vec!["example.com".to_string()], + ..Default::default() + }, + ..Default::default() + }; + let mut logger = Logger::new(Mode::Buffer); + let resp = r.run(&request, &mut logger); + assert_eq!(resp.exit_code, ERROR_EXIT_CODE); + assert!(resp.error_message.contains(ERR_NETWORK_POLICY)); + } + + #[test] + fn policy_rejects_block_default_network() { + let mut r = runner(); + let request = CodexRequest { + policy: ContainerPolicy { + default_network_policy: NetworkPolicy::Block, + ..Default::default() + }, + ..Default::default() + }; + let mut logger = Logger::new(Mode::Buffer); + let resp = r.run(&request, &mut logger); + assert_eq!(resp.exit_code, ERROR_EXIT_CODE); + assert!(resp.error_message.contains(ERR_NETWORK_POLICY)); + } + + #[test] + fn policy_rejects_working_directory() { + let mut r = runner(); + let request = CodexRequest { + working_directory: "C:/tmp".to_string(), + ..Default::default() + }; + let mut logger = Logger::new(Mode::Buffer); + let resp = r.run(&request, &mut logger); + assert_eq!(resp.exit_code, ERROR_EXIT_CODE); + assert!(resp.error_message.contains(ERR_WORKDIR)); + } +} diff --git a/src/wxc_common/src/lib.rs b/src/wxc_common/src/lib.rs index fba1d8db..72fa85f2 100644 --- a/src/wxc_common/src/lib.rs +++ b/src/wxc_common/src/lib.rs @@ -5,6 +5,8 @@ pub mod config_parser; pub mod encoding; pub mod error; +#[cfg(feature = "hyperlight")] +pub mod hyperlight_runner; pub mod id; pub mod logger; #[cfg(target_os = "windows")] diff --git a/src/wxc_common/src/models.rs b/src/wxc_common/src/models.rs index 0720abd5..24dfe769 100644 --- a/src/wxc_common/src/models.rs +++ b/src/wxc_common/src/models.rs @@ -23,6 +23,11 @@ pub enum ContainmentBackend { /// MicroVM isolation via Windows Hypervisor Platform (internally powered by NanVix). #[serde(rename = "microvm")] 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 + /// --experimental. Cross-platform (Linux KVM, Windows WHP). + Hyperlight, /// Windows Sandbox — full VM isolation (experimental, requires --experimental flag). WindowsSandbox, /// Isolation Session — process isolation via IsoEnvBroker Session API (experimental). diff --git a/src/wxc_e2e_tests/src/lib.rs b/src/wxc_e2e_tests/src/lib.rs index 5c4a0ed2..baf6f297 100644 --- a/src/wxc_e2e_tests/src/lib.rs +++ b/src/wxc_e2e_tests/src/lib.rs @@ -168,6 +168,29 @@ pub fn has_nanvix_binaries() -> bool { present } +/// Return whether the Hyperlight snapshot is installed at the default +/// location (`%LOCALAPPDATA%\pyhl\snapshot.hls`). +pub fn has_hyperlight_snapshot() -> bool { + let home = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| { + std::env::var_os("USERPROFILE") + .map(|v| PathBuf::from(v).join("AppData").join("Local")) + .unwrap_or_default() + }); + let snapshot = home.join("pyhl").join("snapshot.hls"); + if snapshot.is_file() { + println!("Using Hyperlight snapshot at {}", snapshot.display()); + true + } else { + println!( + "SKIPPED: Hyperlight snapshot not found at {} — run --setup-hyperlight first", + snapshot.display() + ); + false + } +} + /// Return whether the Windows Sandbox optional feature is enabled. pub fn has_windows_sandbox_feature() -> bool { let available = Command::new("dism") diff --git a/src/wxc_e2e_tests/tests/e2e_windows.rs b/src/wxc_e2e_tests/tests/e2e_windows.rs index f9262db6..a6369777 100644 --- a/src/wxc_e2e_tests/tests/e2e_windows.rs +++ b/src/wxc_e2e_tests/tests/e2e_windows.rs @@ -16,8 +16,9 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::Serialize; use wxc_e2e_tests::{ assert_exit, assert_success, assert_success_or_skip_missing_prerequisite, examples_dir, - find_binary, has_daemon, has_nanvix_binaries, has_test_driver, has_windows_sandbox_feature, - has_wxc_exe, repo_root, run_test_driver, run_wxc_config, test_configs_dir, TempDirs, + find_binary, has_daemon, 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_state_aware, test_configs_dir, TempDirs, }; static HAS_WXC_EXE: OnceLock = OnceLock::new(); @@ -25,6 +26,7 @@ static HAS_TEST_DRIVER: OnceLock = OnceLock::new(); static HAS_NANVIX_BINARIES: OnceLock = OnceLock::new(); static HAS_DAEMON: OnceLock = OnceLock::new(); static HAS_WINDOWS_SANDBOX: OnceLock = OnceLock::new(); +static HAS_HYPERLIGHT: OnceLock = OnceLock::new(); static TEST_LOCK: OnceLock> = OnceLock::new(); /// Caches the `wxc-exec.exe` prerequisite probe so repeated tests do not @@ -53,6 +55,11 @@ fn cached_has_windows_sandbox_feature() -> bool { *HAS_WINDOWS_SANDBOX.get_or_init(has_windows_sandbox_feature) } +/// Caches the Hyperlight snapshot probe. +fn cached_has_hyperlight() -> bool { + *HAS_HYPERLIGHT.get_or_init(has_hyperlight_snapshot) +} + fn with_test_lock(run: impl FnOnce()) { let lock = TEST_LOCK.get_or_init(|| Mutex::new(())); let _guard = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -563,6 +570,131 @@ fn expected_exit_description(case: &MicrovmCase) -> String { } } +// --------------------------------------------------------------------------- +// Hyperlight suite +// --------------------------------------------------------------------------- + +#[derive(Debug)] +struct HyperlightCase { + config: &'static str, + description: &'static str, + expected_exit: i32, + output_contains: Option<&'static str>, +} + +fn hyperlight_suite() { + let cases = [ + HyperlightCase { + config: "hyperlight_hello.json", + description: "Hello world", + expected_exit: 0, + output_contains: Some("Hello from Hyperlight!"), + }, + HyperlightCase { + config: "hyperlight_pandas.json", + description: "numpy + pandas", + expected_exit: 0, + output_contains: Some("'x':"), + }, + ]; + + let mut failures = Vec::new(); + for case in cases { + println!("--- {} ({}) ---", case.description, case.config); + let result = run_wxc_config(case.config, &["--debug", "--experimental"]); + + if result.code != Some(case.expected_exit) { + failures.push(format!( + "{}: expected exit {}, got {:?}\n--- stdout ---\n{}\n--- stderr ---\n{}", + case.config, case.expected_exit, result.code, result.stdout, result.stderr + )); + } else if let Some(expected) = case.output_contains { + let combined = result.combined_output_with_decoded_base64(); + if !combined.contains(expected) { + failures.push(format!( + "{}: output missing '{}'\n--- combined ---\n{}", + case.config, expected, combined + )); + } else { + println!(" PASS ({} ms)", result.wall_time_ms); + } + } else { + println!(" PASS ({} ms)", result.wall_time_ms); + } + } + + // Filesystem test — uses an absolute temp dir to avoid relative-path issues. + { + println!("--- hostfs read/write (hyperlight_fs) ---"); + let mount_dir = std::env::temp_dir().join("hyperlight-fs-e2e"); + let _ = std::fs::remove_dir_all(&mount_dir); + std::fs::create_dir_all(&mount_dir).unwrap(); + + let script = format!( + "import os\n\ + BASE = '/host/{}'\n\ + path = f'{{BASE}}/hello.txt'\n\ + with open(path, 'w') as f:\n\ + \x20 f.write('hyperlight was here\\n')\n\ + print(f'wrote: {{path}}')\n\ + with open(path, 'r') as f:\n\ + \x20 print(f'read: {{f.read().strip()}}')\n\ + print('done')\n", + mount_dir.file_name().unwrap().to_string_lossy() + ); + + let config = serde_json::json!({ + "process": { "commandLine": script, "timeout": 30000 }, + "containment": "hyperlight", + "filesystem": { "readwritePaths": [mount_dir.to_string_lossy()] } + }); + + let result = run_wxc_state_aware("hyperlight-fs", &config, &["--debug", "--experimental"]); + + if result.code != Some(0) { + failures.push(format!( + "hyperlight-fs: expected exit 0, got {:?}\n--- stdout ---\n{}\n--- stderr ---\n{}", + result.code, result.stdout, result.stderr + )); + } else { + let written = mount_dir.join("hello.txt"); + if !written.exists() { + failures.push("hyperlight-fs: hello.txt not created on host".to_string()); + } else { + let contents = std::fs::read_to_string(&written).unwrap_or_default(); + if !contents.contains("hyperlight was here") { + failures.push(format!( + "hyperlight-fs: hello.txt missing expected content, got: {contents}" + )); + } else { + println!(" PASS ({} ms)", result.wall_time_ms); + } + } + } + + let _ = std::fs::remove_dir_all(&mount_dir); + } + + if !failures.is_empty() { + panic!("Hyperlight E2E failures:\n{}", failures.join("\n")); + } +} + +#[test] +fn test_hyperlight_suite() { + if !cached_has_wxc_exe() { + return; + } + if !cached_has_hyperlight() { + return; + } + 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()), diff --git a/test_configs/hyperlight_fs.json b/test_configs/hyperlight_fs.json new file mode 100644 index 00000000..b87bef9f --- /dev/null +++ b/test_configs/hyperlight_fs.json @@ -0,0 +1,12 @@ +{ + "_comment": "End-to-end filesystem test. readwritePaths are exposed to the guest under /host/. Before running, create /tmp/hyperlight-fs-demo/ and drop a file in it (or let the script create one). After the run, /tmp/hyperlight-fs-demo/written.txt will exist with guest-written content.", + + "process": { + "commandLine": "import os\n\nBASE = '/host/hyperlight-fs-demo'\nos.makedirs(BASE, exist_ok=True)\n\n# Write\npath = f'{BASE}/hello.txt'\nwith open(path, 'w') as f:\n f.write('hello, filesystem!\\n')\n f.write('line 2\\n')\nprint(f'wrote: {path}')\n\n# Read back\nwith open(path, 'r') as f:\n print(f'read:\\n{f.read()}')\n\n# Append\nwith open(path, 'a') as f:\n f.write('appended line\\n')\n\n# Stat\nst = os.stat(path)\nprint(f'size: {st.st_size} bytes')\n\n# Listdir\nprint(f'files in {BASE}: {sorted(os.listdir(BASE))}')\n\n# Write a marker so the host can verify\nwith open(f'{BASE}/written.txt', 'w') as f:\n f.write('hyperlight was here\\n')\nprint('done')", + "timeout": 30000 + }, + "containment": "hyperlight", + "filesystem": { + "readwritePaths": ["../tmp/hyperlight-fs-demo"] + } +} diff --git a/test_configs/hyperlight_hello.json b/test_configs/hyperlight_hello.json new file mode 100644 index 00000000..d31dd67c --- /dev/null +++ b/test_configs/hyperlight_hello.json @@ -0,0 +1,7 @@ +{ + "process": { + "commandLine": "import sys\nprint(f'Hello from Hyperlight! Python {sys.version.split()[0]} on {sys.platform}')", + "timeout": 30000 + }, + "containment": "hyperlight" +} diff --git a/test_configs/hyperlight_pandas.json b/test_configs/hyperlight_pandas.json new file mode 100644 index 00000000..c0997184 --- /dev/null +++ b/test_configs/hyperlight_pandas.json @@ -0,0 +1,7 @@ +{ + "process": { + "commandLine": "import pandas as pd, numpy as np\ndf = pd.DataFrame({'x': np.arange(5), 'y': np.arange(5) ** 2})\nprint(df.sum().to_dict())", + "timeout": 30000 + }, + "containment": "hyperlight" +}