diff --git a/lib/src/fuzz_helpers.rs b/lib/src/fuzz_helpers.rs new file mode 100644 index 0000000..5c8d836 --- /dev/null +++ b/lib/src/fuzz_helpers.rs @@ -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 = 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 + } +}