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:
Johnathan Corgan
2026-05-03 21:02:51 +00:00
parent 00f4a4c7af
commit 9204888a54
2 changed files with 234 additions and 2 deletions
+129
View File
@@ -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(&ethertype.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
View File
@@ -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);
}
}
}