Skip to content

Commit b23fc1c

Browse files
authored
feat: add virtqueue transport foundations (#1793)
The patch implements shared layout and ownership model for virtq based host and guest communication. This is a preparation work for replacing stack based IO with virtq, in the meantime function calls and logging use the legacy transport. Actual takeover, buffer mappings, guest allocated pools and retained buffer snapshots are separate stages. - queue configurations and a shared scratch arena for rings and buffer pools. - `SlotPool` allocation, - stateful chain I/O, - shared message framing, - guest queue initialization, - canonical ring state validation --------- Signed-off-by: Tomasz Andrzejak <andreiltd@gmail.com>
1 parent 5e5a5c0 commit b23fc1c

58 files changed

Lines changed: 8377 additions & 3302 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
55
## [Prerelease] - Unreleased
66

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

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

2645
### Removed
46+
* `RunPool` and the run-specific `AllocError::InvalidAlign` variant.
2747

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

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

‎docs/snapshot-oci-format.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@ Three blob kinds per tag:
3131
* **manifest** (`application/vnd.oci.image.manifest.v1+json`). Tiny JSON
3232
pointer record selected via `index.json`. References one config and
3333
one layer by digest.
34-
* **config** (`application/vnd.hyperlight.snapshot.config.v1+json`). The
34+
* **config** (`application/vnd.hyperlight.snapshot.config.v2+json`). The
3535
snapshot descriptor: arch, hypervisor, CPU vendor, ABI version,
36-
resume address and captured registers, memory layout, registered
37-
host functions, snapshot generation counter. Loaded eagerly and
38-
fully parsed.
36+
resume address and captured registers, memory and transport layout,
37+
registered host functions, snapshot generation counter. Loaded
38+
eagerly and fully parsed.
3939
* **layer / memory** (`application/vnd.hyperlight.snapshot.memory.v1`).
4040
The raw guest memory image, exactly `memory_size` bytes. mmap'd on
4141
restore.

‎docs/snapshot-versioning.md‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,17 @@ A snapshot carries three independently evolvable version markers:
1414
[src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs](../src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs)).
1515
This is what the host reads back from a snapshot: the `OutBAction`
1616
and `VmAction` port numbers, the input and output buffer stack
17-
format, the offset and size of each memory region (including the
18-
`HyperlightPEB` size), and the calling convention for guest function
19-
entry. A change to any of these breaks older snapshots unless the
20-
loader adds a compat path.
17+
format, the virtqueue transport layout, the offset and size of each
18+
memory region (including the `HyperlightPEB` size), and the calling
19+
convention for guest function entry. A change to any of these breaks
20+
older snapshots unless the loader adds a compat path.
2121
* **Snapshot blob encoding**, `MT_SNAPSHOT_V1`
2222
(`application/vnd.hyperlight.snapshot.memory.v1`), aliased as
2323
`MT_SNAPSHOT_CURRENT`. This is the on-wire format of the snapshot
2424
blob: framing, section ordering, alignment, dirty/zero-page elision,
2525
anything about how the bytes are packed inside the OCI layer.
26-
* **Config schema**, `MT_CONFIG_V1`
27-
(`application/vnd.hyperlight.snapshot.config.v1+json`), aliased as
26+
* **Config schema**, `MT_CONFIG_V2`
27+
(`application/vnd.hyperlight.snapshot.config.v2+json`), aliased as
2828
`MT_CONFIG_CURRENT`. This is the JSON shape of the config blob:
2929
field names, types, required vs optional, the descriptors the loader
3030
needs in order to reconstruct the sandbox (memory sizes, buffer
@@ -367,4 +367,3 @@ major:
367367
* The loader accepts the old `abi_version` (Option 2 step 4), so the old
368368
golden loads.
369369
* Register the host functions the old golden's checks call.
370-

‎fuzz/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ which evaluates to the following command `cargo +nightly fuzz run fuzz_host_prin
1010

1111
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.
1212

13-
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.
13+
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.
1414

1515
## On Failure
1616

‎fuzz/fuzz_targets/guest_trace.rs‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,7 @@ impl<'a> Arbitrary<'a> for FuzzInput {
5353
// Any unexpected errors from the guest should be reported.
5454
fuzz_target!(
5555
init: {
56-
// In local tests, 256 KiB seemed sufficient for deep recursion
5756
let mu_sbox = SandboxBuilder::from_file(simple_guest_for_fuzzing_as_pathbuf())
58-
.scratch_size(256 * 1024)
5957
.build()
6058
.unwrap();
6159

‎fuzz/fuzz_targets/virtq_packed_ring.rs‎

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ use std::num::NonZeroU16;
88
use std::ops::Range;
99
use std::rc::Rc;
1010

11-
use hyperlight_common::virtq::{Descriptor, Layout, MemOps, RingConsumer};
11+
use hyperlight_common::virtq::canonical::validate_canon_image;
12+
use hyperlight_common::virtq::{Descriptor, Layout, MemOps, RingConsumer, RingError};
1213
use libfuzzer_sys::{Corpus, fuzz_target};
1314

1415
const DEFAULT_QUEUE_SIZE: usize = 16;
@@ -30,6 +31,7 @@ struct FuzzDesc {
3031
#[derive(Clone, Debug)]
3132
struct FuzzCase {
3233
queue_size: usize,
34+
avail_descs: usize,
3335
driver_event_off_wrap: u16,
3436
driver_event_flags: u16,
3537
written_len: u32,
@@ -112,9 +114,9 @@ unsafe impl MemOps for FuzzMem {
112114
}
113115
}
114116

115-
fn write_driver_event(mem: &FuzzMem, layout: Layout, off_wrap: u16, flags: u16) -> Result<(), ()> {
117+
fn write_event(mem: &FuzzMem, addr: u64, off_wrap: u16, flags: u16) -> Result<(), ()> {
116118
mem.write(
117-
layout.drv_evt_addr(),
119+
addr,
118120
&[
119121
(off_wrap & 0xff) as u8,
120122
(off_wrap >> 8) as u8,
@@ -150,7 +152,8 @@ fn parse_case(data: &[u8]) -> Option<FuzzCase> {
150152

151153
let raw_queue_size = read_u16(0);
152154
let queue_size = normalize_queue_size(raw_queue_size);
153-
let desc_count = usize::from(read_u16(2)).min(MAX_DESCS).min(queue_size);
155+
let avail_descs = usize::from(read_u16(2));
156+
let desc_count = avail_descs.min(MAX_DESCS).min(queue_size);
154157

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

178181
Some(FuzzCase {
179182
queue_size,
183+
avail_descs,
180184
driver_event_off_wrap,
181185
driver_event_flags,
182186
written_len,
@@ -194,6 +198,60 @@ fn normalize_queue_size(raw: u16) -> usize {
194198
raw.min(MAX_QUEUE_SIZE)
195199
}
196200

201+
fn fuzz_canon_image(
202+
mem: &FuzzMem,
203+
layout: Layout,
204+
case: &FuzzCase,
205+
payload_base: u64,
206+
) -> Result<(), ()> {
207+
write_event(
208+
mem,
209+
layout.drv_evt_addr(),
210+
case.driver_event_off_wrap,
211+
case.driver_event_flags,
212+
)?;
213+
let _ = validate_canon_image(mem, layout, case.avail_descs, |_, _| true);
214+
215+
write_event(mem, layout.drv_evt_addr(), 0, 0)?;
216+
let canon = validate_canon_image(mem, layout, case.avail_descs, |_, _| true);
217+
218+
let payload_end = payload_base + PAYLOAD_SIZE as u64;
219+
let _ = validate_canon_image(mem, layout, case.avail_descs, |_, elem| {
220+
elem.addr >= payload_base
221+
&& elem
222+
.addr
223+
.checked_add(u64::from(elem.len))
224+
.is_some_and(|end| end <= payload_end)
225+
});
226+
227+
if let Ok(chains) = canon {
228+
let mut consumer = RingConsumer::new(layout, mem.clone());
229+
for expected in chains {
230+
let Ok((id, actual)) = consumer.poll_available() else {
231+
panic!("canonical image was rejected by the ring consumer");
232+
};
233+
assert_eq!(id, expected.id());
234+
assert_eq!(actual.elems().len(), expected.buffers().elems().len());
235+
for (actual, expected) in actual.elems().iter().zip(expected.buffers().elems()) {
236+
assert_eq!(actual.addr, expected.addr);
237+
assert_eq!(actual.len, expected.len);
238+
assert_eq!(actual.writable, expected.writable);
239+
}
240+
}
241+
assert!(matches!(
242+
consumer.poll_available(),
243+
Err(RingError::WouldBlock)
244+
));
245+
}
246+
247+
write_event(
248+
mem,
249+
layout.drv_evt_addr(),
250+
case.driver_event_off_wrap,
251+
case.driver_event_flags,
252+
)
253+
}
254+
197255
fn run_case(case: FuzzCase) -> Corpus {
198256
let Some(num_descs) = NonZeroU16::new(case.queue_size as u16) else {
199257
return Corpus::Reject;
@@ -206,17 +264,6 @@ fn run_case(case: FuzzCase) -> Corpus {
206264
Err(_) => return Corpus::Reject,
207265
};
208266

209-
if write_driver_event(
210-
&mem,
211-
layout,
212-
case.driver_event_off_wrap,
213-
case.driver_event_flags,
214-
)
215-
.is_err()
216-
{
217-
return Corpus::Reject;
218-
}
219-
220267
let payload_base = BASE_ADDR + ring_size as u64;
221268
for (idx, fuzz_desc) in case.descs.iter().enumerate() {
222269
let payload_offset = fuzz_desc.addr_offset as usize % PAYLOAD_SIZE;
@@ -232,6 +279,10 @@ fn run_case(case: FuzzCase) -> Corpus {
232279
}
233280
}
234281

282+
if fuzz_canon_image(&mem, layout, &case, payload_base).is_err() {
283+
return Corpus::Reject;
284+
}
285+
235286
let mut consumer = RingConsumer::new(layout, mem);
236287
for _ in 0..case.poll_count {
237288
let Ok((id, _chain)) = consumer.poll_available() else {

0 commit comments

Comments
 (0)