diff --git a/.github/workflows/RegenSnapshotGoldens.yml b/.github/workflows/RegenSnapshotGoldens.yml
new file mode 100644
index 000000000..d456527ba
--- /dev/null
+++ b/.github/workflows/RegenSnapshotGoldens.yml
@@ -0,0 +1,241 @@
+# 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/goldens_version.rs). The check-published
+# job reads that version and checks GHCR for its `{version}-complete`
+# marker. If the marker is absent, the matrix walks every (hv, cpu,
+# config) combination, dumps the canonical snapshot, and uploads it as a
+# workflow artifact. A single publish job then downloads every artifact,
+# pushes each as a tag named `{version}-{hv}-{cpu}-{profile}`, and
+# pushes the marker last. Publishing the whole set from one job means a
+# partial run leaves no marker and is republished on the next run.
+#
+# A version whose marker exists 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/goldens_version.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:
+ check-published:
+ 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ - 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/goldens_version.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 its completion marker exists on
+ # GHCR. The marker is pushed only after every matrix job has
+ # uploaded its tag, so a partial push (some jobs failed)
+ # leaves no marker and the next run republishes the missing
+ # combinations. Publishing only when the marker is absent makes the
+ # workflow idempotent and never clobbers a complete baseline.
+ echo "${GHCR_TOKEN}" | oras login ghcr.io -u "${GHCR_USER}" --password-stdin
+ if oras repo tags "${GHCR_IMAGE}" 2>/dev/null | grep -qxF "${SRC}-complete"; then
+ echo "${SRC} already published (marker ${SRC}-complete present). Nothing to do."
+ echo "needs_publish=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "${SRC} not fully published yet. Will publish."
+ echo "needs_publish=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ build-guests:
+ needs: check-published
+ if: needs.check-published.outputs.needs_publish == 'true'
+ strategy:
+ matrix:
+ config: [debug, release]
+ uses: ./.github/workflows/dep_build_guests.yml
+ with:
+ config: ${{ matrix.config }}
+ secrets: inherit
+
+ generate-snapshots:
+ needs: [check-published, build-guests]
+ if: needs.check-published.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ - uses: hyperlight-dev/ci-setup-workflow@f6bd9cc86d0737976d2128c8b8ced8edc017cbb4 # 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: rust-guests-${{ matrix.config }}
+ path: src/tests/rust_guests/bin/${{ matrix.config }}/
+
+ - name: Confirm source matches resolved version
+ env:
+ RESOLVED_VERSION: ${{ needs.check-published.outputs.version }}
+ run: |
+ set -euo pipefail
+ SRC=$(grep -oE 'GOLDENS_VERSION: &str = "[^"]+"' src/hyperlight_host/tests/snapshot_goldens/goldens_version.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: Resolve produced tag
+ id: tag
+ env:
+ GOLDENS_VERSION: ${{ needs.check-published.outputs.version }}
+ run: |
+ set -euo pipefail
+ layout=$(echo "target/snapshot-goldens/${GOLDENS_VERSION}"/*/)
+ echo "tag=$(basename "${layout%/}")" >> "$GITHUB_OUTPUT"
+
+ - name: Upload golden layout
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: golden-${{ steps.tag.outputs.tag }}
+ path: target/snapshot-goldens/${{ needs.check-published.outputs.version }}/${{ steps.tag.outputs.tag }}/
+ if-no-files-found: error
+ retention-days: 1
+
+ # Push every matrix job's snapshot from this single job, so the published set is
+ # whole or absent. `generate-snapshots` runs `fail-fast: false` and uploads each
+ # snapshot as an artifact, so this job's `needs` succeeds only when
+ # all matrix jobs did. It downloads every artifact, pushes each tag, then
+ # pushes the `{version}-complete` marker that `check-published` gates on. A
+ # push that dies partway leaves no marker, so the next run republishes.
+ publish:
+ needs: [check-published, generate-snapshots]
+ if: needs.check-published.outputs.needs_publish == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Install oras
+ uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2.0.0
+ with:
+ version: 1.3.2
+
+ - name: Download all golden layouts
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ pattern: golden-*
+ path: layouts
+
+ - name: Push goldens and completion marker
+ env:
+ GHCR_USER: ${{ github.actor }}
+ GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GOLDENS_VERSION: ${{ needs.check-published.outputs.version }}
+ run: |
+ set -euo pipefail
+ echo "${GHCR_TOKEN}" | oras login ghcr.io -u "${GHCR_USER}" --password-stdin
+ for layout in layouts/golden-*/; do
+ tag=$(basename "${layout%/}")
+ tag=${tag#golden-}
+ echo "::group::push ${tag}"
+ oras cp --from-oci-layout "${layout%/}:${tag}" "${GHCR_IMAGE}:${tag}"
+ echo "::endgroup::"
+ done
+ printf '%s' "${GOLDENS_VERSION}" > complete.txt
+ oras push "${GHCR_IMAGE}:${GOLDENS_VERSION}-complete" \
+ --artifact-type application/vnd.hyperlight.goldens.complete.v1 \
+ complete.txt:text/plain
diff --git a/.github/workflows/ValidatePullRequest.yml b/.github/workflows/ValidatePullRequest.yml
index 659ec9acc..5493012eb 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 91ce867aa..bebaa5295 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:
@@ -138,3 +144,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/Cargo.lock b/Cargo.lock
index 14effcde1..2dafe9870 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"
@@ -947,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"
@@ -1709,6 +1728,7 @@ dependencies = [
"kvm-ioctls",
"lazy_static",
"libc",
+ "libtest-mimic",
"log",
"metrics",
"metrics-exporter-prometheus",
@@ -2164,6 +2184,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"
diff --git a/Justfile b/Justfile
index 2bdb842b9..26c4bbbdc 100644
--- a/Justfile
+++ b/Justfile
@@ -249,8 +249,10 @@ test-integration target=default-target features="": (witguest-wit)
@# run component-util integration tests that depend on generated WIT inputs
{{ cargo-cmd }} test -p hyperlight-component-util --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --test wasmtime_guest_codegen
- @# 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:
@@ -573,3 +575,56 @@ 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 golden for this host 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/goldens_version.rs)
+ # Mirror of `Platform::tag` in platform.rs. Keep both in sync.
+ tag="${version}-${hv}-${cpu}-{{ profile }}"
+ dir="target/snapshot-goldens/${version}/${tag}"
+ mkdir -p "${dir}"
+ oras cp --to-oci-layout "{{ image }}:${tag}" "${dir}:${tag}"
+
+# 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-versioning.md b/docs/snapshot-versioning.md
new file mode 100644
index 000000000..a1030f172
--- /dev/null
+++ b/docs/snapshot-versioning.md
@@ -0,0 +1,328 @@
+# 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 `{GOLDENS_VERSION}-{hv}-{cpu}-{profile}` 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). MAJOR tracks `SNAPSHOT_ABI_VERSION`: every format break bumps
+ both, so a new MAJOR means the on-disk contract moved and old
+ snapshots load through a compatibility path or not at all. 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/test is added). The on-disk contract is
+ unchanged, so `SNAPSHOT_ABI_VERSION` stays put. The new tag set
+ contains every check, including the unchanged ones, regenerated
+ against the current source.
+
+`GOLDENS_VERSION` and `SNAPSHOT_ABI_VERSION` are two separate counters
+with different purposes. `SNAPSHOT_ABI_VERSION` is the integer stamped into
+every snapshot blob, and the loader reads it to decide how to parse the
+bytes. `GOLDENS_VERSION` names the published golden tag set on GHCR. A
+format break bumps both. A check-set change bumps only
+`GOLDENS_VERSION`.
+
+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 completion marker is 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 marker check, not by a
+registry policy. Each `(hv, cpu, profile)` combination generates its snapshot
+and uploads it as a workflow artifact. A single publish job downloads
+every artifact, pushes each as its tag, then pushes a
+`{version}-complete` marker last. Pushing the whole set from one job
+means a partial run leaves no marker, so the next run republishes
+rather than freezing an incomplete set. Republishing a complete version
+takes a manual dispatch with `force: true`, reserved for recovering a
+corrupted 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/goldens_version.rs`).
+
+The workflow walks every supported `(hypervisor, cpu, profile)`
+combination on the self-hosted runner pool, generates the canonical
+snapshot with
+`cargo test --test snapshot_goldens -- generate
`, and uploads each
+OCI layout as a workflow artifact. A single publish job downloads them
+all and pushes each with `oras cp` as the tag
+`{version}-{hv}-{cpu}-{profile}`, then pushes the
+`{version}-complete` marker.
+
+A lightweight `check-published` job gates the matrix. It reads `GOLDENS_VERSION`
+from source and checks GHCR for the `{version}-complete` marker tag. If
+the marker is present the version is fully published and the workflow
+stops there, so re-running it, or merging an unrelated change, is a
+no-op. The marker is pushed last by the publish job, which runs only
+after every matrix job uploaded its snapshot, so a version counts as
+published only as a whole set. This makes publishing idempotent, keeps
+a complete baseline from being clobbered, and lets a run that follows a
+partial push fill in the missing combinations.
+
+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.
+
+### Bootstrapping the first version
+
+The first publish runs through the normal path. The merge that adds
+`goldens_version.rs` touches the file the push trigger watches, so the workflow
+fires. `check-published` lists GHCR tags for the marker. An empty
+registry returns an empty list, so the job publishes. The matrix lands
+the first tag set and its marker.
+
+The first `oras` push creates the GHCR package
+`ghcr.io/hyperlight-dev/hyperlight-snapshot-goldens` on demand. The
+organization must allow the Actions `GITHUB_TOKEN` to create packages.
+A cold start that fails here means that setting is off. Turn it on and
+re-run.
+
+To seed a version by hand, dispatch the workflow with `force: true` and
+a `version` input equal to the `GOLDENS_VERSION` in the dispatched ref.
+
+## 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..83b6540f7 100644
--- a/src/hyperlight_common/src/layout.rs
+++ b/src/hyperlight_common/src/layout.rs
@@ -26,6 +26,8 @@ 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 page-fault exception stack, one page below the top of scratch memory.
+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/src/arch/amd64/prim_alloc.rs b/src/hyperlight_guest/src/arch/amd64/prim_alloc.rs
index cfaad9a0b..3392fd19e 100644
--- a/src/hyperlight_guest/src/arch/amd64/prim_alloc.rs
+++ b/src/hyperlight_guest/src/arch/amd64/prim_alloc.rs
@@ -31,8 +31,9 @@ pub unsafe fn alloc_phys_pages(n: u64) -> u64 {
x = inout(reg) x
);
}
- // Set aside two pages at the top of the scratch region for the
- // exception stack, shared state, etc
+ // Set aside two pages at the top of the scratch region. The top
+ // page holds shared metadata and the general exception stack. The
+ // page below it holds the page-fault exception stack.
let max_avail = hyperlight_common::layout::MAX_GPA - hyperlight_common::vmem::PAGE_SIZE * 2;
if x.checked_add(nbytes)
.is_none_or(|xx| xx >= max_avail 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..576b7bd9e 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 (own 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..912bc5d5d 100644
--- a/src/hyperlight_guest_bin/src/arch/amd64/init.rs
+++ b/src/hyperlight_guest_bin/src/arch/amd64/init.rs
@@ -79,10 +79,19 @@ 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 provides two IST stacks. The CPU switches to one
+/// when an exception is taken, so a handler always runs on a known-good
+/// stack. This matters because a fault can mean the main stack needs to
+/// grow.
+///
+/// * `ist1` is the general exception stack.
+/// * `ist2` is the page-fault stack.
+///
+/// Page faults get a separate stack because they can nest inside
+/// another exception. A handler running on `ist1` may write a
+/// copy-on-write page, which raises a page fault. The CPU delivers that
+/// fault on `ist2`, so each one has its own stack and the handler
+/// resumes 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 +105,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..b4facd6d0 100644
--- a/src/hyperlight_guest_bin/src/arch/amd64/machine.rs
+++ b/src/hyperlight_guest_bin/src/arch/amd64/machine.rs
@@ -20,6 +20,12 @@ use hyperlight_common::vmem::{BasicMapping, MappingKind, PAGE_SIZE};
use super::layout::PROC_CONTROL_GVA;
+/// IDT gate IST index for general exceptions. Selects [`TSS::ist1`].
+pub(super) const IST_GENERAL_EXCEPTION: u8 = 1;
+/// IDT gate IST index for page faults. Selects [`TSS::ist2`], the
+/// page-fault stack. See the TSS setup in `init.rs` for why.
+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 +123,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 +133,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 +161,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 IST stack `ist` (1-based, 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 c663504dc..fe8c198af 100644
--- a/src/hyperlight_host/Cargo.toml
+++ b/src/hyperlight_host/Cargo.toml
@@ -109,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 = [
@@ -144,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/src/sandbox/snapshot/file/config.rs b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs
index 4e926a62a..107ccf164 100644
--- a/src/hyperlight_host/src/sandbox/snapshot/file/config.rs
+++ b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs
@@ -696,3 +696,232 @@ mod tests {
}
}
}
+
+#[cfg(test)]
+mod schema_pin {
+ use super::*;
+
+ const PINNED_CALL: &str = r#"{
+ "hyperlight_version": "x.y.z",
+ "arch": "x86_64",
+ "abi_version": 1,
+ "hypervisor": "mshv",
+ "stack_top_gva": 3735928559,
+ "entrypoint_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 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/media_types.rs b/src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs
index 0b3d64fba..31156a134 100644
--- a/src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs
+++ b/src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs
@@ -14,24 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-// Media types are versioned by suffix. The loader matches each
-// version specifically (no `_CURRENT` shortcut on the read side); the
-// writer always emits `_CURRENT`. A new version is added by:
-//
-// 1. Declare `MT_FOO_V2` next to `MT_FOO_V1`.
-// 2. Point `MT_FOO_CURRENT` at `MT_FOO_V2`.
-// 3. Add a dispatch arm in the loader that converts v1 -> v2 (or
-// rejects v1 if no compatibility window is offered).
-pub(super) const MT_CONFIG_V1: &str = "application/vnd.hyperlight.snapshot.config.v1+json";
-pub(super) const MT_CONFIG_CURRENT: &str = MT_CONFIG_V1;
-pub(super) const MT_SNAPSHOT_V1: &str = "application/vnd.hyperlight.snapshot.memory.v1";
-pub(super) const MT_SNAPSHOT_CURRENT: &str = MT_SNAPSHOT_V1;
+// 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 whenever the
-/// host-guest contract for the bytes inside the snapshot blob changes
-/// (PEB layout, calling convention, init state, etc.). Independent of
-/// the config blob's media-type version.
-pub(super) const SNAPSHOT_ABI_VERSION: u32 = 1;
+/// 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
diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs b/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs
index c95c129e6..59ecb82cb 100644
--- a/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs
+++ b/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs
@@ -35,9 +35,9 @@ use oci_spec::image::{
use self::config::{Arch, 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, MT_CONFIG_CURRENT, MT_CONFIG_V1,
- MT_SNAPSHOT_CURRENT, MT_SNAPSHOT_V1, SNAPSHOT_ABI_VERSION,
+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};
@@ -46,7 +46,7 @@ use crate::mem::layout::SandboxMemoryLayout;
use crate::mem::memory_region::MemoryRegionFlags;
use crate::mem::shared_mem::{ReadonlySharedMemory, SharedMemory};
-const OCI_LAYOUT_VERSION: &str = "1.0.0";
+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
diff --git a/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs
index 9383d1b8b..56f0cd1f2 100644
--- a/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs
+++ b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs
@@ -2784,3 +2784,146 @@ fn read_blob_dir(
})
.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::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 mut sbox = UninitializedSandbox::new(env, None)
+ .unwrap()
+ .evolve()
+ .unwrap();
+ let snap = sbox.snapshot().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.save(&oci_dir, &OciTag::new("latest").unwrap())
+ .unwrap();
+ let loaded = Snapshot::checked_load(&oci_dir, OciTag::new("latest").unwrap()).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 c9ec426b4..155195475 100644
--- a/src/hyperlight_host/src/sandbox/snapshot/mod.rs
+++ b/src/hyperlight_host/src/sandbox/snapshot/mod.rs
@@ -16,6 +16,7 @@ limitations under the License.
mod file;
mod file_tests;
+mod tripwires;
use std::collections::HashMap;
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..eaa3941ab 100644
--- a/src/hyperlight_host/tests/integration_test.rs
+++ b/src/hyperlight_host/tests/integration_test.rs
@@ -535,7 +535,9 @@ fn guest_malloc_abort() {
});
// allocate a vector (on heap) that is bigger than the heap
- let heap_size = 0x4000;
+ // Guest init registers every guest function into a heap map.
+ // 0x6000 leaves room for that so the sandbox can start.
+ let heap_size = 0x6000;
let size_to_allocate = 0x10000;
assert!(
size_to_allocate > heap_size,
@@ -616,7 +618,9 @@ fn corrupt_output_back_pointer_rejected() {
#[test]
fn guest_panic_no_alloc() {
- let heap_size = 0x4000;
+ // Guest init registers every guest function into a heap map.
+ // 0x6000 leaves room for that so the sandbox can start.
+ let heap_size = 0x6000;
let mut cfg = SandboxConfiguration::default();
cfg.set_heap_size(heap_size);
@@ -1674,6 +1678,31 @@ fn exception_handler_installation_and_validation() {
});
}
+/// A guest exception handler writes a copy-on-write page, which faults while
+/// the handler runs on the exception stack. Page faults use their own stack,
+/// so the handler frame survives and the guest resumes.
+#[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 faults as it runs. The guest resumes from int3 and returns 0.
+ 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..912ee1b36
--- /dev/null
+++ b/src/hyperlight_host/tests/snapshot_goldens/checks.rs
@@ -0,0 +1,270 @@
+/*
+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, 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, register_host_echo_fns};
+
+pub struct Check {
+ pub name: &'static str,
+ pub run: fn(&mut MultiUseSandbox) -> Result<(), String>,
+}
+
+pub const CHECKS: &[Check] = &[
+ Check {
+ name: "captured_bss",
+ run: captured_bss,
+ },
+ Check {
+ name: "captured_heap_pattern",
+ run: captured_heap_pattern,
+ },
+ Check {
+ name: "guest_types_round_trip",
+ run: guest_types_round_trip,
+ },
+ Check {
+ name: "host_round_trips",
+ run: host_round_trips,
+ },
+ Check {
+ name: "chained_snapshot",
+ run: chained_snapshot,
+ },
+];
+
+/// Captured BSS restores exactly: `COUNTER == CALL_COUNTER_BUMP`.
+/// Covers the dispatch convention, sregs apply, page-table
+/// relocation, captured stack/BSS.
+fn 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 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 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 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]);
+ let _: () = sbox
+ .call("RoundTripHostNoOp", ())
+ .map_err(|e| format!("RoundTripHostNoOp: {e}"))?;
+ Ok(())
+}
+
+/// Snapshot-from-loaded-snapshot path. Mutates state on the loaded
+/// golden, takes a fresh snapshot, round-trips it through an
+/// OCI layout on disk, and asserts the mutation survives.
+fn 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.save(&layout, &tag).map_err(|e| format!("save: {e}"))?;
+
+ let loaded = Snapshot::checked_load(&layout, tag).map_err(|e| format!("checked_load: {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..f45e19a76
--- /dev/null
+++ b/src/hyperlight_host/tests/snapshot_goldens/fixtures.rs
@@ -0,0 +1,127 @@
+/*
+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::{GuestBinary, MultiUseSandbox, UninitializedSandbox};
+use hyperlight_testing::simple_guest_as_string;
+
+/// Heap pattern length used by the 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 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() -> 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 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/goldens_version.rs b/src/hyperlight_host/tests/snapshot_goldens/goldens_version.rs
new file mode 100644
index 000000000..2d911434b
--- /dev/null
+++ b/src/hyperlight_host/tests/snapshot_goldens/goldens_version.rs
@@ -0,0 +1,24 @@
+/*
+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.
+*/
+
+//! The goldens version string, kept in its own file.
+//!
+//! The `.github/workflows/RegenSnapshotGoldens.yml` path filter watches
+//! this file, so a version bump is the only edit that triggers a
+//! publish. See `docs/snapshot-versioning.md`.
+
+/// Goldens version, a `vMAJOR.MINOR` string.
+pub const GOLDENS_VERSION: &str = "v1.0";
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..ae3d83830
--- /dev/null
+++ b/src/hyperlight_host/tests/snapshot_goldens/main.rs
@@ -0,0 +1,125 @@
+/*
+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 the golden from
+//! `target/snapshot-goldens/{version}/{tag}/`. The
+//! `generate [out-dir]` subcommand writes the canonical snapshot
+//! for the local platform as an OCI Image Layout 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 goldens_version;
+mod oci;
+mod platform;
+
+use checks::Check;
+use platform::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(),
+ goldens_version::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();
+ Trial::test(check.name, move || {
+ let dir = oci::golden_dir(&tag).map_err(Failed::from)?;
+ let mut sbox = load_sandbox(&dir, &tag).map_err(Failed::from)?;
+ (check.run)(&mut sbox).map_err(Failed::from)
+ })
+}
+
+fn load_sandbox(golden_dir: &Path, tag: &str) -> Result {
+ let reference = OciTag::new(tag).map_err(|e| format!("invalid golden tag {tag}: {e}"))?;
+ let snap = Snapshot::checked_load(golden_dir, reference)
+ .map_err(|e| format!("Snapshot::checked_load({tag}): {e}"))?;
+ let mut funcs = HostFunctions::default();
+ 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(),
+ goldens_version::GOLDENS_VERSION,
+ out_dir.display(),
+ );
+ let tag = platform.tag();
+ 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();
+ if let Err(e) = snap.save(&dir, &oci_tag) {
+ eprintln!("snapshot goldens: generate: save({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..6cb3f9579
--- /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::goldens_version::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..bf0ab2f01
--- /dev/null
+++ b/src/hyperlight_host/tests/snapshot_goldens/platform.rs
@@ -0,0 +1,158 @@
+/*
+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 tag, named
+//! `{GOLDENS_VERSION}-{hv}-{cpu}-{profile}`.
+
+use crate::goldens_version::GOLDENS_VERSION;
+
+#[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 {
+ // The `snapshot-goldens-pull` recipe in the Justfile rebuilds this
+ // same `{hv}-{cpu}-{profile}` string in bash. Keep both in sync.
+ format!(
+ "{}-{}-{}",
+ self.hv.as_str(),
+ self.cpu.as_str(),
+ self.profile.as_str(),
+ )
+ }
+
+ pub fn tag(&self) -> String {
+ format!("{}-{}", GOLDENS_VERSION, self.suffix())
+ }
+}
diff --git a/src/tests/rust_guests/simpleguest/src/main.rs b/src/tests/rust_guests/simpleguest/src/main.rs
index acc176052..6a37aea53 100644
--- a/src/tests/rust_guests/simpleguest/src/main.rs
+++ b/src/tests/rust_guests/simpleguest/src/main.rs
@@ -187,6 +187,54 @@ fn trigger_int3() -> i32 {
0
}
+/// Page-aligned probe written from [`cow_faulting_exception_handler`].
+/// Its page stays copy-on-write after the snapshot, so the handler's
+/// first write faults while the handler runs on the exception stack.
+#[repr(align(4096))]
+struct CowFaultProbe([u64; 512]);
+static mut COW_FAULT_PROBE: CowFaultProbe = CowFaultProbe([0; 512]);
+
+/// Handler that faults while it runs by writing a copy-on-write page.
+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);
+
+ // First write to this page faults, here 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 resume execution.
+ true
+}
+
+/// Install [`cow_faulting_exception_handler`] for a 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 (vector 3). 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 +437,132 @@ 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();
+ // SAFETY: the guest is single threaded, so the static has no concurrent access.
+ unsafe { HEAP_PATTERN = Some(v) };
+}
+
+#[guest_function("ReadPattern")]
+fn read_pattern() -> Vec {
+ // SAFETY: the guest is single threaded, so the static has no concurrent access.
+ #[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"