Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Prerelease] - Unreleased

### Added
* Namespaced application metadata on immutable snapshots.
* Per-direction virtqueue configuration through `SandboxConfiguration` and
`SandboxBuilder`, with allocations included in scratch sizing.
* Shared virtqueue framing with a 12-byte `MsgHeader` and external byte values.
Expand Down
16 changes: 14 additions & 2 deletions docs/snapshot-oci-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ Four blob kinds per tag:
* **config** (`application/vnd.hyperlight.snapshot.config.v3+json`). The
snapshot descriptor: arch, hypervisor, CPU vendor, ABI version,
resume address and captured registers, memory and transport layout,
registered host functions, and snapshot generation counter. Loaded
eagerly and fully parsed.
registered host functions, snapshot generation counter, and namespaced
application metadata. 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.
Expand All @@ -51,6 +51,18 @@ The runtime queue protocol and canonical checkpoint are described in
Blob filenames are the sha256 of the blob bytes, so identical blobs
across tags are stored once.

## Application metadata

`Snapshot::with_metadata` creates a snapshot that shares the source snapshot's
sandbox state and stores a serializable value under an application-owned
namespace. `Snapshot::metadata` deserializes the value for that namespace.
The source snapshot remains unchanged.

Metadata is stored in the config blob as a JSON object with namespaces as
keys. Snapshots without metadata omit the field. The loader treats a missing
field as an empty map, so snapshots written before metadata support remain
loadable. Metadata counts toward the config blob size limit.
Comment thread
ludfjig marked this conversation as resolved.

## Transport framing

The transport layer is at most 2 MiB. Integers are unsigned and little-endian.
Expand Down
3 changes: 3 additions & 0 deletions docs/snapshot-versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ 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.

The optional `metadata` field stores application-owned values by namespace.
Writers omit an empty map, and readers treat a missing field as empty.

## Compatibility cleanup

Record compatibility paths here when a future hard snapshot break can remove
Expand Down
6 changes: 6 additions & 0 deletions src/hyperlight_host/src/sandbox/snapshot/file/config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2025 The Hyperlight Authors.

use std::collections::BTreeMap;

use hyperlight_common::flatbuffer_wrappers::function_types::{ParameterType, ReturnType};
use hyperlight_common::flatbuffer_wrappers::host_function_definition::HostFunctionDefinition;
use hyperlight_common::vmem::PAGE_SIZE;
Expand Down Expand Up @@ -199,6 +201,9 @@ pub(super) struct OciSnapshotConfig {
/// `SCRATCH_TOP_SNAPSHOT_GENERATION_OFFSET` is continuous across
/// save/load.
pub(super) snapshot_generation: u64,
/// Application-owned metadata keyed by namespace.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub(super) metadata: BTreeMap<String, serde_json::Value>,
Comment thread
ludfjig marked this conversation as resolved.
}

/// Sizes and permissions of the regions inside the snapshot blob,
Expand Down Expand Up @@ -852,6 +857,7 @@ mod tests {
memory_size: PAGE_SIZE as u64,
host_functions: Vec::new(),
snapshot_generation: 0,
metadata: BTreeMap::new(),
}
}

