From 9204888a5462d3a38338c264aeea55d79a7ace6c Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Sun, 3 May 2026 17:36:11 +0000 Subject: [PATCH] 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. --- src/transport/ethernet/socket_macos.rs | 129 +++++++++++++++++++++++++ src/upper/tun.rs | 107 +++++++++++++++++++- 2 files changed, 234 insertions(+), 2 deletions(-) diff --git a/src/transport/ethernet/socket_macos.rs b/src/transport/ethernet/socket_macos.rs index 3ae36710..4f24fdc0 100644 --- a/src/transport/ethernet/socket_macos.rs +++ b/src/transport/ethernet/socket_macos.rs @@ -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::(), 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::(); + 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::() as u16, + _pad: 0, + }; + let hdr_size = std::mem::size_of::(); + 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. diff --git a/src/upper/tun.rs b/src/upper/tun.rs index ebfda830..0d01dcbe 100644 --- a/src/upper/tun.rs +++ b/src/upper/tun.rs @@ -361,6 +361,41 @@ impl TunDevice { } } +/// macOS utun protocol family value for IPv6 (matches `` +/// `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 { + 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 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); + } + } }