Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e6249bf
feat(virtq): add stateful chain byte streams
andreiltd Jul 21, 2026
949dad4
refactor(virtq): remove reset api and harden allocation rollback
andreiltd Jul 22, 2026
f4d1234
feat(virtq): add tiered fixed slot allocation
andreiltd Jul 22, 2026
01afb5f
feat(virtq): add canonical packed ring images
andreiltd Jul 23, 2026
9faed99
refactor(layout): model scratch-top metadata
andreiltd Jul 24, 2026
71eeb0a
fix(guest): correct scratch allocator boundary
andreiltd Jul 24, 2026
65e5022
feat(virtq): define virtq transport metadata
andreiltd Jul 27, 2026
d3d84c7
feat(virtq): implement host side memory access
andreiltd Jul 28, 2026
423f1e0
feat(virtq): configure transport geometry
andreiltd Jul 29, 2026
b6dc136
feat(virtq): initialize runtime transport
andreiltd Jul 30, 2026
ed881c8
feat(snapshot): preserve canonical virtq state
andreiltd Jul 31, 2026
7911971
feat(virtq): optimize inflight bookkeeping memory
andreiltd Aug 10, 2026
b1cb8f9
feat(virtq): use grouped allocation for virtq chains
andreiltd Aug 13, 2026
38754eb
refactor(virtq) adjust virtio terminology
andreiltd Aug 11, 2026
fccce45
fix: size foundation memory tests
andreiltd Sep 2, 2026
692855c
fix: add missing license headers
andreiltd Sep 5, 2026
9b213b4
feat(virtq): remove run pool
andreiltd Sep 8, 2026
11a8e9a
fix: inline allocation_exceeds_limit calls
andreiltd Sep 8, 2026
3426164
fix: align up default scratch size to 16K pages
andreiltd Sep 8, 2026
343d4e1
fix: validate queue types at construction
andreiltd Sep 8, 2026
49d5d93
refactor: cleanup queue layout types
andreiltd Sep 9, 2026
cec4878
fix: typos
andreiltd Sep 9, 2026
04c30e4
fix: adjust scratch size for fuzzing
andreiltd Sep 9, 2026
6eade71
refactor: simplify host virtqueue access and snapshot admission
andreiltd Sep 16, 2026
75d5c2c
fix: address code review
andreiltd Sep 16, 2026
2180539
fix: address code review feedback 2
andreiltd Sep 22, 2026
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
24 changes: 23 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Prerelease] - Unreleased

### Added
* Add per-direction virtqueue configuration and account its allocations in
scratch sizing.
* Shared virtqueue framing with a 12-byte `MsgHeader` and external byte values.
* Producer batch completion without notification and segmented payload
extraction without flattening.

### Changed
* Support overriding the guest log level when building or restoring initialized snapshots. Persisted snapshots use ABI version 3 and must be regenerated.
* Support overriding the guest log level when building or restoring initialized
snapshots.
* `Snapshot::save` now writes the guest memory blob sparsely, skipping all-zero
blocks instead of writing them. A guest memory image is mostly untouched
pages, so this cuts the bytes actually written by roughly the proportion of
Expand All @@ -22,10 +28,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
level is `OFF`. `hyperlight_guest_tracing::is_trace_enabled` reports whether
the configured level is above `OFF` rather than whether the tracing state was
allocated.
* **Breaking:** Virtqueue rings and pools occupy host-owned scratch before page
tables. Snapshots use ABI 4 and config schema v2. Existing snapshots must be
regenerated.
* Host virtqueue access uses checked copies and atomics across mapped scratch.
Snapshot admission checks geometry, canonical ring state, and H2G buffer shape.
Consumers validate descriptors and payload accesses during use.
* Virtqueue producers use concrete `SlotPool` allocation and `BufferLease`
ownership. `BufferMap` supplies complete owners exposing initialized bytes.
* `VirtqProducer::reset` is unsafe and requires a stopped peer with no live
consumer-side chain handles.
* `ChainBuilder::build()` allocates readable and writable requests.
`writable_avail()` reserves available upper-tier slots within the descriptor
budget. It may add zero slots to a nonempty chain.

### Removed
* `RunPool` and the run-specific `AllocError::InvalidAlign` variant.