Expand Down
49 changes: 27 additions & 22 deletions src/hyperlight_host/src/sandbox/snapshot/file/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ impl Snapshot {
cfg: &OciSnapshotConfig,
cfg_bytes: &[u8],
) -> crate::Result<Descriptor> {
let memory_bytes = self.memory.as_slice();
let memory_bytes = self.state.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!(
Expand All @@ -516,7 +516,7 @@ impl Snapshot {
put_blob_if_absent(&blobs_dir, &snapshot_digest, memory_bytes)?;

// Transport blob: the canonical ring image omitted from memory.
let transport = self.virtq.as_ref().ok_or_else(|| {
let transport = self.state.virtq.as_ref().ok_or_else(|| {
crate::new_error!("initialized snapshot has no canonical transport state")
})?;
let transport_bytes = transport::encode(transport)?;
Expand Down Expand Up @@ -585,7 +585,7 @@ impl Snapshot {
}

fn build_config(&self) -> crate::Result<OciSnapshotConfig> {
let (entrypoint_addr, sregs) = match (self.next_action, self.sregs.as_ref()) {
let (entrypoint_addr, sregs) = match (self.state.next_action, self.state.sregs.as_ref()) {
(NextAction::Call(addr), Some(sregs)) => (addr, sregs),
(NextAction::Call(_), None) => {
return Err(crate::new_error!(
Expand All @@ -605,31 +605,32 @@ impl Snapshot {
}
};

if self.virtq.is_none() {
if self.state.virtq.is_none() {
return Err(crate::new_error!(
"initialized snapshot has no canonical transport state"
));
}

let host_functions = match &self.host_functions.host_functions {
let host_functions = match &self.state.host_functions.host_functions {
Comment thread
ludfjig marked this conversation as resolved.
Some(v) => v.iter().map(HostFunction::from).collect(),
None => Vec::new(),
};

let l = &self.layout;
let l = &self.state.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"))?,
cpu_vendor: CpuVendor::current(),
stack_top_gva: self.stack_top_gva,
stack_top_gva: self.state.stack_top_gva,
entrypoint_addr,
original_entrypoint_addr: self.original_entrypoint,
original_entrypoint_addr: self.state.original_entrypoint,
sregs: *sregs,
#[cfg(target_arch = "x86_64")]
msrs: self
.state
.msrs
.as_ref()
.ok_or_else(|| crate::new_error!("snapshot has no MSR state"))?
Expand All @@ -650,9 +651,10 @@ impl Snapshot {
snapshot_size: l.snapshot_size(),
pt_size: l.pt_size(),
},
memory_size: self.memory.mem_size() as u64,
memory_size: self.state.memory.mem_size() as u64,
host_functions,
snapshot_generation: self.snapshot_generation,
snapshot_generation: self.state.snapshot_generation,
metadata: self.metadata.clone(),
})
}

Expand Down Expand Up @@ -957,18 +959,21 @@ impl Snapshot {
};

Ok(Snapshot {
layout,
memory,
load_info: crate::mem::exe::LoadInfo::dummy(),
stack_top_gva: cfg.stack_top_gva,
sregs: Some(cfg.sregs),
#[cfg(target_arch = "x86_64")]
msrs: Some(cfg.msrs),
next_action,
original_entrypoint: cfg.original_entrypoint_addr,
snapshot_generation,
host_functions,
virtq: Some(virtq),
state: std::sync::Arc::new(super::SnapshotState {
layout,
memory,
load_info: crate::mem::exe::LoadInfo::dummy(),
stack_top_gva: cfg.stack_top_gva,
sregs: Some(cfg.sregs),
#[cfg(target_arch = "x86_64")]
msrs: Some(cfg.msrs),
next_action,
original_entrypoint: cfg.original_entrypoint_addr,
snapshot_generation,
host_functions,
virtq: Some(virtq),
}),
metadata: cfg.metadata,
})
}
}
106 changes: 103 additions & 3 deletions src/hyperlight_host/src/sandbox/snapshot/file_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use std::sync::Arc;

use hyperlight_testing::{c_simple_guest_as_pathbuf, simple_guest_as_pathbuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest as _, Sha256};

Expand Down Expand Up @@ -50,6 +51,12 @@ fn create_snapshot() -> Arc<Snapshot> {
sbox.snapshot().unwrap()
}

#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
struct TestMetadata {
format_version: u32,
runtime: String,
}

/// `Result::unwrap_err` requires `T: Debug`, but `Snapshot` is not
/// `Debug`. This wrapper is the test-side equivalent.
#[track_caller]
Expand Down Expand Up @@ -143,6 +150,45 @@ fn from_snapshot_in_memory_pre_init() {
assert_eq!(result, 0);
}

#[test]
fn snapshot_metadata_is_immutable_and_shares_state() {
let snapshot = create_snapshot();
let metadata = TestMetadata {
format_version: 1,
runtime: "test".to_string(),
};

let with_metadata = snapshot
.with_metadata("snapshot-metadata-namespace-v1", &metadata)
.unwrap();

assert!(Arc::ptr_eq(&snapshot.state, &with_metadata.state));
assert_eq!(
snapshot
.metadata::<TestMetadata>("snapshot-metadata-namespace-v1")
.unwrap(),
None
);
assert_eq!(
with_metadata
.metadata::<TestMetadata>("snapshot-metadata-namespace-v1")
.unwrap(),
Some(metadata)
);
}

#[test]
fn snapshot_metadata_namespaces_are_independent() {
let snapshot = create_snapshot();
let first = snapshot.with_metadata("first", &1_u32).unwrap();
let second = first.with_metadata("second", &2_u32).unwrap();
let replaced = second.with_metadata("first", &3_u32).unwrap();

assert_eq!(second.metadata::<u32>("first").unwrap(), Some(1));
assert_eq!(replaced.metadata::<u32>("first").unwrap(), Some(3));
assert_eq!(replaced.metadata::<u32>("second").unwrap(), Some(2));
}

// Round-trip via OCI layout on disk.

#[test]
Expand All @@ -163,6 +209,59 @@ fn round_trip_save_load_call() {
assert_eq!(result, "hello\n");
}

#[test]
fn snapshot_metadata_round_trip() {
let metadata = TestMetadata {
format_version: 1,
runtime: "test".to_string(),
};
let snapshot = create_snapshot()
.with_metadata("snapshot-metadata-namespace-v1", &metadata)
.unwrap();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("metadata");

snapshot
.save(&path, &OciTag::new("latest").unwrap())
.unwrap();
let config: Value =
serde_json::from_slice(&std::fs::read(find_config_blob(&path)).unwrap()).unwrap();
assert_eq!(
config["metadata"],
serde_json::json!({
"snapshot-metadata-namespace-v1": {
"format_version": 1,
"runtime": "test",
}
})
);
let loaded = Snapshot::checked_load(&path, OciTag::new("latest").unwrap()).unwrap();

assert_eq!(
loaded
.metadata::<TestMetadata>("snapshot-metadata-namespace-v1")
.unwrap(),
Some(metadata)
);
}

#[test]
fn snapshot_without_metadata_omits_config_field() {
let snapshot = create_snapshot();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("metadata");

snapshot
.save(&path, &OciTag::new("latest").unwrap())
.unwrap();
let config: Value =
serde_json::from_slice(&std::fs::read(find_config_blob(&path)).unwrap()).unwrap();
let loaded = Snapshot::checked_load(&path, OciTag::new("latest").unwrap()).unwrap();

assert!(config.get("metadata").is_none());
assert_eq!(loaded.metadata::<Value>("missing").unwrap(), None);
}

/// A pre-existing snapshot blob with the right length but wrong
/// bytes (corruption, partial copy, foreign tool) must be detected
/// and replaced by `save`, not silently trusted.
Expand Down Expand Up @@ -438,7 +537,8 @@ fn restore_from_loaded_snapshot() {
fn restore_missing_transport_preserves_target() {
// Remove transport from a snapshot with valid memory and vCPU state.
let mut bad_snapshot = create_snapshot();
Arc::get_mut(&mut bad_snapshot).unwrap().virtq = None;
let snapshot = Arc::get_mut(&mut bad_snapshot).unwrap();
Arc::get_mut(&mut snapshot.state).unwrap().virtq = None;

// Seed guest state and read the mapped file before caching the snapshot.
let file = tempfile::NamedTempFile::new().unwrap();
Expand Down Expand Up @@ -3423,7 +3523,7 @@ fn save_new_tag_into_loaded_layout_preserves_live_mapping() {

// 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 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);

Expand All @@ -3436,7 +3536,7 @@ fn save_new_tag_into_loaded_layout_preserves_live_mapping() {

// The live mapping is unchanged, byte for byte.
assert_eq!(
loaded_a.memory.as_slice(),
loaded_a.memory().as_slice(),
mapping_before.as_slice(),
"live snapshot mapping changed after a new tag was written"
);
Expand Down
Loading
Loading