Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6019721
feat(virtq): add stateful chain byte streams
andreiltd Jul 21, 2026
cf03a3e
refactor(virtq): remove reset api and harden allocation rollback
andreiltd Jul 22, 2026
317d073
feat(virtq): add tiered fixed slot allocation
andreiltd Jul 22, 2026
1eb9b4c
feat(virtq): add canonical packed ring images
andreiltd Jul 23, 2026
197a682
refactor(layout): model scratch-top metadata
andreiltd Jul 24, 2026
22e7083
fix(guest): correct scratch allocator boundary
andreiltd Jul 24, 2026
508d124
feat(virtq): define virtq transport metadata
andreiltd Jul 27, 2026
b0c9700
feat(virtq): implement host side memory access
andreiltd Jul 28, 2026
a78cac3
feat(virtq): configure transport geometry
andreiltd Jul 29, 2026
d7b9964
feat(virtq): initialize runtime transport
andreiltd Jul 30, 2026
6ec6d01
feat(snapshot): preserve canonical virtq state
andreiltd Jul 31, 2026
24e5bb8
feat(virtq): optimize inflight bookkeeping memory
andreiltd Aug 10, 2026
672d2b6
feat(virtq): use grouped allocation for virtq chains
andreiltd Aug 13, 2026
afe3afc
refactor(virtq) adjust virtio terminology
andreiltd Aug 11, 2026
6e0d086
fix: size foundation memory tests
andreiltd Sep 2, 2026
9b5f70c
fix: add missing license headers
andreiltd Sep 5, 2026
301c77e
feat(virtq): remove run pool
andreiltd Sep 8, 2026
dad0769
fix: inline allocation_exceeds_limit calls
andreiltd Sep 8, 2026
cadc366
fix: align up default scratch size to 16K pages
andreiltd Sep 8, 2026
6e812a1
fix: validate queue types at construction
andreiltd Sep 8, 2026
8fb02e9
refactor: cleanup queue layout types
andreiltd Sep 9, 2026
8b73c1f
fix: typos
andreiltd Sep 9, 2026
1f9ce21
fix: adjust scratch size for fuzzing
andreiltd Sep 9, 2026
f004c35
refactor: simplify host virtqueue access and snapshot admission
andreiltd Sep 16, 2026
ff9cd1a
fix: address code review
andreiltd Sep 16, 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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ 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
* `Snapshot::save` now writes the guest memory blob sparsely, skipping all-zero
Expand All @@ -17,10 +22,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
* Expose C guest `ByteChunks` values as pointer and length arrays.
* Return typed `hl_ReturnValue` objects from C guest functions through
`hl_result_from_*` constructors.
* Place virtqueue rings and pools in host-owned scratch before page tables.
Snapshot ABI 3 rejects snapshots created with earlier layouts.
* 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
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