feat: add fuzz_helpers module with response validation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Davíð Steinn Geirsson 2026-03-25 22:00:17 +00:00
parent 72e9ba292e
commit c59bb719d5

224
lib/src/fuzz_helpers.rs Normal file
View file

@ -0,0 +1,224 @@
//! Response validation for fuzz targets.
//!
//! Validates that USB/IP responses written to the output side of a MockSocket
//! are well-formed, catching issues that could confuse the host kernel's vhci_hcd.
use std::collections::HashSet;
use crate::usbip_protocol::{
OP_REP_DEVLIST, OP_REP_IMPORT, USBIP_RET_SUBMIT, USBIP_RET_UNLINK, USBIP_VERSION,
};
/// Linux errno: ECONNRESET = 104
const ECONNRESET: i32 = 104;
/// Validate all complete USB/IP responses in the output buffer.
///
/// Silently returns if the buffer is empty or ends with a partial response
/// (expected when the fuzzer hits EOF mid-stream).
///
/// Panics (triggering a fuzz finding) if any complete response violates
/// structural or semantic invariants.
pub fn assert_usbip_responses_valid(output: &[u8]) {
let mut offset = 0;
let mut seen_seqnums: HashSet<u32> = HashSet::new();
while offset < output.len() {
// Try to parse negotiation-phase responses first (version + command header)
if output.len() - offset >= 8 {
let version = u16::from_be_bytes([output[offset], output[offset + 1]]);
let command = u16::from_be_bytes([output[offset + 2], output[offset + 3]]);
if version == USBIP_VERSION {
match command {
OP_REP_DEVLIST => {
offset = validate_op_rep_devlist(output, offset);
if offset == 0 {
return; // partial response, ok
}
continue;
}
OP_REP_IMPORT => {
offset = validate_op_rep_import(output, offset);
if offset == 0 {
return; // partial response, ok
}
continue;
}
_ => {
panic!(
"Unknown negotiation response command: {command:#06x} at offset {offset}"
);
}
}
}
}
// URB-phase responses: 48-byte header minimum
if output.len() - offset < 48 {
return; // partial response, ok
}
let command = u32::from_be_bytes(output[offset..offset + 4].try_into().unwrap());
let seqnum = u32::from_be_bytes(output[offset + 4..offset + 8].try_into().unwrap());
let _devid = u32::from_be_bytes(output[offset + 8..offset + 12].try_into().unwrap());
let direction = u32::from_be_bytes(output[offset + 12..offset + 16].try_into().unwrap());
let _ep = u32::from_be_bytes(output[offset + 16..offset + 20].try_into().unwrap());
assert!(
direction <= 1,
"Invalid direction {direction} in response at offset {offset}"
);
assert!(
seen_seqnums.insert(seqnum),
"Duplicate seqnum {seqnum} in response at offset {offset}"
);
match command {
cmd if cmd == u32::from(USBIP_RET_SUBMIT) => {
let status =
i32::from_be_bytes(output[offset + 20..offset + 24].try_into().unwrap());
let actual_length =
u32::from_be_bytes(output[offset + 24..offset + 28].try_into().unwrap());
let _start_frame =
u32::from_be_bytes(output[offset + 28..offset + 32].try_into().unwrap());
let number_of_packets =
i32::from_be_bytes(output[offset + 32..offset + 36].try_into().unwrap());
let _error_count =
u32::from_be_bytes(output[offset + 36..offset + 40].try_into().unwrap());
// bytes 40..48 are padding
offset += 48;
// Transfer buffer follows the header
let transfer_buf_len = if status < 0 {
// On error, no transfer data
0u32
} else if direction == 1 {
// IN: actual_length bytes of data
actual_length
} else {
// OUT: no data in response
0
};
if output.len() - offset < transfer_buf_len as usize {
return; // partial response, ok
}
offset += transfer_buf_len as usize;
// ISO packet descriptors follow the transfer buffer
let iso_count = if number_of_packets > 0 && status >= 0 {
number_of_packets as u32
} else {
0
};
let iso_bytes = iso_count as usize * 16;
if output.len() - offset < iso_bytes {
return; // partial response, ok
}
// Validate each ISO packet descriptor
for i in 0..iso_count {
let iso_offset = offset + (i as usize * 16);
let pkt_offset = u32::from_be_bytes(
output[iso_offset..iso_offset + 4].try_into().unwrap(),
);
let _pkt_length = u32::from_be_bytes(
output[iso_offset + 4..iso_offset + 8].try_into().unwrap(),
);
let pkt_actual_length = u32::from_be_bytes(
output[iso_offset + 8..iso_offset + 12].try_into().unwrap(),
);
let _pkt_status = i32::from_be_bytes(
output[iso_offset + 12..iso_offset + 16].try_into().unwrap(),
);
// actual data referenced must be within transfer buffer
if pkt_actual_length > 0 {
assert!(
pkt_offset.saturating_add(pkt_actual_length) <= actual_length,
"ISO packet {i}: offset({pkt_offset}) + actual_length({pkt_actual_length}) \
exceeds transfer buffer length({actual_length}) at response offset {offset}"
);
}
}
offset += iso_bytes;
}
cmd if cmd == u32::from(USBIP_RET_UNLINK) => {
let status =
i32::from_be_bytes(output[offset + 20..offset + 24].try_into().unwrap());
// bytes 24..48 are padding
assert!(
status == 0 || status == -(ECONNRESET as i32),
"RET_UNLINK status must be 0 or -ECONNRESET(-{ECONNRESET}), got {status} \
at offset {offset}"
);
offset += 48;
}
_ => {
panic!("Unknown URB response command: {command:#010x} at offset {offset}");
}
}
}
}
/// Validate an OP_REP_DEVLIST response starting at `start`.
/// Returns the new offset after the response, or 0 if the response is partial.
fn validate_op_rep_devlist(output: &[u8], start: usize) -> usize {
// Header: version(2) + command(2) + status(4) + device_count(4) = 12
if output.len() - start < 12 {
return 0;
}
let status = u32::from_be_bytes(output[start + 4..start + 8].try_into().unwrap());
assert_eq!(status, 0, "OP_REP_DEVLIST status must be 0, got {status}");
let device_count =
u32::from_be_bytes(output[start + 8..start + 12].try_into().unwrap());
let mut offset = start + 12;
// Each device: 0x138 bytes (312) + 4 bytes per interface
for _i in 0..device_count {
if output.len() - offset < 0x138 {
return 0; // partial
}
let num_interfaces = u32::from_be_bytes(
output[offset + 0x134..offset + 0x138].try_into().unwrap(),
);
offset += 0x138;
let intf_bytes = num_interfaces as usize * 4;
if output.len() - offset < intf_bytes {
return 0; // partial
}
offset += intf_bytes;
}
offset
}
/// Validate an OP_REP_IMPORT response starting at `start`.
/// Returns the new offset after the response, or 0 if the response is partial.
fn validate_op_rep_import(output: &[u8], start: usize) -> usize {
// Header: version(2) + command(2) + status(4) = 8
if output.len() - start < 8 {
return 0;
}
let status = u32::from_be_bytes(output[start + 4..start + 8].try_into().unwrap());
if status == 0 {
// Success: device descriptor follows (0x138 bytes)
if output.len() - start < 8 + 0x138 {
return 0; // partial
}
start + 8 + 0x138
} else {
// Failure: no device descriptor
start + 8
}
}