From 3da96ef46fe790bc22f804d4faf999edf6162843 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Tue, 9 Jun 2026 09:36:47 -0700 Subject: [PATCH 1/7] Map snapshot pages writable on MSHV Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- src/hyperlight_host/src/mem/shared_mem.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/hyperlight_host/src/mem/shared_mem.rs b/src/hyperlight_host/src/mem/shared_mem.rs index 461daa756..dcaec8b47 100644 --- a/src/hyperlight_host/src/mem/shared_mem.rs +++ b/src/hyperlight_host/src/mem/shared_mem.rs @@ -1568,6 +1568,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 +1610,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 +1631,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, From dc0a91d4f2eeaf3134192c7bf72df6748cf9b155 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Fri, 1 May 2026 16:01:13 -0700 Subject: [PATCH 2/7] Add OCI snapshot persistence Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- CHANGELOG.md | 3 + Cargo.lock | 186 ++++ docs/snapshot-oci-format.md | 115 +++ src/hyperlight_host/Cargo.toml | 7 +- src/hyperlight_host/src/mem/shared_mem.rs | 1 - .../src/sandbox/initialized_multi_use.rs | 15 + .../src/sandbox/snapshot/file/config.rs | 601 +++++++++++++ .../src/sandbox/snapshot/file/digest.rs | 118 +++ .../src/sandbox/snapshot/file/fsutil.rs | 139 +++ .../src/sandbox/snapshot/file/media_types.rs | 48 + .../src/sandbox/snapshot/file/mod.rs | 828 ++++++++++++++++++ .../src/sandbox/snapshot/file/reference.rs | 232 +++++ .../src/sandbox/snapshot/mod.rs | 4 + 13 files changed, 2294 insertions(+), 3 deletions(-) create mode 100644 docs/snapshot-oci-format.md create mode 100644 src/hyperlight_host/src/sandbox/snapshot/file/config.rs create mode 100644 src/hyperlight_host/src/sandbox/snapshot/file/digest.rs create mode 100644 src/hyperlight_host/src/sandbox/snapshot/file/fsutil.rs create mode 100644 src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs create mode 100644 src/hyperlight_host/src/sandbox/snapshot/file/mod.rs create mode 100644 src/hyperlight_host/src/sandbox/snapshot/file/reference.rs 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..b646fd010 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -530,6 +530,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 +751,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 +797,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" @@ -1154,6 +1241,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 +1458,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 +1688,7 @@ dependencies = [ "gdbstub", "gdbstub_arch", "goblin", + "hex", "hyperlight-common", "hyperlight-component-macro", "hyperlight-guest-tracing", @@ -1598,6 +1704,7 @@ dependencies = [ "metrics-util", "mshv-bindings", "mshv-ioctls", + "oci-spec", "opentelemetry", "opentelemetry-otlp", "opentelemetry-semantic-conventions", @@ -1610,6 +1717,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2", "signal-hook-registry", "tempfile", "termcolor", @@ -1787,6 +1895,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 +2024,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" @@ -2324,6 +2453,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 +2912,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 +3586,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/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/src/hyperlight_host/Cargo.toml b/src/hyperlight_host/Cargo.toml index 32b1604e2..c663504dc 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" diff --git a/src/hyperlight_host/src/mem/shared_mem.rs b/src/hyperlight_host/src/mem/shared_mem.rs index dcaec8b47..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() 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..5331e7510 --- /dev/null +++ b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs @@ -0,0 +1,601 @@ +/* +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(()) + } +} 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..0b3d64fba --- /dev/null +++ b/src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs @@ -0,0 +1,48 @@ +/* +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 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; + +/// 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; + +/// 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..a95e4c662 --- /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, 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}; + +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/mod.rs b/src/hyperlight_host/src/sandbox/snapshot/mod.rs index 50d1583d0..c9ec426b4 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/mod.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/mod.rs @@ -14,8 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ +mod file; +mod file_tests; + 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; From ce0f2ef79d08df8b35eff0b4fda82e0d379388a6 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Fri, 1 May 2026 16:01:13 -0700 Subject: [PATCH 3/7] Add tests for OCI snapshot persistence Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .../src/sandbox/snapshot/file/config.rs | 117 + .../src/sandbox/snapshot/file_tests.rs | 2536 +++++++++++++++++ 2 files changed, 2653 insertions(+) create mode 100644 src/hyperlight_host/src/sandbox/snapshot/file_tests.rs diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/config.rs b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs index 5331e7510..be8800752 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file/config.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs @@ -599,3 +599,120 @@ impl OciSnapshotConfig { 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); + } + } +} 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..a103fb727 --- /dev/null +++ b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs @@ -0,0 +1,2536 @@ +/* +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 f96e95537117bd187d7560319f3e9d1a0829c511 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:54:14 -0700 Subject: [PATCH 4/7] Add OCI snapshot benchmarks Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- src/hyperlight_host/benches/benchmarks.rs | 176 +++++++++++++++++++++- 1 file changed, 175 insertions(+), 1 deletion(-) 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); From 37f5a0cc2b537f3b2e5e8fa71ac28da6d4809b85 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:08:10 -0700 Subject: [PATCH 5/7] Golden test Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .github/workflows/RegenSnapshotGoldens.yml | 125 +++++++ .github/workflows/ValidatePullRequest.yml | 19 +- .github/workflows/dep_build_test.yml | 32 ++ Cargo.lock | 32 ++ Justfile | 60 ++- docs/github-labels.md | 6 + docs/snapshot-versioning.md | 273 ++++++++++++++ src/hyperlight_host/Cargo.toml | 10 + .../src/sandbox/snapshot/file/config.rs | 324 +++++++++++++++++ .../src/sandbox/snapshot/file/media_types.rs | 30 +- .../src/sandbox/snapshot/file/mod.rs | 8 +- .../src/sandbox/snapshot/file_tests.rs | 139 +++++++ .../src/sandbox/snapshot/mod.rs | 14 + .../src/sandbox/snapshot/tripwires.rs | 75 ++++ src/hyperlight_host/tests/integration_test.rs | 4 +- .../tests/snapshot_goldens/checks.rs | 343 ++++++++++++++++++ .../tests/snapshot_goldens/fixtures.rs | 145 ++++++++ .../tests/snapshot_goldens/main.rs | 128 +++++++ .../tests/snapshot_goldens/oci.rs | 52 +++ .../tests/snapshot_goldens/platform.rs | 173 +++++++++ src/tests/rust_guests/simpleguest/src/main.rs | 124 +++++++ 21 files changed, 2090 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/RegenSnapshotGoldens.yml create mode 100644 docs/snapshot-versioning.md create mode 100644 src/hyperlight_host/src/sandbox/snapshot/tripwires.rs create mode 100644 src/hyperlight_host/tests/snapshot_goldens/checks.rs create mode 100644 src/hyperlight_host/tests/snapshot_goldens/fixtures.rs create mode 100644 src/hyperlight_host/tests/snapshot_goldens/main.rs create mode 100644 src/hyperlight_host/tests/snapshot_goldens/oci.rs create mode 100644 src/hyperlight_host/tests/snapshot_goldens/platform.rs diff --git a/.github/workflows/RegenSnapshotGoldens.yml b/.github/workflows/RegenSnapshotGoldens.yml new file mode 100644 index 000000000..4c2f99ead --- /dev/null +++ b/.github/workflows/RegenSnapshotGoldens.yml @@ -0,0 +1,125 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + +# Regenerate snapshot goldens stored at +# ghcr.io/hyperlight-dev/hyperlight-snapshot-goldens. +# +# The workflow walks every (hv, cpu, config) cell, dumps the canonical +# init and call snapshots, then pushes each one to GHCR as its own tag +# named `{version}-{hv}-{cpu}-{profile}-{kind}`. +# +# See docs/snapshot-versioning.md, section "Publishing a new version", +# for how to run it. + +name: Regenerate Snapshot Goldens + +on: + workflow_dispatch: + inputs: + version: + description: Goldens version string. Must match GOLDENS_VERSION in source (e.g. "v1.0"). + required: true + type: string + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: full + GHCR_IMAGE: ghcr.io/hyperlight-dev/hyperlight-snapshot-goldens + +permissions: + contents: read + packages: write + +defaults: + run: + shell: bash + +jobs: + build-guests: + strategy: + matrix: + config: [debug, release] + uses: ./.github/workflows/dep_build_guests.yml + with: + config: ${{ matrix.config }} + secrets: inherit + + dump-and-push: + needs: build-guests + 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.89" + 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: Verify GOLDENS_VERSION matches input + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if ! [[ "${INPUT_VERSION}" =~ ^v[0-9]+\.[0-9]+$ ]]; then + echo "::error::version input must match ^v[0-9]+\.[0-9]+$ (e.g. v1.0)" + exit 1 + fi + IN_SRC=$(grep -oE 'GOLDENS_VERSION: &str = "[^"]+"' src/hyperlight_host/tests/snapshot_goldens/platform.rs | head -n1 | sed -E 's/.*"([^"]+)".*/\1/') + echo "GOLDENS_VERSION in source: ${IN_SRC}" + echo "version input: ${INPUT_VERSION}" + if [ "${IN_SRC}" != "${INPUT_VERSION}" ]; then + echo "::error::version input does not match GOLDENS_VERSION in source" + 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: ${{ inputs.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 copy --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/Cargo.lock b/Cargo.lock index b646fd010..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" @@ -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" @@ -1698,6 +1717,7 @@ dependencies = [ "kvm-ioctls", "lazy_static", "libc", + "libtest-mimic", "log", "metrics", "metrics-exporter-prometheus", @@ -2153,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" diff --git a/Justfile b/Justfile index 4f4c736d5..7af6bb1cf 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 copy --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-versioning.md b/docs/snapshot-versioning.md new file mode 100644 index 000000000..a6cceac53 --- /dev/null +++ b/docs/snapshot-versioning.md @@ -0,0 +1,273 @@ +# 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. After +bumping `GOLDENS_VERSION`, the matching tags must be pushed before the +verify job can pass. + +### 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. To +validate the change on every platform, dispatch the regen workflow +(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 frozen once `main` references it and its tag set is +published. The publish step refuses to overwrite a tag that already +exists on GHCR, so a published baseline cannot be clobbered by a later +regeneration. A version that `main` does not yet reference is in-flight +and may be regenerated as many times as needed while a developer +iterates on a v1 to v2 bump. + +Overwriting an in-flight tag leaves the previous manifest on GHCR as an +orphan. A scheduled cleanup workflow that reaps orphans and abandoned +in-flight tags is a follow-up. + +### 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 + +The published tag set for a `GOLDENS_VERSION` is produced by the +`Regenerate Snapshot Goldens` workflow, dispatched manually against a +ref. 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 copy`. The push refuses any tag that +already exists, so a published baseline cannot be overwritten. + +The workflow takes a `version` input that must equal `GOLDENS_VERSION` +in the dispatched source. This guards against publishing a tag set the +test binary would ignore. + +The exact trigger for promoting an in-flight version to published (a +push-to-`main` automation versus a deliberate maintainer dispatch after +merge) is still being settled. Until that is fixed, a maintainer +dispatches the workflow against `main` once the breaking change lands, +which closes the window in which new pull requests would need the +`regen-goldens` label to go green. + +## 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_host/Cargo.toml b/src/hyperlight_host/Cargo.toml index c663504dc..762b238fe 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 the `--test '*'` / `--tests` aggregate so the normal test +# run does not require a populated golden cache. A dedicated CI step runs +# this target explicitly after pulling or regenerating the goldens. +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 be8800752..ead35a513 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file/config.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs @@ -716,3 +716,327 @@ mod tests { } } } + +#[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/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 a95e4c662..cfa859c51 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs @@ -39,9 +39,9 @@ use self::config::{ }; 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}; @@ -50,7 +50,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 a103fb727..42504000b 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs @@ -2534,3 +2534,142 @@ 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::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 c9ec426b4..69a5e1813 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; @@ -371,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..b3b6ce4fb 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); 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..0f35fdc59 100644 --- a/src/tests/rust_guests/simpleguest/src/main.rs +++ b/src/tests/rust_guests/simpleguest/src/main.rs @@ -389,6 +389,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" From 23f8916e65a8b03bb8a8d1d375ba5bcd271dedbd Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:02:00 -0700 Subject: [PATCH 6/7] fixup! Golden test Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .github/workflows/RegenSnapshotGoldens.yml | 119 +++++++++++++++++---- Justfile | 2 +- docs/snapshot-versioning.md | 78 ++++++++------ src/hyperlight_host/Cargo.toml | 6 +- 4 files changed, 149 insertions(+), 56 deletions(-) diff --git a/.github/workflows/RegenSnapshotGoldens.yml b/.github/workflows/RegenSnapshotGoldens.yml index 4c2f99ead..4b5bd4e66 100644 --- a/.github/workflows/RegenSnapshotGoldens.yml +++ b/.github/workflows/RegenSnapshotGoldens.yml @@ -1,24 +1,40 @@ # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json -# Regenerate snapshot goldens stored at +# Publish snapshot goldens to # ghcr.io/hyperlight-dev/hyperlight-snapshot-goldens. # -# The workflow walks every (hv, cpu, config) cell, dumps the canonical -# init and call snapshots, then pushes each one to GHCR as its own tag -# named `{version}-{hv}-{cpu}-{profile}-{kind}`. +# 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}`. # -# See docs/snapshot-versioning.md, section "Publishing a new version", -# for how to run it. +# 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 @@ -29,12 +45,78 @@ 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] @@ -44,7 +126,8 @@ jobs: secrets: inherit dump-and-push: - needs: build-guests + needs: [resolve, build-guests] + if: needs.resolve.outputs.needs_publish == 'true' strategy: fail-fast: false matrix: @@ -65,7 +148,7 @@ jobs: - uses: hyperlight-dev/ci-setup-workflow@v1.9.0 with: - rust-toolchain: "1.89" + rust-toolchain: "1.94" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -84,20 +167,14 @@ jobs: with: version: 1.3.2 - - name: Verify GOLDENS_VERSION matches input + - name: Confirm source matches resolved version env: - INPUT_VERSION: ${{ inputs.version }} + RESOLVED_VERSION: ${{ needs.resolve.outputs.version }} run: | set -euo pipefail - if ! [[ "${INPUT_VERSION}" =~ ^v[0-9]+\.[0-9]+$ ]]; then - echo "::error::version input must match ^v[0-9]+\.[0-9]+$ (e.g. v1.0)" - exit 1 - fi - IN_SRC=$(grep -oE 'GOLDENS_VERSION: &str = "[^"]+"' src/hyperlight_host/tests/snapshot_goldens/platform.rs | head -n1 | sed -E 's/.*"([^"]+)".*/\1/') - echo "GOLDENS_VERSION in source: ${IN_SRC}" - echo "version input: ${INPUT_VERSION}" - if [ "${IN_SRC}" != "${INPUT_VERSION}" ]; then - echo "::error::version input does not match GOLDENS_VERSION in source" + 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 @@ -113,13 +190,13 @@ jobs: - name: Push goldens to GHCR env: - GOLDENS_VERSION: ${{ inputs.version }} + 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 copy --from-oci-layout "${layout%/}:${tag}" "${GHCR_IMAGE}:${tag}" + oras cp --from-oci-layout "${layout%/}:${tag}" "${GHCR_IMAGE}:${tag}" echo "::endgroup::" done diff --git a/Justfile b/Justfile index 7af6bb1cf..fdab06728 100644 --- a/Justfile +++ b/Justfile @@ -611,7 +611,7 @@ snapshot-goldens-pull image=default-snapshot-goldens-image profile="debug": tag="${version}-${hv}-${cpu}-{{ profile }}-${kind}" dir="target/snapshot-goldens/${version}/${tag}" mkdir -p "${dir}" - oras copy --to-oci-layout "{{ image }}:${tag}" "${dir}:${tag}" + oras cp --to-oci-layout "{{ image }}:${tag}" "${dir}:${tag}" done # Build the local snapshots into the directory that diff --git a/docs/snapshot-versioning.md b/docs/snapshot-versioning.md index a6cceac53..758251617 100644 --- a/docs/snapshot-versioning.md +++ b/docs/snapshot-versioning.md @@ -147,18 +147,21 @@ snapshot loaded against the new build fails the 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. After -bumping `GOLDENS_VERSION`, the matching tags must be pushed before the -verify job can pass. +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. To -validate the change on every platform, dispatch the regen workflow -(see [Publishing a new version](#publishing-a-new-version)). +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 @@ -173,16 +176,18 @@ for a given version is keyed by the full string, so `v1.0`, `v1.1`, and every check, including the unchanged ones, regenerated against the current source. -A version is frozen once `main` references it and its tag set is -published. The publish step refuses to overwrite a tag that already -exists on GHCR, so a published baseline cannot be clobbered by a later -regeneration. A version that `main` does not yet reference is in-flight -and may be regenerated as many times as needed while a developer -iterates on a v1 to v2 bump. +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. -Overwriting an in-flight tag leaves the previous manifest on GHCR as an -orphan. A scheduled cleanup workflow that reaps orphans and abandoned -in-flight tags is a follow-up. +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 @@ -209,25 +214,36 @@ silently degrading into a self-check. ### Publishing a new version -The published tag set for a `GOLDENS_VERSION` is produced by the -`Regenerate Snapshot Goldens` workflow, dispatched manually against a -ref. The workflow walks every supported `(hypervisor, cpu, profile)` +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 copy`. The push refuses any tag that -already exists, so a published baseline cannot be overwritten. - -The workflow takes a `version` input that must equal `GOLDENS_VERSION` -in the dispatched source. This guards against publishing a tag set the -test binary would ignore. - -The exact trigger for promoting an in-flight version to published (a -push-to-`main` automation versus a deliberate maintainer dispatch after -merge) is still being settled. Until that is fixed, a maintainer -dispatches the workflow against `main` once the breaking change lands, -which closes the window in which new pull requests would need the -`regen-goldens` label to go green. +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 diff --git a/src/hyperlight_host/Cargo.toml b/src/hyperlight_host/Cargo.toml index 762b238fe..fe8c198af 100644 --- a/src/hyperlight_host/Cargo.toml +++ b/src/hyperlight_host/Cargo.toml @@ -150,7 +150,7 @@ harness = false name = "snapshot_goldens" path = "tests/snapshot_goldens/main.rs" harness = false -# Excluded from the `--test '*'` / `--tests` aggregate so the normal test -# run does not require a populated golden cache. A dedicated CI step runs -# this target explicitly after pulling or regenerating the goldens. +# 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 From cc3e7f8a4d8f055859d988fec186bdd471311a9f Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:02:14 -0700 Subject: [PATCH 7/7] fix(guest): give page faults a dedicated exception stack A guest exception handler runs on the IST1 exception stack. When such a handler writes a copy-on-write page (present and read-only after a snapshot), the first write raises a page fault. Routing that page fault through the same IST1 stack makes the CPU reset RSP to the top of IST1 and push the fault frame over the live handler frame, clobbering it. The handler then returns to a garbage RIP and the guest aborts. Deliver page faults on their own IST2 stack so a nested fault leaves the outer handler frame intact. The page-fault stack occupies the second of the two scratch pages reserved at the top of the region, so snapshot sizes and golden hashes are undisturbed. Add a deterministic regression test that installs a handler which writes a never-touched copy-on-write page, forcing a nested fault on every run. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- src/hyperlight_common/src/layout.rs | 8 +++ .../src/arch/amd64/exception/entry.rs | 14 +++-- .../src/arch/amd64/init.rs | 27 ++++++++-- .../src/arch/amd64/machine.rs | 21 +++++++- src/hyperlight_host/tests/integration_test.rs | 29 +++++++++++ src/tests/rust_guests/simpleguest/src/main.rs | 52 +++++++++++++++++++ 6 files changed, 141 insertions(+), 10 deletions(-) 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/tests/integration_test.rs b/src/hyperlight_host/tests/integration_test.rs index b3b6ce4fb..d235fbc9c 100644 --- a/src/hyperlight_host/tests/integration_test.rs +++ b/src/hyperlight_host/tests/integration_test.rs @@ -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/tests/rust_guests/simpleguest/src/main.rs b/src/tests/rust_guests/simpleguest/src/main.rs index 0f35fdc59..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