mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-05 19:18:25 +00:00
Pin macOS utun AF prefix and BPF frame parsing at unit level
Add #[cfg(target_os = "macos")] unit tests catching macOS-specific regressions before they reach the macos-latest GitHub runner. src/upper/tun.rs: surgical refactor extracts the inline AF_INET6_HEADER constant into module-scope helpers utun_af_inet6_header() (encode) and parse_utun_af_prefix() (decode inverse for round-trip testability). TunWriter::run now calls the helper instead of the inline const; behavior unchanged. Six new tests pin the AF=30 constant matching Darwin, big-endian byte order, encode/parse round-trip, short-buffer rejection, minimum header acceptance with trailing payload, and no-panic on garbage bytes. src/transport/ethernet/socket_macos.rs: existing test mod already covered bpf_wordalign and 5 parse_next_frame cases. Three new tests fill genuine gaps: struct layout pin against kernel ABI, caplen- overrun rejection, full Ethernet header round-trip via parse. Existing tests pre-exist; only adding to the same #[cfg(test)] block.
This commit is contained in:
@@ -554,9 +554,17 @@ fn get_mac_addr(interface: &str) -> Result<[u8; 6], TransportError> {
|
||||
|
||||
// ============================================================================
|
||||
// Unit tests
|
||||
//
|
||||
// The whole `socket_macos.rs` file is `#[cfg(target_os = "macos")]`-included
|
||||
// by `socket.rs`, so this `#[cfg(test)]` mod naturally only compiles on macOS.
|
||||
// The redundant `#[cfg(target_os = "macos")]` below is belt-and-suspenders:
|
||||
// it makes the macOS-only intent explicit so that any future refactor that
|
||||
// includes this file on additional targets won't silently activate macOS-
|
||||
// specific tests.
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(target_os = "macos")]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -814,6 +822,127 @@ mod tests {
|
||||
}
|
||||
assert!(readable, "pipe read end should be readable after write");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// BpfHeader layout pin
|
||||
//
|
||||
// The `bpf_hdr` wire layout is fixed by the macOS kernel. If `BpfHeader`
|
||||
// ever drifts (e.g. someone adds a field, or the timestamp field type
|
||||
// changes), `parse_next_frame` will misread the kernel's frames. Pin
|
||||
// both the size and the per-field byte offsets so any such drift fails
|
||||
// at unit-test time rather than as runtime garbage MAC addresses.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_bpf_header_layout_matches_kernel() {
|
||||
// size_of pinned at type-define site too via `const _: () = assert!`,
|
||||
// but repeating here makes the failure mode obvious in test output.
|
||||
assert_eq!(std::mem::size_of::<BpfHeader>(), 20);
|
||||
// bh_hdrlen lives at offset 16 (4 + 4 + 4 + 4).
|
||||
let hdr = BpfHeader {
|
||||
bh_tstamp_sec: 0,
|
||||
bh_tstamp_usec: 0,
|
||||
bh_caplen: 0,
|
||||
bh_datalen: 0,
|
||||
bh_hdrlen: 0xABCD,
|
||||
_pad: 0,
|
||||
};
|
||||
let bytes: &[u8] =
|
||||
unsafe { std::slice::from_raw_parts(&hdr as *const BpfHeader as *const u8, 20) };
|
||||
assert_eq!(&bytes[16..18], &0xABCDu16.to_ne_bytes());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// parse_next_frame — additional malformed-header rejection cases
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_parse_next_frame_caplen_exceeds_remaining_buffer() {
|
||||
// Build a header that claims more captured data than the buffer
|
||||
// actually holds. parse_next_frame should reject (return None)
|
||||
// rather than read past the end.
|
||||
let hdr_size = std::mem::size_of::<BpfHeader>();
|
||||
let claimed_cap_len: usize = 200; // > what's actually in the buffer
|
||||
let hdr = BpfHeader {
|
||||
bh_tstamp_sec: 0,
|
||||
bh_tstamp_usec: 0,
|
||||
bh_caplen: claimed_cap_len as u32,
|
||||
bh_datalen: claimed_cap_len as u32,
|
||||
bh_hdrlen: hdr_size as u16,
|
||||
_pad: 0,
|
||||
};
|
||||
// Allocate only header + 32 bytes — far short of claimed cap_len.
|
||||
let mut buf = vec![0u8; hdr_size + 32];
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
&hdr as *const BpfHeader as *const u8,
|
||||
buf.as_mut_ptr(),
|
||||
hdr_size,
|
||||
);
|
||||
}
|
||||
let mut out_buf = vec![0u8; 1500];
|
||||
let mut offset = 0usize;
|
||||
// Truncated frame: returns None (skipped, not an error).
|
||||
assert!(parse_next_frame(&buf, &mut offset, buf.len(), &mut out_buf).is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Ethernet-header construction round-trip
|
||||
//
|
||||
// Mirrors the byte layout that `send_to` lays down in front of the
|
||||
// payload, then runs that through `parse_next_frame` to confirm the
|
||||
// source MAC bytes survive the round trip. Pure data — no actual fd.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_ethernet_header_round_trip_via_parse() {
|
||||
// Hand-build the Ethernet header the way send_to() does.
|
||||
let dst_mac: [u8; 6] = [0xff; 6];
|
||||
let src_mac: [u8; 6] = [0x02, 0x00, 0x00, 0x12, 0x34, 0x56];
|
||||
let ethertype: u16 = 0x88B5; // local-experimental EtherType
|
||||
let payload: &[u8] = b"FIPS-frame-payload";
|
||||
|
||||
// Construct the in-buffer BPF frame the kernel would have written:
|
||||
// [bpf_hdr][dst_mac][src_mac][ethertype_be][payload]
|
||||
let cap_len = ETH_HDRLEN + payload.len();
|
||||
let hdr = BpfHeader {
|
||||
bh_tstamp_sec: 0,
|
||||
bh_tstamp_usec: 0,
|
||||
bh_caplen: cap_len as u32,
|
||||
bh_datalen: cap_len as u32,
|
||||
bh_hdrlen: std::mem::size_of::<BpfHeader>() as u16,
|
||||
_pad: 0,
|
||||
};
|
||||
let hdr_size = std::mem::size_of::<BpfHeader>();
|
||||
let total = bpf_wordalign(hdr_size + cap_len);
|
||||
let mut buf = vec![0u8; total];
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
&hdr as *const BpfHeader as *const u8,
|
||||
buf.as_mut_ptr(),
|
||||
hdr_size,
|
||||
);
|
||||
}
|
||||
let frame_start = hdr_size;
|
||||
buf[frame_start..frame_start + 6].copy_from_slice(&dst_mac);
|
||||
buf[frame_start + 6..frame_start + 12].copy_from_slice(&src_mac);
|
||||
buf[frame_start + 12..frame_start + 14].copy_from_slice(ðertype.to_be_bytes());
|
||||
buf[frame_start + ETH_HDRLEN..frame_start + ETH_HDRLEN + payload.len()]
|
||||
.copy_from_slice(payload);
|
||||
|
||||
// Parse it back.
|
||||
let mut out_buf = vec![0u8; 1500];
|
||||
let mut offset = 0usize;
|
||||
let (n, parsed_src) = parse_next_frame(&buf, &mut offset, buf.len(), &mut out_buf)
|
||||
.expect("Some")
|
||||
.expect("Ok");
|
||||
|
||||
// The 14-byte Ethernet header is stripped; only the payload survives.
|
||||
assert_eq!(n, payload.len());
|
||||
assert_eq!(&out_buf[..n], payload);
|
||||
// Source MAC is reported directly from bytes [6..12] of the frame.
|
||||
assert_eq!(parsed_src, src_mac);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the MTU of an interface by index.
|
||||
|
||||
+105
-2
@@ -361,6 +361,41 @@ impl TunDevice {
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS utun protocol family value for IPv6 (matches `<sys/socket.h>`
|
||||
/// `AF_INET6` on Darwin). Used as the 4-byte big-endian packet-info
|
||||
/// header prepended to every utun frame.
|
||||
#[cfg(target_os = "macos")]
|
||||
const UTUN_AF_INET6: u32 = 30;
|
||||
|
||||
/// Build the 4-byte big-endian utun packet-info header for an IPv6 frame.
|
||||
///
|
||||
/// utun devices on macOS require a 4-byte address-family prefix on every
|
||||
/// frame: a single big-endian `u32` carrying the protocol family. For
|
||||
/// IPv6 traffic (the only family FIPS sends) this is `AF_INET6 = 30`,
|
||||
/// which serializes as `[0x00, 0x00, 0x00, 0x1e]`.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[inline]
|
||||
fn utun_af_inet6_header() -> [u8; 4] {
|
||||
UTUN_AF_INET6.to_be_bytes()
|
||||
}
|
||||
|
||||
/// Parse the 4-byte big-endian utun packet-info header.
|
||||
///
|
||||
/// Returns the address-family value (`AF_INET6 = 30` for IPv6 frames),
|
||||
/// or `None` if the buffer is shorter than the 4-byte header. The `tun`
|
||||
/// crate's `Read` impl strips this transparently for us in the read
|
||||
/// path; this helper exists for round-trip testability with
|
||||
/// [`utun_af_inet6_header`] and for any future code path that reads
|
||||
/// from the dup'd fd directly.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[inline]
|
||||
fn parse_utun_af_prefix(buf: &[u8]) -> Option<u32> {
|
||||
if buf.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
Some(u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]))
|
||||
}
|
||||
|
||||
/// Writer thread for TUN device.
|
||||
///
|
||||
/// Services a queue of outbound packets and writes them to the TUN device.
|
||||
@@ -413,10 +448,10 @@ impl TunWriter {
|
||||
#[cfg(target_os = "macos")]
|
||||
let write_result = {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
const AF_INET6_HEADER: [u8; 4] = [0, 0, 0, 30];
|
||||
let af_header = utun_af_inet6_header();
|
||||
let iov = [
|
||||
libc::iovec {
|
||||
iov_base: AF_INET6_HEADER.as_ptr() as *mut libc::c_void,
|
||||
iov_base: af_header.as_ptr() as *mut libc::c_void,
|
||||
iov_len: 4,
|
||||
},
|
||||
libc::iovec {
|
||||
@@ -1482,4 +1517,72 @@ mod tests {
|
||||
assert_eq!(per_flow_max_mss(&lookup, a.as_bytes(), 1360), 1143);
|
||||
assert_eq!(per_flow_max_mss(&lookup, b.as_bytes(), 1360), 1315);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// macOS utun packet-info header (AF_INET6 4-byte big-endian prefix)
|
||||
//
|
||||
// These tests are pure-data byte-buffer manipulation and require no
|
||||
// privilege, no actual TUN device, no system calls. They pin the wire
|
||||
// format that `TunWriter::run` emits ahead of every IPv6 frame on the
|
||||
// dup'd utun fd, and the inverse parse used for round-trip checking.
|
||||
// ========================================================================
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_utun_header {
|
||||
use super::super::{UTUN_AF_INET6, parse_utun_af_prefix, utun_af_inet6_header};
|
||||
|
||||
#[test]
|
||||
fn af_inet6_constant_matches_darwin() {
|
||||
// Darwin's <sys/socket.h> defines AF_INET6 = 30. If this ever
|
||||
// diverges, every utun write FIPS issues will be misclassified
|
||||
// by the kernel and dropped.
|
||||
assert_eq!(UTUN_AF_INET6, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_produces_big_endian_af_inet6() {
|
||||
// The kernel reads the 4-byte prefix as a big-endian u32.
|
||||
// 30 == 0x0000001e, so the wire bytes are [0, 0, 0, 0x1e].
|
||||
let header = utun_af_inet6_header();
|
||||
assert_eq!(header, [0x00, 0x00, 0x00, 0x1e]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_round_trips_through_parse() {
|
||||
let header = utun_af_inet6_header();
|
||||
let parsed = parse_utun_af_prefix(&header).expect("4 bytes is enough");
|
||||
assert_eq!(parsed, UTUN_AF_INET6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_short_buffer() {
|
||||
// Anything shorter than the 4-byte header is ill-formed.
|
||||
assert_eq!(parse_utun_af_prefix(&[]), None);
|
||||
assert_eq!(parse_utun_af_prefix(&[0x00]), None);
|
||||
assert_eq!(parse_utun_af_prefix(&[0x00, 0x00]), None);
|
||||
assert_eq!(parse_utun_af_prefix(&[0x00, 0x00, 0x00]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_minimum_header_with_trailing_payload() {
|
||||
// A real utun read returns header + IP packet concatenated.
|
||||
// The parser only consumes the first 4 bytes.
|
||||
let mut frame = utun_af_inet6_header().to_vec();
|
||||
frame.extend_from_slice(&[0x60; 40]); // dummy IPv6 header
|
||||
let parsed = parse_utun_af_prefix(&frame).expect("4 bytes is enough");
|
||||
assert_eq!(parsed, UTUN_AF_INET6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_garbage_bytes_returns_garbage_value_not_panic() {
|
||||
// A well-formed 4-byte buffer whose value is not AF_INET6
|
||||
// should parse successfully (returning the raw u32) without
|
||||
// panicking. Discriminating "expected" vs "unexpected" AF
|
||||
// values is the caller's responsibility.
|
||||
let buf = [0xde, 0xad, 0xbe, 0xef];
|
||||
let parsed = parse_utun_af_prefix(&buf).expect("4 bytes is enough");
|
||||
assert_eq!(parsed, 0xdeadbeef);
|
||||
assert_ne!(parsed, UTUN_AF_INET6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user