Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ Each bullet below is a standalone, one-PR-sized deliverable unless noted otherwi
**IPv6** — Full dual-stack support: IPv6 addresses accepted anywhere IPv4 is today, 40-byte IPv6 headers on the wire, NDP (the IPv6 replacement for ARP), and ICMPv6 (echo + errors).

- [x] **1. IPv6 header build/parse** — 40-byte fixed header, plus extension-header chain walk (Hop-by-Hop, Routing, Fragment, Destination Options) to locate the L4 payload. New `dpdk-udp/src/ipv6.rs`. *(PR [#49](https://github.com/gspivey/dpdk-stdlib-rust/pull/49), 34 tests)*
- [ ] **2. UDP over IPv6 checksum** — mandatory IPv6 pseudo-header checksum (unlike IPv4 where UDP checksum is optional). `verify_udp6_checksum` / `udp6_pseudo_header_checksum` helpers parallel to the existing IPv4 helpers.
- [x] **2. UDP over IPv6 checksum** — mandatory IPv6 pseudo-header checksum (unlike IPv4 where UDP checksum is optional). `verify_udp6_checksum` / `udp6_pseudo_header_checksum` helpers parallel to the existing IPv4 helpers. RX path validates IPv6 UDP checksums and rejects zero checksums (RFC 8200 §8.1). *(PR [#61](https://github.com/gspivey/dpdk-stdlib-rust/pull/61), 21 tests)*
- [ ] **3. `SocketAddrV6` through `UdpSocket`** — `bind` / `send_to` / `recv_from` / `connect` / `local_addr` / `peer_addr` accept and return IPv6 addresses. `set_only_v6` / `only_v6` socket option. `AddressFamily` state on the socket so the send/recv paths pick the right wire format.
- [x] **4. IPv6 hardware offload flags** — TX: set `RTE_MBUF_F_TX_IPV6` + `RTE_MBUF_F_TX_UDP_CKSUM` with the IPv6 pseudo-header checksum in the UDP field. RX: validate IPv6 UDP checksums (honor `PKT_RX_L4_CKSUM_GOOD`). Software fallback on NICs without support. `has_tx_ipv6_cksum_offload()` accessor. *(PR [#55](https://github.com/gspivey/dpdk-stdlib-rust/pull/55), 8 tests)*
- [x] **5. Link-local / scope IDs / solicited-node multicast MAC** — `fe80::/10` handling, `%ifindex` scope parsing, `33:33:ff:XX:XX:XX` MAC derivation from the low 24 bits of the target IPv6 address. Prereq for task 6 (NDP).
Expand Down
84 changes: 83 additions & 1 deletion docs/perf-test-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -2750,4 +2750,86 @@ The IPv6 hardware offload change adds a single ethertype comparison (`u16::from_
- rust-dpdk at 700K/64B: 6.44% drop (within normal variance; native-dpdk also shows 7.76% this run vs ~2% in prior runs, indicating ENA rate-limiter variance)
- rust-dpdk at 700K/1400B: 1.02% drop (consistent with Run #23's 1.3%)

**No regressions detected.** The ethertype branch adds zero measurable overhead to the IPv4 hot path. The IPv6 offload code path is not exercised during benchmarks (no IPv6 traffic in integration tests) and will only activate when IPv6 frames are sent through the DPDK backend.
**No regressions detected.** The ethertype branch adds zero measurable overhead to the IPv4 hot path. The IPv6 offload code path is not exercised during benchmarks (no IPv6 traffic in integration tests) and will only activate when IPv6 frames are sent through the DPDK backend.

## Run #25: IPv6 UDP Checksum Validation — Regression Check

| Field | Value |
|-------|-------|
| **Date** | 2026-05-21 |
| **Git Hash** | `60dfc50c` |
| **Branch** | `agent/udp6-checksum-validation` |
| **PR** | [#61](https://github.com/gspivey/dpdk-stdlib-rust/pull/61) |
| **GH Actions Run** | [26227356354](https://github.com/gspivey/dpdk-stdlib-rust/actions/runs/26227356354) |
| **Environment** | Hardware PPS: Graviton (ENA, DPDK 23.11). TRex traffic generator. |

### Changes Since Run #24

1. **IPv6 UDP checksum validation in RX path**: `process_frame_zerocopy()` now parses incoming IPv6/UDP frames via `parse_udp6_packet_ref` and validates the mandatory UDP checksum via `verify_udp6_checksum` (RFC 8200 §8.1).
2. **Zero checksum rejection**: IPv6 frames with UDP checksum field = 0 are dropped (mandatory per RFC, unlike IPv4 where 0 means disabled).
3. **21 new tests** covering VLAN-tagged frames, extension headers, various payload sizes, corruption detection, and RX path integration.

**Key question:** Does the IPv6 fallback parse attempt in the RX path add measurable overhead to IPv4 traffic? (Expected answer: no — `parse_udp6_packet_ref` is only attempted after `parse_udp_packet_ref` returns None, which doesn't happen for IPv4 frames.)

### Results: Hardware (TRex, Graviton)

#### 64-byte packets

| Config | Target PPS | RX pps | Drop % |
|--------|-----------|--------|--------|
| native-dpdk | 70K | 69,998 | 0.00% |
| native-dpdk | 140K | 140,000 | 0.00% |
| native-dpdk | 350K | 349,949 | 0.01% |
| native-dpdk | 700K | 699,888 | 0.02% |
| rust-dpdk | 70K | 69,000 | 1.43% |
| rust-dpdk | 140K | 139,000 | 0.71% |
| rust-dpdk | 350K | 349,000 | 0.29% |
| rust-dpdk | 700K | 698,383 | 0.23% |

#### 512-byte packets

| Config | Target PPS | RX pps | Drop % |
|--------|-----------|--------|--------|
| native-dpdk | 70K | 70,000 | 0.00% |
| native-dpdk | 140K | 140,000 | 0.00% |
| native-dpdk | 350K | 349,993 | 0.00% |
| native-dpdk | 700K | 699,882 | 0.02% |
| rust-dpdk | 70K | 69,000 | 1.43% |
| rust-dpdk | 140K | 139,000 | 0.71% |
| rust-dpdk | 350K | 349,000 | 0.29% |
| rust-dpdk | 700K | 698,726 | 0.18% |

#### 1400-byte packets (near MTU)

| Config | Target PPS | RX pps | Drop % |
|--------|-----------|--------|--------|
| native-dpdk | 70K | 70,000 | 0.00% |
| native-dpdk | 140K | 140,000 | 0.00% |
| native-dpdk | 350K | 349,986 | 0.00% |
| native-dpdk | 700K | 472,950 | 0.78% |
| rust-dpdk | 70K | 69,000 | 1.43% |
| rust-dpdk | 140K | 138,996 | 0.72% |
| rust-dpdk | 350K | 348,973 | 0.29% |
| rust-dpdk | 700K | 475,467 | 0.17% |

#### 8500-byte packets (jumbo)

| Config | Target PPS | RX pps | Drop % |
|--------|-----------|--------|--------|
| native-dpdk | 70K | 70,000 | 0.00% |
| native-dpdk | 140K | 77,260 | 1.39% |
| native-dpdk | 350K | 75,387 | 3.77% |
| rust-dpdk | 70K | 69,000 | 1.43% |
| rust-dpdk | 140K | 77,116 | 1.59% |
| rust-dpdk | 350K | 73,728 | 5.89% |

### Regression Check vs Run #24

The IPv6 UDP checksum validation adds an IPv6 parse fallback path to `process_frame_zerocopy()`. For IPv4 traffic (all benchmark traffic), the IPv6 path is never reached — `parse_udp_packet_ref` succeeds on the first attempt.

- rust-dpdk at 350K/64B: 0.29% drop (identical to Run #24)
- rust-dpdk at 700K/64B: 0.23% drop (excellent — better than Run #24's 6.44% on x86, consistent with Graviton's higher throughput ceiling)
- rust-dpdk at 700K/1400B: 0.17% drop (consistent with prior runs)
- rust-dpdk at 700K/512B: 0.18% drop (consistent)

**No regressions detected.** The IPv6 fallback path adds zero overhead to IPv4 traffic because `parse_udp_packet_ref` succeeds immediately for IPv4 frames, and the IPv6 branch is never entered.
291 changes: 291 additions & 0 deletions dpdk-udp/src/ipv6.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,297 @@ mod tests {
assert_eq!(parsed.src_ip, ll);
}

// --- UDP6 checksum: VLAN-tagged frames ---

#[test]
fn udp6_checksum_valid_on_vlan_tagged_frame() {
// Manually construct a VLAN-tagged IPv6/UDP frame with valid checksum
let payload = b"vlan-cksum";
let inner_len = IPV6_HEADER_LEN + UDP_HEADER_LEN + payload.len();
let total = ETH_HEADER_LEN + crate::VLAN_TAG_LEN + inner_len;
let mut frame = vec![0u8; total];

frame[0..6].copy_from_slice(&DST_MAC);
frame[6..12].copy_from_slice(&SRC_MAC);
frame[12..14].copy_from_slice(&crate::ETH_TYPE_VLAN.to_be_bytes());
frame[14..16].copy_from_slice(&200u16.to_be_bytes());
frame[16..18].copy_from_slice(&ETH_TYPE_IPV6.to_be_bytes());

let l3 = ETH_HEADER_LEN + crate::VLAN_TAG_LEN;
frame[l3] = 0x60;
let udp_len = (UDP_HEADER_LEN + payload.len()) as u16;
frame[l3 + 4..l3 + 6].copy_from_slice(&udp_len.to_be_bytes());
frame[l3 + 6] = IP_PROTO_UDP;
frame[l3 + 7] = 64;
frame[l3 + 8..l3 + 24].copy_from_slice(&src_ip().octets());
frame[l3 + 24..l3 + 40].copy_from_slice(&dst_ip().octets());

let udp_off = l3 + IPV6_HEADER_LEN;
frame[udp_off..udp_off + 2].copy_from_slice(&5000u16.to_be_bytes());
frame[udp_off + 2..udp_off + 4].copy_from_slice(&6000u16.to_be_bytes());
frame[udp_off + 4..udp_off + 6].copy_from_slice(&udp_len.to_be_bytes());
frame[udp_off + UDP_HEADER_LEN..].copy_from_slice(payload);

let cksum = udp6_checksum(
&src_ip(), &dst_ip(),
&frame[udp_off..udp_off + UDP_HEADER_LEN],
payload,
);
frame[udp_off + 6..udp_off + 8].copy_from_slice(&cksum.to_be_bytes());

assert!(verify_udp6_checksum(&frame));
}

#[test]
fn udp6_checksum_detects_corruption_in_vlan_frame() {
let payload = b"vlan-corrupt";
let inner_len = IPV6_HEADER_LEN + UDP_HEADER_LEN + payload.len();
let total = ETH_HEADER_LEN + crate::VLAN_TAG_LEN + inner_len;
let mut frame = vec![0u8; total];

frame[0..6].copy_from_slice(&DST_MAC);
frame[6..12].copy_from_slice(&SRC_MAC);
frame[12..14].copy_from_slice(&crate::ETH_TYPE_VLAN.to_be_bytes());
frame[14..16].copy_from_slice(&100u16.to_be_bytes());
frame[16..18].copy_from_slice(&ETH_TYPE_IPV6.to_be_bytes());

let l3 = ETH_HEADER_LEN + crate::VLAN_TAG_LEN;
frame[l3] = 0x60;
let udp_len = (UDP_HEADER_LEN + payload.len()) as u16;
frame[l3 + 4..l3 + 6].copy_from_slice(&udp_len.to_be_bytes());
frame[l3 + 6] = IP_PROTO_UDP;
frame[l3 + 7] = 64;
frame[l3 + 8..l3 + 24].copy_from_slice(&src_ip().octets());
frame[l3 + 24..l3 + 40].copy_from_slice(&dst_ip().octets());

let udp_off = l3 + IPV6_HEADER_LEN;
frame[udp_off..udp_off + 2].copy_from_slice(&5000u16.to_be_bytes());
frame[udp_off + 2..udp_off + 4].copy_from_slice(&6000u16.to_be_bytes());
frame[udp_off + 4..udp_off + 6].copy_from_slice(&udp_len.to_be_bytes());
frame[udp_off + UDP_HEADER_LEN..].copy_from_slice(payload);

let cksum = udp6_checksum(
&src_ip(), &dst_ip(),
&frame[udp_off..udp_off + UDP_HEADER_LEN],
payload,
);
frame[udp_off + 6..udp_off + 8].copy_from_slice(&cksum.to_be_bytes());

// Corrupt a payload byte
let last = frame.len() - 1;
frame[last] ^= 0xFF;
assert!(!verify_udp6_checksum(&frame));
}

// --- UDP6 checksum: extension headers ---

#[test]
fn udp6_checksum_with_hop_by_hop_extension() {
// Build frame with Hop-by-Hop extension header before UDP
let payload = b"ext-hdr";
let ext_len = 8; // minimum extension header size
let inner_len = IPV6_HEADER_LEN + ext_len + UDP_HEADER_LEN + payload.len();
let total = ETH_HEADER_LEN + inner_len;
let mut frame = vec![0u8; total];

frame[0..6].copy_from_slice(&DST_MAC);
frame[6..12].copy_from_slice(&SRC_MAC);
frame[12..14].copy_from_slice(&ETH_TYPE_IPV6.to_be_bytes());

let l3 = ETH_HEADER_LEN;
frame[l3] = 0x60;
let ipv6_payload_len = (ext_len + UDP_HEADER_LEN + payload.len()) as u16;
frame[l3 + 4..l3 + 6].copy_from_slice(&ipv6_payload_len.to_be_bytes());
frame[l3 + 6] = IP_PROTO_HOPOPT; // Next Header = Hop-by-Hop
frame[l3 + 7] = 64;
frame[l3 + 8..l3 + 24].copy_from_slice(&src_ip().octets());
frame[l3 + 24..l3 + 40].copy_from_slice(&dst_ip().octets());

// Hop-by-Hop extension header (8 bytes)
let ext_off = l3 + IPV6_HEADER_LEN;
frame[ext_off] = IP_PROTO_UDP; // Next Header = UDP
frame[ext_off + 1] = 0; // Length = (0+1)*8 = 8 bytes

// UDP header
let udp_off = ext_off + ext_len;
let udp_len = (UDP_HEADER_LEN + payload.len()) as u16;
frame[udp_off..udp_off + 2].copy_from_slice(&7000u16.to_be_bytes());
frame[udp_off + 2..udp_off + 4].copy_from_slice(&8000u16.to_be_bytes());
frame[udp_off + 4..udp_off + 6].copy_from_slice(&udp_len.to_be_bytes());
frame[udp_off + UDP_HEADER_LEN..].copy_from_slice(payload);

// Compute checksum (pseudo-header uses the IPv6 addresses, not extension headers)
let cksum = udp6_checksum(
&src_ip(), &dst_ip(),
&frame[udp_off..udp_off + UDP_HEADER_LEN],
payload,
);
frame[udp_off + 6..udp_off + 8].copy_from_slice(&cksum.to_be_bytes());

assert!(verify_udp6_checksum(&frame));
}

// --- UDP6 checksum: various payload sizes ---

#[test]
fn udp6_checksum_empty_payload() {
let frame = build_udp6_frame(
&SRC_MAC, &DST_MAC, src_ip(), dst_ip(), 1, 2, b"", 64,
).unwrap();
assert!(verify_udp6_checksum(&frame));
}

#[test]
fn udp6_checksum_single_byte_payload() {
let frame = build_udp6_frame(
&SRC_MAC, &DST_MAC, src_ip(), dst_ip(), 1, 2, b"x", 64,
).unwrap();
assert!(verify_udp6_checksum(&frame));
}

#[test]
fn udp6_checksum_odd_length_payload() {
// Odd-length payload exercises the padding logic in checksum_add
let frame = build_udp6_frame(
&SRC_MAC, &DST_MAC, src_ip(), dst_ip(), 1, 2, b"odd", 64,
).unwrap();
assert!(verify_udp6_checksum(&frame));
}

#[test]
fn udp6_checksum_max_standard_payload() {
let payload = vec![0xAB; MAX_UDP_PAYLOAD_V6];
let frame = build_udp6_frame(
&SRC_MAC, &DST_MAC, src_ip(), dst_ip(), 1, 2, &payload, 64,
).unwrap();
assert!(verify_udp6_checksum(&frame));
}

// --- UDP6 checksum: corruption in different fields ---

#[test]
fn udp6_checksum_detects_src_ip_corruption() {
let mut frame = build_udp6_frame(
&SRC_MAC, &DST_MAC, src_ip(), dst_ip(), 1, 2, b"data", 64,
).unwrap();
// Corrupt source IP (byte 8 of IPv6 header)
frame[ETH_HEADER_LEN + 8] ^= 0x01;
assert!(!verify_udp6_checksum(&frame));
}

#[test]
fn udp6_checksum_detects_dst_ip_corruption() {
let mut frame = build_udp6_frame(
&SRC_MAC, &DST_MAC, src_ip(), dst_ip(), 1, 2, b"data", 64,
).unwrap();
// Corrupt destination IP (byte 24 of IPv6 header)
frame[ETH_HEADER_LEN + 24] ^= 0x01;
assert!(!verify_udp6_checksum(&frame));
}

#[test]
fn udp6_checksum_detects_src_port_corruption() {
let mut frame = build_udp6_frame(
&SRC_MAC, &DST_MAC, src_ip(), dst_ip(), 1000, 2000, b"data", 64,
).unwrap();
let udp_off = ETH_HEADER_LEN + IPV6_HEADER_LEN;
frame[udp_off] ^= 0x01; // corrupt src port high byte
assert!(!verify_udp6_checksum(&frame));
}

#[test]
fn udp6_checksum_detects_dst_port_corruption() {
let mut frame = build_udp6_frame(
&SRC_MAC, &DST_MAC, src_ip(), dst_ip(), 1000, 2000, b"data", 64,
).unwrap();
let udp_off = ETH_HEADER_LEN + IPV6_HEADER_LEN;
frame[udp_off + 2] ^= 0x01; // corrupt dst port high byte
assert!(!verify_udp6_checksum(&frame));
}

// --- UDP6 checksum: edge cases ---

#[test]
fn udp6_checksum_rejects_truncated_frame() {
let frame = build_udp6_frame(
&SRC_MAC, &DST_MAC, src_ip(), dst_ip(), 1, 2, b"data", 64,
).unwrap();
// Truncate to just past the UDP header (missing payload)
let truncated = &frame[..ETH_HEADER_LEN + IPV6_HEADER_LEN + UDP_HEADER_LEN];
// The UDP length field says there's payload, but it's missing
assert!(!verify_udp6_checksum(truncated));
}

#[test]
fn udp6_checksum_rejects_non_ipv6_frame() {
// Build an IPv4 frame
let frame = crate::build_udp_frame(
&SRC_MAC, &DST_MAC,
"10.0.0.1".parse().unwrap(),
"10.0.0.2".parse().unwrap(),
1000, 2000, b"ipv4", 64,
).unwrap();
assert!(!verify_udp6_checksum(&frame));
}

#[test]
fn udp6_checksum_rejects_too_short_frame() {
// Frame too short to contain even Ethernet + IPv6 header
let frame = vec![0u8; 20];
assert!(!verify_udp6_checksum(&frame));
}

// --- UDP6 pseudo-header checksum properties ---

#[test]
fn udp6_pseudo_header_checksum_different_lengths() {
let a = udp6_pseudo_header_checksum(&src_ip(), &dst_ip(), 8);
let b = udp6_pseudo_header_checksum(&src_ip(), &dst_ip(), 100);
assert_ne!(a, b);
}

#[test]
fn udp6_pseudo_header_checksum_different_src() {
let other_src: Ipv6Addr = "2001:db8::ff".parse().unwrap();
let a = udp6_pseudo_header_checksum(&src_ip(), &dst_ip(), 16);
let b = udp6_pseudo_header_checksum(&other_src, &dst_ip(), 16);
assert_ne!(a, b);
}

#[test]
fn udp6_pseudo_header_checksum_different_dst() {
let other_dst: Ipv6Addr = "2001:db8::ff".parse().unwrap();
let a = udp6_pseudo_header_checksum(&src_ip(), &dst_ip(), 16);
let b = udp6_pseudo_header_checksum(&src_ip(), &other_dst, 16);
assert_ne!(a, b);
}

#[test]
fn udp6_pseudo_header_checksum_large_length() {
// Test with a length > 65535 (uses the 32-bit upper-layer length field)
let phc = udp6_pseudo_header_checksum(&src_ip(), &dst_ip(), 70000);
assert_ne!(phc, 0);
}

// --- UDP6 checksum: computed value of 0 becomes 0xFFFF ---

#[test]
fn udp6_checksum_zero_becomes_ffff() {
// RFC 8200 §8.1: if the computed checksum is zero, it is transmitted as 0xFFFF
// We can't easily construct a payload that produces exactly zero, but we can
// verify the function's contract: the return value is never 0.
// Test with many different payloads to increase confidence.
for i in 0..256u16 {
let payload = [i as u8; 1];
let cksum = udp6_checksum(
&src_ip(), &dst_ip(),
&[0x00, 0x01, 0x00, 0x02, 0x00, 0x09, 0x00, 0x00], // ports 1,2 len 9
&payload,
);
assert_ne!(cksum, 0, "checksum must never be 0 for IPv6");
}
}

// --- Synthetic performance benchmark ---

#[test]
Expand Down
Loading
Loading