diff --git a/.github/workflows/RegenSnapshotGoldens.yml b/.github/workflows/RegenSnapshotGoldens.yml new file mode 100644 index 000000000..4b5bd4e66 --- /dev/null +++ b/.github/workflows/RegenSnapshotGoldens.yml @@ -0,0 +1,202 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + +# Publish snapshot goldens to +# ghcr.io/hyperlight-dev/hyperlight-snapshot-goldens. +# +# Runs automatically when a merge to main changes GOLDENS_VERSION (the +# version string lives in +# src/hyperlight_host/tests/snapshot_goldens/platform.rs). The resolve +# job reads that version and checks whether it is already published. If +# it is not, the matrix walks every (hv, cpu, config) cell, dumps the +# canonical init and call snapshots, and pushes each one to GHCR as its +# own tag named `{version}-{hv}-{cpu}-{profile}-{kind}`. +# +# An already-published version is left untouched, so a merge that does +# not bump the version, or a re-run of the same version, is a no-op. +# Manual dispatch with `force: true` overwrites an existing version and +# exists for recovery only. +# +# See docs/snapshot-versioning.md + +name: Regenerate Snapshot Goldens + +on: + push: + branches: [main] + paths: + - src/hyperlight_host/tests/snapshot_goldens/platform.rs + workflow_dispatch: + inputs: + version: + description: Goldens version string. Must match GOLDENS_VERSION in source (e.g. "v1.0"). + required: true + type: string + force: + description: Overwrite tags even if the version is already published (recovery only). + type: boolean + default: false + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: full + GHCR_IMAGE: ghcr.io/hyperlight-dev/hyperlight-snapshot-goldens + +permissions: + contents: read + packages: write + +concurrency: + group: regen-snapshot-goldens-${{ github.ref }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + resolve: + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + outputs: + version: ${{ steps.decide.outputs.version }} + needs_publish: ${{ steps.decide.outputs.needs_publish }} + steps: + - uses: actions/checkout@v6 + + - name: Install oras + uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2.0.0 + with: + version: 1.3.2 + + - name: Decide version and whether to publish + id: decide + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_VERSION: ${{ inputs.version }} + FORCE: ${{ inputs.force }} + GHCR_USER: ${{ github.actor }} + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + SRC=$(grep -oE 'GOLDENS_VERSION: &str = "[^"]+"' src/hyperlight_host/tests/snapshot_goldens/platform.rs | head -n1 | sed -E 's/.*"([^"]+)".*/\1/') + if ! [[ "${SRC}" =~ ^v[0-9]+\.[0-9]+$ ]]; then + echo "::error::GOLDENS_VERSION in source must match ^v[0-9]+\.[0-9]+$ (e.g. v1.0), found '${SRC}'" + exit 1 + fi + + # On manual dispatch the input must name the version that the + # dispatched ref actually carries. This catches a stale input. + if [ "${EVENT_NAME}" = "workflow_dispatch" ] && [ "${INPUT_VERSION}" != "${SRC}" ]; then + echo "::error::version input '${INPUT_VERSION}' does not match GOLDENS_VERSION in source '${SRC}'" + exit 1 + fi + + echo "version=${SRC}" >> "$GITHUB_OUTPUT" + + if [ "${EVENT_NAME}" = "workflow_dispatch" ] && [ "${FORCE}" = "true" ]; then + echo "force requested: will publish ${SRC} even if it already exists" + echo "needs_publish=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # A version is frozen once any of its tags exist on GHCR. + # Publishing only when the set is absent makes the workflow + # idempotent and never clobbers a published baseline. + echo "${GHCR_TOKEN}" | oras login ghcr.io -u "${GHCR_USER}" --password-stdin + EXISTING=$(oras repo tags "${GHCR_IMAGE}" 2>/dev/null | grep -c "^${SRC}-" || true) + if [ "${EXISTING}" -gt 0 ]; then + echo "${SRC} already published (${EXISTING} tags). Nothing to do." + echo "needs_publish=false" >> "$GITHUB_OUTPUT" + else + echo "${SRC} not published yet. Will publish." + echo "needs_publish=true" >> "$GITHUB_OUTPUT" + fi + + build-guests: + needs: resolve + if: needs.resolve.outputs.needs_publish == 'true' + strategy: + matrix: + config: [debug, release] + uses: ./.github/workflows/dep_build_guests.yml + with: + config: ${{ matrix.config }} + secrets: inherit + + dump-and-push: + needs: [resolve, build-guests] + if: needs.resolve.outputs.needs_publish == 'true' + strategy: + fail-fast: false + matrix: + hypervisor: [kvm, mshv3, hyperv-ws2025] + cpu: [amd, intel] + config: [debug, release] + runs-on: ${{ fromJson( + format('["self-hosted", "{0}", "X64", "1ES.Pool=hld-{1}-{2}", "JobId=regen-goldens-{3}-{4}-{5}-{6}"]', + matrix.hypervisor == 'hyperv-ws2025' && 'Windows' || 'Linux', + matrix.hypervisor == 'hyperv-ws2025' && 'win2025' || matrix.hypervisor == 'mshv3' && 'azlinux3-mshv' || matrix.hypervisor, + matrix.cpu, + matrix.config, + github.run_id, + github.run_number, + github.run_attempt)) }} + steps: + - uses: actions/checkout@v6 + + - uses: hyperlight-dev/ci-setup-workflow@v1.9.0 + with: + rust-toolchain: "1.94" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Fix cargo home permissions + if: runner.os == 'Linux' + run: sudo chown -R $(id -u):$(id -g) /opt/cargo || true + + - name: Download Rust guests + uses: actions/download-artifact@v7 + with: + name: rust-guests-${{ matrix.config }} + path: src/tests/rust_guests/bin/${{ matrix.config }}/ + + - name: Install oras + uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2.0.0 + with: + version: 1.3.2 + + - name: Confirm source matches resolved version + env: + RESOLVED_VERSION: ${{ needs.resolve.outputs.version }} + run: | + set -euo pipefail + SRC=$(grep -oE 'GOLDENS_VERSION: &str = "[^"]+"' src/hyperlight_host/tests/snapshot_goldens/platform.rs | head -n1 | sed -E 's/.*"([^"]+)".*/\1/') + if [ "${SRC}" != "${RESOLVED_VERSION}" ]; then + echo "::error::source GOLDENS_VERSION '${SRC}' does not match resolved '${RESOLVED_VERSION}'" + exit 1 + fi + + - name: Generate snapshots + run: just snapshot-goldens-generate ${{ matrix.config }} + + - name: Log in to GHCR + env: + GHCR_USER: ${{ github.actor }} + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "${GHCR_TOKEN}" | oras login ghcr.io -u "${GHCR_USER}" --password-stdin + + - name: Push goldens to GHCR + env: + GOLDENS_VERSION: ${{ needs.resolve.outputs.version }} + run: | + set -euo pipefail + GOLDENS_DIR="target/snapshot-goldens/${GOLDENS_VERSION}" + for layout in "$GOLDENS_DIR"/*/; do + tag=$(basename "$layout") + echo "::group::push ${tag}" + oras cp --from-oci-layout "${layout%/}:${tag}" "${GHCR_IMAGE}:${tag}" + echo "::endgroup::" + done diff --git a/.github/workflows/ValidatePullRequest.yml b/.github/workflows/ValidatePullRequest.yml index 2f6294476..2523ec0b6 100644 --- a/.github/workflows/ValidatePullRequest.yml +++ b/.github/workflows/ValidatePullRequest.yml @@ -79,17 +79,33 @@ jobs: with: docs_only: ${{ needs.docs-pr.outputs.docs-only }} + # Pick the goldens mode. The `regen-goldens` label means regenerate. No label means pull. + goldens-mode: + runs-on: ubuntu-latest + outputs: + regen: ${{ steps.check.outputs.regen }} + steps: + - id: check + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr view ${{ github.event.pull_request.number }} --repo ${{ github.repository }} \ + --json labels -q '.labels[].name' | grep -qx regen-goldens \ + && echo "regen=true" >> "$GITHUB_OUTPUT" || echo "regen=false" >> "$GITHUB_OUTPUT" + # Build and test - needs guest artifacts build-test: needs: - docs-pr - build-guests + - goldens-mode # Required because update-guest-locks is skipped on non-dependabot PRs, # and a skipped dependency transitively skips all downstream jobs. # See: https://github.com/actions/runner/issues/2205 if: ${{ !cancelled() && !failure() }} strategy: - fail-fast: true + fail-fast: false matrix: hypervisor: ['hyperv-ws2025', mshv3, kvm] cpu: [amd, intel] @@ -101,6 +117,7 @@ jobs: hypervisor: ${{ matrix.hypervisor }} cpu: ${{ matrix.cpu }} config: ${{ matrix.config }} + regen_goldens: ${{ needs.goldens-mode.outputs.regen }} # Run examples - needs guest artifacts, runs in parallel with build-test run-examples: diff --git a/.github/workflows/dep_build_test.yml b/.github/workflows/dep_build_test.yml index 2a28d169f..f13ffa3fd 100644 --- a/.github/workflows/dep_build_test.yml +++ b/.github/workflows/dep_build_test.yml @@ -22,6 +22,11 @@ on: description: CPU architecture for the build (passed from caller matrix) required: true type: string + regen_goldens: + description: Regenerate snapshot goldens from the branch and skip pulling published ones + required: false + type: string + default: "false" env: CARGO_TERM_COLOR: always @@ -29,6 +34,7 @@ env: permissions: contents: read + packages: read defaults: run: @@ -132,3 +138,29 @@ jobs: env: RUST_LOG: debug run: just test-rust-tracing ${{ inputs.config }} + + - name: Install oras + if: ${{ inputs.regen_goldens != 'true' }} + uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2.0.0 + with: + version: 1.3.2 + + # Pull the published goldens for this cell and load them with the + # branch. A missing tag fails the job and flags a format break. + - name: Snapshot goldens (pull and verify) + if: ${{ inputs.regen_goldens != 'true' }} + env: + GHCR_USER: ${{ github.actor }} + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "${GHCR_TOKEN}" | oras login ghcr.io -u "${GHCR_USER}" --password-stdin + just snapshot-goldens-pull ghcr.io/hyperlight-dev/hyperlight-snapshot-goldens ${{ inputs.config }} + just snapshot-goldens-verify ${{ inputs.config }} + + # Label path: generate the goldens from the branch and load them + # back. Used when no published tag set exists yet. + - name: Snapshot goldens (regenerate and verify) + if: ${{ inputs.regen_goldens == 'true' }} + run: | + just snapshot-goldens-generate ${{ inputs.config }} + just snapshot-goldens-verify ${{ inputs.config }} diff --git a/CHANGELOG.md b/CHANGELOG.md index f3056c142..5fba100df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Prerelease] - Unreleased +### Added +* `Snapshot::to_oci`, `Snapshot::from_oci`, and `Snapshot::from_oci_unchecked` for persisting and loading sandbox snapshots as OCI Image Layout directories by @ludfjig in https://github.com/hyperlight-dev/hyperlight/pull/1465 + ### Changed * **Breaking:** `MultiUseSandbox::map_file_cow` and `UninitializedSandbox::map_file_cow` no longer take a label argument. The APIs now accept only `(file_path, guest_base)`. diff --git a/Cargo.lock b/Cargo.lock index 0382ac498..f3342b02b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -504,6 +504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -518,6 +519,18 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "clap_lex" version = "1.0.0" @@ -530,6 +543,27 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -730,6 +764,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -741,6 +810,37 @@ dependencies = [ "syn", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -860,6 +960,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "escape8259" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5692dd7b5a1978a5aeb0ce83b7655c58ca8efdcb79d21036ea249da95afec2c6" + [[package]] name = "euclid" version = "0.22.13" @@ -1154,6 +1260,18 @@ dependencies = [ "wasip3", ] +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "gimli" version = "0.33.0" @@ -1359,6 +1477,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.0" @@ -1583,6 +1707,7 @@ dependencies = [ "gdbstub", "gdbstub_arch", "goblin", + "hex", "hyperlight-common", "hyperlight-component-macro", "hyperlight-guest-tracing", @@ -1592,12 +1717,14 @@ dependencies = [ "kvm-ioctls", "lazy_static", "libc", + "libtest-mimic", "log", "metrics", "metrics-exporter-prometheus", "metrics-util", "mshv-bindings", "mshv-ioctls", + "oci-spec", "opentelemetry", "opentelemetry-otlp", "opentelemetry-semantic-conventions", @@ -1610,6 +1737,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2", "signal-hook-registry", "tempfile", "termcolor", @@ -1787,6 +1915,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1910,6 +2044,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "kurbo" version = "0.11.3" @@ -2024,6 +2173,18 @@ dependencies = [ "libc", ] +[[package]] +name = "libtest-mimic" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14e6ba06f0ade6e504aff834d7c34298e5155c6baca353cc6a4aaff2f9fd7f33" +dependencies = [ + "anstream 1.0.0", + "anstyle", + "clap", + "escape8259", +] + [[package]] name = "libz-sys" version = "1.1.23" @@ -2324,6 +2485,23 @@ dependencies = [ "ruzstd", ] +[[package]] +name = "oci-spec" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3da52b83ce3258fbf29f66ac784b279453c2ac3c22c5805371b921ede0d308" +dependencies = [ + "const_format", + "derive_builder", + "getset", + "regex", + "serde", + "serde_json", + "strum", + "strum_macros", + "thiserror", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2766,6 +2944,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3418,6 +3618,24 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "syn" version = "2.0.118" diff --git a/Justfile b/Justfile index 4f4c736d5..fdab06728 100644 --- a/Justfile +++ b/Justfile @@ -241,8 +241,10 @@ test-integration target=default-target features="": @# run execute_on_heap test with feature "executable_heap" on (runs with off during normal tests) {{ cargo-cmd }} test {{ if features =="" {"--features executable_heap"} else if features=="no-default-features" {"--no-default-features --features executable_heap"} else {"--no-default-features -F executable_heap," + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --test integration_test execute_on_heap - @# run the rest of the integration tests - {{ cargo-cmd }} test -p hyperlight-host {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --test '*' + @# run the rest of the integration tests. `snapshot_goldens` is + @# left out here. It runs in its own step against a filled golden + @# cache (see the snapshot-goldens recipes). + {{ cargo-cmd }} test -p hyperlight-host {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --test integration_test --test sandbox_host_tests --test wit_test # tests compilation with no default features on different platforms test-compilation-no-default-features target=default-target: @@ -565,3 +567,57 @@ install-vcpkg: install-flatbuffers-with-vcpkg: install-vcpkg cd ../vcpkg && ./vcpkg install flatbuffers || cd - + +################################### +### SNAPSHOT GOLDEN HELPERS ### +################################### +# Test binary that checks or rebuilds snapshot goldens. It reads +# snapshots from target/snapshot-goldens/{version}/{tag}/. +# `snapshot-goldens-pull` fills that directory. It uses `oras` to copy +# from the registry (install from https://oras.land). + +# Default OCI registry image (without tag) that hosts the goldens. +default-snapshot-goldens-image := "ghcr.io/hyperlight-dev/hyperlight-snapshot-goldens" + +# Check the local snapshots against the goldens for the current +# GOLDENS_VERSION. Run `snapshot-goldens-pull` first to fill the +# local directory. A missing entry fails the test. +snapshot-goldens-verify target=default-target: + cargo test {{ if target == "release" { "--release" } else { "" } }} \ + -p hyperlight-host --test snapshot_goldens + +# Pull the two goldens for this host (init and call) from `image` +# into the directory that `snapshot-goldens-verify` reads. It picks the +# hypervisor and CPU vendor from the host. Pass `profile=release` +# to fetch the release tags. +snapshot-goldens-pull image=default-snapshot-goldens-image profile="debug": + #!/usr/bin/env bash + set -euo pipefail + if [[ -e /dev/mshv ]]; then hv=mshv + elif [[ -e /dev/kvm ]]; then hv=kvm + elif [[ "${OS:-}" == "Windows_NT" ]]; then hv=whp + else echo "snapshot-goldens-pull: no hypervisor found" >&2; exit 1 + fi + if [[ -r /proc/cpuinfo ]]; then vendor=$(awk -F: '/vendor_id/{print $2; exit}' /proc/cpuinfo) + else vendor="${PROCESSOR_IDENTIFIER:-}" + fi + case "${vendor}" in + *GenuineIntel*) cpu=intel ;; + *AuthenticAMD*) cpu=amd ;; + *) echo "snapshot-goldens-pull: unknown CPU vendor" >&2; exit 1 ;; + esac + version=$(awk -F'"' '/GOLDENS_VERSION: &str =/{print $2; exit}' src/hyperlight_host/tests/snapshot_goldens/platform.rs) + for kind in init call; do + tag="${version}-${hv}-${cpu}-{{ profile }}-${kind}" + dir="target/snapshot-goldens/${version}/${tag}" + mkdir -p "${dir}" + oras cp --to-oci-layout "{{ image }}:${tag}" "${dir}:${tag}" + done + +# Build the local snapshots into the directory that +# `snapshot-goldens-verify` reads. Run `snapshot-goldens-generate` +# then `snapshot-goldens-verify` to test the round trip on one host. +# Pass `out` to write the snapshots to another directory. +snapshot-goldens-generate target=default-target out="": + cargo test {{ if target == "release" { "--release" } else { "" } }} \ + -p hyperlight-host --test snapshot_goldens -- generate {{ out }} diff --git a/docs/github-labels.md b/docs/github-labels.md index 5133f048a..e1f28c2ed 100644 --- a/docs/github-labels.md +++ b/docs/github-labels.md @@ -55,6 +55,12 @@ In addition to **kind/*** labels, we use optional **area/*** labels to specify t - **area/security** - Involves security-related changes or fixes. - **area/testing** - Related to tests or testing infrastructure. +## Workflow labels + +Some labels change CI behaviour on a PR rather than categorizing it: + +- **regen-goldens** - Switches the snapshot golden verify job into regenerate mode. A PR that intentionally changes the snapshot format and bumps `GOLDENS_VERSION` carries this label so the verify job generates the goldens from the branch and runs them back through the branch loader, rather than pulling a published tag set that does not exist yet. See [snapshot-versioning.md](snapshot-versioning.md). + ## Notes This document is a work in progress and may be updated as needed. The labels and categories are subject to change based on the evolving needs of the project and community feedback. diff --git a/docs/snapshot-oci-format.md b/docs/snapshot-oci-format.md new file mode 100644 index 000000000..3458977ea --- /dev/null +++ b/docs/snapshot-oci-format.md @@ -0,0 +1,115 @@ +# Hyperlight snapshot on-disk format + +Hyperlight serialises a `Snapshot` to disk as an [OCI Image Layout] +directory. `Snapshot::to_oci` writes one. `Snapshot::from_oci` and +`Snapshot::from_oci_unchecked` read one back. + +[OCI Image Layout]: https://github.com/opencontainers/image-spec/blob/main/image-layout.md + +## Directory layout + +```text +path/ + oci-layout {"imageLayoutVersion":"1.0.0"} + index.json one manifest descriptor per tag, + tagged via the OCI standard + `org.opencontainers.image.ref.name` + annotation + blobs/sha256/ + OCI image manifest JSON + Hyperlight config JSON + raw memory bytes + (`memory_size` bytes) +``` + +Three blob kinds per tag: + +* **manifest** (`application/vnd.oci.image.manifest.v1+json`). Tiny JSON + pointer record selected via `index.json`. References one config and + one layer by digest. +* **config** (`application/vnd.hyperlight.snapshot.config.v1+json`). The + snapshot descriptor: arch, hypervisor, ABI version, entrypoint, + memory layout, registered host functions, snapshot generation + counter. Loaded eagerly and fully parsed. +* **layer / memory** (`application/vnd.hyperlight.snapshot.memory.v1`). + The raw guest memory image, exactly `memory_size` bytes. mmap'd on + restore. + +Blob filenames are the sha256 of the blob bytes, so identical blobs +across tags are stored once. + +## What is one snapshot + +A single saved `Snapshot` consists of exactly: + +* one entry in `index.json`, carrying the `tag` as + `org.opencontainers.image.ref.name`, plus advisory + `dev.hyperlight.snapshot.arch` and + `dev.hyperlight.snapshot.hypervisor` annotations that mirror the + config blob for tooling visibility, +* one **manifest** blob (referenced by that index entry), +* one **config** blob (referenced by the manifest's `config` field), +* one **layer** blob (the only entry in the manifest's `layers` + array, holding the raw memory image). + +Saving two snapshots under different tags into the same `path` +produces two index entries and two manifests. Configs and layers are +deduplicated by content, so identical bytes are stored once and +referenced by both manifests. + +Saving the same tag a second time replaces that tag's index entry +and writes a fresh manifest. The previous manifest, and any of its +config or layer blobs that no other tag references, become orphans +in `blobs/sha256/`. + +## Write semantics + +`Snapshot::to_oci(path, tag)` opens or creates the OCI layout at +`path` and writes one snapshot under `tag`, an [`OciTag`] whose +grammar is validated when it is parsed. It returns the manifest +descriptor digest as an [`OciDigest`], a content address that selects +the written manifest on a later load. The parent directory of `path` +must already exist. `path` itself is created if absent. An existing +layout at `path` is preserved: other tags are kept, and a tag equal +to `tag` is replaced. + +[`OciTag`]: https://docs.rs/hyperlight-host/latest/hyperlight_host/sandbox/snapshot/struct.OciTag.html +[`OciDigest`]: https://docs.rs/hyperlight-host/latest/hyperlight_host/sandbox/snapshot/struct.OciDigest.html + +`index.json` is rewritten via a tmp file plus `rename`, the commit +point for the whole operation. Concurrent readers observe either the +prior layout or the new one, never a partial write. Power-loss +durability is the caller's responsibility: add `fsync` on the file +and parent directory if a crash must not lose a committed tag. + +Replaced tags leave orphan blobs behind. To compact, remove the +directory and re-save. Concurrent writers to the same `path` are +unsupported. + +This mirrors the merge behaviour of `containers/image` (skopeo, +podman), `go-containerregistry` (crane), and `regclient`. + +## Read semantics + +`Snapshot::from_oci(path, reference)` verifies sha256 for manifest, +config, and snapshot blobs. `reference` is an [`OciReference`], +either a tag that matches the +`org.opencontainers.image.ref.name` annotation or the manifest +digest returned by `to_oci`. `Snapshot::from_oci_unchecked` is +`unsafe` and skips digest verification, trading integrity for +performance. It keeps every other check (OCI structure, descriptor +sizes, schema versions, arch / hypervisor / ABI tags, layout bounds, +entrypoint bounds). The caller is responsible for trusting the +source. + +A reference that matches no manifest, or a tag that matches more than +one manifest in `index.json`, is rejected. + +[`OciReference`]: https://docs.rs/hyperlight-host/latest/hyperlight_host/sandbox/snapshot/enum.OciReference.html + +## Portability + +Snapshot images are bound to a specific CPU architecture and +hypervisor. Both are recorded in the config blob and checked at load +time, with mismatches rejected with a clear error. The hypervisor +tag (`kvm`, `mshv`, `whp`) constrains the host OS. diff --git a/docs/snapshot-versioning.md b/docs/snapshot-versioning.md new file mode 100644 index 000000000..758251617 --- /dev/null +++ b/docs/snapshot-versioning.md @@ -0,0 +1,289 @@ +# Snapshot versioning + +Hyperlight snapshots are written to disk as OCI image layouts and may be +loaded by a different build than the one that produced them. This +document describes how to evolve the snapshot format while keeping +existing snapshots loadable, or while rejecting them with a clear error. + +## What is versioned + +A snapshot carries three independently evolvable version markers: + +* **Memory blob ABI**, `SNAPSHOT_ABI_VERSION` (a `u32` inside the + config blob, defined in + [src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs](../src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs)). + This is the host/guest runtime contract baked into the captured + memory: the `HyperlightPEB` layout (the struct host and guest share + to exchange state, field offsets and types), the `OutBAction` port + numbers (the I/O ports the guest writes to for `Log`, `CallFunction`, + `Abort`, `DebugPrint`), the layout of the sandbox memory regions + (stack, heap, guest binary, input and output buffers, page tables), + and the calling convention used for guest function entry. The loader + trusts the captured bytes to match this contract, so any change here + invalidates older snapshots unless an explicit compat path translates + them. +* **Snapshot blob encoding**, `MT_SNAPSHOT_V1` + (`application/vnd.hyperlight.snapshot.memory.v1`), aliased as + `MT_SNAPSHOT_CURRENT`. This is the on-wire format of the snapshot + blob: framing, section ordering, alignment, dirty/zero-page elision, + anything about how the bytes are packed inside the OCI layer. +* **Config schema**, `MT_CONFIG_V1` + (`application/vnd.hyperlight.snapshot.config.v1+json`), aliased as + `MT_CONFIG_CURRENT`. This is the JSON shape of the config blob: + field names, types, required vs optional, the descriptors the loader + needs in order to reconstruct the sandbox (memory sizes, buffer + sizes, `abi_version`, `hyperlight_version`, etc.). Renaming a field, + changing its type, or adding a required field is a schema change and + bumps this constant. + +The `OCI_LAYOUT_VERSION` constant is pinned by the OCI image-layout +spec at `1.0.0`. + +Each media-type axis is a `_VN` constant with a `_CURRENT` alias. The +writer emits `_CURRENT`. The loader matches each `_VN` explicitly. To +add a version, declare `MT_FOO_V2`, point `MT_FOO_CURRENT` at it, and +add a loader arm that translates the old version or rejects it. + +The config blob also records `hyperlight_version`, the `CARGO_PKG_VERSION` +of the host crate at write time. This is informational only. The loader +records it for diagnostics and does not gate loading on it. + +## Enforcement + +The format is large and easy to change by accident. Two mechanisms +catch a change to it so reviewers do not have to spot every break by +eye, and so a developer who breaks the format unintentionally finds +out at build time rather than in production. + +Compile-time tripwires in +[src/hyperlight_host/src/sandbox/snapshot/tripwires.rs](../src/hyperlight_host/src/sandbox/snapshot/tripwires.rs) +hold a copy of every value that defines the format: +`SNAPSHOT_ABI_VERSION`, the snapshot and config media-type strings, the +OCI layout version, every `HyperlightPEB` field offset and the struct's +total size, and every `OutBAction` discriminant. If the source value +drifts from the copy in `tripwires.rs`, the crate fails to compile. + +The snapshot golden verify test +(`cargo test -p hyperlight-host --test snapshot_goldens`) loads +snapshots from a local directory (populated by `just snapshot-goldens-pull`, +which fetches the tag set for the current `GOLDENS_VERSION` from GHCR) +and runs them through the current loader. If the new loader cannot +decode the old bytes, the test fails. + +On a pull request the verify test runs on every supported hypervisor +runner. The default path pulls the published tag set for the current +`GOLDENS_VERSION` and verifies it against the branch's loader. A pull +request that intentionally changes the format takes the labelled path +described in [Breaking the format on a pull request](#breaking-the-format-on-a-pull-request). + +## Changing the format + +When you change anything on the list above, you have three options. + +### Option 1: avoid the break + +Restructure the change so the on-disk contract stays put. Prefer this +whenever possible. + +### Option 2: backwards-compatible break + +You break the ABI for new snapshots, and you teach the loader to +accept the older version as well by translating it into the current +contract on the fly. For example, if you renumber the `OutBAction` +ports, the host's port dispatch keeps a match arm for the old port +number alongside the new one, so a resumed v1 guest that still writes +to the old port is handled correctly. + +Steps: + +1. Make the source change. +2. Update `Snapshot::to_oci` to write the new format. +3. Bump `SNAPSHOT_ABI_VERSION`. The writer stamps this value into + every config blob it produces. +4. Update `Snapshot::from_oci` to load both the old and the new + format, dispatching on `abi_version`. +5. Update the tripwire assertions in `tripwires.rs` and any affected + tests to match the new values. +6. Bump `GOLDENS_VERSION` to the next major. Apply the `regen-goldens` + label to the pull request so the verify job regenerates against the + branch. See + [Breaking the format on a pull request](#breaking-the-format-on-a-pull-request) + and [Goldens version numbering](#goldens-version-numbering). +7. Keep the old goldens on GHCR and extend the verify test to exercise + them as well, so the compatibility path stays covered. See + [Verifying multiple golden versions](#verifying-multiple-golden-versions). + +Old snapshots on disk continue to load. New snapshots use the new +contract. The compatibility path becomes part of the supported surface +and must stay correct until you formally drop the old major. + +### Option 3: hard break + +You change the contract and the loader rejects old snapshots outright. +Using the same `OutBAction` example, the host's port dispatch only +matches on the new port number, and a resumed v1 guest writing to the +old port has nowhere to land. + +Steps: + +1. Make the source change. +2. Update `Snapshot::to_oci` to write the new format. +3. Bump `SNAPSHOT_ABI_VERSION`. +4. Update the tripwire assertions in `tripwires.rs` and any affected + tests to match the new values. +5. Bump `GOLDENS_VERSION` to the next major. Apply the `regen-goldens` + label to the pull request so the verify job regenerates against the + branch. See + [Breaking the format on a pull request](#breaking-the-format-on-a-pull-request) + and [Goldens version numbering](#goldens-version-numbering). +6. Record the break in `CHANGELOG.md`. Anyone holding old snapshots on + disk has to regenerate them against the new build. + +The loader's single-version check enforces the rejection. An old +snapshot loaded against the new build fails the +`abi_version == SNAPSHOT_ABI_VERSION` test with a clear error. + +## Regenerating goldens + +The verify test (`cargo test -p hyperlight-host --test snapshot_goldens`) +loads the tag set `{GOLDENS_VERSION}-{hv}-{cpu}-{profile}-{kind}` from a +local directory that `just snapshot-goldens-pull` populates from GHCR. A +freshly bumped `GOLDENS_VERSION` has no tags on GHCR until the bump +merges to `main` and the publish workflow runs, so pull requests that +bump the version verify through the `regen-goldens` label instead (see +[Breaking the format on a pull request](#breaking-the-format-on-a-pull-request)). + +### Iterating locally + +`just snapshot-goldens-generate` regenerates the directory for the current +`GOLDENS_VERSION` from the local source, so the verify test runs green +against your in-progress changes on your own platform. Use this loop +for iteration that does not need to cross hypervisor boundaries. +Cross-platform coverage comes from the publish workflow's matrix, which +runs automatically when the bump merges to `main` (see +[Publishing a new version](#publishing-a-new-version)). + +### Goldens version numbering + +`GOLDENS_VERSION` follows a `vMAJOR.MINOR` scheme. The tag set on GHCR +for a given version is keyed by the full string, so `v1.0`, `v1.1`, and +`v2.0` are independent namespaces that never collide. + +* Bump **MAJOR** when the snapshot ABI changes (Option 2 or Option 3 + above). The old tag set stays on GHCR untouched. +* Bump **MINOR** when the set of golden checks changes but the ABI does + not (for example, a new check is added). The new tag set contains + every check, including the unchanged ones, regenerated against the + current source. + +A version is published once, when the bump merges to `main`, and is +frozen from then on. The publish workflow only publishes a version +whose tags are absent from GHCR, so a published baseline cannot be +clobbered by a later run. While a developer iterates on a v1 to v2 +bump the new version is unpublished, so they verify locally with +`just snapshot-goldens-generate` and the `regen-goldens` label rather +than pushing to GHCR. + +The freeze is enforced by the publish workflow's existence check, not +by a registry policy. Republishing a frozen version takes a manual +dispatch with `force: true`, reserved for recovering a corrupted or +partial push. + +### Breaking the format on a pull request + +A pull request that bumps `GOLDENS_VERSION` introduces a tag set that +GHCR does not carry yet, so the default pull-and-verify path has nothing +to load. The `regen-goldens` label switches the verify job into +regenerate mode for that pull request. + +* **Without the label**, the job pulls the published tag set for the + current `GOLDENS_VERSION` and verifies it against the branch. Missing + tags fail the job. This is what turns an accidental format break into + a red build: the published bytes stop loading, and the author must + either restructure the change or own the break with the label. +* **With the `regen-goldens` label**, the job generates the goldens + from the branch source and runs them straight back through the + branch loader. This proves the new format is internally loadable on + each runner. It does not prove anything about the old tag set, which + belongs to a different version namespace. + +The label is an explicit, reviewable assertion that the format break is +intended. The verify job never regenerates on its own initiative, so a +flaky pull or a mistyped version stays a hard failure rather than +silently degrading into a self-check. + +### Publishing a new version + +Publishing is automatic. When a bump to `GOLDENS_VERSION` merges to +`main`, the `Regenerate Snapshot Goldens` workflow runs on the push and +publishes the new version's tag set. No manual step is needed, and a +merge that does not change `GOLDENS_VERSION` does not publish (the push +trigger is filtered to the file that holds the version, +`tests/snapshot_goldens/platform.rs`). + +The workflow walks every supported `(hypervisor, cpu, profile)` +combination on the self-hosted runner pool, generates the canonical +init and call snapshots with +`cargo test --test snapshot_goldens -- generate `, and pushes each +OCI layout to GHCR with `oras cp` as the tag +`{version}-{hv}-{cpu}-{profile}-{kind}`. + +A lightweight `resolve` job gates the matrix. It reads `GOLDENS_VERSION` +from source and checks GHCR for any tag with that version prefix. If the +version is already published the workflow stops there, so re-running it, +or merging an unrelated change, is a no-op. This makes publishing +idempotent and keeps a frozen baseline from being clobbered. + +The workflow can also be dispatched manually. The `version` input must +equal `GOLDENS_VERSION` in the dispatched ref, which guards against +publishing a tag set the test binary would ignore. A manual dispatch +with `force: true` republishes a version that already exists, reserved +for recovering a corrupted or partial push. + +The push-triggered publish closes the window in which a pull request +that bumped the version needs the `regen-goldens` label. Once `main` +carries the bump and the publish lands, new pull requests pass on the +default pull-and-verify path. + +## Adding a new check under the current ABI + +Adding a new entry to `CHECKS` does not change the snapshot ABI. It +does change the set of tags the verify test expects, so it requires a +minor `GOLDENS_VERSION` bump. + +Steps: + +1. Add the entry to `CHECKS` in + `src/hyperlight_host/tests/snapshot_goldens/`. +2. Bump `GOLDENS_VERSION` minor (e.g. `v1.2` to `v1.3`). The new prefix + has no published tags, so the default verify path fails until they + exist. +3. Apply the `regen-goldens` label to the pull request. The verify job + regenerates the full check set against the branch and runs it back + through the branch loader. See + [Breaking the format on a pull request](#breaking-the-format-on-a-pull-request). +4. Once the change lands, the new prefix is published per + [Publishing a new version](#publishing-a-new-version). The older + tag set stays on GHCR untouched. + +The older minor's tags can be deleted from GHCR once nothing depends +on them. + +## Verifying multiple golden versions + +The verify test pulls exactly one tag set, the one for the current +`GOLDENS_VERSION`. That covers the hard-break case (Option 3), where a +fresh tag set replaces the older one. + +The backwards-compatible case (Option 2) needs more. A v1 loader path +is only correct if real v1 goldens load against the new build, which +means verifying against multiple versions in the same run. + +The intended design is to replace the single `GOLDENS_VERSION` constant +with a slice of the supported major versions, e.g. +`pub const GOLDENS_VERSIONS: &[&str] = &["v1.3", "v2.0"];`, and have +the verify test run every check against every entry. Dropping an old +major is then a one-line removal from that slice. + +The single-version variant suffices for Option 3. Build the +multi-version variant the first time you take Option 2. diff --git a/src/hyperlight_common/src/layout.rs b/src/hyperlight_common/src/layout.rs index 69ecdb6ef..781832bcf 100644 --- a/src/hyperlight_common/src/layout.rs +++ b/src/hyperlight_common/src/layout.rs @@ -26,6 +26,14 @@ pub const SCRATCH_TOP_ALLOCATOR_OFFSET: u64 = 0x10; pub const SCRATCH_TOP_SNAPSHOT_PT_GPA_BASE_OFFSET: u64 = 0x18; pub const SCRATCH_TOP_SNAPSHOT_GENERATION_OFFSET: u64 = 0x20; pub const SCRATCH_TOP_EXN_STACK_OFFSET: u64 = 0x30; +/// Top of the dedicated page-fault exception stack, one page below the +/// top of scratch memory. Page faults use a separate stack from all +/// other exceptions (see the TSS setup in the guest's init code), so +/// the general exception stack occupies the top page (below the +/// metadata at offsets `0x08..0x30`) and the page-fault stack occupies +/// the page below it. Both live within the two scratch pages reserved +/// at the top of the region by `alloc_phys_pages`. +pub const SCRATCH_TOP_PF_EXN_STACK_OFFSET: u64 = 0x1000; pub fn scratch_base_gpa(size: usize) -> u64 { (MAX_GPA - size + 1) as u64 diff --git a/src/hyperlight_guest_bin/src/arch/amd64/exception/entry.rs b/src/hyperlight_guest_bin/src/arch/amd64/exception/entry.rs index 87f89f15c..9d593f555 100644 --- a/src/hyperlight_guest_bin/src/arch/amd64/exception/entry.rs +++ b/src/hyperlight_guest_bin/src/arch/amd64/exception/entry.rs @@ -22,7 +22,9 @@ use core::arch::{asm, global_asm}; use hyperlight_common::outb::Exception; use super::super::context; -use super::super::machine::{IDT, IdtEntry, IdtPointer, ProcCtrl}; +use super::super::machine::{ + IDT, IST_GENERAL_EXCEPTION, IST_PAGE_FAULT, IdtEntry, IdtPointer, ProcCtrl, +}; unsafe extern "C" { // Exception handlers @@ -174,12 +176,16 @@ global_asm!( pub(in super::super) fn init_idt(pc: *mut ProcCtrl) { let idt = unsafe { &raw mut (*pc).idt }; - let set_idt_entry = |idx, handler: unsafe extern "C" fn()| { + let set_idt_entry_ist = |idx, handler: unsafe extern "C" fn(), ist: u8| { let handler_addr = handler as *const () as u64; unsafe { - (&raw mut (*idt).entries[idx as usize]).write_volatile(IdtEntry::new(handler_addr)); + (&raw mut (*idt).entries[idx as usize]) + .write_volatile(IdtEntry::new_with_ist(handler_addr, ist)); } }; + let set_idt_entry = |idx, handler: unsafe extern "C" fn()| { + set_idt_entry_ist(idx, handler, IST_GENERAL_EXCEPTION) + }; set_idt_entry(Exception::DivideByZero, _do_excp0); // Divide by zero set_idt_entry(Exception::Debug, _do_excp1); // Debug set_idt_entry(Exception::NonMaskableInterrupt, _do_excp2); // Non-maskable interrupt @@ -194,7 +200,7 @@ pub(in super::super) fn init_idt(pc: *mut ProcCtrl) { set_idt_entry(Exception::SegmentNotPresent, _do_excp11); // Segment Not Present set_idt_entry(Exception::StackSegmentFault, _do_excp12); // Stack-Segment Fault set_idt_entry(Exception::GeneralProtectionFault, _do_excp13); // General Protection Fault - set_idt_entry(Exception::PageFault, _do_excp14); // Page Fault + set_idt_entry_ist(Exception::PageFault, _do_excp14, IST_PAGE_FAULT); // Page Fault (dedicated IST stack) set_idt_entry(Exception::Reserved, _do_excp15); // Reserved set_idt_entry(Exception::X87FloatingPointException, _do_excp16); // x87 Floating-Point Exception set_idt_entry(Exception::AlignmentCheck, _do_excp17); // Alignment Check diff --git a/src/hyperlight_guest_bin/src/arch/amd64/init.rs b/src/hyperlight_guest_bin/src/arch/amd64/init.rs index 073bd3a2f..571351782 100644 --- a/src/hyperlight_guest_bin/src/arch/amd64/init.rs +++ b/src/hyperlight_guest_bin/src/arch/amd64/init.rs @@ -79,10 +79,24 @@ unsafe fn init_gdt(pc: *mut ProcCtrl) { } } -/// Hyperlight's TSS contains only a single IST entry, which is used -/// to set up the stack switch to the exception stack whenever we take -/// an exception (including page faults, which are important, since -/// the fault might be due to needing to grow the stack!) +/// Hyperlight's TSS uses two IST entries to switch onto a known-good +/// exception stack whenever an exception is taken (including page +/// faults, which is important, since the fault might be due to needing +/// to grow the main stack). +/// +/// * `ist1` is the general exception stack, used by every exception +/// other than page faults. +/// * `ist2` is a stack dedicated to page faults. +/// +/// Page faults get their own stack because copy-on-write pages are +/// mapped present but read-only, so the first write to a restored +/// snapshot page raises a page fault. When that write happens inside +/// another exception handler (for example a guest-installed handler +/// running on `ist1`), delivering the page fault on the same stack +/// would reset RSP to the stack top and overwrite the outer handler's +/// saved context and interrupt frame, corrupting the return path. A +/// separate page-fault stack keeps the outer frame intact so the +/// interrupted handler resumes correctly once the fault is serviced. /// /// This function sets up the TSS and then points the processor at the /// system segment descriptor, initialized in [`init_gdt`] above, @@ -96,6 +110,11 @@ unsafe fn init_tss(pc: *mut ProcCtrl) { - hyperlight_common::layout::SCRATCH_TOP_EXN_STACK_OFFSET + 1; ist1_ptr.write_volatile(exn_stack.to_ne_bytes()); + let ist2_ptr = &raw mut (*tss_ptr).ist2 as *mut [u8; 8]; + let pf_exn_stack = hyperlight_common::layout::MAX_GVA as u64 + - hyperlight_common::layout::SCRATCH_TOP_PF_EXN_STACK_OFFSET + + 1; + ist2_ptr.write_volatile(pf_exn_stack.to_ne_bytes()); asm!( "ltr ax", in("ax") core::mem::offset_of!(HyperlightGDT, tss), diff --git a/src/hyperlight_guest_bin/src/arch/amd64/machine.rs b/src/hyperlight_guest_bin/src/arch/amd64/machine.rs index cde8118e3..422618250 100644 --- a/src/hyperlight_guest_bin/src/arch/amd64/machine.rs +++ b/src/hyperlight_guest_bin/src/arch/amd64/machine.rs @@ -20,6 +20,16 @@ use hyperlight_common::vmem::{BasicMapping, MappingKind, PAGE_SIZE}; use super::layout::PROC_CONTROL_GVA; +/// IST index used in the IDT gate descriptor for every exception other +/// than page faults. Selects [`TSS::ist1`]. +pub(super) const IST_GENERAL_EXCEPTION: u8 = 1; +/// IST index used in the IDT gate descriptor for page faults. Selects +/// [`TSS::ist2`], a stack separate from the general exception stack so +/// that a page fault taken while another handler is running cannot +/// overwrite that handler's saved context. See the TSS setup in +/// `init.rs` for the full rationale. +pub(super) const IST_PAGE_FAULT: u8 = 2; + /// Entry in the Global Descriptor Table (GDT) /// For reference, see page 3-10 Vol. 3A of Intel 64 and IA-32 /// Architectures Software Developer's Manual, figure 3-8 @@ -117,7 +127,7 @@ pub(super) struct TSS { _rsp2: u64, _rsvd1: [u8; 8], pub(super) ist1: u64, - _ist2: u64, + pub(super) ist2: u64, _ist3: u64, _ist4: u64, _ist5: u64, @@ -127,6 +137,7 @@ pub(super) struct TSS { } const _: () = assert!(mem::size_of::() == 0x64); const _: () = assert!(mem::offset_of!(TSS, ist1) == 0x24); +const _: () = assert!(mem::offset_of!(TSS, ist2) == 0x2c); /// An entry in the Interrupt Descriptor Table (IDT) /// For reference, see page 7-20 Vol. 3A of Intel 64 and IA-32 @@ -154,10 +165,16 @@ const _: () = assert!(mem::size_of::() == 0x10); impl IdtEntry { pub(super) fn new(handler: u64) -> Self { + Self::new_with_ist(handler, IST_GENERAL_EXCEPTION) + } + + /// Build an IDT gate that switches to the given IST stack (1-based, + /// selecting one of `TSS::ist1..ist7`) when the vector is taken. + pub(super) fn new_with_ist(handler: u64, ist: u8) -> Self { Self { offset_low: (handler & 0xFFFF) as u16, selector: 0x08, // Kernel Code Segment - interrupt_stack_table_offset: 1, + interrupt_stack_table_offset: ist, type_attr: 0x8E, // 0x8E = 10001110b // 1 00 0 1101 diff --git a/src/hyperlight_host/Cargo.toml b/src/hyperlight_host/Cargo.toml index 32b1604e2..fe8c198af 100644 --- a/src/hyperlight_host/Cargo.toml +++ b/src/hyperlight_host/Cargo.toml @@ -48,9 +48,14 @@ thiserror = "2.0.18" chrono = { version = "0.4", optional = true } anyhow = "1.0" metrics = "0.24.6" +serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" elfcore = { version = "2.0", optional = true } uuid = { version = "1.23.3", features = ["v4"] } +oci-spec = { version = "0.8", default-features = false, features = ["image"] } +sha2 = "0.10" +hex = "0.4" +tempfile = "3.27.0" [target.'cfg(windows)'.dependencies] windows = { version = "0.62", features = [ @@ -82,10 +87,8 @@ mshv-ioctls = { version = "0.6", optional = true} [dev-dependencies] uuid = { version = "1.23.3", features = ["v4"] } signal-hook-registry = "1.4.8" -serde = "1.0" iced-x86 = { version = "1.21", default-features = false, features = ["std", "code_asm"] } proptest = "1.11.0" -tempfile = "3.27.0" crossbeam-queue = "0.3.12" tracing-serde = "0.2.0" serial_test = "3.5.0" @@ -106,6 +109,7 @@ metrics-util = "0.20.4" metrics-exporter-prometheus = { version = "0.18.3", default-features = false } serde_json = "1.0" hyperlight-component-macro = { workspace = true } +libtest-mimic = "0.8.2" [target.'cfg(windows)'.dev-dependencies] windows = { version = "0.62", features = [ @@ -141,3 +145,12 @@ build-metadata = ["dep:built"] [[bench]] name = "benchmarks" harness = false + +[[test]] +name = "snapshot_goldens" +path = "tests/snapshot_goldens/main.rs" +harness = false +# Excluded from `cargo test` so a normal run does not need the golden tests +# downloaded. A `--test '*'` glob still matches it, so callers name targets +# explicitly. +test = false diff --git a/src/hyperlight_host/benches/benchmarks.rs b/src/hyperlight_host/benches/benchmarks.rs index 462e8908d..3b4110f60 100644 --- a/src/hyperlight_host/benches/benchmarks.rs +++ b/src/hyperlight_host/benches/benchmarks.rs @@ -153,6 +153,15 @@ fn sandbox_lifecycle_benchmark(c: &mut Criterion) { ); } + // Isolates the cost of building a MultiUseSandbox from an + // already-resident Snapshot. The Snapshot is loaded outside the + // timed region. + for size in SandboxSize::all() { + group.bench_function(format!("sandbox_from_snapshot/{}", size.name()), |b| { + bench_sandbox_from_snapshot(b, size) + }); + } + group.finish(); } @@ -347,6 +356,28 @@ fn bench_snapshot_restore(b: &mut criterion::Bencher, size: SandboxSize) { }); } +fn bench_sandbox_from_snapshot(b: &mut criterion::Bencher, size: SandboxSize) { + use hyperlight_host::HostFunctions; + use hyperlight_host::sandbox::snapshot::{OciTag, Snapshot}; + + let dir = tempfile::tempdir().unwrap(); + let snap_path = dir.path().join("bench"); + let tag = OciTag::new("latest").unwrap(); + { + let mut sbox = create_multiuse_sandbox_with_size(size); + let snapshot = sbox.snapshot().unwrap(); + snapshot.to_oci(&snap_path, &tag).unwrap(); + } + let loaded = std::sync::Arc::new(Snapshot::from_oci(&snap_path, tag).unwrap()); + + // Drop is not included. + b.iter_batched( + || (), + |_| MultiUseSandbox::from_snapshot(loaded.clone(), HostFunctions::default(), None).unwrap(), + criterion::BatchSize::PerIteration, + ); +} + fn snapshots_benchmark(c: &mut Criterion) { let mut group = c.benchmark_group("snapshots"); @@ -551,6 +582,148 @@ fn shared_memory_benchmark(c: &mut Criterion) { group.finish(); } +// ============================================================================ +// Benchmark Category: Snapshot Files +// ============================================================================ + +fn snapshot_file_benchmark(c: &mut Criterion) { + use hyperlight_host::HostFunctions; + use hyperlight_host::sandbox::snapshot::{OciTag, Snapshot}; + + let mut group = c.benchmark_group("snapshot_files"); + + // Pre-create OCI snapshot images for all sizes. + let dirs: Vec<_> = SandboxSize::all() + .iter() + .map(|size| { + let dir = tempfile::tempdir().unwrap(); + let snap_path = dir.path().join(size.name()); + let snapshot = { + let mut sbox = create_multiuse_sandbox_with_size(*size); + sbox.snapshot().unwrap() + }; + snapshot + .to_oci(&snap_path, &OciTag::new("latest").unwrap()) + .unwrap(); + (dir, snapshot, snap_path) + }) + .collect(); + + // Benchmark: save_snapshot. Wipe the layout between iterations + // so each save measures a fresh write rather than a tag-append. + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_dir = tempfile::tempdir().unwrap(); + let path = snap_dir.path().join("bench"); + let snapshot = &dirs[i].1; + group.bench_function(format!("save_snapshot/{}", size.name()), |b| { + b.iter_batched( + || { + let _ = std::fs::remove_dir_all(&path); + }, + |_| { + snapshot + .to_oci(&path, &OciTag::new("latest").unwrap()) + .unwrap() + }, + criterion::BatchSize::PerIteration, + ); + }); + } + + // Benchmark: load_snapshot (parse manifest + config + mmap blob). + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_path = dirs[i].2.clone(); + group.bench_function(format!("load_snapshot/{}", size.name()), |b| { + b.iter(|| { + let _ = Snapshot::from_oci(&snap_path, OciTag::new("latest").unwrap()).unwrap(); + }); + }); + } + + // Benchmark: load_snapshot_unchecked (skip blob digest verification). + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_path = dirs[i].2.clone(); + group.bench_function(format!("load_snapshot_unchecked/{}", size.name()), |b| { + b.iter(|| { + // SAFETY: snapshot path produced by `to_oci` in bench setup. + let _ = unsafe { + Snapshot::from_oci_unchecked(&snap_path, OciTag::new("latest").unwrap()) + } + .unwrap(); + }); + }); + } + + // Benchmark: cold_start_via_evolve (new + evolve + call). Drop is not included. + for size in SandboxSize::all() { + group.bench_function(format!("cold_start_via_evolve/{}", size.name()), |b| { + b.iter_batched( + || (), + |_| { + let mut sbox = create_multiuse_sandbox_with_size(size); + sbox.call::("Echo", "hello\n".to_string()).unwrap(); + sbox + }, + criterion::BatchSize::PerIteration, + ); + }); + } + + // Benchmark: cold_start_via_snapshot (load + from_snapshot + call). Drop is not included. + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_path = dirs[i].2.clone(); + group.bench_function(format!("cold_start_via_snapshot/{}", size.name()), |b| { + b.iter_batched( + || (), + |_| { + let loaded = + Snapshot::from_oci(&snap_path, OciTag::new("latest").unwrap()).unwrap(); + let mut sbox = MultiUseSandbox::from_snapshot( + std::sync::Arc::new(loaded), + HostFunctions::default(), + None, + ) + .unwrap(); + sbox.call::("Echo", "hello\n".to_string()).unwrap(); + sbox + }, + criterion::BatchSize::PerIteration, + ); + }); + } + + // Benchmark: cold_start_via_snapshot_unchecked (load unchecked + from_snapshot + call). Drop is not included. + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_path = dirs[i].2.clone(); + group.bench_function( + format!("cold_start_via_snapshot_unchecked/{}", size.name()), + |b| { + b.iter_batched( + || (), + |_| { + // SAFETY: snapshot path produced by `to_oci` in bench setup. + let loaded = unsafe { + Snapshot::from_oci_unchecked(&snap_path, OciTag::new("latest").unwrap()) + } + .unwrap(); + let mut sbox = MultiUseSandbox::from_snapshot( + std::sync::Arc::new(loaded), + HostFunctions::default(), + None, + ) + .unwrap(); + sbox.call::("Echo", "hello\n".to_string()).unwrap(); + sbox + }, + criterion::BatchSize::PerIteration, + ); + }, + ); + } + + group.finish(); +} + criterion_group! { name = benches; config = Criterion::default(); @@ -561,6 +734,7 @@ criterion_group! { guest_call_benchmark_large_param, function_call_serialization_benchmark, sample_workloads_benchmark, - shared_memory_benchmark + shared_memory_benchmark, + snapshot_file_benchmark } criterion_main!(benches); diff --git a/src/hyperlight_host/src/mem/shared_mem.rs b/src/hyperlight_host/src/mem/shared_mem.rs index 461daa756..53fe379cc 100644 --- a/src/hyperlight_host/src/mem/shared_mem.rs +++ b/src/hyperlight_host/src/mem/shared_mem.rs @@ -1521,7 +1521,6 @@ impl ReadonlySharedMemory { /// The file's length must be a non-zero multiple of `PAGE_SIZE`. /// `guest_mapped_size` must be a non-zero multiple of `PAGE_SIZE` /// no greater than the file's length. - #[cfg_attr(not(test), expect(dead_code))] pub(crate) fn from_file(file: &std::fs::File, guest_mapped_size: usize) -> Result { let len: usize = file .metadata() @@ -1568,6 +1567,8 @@ impl ReadonlySharedMemory { fn map_file(file: &std::fs::File, len: usize) -> Result> { use std::os::unix::io::AsRawFd; + #[cfg(mshv3)] + use libc::PROT_WRITE; use libc::{ MAP_ANONYMOUS, MAP_FAILED, MAP_FIXED, MAP_NORESERVE, MAP_PRIVATE, PROT_NONE, PROT_READ, mmap, off_t, size_t, @@ -1608,6 +1609,15 @@ impl ReadonlySharedMemory { // `MAP_FIXED`. The guest maps these pages READ|EXECUTE, // so the host VMA is read-only. `MAP_PRIVATE` keeps the // mapping detached from the underlying file. + + // MSHV's map_user_memory requires host-writable pages + // (the kernel module calls `pin_user_pages(FOLL_PIN|FOLL_WRITE)` + // on the region when it is mapped into the partition). + // KVM accepts read-only host pages for read-only guest slots. + #[cfg(mshv3)] + let file_prot = PROT_READ | PROT_WRITE; + #[cfg(not(mshv3))] + let file_prot = PROT_READ; // SAFETY: `total_size = len + 2 * PAGE_SIZE_USIZE`, so // `base + PAGE_SIZE_USIZE` is in-bounds of the reservation. let usable_ptr = unsafe { (base as *mut u8).add(PAGE_SIZE_USIZE) }; @@ -1620,7 +1630,7 @@ impl ReadonlySharedMemory { mmap( usable_ptr as *mut c_void, len as size_t, - PROT_READ, + file_prot, MAP_PRIVATE | MAP_FIXED | MAP_NORESERVE, fd, 0 as off_t, diff --git a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs index 9173c0d99..ea76e1c65 100644 --- a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs +++ b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs @@ -160,6 +160,21 @@ impl MultiUseSandbox { /// # Ok(()) /// # } /// ``` + /// + /// From a snapshot loaded from disk: + /// + /// ```no_run + /// # use std::sync::Arc; + /// # use hyperlight_host::{HostFunctions, MultiUseSandbox}; + /// # use hyperlight_host::sandbox::snapshot::{OciTag, Snapshot}; + /// # fn example() -> Result<(), Box> { + /// let tag = OciTag::new("latest")?; + /// let snapshot = Arc::new(Snapshot::from_oci("./guest_snapshot", tag)?); + /// let mut sandbox = MultiUseSandbox::from_snapshot(snapshot, HostFunctions::default(), None)?; + /// let result: String = sandbox.call("Echo", "hello".to_string())?; + /// # Ok(()) + /// # } + /// ``` #[instrument(err(Debug), skip_all, parent = Span::current(), level = "Trace")] pub fn from_snapshot( snapshot: Arc, diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/config.rs b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs new file mode 100644 index 000000000..ead35a513 --- /dev/null +++ b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs @@ -0,0 +1,1042 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +use hyperlight_common::flatbuffer_wrappers::function_types::{ParameterType, ReturnType}; +use hyperlight_common::flatbuffer_wrappers::host_function_definition::HostFunctionDefinition; +use hyperlight_common::vmem::PAGE_SIZE; +use serde::{Deserialize, Serialize}; + +use super::media_types::SNAPSHOT_ABI_VERSION; +use crate::hypervisor::regs::{CommonSegmentRegister, CommonSpecialRegisters, CommonTableRegister}; +use crate::mem::layout::SandboxMemoryLayout; +use crate::mem::memory_region::MemoryRegionFlags; + +// --- Arch and hypervisor identifiers -------------------------------- + +/// Guest architecture the snapshot was captured for. Checked on load +/// against the running host. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(super) enum Arch { + X86_64, + Aarch64, +} + +impl Arch { + pub(super) fn current() -> Self { + #[cfg(target_arch = "x86_64")] + { + Self::X86_64 + } + #[cfg(target_arch = "aarch64")] + { + Self::Aarch64 + } + } + + /// Lowercase token matching the config JSON serialization, used + /// for the advisory arch annotation on the manifest descriptor. + pub(super) fn as_str(&self) -> &'static str { + match self { + Self::X86_64 => "x86_64", + Self::Aarch64 => "aarch64", + } + } +} + +/// Hypervisor backend the snapshot was captured under. Checked on +/// load because vCPU register state is backend-specific. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(super) enum Hypervisor { + Kvm, + Mshv, + Whp, +} + +impl Hypervisor { + pub(super) fn current() -> Option { + #[allow(unused_imports)] + use crate::hypervisor::virtual_machine::HypervisorType; + use crate::hypervisor::virtual_machine::get_available_hypervisor; + + match get_available_hypervisor() { + #[cfg(kvm)] + Some(HypervisorType::Kvm) => Some(Self::Kvm), + #[cfg(mshv3)] + Some(HypervisorType::Mshv) => Some(Self::Mshv), + #[cfg(target_os = "windows")] + Some(HypervisorType::Whp) => Some(Self::Whp), + None => None, + } + } + + fn name(&self) -> &'static str { + match self { + Self::Kvm => "KVM", + Self::Mshv => "MSHV", + Self::Whp => "WHP", + } + } + + /// Lowercase token matching the config JSON serialization, used + /// for the advisory hypervisor annotation on the manifest + /// descriptor. + pub(super) fn as_str(&self) -> &'static str { + match self { + Self::Kvm => "kvm", + Self::Mshv => "mshv", + Self::Whp => "whp", + } + } +} + +// --- Config JSON shape ---------------------------------------------- + +/// Top-level Hyperlight snapshot config JSON. Lives at +/// `blobs/sha256/` with media type +/// `application/vnd.hyperlight.snapshot.config.v1+json`. +/// +/// In OCI terms this is the "image config" blob that the manifest's +/// `config` descriptor points to. It describes the accompanying +/// memory layer (the snapshot bytes) and everything the loader needs +/// to reconstruct a runnable `Snapshot`. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct OciSnapshotConfig { + /// Hyperlight crate version that produced this config. Recorded + /// for diagnostics. Not checked on load. + pub(super) hyperlight_version: String, + pub(super) arch: Arch, + /// Memory blob ABI version. See `SNAPSHOT_ABI_VERSION`. + pub(super) abi_version: u32, + pub(super) hypervisor: Hypervisor, + /// Top of the guest stack, in guest virtual address space. + pub(super) stack_top_gva: u64, + pub(super) entrypoint: Entrypoint, + pub(super) layout: MemoryLayout, + /// Total size of the memory blob in bytes (including the guest + /// page-table tail, if any). Equal to `self.memory.mem_size()`. + pub(super) memory_size: u64, + /// Names and signatures of host functions registered when this + /// snapshot was taken. Validated against the loader's registry. + pub(super) host_functions: Vec, + /// Generation counter for the snapshot. Restored verbatim into + /// the `Snapshot` so guest-visible bookkeeping at + /// `SCRATCH_TOP_SNAPSHOT_GENERATION_OFFSET` is continuous across + /// save/load. + pub(super) snapshot_generation: u64, +} + +/// What the loader should do with the restored sandbox: jump to the +/// guest entrypoint, or resume a paused call with captured sregs. +/// The enum shape enforces that `Call` carries sregs and `Initialise` +/// does not. +#[derive(Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)] +pub(super) enum Entrypoint { + Initialise { addr: u64 }, + Call { addr: u64, sregs: Box }, +} + +/// Sizes and permissions of the regions inside the snapshot blob, +/// enough for the loader to rebuild a `SandboxMemoryLayout`. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct MemoryLayout { + pub(super) input_data_size: usize, + pub(super) output_data_size: usize, + pub(super) heap_size: usize, + pub(super) code_size: usize, + pub(super) init_data_size: usize, + /// Memory region flag bits. `None` means default permissions. + pub(super) init_data_permissions: Option, + pub(super) scratch_size: usize, + pub(super) snapshot_size: usize, + pub(super) pt_size: Option, +} + +/// Name and signature of one host function registered when the +/// snapshot was taken. The loader validates these against the +/// registry of the sandbox it is restoring into. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct HostFunction { + function_name: String, + parameter_types: Vec, + return_type: ReturnTypeRepr, +} + +/// JSON-friendly mirror of +/// [`hyperlight_common::flatbuffer_wrappers::function_types::ParameterType`]. +/// Kept local so we don't have to plumb serde through `hyperlight_common`. +/// The `match`es below are exhaustive: any new variant upstream forces +/// an explicit decision here. +#[derive(Serialize, Deserialize, Copy, Clone)] +#[serde(rename_all = "snake_case")] +enum ParameterTypeRepr { + Int, + UInt, + Long, + ULong, + Float, + Double, + String, + Bool, + VecBytes, +} + +/// JSON-friendly mirror of +/// [`hyperlight_common::flatbuffer_wrappers::function_types::ReturnType`]. +#[derive(Serialize, Deserialize, Copy, Clone)] +#[serde(rename_all = "snake_case")] +enum ReturnTypeRepr { + Int, + UInt, + Long, + ULong, + Float, + Double, + String, + Bool, + Void, + VecBytes, +} + +impl From<&ParameterType> for ParameterTypeRepr { + fn from(p: &ParameterType) -> Self { + match p { + ParameterType::Int => Self::Int, + ParameterType::UInt => Self::UInt, + ParameterType::Long => Self::Long, + ParameterType::ULong => Self::ULong, + ParameterType::Float => Self::Float, + ParameterType::Double => Self::Double, + ParameterType::String => Self::String, + ParameterType::Bool => Self::Bool, + ParameterType::VecBytes => Self::VecBytes, + } + } +} + +impl From for ParameterType { + fn from(r: ParameterTypeRepr) -> Self { + match r { + ParameterTypeRepr::Int => Self::Int, + ParameterTypeRepr::UInt => Self::UInt, + ParameterTypeRepr::Long => Self::Long, + ParameterTypeRepr::ULong => Self::ULong, + ParameterTypeRepr::Float => Self::Float, + ParameterTypeRepr::Double => Self::Double, + ParameterTypeRepr::String => Self::String, + ParameterTypeRepr::Bool => Self::Bool, + ParameterTypeRepr::VecBytes => Self::VecBytes, + } + } +} + +impl From<&ReturnType> for ReturnTypeRepr { + fn from(r: &ReturnType) -> Self { + match r { + ReturnType::Int => Self::Int, + ReturnType::UInt => Self::UInt, + ReturnType::Long => Self::Long, + ReturnType::ULong => Self::ULong, + ReturnType::Float => Self::Float, + ReturnType::Double => Self::Double, + ReturnType::String => Self::String, + ReturnType::Bool => Self::Bool, + ReturnType::Void => Self::Void, + ReturnType::VecBytes => Self::VecBytes, + } + } +} + +impl From for ReturnType { + fn from(r: ReturnTypeRepr) -> Self { + match r { + ReturnTypeRepr::Int => Self::Int, + ReturnTypeRepr::UInt => Self::UInt, + ReturnTypeRepr::Long => Self::Long, + ReturnTypeRepr::ULong => Self::ULong, + ReturnTypeRepr::Float => Self::Float, + ReturnTypeRepr::Double => Self::Double, + ReturnTypeRepr::String => Self::String, + ReturnTypeRepr::Bool => Self::Bool, + ReturnTypeRepr::Void => Self::Void, + ReturnTypeRepr::VecBytes => Self::VecBytes, + } + } +} + +/// Captured x86_64 special registers for a paused vCPU. Round-trips +/// to/from [`CommonSpecialRegisters`] and is restored verbatim when +/// resuming a `Call` entrypoint. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Sregs { + cs: SegmentRegister, + ds: SegmentRegister, + es: SegmentRegister, + fs: SegmentRegister, + gs: SegmentRegister, + ss: SegmentRegister, + tr: SegmentRegister, + ldt: SegmentRegister, + gdt: TableRegister, + idt: TableRegister, + cr0: u64, + cr2: u64, + // CR3 is recomputed at load from the reconstructed layout via + // `get_pt_base_gpa()`, so it is omitted to keep the config + // digest stable across re-snapshots of the same guest state. + cr4: u64, + cr8: u64, + efer: u64, + apic_base: u64, + interrupt_bitmap: [u64; 4], +} + +/// Serde mirror of [`CommonSegmentRegister`]. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SegmentRegister { + base: u64, + limit: u32, + selector: u16, + type_: u8, + present: u8, + dpl: u8, + db: u8, + s: u8, + l: u8, + g: u8, + avl: u8, + unusable: u8, + padding: u8, +} + +/// Serde mirror of [`CommonTableRegister`]. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct TableRegister { + base: u64, + limit: u16, +} + +// --- Conversions between repr and runtime types --------------------- + +impl From<&CommonSpecialRegisters> for Sregs { + fn from(s: &CommonSpecialRegisters) -> Self { + let seg = |r: &CommonSegmentRegister| SegmentRegister { + base: r.base, + limit: r.limit, + selector: r.selector, + type_: r.type_, + present: r.present, + dpl: r.dpl, + db: r.db, + s: r.s, + l: r.l, + g: r.g, + avl: r.avl, + unusable: r.unusable, + padding: r.padding, + }; + let tab = |r: &CommonTableRegister| TableRegister { + base: r.base, + limit: r.limit, + }; + Self { + cs: seg(&s.cs), + ds: seg(&s.ds), + es: seg(&s.es), + fs: seg(&s.fs), + gs: seg(&s.gs), + ss: seg(&s.ss), + tr: seg(&s.tr), + ldt: seg(&s.ldt), + gdt: tab(&s.gdt), + idt: tab(&s.idt), + cr0: s.cr0, + cr2: s.cr2, + cr4: s.cr4, + cr8: s.cr8, + efer: s.efer, + apic_base: s.apic_base, + interrupt_bitmap: s.interrupt_bitmap, + } + } +} + +impl From for CommonSpecialRegisters { + fn from(r: Sregs) -> Self { + let seg = |s: SegmentRegister| CommonSegmentRegister { + base: s.base, + limit: s.limit, + selector: s.selector, + type_: s.type_, + present: s.present, + dpl: s.dpl, + db: s.db, + s: s.s, + l: s.l, + g: s.g, + avl: s.avl, + unusable: s.unusable, + padding: s.padding, + }; + let tab = |t: TableRegister| CommonTableRegister { + base: t.base, + limit: t.limit, + }; + Self { + cs: seg(r.cs), + ds: seg(r.ds), + es: seg(r.es), + fs: seg(r.fs), + gs: seg(r.gs), + ss: seg(r.ss), + tr: seg(r.tr), + ldt: seg(r.ldt), + gdt: tab(r.gdt), + idt: tab(r.idt), + cr0: r.cr0, + cr2: r.cr2, + cr3: 0, + cr4: r.cr4, + cr8: r.cr8, + efer: r.efer, + apic_base: r.apic_base, + interrupt_bitmap: r.interrupt_bitmap, + } + } +} + +impl From<&HostFunctionDefinition> for HostFunction { + fn from(d: &HostFunctionDefinition) -> Self { + let parameter_types = d + .parameter_types + .as_ref() + .map(|v| v.iter().map(ParameterTypeRepr::from).collect()) + .unwrap_or_default(); + Self { + function_name: d.function_name.clone(), + parameter_types, + return_type: ReturnTypeRepr::from(&d.return_type), + } + } +} + +impl From for HostFunctionDefinition { + fn from(r: HostFunction) -> Self { + Self { + function_name: r.function_name, + parameter_types: Some(r.parameter_types.into_iter().map(Into::into).collect()), + return_type: r.return_type.into(), + } + } +} + +impl OciSnapshotConfig { + pub(super) fn validate_for_load(&self) -> crate::Result<()> { + if self.arch != Arch::current() { + return Err(crate::new_error!( + "snapshot architecture mismatch: file is {:?}, current host is {:?} \ + (snapshot produced by hyperlight {})", + self.arch, + Arch::current(), + self.hyperlight_version + )); + } + if self.abi_version != SNAPSHOT_ABI_VERSION { + return Err(crate::new_error!( + "snapshot ABI version mismatch: file has version {}, this build expects {}. \ + The snapshot must be regenerated from the guest binary \ + (snapshot produced by hyperlight {}).", + self.abi_version, + SNAPSHOT_ABI_VERSION, + self.hyperlight_version + )); + } + let current_hv = Hypervisor::current() + .ok_or_else(|| crate::new_error!("no hypervisor available to load snapshot"))?; + if self.hypervisor != current_hv { + return Err(crate::new_error!( + "snapshot hypervisor mismatch: file was created on {} but the current hypervisor is {} \ + (snapshot produced by hyperlight {})", + self.hypervisor.name(), + current_hv.name(), + self.hyperlight_version + )); + } + // Bound memory size early so the subsequent file-size check + // does not have to deal with absurd values. + if self.memory_size == 0 || self.memory_size > SandboxMemoryLayout::MAX_MEMORY_SIZE as u64 { + return Err(crate::new_error!( + "snapshot memory_size ({}) is out of range", + self.memory_size + )); + } + if !(self.memory_size as usize).is_multiple_of(PAGE_SIZE) { + return Err(crate::new_error!( + "snapshot memory_size ({}) is not a multiple of PAGE_SIZE", + self.memory_size + )); + } + // `snapshot_size` is the guest-visible prefix of the blob, + // mapped at `BASE_ADDRESS`. `pt_size` is the page-table tail + // after it, present in the blob and host mapping but outside + // the guest mapping. They sum to `memory_size`. + if self.layout.snapshot_size == 0 { + return Err(crate::new_error!("snapshot snapshot_size must be nonzero")); + } + if !self.layout.snapshot_size.is_multiple_of(PAGE_SIZE) { + return Err(crate::new_error!( + "snapshot snapshot_size ({}) is not a multiple of PAGE_SIZE", + self.layout.snapshot_size + )); + } + let pt = self.layout.pt_size.unwrap_or(0); + if !pt.is_multiple_of(PAGE_SIZE) { + return Err(crate::new_error!( + "snapshot pt_size ({}) is not a multiple of PAGE_SIZE", + pt + )); + } + if (self.layout.snapshot_size as u64).saturating_add(pt as u64) != self.memory_size { + return Err(crate::new_error!( + "snapshot snapshot_size ({}) + pt_size ({}) does not equal memory_size ({})", + self.layout.snapshot_size, + pt, + self.memory_size + )); + } + // Bound every layout field that feeds size and offset + // arithmetic in `SandboxMemoryLayout`. Each region is + // independently capped at `MAX_MEMORY_SIZE` so the sums + // computed while reconstructing the layout cannot overflow. + let max_region = SandboxMemoryLayout::MAX_MEMORY_SIZE; + for (name, value) in [ + ("input_data_size", self.layout.input_data_size), + ("output_data_size", self.layout.output_data_size), + ("heap_size", self.layout.heap_size), + ("code_size", self.layout.code_size), + ("init_data_size", self.layout.init_data_size), + ("scratch_size", self.layout.scratch_size), + ] { + if value > max_region { + return Err(crate::new_error!( + "snapshot layout field {} ({}) exceeds maximum allowed {}", + name, + value, + max_region + )); + } + } + if let Some(bits) = self.layout.init_data_permissions { + MemoryRegionFlags::from_bits(bits).ok_or_else(|| { + crate::new_error!( + "snapshot init_data_permissions {:#x} contains unknown flag bits", + bits + ) + })?; + } + + // Entrypoint address must point inside the guest snapshot + // region `[BASE_ADDRESS, BASE_ADDRESS + snapshot_size)`. The + // `Initialise` address is a GPA; the `Call` address is a GVA, + // bounded by the same range because guests identity-map the + // snapshot region at low VAs. A guest dispatching from a + // non-identity-mapped VA must relax this check. + let snap_lo = SandboxMemoryLayout::BASE_ADDRESS as u64; + let snap_hi = snap_lo + .checked_add(self.layout.snapshot_size as u64) + .ok_or_else(|| { + crate::new_error!( + "snapshot layout overflow: BASE_ADDRESS + snapshot_size ({}) does not fit in u64", + self.layout.snapshot_size + ) + })?; + let entry_addr = match &self.entrypoint { + Entrypoint::Initialise { addr } => *addr, + Entrypoint::Call { addr, .. } => *addr, + }; + if entry_addr < snap_lo || entry_addr >= snap_hi { + return Err(crate::new_error!( + "snapshot entrypoint addr {:#x} is outside the snapshot region [{:#x}, {:#x})", + entry_addr, + snap_lo, + snap_hi + )); + } + + // `stack_top_gva` is restored into the guest stack pointer. + // Bound it to the architecture's addressable GVA range so a + // malformed config cannot install an out-of-range stack + // pointer. The range is `(0, MAX_GVA]`. + let max_gva = hyperlight_common::layout::MAX_GVA as u64; + if self.stack_top_gva == 0 || self.stack_top_gva > max_gva { + return Err(crate::new_error!( + "snapshot stack_top_gva {:#x} is outside the valid range (0, {:#x}]", + self.stack_top_gva, + max_gva + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use hyperlight_common::flatbuffer_wrappers::function_types::{ParameterType, ReturnType}; + + use super::*; + + /// Build a `CommonSegmentRegister` whose every field holds a + /// distinct value, so a transposed field in the `Sregs` + /// conversion produces an inequality. + fn distinct_segment(start: u64) -> CommonSegmentRegister { + CommonSegmentRegister { + base: start, + limit: (start + 1) as u32, + selector: (start + 2) as u16, + type_: (start + 3) as u8, + present: (start + 4) as u8, + dpl: (start + 5) as u8, + db: (start + 6) as u8, + s: (start + 7) as u8, + l: (start + 8) as u8, + g: (start + 9) as u8, + avl: (start + 10) as u8, + unusable: (start + 11) as u8, + padding: (start + 12) as u8, + } + } + + fn distinct_table(start: u64) -> CommonTableRegister { + CommonTableRegister { + base: start, + limit: (start + 1) as u16, + } + } + + /// Special registers with a unique value in every field, including + /// a nonzero `cr3`. + fn distinct_sregs() -> CommonSpecialRegisters { + CommonSpecialRegisters { + cs: distinct_segment(10), + ds: distinct_segment(30), + es: distinct_segment(50), + fs: distinct_segment(70), + gs: distinct_segment(90), + ss: distinct_segment(110), + tr: distinct_segment(130), + ldt: distinct_segment(150), + gdt: distinct_table(170), + idt: distinct_table(180), + cr0: 200, + cr2: 201, + cr3: 202, + cr4: 203, + cr8: 204, + efer: 205, + apic_base: 206, + interrupt_bitmap: [207, 208, 209, 210], + } + } + + /// Round-tripping special registers through the serde mirror + /// preserves every field. `cr3` is the sole exception: it is + /// omitted from the config and recomputed at load, so it returns + /// as zero. + #[test] + fn sregs_round_trip_preserves_all_fields_except_cr3() { + let original = distinct_sregs(); + let restored: CommonSpecialRegisters = Sregs::from(&original).into(); + + let mut expected = original; + expected.cr3 = 0; + assert_eq!(restored, expected); + } + + /// Every `ParameterType` survives the round-trip through its serde + /// mirror, guarding against a transposed variant in either match. + #[test] + fn parameter_type_repr_round_trips_every_variant() { + let variants = [ + ParameterType::Int, + ParameterType::UInt, + ParameterType::Long, + ParameterType::ULong, + ParameterType::Float, + ParameterType::Double, + ParameterType::String, + ParameterType::Bool, + ParameterType::VecBytes, + ]; + for p in variants { + let back: ParameterType = ParameterTypeRepr::from(&p).into(); + assert_eq!(back, p, "parameter type {:?} did not round-trip", p); + } + } + + /// Every `ReturnType` survives the round-trip through its serde + /// mirror, guarding against a transposed variant in either match. + #[test] + fn return_type_repr_round_trips_every_variant() { + let variants = [ + ReturnType::Int, + ReturnType::UInt, + ReturnType::Long, + ReturnType::ULong, + ReturnType::Float, + ReturnType::Double, + ReturnType::String, + ReturnType::Bool, + ReturnType::Void, + ReturnType::VecBytes, + ]; + for r in variants { + let back: ReturnType = ReturnTypeRepr::from(&r).into(); + assert_eq!(back, r, "return type {:?} did not round-trip", r); + } + } +} + +#[cfg(test)] +mod schema_pin { + use super::*; + + const PINNED_INITIALISE: &str = r#"{ + "hyperlight_version": "x.y.z", + "arch": "x86_64", + "abi_version": 1, + "hypervisor": "kvm", + "stack_top_gva": 3735928559, + "entrypoint": { + "kind": "initialise", + "addr": 4096 + }, + "layout": { + "input_data_size": 1, + "output_data_size": 2, + "heap_size": 3, + "code_size": 4, + "init_data_size": 5, + "init_data_permissions": 7, + "scratch_size": 8, + "snapshot_size": 9, + "pt_size": 10 + }, + "memory_size": 65536, + "host_functions": [ + { + "function_name": "fn_int", + "parameter_types": [ + "int", + "u_int", + "long", + "u_long", + "float", + "double", + "string", + "bool", + "vec_bytes" + ], + "return_type": "int" + }, + { + "function_name": "fn_uint", + "parameter_types": [], + "return_type": "u_int" + }, + { + "function_name": "fn_long", + "parameter_types": [], + "return_type": "long" + }, + { + "function_name": "fn_ulong", + "parameter_types": [], + "return_type": "u_long" + }, + { + "function_name": "fn_float", + "parameter_types": [], + "return_type": "float" + }, + { + "function_name": "fn_double", + "parameter_types": [], + "return_type": "double" + }, + { + "function_name": "fn_string", + "parameter_types": [], + "return_type": "string" + }, + { + "function_name": "fn_bool", + "parameter_types": [], + "return_type": "bool" + }, + { + "function_name": "fn_void", + "parameter_types": [], + "return_type": "void" + }, + { + "function_name": "fn_vecbytes", + "parameter_types": [], + "return_type": "vec_bytes" + } + ], + "snapshot_generation": 42 +}"#; + + const PINNED_CALL: &str = r#"{ + "hyperlight_version": "x.y.z", + "arch": "x86_64", + "abi_version": 1, + "hypervisor": "mshv", + "stack_top_gva": 3735928559, + "entrypoint": { + "kind": "call", + "addr": 8192, + "sregs": { + "cs": { + "base": 1, + "limit": 2, + "selector": 3, + "type_": 4, + "present": 5, + "dpl": 6, + "db": 7, + "s": 8, + "l": 9, + "g": 10, + "avl": 11, + "unusable": 12, + "padding": 13 + }, + "ds": { + "base": 1, + "limit": 2, + "selector": 3, + "type_": 4, + "present": 5, + "dpl": 6, + "db": 7, + "s": 8, + "l": 9, + "g": 10, + "avl": 11, + "unusable": 12, + "padding": 13 + }, + "es": { + "base": 1, + "limit": 2, + "selector": 3, + "type_": 4, + "present": 5, + "dpl": 6, + "db": 7, + "s": 8, + "l": 9, + "g": 10, + "avl": 11, + "unusable": 12, + "padding": 13 + }, + "fs": { + "base": 1, + "limit": 2, + "selector": 3, + "type_": 4, + "present": 5, + "dpl": 6, + "db": 7, + "s": 8, + "l": 9, + "g": 10, + "avl": 11, + "unusable": 12, + "padding": 13 + }, + "gs": { + "base": 1, + "limit": 2, + "selector": 3, + "type_": 4, + "present": 5, + "dpl": 6, + "db": 7, + "s": 8, + "l": 9, + "g": 10, + "avl": 11, + "unusable": 12, + "padding": 13 + }, + "ss": { + "base": 1, + "limit": 2, + "selector": 3, + "type_": 4, + "present": 5, + "dpl": 6, + "db": 7, + "s": 8, + "l": 9, + "g": 10, + "avl": 11, + "unusable": 12, + "padding": 13 + }, + "tr": { + "base": 1, + "limit": 2, + "selector": 3, + "type_": 4, + "present": 5, + "dpl": 6, + "db": 7, + "s": 8, + "l": 9, + "g": 10, + "avl": 11, + "unusable": 12, + "padding": 13 + }, + "ldt": { + "base": 1, + "limit": 2, + "selector": 3, + "type_": 4, + "present": 5, + "dpl": 6, + "db": 7, + "s": 8, + "l": 9, + "g": 10, + "avl": 11, + "unusable": 12, + "padding": 13 + }, + "gdt": { + "base": 1, + "limit": 2 + }, + "idt": { + "base": 3, + "limit": 4 + }, + "cr0": 1, + "cr2": 2, + "cr4": 4, + "cr8": 5, + "efer": 6, + "apic_base": 7, + "interrupt_bitmap": [ + 8, + 9, + 10, + 11 + ] + } + }, + "layout": { + "input_data_size": 1, + "output_data_size": 2, + "heap_size": 3, + "code_size": 4, + "init_data_size": 5, + "init_data_permissions": null, + "scratch_size": 8, + "snapshot_size": 9, + "pt_size": null + }, + "memory_size": 65536, + "host_functions": [ + { + "function_name": "fn_void", + "parameter_types": [ + "bool" + ], + "return_type": "void" + } + ], + "snapshot_generation": 42 +}"#; + + const PINNED_ARCH: &str = r#"[ + "x86_64", + "aarch64" +]"#; + + const PINNED_HYPERVISOR: &str = r#"[ + "kvm", + "mshv", + "whp" +]"#; + + fn assert_round_trip(pinned: &str) { + let parsed: OciSnapshotConfig = + serde_json::from_str(pinned).expect("pinned JSON must deserialize"); + let actual = serde_json::to_string_pretty(&parsed).expect("serialize"); + assert_eq!( + actual.trim(), + pinned.trim(), + "Snapshot config JSON schema changed. If the change can break \ + existing snapshots on disk, bump `MT_CONFIG_V1` in \ + `super::media_types` and follow `docs/snapshot-versioning.md`. \ + Either way, paste the actual output below into the matching \ + `PINNED_*`.\n\nactual:\n{actual}" + ); + } + + #[test] + fn initialise_round_trip() { + assert_round_trip(PINNED_INITIALISE); + } + + #[test] + fn call_round_trip() { + assert_round_trip(PINNED_CALL); + } + + #[test] + fn arch_variants_round_trip() { + let parsed: Vec = + serde_json::from_str(PINNED_ARCH).expect("pinned arch JSON must deserialize"); + let actual = serde_json::to_string_pretty(&parsed).expect("serialize"); + assert_eq!(actual.trim(), PINNED_ARCH.trim(), "Arch variants changed."); + } + + #[test] + fn hypervisor_variants_round_trip() { + let parsed: Vec = serde_json::from_str(PINNED_HYPERVISOR) + .expect("pinned hypervisor JSON must deserialize"); + let actual = serde_json::to_string_pretty(&parsed).expect("serialize"); + assert_eq!( + actual.trim(), + PINNED_HYPERVISOR.trim(), + "Hypervisor variants changed." + ); + } +} diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/digest.rs b/src/hyperlight_host/src/sandbox/snapshot/file/digest.rs new file mode 100644 index 000000000..deb97ab54 --- /dev/null +++ b/src/hyperlight_host/src/sandbox/snapshot/file/digest.rs @@ -0,0 +1,118 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +use std::io::{Read, Seek, SeekFrom}; + +use oci_spec::image::Digest; +use sha2::{Digest as _, Sha256}; + +/// A `sha256:` digest as recorded in OCI manifests. The bare hex +/// (without prefix) is also the blob's filename inside `blobs/sha256/`. +#[derive(Clone)] +pub(super) struct Digest256 { + /// Lowercase hex of the 32-byte sha256 output. + pub(super) hex: String, +} + +impl Digest256 { + pub(super) fn from_bytes(bytes: &[u8]) -> Self { + let arr: [u8; 32] = Sha256::digest(bytes).into(); + Self::from_digest_array(arr) + } + + fn from_digest_array(arr: [u8; 32]) -> Self { + Self { + hex: hex::encode(arr), + } + } +} + +/// Build an `oci_spec::image::Digest` from a [`Digest256`]. +pub(super) fn oci_digest(d: &Digest256) -> crate::Result { + Digest::try_from(format!("sha256:{}", d.hex)) + .map_err(|e| crate::new_error!("failed to construct OCI digest: {}", e)) +} + +pub(super) fn parse_oci_digest(digest: &Digest) -> crate::Result { + let s = digest.as_ref(); + let rest = s.strip_prefix("sha256:").ok_or_else(|| { + crate::new_error!( + "OCI descriptor digest {:?} is not a sha256 digest (only sha256 is supported)", + s + ) + })?; + Ok(rest.to_string()) +} + +/// Compute sha256 of `bytes` and verify it equals `expected_hex`. +/// Used to validate manifest and config blobs (small, already in +/// memory). +pub(super) fn verify_blob_bytes( + label: &str, + bytes: &[u8], + expected_hex: &str, +) -> crate::Result<()> { + let actual = Digest256::from_bytes(bytes); + if actual.hex != expected_hex { + return Err(crate::new_error!( + "{} blob digest mismatch: descriptor declares sha256:{}, file hashes to sha256:{}", + label, + expected_hex, + actual.hex + )); + } + Ok(()) +} + +/// Stream-hash an already-open file and verify its sha256 equals +/// `expected_hex`. +/// +/// Takes the same `File` handle the caller will subsequently `mmap`, +/// not a path. Hashing one open and mapping another is open-then- +/// replace TOCTOU bait. Seeks to start before and after so the +/// caller's file position is unchanged. +pub(super) fn verify_blob_file( + label: &str, + file: &mut std::fs::File, + expected_hex: &str, +) -> crate::Result<()> { + file.seek(SeekFrom::Start(0)) + .map_err(|e| crate::new_error!("failed to seek {} blob: {}", label, e))?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = file + .read(&mut buf) + .map_err(|e| crate::new_error!("failed to read {} blob: {}", label, e))?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + file.seek(SeekFrom::Start(0)) + .map_err(|e| crate::new_error!("failed to rewind {} blob: {}", label, e))?; + let arr: [u8; 32] = hasher.finalize().into(); + let actual = Digest256::from_digest_array(arr); + if actual.hex != expected_hex { + return Err(crate::new_error!( + "{} blob digest mismatch: descriptor declares sha256:{}, file hashes to sha256:{}", + label, + expected_hex, + actual.hex + )); + } + Ok(()) +} diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/fsutil.rs b/src/hyperlight_host/src/sandbox/snapshot/file/fsutil.rs new file mode 100644 index 000000000..aa84fbfe8 --- /dev/null +++ b/src/hyperlight_host/src/sandbox/snapshot/file/fsutil.rs @@ -0,0 +1,139 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +use std::io::{Read, Write}; +use std::path::Path; + +use tempfile::NamedTempFile; + +use super::digest::{Digest256, verify_blob_file}; + +/// Replace `target` atomically: a reader either sees the old +/// contents or the full new contents, never a partial write. A +/// failure before commit leaves `target` untouched and removes the +/// staging file. +pub(super) fn replace_file_atomic(target: &Path, bytes: &[u8]) -> crate::Result<()> { + let parent = target.parent().ok_or_else(|| { + crate::new_error!("atomic write: target {:?} has no parent directory", target) + })?; + let mut tmp = NamedTempFile::new_in(parent).map_err(|e| { + crate::new_error!("atomic write: failed to create tmp in {:?}: {}", parent, e) + })?; + tmp.write_all(bytes).map_err(|e| { + crate::new_error!("atomic write: failed to write tmp {:?}: {}", tmp.path(), e) + })?; + tmp.as_file_mut().sync_all().map_err(|e| { + crate::new_error!("atomic write: failed to sync tmp {:?}: {}", tmp.path(), e) + })?; + tmp.persist(target).map_err(|e| { + crate::new_error!("atomic write: failed to persist tmp to {:?}: {}", target, e) + })?; + Ok(()) +} + +/// Write a content-addressed blob into `blobs_dir` unconditionally, +/// via [`replace_file_atomic`]. Intended for small blobs (manifest, +/// config) where the cost of an extra atomic write is negligible +/// compared to the cost of reading and re-hashing the existing file. +pub(super) fn put_blob(blobs_dir: &Path, digest: &Digest256, bytes: &[u8]) -> crate::Result<()> { + replace_file_atomic(&blobs_dir.join(&digest.hex), bytes) +} + +/// Write a content-addressed blob into `blobs_dir`, reusing the +/// existing file at `blobs_dir/` only if it is present, has the +/// expected length, AND hashes to `digest`. A wrong-content file of +/// the right length (corruption, partial copy, foreign tool) is +/// overwritten rather than silently trusted. +/// +/// Intended for the large snapshot blob, where the cost of one full +/// re-hash of the existing file is far less than the cost of an +/// unconditional rewrite. +pub(super) fn put_blob_if_absent( + blobs_dir: &Path, + digest: &Digest256, + bytes: &[u8], +) -> crate::Result<()> { + let target = blobs_dir.join(&digest.hex); + if let Ok(meta) = std::fs::symlink_metadata(&target) + && meta.is_file() + && meta.len() == bytes.len() as u64 + && let Ok(mut file) = std::fs::File::open(&target) + && verify_blob_file("existing snapshot", &mut file, &digest.hex).is_ok() + { + return Ok(()); + } + replace_file_atomic(&target, bytes) +} + +/// Reject a path that is a symbolic link. +/// +/// Blobs in an OCI layout are content-addressed regular files. A +/// symlink in their place could redirect a read outside the layout +/// directory, so refuse it before opening. +#[cfg(not(unix))] +pub(super) fn reject_symlink(path: &Path) -> crate::Result<()> { + let meta = std::fs::symlink_metadata(path) + .map_err(|e| crate::new_error!("failed to stat {:?}: {}", path, e))?; + if meta.file_type().is_symlink() { + return Err(crate::new_error!( + "{:?} is a symbolic link; refusing to follow it", + path + )); + } + Ok(()) +} + +/// Read a file in full, refusing if the file is bigger than `max_size`. +/// +/// The cap is enforced on the actual byte stream via [`Read::take`], so files +/// whose `metadata().len()` is misleading cannot exceed the limit. Symbolic +/// links are rejected rather than followed. +pub(super) fn read_bounded(path: &Path, max_size: u64) -> crate::Result> { + // On unix, `O_NOFOLLOW` rejects a final-component symlink in the + // same syscall that opens the file, so there is no window between + // a stat and the open for the path to be swapped. On other + // platforms, a stat-then-open pre-check is the available option. + #[cfg(unix)] + let f = { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(|e| crate::new_error!("failed to open {:?}: {}", path, e))? + }; + #[cfg(not(unix))] + let f = { + reject_symlink(path)?; + std::fs::File::open(path) + .map_err(|e| crate::new_error!("failed to open {:?}: {}", path, e))? + }; + let hint = f.metadata().map(|m| m.len().min(max_size)).unwrap_or(0); + let mut buf = Vec::with_capacity(hint as usize); + // Read one extra byte so an oversize file is detected as "over the + // limit" and rejected, never silently truncated to the cap. + f.take(max_size.saturating_add(1)) + .read_to_end(&mut buf) + .map_err(|e| crate::new_error!("failed to read {:?}: {}", path, e))?; + if buf.len() as u64 > max_size { + return Err(crate::new_error!( + "file {:?} exceeds maximum allowed {} bytes", + path, + max_size + )); + } + Ok(buf) +} diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs b/src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs new file mode 100644 index 000000000..31156a134 --- /dev/null +++ b/src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs @@ -0,0 +1,44 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Media types are versioned by suffix. The writer emits `_CURRENT`. +// The loader matches each version explicitly. See +// docs/snapshot-versioning.md for how to add a version. +pub(in crate::sandbox::snapshot) const MT_CONFIG_V1: &str = + "application/vnd.hyperlight.snapshot.config.v1+json"; +pub(in crate::sandbox::snapshot) const MT_CONFIG_CURRENT: &str = MT_CONFIG_V1; +pub(in crate::sandbox::snapshot) const MT_SNAPSHOT_V1: &str = + "application/vnd.hyperlight.snapshot.memory.v1"; +pub(in crate::sandbox::snapshot) const MT_SNAPSHOT_CURRENT: &str = MT_SNAPSHOT_V1; + +/// ABI version for the snapshot memory blob. Bumped when the +/// host-guest contract for the snapshot bytes changes. See +/// docs/snapshot-versioning.md. +pub(in crate::sandbox::snapshot) const SNAPSHOT_ABI_VERSION: u32 = 1; + +/// OCI standard annotation key for a manifest's tag inside an image +/// index. Set on the manifest descriptor in `index.json`, not on the +/// manifest blob itself. See the OCI Image Spec, "Annotations" and +/// the Image Layout spec. +pub(super) const ANNOTATION_REF_NAME: &str = "org.opencontainers.image.ref.name"; + +/// Advisory annotation keys recording the guest arch and hypervisor +/// backend on the manifest descriptor in `index.json`. These mirror +/// the authoritative `arch` and `hypervisor` fields in the config +/// blob so registry UIs and tools like `oras` and `skopeo` can show +/// them. The loader validates against the config blob. +pub(super) const ANNOTATION_ARCH: &str = "dev.hyperlight.snapshot.arch"; +pub(super) const ANNOTATION_HYPERVISOR: &str = "dev.hyperlight.snapshot.hypervisor"; diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs b/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs new file mode 100644 index 000000000..cfa859c51 --- /dev/null +++ b/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs @@ -0,0 +1,828 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! OCI Image Layout serde for [`Snapshot`]. See +//! `docs/snapshot-oci-format.md` for the on-disk format. + +mod config; +mod digest; +mod fsutil; +mod media_types; +pub(crate) mod reference; + +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; + +use hyperlight_common::flatbuffer_wrappers::host_function_details::HostFunctionDetails; +use hyperlight_common::vmem::PAGE_SIZE; +use oci_spec::image::{ + Descriptor, DescriptorBuilder, ImageIndex, ImageIndexBuilder, ImageManifest, + ImageManifestBuilder, MediaType, SCHEMA_VERSION, +}; + +use self::config::{ + Arch, Entrypoint, HostFunction, Hypervisor, MemoryLayout, OciSnapshotConfig, Sregs, +}; +use self::digest::{Digest256, oci_digest, parse_oci_digest, verify_blob_bytes, verify_blob_file}; +use self::fsutil::{put_blob, put_blob_if_absent, read_bounded, replace_file_atomic}; +use self::media_types::{ANNOTATION_ARCH, ANNOTATION_HYPERVISOR, ANNOTATION_REF_NAME}; +pub(super) use self::media_types::{ + MT_CONFIG_CURRENT, MT_CONFIG_V1, MT_SNAPSHOT_CURRENT, MT_SNAPSHOT_V1, SNAPSHOT_ABI_VERSION, +}; +use self::reference::{OciDigest, OciReference, OciTag}; +use super::{NextAction, Snapshot}; +use crate::hypervisor::regs::CommonSpecialRegisters; +use crate::mem::layout::SandboxMemoryLayout; +use crate::mem::memory_region::MemoryRegionFlags; +use crate::mem::shared_mem::{ReadonlySharedMemory, SharedMemory}; + +pub(super) const OCI_LAYOUT_VERSION: &str = "1.0.0"; + +/// Maximum size of any JSON blob read from disk during load: +/// `oci-layout`, `index.json`, the OCI image manifest, and the +/// Hyperlight config blob. Bounds the allocation done before parsing. +const MAX_JSON_BLOB_SIZE: u64 = 1024 * 1024; + +/// Select one manifest descriptor from `index` by `reference`. +/// +/// A tag matches the `org.opencontainers.image.ref.name` annotation +/// and must be unique. A digest matches the manifest content digest. +/// Identical manifests shared across tags select the first, since +/// they are byte-for-byte equal. +fn select_manifest<'a>( + index: &'a ImageIndex, + reference: &OciReference, + path: &Path, +) -> crate::Result<&'a Descriptor> { + match reference { + OciReference::Tag(tag) => { + let mut matching = index.manifests().iter().filter(|d| { + d.annotations() + .as_ref() + .and_then(|a| a.get(ANNOTATION_REF_NAME)) + .map(|s| s.as_str() == tag.as_str()) + .unwrap_or(false) + }); + match (matching.next(), matching.next()) { + (None, _) => { + let known: Vec<&str> = index + .manifests() + .iter() + .filter_map(|d| { + d.annotations() + .as_ref() + .and_then(|a| a.get(ANNOTATION_REF_NAME)) + .map(|s| s.as_str()) + }) + .collect(); + Err(crate::new_error!( + "no manifest tagged {:?} in OCI layout {:?}. Available tags: {:?}", + tag.as_str(), + path, + known + )) + } + (Some(_), Some(_)) => Err(crate::new_error!( + "OCI layout {:?} has multiple manifests tagged {:?}; tags must be unique", + path, + tag.as_str() + )), + (Some(d), None) => Ok(d), + } + } + OciReference::Digest(digest) => index + .manifests() + .iter() + .find(|d| d.digest().to_string() == digest.as_str()) + .ok_or_else(|| { + crate::new_error!( + "no manifest with digest {} in OCI layout {:?}", + digest.as_str(), + path + ) + }), + } +} + +fn read_layout_marker(path: &Path) -> crate::Result<()> { + let layout_bytes = read_bounded(&path.join("oci-layout"), MAX_JSON_BLOB_SIZE) + .map_err(|e| crate::new_error!("failed to read oci-layout: {}", e))?; + let layout_json: serde_json::Value = serde_json::from_slice(&layout_bytes) + .map_err(|e| crate::new_error!("oci-layout is not valid JSON: {}", e))?; + let v = layout_json + .get("imageLayoutVersion") + .and_then(|v| v.as_str()) + .ok_or_else(|| crate::new_error!("oci-layout missing imageLayoutVersion field"))?; + if v != OCI_LAYOUT_VERSION { + return Err(crate::new_error!( + "unsupported OCI image layout version {:?} (expected {:?})", + v, + OCI_LAYOUT_VERSION + )); + } + Ok(()) +} + +fn load_manifest( + path: &Path, + blobs_dir: &Path, + reference: &OciReference, + verify_blobs: bool, +) -> crate::Result { + let index_bytes = read_bounded(&path.join("index.json"), MAX_JSON_BLOB_SIZE) + .map_err(|e| crate::new_error!("failed to read index.json: {}", e))?; + let index: ImageIndex = serde_json::from_slice(&index_bytes) + .map_err(|e| crate::new_error!("failed to parse index.json: {}", e))?; + let manifest_desc = select_manifest(&index, reference, path)?; + if !matches!(manifest_desc.media_type(), MediaType::ImageManifest) { + return Err(crate::new_error!( + "manifest descriptor for {} has unexpected media type {:?} (expected {:?})", + reference, + manifest_desc.media_type().to_string(), + MediaType::ImageManifest.to_string() + )); + } + let manifest_hex = parse_oci_digest(manifest_desc.digest())?; + let manifest_path = blobs_dir.join(&manifest_hex); + let manifest_bytes = read_bounded(&manifest_path, MAX_JSON_BLOB_SIZE)?; + if manifest_bytes.len() as u64 != manifest_desc.size() { + return Err(crate::new_error!( + "OCI manifest size mismatch: descriptor says {}, file is {}", + manifest_desc.size(), + manifest_bytes.len() + )); + } + if verify_blobs { + verify_blob_bytes("manifest", &manifest_bytes, &manifest_hex)?; + } + let manifest: ImageManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|e| crate::new_error!("failed to parse OCI manifest JSON: {}", e))?; + if manifest.schema_version() != SCHEMA_VERSION { + return Err(crate::new_error!( + "unsupported OCI manifest schemaVersion {} (expected {})", + manifest.schema_version(), + SCHEMA_VERSION + )); + } + Ok(manifest) +} + +fn load_config( + blobs_dir: &Path, + cfg_desc: &Descriptor, + verify_blobs: bool, +) -> crate::Result { + let cfg_hex = parse_oci_digest(cfg_desc.digest())?; + let cfg_path = blobs_dir.join(&cfg_hex); + let cfg_bytes = read_bounded(&cfg_path, MAX_JSON_BLOB_SIZE)?; + if cfg_bytes.len() as u64 != cfg_desc.size() { + return Err(crate::new_error!( + "config blob size mismatch: descriptor says {}, file is {}", + cfg_desc.size(), + cfg_bytes.len() + )); + } + if verify_blobs { + verify_blob_bytes("config", &cfg_bytes, &cfg_hex)?; + } + let cfg: OciSnapshotConfig = serde_json::from_slice(&cfg_bytes) + .map_err(|e| crate::new_error!("failed to parse Hyperlight config JSON: {}", e))?; + cfg.validate_for_load()?; + Ok(cfg) +} + +fn open_snapshot_blob( + blobs_dir: &Path, + snap_desc: &Descriptor, + expected_blob_len: u64, + verify_blobs: bool, +) -> crate::Result { + let snap_hex = parse_oci_digest(snap_desc.digest())?; + let snap_path = blobs_dir.join(&snap_hex); + + #[cfg(unix)] + let mut snap_file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(&snap_path) + .map_err(|e| crate::new_error!("failed to open snapshot blob {:?}: {}", snap_path, e))?; + + #[cfg(not(unix))] + let mut snap_file = { + self::fsutil::reject_symlink(&snap_path)?; + std::fs::File::open(&snap_path) + .map_err(|e| crate::new_error!("failed to open snapshot blob {:?}: {}", snap_path, e))? + }; + + let snap_file_len = snap_file + .metadata() + .map_err(|e| crate::new_error!("failed to stat snapshot blob: {}", e))? + .len(); + if snap_file_len != expected_blob_len { + return Err(crate::new_error!( + "snapshot blob size mismatch: file is {} bytes, expected {} (memory_size)", + snap_file_len, + expected_blob_len, + )); + } + if snap_file_len != snap_desc.size() { + return Err(crate::new_error!( + "snapshot blob size {} disagrees with OCI descriptor size {}", + snap_file_len, + snap_desc.size() + )); + } + if verify_blobs { + verify_blob_file("snapshot", &mut snap_file, &snap_hex)?; + } + Ok(snap_file) +} + +impl Snapshot { + /// Save this snapshot into an OCI Image Layout directory on disk. + /// The saved snapshot can be loaded later with + /// [`Snapshot::from_oci`]. + /// + /// Returns the [`OciDigest`] of the manifest that was written, + /// which [`Snapshot::from_oci`] accepts as a stable handle to + /// this exact snapshot. + /// + /// # `path` + /// + /// The OCI Image Layout directory to write to. The directory at + /// `path` is created if it does not exist, and its parent + /// directory must already exist. + /// + /// If `path` does not yet hold an OCI layout, a new one is + /// created. If it already holds one, this snapshot is added + /// alongside the snapshots already there. If `path` holds + /// something that is not a readable OCI layout, the call fails + /// and the directory is left unchanged. + /// + /// # `tag` + /// + /// A standard OCI tag that names this snapshot within the layout. + /// [`Snapshot::from_oci`] can load the snapshot back by this tag. + /// + /// A tag points to one snapshot at a time. If the layout already + /// has a snapshot under this tag, the tag is moved to the new + /// snapshot. The old snapshot's data stays on disk, reachable by + /// its digest but not by this tag. Snapshots under other tags are + /// untouched. + /// + /// # Portability + /// + /// Snapshot images are bound to the specific CPU architecture and + /// hypervisor that the snapshot was created on. For example, a + /// snapshot taken on x86_64 with KVM can only be loaded on an + /// x86_64 host running KVM. + pub fn to_oci(&self, path: impl AsRef, tag: &OciTag) -> crate::Result { + let path = path.as_ref(); + + // The parent directory must already exist. `path` itself is + // created if absent. An existing regular file at `path` is + // rejected by the underlying `create_dir`. + match path.parent() { + Some(p) if !p.as_os_str().is_empty() => { + let parent_meta = std::fs::metadata(p).map_err(|e| { + crate::new_error!("to_oci: parent directory {:?} not accessible: {}", p, e) + })?; + if !parent_meta.is_dir() { + return Err(crate::new_error!( + "to_oci: parent of {:?} is not a directory", + path + )); + } + } + _ => {} + } + match std::fs::create_dir(path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + let meta = std::fs::metadata(path) + .map_err(|e| crate::new_error!("to_oci: failed to stat {:?}: {}", path, e))?; + if !meta.is_dir() { + return Err(crate::new_error!( + "to_oci: {:?} exists and is not a directory", + path + )); + } + } + Err(e) => { + return Err(crate::new_error!( + "to_oci: failed to create layout dir {:?}: {}", + path, + e + )); + } + } + + // Validate any pre-existing `oci-layout` marker before + // touching anything else, so a foreign layout (future + // version, hand-edited file) is reported without altering + // the directory. + let layout_marker = path.join("oci-layout"); + let marker_existed = layout_marker + .try_exists() + .map_err(|e| crate::new_error!("to_oci: failed to stat {:?}: {}", layout_marker, e))?; + if marker_existed { + let bytes = read_bounded(&layout_marker, MAX_JSON_BLOB_SIZE).map_err(|e| { + crate::new_error!("to_oci: failed to read existing oci-layout: {}", e) + })?; + let v: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| { + crate::new_error!("to_oci: existing oci-layout is not valid JSON: {}", e) + })?; + match v.get("imageLayoutVersion").and_then(|s| s.as_str()) { + Some(s) if s == OCI_LAYOUT_VERSION => {} + Some(other) => { + return Err(crate::new_error!( + "to_oci: existing imageLayoutVersion {:?} is unsupported (expected {:?})", + other, + OCI_LAYOUT_VERSION + )); + } + None => { + return Err(crate::new_error!( + "to_oci: existing oci-layout is missing imageLayoutVersion" + )); + } + } + } + + let index_path = path.join("index.json"); + let index_existed = index_path + .try_exists() + .map_err(|e| crate::new_error!("to_oci: failed to stat {:?}: {}", index_path, e))?; + let mut manifests: Vec = if index_existed { + let bytes = read_bounded(&index_path, MAX_JSON_BLOB_SIZE).map_err(|e| { + crate::new_error!("to_oci: failed to read existing index.json: {}", e) + })?; + let existing: ImageIndex = serde_json::from_slice(&bytes).map_err(|e| { + crate::new_error!( + "to_oci: existing index.json is not a valid OCI image index: {}", + e + ) + })?; + existing.manifests().to_vec() + } else { + Vec::new() + }; + + let new_desc = self.write_blobs_and_build_descriptor(path, tag)?; + let written_digest = OciDigest::from_oci_spec_digest(new_desc.digest()); + + // Replacement is by tag, not by digest: a new snapshot may + // hash to a different value but still claim the same logical + // ref. Blobs from the replaced manifest become orphans. + manifests.retain(|d| { + d.annotations() + .as_ref() + .and_then(|a| a.get(ANNOTATION_REF_NAME)) + .map(|s| s.as_str() != tag.as_str()) + .unwrap_or(true) + }); + manifests.push(new_desc); + + let index = ImageIndexBuilder::default() + .schema_version(SCHEMA_VERSION) + .media_type(MediaType::ImageIndex) + .manifests(manifests) + .build() + .map_err(|e| crate::new_error!("failed to build OCI index: {}", e))?; + let index_bytes = serde_json::to_vec_pretty(&index) + .map_err(|e| crate::new_error!("failed to serialise OCI index: {}", e))?; + + // Write the marker before the index swap. A loader that sees + // the new index requires the marker; ordering them this way + // keeps the layout valid at every step. + if !marker_existed { + let layout_bytes = serde_json::to_vec(&serde_json::json!({ + "imageLayoutVersion": OCI_LAYOUT_VERSION, + })) + .map_err(|e| crate::new_error!("failed to serialise oci-layout: {}", e))?; + replace_file_atomic(&layout_marker, &layout_bytes)?; + } + + // Index swap is the commit point. + replace_file_atomic(&index_path, &index_bytes)?; + + Ok(written_digest) + } + + fn write_blobs_and_build_descriptor( + &self, + dir: &Path, + tag: &OciTag, + ) -> crate::Result { + let blobs_dir = dir.join("blobs").join("sha256"); + std::fs::create_dir_all(&blobs_dir).map_err(|e| { + crate::new_error!("failed to create OCI blobs dir {:?}: {}", blobs_dir, e) + })?; + + // Snapshot blob: the raw memory bytes. + let memory_bytes = self.memory.as_slice(); + let memory_size = memory_bytes.len(); + if memory_size == 0 || !memory_size.is_multiple_of(PAGE_SIZE) { + return Err(crate::new_error!( + "snapshot memory size {} must be a non-zero multiple of PAGE_SIZE", + memory_size + )); + } + let snapshot_digest = Digest256::from_bytes(memory_bytes); + put_blob_if_absent(&blobs_dir, &snapshot_digest, memory_bytes)?; + + // Config blob. + let cfg = self.build_config()?; + let cfg_bytes = serde_json::to_vec_pretty(&cfg) + .map_err(|e| crate::new_error!("failed to serialise config JSON: {}", e))?; + let cfg_digest = Digest256::from_bytes(&cfg_bytes); + put_blob(&blobs_dir, &cfg_digest, &cfg_bytes)?; + + // Manifest blob. + let config_descriptor = DescriptorBuilder::default() + .media_type(MediaType::Other(MT_CONFIG_CURRENT.to_string())) + .digest(oci_digest(&cfg_digest)?) + .size(cfg_bytes.len() as u64) + .build() + .map_err(|e| crate::new_error!("failed to build config descriptor: {}", e))?; + let snapshot_descriptor = DescriptorBuilder::default() + .media_type(MediaType::Other(MT_SNAPSHOT_CURRENT.to_string())) + .digest(oci_digest(&snapshot_digest)?) + .size(memory_size as u64) + .build() + .map_err(|e| crate::new_error!("failed to build snapshot descriptor: {}", e))?; + // `artifactType` is set equal to `config.mediaType` per OCI + // image-spec "Guidelines for Artifact Usage". Registries + // surface this on the distribution-spec referrers API. Tools + // that read only `config.mediaType` see the same value. + let manifest = ImageManifestBuilder::default() + .schema_version(SCHEMA_VERSION) + .media_type(MediaType::ImageManifest) + .artifact_type(MediaType::Other(MT_CONFIG_CURRENT.to_string())) + .config(config_descriptor) + .layers(vec![snapshot_descriptor]) + .build() + .map_err(|e| crate::new_error!("failed to build OCI manifest: {}", e))?; + let manifest_bytes = serde_json::to_vec_pretty(&manifest) + .map_err(|e| crate::new_error!("failed to serialise OCI manifest: {}", e))?; + let manifest_digest = Digest256::from_bytes(&manifest_bytes); + put_blob(&blobs_dir, &manifest_digest, &manifest_bytes)?; + + let mut anns = std::collections::HashMap::new(); + anns.insert(ANNOTATION_REF_NAME.to_string(), tag.as_str().to_string()); + anns.insert(ANNOTATION_ARCH.to_string(), cfg.arch.as_str().to_string()); + anns.insert( + ANNOTATION_HYPERVISOR.to_string(), + cfg.hypervisor.as_str().to_string(), + ); + DescriptorBuilder::default() + .media_type(MediaType::ImageManifest) + .digest(oci_digest(&manifest_digest)?) + .size(manifest_bytes.len() as u64) + .annotations(anns) + .build() + .map_err(|e| crate::new_error!("failed to build manifest descriptor: {}", e)) + } + + fn build_config(&self) -> crate::Result { + let entrypoint = match (self.entrypoint, self.sregs.as_ref()) { + (NextAction::Initialise(addr), None) => Entrypoint::Initialise { addr }, + (NextAction::Call(addr), Some(sregs)) => Entrypoint::Call { + addr, + sregs: Box::new(Sregs::from(sregs)), + }, + (NextAction::Initialise(_), Some(_)) => { + return Err(crate::new_error!( + "snapshot inconsistent: Initialise entrypoint must not have sregs" + )); + } + (NextAction::Call(_), None) => { + return Err(crate::new_error!( + "snapshot inconsistent: Call entrypoint must have sregs" + )); + } + #[cfg(test)] + (NextAction::None, _) => { + return Err(crate::new_error!( + "snapshot with NextAction::None cannot be persisted" + )); + } + }; + + let host_functions = match &self.host_functions.host_functions { + Some(v) => v.iter().map(HostFunction::from).collect(), + None => Vec::new(), + }; + + let l = &self.layout; + Ok(OciSnapshotConfig { + hyperlight_version: env!("CARGO_PKG_VERSION").to_string(), + arch: Arch::current(), + abi_version: SNAPSHOT_ABI_VERSION, + hypervisor: Hypervisor::current() + .ok_or_else(|| crate::new_error!("no hypervisor available to tag snapshot"))?, + stack_top_gva: self.stack_top_gva, + entrypoint, + layout: MemoryLayout { + input_data_size: l.input_data_size, + output_data_size: l.output_data_size, + heap_size: l.heap_size, + code_size: l.code_size, + init_data_size: l.init_data_size, + init_data_permissions: l.init_data_permissions.map(|f| f.bits()), + scratch_size: l.get_scratch_size(), + snapshot_size: l.snapshot_size, + pt_size: l.pt_size, + }, + memory_size: self.memory.mem_size() as u64, + host_functions, + snapshot_generation: self.snapshot_generation, + }) + } + + /// Load a snapshot from an OCI Image Layout directory produced by + /// [`Snapshot::to_oci`]. + /// + /// # `path` + /// + /// The OCI Image Layout directory to read from. It must hold a + /// readable OCI layout containing at least one Hyperlight + /// snapshot. + /// + /// # `reference` + /// + /// Which snapshot in the layout to load, named by either an + /// [`OciTag`] or an [`OciDigest`]. A tag matches the name the + /// snapshot was saved under. A digest matches the value returned + /// by [`Snapshot::to_oci`]. Loading fails if the reference + /// matches no snapshot. + /// + /// # Portability + /// + /// Snapshot images are bound to the specific CPU architecture and + /// hypervisor that the snapshot was created on. For example, a + /// snapshot taken on x86_64 with KVM can only be loaded on an + /// x86_64 host running KVM. + /// + /// # Verification + /// + /// The manifest, config, and snapshot blobs are checked against + /// their recorded sha256 digests before use, guarding against + /// accidental corruption on disk. For a trusted layout where + /// loading speed matters, [`Snapshot::from_oci_unchecked`] can + /// skip this check. + /// + /// # File-mutation hazard + /// + /// The snapshot blob stays memory-mapped while the returned + /// `Snapshot` or any sandbox built from it is alive. The existing + /// blob files in the layout at `path` must not be overwritten, + /// truncated, or deleted while the mapping is live. Doing so can + /// corrupt guest memory and can lead to undefined behavior. + pub fn from_oci( + path: impl AsRef, + reference: impl Into, + ) -> crate::Result { + Self::from_oci_inner(path.as_ref(), &reference.into(), true) + } + + /// Like [`Snapshot::from_oci`] but skips the sha256 digest check + /// on the three content-addressed blobs under + /// `path/blobs/sha256/`: the OCI image manifest, the Hyperlight + /// config JSON, and the raw snapshot memory image. + /// + /// Skipping the check makes loading faster. The snapshot blob is + /// mapped straight into the guest rather than being read and + /// hashed in full first, which matters most for large snapshots. + /// + /// # Safety + /// + /// The sha256 check that this method skips guards against + /// accidental corruption of the blobs, not against tampering. An + /// attacker who rewrites a blob can recompute its digest to + /// match, so the check passing does not prove the bytes are + /// authentic. The caller must therefore ensure the layout at + /// `path` comes from a trusted source and was not modified after + /// it was produced, the same requirement [`Snapshot::from_oci`] + /// relies on for safety. + /// + /// The file-mutation hazard documented on [`Snapshot::from_oci`] + /// applies here too, since the snapshot blob is mapped the same + /// way. + pub unsafe fn from_oci_unchecked( + path: impl AsRef, + reference: impl Into, + ) -> crate::Result { + Self::from_oci_inner(path.as_ref(), &reference.into(), false) + } + + fn from_oci_inner( + path: &Path, + reference: &OciReference, + verify_blobs: bool, + ) -> crate::Result { + let meta = std::fs::metadata(path) + .map_err(|e| crate::new_error!("from_oci failed to stat {:?}: {}", path, e))?; + if !meta.is_dir() { + return Err(crate::new_error!( + "from_oci path {:?} is not a directory", + path + )); + } + + let blobs_dir: PathBuf = path.join("blobs").join("sha256"); + + // 1. oci-layout + read_layout_marker(path)?; + + // 2. index.json -> manifest descriptor for `reference`. + // Multiple manifests are valid in an OCI Image Layout. A + // tag selects the one whose + // `org.opencontainers.image.ref.name` annotation matches it + // (two manifests sharing a tag is a malformed layout). A + // digest selects the descriptor carrying that manifest + // digest. + let manifest = load_manifest(path, &blobs_dir, reference, verify_blobs)?; + let cfg_desc = manifest.config(); + // Loader dispatch on config media type. A future v2 lands + // as a new arm that converts to the in-memory current shape. + let cfg_media = cfg_desc.media_type().to_string(); + match cfg_media.as_str() { + MT_CONFIG_V1 => {} + other => { + return Err(crate::new_error!( + "unexpected config media type {:?} (supported: {:?})", + other, + MT_CONFIG_V1 + )); + } + } + // `artifactType` mirrors `config.mediaType` (manifest.md + // "Guidelines for Artifact Usage"). The OCI spec leaves this + // field OPTIONAL. A Hyperlight snapshot requires it to be + // present and equal to `config.mediaType` so loaders can + // distinguish a Hyperlight artifact from an arbitrary + // manifest that happens to share blob layout. + match manifest.artifact_type() { + Some(at) if at.to_string() == cfg_media => {} + Some(at) => { + return Err(crate::new_error!( + "OCI manifest artifactType {:?} does not match config media type {:?}", + at.to_string(), + cfg_media + )); + } + None => { + return Err(crate::new_error!( + "OCI manifest is missing required artifactType (expected {:?})", + cfg_media + )); + } + } + let layers = manifest.layers(); + if layers.len() != 1 { + return Err(crate::new_error!( + "expected exactly one OCI layer (the snapshot), found {}", + layers.len() + )); + } + let snap_desc = &layers[0]; + let snap_media = snap_desc.media_type().to_string(); + match snap_media.as_str() { + MT_SNAPSHOT_V1 => {} + other => { + return Err(crate::new_error!( + "unexpected snapshot layer media type {:?} (supported: {:?})", + other, + MT_SNAPSHOT_V1 + )); + } + } + + // 4. config blob + let cfg = load_config(&blobs_dir, cfg_desc, verify_blobs)?; + + // 5. snapshot blob: open once, hash and mmap the same + // handle so an attacker cannot swap the file between + // verification and mapping. + let snap_file = open_snapshot_blob(&blobs_dir, snap_desc, cfg.memory_size, verify_blobs)?; + + // 6. Reconstruct layout. + let mut sbox_cfg = crate::sandbox::SandboxConfiguration::default(); + sbox_cfg.set_input_data_size(cfg.layout.input_data_size); + sbox_cfg.set_output_data_size(cfg.layout.output_data_size); + sbox_cfg.set_heap_size(cfg.layout.heap_size as u64); + sbox_cfg.set_scratch_size(cfg.layout.scratch_size); + let init_data_perms = match cfg.layout.init_data_permissions { + None => None, + Some(bits) => Some(MemoryRegionFlags::from_bits(bits).ok_or_else(|| { + crate::new_error!( + "snapshot init_data_permissions {:#x} contains unknown flag bits", + bits + ) + })?), + }; + let mut layout = SandboxMemoryLayout::new( + sbox_cfg, + cfg.layout.code_size, + cfg.layout.init_data_size, + init_data_perms, + )?; + // `snapshot_size` and `pt_size` are independent fields. + if let Some(pt) = cfg.layout.pt_size { + layout.set_pt_size(pt)?; + } + layout.set_snapshot_size(cfg.layout.snapshot_size); + + // `snapshot_size` is the guest-visible prefix mapped into the + // snapshot region. It must cover at least the regions the + // layout fields describe (code, PEB, heap, init data), + // otherwise the guest mapping is too short to back them. The + // `snapshot_size + pt_size == memory_size` invariant alone + // does not bound `snapshot_size` from below, since a smaller + // `snapshot_size` can be offset by a larger `pt_size`. + let required_memory_size = layout.get_memory_size()? as u64; + if (layout.snapshot_size as u64) < required_memory_size { + return Err(crate::new_error!( + "snapshot snapshot_size ({}) is smaller than the layout size ({})", + layout.snapshot_size, + required_memory_size + )); + } + + // 7. mmap the snapshot blob (file-backed CoW). The blob is + // the raw memory image. `ReadonlySharedMemory::from_file` + // surrounds it with host guard pages. The guest mapping + // of the snapshot region covers only the data prefix + // (`snapshot_size`). The PT tail sits past that prefix + // in the host mapping and is copied into the scratch + // region on restore. Keeping it out of the guest mapping + // of the snapshot region avoids overlap with + // `map_file_cow` regions installed immediately after the + // snapshot in guest PA space. + let memory = ReadonlySharedMemory::from_file(&snap_file, layout.snapshot_size)?; + + // The size validation in `open_snapshot_blob` stats the file + // before mapping. Nothing prevents the file from being + // truncated between that stat and the mmap, which would leave + // the mapping shorter than the config claims and make restore + // read past the end. Compare the mapped length against + // `memory_size` to reject a file mutated under us. + if memory.mem_size() as u64 != cfg.memory_size { + return Err(crate::new_error!( + "mapped snapshot size ({}) does not match config memory_size ({}); the blob may have changed during loading", + memory.mem_size(), + cfg.memory_size + )); + } + + // 8. Build entrypoint + sregs back from the tagged enum. + let (entrypoint, sregs) = match cfg.entrypoint { + Entrypoint::Initialise { addr } => (NextAction::Initialise(addr), None), + Entrypoint::Call { addr, sregs } => ( + NextAction::Call(addr), + Some(CommonSpecialRegisters::from(*sregs)), + ), + }; + + // 9. Reconstitute host_functions metadata. + let snapshot_generation = cfg.snapshot_generation; + let host_funcs_vec: Vec< + hyperlight_common::flatbuffer_wrappers::host_function_definition::HostFunctionDefinition, + > = cfg.host_functions.into_iter().map(Into::into).collect(); + let host_functions = if host_funcs_vec.is_empty() { + HostFunctionDetails { + host_functions: None, + } + } else { + HostFunctionDetails { + host_functions: Some(host_funcs_vec), + } + }; + + Ok(Snapshot { + layout, + memory, + load_info: crate::mem::exe::LoadInfo::dummy(), + stack_top_gva: cfg.stack_top_gva, + sregs, + entrypoint, + snapshot_generation, + host_functions, + }) + } +} diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/reference.rs b/src/hyperlight_host/src/sandbox/snapshot/file/reference.rs new file mode 100644 index 000000000..550e1b4af --- /dev/null +++ b/src/hyperlight_host/src/sandbox/snapshot/file/reference.rs @@ -0,0 +1,232 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Validated identifiers for snapshots stored in an OCI Image Layout: +//! a human-readable tag, a content digest, and the reference enum that +//! selects a manifest by either one. + +use std::fmt; +use std::str::FromStr; + +use oci_spec::image::{Digest as OciSpecDigest, DigestAlgorithm}; + +/// A tag naming one snapshot inside an OCI Image Layout directory. +/// +/// Written to `index.json` as `org.opencontainers.image.ref.name` and +/// constrained to the OCI Distribution grammar +/// `[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}` so the same value works in a +/// local layout and when pushed to a registry via `oras`, `crane`, or +/// `skopeo`. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct OciTag(String); + +impl OciTag { + /// Construct a tag, validating it against the OCI Distribution + /// grammar `[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}`. Returns an error + /// if the input does not match. + pub fn new(tag: impl Into) -> crate::Result { + Self::try_from(tag.into()) + } + + /// The tag as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +fn validate_tag(tag: &str) -> crate::Result<()> { + let bytes = tag.as_bytes(); + if bytes.is_empty() || bytes.len() > 128 { + return Err(crate::new_error!( + "tag {:?} is invalid: must be 1..=128 bytes", + tag + )); + } + let first = bytes[0]; + if !(first.is_ascii_alphanumeric() || first == b'_') { + return Err(crate::new_error!( + "tag {:?} is invalid: first character must be alphanumeric or '_'", + tag + )); + } + for &b in &bytes[1..] { + if !(b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'-') { + return Err(crate::new_error!( + "tag {:?} is invalid: characters after the first must be \ + alphanumeric or one of '_', '.', '-'", + tag + )); + } + } + Ok(()) +} + +impl FromStr for OciTag { + type Err = crate::HyperlightError; + + fn from_str(s: &str) -> crate::Result { + validate_tag(s)?; + Ok(Self(s.to_string())) + } +} + +impl TryFrom<&str> for OciTag { + type Error = crate::HyperlightError; + + fn try_from(s: &str) -> crate::Result { + s.parse() + } +} + +impl TryFrom for OciTag { + type Error = crate::HyperlightError; + + fn try_from(s: String) -> crate::Result { + validate_tag(&s)?; + Ok(Self(s)) + } +} + +impl AsRef for OciTag { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for OciTag { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// A sha256 content digest in canonical `sha256:<64 lowercase hex>` +/// form, identifying a manifest by its bytes. +/// +/// [`Snapshot::to_oci`] returns the digest of the manifest it wrote, +/// and [`Snapshot::from_oci`] accepts one to load that exact manifest +/// regardless of which tags point at it. +/// +/// [`Snapshot::to_oci`]: crate::sandbox::snapshot::Snapshot::to_oci +/// [`Snapshot::from_oci`]: crate::sandbox::snapshot::Snapshot::from_oci +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct OciDigest(String); + +impl OciDigest { + /// The digest as a `sha256:` string slice. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Wrap a validated `oci-spec` digest. The caller guarantees it + /// uses the sha256 algorithm. + pub(super) fn from_oci_spec_digest(digest: &OciSpecDigest) -> Self { + Self(digest.to_string()) + } +} + +fn validate_digest(s: &str) -> crate::Result { + let digest = OciSpecDigest::from_str(s) + .map_err(|e| crate::new_error!("invalid OCI digest {:?}: {}", s, e))?; + if digest.algorithm() != &DigestAlgorithm::Sha256 { + return Err(crate::new_error!( + "OCI digest {:?} must use the sha256 algorithm, found {}", + s, + digest.algorithm() + )); + } + Ok(digest.to_string()) +} + +impl FromStr for OciDigest { + type Err = crate::HyperlightError; + + fn from_str(s: &str) -> crate::Result { + Ok(Self(validate_digest(s)?)) + } +} + +impl TryFrom<&str> for OciDigest { + type Error = crate::HyperlightError; + + fn try_from(s: &str) -> crate::Result { + s.parse() + } +} + +impl TryFrom for OciDigest { + type Error = crate::HyperlightError; + + fn try_from(s: String) -> crate::Result { + Ok(Self(validate_digest(&s)?)) + } +} + +impl AsRef for OciDigest { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for OciDigest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// A selector for one snapshot manifest in an OCI Image Layout: +/// either a tag or a content digest. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum OciReference { + /// Select by `org.opencontainers.image.ref.name` annotation. + Tag(OciTag), + /// Select by manifest content digest. + Digest(OciDigest), +} + +impl From for OciReference { + fn from(tag: OciTag) -> Self { + OciReference::Tag(tag) + } +} + +impl From for OciReference { + fn from(digest: OciDigest) -> Self { + OciReference::Digest(digest) + } +} + +impl FromStr for OciReference { + type Err = crate::HyperlightError; + + /// Parse a tag or a digest. A `:` marks a digest, since the tag + /// grammar forbids that character. + fn from_str(s: &str) -> crate::Result { + if s.contains(':') { + Ok(OciReference::Digest(s.parse()?)) + } else { + Ok(OciReference::Tag(s.parse()?)) + } + } +} + +impl fmt::Display for OciReference { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OciReference::Tag(t) => fmt::Display::fmt(t, f), + OciReference::Digest(d) => fmt::Display::fmt(d, f), + } + } +} diff --git a/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs new file mode 100644 index 000000000..42504000b --- /dev/null +++ b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs @@ -0,0 +1,2675 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Tests for the OCI Image Layout snapshot format (`super::file`). + +#![cfg(test)] + +use std::sync::Arc; + +use hyperlight_testing::simple_guest_as_string; +use serde_json::Value; +use sha2::{Digest as _, Sha256}; + +use crate::func::Registerable; +use crate::sandbox::snapshot::{OciDigest, OciReference, OciTag, Snapshot}; +use crate::{GuestBinary, HostFunctions, MultiUseSandbox, UninitializedSandbox}; + +fn create_test_sandbox() -> MultiUseSandbox { + let path = simple_guest_as_string().unwrap(); + UninitializedSandbox::new(GuestBinary::FilePath(path), None) + .unwrap() + .evolve() + .unwrap() +} + +/// Parse a string into an [`OciTag`], panicking on an invalid tag. +/// Keeps test call sites for the typed `to_oci` / `from_oci` API +/// short. +#[track_caller] +fn tag(s: &str) -> OciTag { + OciTag::new(s).unwrap() +} + +fn create_snapshot_from_binary() -> Snapshot { + let path = simple_guest_as_string().unwrap(); + Snapshot::from_env( + GuestBinary::FilePath(path), + crate::sandbox::SandboxConfiguration::default(), + ) + .unwrap() +} + +/// `Result::unwrap_err` requires `T: Debug`, but `Snapshot` is not +/// `Debug`. This wrapper is the test-side equivalent. +#[track_caller] +fn unwrap_err_snapshot(r: crate::Result) -> crate::HyperlightError { + match r { + Err(e) => e, + Ok(_) => panic!("expected Snapshot::from_oci to fail"), + } +} + +/// Locate the single config blob inside `oci_dir`. Returns its full +/// path. Used by tests that mutate the on-disk JSON. +fn find_config_blob(oci_dir: &std::path::Path) -> std::path::PathBuf { + let manifest_bytes = std::fs::read(oci_dir.join("index.json")).unwrap(); + let index: Value = serde_json::from_slice(&manifest_bytes).unwrap(); + let manifest_digest = index["manifests"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + let manifest_path = oci_dir.join("blobs").join("sha256").join(manifest_digest); + let manifest: Value = serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + let cfg_digest = manifest["config"]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + oci_dir.join("blobs").join("sha256").join(cfg_digest) +} + +/// Locate the snapshot (layer 0) blob inside `oci_dir`. +fn find_snapshot_blob(oci_dir: &std::path::Path) -> std::path::PathBuf { + let index: Value = + serde_json::from_slice(&std::fs::read(oci_dir.join("index.json")).unwrap()).unwrap(); + let manifest_digest = index["manifests"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + let manifest_path = oci_dir.join("blobs").join("sha256").join(manifest_digest); + let manifest: Value = serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + let snap_digest = manifest["layers"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + oci_dir.join("blobs").join("sha256").join(snap_digest) +} + +// In-memory `from_snapshot` round-trips. + +#[test] +fn from_snapshot_already_initialized_in_memory() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(snapshot, HostFunctions::default(), None).unwrap(); + let result: i32 = sbox2.call("GetStatic", ()).unwrap(); + assert_eq!(result, 0); +} + +#[test] +fn from_snapshot_in_memory_pre_init() { + let snap = create_snapshot_from_binary(); + let mut sbox = + MultiUseSandbox::from_snapshot(Arc::new(snap), HostFunctions::default(), None).unwrap(); + let result: i32 = sbox.call("GetStatic", ()).unwrap(); + assert_eq!(result, 0); +} + +// Round-trip via OCI layout on disk. + +#[test] +fn round_trip_save_load_call() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let oci = dir.path().join("snap"); + snapshot.to_oci(&oci, &tag("latest")).unwrap(); + + let loaded = Snapshot::from_oci(&oci, tag("latest")).unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + + let result: String = sbox2.call("Echo", "hello\n".to_string()).unwrap(); + assert_eq!(result, "hello\n"); +} + +/// A pre-existing snapshot blob with the right length but wrong +/// bytes (corruption, partial copy, foreign tool) must be detected +/// and replaced by `to_oci`, not silently trusted. +#[test] +fn to_oci_self_heals_same_length_wrong_content_snapshot_blob() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let oci = dir.path().join("snap"); + snapshot.to_oci(&oci, &tag("latest")).unwrap(); + + // Overwrite the snapshot blob with wrong bytes of the same + // length, simulating on-disk corruption. + let snap_path = find_snapshot_blob(&oci); + let len = std::fs::metadata(&snap_path).unwrap().len() as usize; + std::fs::write(&snap_path, vec![0xAAu8; len]).unwrap(); + + // Re-save. `put_blob_if_absent` must notice the digest mismatch + // and rewrite the blob. + snapshot.to_oci(&oci, &tag("latest")).unwrap(); + + // A checked load succeeds: the rewritten blob matches the + // descriptor digest. + let loaded = Snapshot::from_oci(&oci, tag("latest")).unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + let result: String = sbox2.call("Echo", "hello\n".to_string()).unwrap(); + assert_eq!(result, "hello\n"); +} + +#[test] +fn snapshot_and_pt_size_round_trip() { + let mut sbox = create_test_sandbox(); + let snap = sbox.snapshot().unwrap(); + let original_snapshot_size = snap.layout().snapshot_size; + let original_pt_size = snap.layout().pt_size; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("running"); + snap.to_oci(&path, &tag("latest")).unwrap(); + + let loaded = Snapshot::from_oci(&path, tag("latest")).unwrap(); + assert_eq!(loaded.layout().snapshot_size, original_snapshot_size); + assert_eq!(loaded.layout().pt_size, original_pt_size); + + // Pre-init snapshot. + let preinit = create_snapshot_from_binary(); + let preinit_snapshot_size = preinit.layout().snapshot_size; + let preinit_pt_size = preinit.layout().pt_size; + + let path = dir.path().join("preinit"); + preinit.to_oci(&path, &tag("latest")).unwrap(); + + let loaded = Snapshot::from_oci(&path, tag("latest")).unwrap(); + assert_eq!(loaded.layout().snapshot_size, preinit_snapshot_size); + assert_eq!(loaded.layout().pt_size, preinit_pt_size); +} + +#[test] +fn snapshot_generation_round_trip() { + let mut sbox = create_test_sandbox(); + sbox.call::("Echo", "a".to_string()).unwrap(); + let snap1 = sbox.snapshot().unwrap(); + sbox.call::("Echo", "b".to_string()).unwrap(); + sbox.call::("Echo", "c".to_string()).unwrap(); + let snap3 = sbox.snapshot().unwrap(); + let gen1 = snap1.snapshot_generation(); + let gen3 = snap3.snapshot_generation(); + assert_ne!(gen1, gen3); + + let dir = tempfile::tempdir().unwrap(); + let p1 = dir.path().join("s1"); + let p3 = dir.path().join("s3"); + snap1.to_oci(&p1, &tag("latest")).unwrap(); + snap3.to_oci(&p3, &tag("latest")).unwrap(); + + let loaded1 = Snapshot::from_oci(&p1, tag("latest")).unwrap(); + let loaded3 = Snapshot::from_oci(&p3, tag("latest")).unwrap(); + assert_eq!(loaded1.snapshot_generation(), gen1); + assert_eq!(loaded3.snapshot_generation(), gen3); +} + +#[test] +fn pre_init_snapshot_save_load() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("preinit"); + snap.to_oci(&path, &tag("latest")).unwrap(); + + let loaded = Snapshot::from_oci(&path, tag("latest")).unwrap(); + let mut sbox = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); +} + +// Restore semantics. + +#[test] +fn restore_from_loaded_snapshot() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + let loaded = Arc::new(Snapshot::from_oci(&path, tag("latest")).unwrap()); + let mut sbox2 = + MultiUseSandbox::from_snapshot(loaded.clone(), HostFunctions::default(), None).unwrap(); + + sbox2.call::("AddToStatic", 5i32).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 5); + + sbox2.restore(loaded).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 0); +} + +/// Independent loads of the same image are structurally identical, so a +/// sandbox built from one accepts a restore from the other. +#[test] +fn restore_across_independent_oci_loads_succeeds() { + let mut sbox = create_test_sandbox(); + let snap1 = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let p1 = dir.path().join("snap1"); + snap1.to_oci(&p1, &tag("latest")).unwrap(); + let p2 = dir.path().join("snap2"); + snap1.to_oci(&p2, &tag("latest")).unwrap(); + + let loaded1 = Arc::new(Snapshot::from_oci(&p1, tag("latest")).unwrap()); + let loaded2 = Arc::new(Snapshot::from_oci(&p2, tag("latest")).unwrap()); + + let mut sbox = MultiUseSandbox::from_snapshot(loaded2, HostFunctions::default(), None).unwrap(); + sbox.restore(loaded1).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn cow_does_not_mutate_backing_file() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + // Hash every blob file to verify nothing changes after a CoW write + // through the loaded sandbox. + let blobs_dir = path.join("blobs").join("sha256"); + let snapshot_before: std::collections::BTreeMap<_, _> = std::fs::read_dir(&blobs_dir) + .unwrap() + .map(|e| { + let e = e.unwrap(); + let bytes = std::fs::read(e.path()).unwrap(); + (e.file_name(), bytes) + }) + .collect(); + + { + let loaded = Snapshot::from_oci(&path, tag("latest")).unwrap(); + let mut sbox = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None) + .unwrap(); + sbox.call::("AddToStatic", 99).unwrap(); + } + + let snapshot_after: std::collections::BTreeMap<_, _> = std::fs::read_dir(&blobs_dir) + .unwrap() + .map(|e| { + let e = e.unwrap(); + let bytes = std::fs::read(e.path()).unwrap(); + (e.file_name(), bytes) + }) + .collect(); + assert_eq!( + snapshot_before, snapshot_after, + "CoW writes must not mutate any blob in the OCI layout" + ); +} + +// Architecture, hypervisor, and ABI gating. + +/// Compute sha256 of `bytes` and return the lowercase hex digest. +fn sha256_hex(bytes: &[u8]) -> String { + let arr: [u8; 32] = Sha256::digest(bytes).into(); + hex::encode(arr) +} + +fn rewrite_config(oci_dir: &std::path::Path, mutate: F) { + // Mutate the config blob and rewrite the manifest and index so blob + // filenames, descriptor sizes, and descriptor digests stay consistent. + // For tests that target the digest layer directly, write raw bytes. + let cfg_path = find_config_blob(oci_dir); + let mut cfg: Value = serde_json::from_slice(&std::fs::read(&cfg_path).unwrap()).unwrap(); + mutate(&mut cfg); + let new_cfg_bytes = serde_json::to_vec_pretty(&cfg).unwrap(); + let new_cfg_hex = sha256_hex(&new_cfg_bytes); + let blobs_dir = oci_dir.join("blobs").join("sha256"); + let new_cfg_path = blobs_dir.join(&new_cfg_hex); + std::fs::write(&new_cfg_path, &new_cfg_bytes).unwrap(); + if new_cfg_path != cfg_path { + std::fs::remove_file(&cfg_path).ok(); + } + + let mp = manifest_path(oci_dir); + let mut manifest: Value = serde_json::from_slice(&std::fs::read(&mp).unwrap()).unwrap(); + manifest["config"]["digest"] = Value::from(format!("sha256:{}", new_cfg_hex)); + manifest["config"]["size"] = Value::from(new_cfg_bytes.len() as u64); + let new_manifest_bytes = serde_json::to_vec_pretty(&manifest).unwrap(); + let new_manifest_hex = sha256_hex(&new_manifest_bytes); + let new_manifest_path = blobs_dir.join(&new_manifest_hex); + std::fs::write(&new_manifest_path, &new_manifest_bytes).unwrap(); + if new_manifest_path != mp { + std::fs::remove_file(&mp).ok(); + } + + let index_path = oci_dir.join("index.json"); + let mut index: Value = serde_json::from_slice(&std::fs::read(&index_path).unwrap()).unwrap(); + index["manifests"][0]["digest"] = Value::from(format!("sha256:{}", new_manifest_hex)); + index["manifests"][0]["size"] = Value::from(new_manifest_bytes.len() as u64); + std::fs::write(index_path, serde_json::to_vec_pretty(&index).unwrap()).unwrap(); +} + +/// Locate the manifest blob path inside `oci_dir`. +fn manifest_path(oci_dir: &std::path::Path) -> std::path::PathBuf { + let index: Value = + serde_json::from_slice(&std::fs::read(oci_dir.join("index.json")).unwrap()).unwrap(); + let digest = index["manifests"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap() + .to_string(); + oci_dir.join("blobs").join("sha256").join(digest) +} + +/// Mutate the on-disk manifest JSON and keep the index's manifest +/// descriptor `size` and `digest` in sync. +fn rewrite_manifest(oci_dir: &std::path::Path, mutate: F) { + let mp = manifest_path(oci_dir); + let mut manifest: Value = serde_json::from_slice(&std::fs::read(&mp).unwrap()).unwrap(); + mutate(&mut manifest); + let new_bytes = serde_json::to_vec_pretty(&manifest).unwrap(); + let new_hex = sha256_hex(&new_bytes); + let blobs_dir = oci_dir.join("blobs").join("sha256"); + let new_path = blobs_dir.join(&new_hex); + std::fs::write(&new_path, &new_bytes).unwrap(); + if new_path != mp { + std::fs::remove_file(&mp).ok(); + } + + let index_path = oci_dir.join("index.json"); + let mut index: Value = serde_json::from_slice(&std::fs::read(&index_path).unwrap()).unwrap(); + index["manifests"][0]["digest"] = Value::from(format!("sha256:{}", new_hex)); + index["manifests"][0]["size"] = Value::from(new_bytes.len() as u64); + std::fs::write(index_path, serde_json::to_vec_pretty(&index).unwrap()).unwrap(); +} + +/// Mutate the on-disk index JSON in place. The index is the root of +/// the OCI layout and is not referenced by any digest. +fn rewrite_index(oci_dir: &std::path::Path, mutate: F) { + let path = oci_dir.join("index.json"); + let mut index: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + mutate(&mut index); + std::fs::write(path, serde_json::to_vec_pretty(&index).unwrap()).unwrap(); +} + +#[test] +fn arch_mismatch_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + rewrite_config(&path, |cfg| { + cfg["arch"] = Value::from("aarch64"); + }); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("architecture") || msg.contains("arch"), + "expected architecture mismatch, got: {}", + msg + ); +} + +#[test] +fn abi_version_mismatch_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + rewrite_config(&path, |cfg| { + cfg["abi_version"] = Value::from(9999u32); + }); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("ABI") || msg.contains("abi"), + "expected ABI version mismatch, got: {}", + msg + ); +} + +#[test] +fn hypervisor_mismatch_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + // Pick a hypervisor that is not the current one. + let current = cfg_current_hypervisor(); + let other = if current == "kvm" { "mshv" } else { "kvm" }; + + rewrite_config(&path, |cfg| { + cfg["hypervisor"] = Value::from(other); + }); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("hypervisor"), + "expected hypervisor mismatch, got: {}", + msg + ); +} + +fn cfg_current_hypervisor() -> &'static str { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("probe"); + create_snapshot_from_binary() + .to_oci(&path, &tag("latest")) + .unwrap(); + let cfg_path = find_config_blob(&path); + let cfg: Value = serde_json::from_slice(&std::fs::read(&cfg_path).unwrap()).unwrap(); + match cfg["hypervisor"].as_str().unwrap() { + "kvm" => "kvm", + "mshv" => "mshv", + "whp" => "whp", + other => panic!("unknown hypervisor tag {other}"), + } +} + +// Entrypoint vs sregs invariants enforced by serde shape. + +#[test] +fn call_snapshot_without_sregs_rejected() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + // Strip sregs from the entrypoint variant. serde must reject the + // missing field at parse time. + rewrite_config(&path, |cfg| { + let entry = cfg["entrypoint"].as_object_mut().unwrap(); + assert_eq!(entry["kind"].as_str().unwrap(), "call"); + entry.remove("sregs"); + }); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("sregs") || msg.contains("missing field") || msg.contains("config"), + "expected serde error about missing sregs, got: {}", + msg + ); +} + +#[test] +fn initialise_snapshot_with_sregs_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + // Add a bogus sregs field to the Initialise variant. serde must + // reject the unknown field (variant has deny_unknown_fields). + rewrite_config(&path, |cfg| { + let entry = cfg["entrypoint"].as_object_mut().unwrap(); + assert_eq!(entry["kind"].as_str().unwrap(), "initialise"); + entry.insert("sregs".to_string(), Value::from("{}")); + }); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("sregs") || msg.contains("unknown field") || msg.contains("config"), + "expected serde error about unknown field sregs, got: {}", + msg + ); +} + +// Host-function validation. The loaded sandbox's `HostFunctions` must +// be a superset (by name and signature) of those recorded in the snapshot. + +/// Build a `MultiUseSandbox` with the default host functions plus a +/// custom `Add(i32, i32) -> i32`. +fn create_sandbox_with_custom_host_funcs() -> MultiUseSandbox { + let path = simple_guest_as_string().unwrap(); + let mut u = UninitializedSandbox::new(GuestBinary::FilePath(path), None).unwrap(); + u.register_host_function("Add", |a: i32, b: i32| Ok(a + b)) + .unwrap(); + u.evolve().unwrap() +} + +/// `HostFunctions::default()` plus a matching `Add(i32, i32) -> i32`. +fn host_funcs_with_matching_add() -> HostFunctions { + let mut hf = HostFunctions::default(); + hf.register_host_function("Add", |a: i32, b: i32| Ok(a + b)) + .unwrap(); + hf +} + +#[test] +fn from_snapshot_accepts_matching_host_functions() { + let mut sbox = create_sandbox_with_custom_host_funcs(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, &tag("latest")).unwrap(); + + let loaded = Snapshot::from_oci(&path, tag("latest")).unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), host_funcs_with_matching_add(), None) + .unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 0); +} + +/// A snapshot taken with `Add` registered is rejected when loaded +/// against a `HostFunctions` set that lacks `Add`. +#[test] +fn from_snapshot_rejects_missing_host_function() { + let mut sbox = create_sandbox_with_custom_host_funcs(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, &tag("latest")).unwrap(); + + let loaded = Snapshot::from_oci(&path, tag("latest")).unwrap(); + let err = MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None) + .expect_err("from_snapshot must reject a HostFunctions set missing `Add`"); + let msg = format!("{}", err); + assert!( + msg.contains("missing") && msg.contains("Add"), + "expected missing-host-function error mentioning Add, got: {}", + msg + ); +} + +/// Loading registers `Add` with a signature different from the one the +/// snapshot recorded, which must be refused. +#[test] +fn from_snapshot_rejects_signature_mismatch() { + let mut sbox = create_sandbox_with_custom_host_funcs(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, &tag("latest")).unwrap(); + + let mut hf = HostFunctions::default(); + hf.register_host_function("Add", |a: String, b: String| Ok(format!("{a}{b}"))) + .unwrap(); + + let loaded = Snapshot::from_oci(&path, tag("latest")).unwrap(); + let err = MultiUseSandbox::from_snapshot(Arc::new(loaded), hf, None) + .expect_err("from_snapshot must reject a signature mismatch on Add"); + let msg = format!("{}", err); + assert!( + msg.contains("signature mismatches") && msg.contains("Add"), + "expected signature-mismatch error mentioning Add, got: {}", + msg + ); +} + +/// Registering host functions beyond those the snapshot recorded is +/// accepted. +#[test] +fn from_snapshot_accepts_extra_host_functions() { + let mut sbox = create_sandbox_with_custom_host_funcs(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, &tag("latest")).unwrap(); + + let mut hf = host_funcs_with_matching_add(); + hf.register_host_function("Mul", |a: i32, b: i32| Ok(a * b)) + .unwrap(); + + let loaded = Snapshot::from_oci(&path, tag("latest")).unwrap(); + let mut sbox2 = MultiUseSandbox::from_snapshot(Arc::new(loaded), hf, None).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn from_snapshot_accepts_zero_arg_host_function() { + let path = simple_guest_as_string().unwrap(); + let mut u = UninitializedSandbox::new(GuestBinary::FilePath(path), None).unwrap(); + u.register_host_function("Zero", || Ok(7i64)).unwrap(); + let mut sbox = u.evolve().unwrap(); + + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, &tag("latest")).unwrap(); + + let mut hf = HostFunctions::default(); + hf.register_host_function("Zero", || Ok(7i64)).unwrap(); + + let loaded = Snapshot::from_oci(&path, tag("latest")).unwrap(); + let _sbox2 = MultiUseSandbox::from_snapshot(Arc::new(loaded), hf, None) + .expect("zero-arg host function must round-trip through OCI"); +} + +// OCI-shape invariants. + +#[test] +fn missing_oci_layout_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + std::fs::remove_file(path.join("oci-layout")).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("oci-layout"), + "expected missing oci-layout error, got: {}", + msg + ); +} + +#[test] +fn wrong_image_layout_version_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + std::fs::write( + path.join("oci-layout"), + r#"{"imageLayoutVersion":"99.0.0"}"#, + ) + .unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("image layout version") || msg.contains("imageLayoutVersion"), + "expected layout version error, got: {}", + msg + ); +} + +#[test] +fn missing_index_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + std::fs::remove_file(path.join("index.json")).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("index.json"), + "expected missing index.json error, got: {}", + msg + ); +} + +#[test] +fn snapshot_blob_size_mismatch_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + // Truncate the snapshot blob by one byte. + let blobs_dir = path.join("blobs").join("sha256"); + let manifest_bytes = std::fs::read(path.join("index.json")).unwrap(); + let index: Value = serde_json::from_slice(&manifest_bytes).unwrap(); + let manifest_digest = index["manifests"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + let manifest_path = blobs_dir.join(manifest_digest); + let manifest: Value = serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + let snap_digest = manifest["layers"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + let snap_path = blobs_dir.join(snap_digest); + let bytes = std::fs::read(&snap_path).unwrap(); + std::fs::write(&snap_path, &bytes[..bytes.len() - 1]).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("size") || msg.contains("mismatch"), + "expected size mismatch error, got: {}", + msg + ); +} + +#[test] +fn snapshot_layout_snapshot_size_zero_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + rewrite_config(&path, |cfg| { + cfg["layout"]["snapshot_size"] = Value::from(0u64); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("snapshot_size"), + "expected snapshot_size error, got: {}", + msg + ); +} + +#[test] +fn snapshot_layout_snapshot_size_unaligned_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + rewrite_config(&path, |cfg| { + let s = cfg["layout"]["snapshot_size"].as_u64().unwrap(); + cfg["layout"]["snapshot_size"] = Value::from(s + 1); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("PAGE_SIZE") || msg.contains("multiple"), + "expected page alignment error, got: {}", + msg + ); +} + +#[test] +fn snapshot_layout_snapshot_size_must_match_memory_size() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + let page = hyperlight_common::vmem::PAGE_SIZE as u64; + rewrite_config(&path, |cfg| { + let m = cfg["memory_size"].as_u64().unwrap(); + cfg["layout"]["snapshot_size"] = Value::from(m + page); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("does not equal memory_size"), + "expected snapshot_size + pt_size != memory_size error, got: {}", + msg + ); +} + +#[test] +fn snapshot_size_smaller_than_layout_rejected() { + // Shrinking `snapshot_size` while growing `pt_size` by the same + // amount preserves `snapshot_size + pt_size == memory_size` and the + // blob length, yet leaves the guest mapping too short to back the + // regions the layout describes. The loader must compare + // `snapshot_size` against the size the layout fields imply. + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + let page = hyperlight_common::vmem::PAGE_SIZE as u64; + // Size the layout fields imply. The guest-visible prefix must + // cover at least this much. + let required = snapshot.layout().get_memory_size().unwrap() as u64; + rewrite_config(&path, |cfg| { + let mem = cfg["memory_size"].as_u64().unwrap(); + // One page short of the required size, with the page-table + // tail absorbing the rest so `memory_size` (and the blob + // length) stay constant. + let short = required - page; + cfg["layout"]["snapshot_size"] = Value::from(short); + cfg["layout"]["pt_size"] = Value::from(mem - short); + // Grow scratch to cover the larger pt tail so the scratch + // bound is not what trips. + cfg["layout"]["scratch_size"] = Value::from(mem + page); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "is smaller than the layout size"); +} + +#[test] +fn snapshot_layout_pt_size_unaligned_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + rewrite_config(&path, |cfg| { + if let Some(p) = cfg["layout"]["pt_size"].as_u64() { + cfg["layout"]["pt_size"] = Value::from(p + 1); + } else { + cfg["layout"]["pt_size"] = Value::from(1u64); + } + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("pt_size") || msg.contains("PAGE_SIZE") || msg.contains("multiple"), + "expected pt_size validation error, got: {}", + msg + ); +} + +#[test] +fn missing_snapshot_blob_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + let blobs_dir = path.join("blobs").join("sha256"); + let manifest_bytes = std::fs::read(path.join("index.json")).unwrap(); + let index: Value = serde_json::from_slice(&manifest_bytes).unwrap(); + let manifest_digest = index["manifests"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + let manifest_path = blobs_dir.join(manifest_digest); + let manifest: Value = serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + let snap_digest = manifest["layers"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + std::fs::remove_file(blobs_dir.join(snap_digest)).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("snapshot blob") || msg.contains("No such") || msg.contains("not found"), + "expected missing-blob error, got: {}", + msg + ); +} + +// Path semantics. + +#[test] +fn from_oci_nonexistent_path_returns_error() { + let err = unwrap_err_snapshot(Snapshot::from_oci( + "/nonexistent/path/to/oci", + tag("latest"), + )); + let msg = format!("{}", err); + assert!( + msg.contains("stat") || msg.contains("No such") || msg.contains("not found"), + "expected missing-path error, got: {}", + msg + ); +} + +#[test] +fn from_oci_file_not_directory_rejected() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("not-a-dir"); + std::fs::write(&file_path, b"hello").unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&file_path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("not a directory"), + "expected not-a-directory error, got: {}", + msg + ); +} + +/// Two snapshots written to one directory under different tags coexist +/// and load independently. +#[test] +fn to_oci_appends_into_existing_layout_with_new_tag() { + let snap_a = create_snapshot_from_binary(); + let snap_b = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store"); + + snap_a.to_oci(&path, &tag("a")).unwrap(); + snap_b.to_oci(&path, &tag("b")).unwrap(); + + let _ = Snapshot::from_oci(&path, tag("a")).unwrap(); + let _ = Snapshot::from_oci(&path, tag("b")).unwrap(); + + let index: Value = + serde_json::from_slice(&std::fs::read(path.join("index.json")).unwrap()).unwrap(); + let manifests = index["manifests"].as_array().unwrap(); + let tags: Vec<&str> = manifests + .iter() + .map(|m| { + m["annotations"]["org.opencontainers.image.ref.name"] + .as_str() + .unwrap() + }) + .collect(); + assert_eq!(tags.len(), 2); + assert!(tags.contains(&"a")); + assert!(tags.contains(&"b")); + + // Every descriptor carries advisory arch and hypervisor + // annotations so registry UIs and OCI tooling can show them. + let expected_arch = if cfg!(target_arch = "aarch64") { + "aarch64" + } else { + "x86_64" + }; + for m in manifests { + let anns = &m["annotations"]; + assert_eq!( + anns["dev.hyperlight.snapshot.arch"].as_str().unwrap(), + expected_arch + ); + let hv = anns["dev.hyperlight.snapshot.hypervisor"].as_str().unwrap(); + assert!( + ["kvm", "mshv", "whp"].contains(&hv), + "unexpected hypervisor annotation: {}", + hv + ); + } +} + +#[test] +fn to_oci_replaces_descriptor_for_same_tag() { + let mut sbox = create_test_sandbox(); + sbox.call::("Echo", "first".to_string()).unwrap(); + let snap_first = sbox.snapshot().unwrap(); + sbox.call::("Echo", "second".to_string()).unwrap(); + let snap_second = sbox.snapshot().unwrap(); + let gen_second = snap_second.snapshot_generation(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store"); + + snap_first.to_oci(&path, &tag("latest")).unwrap(); + snap_second.to_oci(&path, &tag("latest")).unwrap(); + + let loaded = Snapshot::from_oci(&path, tag("latest")).unwrap(); + assert_eq!(loaded.snapshot_generation(), gen_second); + + let index: Value = + serde_json::from_slice(&std::fs::read(path.join("index.json")).unwrap()).unwrap(); + let entries: Vec<&Value> = index["manifests"] + .as_array() + .unwrap() + .iter() + .filter(|m| { + m["annotations"]["org.opencontainers.image.ref.name"].as_str() == Some("latest") + }) + .collect(); + assert_eq!(entries.len(), 1, "expected one descriptor for tag 'latest'"); +} + +/// `to_oci` creates the leaf directory but requires its parent chain to +/// exist. +#[test] +fn to_oci_requires_parent_dir_to_exist() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let missing_parent = dir.path().join("a").join("b").join("c"); + let path = missing_parent.join("store"); + let err = snap.to_oci(&path, &tag("latest")).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("parent directory") || msg.contains("not accessible"), + "expected missing-parent error, got: {msg}" + ); + assert!(!missing_parent.exists(), "no parent dirs should be created"); +} + +#[test] +fn to_oci_rejects_regular_file_at_path() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("not-a-dir"); + std::fs::write(&path, b"i am a file").unwrap(); + let err = snap.to_oci(&path, &tag("latest")).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("is not a directory") || msg.contains("layout dir"), + "expected non-directory error, got: {msg}" + ); + assert_eq!(std::fs::read(&path).unwrap(), b"i am a file"); +} + +/// A pre-existing `oci-layout` with an unknown version is left in place +/// and the call errors. +#[test] +fn to_oci_rejects_unsupported_existing_layout_version() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store"); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write( + path.join("oci-layout"), + br#"{"imageLayoutVersion":"99.0.0"}"#, + ) + .unwrap(); + let err = snap.to_oci(&path, &tag("latest")).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("imageLayoutVersion") || msg.contains("unsupported"), + "expected unsupported-version error, got: {msg}" + ); + assert!( + !path.join("index.json").exists(), + "to_oci must not have written index.json" + ); +} + +#[test] +fn to_oci_into_empty_existing_directory() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store"); + std::fs::create_dir_all(&path).unwrap(); + + snap.to_oci(&path, &tag("latest")).unwrap(); + let _ = Snapshot::from_oci(&path, tag("latest")).unwrap(); + assert!(path.join("oci-layout").exists()); + assert!(path.join("index.json").exists()); +} + +/// Files in the layout dir that are not part of the OCI structure are +/// left alone, matching containers/image, crane, and regclient. +#[test] +fn to_oci_preserves_unrelated_files_in_layout_dir() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store"); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write(path.join("README.md"), b"keep me").unwrap(); + + snap.to_oci(&path, &tag("latest")).unwrap(); + assert_eq!(std::fs::read(path.join("README.md")).unwrap(), b"keep me"); +} + +/// Saving the same snapshot under the same tag twice keeps one +/// descriptor and reuses the content-addressed blobs. +#[test] +fn to_oci_same_tag_same_content_is_idempotent() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store"); + + snap.to_oci(&path, &tag("latest")).unwrap(); + let blobs_after_first: Vec<_> = std::fs::read_dir(path.join("blobs").join("sha256")) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.file_name())) + .collect(); + + snap.to_oci(&path, &tag("latest")).unwrap(); + let blobs_after_second: Vec<_> = std::fs::read_dir(path.join("blobs").join("sha256")) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.file_name())) + .collect(); + assert_eq!(blobs_after_first.len(), blobs_after_second.len()); + + let index: Value = + serde_json::from_slice(&std::fs::read(path.join("index.json")).unwrap()).unwrap(); + let manifests = index["manifests"].as_array().unwrap(); + assert_eq!(manifests.len(), 1); + assert_eq!( + manifests[0]["annotations"]["org.opencontainers.image.ref.name"], + "latest" + ); +} + +/// Two tags written from one in-memory snapshot share all three blobs +/// (manifest, config, snapshot). +#[test] +fn to_oci_shares_blobs_across_tags_with_identical_content() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store"); + + snap.to_oci(&path, &tag("a")).unwrap(); + snap.to_oci(&path, &tag("b")).unwrap(); + + let blobs: Vec<_> = std::fs::read_dir(path.join("blobs").join("sha256")) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.file_name())) + .collect(); + assert_eq!(blobs.len(), 3, "expected 3 deduped blobs, got {:?}", blobs); +} + +/// Replacing one tag in a three-tag layout keeps the other two +/// descriptors intact. +#[test] +fn to_oci_replace_in_middle_preserves_other_tags() { + let mut sbox = create_test_sandbox(); + let snap_a = sbox.snapshot().unwrap(); + sbox.call::("Echo", "x".to_string()).unwrap(); + let snap_b = sbox.snapshot().unwrap(); + sbox.call::("Echo", "y".to_string()).unwrap(); + let snap_c = sbox.snapshot().unwrap(); + sbox.call::("Echo", "z".to_string()).unwrap(); + let snap_b2 = sbox.snapshot().unwrap(); + let gen_b2 = snap_b2.snapshot_generation(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store"); + snap_a.to_oci(&path, &tag("a")).unwrap(); + snap_b.to_oci(&path, &tag("b")).unwrap(); + snap_c.to_oci(&path, &tag("c")).unwrap(); + snap_b2.to_oci(&path, &tag("b")).unwrap(); + + let index: Value = + serde_json::from_slice(&std::fs::read(path.join("index.json")).unwrap()).unwrap(); + let tags: Vec<&str> = index["manifests"] + .as_array() + .unwrap() + .iter() + .map(|m| { + m["annotations"]["org.opencontainers.image.ref.name"] + .as_str() + .unwrap() + }) + .collect(); + assert_eq!(tags.len(), 3); + assert!(tags.contains(&"a")); + assert!(tags.contains(&"b")); + assert!(tags.contains(&"c")); + + let loaded_b = Snapshot::from_oci(&path, tag("b")).unwrap(); + assert_eq!(loaded_b.snapshot_generation(), gen_b2); +} + +#[test] +fn to_oci_rejects_malformed_existing_oci_layout_json() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store"); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write(path.join("oci-layout"), b"not json").unwrap(); + + let err = snap.to_oci(&path, &tag("latest")).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("oci-layout") && msg.contains("JSON"), + "expected oci-layout JSON error, got: {msg}" + ); + assert!(!path.join("index.json").exists()); +} + +#[test] +fn to_oci_rejects_existing_oci_layout_missing_version() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store"); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write(path.join("oci-layout"), br#"{"other":"field"}"#).unwrap(); + + let err = snap.to_oci(&path, &tag("latest")).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("imageLayoutVersion"), + "expected missing-version error, got: {msg}" + ); + assert!(!path.join("index.json").exists()); +} + +#[test] +fn to_oci_rejects_malformed_existing_index_json() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store"); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write( + path.join("oci-layout"), + br#"{"imageLayoutVersion":"1.0.0"}"#, + ) + .unwrap(); + std::fs::write(path.join("index.json"), b"{not valid json").unwrap(); + + let err = snap.to_oci(&path, &tag("latest")).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("index.json"), + "expected index.json error, got: {msg}" + ); + assert_eq!( + std::fs::read(path.join("index.json")).unwrap(), + b"{not valid json", + "to_oci must not overwrite a malformed existing index.json" + ); +} + +/// A snapshot blob whose bytes have been replaced (with length +/// preserved so descriptor sizes still match) must be rejected via +/// digest mismatch. +#[test] +fn from_oci_rejects_snapshot_blob_byte_mutation() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + // Flip one byte in the middle of the snapshot blob. Length is + // preserved so only a digest re-hash can detect this. + let blobs_dir = path.join("blobs").join("sha256"); + let index: Value = + serde_json::from_slice(&std::fs::read(path.join("index.json")).unwrap()).unwrap(); + let manifest_digest = index["manifests"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap() + .to_string(); + let manifest: Value = + serde_json::from_slice(&std::fs::read(blobs_dir.join(&manifest_digest)).unwrap()).unwrap(); + let snap_digest = manifest["layers"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap() + .to_string(); + let snap_path = blobs_dir.join(&snap_digest); + let mut bytes = std::fs::read(&snap_path).unwrap(); + let mid = bytes.len() / 2; + bytes[mid] ^= 0xFF; + std::fs::write(&snap_path, &bytes).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("digest") || msg.contains("hash") || msg.contains("sha256"), + "expected digest-mismatch error, got: {}", + msg + ); +} + +/// Config-blob byte mutation must be caught by digest verification +/// before any structural validator runs. +#[test] +fn from_oci_rejects_config_blob_byte_mutation() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + let cfg_path = find_config_blob(&path); + let mut bytes = std::fs::read(&cfg_path).unwrap(); + // Length-preserving byte flip so the digest layer rejects before + // the JSON parser. + bytes[0] = b' '; + std::fs::write(&cfg_path, &bytes).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("digest") || msg.contains("hash") || msg.contains("sha256"), + "expected digest-mismatch error, got: {}", + msg + ); +} + +// Input validation for `from_oci`. + +fn save_for_mutation() -> (tempfile::TempDir, std::path::PathBuf) { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + (dir, path) +} + +fn assert_err_contains(err: crate::HyperlightError, needle: &str) { + let msg = format!("{}", err); + assert!( + msg.contains(needle), + "expected error to contain {:?}, got: {}", + needle, + msg + ); +} + +#[test] +fn malformed_oci_layout_rejected() { + let (_dir, path) = save_for_mutation(); + std::fs::write(path.join("oci-layout"), b"not-valid-json{").unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "oci-layout"); +} + +#[test] +fn oci_layout_missing_version_field_rejected() { + let (_dir, path) = save_for_mutation(); + std::fs::write(path.join("oci-layout"), r#"{"unrelated":"field"}"#).unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "imageLayoutVersion"); +} + +#[test] +fn malformed_index_json_rejected() { + let (_dir, path) = save_for_mutation(); + std::fs::write(path.join("index.json"), b"{not json").unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "index.json"); +} + +#[test] +fn empty_index_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_index(&path, |idx| { + idx["manifests"] = Value::Array(Vec::new()); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "no manifest tagged"); +} + +/// Two manifests sharing one `org.opencontainers.image.ref.name` +/// annotation are ambiguous, so the load is refused. +#[test] +fn from_oci_rejects_duplicate_tag_in_index() { + let (_dir, path) = save_for_mutation(); + rewrite_index(&path, |idx| { + let first = idx["manifests"][0].clone(); + idx["manifests"].as_array_mut().unwrap().push(first); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "multiple manifests tagged"); +} + +#[test] +fn missing_manifest_blob_rejected() { + let (_dir, path) = save_for_mutation(); + std::fs::remove_file(manifest_path(&path)).unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("open") || msg.contains("No such") || msg.contains("not found"), + "expected missing-manifest error, got: {}", + msg + ); +} + +#[test] +fn bad_digest_format_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_index(&path, |idx| { + // `oci-spec` validates descriptor digests on parse and rejects + // values lacking the algorithm prefix. + idx["manifests"][0]["digest"] = Value::from("deadbeef"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("digest") || msg.contains("index.json"), + "expected digest or parse error, got: {}", + msg + ); +} + +/// `from_oci_unchecked` reaches the manifest JSON parser. The digest +/// path is covered by `from_oci_rejects_manifest_blob_byte_mutation`. +#[test] +fn malformed_manifest_json_rejected() { + let (_dir, path) = save_for_mutation(); + let mp = manifest_path(&path); + std::fs::write(&mp, b"{not json").unwrap(); + // Match the descriptor size so the JSON parser runs, not the size + // check. + let new_len = std::fs::metadata(&mp).unwrap().len(); + rewrite_index(&path, |idx| { + idx["manifests"][0]["size"] = Value::from(new_len); + }); + // SAFETY: locally produced snapshot, test owns the path. + let err = unwrap_err_snapshot(unsafe { Snapshot::from_oci_unchecked(&path, tag("latest")) }); + assert_err_contains(err, "manifest"); +} + +#[test] +fn wrong_manifest_schema_version_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["schemaVersion"] = Value::from(99u32); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "schemaVersion"); +} + +#[test] +fn unknown_config_media_type_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["config"]["mediaType"] = Value::from("application/vnd.example.unknown.v1+json"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "config media type"); +} + +#[test] +fn empty_layers_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["layers"] = Value::Array(Vec::new()); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "layer"); +} + +#[test] +fn extra_layers_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + let first = m["layers"][0].clone(); + m["layers"].as_array_mut().unwrap().push(first); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "layer"); +} + +#[test] +fn unknown_snapshot_layer_media_type_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["layers"][0]["mediaType"] = Value::from("application/vnd.example.unknown.v1"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "snapshot layer media type"); +} + +/// Annotations injected by third-party tools (cosign, ORAS, build +/// pipelines) must not break load. The OCI envelope around +/// `OciSnapshotConfig` is parsed via `oci-spec`'s lenient types. +#[test] +fn manifest_and_index_annotations_tolerated() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + rewrite_manifest(&path, |m| { + let mut anns = serde_json::Map::new(); + anns.insert( + "org.opencontainers.image.created".to_string(), + Value::from("2024-01-01T00:00:00Z"), + ); + anns.insert( + "dev.sigstore.cosign/signature".to_string(), + Value::from("MEUCIQDsignature"), + ); + m["annotations"] = Value::Object(anns); + }); + rewrite_index(&path, |idx| { + let mut anns = serde_json::Map::new(); + anns.insert( + "org.opencontainers.image.ref.name".to_string(), + Value::from("v1.2.3"), + ); + idx["annotations"] = Value::Object(anns); + }); + + let loaded = Snapshot::from_oci(&path, tag("latest")).unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn config_blob_size_descriptor_mismatch_rejected() { + let (_dir, path) = save_for_mutation(); + // Bump the config descriptor's claimed size, leaving the blob as written. + rewrite_manifest(&path, |m| { + let sz = m["config"]["size"].as_u64().unwrap(); + m["config"]["size"] = Value::from(sz + 1); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "config blob size mismatch"); +} + +/// `from_oci_unchecked` reaches the config JSON parser. The digest path +/// is covered by `from_oci_rejects_config_blob_byte_mutation`. +#[test] +fn malformed_config_json_rejected() { + let (_dir, path) = save_for_mutation(); + let cfg_path = find_config_blob(&path); + std::fs::write(&cfg_path, b"{not json").unwrap(); + // Match descriptor sizes so the JSON parser runs, not the size + // check. + let new_cfg_len = std::fs::metadata(&cfg_path).unwrap().len(); + rewrite_manifest(&path, |m| { + m["config"]["size"] = Value::from(new_cfg_len); + }); + // SAFETY: locally produced snapshot, test owns the path. + let err = unwrap_err_snapshot(unsafe { Snapshot::from_oci_unchecked(&path, tag("latest")) }); + assert_err_contains(err, "config JSON"); +} + +#[test] +fn memory_size_zero_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + cfg["memory_size"] = Value::from(0u64); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "memory_size"); +} + +#[test] +fn memory_size_unaligned_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + let sz = cfg["memory_size"].as_u64().unwrap(); + cfg["memory_size"] = Value::from(sz + 1); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + // Either the page-alignment check or the file-size check trips. + assert!( + msg.contains("memory_size") || msg.contains("PAGE_SIZE") || msg.contains("size"), + "expected memory_size rejection, got: {}", + msg + ); +} + +#[test] +fn bad_init_data_permissions_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + // 1u32 << 31 is well outside the defined READ|WRITE|EXECUTE bits. + cfg["layout"]["init_data_permissions"] = Value::from(0x8000_0000u32); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "init_data_permissions"); +} + +#[test] +fn entrypoint_addr_outside_snapshot_region_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + let entry = cfg["entrypoint"].as_object_mut().unwrap(); + // Far above any plausible snapshot region and outside guest + // mapped memory. + entry["addr"] = Value::from(0xDEAD_BEEF_0000u64); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "entrypoint addr"); +} + +#[test] +fn entrypoint_addr_below_base_address_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + let entry = cfg["entrypoint"].as_object_mut().unwrap(); + // Below BASE_ADDRESS (0x1000). + entry["addr"] = Value::from(0u64); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "entrypoint addr"); +} + +// `from_oci_unchecked`: skips blob digest verification, runs every +// other validator. + +#[test] +fn from_oci_unchecked_round_trips() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + // SAFETY: snapshot produced by `to_oci` above, test owns the path. + let loaded = unsafe { Snapshot::from_oci_unchecked(&path, tag("latest")) }.unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + let result: String = sbox2.call("Echo", "hi\n".to_string()).unwrap(); + assert_eq!(result, "hi\n"); +} + +/// Field-level validators (arch, abi, hypervisor, layout and entrypoint +/// bounds) still fire under `from_oci_unchecked`. +#[test] +fn from_oci_unchecked_still_validates_config_fields() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + cfg["arch"] = Value::from("aarch64"); + }); + // SAFETY: locally produced snapshot, test owns the path. + let err = unwrap_err_snapshot(unsafe { Snapshot::from_oci_unchecked(&path, tag("latest")) }); + let msg = format!("{}", err); + assert!( + msg.contains("architecture") || msg.contains("arch"), + "expected architecture mismatch under from_oci_unchecked, got: {}", + msg + ); +} + +/// A length-preserving byte flip breaks the snapshot blob's sha256 but +/// leaves the layout valid. `from_oci` rejects it on the digest, +/// `from_oci_unchecked` loads it. +#[test] +fn from_oci_unchecked_accepts_digest_mismatched_snapshot_blob() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + let snap_path = find_snapshot_blob(&path); + let mut bytes = std::fs::read(&snap_path).unwrap(); + let mid = bytes.len() / 2; + bytes[mid] ^= 0xFF; + std::fs::write(&snap_path, &bytes).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "digest"); + + // SAFETY: locally produced layout, test owns the path. + let _ = unsafe { Snapshot::from_oci_unchecked(&path, tag("latest")) } + .expect("from_oci_unchecked loads a digest-mismatched blob"); +} + +/// Flipping a manifest body byte while the index's descriptor digest is +/// stale must be caught by digest verification before any field-level +/// manifest validator runs. +#[test] +fn from_oci_rejects_manifest_blob_byte_mutation() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + let mp = manifest_path(&path); + let mut bytes = std::fs::read(&mp).unwrap(); + // Length-preserving flip. + bytes[0] ^= 0x20; + std::fs::write(&mp, &bytes).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "digest mismatch"); +} + +#[test] +fn from_oci_unknown_tag_lists_available_tags() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("alpha")).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("missing"))); + let msg = format!("{}", err); + assert!( + msg.contains("no manifest tagged") && msg.contains("\"missing\""), + "expected unknown-tag error mentioning the requested tag, got: {}", + msg + ); + assert!( + msg.contains("alpha"), + "expected available-tags listing to include the actual tag, got: {}", + msg + ); +} + +/// External tools (`oras`, `crane manifest`, `skopeo inspect`) read the +/// tag from the `ref.name` annotation on the manifest descriptor. +#[test] +fn manifest_descriptor_carries_ref_name_annotation() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("production-v3")).unwrap(); + + let index: Value = + serde_json::from_slice(&std::fs::read(path.join("index.json")).unwrap()).unwrap(); + let manifest = &index["manifests"][0]; + assert_eq!( + manifest["annotations"]["org.opencontainers.image.ref.name"] + .as_str() + .unwrap(), + "production-v3" + ); +} + +// Tag validation. Grammar is enforced when an [`OciTag`] is parsed. + +#[test] +fn empty_tag_rejected() { + assert!(OciTag::new("").is_err()); +} + +#[test] +fn tag_with_illegal_leading_char_rejected() { + assert!(OciTag::new(".dotleader").is_err()); + assert!(OciTag::new("-dashleader").is_err()); +} + +#[test] +fn tag_with_illegal_chars_rejected() { + assert!(OciTag::new("with/slash").is_err()); + assert!(OciTag::new("with space").is_err()); +} + +#[test] +fn long_tag_within_limit_accepted() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let long = tag(&"a".repeat(128)); + snap.to_oci(dir.path().join("snap"), &long).unwrap(); + let _ = Snapshot::from_oci(dir.path().join("snap"), long).unwrap(); +} + +#[test] +fn over_long_tag_rejected() { + assert!(OciTag::new("a".repeat(129)).is_err()); +} + +// Save-shape invariants. The on-disk JSON must match what the OCI spec +// prescribes. + +#[test] +fn manifest_descriptor_uses_image_manifest_media_type() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("latest")).unwrap(); + let index: Value = + serde_json::from_slice(&std::fs::read(path.join("index.json")).unwrap()).unwrap(); + assert_eq!( + index["manifests"][0]["mediaType"].as_str().unwrap(), + "application/vnd.oci.image.manifest.v1+json" + ); +} + +/// A descriptor that does not advertise an OCI image manifest is +/// refused even when the blob would parse. +#[test] +fn manifest_descriptor_non_image_manifest_rejected() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("latest")).unwrap(); + rewrite_index(&path, |idx| { + idx["manifests"][0]["mediaType"] = Value::from("application/vnd.oci.image.index.v1+json"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{}", err); + assert!( + msg.contains("unexpected media type"), + "expected manifest-descriptor media type error, got: {}", + msg + ); +} + +#[test] +fn manifest_uses_correct_config_and_layer_media_types() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("latest")).unwrap(); + let manifest: Value = + serde_json::from_slice(&std::fs::read(manifest_path(&path)).unwrap()).unwrap(); + assert_eq!( + manifest["config"]["mediaType"].as_str().unwrap(), + "application/vnd.hyperlight.snapshot.config.v1+json" + ); + assert_eq!(manifest["layers"].as_array().unwrap().len(), 1); + assert_eq!( + manifest["layers"][0]["mediaType"].as_str().unwrap(), + "application/vnd.hyperlight.snapshot.memory.v1" + ); + // `artifactType` mirrors `config.mediaType` so registries that surface + // the distribution-spec referrers API report a useful type, and tooling + // that falls back to `config.mediaType` sees the same value. + assert_eq!( + manifest["artifactType"].as_str().unwrap(), + "application/vnd.hyperlight.snapshot.config.v1+json" + ); +} + +#[test] +fn manifest_missing_artifact_type_rejected() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("latest")).unwrap(); + rewrite_manifest(&path, |m| { + m.as_object_mut().unwrap().remove("artifactType"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "missing required artifactType"); +} + +#[test] +fn manifest_mismatched_artifact_type_rejected() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("latest")).unwrap(); + rewrite_manifest(&path, |m| { + m["artifactType"] = Value::from("application/vnd.example.bogus.v1+json"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "does not match config media type"); +} + +#[test] +fn save_writes_oci_layout_marker() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("latest")).unwrap(); + let marker: Value = + serde_json::from_slice(&std::fs::read(path.join("oci-layout")).unwrap()).unwrap(); + assert_eq!(marker["imageLayoutVersion"].as_str().unwrap(), "1.0.0"); +} + +// Tag selection edge cases. + +#[test] +fn tag_lookup_is_case_sensitive() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("MyTag")).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("mytag"))); + assert_err_contains(err, "no manifest tagged"); + + let _ = Snapshot::from_oci(&path, tag("MyTag")).unwrap(); +} + +/// A miscased annotation key like `org.OpenContainers.image.ref.name` +/// leaves the manifest untagged from the loader's perspective. +#[test] +fn ref_name_annotation_key_is_case_sensitive() { + let (_dir, path) = save_for_mutation(); + rewrite_index(&path, |idx| { + let anns = idx["manifests"][0]["annotations"].as_object_mut().unwrap(); + let value = anns.remove("org.opencontainers.image.ref.name").unwrap(); + anns.insert("org.OpenContainers.image.ref.name".to_string(), value); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "no manifest tagged"); +} + +#[test] +fn tag_with_all_valid_special_chars_accepted() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + let valid = tag("v1.2.3-rc.1_build"); + snap.to_oci(&path, &valid).unwrap(); + let _ = Snapshot::from_oci(&path, valid).unwrap(); +} + +/// A standard ref.name annotation resolves by tag even alongside +/// unrelated annotations (cosign signatures, build pipelines). +#[test] +fn other_descriptor_annotations_do_not_interfere() { + let (_dir, path) = save_for_mutation(); + rewrite_index(&path, |idx| { + let anns = idx["manifests"][0]["annotations"].as_object_mut().unwrap(); + anns.insert( + "dev.sigstore.cosign/signature".to_string(), + Value::from("MEUCIQDfake"), + ); + anns.insert("io.example.build.id".to_string(), Value::from("12345")); + }); + let _ = Snapshot::from_oci(&path, tag("latest")).unwrap(); +} + +// Bad sha256 digest format on the inner descriptors (config and snapshot +// layer). The index-side equivalent is `bad_digest_format_rejected`. + +#[test] +fn bad_config_descriptor_digest_format_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["config"]["digest"] = Value::from("md5:deadbeef"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{err}"); + assert!( + msg.contains("digest"), + "expected digest-format error, got: {msg}" + ); +} + +#[test] +fn bad_snapshot_layer_descriptor_digest_format_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["layers"][0]["digest"] = Value::from("sha256:tooshort"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{err}"); + assert!( + msg.contains("digest"), + "expected digest-format error, got: {msg}" + ); +} + +// Missing inner blobs. + +#[test] +fn missing_config_blob_rejected() { + let (_dir, path) = save_for_mutation(); + let cfg_path = find_config_blob(&path); + std::fs::remove_file(&cfg_path).unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + let msg = format!("{err}"); + assert!( + msg.contains("open") || msg.contains("No such") || msg.contains("not found"), + "expected missing-config-blob error, got: {msg}" + ); +} + +// Size-bound enforcement. + +/// The manifest reader bounds input to 1 MiB. The descriptor size is +/// matched so the bound trips before any size-mismatch check. +#[test] +fn manifest_blob_too_large_rejected() { + let (_dir, path) = save_for_mutation(); + let mp = manifest_path(&path); + let huge = vec![b'a'; (1024 * 1024 + 16) as usize]; + std::fs::write(&mp, &huge).unwrap(); + rewrite_index(&path, |idx| { + idx["manifests"][0]["size"] = Value::from(huge.len() as u64); + }); + // SAFETY: locally produced snapshot, test owns the path. + let err = unwrap_err_snapshot(unsafe { Snapshot::from_oci_unchecked(&path, tag("latest")) }); + assert_err_contains(err, "exceeds maximum allowed"); +} + +#[test] +fn config_blob_too_large_rejected() { + let (_dir, path) = save_for_mutation(); + let cfg_path = find_config_blob(&path); + let huge = vec![b'a'; (1024 * 1024 + 16) as usize]; + std::fs::write(&cfg_path, &huge).unwrap(); + rewrite_manifest(&path, |m| { + m["config"]["size"] = Value::from(huge.len() as u64); + }); + // SAFETY: locally produced snapshot, test owns the path. + let err = unwrap_err_snapshot(unsafe { Snapshot::from_oci_unchecked(&path, tag("latest")) }); + assert_err_contains(err, "exceeds maximum allowed"); +} + +#[test] +fn memory_size_too_large_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + // 16 GiB exceeds MAX_MEMORY_SIZE. + cfg["memory_size"] = Value::from(16u64 * 1024 * 1024 * 1024); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "memory_size"); +} + +#[test] +fn layout_field_too_large_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + // 64 GiB exceeds MAX_MEMORY_SIZE for an individual region. + cfg["layout"]["heap_size"] = Value::from(64u64 * 1024 * 1024 * 1024); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "heap_size"); +} + +#[test] +fn stack_top_gva_zero_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + cfg["stack_top_gva"] = Value::from(0u64); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "stack_top_gva"); +} + +#[test] +fn stack_top_gva_out_of_range_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + cfg["stack_top_gva"] = Value::from(u64::MAX); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "stack_top_gva"); +} + +#[test] +#[cfg(unix)] +fn symlink_snapshot_blob_rejected() { + let (_dir, path) = save_for_mutation(); + // Replace the snapshot blob with a symlink to its real bytes. A + // content-addressed blob must be a regular file, so the loader + // refuses to follow the link. + let blob = find_snapshot_blob(&path); + let real = blob.with_extension("real"); + std::fs::rename(&blob, &real).unwrap(); + std::os::unix::fs::symlink(&real, &blob).unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "symbolic link"); +} + +#[test] +#[cfg(unix)] +fn symlink_config_blob_rejected() { + let (_dir, path) = save_for_mutation(); + // The config blob is read through `read_bounded`, which opens + // with `O_NOFOLLOW`. Replacing it with a symlink to its real + // bytes makes the open fail rather than follow the link. + let blob = find_config_blob(&path); + let real = blob.with_extension("real"); + std::fs::rename(&blob, &real).unwrap(); + std::os::unix::fs::symlink(&real, &blob).unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, tag("latest"))); + assert_err_contains(err, "symbolic link"); +} + +#[test] +#[cfg(unix)] +fn to_oci_replaces_symlink_snapshot_blob_with_regular_file() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + // Replace the snapshot blob with a symlink to its real bytes. A + // content-addressed blob must be a regular file, so the writer + // must not trust the symlink as an already-present blob. + let blob = find_snapshot_blob(&path); + let real = blob.with_extension("real"); + std::fs::rename(&blob, &real).unwrap(); + std::os::unix::fs::symlink(&real, &blob).unwrap(); + + // Re-save. `put_blob_if_absent` sees the path is a symlink, not a + // regular file, and rewrites it via an atomic rename. + snapshot.to_oci(&path, &tag("latest")).unwrap(); + + let meta = std::fs::symlink_metadata(&blob).unwrap(); + assert!( + meta.file_type().is_file() && !meta.file_type().is_symlink(), + "expected the snapshot blob to be a regular file after re-save" + ); + + let loaded = Snapshot::from_oci(&path, tag("latest")).unwrap(); + let mut sbox = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + let result: String = sbox.call("Echo", "hello\n".to_string()).unwrap(); + assert_eq!(result, "hello\n"); +} + +/// A snapshot descriptor claiming a size different from the blob file is +/// rejected before mmap. +#[test] +fn snapshot_descriptor_size_disagrees_with_file_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + let sz = m["layers"][0]["size"].as_u64().unwrap(); + m["layers"][0]["size"] = Value::from(sz + 1); + }); + // SAFETY: locally produced snapshot, test owns the path. + let err = unwrap_err_snapshot(unsafe { Snapshot::from_oci_unchecked(&path, tag("latest")) }); + let msg = format!("{err}"); + assert!( + msg.contains("snapshot blob size"), + "expected snapshot-blob descriptor disagreement error, got: {msg}" + ); +} + +// `from_oci_unchecked` runs every non-digest validator. The unchecked +// path is faster, not more permissive. + +// Round-trip data fidelity for fields not exercised by the +// load-then-call-the-guest tests above. + +#[test] +fn round_trip_preserves_stack_top_gva() { + let mut sbox = create_test_sandbox(); + let snap = sbox.snapshot().unwrap(); + let original = snap.stack_top_gva(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("latest")).unwrap(); + let loaded = Snapshot::from_oci(&path, tag("latest")).unwrap(); + assert_eq!(loaded.stack_top_gva(), original); +} + +#[test] +fn round_trip_preserves_non_default_scratch_size() { + use crate::sandbox::SandboxConfiguration; + let mut cfg = SandboxConfiguration::default(); + let custom_scratch: usize = 256 * 1024; + cfg.set_scratch_size(custom_scratch); + let snap = Snapshot::from_env( + GuestBinary::FilePath(simple_guest_as_string().unwrap()), + cfg, + ) + .unwrap(); + let original = snap.layout().get_scratch_size(); + assert_eq!(original, custom_scratch); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("latest")).unwrap(); + let loaded = Snapshot::from_oci(&path, tag("latest")).unwrap(); + assert_eq!(loaded.layout().get_scratch_size(), custom_scratch); +} + +#[test] +fn pre_init_snapshot_writes_initialise_entrypoint_kind() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("latest")).unwrap(); + let cfg: Value = + serde_json::from_slice(&std::fs::read(find_config_blob(&path)).unwrap()).unwrap(); + assert_eq!(cfg["entrypoint"]["kind"].as_str().unwrap(), "initialise"); + assert!( + cfg["entrypoint"].get("sregs").is_none(), + "Initialise snapshot must not carry sregs in the config" + ); +} + +#[test] +fn already_initialised_snapshot_writes_call_entrypoint_kind() { + let mut sbox = create_test_sandbox(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("latest")).unwrap(); + let cfg: Value = + serde_json::from_slice(&std::fs::read(find_config_blob(&path)).unwrap()).unwrap(); + assert_eq!(cfg["entrypoint"]["kind"].as_str().unwrap(), "call"); + assert!( + cfg["entrypoint"]["sregs"].is_object(), + "Call snapshot must carry sregs in the config" + ); +} + +#[test] +fn round_trip_preserves_host_function_signatures() { + let mut sbox = create_sandbox_with_custom_host_funcs(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("latest")).unwrap(); + + let cfg: Value = + serde_json::from_slice(&std::fs::read(find_config_blob(&path)).unwrap()).unwrap(); + let funcs = cfg["host_functions"].as_array().unwrap(); + let add = funcs + .iter() + .find(|f| f["function_name"].as_str().unwrap() == "Add") + .expect("Add must be recorded"); + assert_eq!( + add["parameter_types"].as_array().unwrap().len(), + 2, + "Add signature must record two parameters" + ); + // Loading and using the snapshot must accept the same signature. + let loaded = Snapshot::from_oci(&path, tag("latest")).unwrap(); + let _ = MultiUseSandbox::from_snapshot(Arc::new(loaded), host_funcs_with_matching_add(), None) + .unwrap(); +} + +#[test] +fn snapshot_with_no_host_functions_round_trips() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("latest")).unwrap(); + + let cfg: Value = + serde_json::from_slice(&std::fs::read(find_config_blob(&path)).unwrap()).unwrap(); + assert!( + cfg["host_functions"].as_array().unwrap().is_empty(), + "expected empty host_functions array for pre-init snapshot" + ); + + let loaded = Snapshot::from_oci(&path, tag("latest")).unwrap(); + let _ = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); +} + +// Snapshot lineage and restore semantics. `restore` accepts any +// snapshot whose memory layout and host-function set match the sandbox. +// Snapshots within a compatible set are interchangeable. + +#[test] +fn linear_chain_restore_in_order() { + let mut sbox = create_test_sandbox(); + let s0 = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 10i32).unwrap(); + let s10 = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 20i32).unwrap(); + let s30 = sbox.snapshot().unwrap(); + + sbox.restore(s0.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + sbox.restore(s10.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 10); + sbox.restore(s30.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 30); +} + +#[test] +fn restore_idempotent() { + let mut sbox = create_test_sandbox(); + sbox.call::("AddToStatic", 11i32).unwrap(); + let s = sbox.snapshot().unwrap(); + + sbox.call::("AddToStatic", 22i32).unwrap(); + sbox.restore(s.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 11); + + // No mutation between restores. + sbox.restore(s.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 11); + + // Mutation after the second restore must take effect. + sbox.call::("AddToStatic", 1i32).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 12); +} + +#[test] +fn separate_oci_loads_are_mutually_restore_compatible() { + let mut seed = create_test_sandbox(); + let snap = seed.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("v1")).unwrap(); + + let s_x = Arc::new(Snapshot::from_oci(&path, tag("v1")).unwrap()); + let s_y = Arc::new(Snapshot::from_oci(&path, tag("v1")).unwrap()); + + let mut sbox_x = + MultiUseSandbox::from_snapshot(s_x.clone(), HostFunctions::default(), None).unwrap(); + sbox_x.restore(s_y.clone()).unwrap(); + assert_eq!(sbox_x.call::("GetStatic", ()).unwrap(), 0); + + sbox_x.restore(s_x.clone()).unwrap(); + assert_eq!(sbox_x.call::("GetStatic", ()).unwrap(), 0); +} + +/// Snapshots taken before and after a save+load round-trip remain +/// mutually restore-compatible. +#[test] +fn oci_loaded_snapshot_supports_full_lifecycle() { + let mut seed = create_test_sandbox(); + let snap = seed.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("v1")).unwrap(); + + let loaded = Arc::new(Snapshot::from_oci(&path, tag("v1")).unwrap()); + let mut sbox = + MultiUseSandbox::from_snapshot(loaded.clone(), HostFunctions::default(), None).unwrap(); + + sbox.call::("AddToStatic", 1i32).unwrap(); + let s1 = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 2i32).unwrap(); + let s3 = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 4i32).unwrap(); + + sbox.restore(s1.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 1); + sbox.restore(s3.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 3); + sbox.restore(loaded.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + + let s_post = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 50i32).unwrap(); + sbox.restore(s_post.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + sbox.restore(s3.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 3); +} + +// Typed references: `OciTag`, `OciDigest`, `OciReference`. + +/// The digest returned by `to_oci` addresses the tag's manifest and +/// loads the same snapshot. +#[test] +fn to_oci_returns_manifest_digest_that_loads() { + let mut sbox = create_test_sandbox(); + sbox.call::("Echo", "x".to_string()).unwrap(); + let snap = sbox.snapshot().unwrap(); + let expected_gen = snap.snapshot_generation(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + let digest = snap.to_oci(&path, &tag("latest")).unwrap(); + + let loaded = Snapshot::from_oci(&path, digest).unwrap(); + assert_eq!(loaded.snapshot_generation(), expected_gen); +} + +/// The returned digest is the sha256 of the manifest blob, matching the +/// digest recorded for that tag's manifest descriptor in `index.json`. +#[test] +fn to_oci_returns_digest_matching_index_manifest_descriptor() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + let digest = snap.to_oci(&path, &tag("latest")).unwrap(); + + let index: Value = + serde_json::from_slice(&std::fs::read(path.join("index.json")).unwrap()).unwrap(); + let descriptor_digest = index["manifests"][0]["digest"].as_str().unwrap(); + assert_eq!(digest.as_str(), descriptor_digest); + + // The digest also matches the sha256 of the manifest blob bytes. + let manifest_hex = descriptor_digest.strip_prefix("sha256:").unwrap(); + let manifest_bytes = + std::fs::read(path.join("blobs").join("sha256").join(manifest_hex)).unwrap(); + assert_eq!( + digest.as_str(), + format!("sha256:{}", sha256_hex(&manifest_bytes)) + ); +} + +/// Loading by digest selects the matching manifest regardless of how +/// many tags share the layout. +#[test] +fn from_oci_by_digest_selects_correct_manifest_among_tags() { + let mut sbox = create_test_sandbox(); + sbox.call::("Echo", "a".to_string()).unwrap(); + let snap_a = sbox.snapshot().unwrap(); + let gen_a = snap_a.snapshot_generation(); + sbox.call::("Echo", "b".to_string()).unwrap(); + let snap_b = sbox.snapshot().unwrap(); + let gen_b = snap_b.snapshot_generation(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + let digest_a = snap_a.to_oci(&path, &tag("a")).unwrap(); + let digest_b = snap_b.to_oci(&path, &tag("b")).unwrap(); + assert_ne!(digest_a.as_str(), digest_b.as_str()); + + let loaded_a = Snapshot::from_oci(&path, digest_a).unwrap(); + let loaded_b = Snapshot::from_oci(&path, digest_b).unwrap(); + assert_eq!(loaded_a.snapshot_generation(), gen_a); + assert_eq!(loaded_b.snapshot_generation(), gen_b); +} + +#[test] +fn from_oci_accepts_reference_value() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("v1")).unwrap(); + + let reference: OciReference = "v1".parse().unwrap(); + let _ = Snapshot::from_oci(&path, reference).unwrap(); +} + +#[test] +fn unknown_digest_reports_missing_manifest() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, &tag("latest")).unwrap(); + + let absent: OciDigest = format!("sha256:{}", "0".repeat(64)).parse().unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, absent)); + assert_err_contains(err, "no manifest with digest"); +} + +#[test] +fn oci_digest_parsing_accepts_canonical_sha256() { + let canonical = format!("sha256:{}", "a".repeat(64)); + let digest: OciDigest = canonical.parse().unwrap(); + assert_eq!(digest.as_str(), canonical); +} + +#[test] +fn oci_digest_parsing_rejects_malformed_values() { + // Bare hex without the algorithm prefix. + assert!("a".repeat(64).parse::().is_err()); + // Uppercase hex is outside the canonical sha256 grammar. + assert!( + format!("sha256:{}", "A".repeat(64)) + .parse::() + .is_err() + ); + // Wrong digest length. + assert!("sha256:deadbeef".parse::().is_err()); + // Unsupported algorithm. + assert!( + format!("sha512:{}", "a".repeat(128)) + .parse::() + .is_err() + ); +} + +#[test] +fn oci_reference_parsing_disambiguates_on_colon() { + let tag_ref: OciReference = "latest".parse().unwrap(); + assert!(matches!(tag_ref, OciReference::Tag(_))); + + let digest_ref: OciReference = format!("sha256:{}", "a".repeat(64)).parse().unwrap(); + assert!(matches!(digest_ref, OciReference::Digest(_))); +} + +/// Adding a new tag to a layout that a live `Snapshot` is already +/// mapped from must not disturb that mapping. `to_oci` writes only new +/// content-addressed blobs and swaps `index.json` atomically, so the +/// blob the mapping holds open stays byte-for-byte identical. +#[test] +fn to_oci_new_tag_into_loaded_layout_preserves_live_mapping() { + let mut sbox = create_test_sandbox(); + let snap_a = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store"); + snap_a.to_oci(&path, &tag("a")).unwrap(); + + // Load tag "a" and keep the mapping live. + let loaded_a = Arc::new(Snapshot::from_oci(&path, tag("a")).unwrap()); + + // Record the full mapped image and every on-disk blob before the + // second save, so any byte change is caught. + let mapping_before = loaded_a.memory.as_slice().to_vec(); + let blobs_dir = path.join("blobs").join("sha256"); + let blobs_before = read_blob_dir(&blobs_dir); + + // While the mapping is live, write a different snapshot under a + // new tag into the same layout. + let mut other = create_test_sandbox(); + other.call::("AddToStatic", 42i32).unwrap(); + let snap_b = other.snapshot().unwrap(); + snap_b.to_oci(&path, &tag("b")).unwrap(); + + // The live mapping is unchanged, byte for byte. + assert_eq!( + loaded_a.memory.as_slice(), + mapping_before.as_slice(), + "live snapshot mapping changed after a new tag was written" + ); + + // The blob the mapping holds open is still present and unchanged, + // and the second save only adds blobs. + let blobs_after = read_blob_dir(&blobs_dir); + for (name, bytes) in &blobs_before { + assert_eq!( + blobs_after.get(name), + Some(bytes), + "existing blob {name:?} was modified by the second save" + ); + } + + // A sandbox built on the live mapping still restores cleanly. + let mut live = + MultiUseSandbox::from_snapshot(loaded_a.clone(), HostFunctions::default(), None).unwrap(); + live.call::("AddToStatic", 7i32).unwrap(); + assert_eq!(live.call::("GetStatic", ()).unwrap(), 7); + live.restore(loaded_a).unwrap(); + assert_eq!(live.call::("GetStatic", ()).unwrap(), 0); + + // Both tags resolve and load independently. + let _ = Snapshot::from_oci(&path, tag("a")).unwrap(); + let _ = Snapshot::from_oci(&path, tag("b")).unwrap(); +} + +/// Read every file in a `blobs/sha256` directory into a name-keyed map +/// for byte-for-byte comparison. +fn read_blob_dir( + blobs_dir: &std::path::Path, +) -> std::collections::BTreeMap> { + std::fs::read_dir(blobs_dir) + .unwrap() + .map(|e| { + let e = e.unwrap(); + (e.file_name(), std::fs::read(e.path()).unwrap()) + }) + .collect() +} + +// ============================================================================= +// `from_snapshot` config plumbing. +// ============================================================================= +// +// `from_snapshot` accepts a caller-supplied `SandboxConfiguration`. +// Layout fields must be silently overridden by the snapshot (the +// on-disk memory blob already encodes those sizes). Runtime fields +// must take effect. + +/// Layout fields supplied via `SandboxConfiguration` must be silently +/// overridden. The snapshot's own layout is authoritative. +#[test] +fn from_snapshot_silently_ignores_layout_overrides() { + use crate::sandbox::SandboxConfiguration; + + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let original_input = snapshot.layout().input_data_size; + let original_output = snapshot.layout().output_data_size; + let original_heap = snapshot.layout().heap_size; + let original_scratch = snapshot.layout().get_scratch_size(); + + let mut config = SandboxConfiguration::default(); + config.set_input_data_size(original_input * 2); + config.set_output_data_size(original_output * 2); + config.set_heap_size((original_heap as u64) * 2); + config.set_scratch_size(original_scratch * 2); + + let mut sbox2 = + MultiUseSandbox::from_snapshot(snapshot.clone(), HostFunctions::default(), Some(config)) + .unwrap(); + + sbox2.call::("GetStatic", ()).unwrap(); + + let new_snap = sbox2.snapshot().unwrap(); + assert_eq!(new_snap.layout().input_data_size, original_input); + assert_eq!(new_snap.layout().output_data_size, original_output); + assert_eq!(new_snap.layout().heap_size, original_heap); + assert_eq!(new_snap.layout().get_scratch_size(), original_scratch); +} + +/// `from_snapshot` honors `guest_core_dump=true` so that +/// `generate_crashdump_to_dir` writes a file. +#[test] +#[cfg(crashdump)] +fn from_snapshot_honors_guest_core_dump_enabled() { + use crate::sandbox::SandboxConfiguration; + + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + + let mut config = SandboxConfiguration::default(); + config.set_guest_core_dump(true); + + let mut sbox2 = + MultiUseSandbox::from_snapshot(snapshot, HostFunctions::default(), Some(config)).unwrap(); + + let dir = tempfile::tempdir().unwrap(); + sbox2 + .generate_crashdump_to_dir(dir.path().to_str().unwrap()) + .unwrap(); + + let entries: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(Result::ok) + .collect(); + assert!( + !entries.is_empty(), + "expected core dump file when guest_core_dump=true" + ); +} + +/// `from_snapshot` honors `guest_core_dump=false` so that +/// `generate_crashdump_to_dir` produces no file. +#[test] +#[cfg(crashdump)] +fn from_snapshot_honors_guest_core_dump_disabled() { + use crate::sandbox::SandboxConfiguration; + + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + + let mut config = SandboxConfiguration::default(); + config.set_guest_core_dump(false); + + let mut sbox2 = + MultiUseSandbox::from_snapshot(snapshot, HostFunctions::default(), Some(config)).unwrap(); + + let dir = tempfile::tempdir().unwrap(); + sbox2 + .generate_crashdump_to_dir(dir.path().to_str().unwrap()) + .unwrap(); + + let entries: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(Result::ok) + .collect(); + assert!( + entries.is_empty(), + "expected no core dump file when guest_core_dump=false, found {:?}", + entries.iter().map(|e| e.path()).collect::>() + ); +} + +/// Non-default `init_data_permissions` survive an OCI round-trip +/// byte-for-byte. The default code path uses `READ`, so this pins +/// `READ | WRITE` instead. A regression in the permission +/// serialisation would silently downgrade or upgrade access to the +/// init_data region. +#[test] +fn round_trip_preserves_non_default_init_data_permissions() { + use crate::mem::memory_region::MemoryRegionFlags; + use crate::sandbox::SandboxConfiguration; + use crate::sandbox::uninitialized::{GuestBlob, GuestEnvironment}; + + let path = simple_guest_as_string().unwrap(); + let data: &[u8] = b"perm-pinned-init-data"; + let env = GuestEnvironment { + guest_binary: GuestBinary::FilePath(path), + init_data: Some(GuestBlob { + data, + permissions: MemoryRegionFlags::READ | MemoryRegionFlags::WRITE, + }), + }; + let snap = Snapshot::from_env(env, SandboxConfiguration::default()).unwrap(); + let expected = snap.layout().init_data_permissions; + assert_eq!( + expected, + Some(MemoryRegionFlags::READ | MemoryRegionFlags::WRITE), + "fixture must produce non-default init_data_permissions", + ); + + let dir = tempfile::tempdir().unwrap(); + let oci_dir = dir.path().join("layout"); + snap.to_oci(&oci_dir, &tag("perms")).unwrap(); + let loaded = Snapshot::from_oci(&oci_dir, tag("perms")).unwrap(); + assert_eq!(loaded.layout().init_data_permissions, expected); +} diff --git a/src/hyperlight_host/src/sandbox/snapshot/mod.rs b/src/hyperlight_host/src/sandbox/snapshot/mod.rs index 50d1583d0..69a5e1813 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/mod.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/mod.rs @@ -14,8 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ +mod file; +mod file_tests; +mod tripwires; + use std::collections::HashMap; +pub use file::reference::{OciDigest, OciReference, OciTag}; use hyperlight_common::flatbuffer_wrappers::host_function_details::HostFunctionDetails; use hyperlight_common::layout::{scratch_base_gpa, scratch_base_gva}; use hyperlight_common::vmem; @@ -367,6 +372,19 @@ impl Snapshot { }) } + /// Build an initialise-kind snapshot directly from a guest environment. + /// + /// Exposed for the snapshot goldens fixture, which lives in an external + /// integration-test crate and pins the initialise-entrypoint snapshot + /// format. Not part of the supported public API. + #[doc(hidden)] + pub fn from_env_unstable<'a, 'b>( + env: impl Into>, + cfg: SandboxConfiguration, + ) -> Result { + Self::from_env(env, cfg) + } + // It might be nice to consider moving at least stack_top_gva into // layout, and sharing (via RwLock or similar) the layout between // the (host-side) mem mgr (where it can be passed in here) and diff --git a/src/hyperlight_host/src/sandbox/snapshot/tripwires.rs b/src/hyperlight_host/src/sandbox/snapshot/tripwires.rs new file mode 100644 index 000000000..41991656b --- /dev/null +++ b/src/hyperlight_host/src/sandbox/snapshot/tripwires.rs @@ -0,0 +1,75 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Compile-time tripwires for the snapshot ABI. +//! +//! Each assertion pins one piece of the contract that snapshots +//! depend on: the manifest media types, the OCI Image Layout version, +//! the `HyperlightPEB` field offsets, and the `OutBAction` port +//! numbers. A change to any of these breaks loading of older +//! snapshots. +//! +//! When an assertion fires, see `docs/snapshot-versioning.md`. + +use super::file::{ + MT_CONFIG_CURRENT, MT_SNAPSHOT_CURRENT, OCI_LAYOUT_VERSION, SNAPSHOT_ABI_VERSION, +}; + +const EXPECTED_ABI_VERSION: u32 = 1; +const EXPECTED_MT_CONFIG: &str = "application/vnd.hyperlight.snapshot.config.v1+json"; +const EXPECTED_MT_SNAPSHOT: &str = "application/vnd.hyperlight.snapshot.memory.v1"; +const EXPECTED_OCI_LAYOUT_VERSION: &str = "1.0.0"; + +const _: () = { + assert!(SNAPSHOT_ABI_VERSION == EXPECTED_ABI_VERSION); + assert!(str_eq(MT_CONFIG_CURRENT, EXPECTED_MT_CONFIG)); + assert!(str_eq(MT_SNAPSHOT_CURRENT, EXPECTED_MT_SNAPSHOT)); + assert!(str_eq(OCI_LAYOUT_VERSION, EXPECTED_OCI_LAYOUT_VERSION)); +}; + +const _: () = { + use hyperlight_common::mem::{GuestMemoryRegion, HyperlightPEB}; + assert!(std::mem::size_of::() == 16); + assert!(std::mem::size_of::() == 4 * 16); + assert!(std::mem::offset_of!(HyperlightPEB, input_stack) == 0); + assert!(std::mem::offset_of!(HyperlightPEB, output_stack) == 16); + assert!(std::mem::offset_of!(HyperlightPEB, init_data) == 32); + assert!(std::mem::offset_of!(HyperlightPEB, guest_heap) == 48); +}; + +const _: () = { + use hyperlight_common::outb::OutBAction; + assert!(OutBAction::Log as u16 == 99); + assert!(OutBAction::CallFunction as u16 == 101); + assert!(OutBAction::Abort as u16 == 102); + assert!(OutBAction::DebugPrint as u16 == 103); +}; + +const fn str_eq(a: &str, b: &str) -> bool { + let a = a.as_bytes(); + let b = b.as_bytes(); + if a.len() != b.len() { + return false; + } + let mut i = 0; + while i < a.len() { + if a[i] != b[i] { + return false; + } + i += 1; + } + true +} diff --git a/src/hyperlight_host/tests/integration_test.rs b/src/hyperlight_host/tests/integration_test.rs index 6b5a7f8e3..d235fbc9c 100644 --- a/src/hyperlight_host/tests/integration_test.rs +++ b/src/hyperlight_host/tests/integration_test.rs @@ -535,7 +535,7 @@ fn guest_malloc_abort() { }); // allocate a vector (on heap) that is bigger than the heap - let heap_size = 0x4000; + let heap_size = 0x6000; let size_to_allocate = 0x10000; assert!( size_to_allocate > heap_size, @@ -616,7 +616,7 @@ fn corrupt_output_back_pointer_rejected() { #[test] fn guest_panic_no_alloc() { - let heap_size = 0x4000; + let heap_size = 0x6000; let mut cfg = SandboxConfiguration::default(); cfg.set_heap_size(heap_size); @@ -1674,6 +1674,35 @@ fn exception_handler_installation_and_validation() { }); } +/// Regression test for a page fault taken while a guest exception handler is +/// running. The installed handler writes a page that is still copy-on-write, +/// so a page fault is delivered while the handler executes on the exception +/// stack. Page faults use a stack separate from other exceptions, so the +/// breakpoint handler's saved frame stays intact and the guest resumes once +/// the breakpoint handler returns. +#[test] +fn exception_handler_nested_page_fault() { + with_rust_sandbox(|mut sandbox| { + let count: i32 = sandbox.call("GetExceptionHandlerCallCount", ()).unwrap(); + assert_eq!(count, 0, "Handler should not have been called yet"); + + sandbox + .call::<()>("InstallCowFaultingHandler", 3i32) + .unwrap(); + + // The handler takes a copy-on-write page fault as it runs. The guest + // resumes from the int3 and returns 0 once the nested fault is serviced. + let trigger_result: i32 = sandbox.call("TriggerInt3Bare", ()).unwrap(); + assert_eq!( + trigger_result, 0, + "Guest should resume after the nested page fault" + ); + + let count: i32 = sandbox.call("GetExceptionHandlerCallCount", ()).unwrap(); + assert_eq!(count, 1, "Handler should have been called once"); + }); +} + /// Tests that an exception can be properly handled even when the heap is exhausted. /// The guest function fills the heap completely, then triggers a ud2 exception. /// This validates that the exception handling path does not require heap allocations. diff --git a/src/hyperlight_host/tests/snapshot_goldens/checks.rs b/src/hyperlight_host/tests/snapshot_goldens/checks.rs new file mode 100644 index 000000000..3c56ae833 --- /dev/null +++ b/src/hyperlight_host/tests/snapshot_goldens/checks.rs @@ -0,0 +1,343 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Functional checks against goldens loaded from the on-disk goldens +//! directory. +//! +//! Each check runs against a fresh `MultiUseSandbox` built from +//! the golden for `Check::kind`, so checks are independent and +//! one failure does not poison the next. See +//! `docs/snapshot-versioning.md` for how to add a check. + +use std::sync::Arc; + +use hyperlight_host::sandbox::snapshot::{OciTag, Snapshot}; +use hyperlight_host::{HostFunctions, MultiUseSandbox}; + +use crate::fixtures::{CALL_COUNTER_BUMP, HEAP_PATTERN_LEN, INIT_DATA, register_host_echo_fns}; +use crate::platform::Kind; + +pub struct Check { + pub name: &'static str, + pub kind: Kind, + pub run: fn(&mut MultiUseSandbox) -> Result<(), String>, +} + +pub const CHECKS: &[Check] = &[ + Check { + name: "init/basic_call", + kind: Kind::Init, + run: init_basic_call, + }, + Check { + name: "init/data_round_trip", + kind: Kind::Init, + run: init_data_round_trip, + }, + Check { + name: "init/custom_layout_works", + kind: Kind::Init, + run: init_custom_layout_works, + }, + Check { + name: "call/captured_bss", + kind: Kind::Call, + run: call_captured_bss, + }, + Check { + name: "call/captured_heap_pattern", + kind: Kind::Call, + run: call_captured_heap_pattern, + }, + Check { + name: "call/guest_types_round_trip", + kind: Kind::Call, + run: call_guest_types_round_trip, + }, + Check { + name: "call/host_round_trips", + kind: Kind::Call, + run: call_host_round_trips, + }, + Check { + name: "call/chained_snapshot", + kind: Kind::Call, + run: call_chained_snapshot, + }, +]; + +// ----------------------------------------------------------------- +// init +// ----------------------------------------------------------------- + +/// Loaded init golden answers a basic call and observes a clean +/// BSS. Covers the header layout, layout arithmetic, PEB contents, +/// the dispatch port, the initialise entry convention, and BSS init. +fn init_basic_call(sbox: &mut MultiUseSandbox) -> Result<(), String> { + let value: i32 = sbox + .call("GetStatic", ()) + .map_err(|e| format!("GetStatic: {e}"))?; + if value != 0 { + return Err(format!("fresh init must observe BSS == 0, got {value}")); + } + Ok(()) +} + +/// `INIT_DATA` survives the snapshot round-trip with permissions +/// intact. The guest's `ReadFromUserMemory` returns the captured +/// bytes. A mismatch means the init_data region was corrupted. +fn init_data_round_trip(sbox: &mut MultiUseSandbox) -> Result<(), String> { + let bytes: Vec = sbox + .call( + "ReadFromUserMemory", + (INIT_DATA.len() as u64, INIT_DATA.to_vec()), + ) + .map_err(|e| format!("ReadFromUserMemory: {e}"))?; + if bytes != INIT_DATA { + return Err(format!( + "captured init_data did not round-trip byte-for-byte (len={})", + bytes.len(), + )); + } + Ok(()) +} + +/// A shift in `SandboxMemoryLayout::new` arithmetic under the +/// custom sizes from `golden_config` lands the PEB or scratch +/// buffers at the wrong addresses. An `Echo` then fails. +fn init_custom_layout_works(sbox: &mut MultiUseSandbox) -> Result<(), String> { + let got: String = sbox + .call("Echo", "custom-layout".to_string()) + .map_err(|e| format!("Echo: {e}"))?; + if got != "custom-layout" { + return Err(format!("Echo returned {got:?}")); + } + Ok(()) +} + +// ----------------------------------------------------------------- +// call +// ----------------------------------------------------------------- + +/// Captured BSS restores exactly: `COUNTER == CALL_COUNTER_BUMP`. +/// Covers the dispatch convention, sregs apply, page-table +/// relocation, captured stack/BSS. +fn call_captured_bss(sbox: &mut MultiUseSandbox) -> Result<(), String> { + let value: i32 = sbox + .call("GetStatic", ()) + .map_err(|e| format!("GetStatic: {e}"))?; + if value != CALL_COUNTER_BUMP { + return Err(format!( + "captured COUNTER expected {CALL_COUNTER_BUMP}, got {value}", + )); + } + Ok(()) +} + +/// Captured heap state restores exactly: the pinned `Vec` +/// pattern produced by `AllocAndWritePattern` survives across +/// save/load. +fn call_captured_heap_pattern(sbox: &mut MultiUseSandbox) -> Result<(), String> { + let got: Vec = sbox + .call("ReadPattern", ()) + .map_err(|e| format!("ReadPattern: {e}"))?; + let expected: Vec = (0..HEAP_PATTERN_LEN as usize) + .map(|i| (i & 0xff) as u8) + .collect(); + if got != expected { + return Err(format!( + "captured heap pattern mismatch (got len {} expected len {})", + got.len(), + expected.len(), + )); + } + Ok(()) +} + +/// Guest-call wire format for every primitive parameter and return +/// type. Each loop asserts an `EchoT` round-trips. Float NaN goes +/// through `is_nan` since `NaN != NaN`. +fn call_guest_types_round_trip(sbox: &mut MultiUseSandbox) -> Result<(), String> { + macro_rules! echo { + ($name:expr, $ty:ty, $values:expr) => {{ + for &v in $values.iter() { + let got: $ty = sbox + .call($name, v) + .map_err(|e| format!("{}({:?}): {e}", $name, v))?; + if got != v { + return Err(format!("{}({:?}) returned {:?}", $name, v, got)); + } + } + }}; + } + echo!("EchoI32", i32, [i32::MIN, -1, 0, 1, i32::MAX]); + echo!("EchoU32", u32, [0u32, 1, u32::MAX]); + echo!("EchoI64", i64, [i64::MIN, -1, 0, 1, i64::MAX]); + echo!("EchoU64", u64, [0u64, 1, u64::MAX]); + echo!( + "EchoFloat", + f32, + [ + 0.0f32, + -1.5, + 1.5, + f32::MIN, + f32::MAX, + f32::INFINITY, + f32::NEG_INFINITY, + ] + ); + let got: f32 = sbox + .call("EchoFloat", f32::NAN) + .map_err(|e| format!("EchoFloat(NaN): {e}"))?; + if !got.is_nan() { + return Err(format!("EchoFloat(NaN) returned {got}")); + } + echo!( + "EchoDouble", + f64, + [ + 0.0f64, + -1.5, + 1.5, + f64::MIN, + f64::MAX, + f64::INFINITY, + f64::NEG_INFINITY, + ] + ); + let got: f64 = sbox + .call("EchoDouble", f64::NAN) + .map_err(|e| format!("EchoDouble(NaN): {e}"))?; + if !got.is_nan() { + return Err(format!("EchoDouble(NaN) returned {got}")); + } + echo!("EchoBool", bool, [false, true]); + + for v in [String::new(), "hello".to_string(), "héllo 🌍".to_string()] { + let got: String = sbox + .call("Echo", v.clone()) + .map_err(|e| format!("Echo({v:?}): {e}"))?; + if got != v { + return Err(format!("Echo({v:?}) returned {got:?}")); + } + } + for v in [ + Vec::::new(), + vec![0u8, 1, 2, 3, 0xff], + (0..256u32).map(|i| (i & 0xff) as u8).collect::>(), + ] { + let got: Vec = sbox + .call("GetSizePrefixedBuffer", v.clone()) + .map_err(|e| format!("GetSizePrefixedBuffer(len={}): {e}", v.len()))?; + if got != v { + return Err(format!( + "GetSizePrefixedBuffer(len={}) did not round-trip", + v.len(), + )); + } + } + let _: () = sbox.call("NoOp", ()).map_err(|e| format!("NoOp: {e}"))?; + let mixed: i32 = sbox + .call( + "PrintElevenArgs", + ( + "a".to_string(), + 1i32, + 2i64, + "b".to_string(), + "c".to_string(), + true, + false, + 3u32, + 4u64, + 5i32, + 6.5f32, + ), + ) + .map_err(|e| format!("PrintElevenArgs: {e}"))?; + if mixed < 0 { + return Err(format!("PrintElevenArgs returned {mixed}")); + } + Ok(()) +} + +/// Host-call wire format for every primitive parameter and return +/// type. Each `RoundTripHostT` invokes the matching `HostEchoT` on +/// the registered host-fn set. +fn call_host_round_trips(sbox: &mut MultiUseSandbox) -> Result<(), String> { + macro_rules! rt { + ($name:expr, $ty:ty, $value:expr) => {{ + let v: $ty = $value; + let got: $ty = sbox + .call($name, v.clone()) + .map_err(|e| format!("{}({:?}): {e}", $name, v))?; + if got != v { + return Err(format!("{}({:?}) returned {:?}", $name, v, got)); + } + }}; + } + rt!("RoundTripHostI32", i32, -7); + rt!("RoundTripHostU32", u32, 0xdead_beef); + rt!("RoundTripHostI64", i64, i64::MIN); + rt!("RoundTripHostU64", u64, u64::MAX); + rt!("RoundTripHostF32", f32, -1.25); + rt!("RoundTripHostF64", f64, 1234.5); + rt!("RoundTripHostBool", bool, false); + rt!("RoundTripHostString", String, "round-trip".to_string()); + rt!("RoundTripHostVecBytes", Vec, vec![0u8, 1, 2, 3, 0xff]); + Ok(()) +} + +/// Snapshot-from-loaded-snapshot path. Mutates state on the loaded +/// call golden, takes a fresh snapshot, round-trips it through an +/// OCI layout on disk, and asserts the mutation survives. +fn call_chained_snapshot(sbox: &mut MultiUseSandbox) -> Result<(), String> { + let val: i32 = sbox + .call("AddToStatic", 5i32) + .map_err(|e| format!("AddToStatic: {e}"))?; + if val != CALL_COUNTER_BUMP + 5 { + return Err(format!( + "AddToStatic returned {val}, expected {}", + CALL_COUNTER_BUMP + 5, + )); + } + let snap = sbox + .snapshot() + .map_err(|e| format!("take chained snapshot: {e}"))?; + + let tmp = tempfile::tempdir().map_err(|e| format!("tempdir: {e}"))?; + let layout = tmp.path().join("chained"); + let tag = OciTag::new("chained").map_err(|e| format!("tag: {e}"))?; + snap.to_oci(&layout, &tag) + .map_err(|e| format!("to_oci: {e}"))?; + + let loaded = Snapshot::from_oci(&layout, tag).map_err(|e| format!("from_oci: {e}"))?; + let mut funcs = HostFunctions::default(); + register_host_echo_fns(&mut funcs); + let mut sbox2 = MultiUseSandbox::from_snapshot(Arc::new(loaded), funcs, None) + .map_err(|e| format!("from_snapshot: {e}"))?; + let val: i32 = sbox2 + .call("GetStatic", ()) + .map_err(|e| format!("GetStatic on chained: {e}"))?; + if val != CALL_COUNTER_BUMP + 5 { + return Err(format!( + "chained snapshot observed COUNTER={val}, expected {}", + CALL_COUNTER_BUMP + 5, + )); + } + Ok(()) +} diff --git a/src/hyperlight_host/tests/snapshot_goldens/fixtures.rs b/src/hyperlight_host/tests/snapshot_goldens/fixtures.rs new file mode 100644 index 000000000..6186ef380 --- /dev/null +++ b/src/hyperlight_host/tests/snapshot_goldens/fixtures.rs @@ -0,0 +1,145 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Canonical fixture builders. These define exactly what bytes a +//! goldens push contains. Any change here is a snapshot content +//! change and requires a goldens regen. + +use std::sync::Arc; + +use hyperlight_host::func::Registerable; +use hyperlight_host::sandbox::SandboxConfiguration; +use hyperlight_host::sandbox::snapshot::Snapshot; +use hyperlight_host::sandbox::uninitialized::GuestEnvironment; +use hyperlight_host::{GuestBinary, MultiUseSandbox, UninitializedSandbox}; +use hyperlight_testing::simple_guest_as_string; + +/// Init data bytes baked into the init golden. Loaded back via +/// `ReadFromUserMemory` to assert byte-for-byte round-trip. +pub const INIT_DATA: &[u8] = b"hyperlight-snapshot-golden-init-data\0"; + +/// Heap pattern length used by the call golden. Small enough to +/// stay cheap, large enough to exercise non-trivial heap state. +pub const HEAP_PATTERN_LEN: u64 = 1024; + +/// Value the captured `COUNTER` static must hold in the call +/// golden. Set by `AddToStatic(CALL_COUNTER_BUMP)` at generate +/// time. +pub const CALL_COUNTER_BUMP: i32 = 42; + +/// Canonical `SandboxConfiguration` used to produce the goldens. +/// Layout knobs are deliberately bumped away from defaults so any +/// silent arithmetic change in `SandboxMemoryLayout::new` shifts at +/// least one region between generate-time and load-time. +fn golden_config() -> SandboxConfiguration { + let mut cfg = SandboxConfiguration::default(); + cfg.set_input_data_size(64 * 1024); + cfg.set_output_data_size(64 * 1024); + cfg.set_heap_size(256 * 1024); + cfg.set_scratch_size(512 * 1024); + cfg +} + +fn simpleguest_path() -> String { + simple_guest_as_string().expect("simpleguest_path") +} + +pub fn generate(kind: crate::platform::Kind) -> Arc { + match kind { + crate::platform::Kind::Init => generate_init(), + crate::platform::Kind::Call => generate_call(), + } +} + +pub fn generate_init() -> Arc { + let env = GuestEnvironment::new(GuestBinary::FilePath(simpleguest_path()), Some(INIT_DATA)); + Arc::new(Snapshot::from_env_unstable(env, golden_config()).expect("Snapshot::from_env (init)")) +} + +pub fn generate_call() -> Arc { + let mut u = UninitializedSandbox::new( + GuestBinary::FilePath(simpleguest_path()), + Some(golden_config()), + ) + .expect("UninitializedSandbox::new"); + register_host_echo_fns(&mut u); + let mut sbox = u.evolve().expect("evolve"); + run_canonical_calls(&mut sbox); + sbox.snapshot().expect("snapshot") +} + +/// Deterministic sequence of guest calls that mutate captured state +/// before snapshotting. Each call lands a specific bit of state +/// (BSS, heap, host-call wiring) that one of the per-surface +/// checks then asserts on after the golden is loaded. +fn run_canonical_calls(sbox: &mut MultiUseSandbox) { + let bumped: i32 = sbox + .call("AddToStatic", CALL_COUNTER_BUMP) + .expect("AddToStatic"); + assert_eq!(bumped, CALL_COUNTER_BUMP); + + let _: () = sbox + .call("AllocAndWritePattern", HEAP_PATTERN_LEN) + .expect("AllocAndWritePattern"); + + // Drive every host fn once so the captured host_function_details + // blob has known signatures and dispatch regressions surface at + // generate time. + sbox.call::("RoundTripHostI32", 1234i32) + .expect("RTH i32"); + sbox.call::("RoundTripHostU32", 4321u32) + .expect("RTH u32"); + sbox.call::("RoundTripHostI64", -42i64) + .expect("RTH i64"); + sbox.call::("RoundTripHostU64", 1u64 << 40) + .expect("RTH u64"); + sbox.call::("RoundTripHostF32", 3.5f32) + .expect("RTH f32"); + sbox.call::("RoundTripHostF64", -2.25f64) + .expect("RTH f64"); + sbox.call::("RoundTripHostBool", true) + .expect("RTH bool"); + sbox.call::("RoundTripHostString", "hi".to_string()) + .expect("RTH string"); + sbox.call::>("RoundTripHostVecBytes", vec![1u8, 2, 3]) + .expect("RTH vec"); + sbox.call::<()>("RoundTripHostNoOp", ()).expect("RTH noop"); +} + +/// Register the `HostEcho*` family used by the call golden. Used at +/// both generate and load time so the registered set matches the +/// captured `host_function_details`. +pub fn register_host_echo_fns(r: &mut R) { + r.register_host_function("HostEchoI32", |v: i32| Ok(v)) + .unwrap(); + r.register_host_function("HostEchoU32", |v: u32| Ok(v)) + .unwrap(); + r.register_host_function("HostEchoI64", |v: i64| Ok(v)) + .unwrap(); + r.register_host_function("HostEchoU64", |v: u64| Ok(v)) + .unwrap(); + r.register_host_function("HostEchoF32", |v: f32| Ok(v)) + .unwrap(); + r.register_host_function("HostEchoF64", |v: f64| Ok(v)) + .unwrap(); + r.register_host_function("HostEchoBool", |v: bool| Ok(v)) + .unwrap(); + r.register_host_function("HostEchoString", |v: String| Ok(v)) + .unwrap(); + r.register_host_function("HostEchoVecBytes", |v: Vec| Ok(v)) + .unwrap(); + r.register_host_function("HostNoOp", || Ok(())).unwrap(); +} diff --git a/src/hyperlight_host/tests/snapshot_goldens/main.rs b/src/hyperlight_host/tests/snapshot_goldens/main.rs new file mode 100644 index 000000000..631a787bd --- /dev/null +++ b/src/hyperlight_host/tests/snapshot_goldens/main.rs @@ -0,0 +1,128 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Snapshot goldens custom-harness test binary. +//! +//! Default mode runs the libtest-mimic harness with one trial per +//! row in `checks::CHECKS`, loading each kind's golden from +//! `target/snapshot-goldens/{version}/{tag}/`. The +//! `generate [out-dir]` subcommand writes the canonical snapshots +//! for the local platform as OCI Image Layouts under `out-dir`, +//! defaulting to the verify directory for a local round-trip. +//! +//! Populate the directory with `just snapshot-goldens-pull` or +//! `just snapshot-goldens-generate`. + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::sync::Arc; + +use hyperlight_host::sandbox::snapshot::{OciTag, Snapshot}; +use hyperlight_host::{HostFunctions, MultiUseSandbox}; +use libtest_mimic::{Arguments, Failed, Trial}; + +mod checks; +mod fixtures; +mod oci; +mod platform; + +use checks::Check; +use platform::{Kind, Platform}; + +fn main() -> ExitCode { + let mut argv = std::env::args().skip(1); + if argv.next().as_deref() == Some("generate") { + let out = argv + .next() + .map(PathBuf::from) + .unwrap_or_else(oci::goldens_root); + return run_generate(&out); + } + run_verify() +} + +fn run_verify() -> ExitCode { + let args = Arguments::from_args(); + let Some(platform) = Platform::detect() else { + eprintln!("snapshot goldens: no (hypervisor, cpu, profile) platform detected on this host",); + return ExitCode::FAILURE; + }; + println!( + "snapshot goldens: verifying platform={} version={}", + platform.suffix(), + platform::GOLDENS_VERSION, + ); + let trials = checks::CHECKS.iter().map(|c| trial(&platform, c)).collect(); + libtest_mimic::run(&args, trials).exit_code() +} + +fn trial(platform: &Platform, check: &'static Check) -> Trial { + let tag = platform.tag(check.kind); + Trial::test(check.name, move || { + let dir = oci::golden_dir(&tag).map_err(Failed::from)?; + let mut sbox = load_sandbox(&dir, &tag, check.kind).map_err(Failed::from)?; + (check.run)(&mut sbox).map_err(Failed::from) + }) +} + +fn load_sandbox(golden_dir: &Path, tag: &str, kind: Kind) -> Result { + let reference = OciTag::new(tag).map_err(|e| format!("invalid golden tag {tag}: {e}"))?; + let snap = Snapshot::from_oci(golden_dir, reference) + .map_err(|e| format!("Snapshot::from_oci({tag}): {e}"))?; + let mut funcs = HostFunctions::default(); + if matches!(kind, Kind::Call) { + fixtures::register_host_echo_fns(&mut funcs); + } + MultiUseSandbox::from_snapshot(Arc::new(snap), funcs, None) + .map_err(|e| format!("MultiUseSandbox::from_snapshot({tag}): {e}")) +} + +fn run_generate(out_dir: &Path) -> ExitCode { + let Some(platform) = Platform::detect() else { + eprintln!( + "snapshot goldens: generate: no (hypervisor, cpu, profile) platform detected on this host", + ); + return ExitCode::FAILURE; + }; + if let Err(e) = std::fs::create_dir_all(out_dir) { + eprintln!("snapshot goldens: generate: create {out_dir:?}: {e}"); + return ExitCode::FAILURE; + } + println!( + "snapshot goldens: generating platform={} version={} into {}", + platform.suffix(), + platform::GOLDENS_VERSION, + out_dir.display(), + ); + for kind in [Kind::Init, Kind::Call] { + let tag = platform.tag(kind); + let oci_tag = match OciTag::new(&tag) { + Ok(t) => t, + Err(e) => { + eprintln!("snapshot goldens: generate: invalid tag {tag}: {e}"); + return ExitCode::FAILURE; + } + }; + let dir = out_dir.join(&tag); + let snap = fixtures::generate(kind); + if let Err(e) = snap.to_oci(&dir, &oci_tag) { + eprintln!("snapshot goldens: generate: to_oci({tag}): {e}"); + return ExitCode::FAILURE; + } + println!(" wrote {tag} -> {}", dir.display()); + } + ExitCode::SUCCESS +} diff --git a/src/hyperlight_host/tests/snapshot_goldens/oci.rs b/src/hyperlight_host/tests/snapshot_goldens/oci.rs new file mode 100644 index 000000000..a4ddf59ea --- /dev/null +++ b/src/hyperlight_host/tests/snapshot_goldens/oci.rs @@ -0,0 +1,52 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +use std::path::PathBuf; + +use crate::platform::GOLDENS_VERSION; + +pub fn goldens_root() -> PathBuf { + // Workspace target dir is two levels up from this crate. + let target = std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| { + let raw = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("target"); + std::fs::canonicalize(&raw).unwrap_or(raw) + }); + target.join("snapshot-goldens").join(GOLDENS_VERSION) +} + +fn goldens_dir_for(tag: &str) -> PathBuf { + goldens_root().join(tag) +} + +/// Locate the golden OCI Image Layout for `tag` in the local +/// directory. A missing layout is an error with guidance to populate +/// it. +pub fn golden_dir(tag: &str) -> Result { + let dir = goldens_dir_for(tag); + if dir.join("oci-layout").is_file() { + return Ok(dir); + } + Err(format!( + "no golden OCI layout found at {dir:?} for tag `{tag}`. \ + Run `just snapshot-goldens-pull` to fetch the published goldens, \ + or `just snapshot-goldens-generate` to regenerate them locally.", + )) +} diff --git a/src/hyperlight_host/tests/snapshot_goldens/platform.rs b/src/hyperlight_host/tests/snapshot_goldens/platform.rs new file mode 100644 index 000000000..2e2159d37 --- /dev/null +++ b/src/hyperlight_host/tests/snapshot_goldens/platform.rs @@ -0,0 +1,173 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Local platform detection and tag naming for snapshot goldens. +//! +//! A snapshot is not portable across `(hypervisor, cpu vendor, +//! build profile)`. Each such triple gets its own set of tags, +//! named `{GOLDENS_VERSION}-{hv}-{cpu}-{profile}-{kind}`. + +/// Goldens version, a `vMAJOR.MINOR` string. See +/// `docs/snapshot-versioning.md`. +pub const GOLDENS_VERSION: &str = "v1.0"; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Kind { + Init, + Call, +} + +impl Kind { + pub fn as_str(self) -> &'static str { + match self { + Self::Init => "init", + Self::Call => "call", + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum Hypervisor { + Kvm, + Mshv, + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + Whp, +} + +impl Hypervisor { + fn as_str(self) -> &'static str { + match self { + Self::Kvm => "kvm", + Self::Mshv => "mshv", + Self::Whp => "whp", + } + } + + /// Detect the locally available hypervisor. Order matches the + /// host crate's preference: `/dev/mshv` over `/dev/kvm` on + /// Linux, WHP on Windows. + fn detect() -> Option { + #[cfg(target_os = "linux")] + { + if std::path::Path::new("/dev/mshv").exists() { + return Some(Self::Mshv); + } + if std::path::Path::new("/dev/kvm").exists() { + return Some(Self::Kvm); + } + None + } + #[cfg(target_os = "windows")] + { + Some(Self::Whp) + } + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + { + None + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum CpuVendor { + Intel, + Amd, +} + +impl CpuVendor { + fn as_str(self) -> &'static str { + match self { + Self::Intel => "intel", + Self::Amd => "amd", + } + } + + /// Detect the local CPU vendor via the `0` leaf of `cpuid`. + /// Returns `None` on non-`x86_64` targets or unknown vendor + /// strings. + fn detect() -> Option { + #[cfg(target_arch = "x86_64")] + { + let r = core::arch::x86_64::__cpuid(0); + let mut bytes = [0u8; 12]; + bytes[0..4].copy_from_slice(&r.ebx.to_le_bytes()); + bytes[4..8].copy_from_slice(&r.edx.to_le_bytes()); + bytes[8..12].copy_from_slice(&r.ecx.to_le_bytes()); + match &bytes { + b"GenuineIntel" => Some(Self::Intel), + b"AuthenticAMD" => Some(Self::Amd), + _ => None, + } + } + #[cfg(not(target_arch = "x86_64"))] + { + None + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum Profile { + Debug, + Release, +} + +impl Profile { + fn as_str(self) -> &'static str { + match self { + Self::Debug => "debug", + Self::Release => "release", + } + } + + fn detect() -> Self { + if cfg!(debug_assertions) { + Self::Debug + } else { + Self::Release + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct Platform { + hv: Hypervisor, + cpu: CpuVendor, + profile: Profile, +} + +impl Platform { + pub fn detect() -> Option { + Some(Self { + hv: Hypervisor::detect()?, + cpu: CpuVendor::detect()?, + profile: Profile::detect(), + }) + } + + pub fn suffix(&self) -> String { + format!( + "{}-{}-{}", + self.hv.as_str(), + self.cpu.as_str(), + self.profile.as_str(), + ) + } + + pub fn tag(&self, kind: Kind) -> String { + format!("{}-{}-{}", GOLDENS_VERSION, self.suffix(), kind.as_str()) + } +} diff --git a/src/tests/rust_guests/simpleguest/src/main.rs b/src/tests/rust_guests/simpleguest/src/main.rs index acc176052..47f7ea4bd 100644 --- a/src/tests/rust_guests/simpleguest/src/main.rs +++ b/src/tests/rust_guests/simpleguest/src/main.rs @@ -187,6 +187,58 @@ fn trigger_int3() -> i32 { 0 } +/// Page-aligned probe written only from inside [`cow_faulting_exception_handler`]. +/// Its page remains copy-on-write after the initial snapshot, so the handler's +/// first write raises a page fault while the handler is running on the exception +/// stack. Page faults are delivered on a stack separate from other exceptions, +/// so this nested fault leaves the breakpoint handler's saved frame intact. +#[repr(align(4096))] +struct CowFaultProbe([u64; 512]); +static mut COW_FAULT_PROBE: CowFaultProbe = CowFaultProbe([0; 512]); + +/// Exception handler that takes a copy-on-write page fault while it runs, by +/// writing a page that has not been written since the snapshot. +fn cow_faulting_exception_handler( + exception_number: u64, + _exception_info: *mut ExceptionInfo, + _context: *mut Context, + _page_fault_address: u64, +) -> bool { + HANDLER_INVOCATION_COUNT.fetch_add(1, Ordering::SeqCst); + + // INT3 is exception vector 3 + assert_eq!(exception_number, 3); + + // The first write to this page is a copy-on-write fault, taken here while + // executing on the exception stack. + unsafe { + let probe = &raw mut COW_FAULT_PROBE.0; + core::ptr::write_volatile(&mut (*probe)[0], TEST_R10_VALUE); + } + + // Return true to suppress abort and continue execution + true +} + +/// Install [`cow_faulting_exception_handler`] for a specific vector +#[guest_function("InstallCowFaultingHandler")] +fn install_cow_faulting_handler(vector: i32) { + hyperlight_guest_bin::exception::arch::HANDLERS[vector as usize].store( + cow_faulting_exception_handler as *const () as usize as u64, + Ordering::Release, + ); +} + +/// Trigger an INT3 breakpoint exception (vector 3) with no register validation. +/// Pairs with [`install_cow_faulting_handler`]. +#[guest_function("TriggerInt3Bare")] +fn trigger_int3_bare() -> i32 { + unsafe { + core::arch::asm!("int3"); + } + 0 +} + #[guest_function("EchoFloat")] fn echo_float(value: f32) -> f32 { value @@ -389,6 +441,130 @@ fn get_size_prefixed_buffer(data: Vec) -> Vec { data } +#[guest_function("EchoI32")] +fn echo_i32(v: i32) -> i32 { + v +} + +#[guest_function("EchoU32")] +fn echo_u32(v: u32) -> u32 { + v +} + +#[guest_function("EchoI64")] +fn echo_i64(v: i64) -> i64 { + v +} + +#[guest_function("EchoU64")] +fn echo_u64(v: u64) -> u64 { + v +} + +#[guest_function("EchoBool")] +fn echo_bool(v: bool) -> bool { + v +} + +#[guest_function("NoOp")] +fn no_op() {} + +#[host_function("HostEchoI32")] +fn host_echo_i32(v: i32) -> Result; + +#[host_function("HostEchoU32")] +fn host_echo_u32(v: u32) -> Result; + +#[host_function("HostEchoI64")] +fn host_echo_i64(v: i64) -> Result; + +#[host_function("HostEchoU64")] +fn host_echo_u64(v: u64) -> Result; + +#[host_function("HostEchoF32")] +fn host_echo_f32(v: f32) -> Result; + +#[host_function("HostEchoF64")] +fn host_echo_f64(v: f64) -> Result; + +#[host_function("HostEchoBool")] +fn host_echo_bool(v: bool) -> Result; + +#[host_function("HostEchoString")] +fn host_echo_string(v: String) -> Result; + +#[host_function("HostEchoVecBytes")] +fn host_echo_vec_bytes(v: Vec) -> Result>; + +#[host_function("HostNoOp")] +fn host_noop() -> Result<()>; + +#[guest_function("RoundTripHostI32")] +fn round_trip_host_i32(v: i32) -> Result { + host_echo_i32(v) +} + +#[guest_function("RoundTripHostU32")] +fn round_trip_host_u32(v: u32) -> Result { + host_echo_u32(v) +} + +#[guest_function("RoundTripHostI64")] +fn round_trip_host_i64(v: i64) -> Result { + host_echo_i64(v) +} + +#[guest_function("RoundTripHostU64")] +fn round_trip_host_u64(v: u64) -> Result { + host_echo_u64(v) +} + +#[guest_function("RoundTripHostF32")] +fn round_trip_host_f32(v: f32) -> Result { + host_echo_f32(v) +} + +#[guest_function("RoundTripHostF64")] +fn round_trip_host_f64(v: f64) -> Result { + host_echo_f64(v) +} + +#[guest_function("RoundTripHostBool")] +fn round_trip_host_bool(v: bool) -> Result { + host_echo_bool(v) +} + +#[guest_function("RoundTripHostString")] +fn round_trip_host_string(v: String) -> Result { + host_echo_string(v) +} + +#[guest_function("RoundTripHostVecBytes")] +fn round_trip_host_vec_bytes(v: Vec) -> Result> { + host_echo_vec_bytes(v) +} + +#[guest_function("RoundTripHostNoOp")] +fn round_trip_host_noop() -> Result<()> { + host_noop() +} + +static mut HEAP_PATTERN: Option> = None; + +#[guest_function("AllocAndWritePattern")] +fn alloc_and_write_pattern(len: u64) { + let v: Vec = (0..len as usize).map(|i| (i & 0xff) as u8).collect(); + unsafe { HEAP_PATTERN = Some(v) }; +} + +#[guest_function("ReadPattern")] +fn read_pattern() -> Vec { + #[allow(static_mut_refs)] + unsafe { + HEAP_PATTERN.clone().unwrap_or_default() + } +} + #[expect( clippy::empty_loop, reason = "This function is used to keep the CPU busy"