### Fixed
* Use a 16 KiB-aligned default scratch size for Apple Silicon compatibility.
* Guest virtqueue copies reject overlapping buffers before accessing memory.

## [v0.17.0] - 2026-08-27

Expand Down
8 changes: 4 additions & 4 deletions docs/snapshot-oci-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ 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
* **config** (`application/vnd.hyperlight.snapshot.config.v2+json`). The

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is breaking right?

@simongdavies simongdavies Sep 21, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it is we should call out the breaking change in the changelog.md

snapshot descriptor: arch, hypervisor, CPU vendor, ABI version,
resume address and captured registers, memory layout, registered
host functions, snapshot generation counter. Loaded eagerly and
fully parsed.
resume address and captured registers, memory and transport 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.
Expand Down
13 changes: 6 additions & 7 deletions docs/snapshot-versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,17 @@ A snapshot carries three independently evolvable version markers:
[src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs](../src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs)).
This is what the host reads back from a snapshot: the `OutBAction`
and `VmAction` port numbers, the input and output buffer stack
format, the offset and size of each memory region (including the
`HyperlightPEB` size), and the calling convention for guest function
entry. A change to any of these breaks older snapshots unless the
loader adds a compat path.
format, the virtqueue transport layout, the offset and size of each
memory region (including the `HyperlightPEB` size), and the calling
convention for guest function entry. A change to any of these breaks
older snapshots unless the loader adds a compat path.
* **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
* **Config schema**, `MT_CONFIG_V2`
(`application/vnd.hyperlight.snapshot.config.v2+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
Expand Down Expand Up @@ -367,4 +367,3 @@ major:
* The loader accepts the old `abi_version` (Option 2 step 4), so the old
golden loads.
* Register the host functions the old golden's checks call.

2 changes: 1 addition & 1 deletion fuzz/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ which evaluates to the following command `cargo +nightly fuzz run fuzz_host_prin

As per Microsoft's Offensive Research & Security Engineering (MORSE) team, all host exposed functions that receive or interact with guest data must be continuously fuzzed for, at least, 500 million fuzz test cases without any crashes. Because `cargo-fuzz` doesn't support setting a maximum number of iterations; instead, we use the `--max_total_time` flag to set a maximum time to run the fuzzer. We have a GitHub action (acting like a CRON job) that runs the fuzzers for 24 hours every week.

Currently, we fuzz the parameters and return type to a hardcoded `PrintOutput` guest function, the `HostPrint` host function, and the packed virtqueue ring parser. We plan to add more fuzzers in the future.
Currently, we fuzz the parameters and return type to a hardcoded `PrintOutput` guest function, the `HostPrint` host function, the packed virtqueue ring parser, and canonical ring image validation. We plan to add more fuzzers in the future.

## On Failure

Expand Down
2 changes: 0 additions & 2 deletions fuzz/fuzz_targets/guest_trace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,7 @@ impl<'a> Arbitrary<'a> for FuzzInput {
// Any unexpected errors from the guest should be reported.
fuzz_target!(
init: {
// In local tests, 256 KiB seemed sufficient for deep recursion
let mu_sbox = SandboxBuilder::from_file(simple_guest_for_fuzzing_as_pathbuf())
.scratch_size(256 * 1024)
.build()
.unwrap();

Expand Down
81 changes: 66 additions & 15 deletions fuzz/fuzz_targets/virtq_packed_ring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ use std::num::NonZeroU16;
use std::ops::Range;
use std::rc::Rc;

use hyperlight_common::virtq::{Descriptor, Layout, MemOps, RingConsumer};
use hyperlight_common::virtq::canonical::validate_canon_image;
use hyperlight_common::virtq::{Descriptor, Layout, MemOps, RingConsumer, RingError};
use libfuzzer_sys::{Corpus, fuzz_target};

const DEFAULT_QUEUE_SIZE: usize = 16;
Expand All @@ -30,6 +31,7 @@ struct FuzzDesc {
#[derive(Clone, Debug)]
struct FuzzCase {
queue_size: usize,
avail_descs: usize,
driver_event_off_wrap: u16,
driver_event_flags: u16,
written_len: u32,
Expand Down Expand Up @@ -112,9 +114,9 @@ unsafe impl MemOps for FuzzMem {
}
}

fn write_driver_event(mem: &FuzzMem, layout: Layout, off_wrap: u16, flags: u16) -> Result<(), ()> {
fn write_event(mem: &FuzzMem, addr: u64, off_wrap: u16, flags: u16) -> Result<(), ()> {
mem.write(
layout.drv_evt_addr(),
addr,
&[
(off_wrap & 0xff) as u8,
(off_wrap >> 8) as u8,
Expand Down Expand Up @@ -150,7 +152,8 @@ fn parse_case(data: &[u8]) -> Option<FuzzCase> {

let raw_queue_size = read_u16(0);
let queue_size = normalize_queue_size(raw_queue_size);
let desc_count = usize::from(read_u16(2)).min(MAX_DESCS).min(queue_size);
let avail_descs = usize::from(read_u16(2));
let desc_count = avail_descs.min(MAX_DESCS).min(queue_size);

let driver_event_off_wrap = read_u16(4);
let driver_event_flags = read_u16(6);
Expand All @@ -177,6 +180,7 @@ fn parse_case(data: &[u8]) -> Option<FuzzCase> {

Some(FuzzCase {
queue_size,
avail_descs,
driver_event_off_wrap,
driver_event_flags,
written_len,
Expand All @@ -194,6 +198,60 @@ fn normalize_queue_size(raw: u16) -> usize {
raw.min(MAX_QUEUE_SIZE)
}

fn fuzz_canon_image(
mem: &FuzzMem,
layout: Layout,
case: &FuzzCase,
payload_base: u64,
) -> Result<(), ()> {
write_event(
mem,
layout.drv_evt_addr(),
case.driver_event_off_wrap,
case.driver_event_flags,
)?;
let _ = validate_canon_image(mem, layout, case.avail_descs, |_, _| true);

write_event(mem, layout.drv_evt_addr(), 0, 0)?;
let canon = validate_canon_image(mem, layout, case.avail_descs, |_, _| true);

let payload_end = payload_base + PAYLOAD_SIZE as u64;
let _ = validate_canon_image(mem, layout, case.avail_descs, |_, elem| {
elem.addr >= payload_base
&& elem
.addr
.checked_add(u64::from(elem.len))
.is_some_and(|end| end <= payload_end)
});

if let Ok(chains) = canon {
let mut consumer = RingConsumer::new(layout, mem.clone());
for expected in chains {
let Ok((id, actual)) = consumer.poll_available() else {
panic!("canonical image was rejected by the ring consumer");
};
assert_eq!(id, expected.id());
assert_eq!(actual.elems().len(), expected.buffers().elems().len());
for (actual, expected) in actual.elems().iter().zip(expected.buffers().elems()) {
assert_eq!(actual.addr, expected.addr);
assert_eq!(actual.len, expected.len);
assert_eq!(actual.writable, expected.writable);
}
}
assert!(matches!(
consumer.poll_available(),
Err(RingError::WouldBlock)
));
}

write_event(
mem,
layout.drv_evt_addr(),
case.driver_event_off_wrap,
case.driver_event_flags,
)
}

fn run_case(case: FuzzCase) -> Corpus {
let Some(num_descs) = NonZeroU16::new(case.queue_size as u16) else {
return Corpus::Reject;
Expand All @@ -206,17 +264,6 @@ fn run_case(case: FuzzCase) -> Corpus {
Err(_) => return Corpus::Reject,
};

if write_driver_event(
&mem,
layout,
case.driver_event_off_wrap,
case.driver_event_flags,
)
.is_err()
{
return Corpus::Reject;
}

let payload_base = BASE_ADDR + ring_size as u64;
for (idx, fuzz_desc) in case.descs.iter().enumerate() {
let payload_offset = fuzz_desc.addr_offset as usize % PAYLOAD_SIZE;
Expand All @@ -232,6 +279,10 @@ fn run_case(case: FuzzCase) -> Corpus {
}
}

if fuzz_canon_image(&mem, layout, &case, payload_base).is_err() {
return Corpus::Reject;
}

let mut consumer = RingConsumer::new(layout, mem);
for _ in 0..case.poll_count {
let Ok((id, _chain)) = consumer.poll_available() else {
Expand Down
Loading
Loading