summaryrefslogtreecommitdiff
path: root/src/net/ip_test.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 12:40:53 -0300
committerGabriel Schneider <[email protected]>2026-08-25 12:46:51 -0300
commitf5f8068fac59b4f16046c2022c2fc7c7e447ef4c (patch)
tree2731a3ed4e51cae09e184e25778eded5fc37d1f5 /src/net/ip_test.zig
downloadesp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.tar.gz
esp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.zip
zig-p4: pure-Zig ESP32-P4 toolchain
build.zig generates the linker script and drives Zig's own LLD; tools/image.zig turns the ELF into a flashable image and tools/{rom,serial}.zig speak the mask ROM loader over the UART. No CMake, ninja, idf.py, esptool, or external linker. src/soc.zig is a comptime register model over ESP-IDF's own *_reg.h headers; src/hal/ adds peripheral sequences; src/io/ implements std.Io for the chip; src/oracle/ diffs this HAL against ESP-IDF's on the die.
Diffstat (limited to 'src/net/ip_test.zig')
-rw-r--r--src/net/ip_test.zig3029
1 files changed, 3029 insertions, 0 deletions
diff --git a/src/net/ip_test.zig b/src/net/ip_test.zig
new file mode 100644
index 0000000..3e4c1f7
--- /dev/null
+++ b/src/net/ip_test.zig
@@ -0,0 +1,3029 @@
+//! Host tests for the IPv4 stack.
+//!
+//! This is the one slice of the P4 bring-up that can be *proven* without the board, and this file is
+//! the proof. The stack takes frames through `onFrame` and time through `tick`, so a network here is
+//! a function that writes bytes by hand and reads back whatever the stack handed to its `send`
+//! callback. Nothing is mocked, nothing is stubbed: the code under test is the code that will run on
+//! the die, byte for byte.
+//!
+//! Two rules keep this honest:
+//!
+//! * **The headers are re-derived here.** These tests do not import `ip.zig`'s offset tables; they
+//! write literal offsets taken from the RFCs and from lwIP's packed structs. A test that shared
+//! the constant it was checking would pass on a consistent misreading of the RFC, which is
+//! exactly the failure mode this stack has to avoid. Where the two transcriptions disagree, one
+//! of them is wrong and the test says so.
+//! * **Every checksum is verified, never merely computed.** A checksum built by the same helper
+//! the stack uses would prove nothing. `verify` below sums the received bytes independently and
+//! asserts the fold is zero, which is the property a peer's kernel will check.
+//!
+//! Run with: zig build test
+
+const std = @import("std");
+const testing = std.testing;
+const ip = @import("ip.zig");
+
+// ============================================================================ capture rig
+//
+// `Stack.init` takes `*const fn ([]const u8) void` - no context pointer - so the captured frames
+// have to live somewhere a plain function can reach. That is a wart in the interface, not in the
+// stack, and the cost is this file-scope buffer.
+
+const cap_max = 32;
+var cap_bytes: [cap_max][ip.frame_max]u8 = undefined;
+var cap_lens: [cap_max]usize = undefined;
+var cap_n: usize = 0;
+var cap_over: usize = 0;
+
+fn capture(frame: []const u8) void {
+ if (cap_n == cap_max) {
+ cap_over += 1;
+ return;
+ }
+ @memcpy(cap_bytes[cap_n][0..frame.len], frame);
+ cap_lens[cap_n] = frame.len;
+ cap_n += 1;
+}
+
+fn clearCapture() void {
+ cap_n = 0;
+ cap_over = 0;
+}
+
+fn sent(i: usize) []const u8 {
+ return cap_bytes[i][0..cap_lens[i]];
+}
+
+fn lastSent() []const u8 {
+ return sent(cap_n - 1);
+}
+
+/// A stack with a MAC and an empty capture log. Every test starts here.
+fn newStack() ip.Stack {
+ clearCapture();
+ return .init(our_mac, capture);
+}
+
+const our_mac: ip.Mac = .{ 0x40, 0x4c, 0xca, 0xfe, 0x00, 0x01 };
+const gw_mac: ip.Mac = .{ 0x02, 0x00, 0x00, 0x11, 0x22, 0x33 };
+const peer_mac: ip.Mac = .{ 0x02, 0x00, 0x00, 0xaa, 0xbb, 0xcc };
+const our_ip: ip.Ip4 = .{ 192, 168, 1, 42 };
+const gw_ip: ip.Ip4 = .{ 192, 168, 1, 1 };
+const mask24: ip.Ip4 = .{ 255, 255, 255, 0 };
+const peer_ip: ip.Ip4 = .{ 192, 168, 1, 90 };
+const off_net_ip: ip.Ip4 = .{ 93, 184, 216, 34 };
+const bcast_mac: ip.Mac = .{ 0xff, 0xff, 0xff, 0xff, 0xff, 0xff };
+/// RFC 826: the target hardware address of a request is "don't care".
+const zero_mac: ip.Mac = .{ 0, 0, 0, 0, 0, 0 };
+
+// ================================================================== independent primitives
+//
+// Header offsets written out again, from the RFCs. See the note at the top of the file.
+
+/// RFC 1071. Written differently from `ip.Checksum` on purpose: a `u32` accumulator over
+/// `readInt`-free manual pairing, so a mistake in one is not a mistake in both.
+fn sum16(bytes: []const u8) u32 {
+ var s: u32 = 0;
+ var i: usize = 0;
+ while (i + 1 < bytes.len) : (i += 2) {
+ s += (@as(u32, bytes[i]) << 8) | bytes[i + 1];
+ }
+ if (i < bytes.len) s += @as(u32, bytes[i]) << 8;
+ while (s >> 16 != 0) s = (s & 0xffff) + (s >> 16);
+ return s;
+}
+
+/// The property every receiver relies on: a buffer that already contains its own checksum sums to
+/// 0xffff, so the complement is zero.
+fn verify(bytes: []const u8) !void {
+ try testing.expectEqual(@as(u32, 0xffff), sum16(bytes));
+}
+
+fn verifyTransport(src: ip.Ip4, dst: ip.Ip4, proto: u8, seg: []const u8) !void {
+ var ph: [12]u8 = undefined;
+ @memcpy(ph[0..4], &src);
+ @memcpy(ph[4..8], &dst);
+ ph[8] = 0;
+ ph[9] = proto;
+ std.mem.writeInt(u16, ph[10..12], @intCast(seg.len), .big);
+ var s = sum16(&ph) + sum16(seg);
+ while (s >> 16 != 0) s = (s & 0xffff) + (s >> 16);
+ try testing.expectEqual(@as(u32, 0xffff), s);
+}
+
+fn be16(b: []const u8, off: usize) u16 {
+ return std.mem.readInt(u16, b[off..][0..2], .big);
+}
+fn be32(b: []const u8, off: usize) u32 {
+ return std.mem.readInt(u32, b[off..][0..4], .big);
+}
+fn put16(b: []u8, off: usize, v: u16) void {
+ std.mem.writeInt(u16, b[off..][0..2], v, .big);
+}
+fn put32(b: []u8, off: usize, v: u32) void {
+ std.mem.writeInt(u32, b[off..][0..4], v, .big);
+}
+
+/// A scratch frame under construction. `len` is the total frame length.
+const Frame = struct {
+ buf: [ip.frame_max]u8 = undefined,
+ len: usize = 0,
+
+ /// Ethernet II: 6 destination, 6 source, 2 ethertype. RFC 894 / lwIP `prot/ethernet.h:76-83`.
+ fn eth(self: *Frame, dst: ip.Mac, src: ip.Mac, ethertype: u16) void {
+ @memcpy(self.buf[0..6], &dst);
+ @memcpy(self.buf[6..12], &src);
+ put16(&self.buf, 12, ethertype);
+ self.len = 14;
+ }
+
+ /// RFC 791 3.1. Fills the header and returns the payload slice to be written; the caller then
+ /// calls `sealIp`.
+ fn ip4(self: *Frame, src: ip.Ip4, dst: ip.Ip4, proto: u8, payload_len: usize) []u8 {
+ const h = self.buf[14..][0..20];
+ h[0] = 0x45;
+ h[1] = 0;
+ put16(h, 2, @intCast(20 + payload_len));
+ put16(h, 4, 0x1234);
+ put16(h, 6, 0);
+ h[8] = 64;
+ h[9] = proto;
+ put16(h, 10, 0);
+ @memcpy(h[12..16], &src);
+ @memcpy(h[16..20], &dst);
+ self.len = 14 + 20 + payload_len;
+ return self.buf[34 .. 34 + payload_len];
+ }
+
+ fn sealIp(self: *Frame) void {
+ const h = self.buf[14..][0..20];
+ put16(h, 10, 0);
+ put16(h, 10, ~@as(u16, @truncate(sum16(h))));
+ }
+
+ /// Fill in a UDP or TCP checksum over the pseudo-header plus the segment.
+ fn sealTransport(self: *Frame, chksum_off: usize) void {
+ const h = self.buf[14..][0..20];
+ const proto = h[9];
+ const seg = self.buf[34..self.len];
+ var ph: [12]u8 = undefined;
+ @memcpy(ph[0..4], h[12..16]);
+ @memcpy(ph[4..8], h[16..20]);
+ ph[8] = 0;
+ ph[9] = proto;
+ std.mem.writeInt(u16, ph[10..12], @intCast(seg.len), .big);
+ put16(seg, chksum_off, 0);
+ var s = sum16(&ph) + sum16(seg);
+ while (s >> 16 != 0) s = (s & 0xffff) + (s >> 16);
+ put16(seg, chksum_off, ~@as(u16, @truncate(s)));
+ self.sealIp();
+ }
+
+ fn bytes(self: *const Frame) []const u8 {
+ return self.buf[0..self.len];
+ }
+};
+
+/// RFC 826 packet format, 28 bytes. lwIP `prot/etharp.h:86-96`.
+fn arpFrame(opcode: u16, sha: ip.Mac, spa: ip.Ip4, tha: ip.Mac, tpa: ip.Ip4, eth_dst: ip.Mac) Frame {
+ var f: Frame = .{};
+ f.eth(eth_dst, sha, 0x0806);
+ const a = f.buf[14..][0..28];
+ put16(a, 0, 1); // hwtype: Ethernet
+ put16(a, 2, 0x0800); // proto: IPv4
+ a[4] = 6;
+ a[5] = 4;
+ put16(a, 6, opcode);
+ @memcpy(a[8..14], &sha);
+ @memcpy(a[14..18], &spa);
+ @memcpy(a[18..24], &tha);
+ @memcpy(a[24..28], &tpa);
+ f.len = 14 + 28;
+ return f;
+}
+
+/// RFC 792 echo. `payload` is the data after the 8-byte header.
+fn icmpEchoFrame(src: ip.Ip4, dst: ip.Ip4, id: u16, seq: u16, payload: []const u8) Frame {
+ var f: Frame = .{};
+ f.eth(our_mac, peer_mac, 0x0800);
+ const p = f.ip4(src, dst, 1, 8 + payload.len);
+ p[0] = 8; // echo request
+ p[1] = 0;
+ put16(p, 2, 0);
+ put16(p, 4, id);
+ put16(p, 6, seq);
+ @memcpy(p[8..], payload);
+ // ICMP has no pseudo-header (RFC 792): the checksum covers the message alone.
+ put16(p, 2, ~@as(u16, @truncate(sum16(p))));
+ f.sealIp();
+ return f;
+}
+
+// ================================================================================= checksum
+
+test "RFC 1071 worked example" {
+ // RFC 1071 section 3, the byte sequence spelled out in the document's own figure:
+ // 00 01 f2 03 f4 f5 f6 f7 -> sum ddf2, checksum 220d
+ const data = [_]u8{ 0x00, 0x01, 0xf2, 0x03, 0xf4, 0xf5, 0xf6, 0xf7 };
+ try testing.expectEqual(@as(u32, 0xddf2), sum16(&data));
+ try testing.expectEqual(@as(u16, 0x220d), ip.checksum(&data));
+}
+
+test "checksum: incremental feeding matches contiguous, including at odd boundaries" {
+ // The bug this catches is a chunk of odd length leaving the high byte of a word unaccounted
+ // for. Splitting at every possible offset is cheap and total.
+ const data = [_]u8{ 0x45, 0x00, 0x00, 0x54, 0xab, 0xcd, 0x40, 0x00, 0x40, 0x01, 0x00, 0x00, 0xc0, 0xa8, 0x01, 0x2a, 0xc0, 0xa8, 0x01, 0x01, 0x7f };
+ const want = ip.checksum(&data);
+ var split: usize = 0;
+ while (split <= data.len) : (split += 1) {
+ var c: ip.Checksum = .{};
+ c.update(data[0..split]);
+ c.update(data[split..]);
+ try testing.expectEqual(want, c.final());
+ }
+ // Three-way split too, so two consecutive odd chunks are exercised.
+ var i: usize = 0;
+ while (i < data.len) : (i += 1) {
+ var j: usize = i;
+ while (j < data.len) : (j += 1) {
+ var c: ip.Checksum = .{};
+ c.update(data[0..i]);
+ c.update(data[i..j]);
+ c.update(data[j..]);
+ try testing.expectEqual(want, c.final());
+ }
+ }
+}
+
+test "checksum: an odd-length buffer is padded with a zero byte, not with the previous byte" {
+ // RFC 1071 section 1. A three-byte buffer must checksum as if it were four with a trailing 0.
+ const odd = [_]u8{ 0xde, 0xad, 0xbe };
+ const padded = [_]u8{ 0xde, 0xad, 0xbe, 0x00 };
+ try testing.expectEqual(ip.checksum(&padded), ip.checksum(&odd));
+}
+
+test "checksum: an all-zero buffer checksums to 0xffff, never to 0x0000" {
+ // A transmitted zero means "no checksum" in UDP, so the distinction is load-bearing.
+ const zeros: [20]u8 = @splat(0);
+ try testing.expectEqual(@as(u16, 0xffff), ip.checksum(&zeros));
+}
+
+test "checksum: RFC 768's transmitted zero is sent as 0xffff" {
+ // A UDP checksum field of zero means "not computed", so a datagram whose checksum genuinely
+ // works out to zero must transmit the arithmetically equivalent 0xffff instead. Tested on the
+ // helper because the case cannot be provoked by choosing DHCP option bytes: it depends on the
+ // whole datagram, headers included, summing to exactly 0xffff.
+ try testing.expectEqual(@as(u16, 0xffff), ip.udpChecksumOnWire(0));
+ try testing.expectEqual(@as(u16, 0xffff), ip.udpChecksumOnWire(0xffff));
+ try testing.expectEqual(@as(u16, 0x1234), ip.udpChecksumOnWire(0x1234));
+}
+
+test "checksum: a real IPv4 header verifies to zero once its own checksum is in place" {
+ var h = [_]u8{ 0x45, 0x00, 0x00, 0x3c, 0x1c, 0x46, 0x40, 0x00, 0x40, 0x06, 0x00, 0x00, 0xac, 0x10, 0x0a, 0x63, 0xac, 0x10, 0x0a, 0x0c };
+ const c = ip.checksum(&h);
+ put16(&h, 10, c);
+ try verify(&h);
+ // And the classic published value for this header, from the Wikipedia/Comer worked example.
+ try testing.expectEqual(@as(u16, 0xb1e6), c);
+}
+
+// ====================================================================================== ARP
+
+test "ARP: a request for our address is answered, and the reply is well formed" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ // setStatic announces; drop that so the reply is the only frame under test.
+ clearCapture();
+
+ var req = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(req.bytes());
+
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const r = lastSent();
+ try testing.expectEqual(@as(usize, 42), r.len);
+ // Unicast back to the requester, not broadcast: a broadcast reply is legal but wasteful, and
+ // every stack on the segment would have to parse it.
+ try testing.expectEqualSlices(u8, &peer_mac, r[0..6]);
+ try testing.expectEqualSlices(u8, &our_mac, r[6..12]);
+ try testing.expectEqual(@as(u16, 0x0806), be16(r, 12));
+
+ const a = r[14..42];
+ try testing.expectEqual(@as(u16, 1), be16(a, 0)); // hwtype Ethernet
+ try testing.expectEqual(@as(u16, 0x0800), be16(a, 2)); // proto IPv4
+ try testing.expectEqual(@as(u8, 6), a[4]);
+ try testing.expectEqual(@as(u8, 4), a[5]);
+ try testing.expectEqual(@as(u16, 2), be16(a, 6)); // reply
+ try testing.expectEqualSlices(u8, &our_mac, a[8..14]); // sender hw = us
+ try testing.expectEqualSlices(u8, &our_ip, a[14..18]); // sender proto = us
+ try testing.expectEqualSlices(u8, &peer_mac, a[18..24]); // target hw = requester
+ try testing.expectEqualSlices(u8, &peer_ip, a[24..28]);
+}
+
+test "ARP: a request for somebody else's address is ignored" {
+ var s = newStack();
+ s.setStatic(our_ip, mask24, gw_ip);
+ clearCapture();
+ var req = arpFrame(1, peer_mac, peer_ip, zero_mac, .{ 192, 168, 1, 77 }, bcast_mac);
+ s.onFrame(req.bytes());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+}
+
+test "ARP: a malformed header is rejected on all four RFC 826 reception checks" {
+ const bad_fields = [_]struct { off: usize, val: u8 }{
+ .{ .off = 1, .val = 2 }, // hwtype 2, not Ethernet
+ .{ .off = 3, .val = 0x06 }, // proto 0x0806, not IPv4
+ .{ .off = 4, .val = 8 }, // hwlen 8
+ .{ .off = 5, .val = 16 }, // protolen 16
+ };
+ for (bad_fields) |bad| {
+ var s = newStack();
+ s.setStatic(our_ip, mask24, gw_ip);
+ clearCapture();
+ var req = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ req.buf[14 + bad.off] = bad.val;
+ s.onFrame(req.bytes());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ }
+}
+
+test "ARP: setStatic announces the address gratuitously" {
+ var s = newStack();
+ s.tick(500);
+ s.setStatic(our_ip, mask24, gw_ip);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const g = lastSent();
+ try testing.expectEqualSlices(u8, &bcast_mac, g[0..6]);
+ try testing.expectEqual(@as(u16, 0x0806), be16(g, 12));
+ const a = g[14..42];
+ try testing.expectEqual(@as(u16, 1), be16(a, 6)); // a request...
+ try testing.expectEqualSlices(u8, &our_ip, a[14..18]); // ...whose sender...
+ try testing.expectEqualSlices(u8, &our_ip, a[24..28]); // ...and target are both us
+}
+
+test "ARP: a four-entry cache is not thrashed by unrelated broadcast traffic" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+
+ // Learn the gateway the legitimate way: it ARPs for us, we reply, and it goes in the cache.
+ var probe = arpFrame(1, gw_mac, gw_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+
+ // Now flood the segment with ARP between other hosts. None of it is addressed to us, so none
+ // of it may evict the gateway.
+ var k: u8 = 0;
+ while (k < 20) : (k += 1) {
+ var noise = arpFrame(
+ 1,
+ .{ 0x02, 0, 0, 0, 0, k },
+ .{ 192, 168, 1, 100 + k },
+ zero_mac,
+ .{ 192, 168, 1, 200 },
+ bcast_mac,
+ );
+ s.onFrame(noise.bytes());
+ }
+ clearCapture();
+
+ // If the gateway survived, a datagram to an off-net address goes straight out to `gw_mac`
+ // instead of provoking an ARP request.
+ var echo = icmpEchoFrame(gw_ip, our_ip, 1, 1, "x");
+ s.onFrame(echo.bytes());
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ try testing.expectEqual(@as(u16, 0x0800), be16(lastSent(), 12)); // IPv4, not an ARP request
+ try testing.expectEqualSlices(u8, &gw_mac, lastSent()[0..6]);
+}
+
+test "ARP: a cache entry ages out even while it is being used" {
+ // The bug this pins: refreshing an entry's timestamp on every lookup. It looks harmless and it
+ // means an entry kept alive by our own traffic is never re-resolved, so a gateway whose MAC
+ // changes is never noticed.
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+
+ // Keep using the entry, all the way past the 300 s age limit.
+ var now: u64 = 1000;
+ while (now < 400_000) : (now += 10_000) {
+ s.tick(now);
+ clearCapture();
+ var echo = icmpEchoFrame(peer_ip, our_ip, 1, 1, "x");
+ s.onFrame(echo.bytes());
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ }
+ // Past the limit the entry is gone: the reply is dropped and an ARP request goes in its place.
+ try testing.expectEqual(@as(u16, 0x0806), be16(lastSent(), 12));
+ try testing.expectEqualSlices(u8, &peer_ip, lastSent()[14 + 24 ..][0..4]);
+}
+
+test "ARP: a host that changes its MAC is followed" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+
+ // Same address, new hardware: a replaced router, or a VRRP failover.
+ const new_mac: ip.Mac = .{ 0x02, 0x00, 0x00, 0xde, 0xad, 0x01 };
+ var again = arpFrame(1, new_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(again.bytes());
+ clearCapture();
+
+ var echo = icmpEchoFrame(peer_ip, our_ip, 1, 1, "x");
+ s.onFrame(echo.bytes());
+ try testing.expectEqualSlices(u8, &new_mac, lastSent()[0..6]);
+}
+
+test "IPv4: a received header carrying options is parsed by its own length field" {
+ // `ping -R` and any router-alert path produce these. A parser that assumes 20 bytes reads the
+ // options as the ICMP header and answers nonsense - or, worse, answers with the checksum
+ // covering the wrong bytes.
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ // 24-byte header: 20 plus a 4-byte NOP,NOP,NOP,END option block.
+ var f: Frame = .{};
+ f.eth(our_mac, peer_mac, 0x0800);
+ const total = 24 + 8 + 4;
+ const h = f.buf[14..][0..24];
+ h[0] = 0x46; // IPv4, 6 words of header
+ h[1] = 0;
+ put16(h, 2, total);
+ put16(h, 4, 0x1234);
+ put16(h, 6, 0);
+ h[8] = 64;
+ h[9] = 1; // ICMP
+ put16(h, 10, 0);
+ @memcpy(h[12..16], &peer_ip);
+ @memcpy(h[16..20], &our_ip);
+ h[20] = 1; // NOP
+ h[21] = 1;
+ h[22] = 1;
+ h[23] = 0; // END
+ put16(h, 10, ~@as(u16, @truncate(sum16(h))));
+ const m = f.buf[14 + 24 ..][0 .. 8 + 4];
+ m[0] = 8;
+ m[1] = 0;
+ put16(m, 2, 0);
+ put16(m, 4, 0x0102);
+ put16(m, 6, 0x0304);
+ @memcpy(m[8..], "wxyz");
+ put16(m, 2, ~@as(u16, @truncate(sum16(m))));
+ f.len = 14 + total;
+ s.onFrame(f.bytes());
+
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const r = lastSent();
+ // The reply is emitted with a plain 20-byte header - nothing here generates options - and the
+ // echoed id, sequence and data prove the request's payload was found at the right offset.
+ try testing.expectEqual(@as(u8, 0x45), r[14]);
+ try verify(r[14..34]);
+ const e = r[34..];
+ try testing.expectEqual(@as(u8, 0), e[0]);
+ try testing.expectEqual(@as(u16, 0x0102), be16(e, 4));
+ try testing.expectEqual(@as(u16, 0x0304), be16(e, 6));
+ try testing.expectEqualStrings("wxyz", e[8..12]);
+ try verify(e);
+}
+
+// ===================================================================================== ICMP
+
+test "ICMP: an echo request is answered with a correct echo reply" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ clearCapture();
+ // Teach the stack the peer's MAC by having it ARP for us first.
+ var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ // The payload `ping` sends: 56 bytes, a timestamp then a counting pattern.
+ var payload: [56]u8 = undefined;
+ for (&payload, 0..) |*b, i| b.* = @intCast(i);
+ var req = icmpEchoFrame(peer_ip, our_ip, 0xbeef, 7, &payload);
+ s.onFrame(req.bytes());
+
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const r = lastSent();
+ try testing.expectEqual(@as(usize, 14 + 20 + 8 + 56), r.len);
+ try testing.expectEqualSlices(u8, &peer_mac, r[0..6]);
+ try testing.expectEqual(@as(u16, 0x0800), be16(r, 12));
+
+ const h = r[14..34];
+ try testing.expectEqual(@as(u8, 0x45), h[0]);
+ try testing.expectEqual(@as(u16, 20 + 8 + 56), be16(h, 2));
+ try testing.expectEqual(@as(u8, 1), h[9]); // ICMP
+ // RFC 1122 3.2.1.7 recommends 64. A TTL of 1 is the failure that works on the bench and dies
+ // at the first router, which is the worst possible time to find out.
+ try testing.expectEqual(@as(u8, 64), h[8]);
+ // Don't Fragment: this stack neither fragments nor reassembles, so a router must not fragment
+ // what it cannot rebuild.
+ try testing.expectEqual(@as(u16, 0x4000), be16(h, 6));
+ try testing.expectEqualSlices(u8, &our_ip, h[12..16]); // src and dst swapped
+ try testing.expectEqualSlices(u8, &peer_ip, h[16..20]);
+ try verify(h); // the IP header checksum, checked independently
+
+ const m = r[34..];
+ try testing.expectEqual(@as(u8, 0), m[0]); // echo reply
+ try testing.expectEqual(@as(u8, 0), m[1]);
+ try testing.expectEqual(@as(u16, 0xbeef), be16(m, 4)); // id echoed
+ try testing.expectEqual(@as(u16, 7), be16(m, 6)); // sequence echoed
+ try testing.expectEqualSlices(u8, &payload, m[8..]);
+ try verify(m); // and the ICMP checksum
+}
+
+test "ICMP: a request with a bad IP header checksum is dropped and counted" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ var req = icmpEchoFrame(peer_ip, our_ip, 1, 1, "abcd");
+ req.buf[14 + 10] ^= 0xff; // corrupt the IP header checksum
+ s.onFrame(req.bytes());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ try testing.expectEqual(@as(u32, 1), s.counters.checksum_bad);
+}
+
+test "ICMP: a request with a bad ICMP checksum is dropped and counted" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ var req = icmpEchoFrame(peer_ip, our_ip, 1, 1, "abcd");
+ req.buf[34 + 2] ^= 0xff; // corrupt the ICMP checksum
+ s.onFrame(req.bytes());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ try testing.expectEqual(@as(u32, 1), s.counters.checksum_bad);
+}
+
+test "ICMP: a fragment is dropped rather than answered as a whole datagram" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ var req = icmpEchoFrame(peer_ip, our_ip, 1, 1, "abcd");
+ put16(&req.buf, 14 + 6, 0x2000); // MF set
+ req.sealIp();
+ s.onFrame(req.bytes());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+}
+
+test "a frame addressed to another station is dropped" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ clearCapture();
+ var req = icmpEchoFrame(peer_ip, our_ip, 1, 1, "abcd");
+ req.buf[0] = 0x02; // not our MAC, not broadcast
+ s.onFrame(req.bytes());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ try testing.expect(s.counters.rx_dropped >= 1);
+}
+
+// ===================================================================================== DHCP
+//
+// RFC 2131. The synthetic server below is what a real one does with the fields that matter, and
+// nothing else: no relay agent, no overload, no vendor options.
+
+/// Offsets into the BOOTP message, from RFC 2131 figure 1 / lwIP `prot/dhcp.h:50-91`.
+const d = struct {
+ const op = 0;
+ const htype = 1;
+ const hlen = 2;
+ const xid = 4;
+ const secs = 8;
+ const flags = 10;
+ const ciaddr = 12;
+ const yiaddr = 16;
+ const siaddr = 20;
+ const chaddr = 28;
+ const cookie = 236;
+ const options = 240;
+};
+
+fn dhcpReply(kind: u8, xid: u32, yiaddr: ip.Ip4, server: ip.Ip4, opts: []const u8, dst_ip: ip.Ip4, dst_mac: ip.Mac) Frame {
+ var f: Frame = .{};
+ f.eth(dst_mac, gw_mac, 0x0800);
+ const payload_len = 8 + d.options + 3 + opts.len + 1;
+ const p = f.ip4(server, dst_ip, 17, payload_len);
+ put16(p, 0, 67); // source port: DHCP server
+ put16(p, 2, 68); // destination port: DHCP client
+ put16(p, 4, @intCast(payload_len));
+ put16(p, 6, 0);
+ const m = p[8..];
+ @memset(m, 0);
+ m[d.op] = 2; // BOOTREPLY
+ m[d.htype] = 1;
+ m[d.hlen] = 6;
+ put32(m, d.xid, xid);
+ @memcpy(m[d.yiaddr..][0..4], &yiaddr);
+ @memcpy(m[d.siaddr..][0..4], &server);
+ @memcpy(m[d.chaddr..][0..6], &our_mac);
+ put32(m, d.cookie, 0x63825363);
+ m[d.options] = 53; // message type
+ m[d.options + 1] = 1;
+ m[d.options + 2] = kind;
+ @memcpy(m[d.options + 3 ..][0..opts.len], opts);
+ m[d.options + 3 + opts.len] = 255; // END
+ f.sealTransport(6);
+ return f;
+}
+
+/// Option 1 (mask), 3 (router), 6 (DNS), 51 (lease), 54 (server id) for the network in the brief.
+const standard_opts = [_]u8{
+ 1, 4, 255, 255, 255, 0, // subnet mask /24
+ 3, 4, 192, 168, 1, 1, // router
+ 6, 4, 192, 168, 1, 1, // DNS
+ 51, 4, 0, 0, 0x1c, 0x20, // lease 7200 s
+ 54, 4, 192, 168, 1, 1, // server identifier
+};
+
+fn findOption(msg: []const u8, want: u8) ?[]const u8 {
+ var i: usize = d.options;
+ while (i < msg.len) {
+ if (msg[i] == 255) return null;
+ if (msg[i] == 0) {
+ i += 1;
+ continue;
+ }
+ if (i + 2 > msg.len) return null;
+ const len = msg[i + 1];
+ if (i + 2 + len > msg.len) return null;
+ if (msg[i] == want) return msg[i + 2 ..][0..len];
+ i += 2 + len;
+ }
+ return null;
+}
+
+/// The DHCP message inside a captured frame, and a few sanity checks that apply to all of them.
+fn dhcpOut(frame: []const u8) ![]const u8 {
+ try testing.expectEqual(@as(u16, 0x0800), be16(frame, 12));
+ const h = frame[14..34];
+ try testing.expectEqual(@as(u8, 17), h[9]); // UDP
+ try verify(h);
+ const seg = frame[34..];
+ try testing.expectEqual(@as(u16, 68), be16(seg, 0)); // from the client port
+ try testing.expectEqual(@as(u16, 67), be16(seg, 2)); // to the server port
+ try testing.expectEqual(@as(u16, @intCast(seg.len)), be16(seg, 4));
+ try verifyTransport(h[12..16].*, h[16..20].*, 17, seg);
+ const msg = seg[8..];
+ try testing.expectEqual(@as(u8, 1), msg[d.op]); // BOOTREQUEST
+ try testing.expectEqual(@as(u8, 1), msg[d.htype]); // Ethernet
+ try testing.expectEqual(@as(u8, 6), msg[d.hlen]);
+ try testing.expectEqual(@as(u32, 0x63825363), be32(msg, d.cookie));
+ try testing.expectEqualSlices(u8, &our_mac, msg[d.chaddr..][0..6]);
+ // RFC 951: a BOOTP message is at least 300 bytes.
+ try testing.expect(msg.len >= 300);
+ return msg;
+}
+
+test "DHCP: a full DISCOVER / OFFER / REQUEST / ACK exchange binds the address" {
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState());
+
+ // ---- DISCOVER
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const disc_frame = sent(0);
+ // Broadcast at both layers: no address yet, so nothing else could work.
+ try testing.expectEqualSlices(u8, &bcast_mac, disc_frame[0..6]);
+ try testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, disc_frame[14 + 12 ..][0..4]);
+ try testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, disc_frame[14 + 16 ..][0..4]);
+ const disc = try dhcpOut(disc_frame);
+ try testing.expectEqual(@as(u16, 0x8000), be16(disc, d.flags)); // ask for a broadcast reply
+ try testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, disc[d.ciaddr..][0..4]);
+ try testing.expectEqualSlices(u8, &.{1}, findOption(disc, 53).?); // DHCPDISCOVER
+ try testing.expect(findOption(disc, 55) != null); // parameter request list
+ try testing.expect(findOption(disc, 57) != null); // maximum message size
+ // A DISCOVER must not claim an address or name a server.
+ try testing.expect(findOption(disc, 50) == null);
+ try testing.expect(findOption(disc, 54) == null);
+ const xid = be32(disc, d.xid);
+
+ // ---- OFFER, unicast to the address about to be granted (RFC 2131 4.1 permits this, and it is
+ // the case that only works because `ip4Input` lets UDP through while unbound).
+ clearCapture();
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(offer.bytes());
+ try testing.expectEqual(ip.DhcpState.requesting, s.dhcpState());
+
+ // ---- REQUEST
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const req = try dhcpOut(sent(0));
+ try testing.expectEqual(xid, be32(req, d.xid)); // same transaction
+ try testing.expectEqualSlices(u8, &.{3}, findOption(req, 53).?); // DHCPREQUEST
+ // RFC 2131 4.3.2: SELECTING carries the offered address in option 50 and the server it is
+ // accepting in option 54, and `ciaddr` stays zero.
+ try testing.expectEqualSlices(u8, &our_ip, findOption(req, 50).?);
+ try testing.expectEqualSlices(u8, &gw_ip, findOption(req, 54).?);
+ try testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, req[d.ciaddr..][0..4]);
+
+ // ---- ACK
+ clearCapture();
+ var ack = dhcpReply(5, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(ack.bytes());
+
+ try testing.expectEqual(ip.DhcpState.bound, s.dhcpState());
+ try testing.expectEqual(our_ip, s.ip().?);
+ try testing.expectEqual(mask24, s.netmask());
+ try testing.expectEqual(gw_ip, s.gateway());
+ try testing.expectEqual(gw_ip, s.dnsServer().?);
+ // Binding announces the new address.
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ try testing.expectEqual(@as(u16, 0x0806), be16(lastSent(), 12));
+ try testing.expectEqualSlices(u8, &our_ip, lastSent()[14 + 14 ..][0..4]);
+}
+
+test "DHCP: a reply with the wrong transaction id is ignored" {
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+ var offer = dhcpReply(2, xid ^ 0xffff_ffff, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(offer.bytes());
+ try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+}
+
+test "DHCP: a reply for another station's hardware address is ignored" {
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ offer.buf[34 + 8 + d.chaddr + 5] ^= 0xff; // a different chaddr
+ offer.sealTransport(6);
+ s.onFrame(offer.bytes());
+ try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+}
+
+test "DHCP: DISCOVER is retransmitted with a growing backoff and the same transaction id" {
+ var s = newStack();
+ s.tick(0);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+
+ // Nothing before the first backoff expires.
+ s.tick(1_999);
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ s.tick(2_000);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ try testing.expectEqual(xid, be32(sent(0)[42..], d.xid));
+
+ // The next interval is longer: nothing at +2 s, a frame at +4 s.
+ s.tick(5_999);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ s.tick(6_000);
+ try testing.expectEqual(@as(usize, 2), cap_n);
+
+ // And the `secs` field tracks how long acquisition has been going.
+ try testing.expectEqual(@as(u16, 6), be16(sent(1)[42..], d.secs));
+}
+
+test "DHCP: a NAK surrenders the address and restarts from DISCOVER" {
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(offer.bytes());
+ clearCapture();
+
+ var nak = dhcpReply(6, xid, .{ 0, 0, 0, 0 }, gw_ip, &.{}, ip.ip_broadcast, bcast_mac);
+ s.onFrame(nak.bytes());
+ try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState());
+ try testing.expect(s.ip() == null);
+ // And a fresh DISCOVER went out immediately.
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ try testing.expectEqualSlices(u8, &.{1}, findOption(try dhcpOut(sent(0)), 53).?);
+}
+
+test "DHCP: at T1 the lease is renewed by unicast REQUEST with ciaddr set" {
+ var s = newStack();
+ s.tick(0);
+ s.dhcpStart();
+ const xid0 = be32(sent(0)[42..], d.xid);
+ var offer = dhcpReply(2, xid0, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(offer.bytes());
+ var ack = dhcpReply(5, xid0, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(ack.bytes());
+ try testing.expectEqual(ip.DhcpState.bound, s.dhcpState());
+
+ // Lease 7200 s, so T1 = 3600 s (lwIP `core/ipv4/dhcp.c:757`: half the lease).
+ clearCapture();
+ s.tick(3_599_000);
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ try testing.expectEqual(ip.DhcpState.bound, s.dhcpState());
+
+ // T1. The REQUEST is unicast to the server, so it needs the server's MAC first: with the cache
+ // empty, the datagram is dropped and an ARP request goes out in its place.
+ s.tick(3_600_000);
+ try testing.expectEqual(ip.DhcpState.renewing, s.dhcpState());
+ try testing.expectEqual(@as(u16, 0x0806), be16(sent(0), 12));
+ try testing.expectEqualSlices(u8, &gw_ip, sent(0)[14 + 24 ..][0..4]); // ARP for the server
+
+ // The server answers by ARPing for us, which is enough to populate the cache.
+ var probe = arpFrame(1, gw_mac, gw_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ // The next retransmission now has a route.
+ s.tick(3_602_000);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const r = lastSent();
+ try testing.expectEqualSlices(u8, &gw_mac, r[0..6]); // unicast to the server
+ try testing.expectEqualSlices(u8, &gw_ip, r[14 + 16 ..][0..4]);
+ const msg = try dhcpOut(r);
+ try testing.expectEqualSlices(u8, &.{3}, findOption(msg, 53).?); // DHCPREQUEST
+ // RFC 2131 4.3.6, the RENEWING column: ciaddr carries the bound address, and there is no
+ // requested-IP option and no server identifier.
+ try testing.expectEqualSlices(u8, &our_ip, msg[d.ciaddr..][0..4]);
+ try testing.expect(findOption(msg, 50) == null);
+ try testing.expect(findOption(msg, 54) == null);
+ // A fresh transaction id for the new exchange (RFC 2131 4.4.5).
+ try testing.expect(be32(msg, d.xid) != xid0);
+
+ // The server ACKs and the lease is extended from now.
+ const xid1 = be32(msg, d.xid);
+ clearCapture();
+ var ack2 = dhcpReply(5, xid1, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(ack2.bytes());
+ try testing.expectEqual(ip.DhcpState.bound, s.dhcpState());
+ try testing.expectEqual(our_ip, s.ip().?);
+}
+
+test "DHCP: at T2 renewal becomes a broadcast rebind, and an expired lease is surrendered" {
+ var s = newStack();
+ s.tick(0);
+ s.dhcpStart();
+ const xid0 = be32(sent(0)[42..], d.xid);
+ var offer = dhcpReply(2, xid0, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(offer.bytes());
+ var ack = dhcpReply(5, xid0, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(ack.bytes());
+
+ // Give the stack the server's MAC so the renewal is not blocked on ARP.
+ var probe = arpFrame(1, gw_mac, gw_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+
+ s.tick(3_600_000); // T1
+ try testing.expectEqual(ip.DhcpState.renewing, s.dhcpState());
+
+ // T2 = 7/8 of 7200 s = 6300 s (lwIP `core/ipv4/dhcp.c:766`).
+ clearCapture();
+ s.tick(6_300_000);
+ try testing.expectEqual(ip.DhcpState.rebinding, s.dhcpState());
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ // Rebinding is broadcast: the granting server is not answering, so ask anybody.
+ try testing.expectEqualSlices(u8, &bcast_mac, lastSent()[0..6]);
+ const msg = try dhcpOut(lastSent());
+ try testing.expectEqualSlices(u8, &our_ip, msg[d.ciaddr..][0..4]);
+ try testing.expect(findOption(msg, 54) == null);
+
+ // Lease expiry: the address must go, because the server may already have handed it out.
+ clearCapture();
+ s.tick(7_200_000);
+ try testing.expect(s.ip() == null);
+ try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState());
+}
+
+test "DHCP: an option whose length runs past the datagram does not read off the end" {
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+ // Option 54 - the server identifier, which the OFFER handler actually looks for - claiming 200
+ // bytes of a message with three left. Unchecked, that is a 200-byte read past the end of the
+ // frame, which is the classic DHCP parser bug and is reachable by any host on the segment.
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &[_]u8{ 54, 200, 192, 168 }, our_ip, our_mac);
+ offer.sealTransport(6);
+ s.onFrame(offer.bytes());
+ // The option did not resolve, so the handler fell back to `siaddr` - and the exchange carried
+ // on rather than crashing.
+ try testing.expectEqual(ip.DhcpState.requesting, s.dhcpState());
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const req = try dhcpOut(sent(0));
+ try testing.expectEqualSlices(u8, &gw_ip, findOption(req, 54).?); // from siaddr
+}
+
+test "DHCP: an option truncated by one byte does not read off the end" {
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+ // Length 4 with only three bytes of message left after it, counting the END marker.
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &[_]u8{ 54, 4, 192, 168 }, our_ip, our_mac);
+ offer.sealTransport(6);
+ s.onFrame(offer.bytes());
+ try testing.expectEqual(ip.DhcpState.requesting, s.dhcpState());
+}
+
+test "DHCP: a bogus option before a good one does not hide it" {
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+ // A zero-length option, then a pad, then the real server identifier.
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &[_]u8{ 12, 0, 0, 54, 4, 192, 168, 1, 1 }, our_ip, our_mac);
+ offer.sealTransport(6);
+ s.onFrame(offer.bytes());
+ const req = try dhcpOut(sent(0));
+ try testing.expectEqualSlices(u8, &gw_ip, findOption(req, 54).?);
+}
+
+/// Cut `drop` bytes off the end of a UDP datagram and re-seal, so the last byte of the options is
+/// wherever the caller wants it. `dhcpReply` always writes an END marker, and END is what stops a
+/// well-behaved option walk - so the only way to test what happens when the walk reaches the end of
+/// the buffer instead is to take the marker away.
+fn truncateUdp(f: *Frame, drop: usize) void {
+ f.len -= drop;
+ const h = f.buf[14..][0..20];
+ put16(h, 2, @intCast(f.len - 14));
+ const seg = f.buf[34..f.len];
+ put16(seg, 4, @intCast(seg.len));
+ f.sealTransport(6);
+}
+
+test "DHCP: an option code in the last byte, with no length byte after it, is not read past" {
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+ // A hostname option, then a bare code 3 where a length byte should be. The END marker that
+ // `dhcpReply` appends is cut off, so the walk runs into the end of the datagram - and no
+ // option 54 is present, so the handler's search for the server identifier walks the whole
+ // list and reaches that last byte. Unchecked, reading its length byte is one past the frame.
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &[_]u8{ 12, 1, 'x', 3 }, our_ip, our_mac);
+ truncateUdp(&offer, 1);
+ s.onFrame(offer.bytes());
+ // It read what it could and stopped, and fell back to `siaddr` for the server identifier.
+ try testing.expectEqual(ip.DhcpState.requesting, s.dhcpState());
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ try testing.expectEqualSlices(u8, &gw_ip, findOption(try dhcpOut(sent(0)), 54).?);
+}
+
+test "DHCP: a reply without the magic cookie is not a DHCP message" {
+ // RFC 2131 3: the four-byte cookie is what distinguishes a DHCP message from plain BOOTP.
+ // Without the check, any BOOTP reply - or any UDP datagram to port 68 that happens to have the
+ // right xid in the right place - is parsed as options.
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ put32(&offer.buf, 34 + 8 + d.cookie, 0x63825364); // one off
+ offer.sealTransport(6);
+ s.onFrame(offer.bytes());
+ try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+}
+
+test "DHCP: a BOOTREQUEST is not mistaken for a reply" {
+ // Every DISCOVER on the segment is a broadcast, including our own. A client that does not check
+ // the `op` field parses its own request - or another client's - as an offer, and RFC 2131 gives
+ // it a `yiaddr` of zero to work with.
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ offer.buf[34 + 8 + d.op] = 1; // BOOTREQUEST
+ offer.sealTransport(6);
+ s.onFrame(offer.bytes());
+ try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+}
+
+// ====================================================================================== TCP
+//
+// The synthetic peer. Sequence numbers here are the *peer's*; the stack's are read out of what it
+// sends, because its ISN is not something a test may assume.
+
+/// Offsets into the TCP header, RFC 793 3.1 / lwIP `prot/tcp.h:56-65`.
+const t = struct {
+ const src = 0;
+ const dst = 2;
+ const seq = 4;
+ const ack = 8;
+ const hdrlen_flags = 12;
+ const window = 14;
+ const chksum = 16;
+
+ const fin: u8 = 0x01;
+ const syn: u8 = 0x02;
+ const rst: u8 = 0x04;
+ const psh: u8 = 0x08;
+ const ack_f: u8 = 0x10;
+};
+
+const Peer = struct {
+ ip: ip.Ip4,
+ port: u16,
+ mac: ip.Mac,
+ /// Our own sequence space, as the peer.
+ seq: u32 = 0x1000_0000,
+ /// The stack's ports and sequence numbers, learnt from its SYN.
+ stack_port: u16 = 0,
+ window: u16 = 8192,
+ /// With an MSS option in our SYN-ACK, or without.
+ mss: ?u16 = 1460,
+
+ fn segment(self: *Peer, flags: u8, ackno: u32, data: []const u8, with_mss: bool) Frame {
+ var f: Frame = .{};
+ f.eth(our_mac, self.mac, 0x0800);
+ const opt_len: usize = if (with_mss) 4 else 0;
+ const p = f.ip4(self.ip, our_ip, 6, 20 + opt_len + data.len);
+ put16(p, t.src, self.port);
+ put16(p, t.dst, self.stack_port);
+ put32(p, t.seq, self.seq);
+ put32(p, t.ack, ackno);
+ put16(p, t.hdrlen_flags, (@as(u16, @intCast((20 + opt_len) / 4)) << 12) | flags);
+ put16(p, t.window, self.window);
+ put16(p, t.chksum, 0);
+ put16(p, 18, 0);
+ if (with_mss) {
+ p[20] = 2;
+ p[21] = 4;
+ put16(p, 22, self.mss.?);
+ }
+ if (data.len != 0) @memcpy(p[20 + opt_len ..], data);
+ f.sealTransport(t.chksum);
+ return f;
+ }
+};
+
+/// A captured TCP segment, decoded, with its checksums verified independently.
+const Seg = struct {
+ src_port: u16,
+ dst_port: u16,
+ seq: u32,
+ ack: u32,
+ flags: u8,
+ window: u16,
+ data: []const u8,
+ mss: ?u16,
+};
+
+fn decode(frame: []const u8) !Seg {
+ try testing.expectEqual(@as(u16, 0x0800), be16(frame, 12));
+ const h = frame[14..34];
+ try testing.expectEqual(@as(u8, 6), h[9]);
+ try verify(h);
+ const total = be16(h, 2);
+ const seg = frame[34 .. 14 + total];
+ try verifyTransport(h[12..16].*, h[16..20].*, 6, seg);
+ const hf = be16(seg, t.hdrlen_flags);
+ const hlen = @as(usize, hf >> 12) * 4;
+ var mss: ?u16 = null;
+ var i: usize = 20;
+ while (i + 1 < hlen) {
+ if (seg[i] == 0) break;
+ if (seg[i] == 1) {
+ i += 1;
+ continue;
+ }
+ const olen = seg[i + 1];
+ if (olen < 2 or i + olen > hlen) break;
+ if (seg[i] == 2 and olen == 4) mss = be16(seg, i + 2);
+ i += olen;
+ }
+ return .{
+ .src_port = be16(seg, t.src),
+ .dst_port = be16(seg, t.dst),
+ .seq = be32(seg, t.seq),
+ .ack = be32(seg, t.ack),
+ .flags = @truncate(hf & 0x3f),
+ .window = be16(seg, t.window),
+ .data = seg[hlen..],
+ .mss = mss,
+ };
+}
+
+/// Bring a stack up statically with the peer's MAC already in the ARP cache, then start a GET.
+/// Returns the peer and the SYN the stack sent.
+fn startGet(s: *ip.Stack, peer: *Peer, path: []const u8, out: []u8) !Seg {
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer.mac, peer.ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, path, out));
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const syn = try decode(sent(0));
+ peer.stack_port = syn.src_port;
+ return syn;
+}
+
+/// Complete the handshake: deliver the SYN-ACK and return the sequence number that acknowledges the
+/// whole request. Afterwards `sent(0)` is the request segment - the capture log is cleared first, so
+/// tests never have to remember whether the SYN is still in it. That off-by-one is exactly the kind
+/// of thing a test helper exists to remove.
+fn handshake(s: *ip.Stack, peer: *Peer, iss: u32) !u32 {
+ clearCapture();
+ var synack = peer.segment(t.syn | t.ack_f, iss +% 1, &.{}, true);
+ s.onFrame(synack.bytes());
+ peer.seq +%= 1;
+ const req = try decode(sent(0));
+ try testing.expect(req.data.len > 0);
+ return iss +% 1 +% @as(u32, @intCast(req.data.len));
+}
+
+test "TCP: the SYN offers an MSS, uses an ephemeral port and advertises a window" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+
+ try testing.expectEqual(t.syn, syn.flags);
+ try testing.expectEqual(@as(u16, 80), syn.dst_port);
+ try testing.expect(syn.src_port >= 49152); // RFC 6335 dynamic range
+ try testing.expectEqual(@as(?u16, 1460), syn.mss);
+ try testing.expect(syn.window > 0);
+ try testing.expectEqual(@as(usize, 0), syn.data.len);
+ try testing.expectEqual(ip.TcpState.syn_sent, s.tcpState());
+}
+
+test "TCP: a handshake, the request, a response and a clean teardown" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/index.html", &out);
+ const iss = syn.seq;
+
+ // ---- SYN-ACK
+ _ = try handshake(&s, &peer, iss);
+ try testing.expectEqual(ip.TcpState.established, s.tcpState());
+
+ // The handshake's ACK carries the request: one frame, not two.
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const req = try decode(sent(0));
+ try testing.expectEqual(t.ack_f | t.psh, req.flags);
+ try testing.expectEqual(iss +% 1, req.seq);
+ try testing.expectEqual(peer.seq, req.ack);
+ try testing.expect(std.mem.startsWith(u8, req.data, "GET /index.html HTTP/1.1\r\n"));
+ // The Host header is the address literal - there is no DNS here - and port 80 is elided.
+ try testing.expect(std.mem.indexOf(u8, req.data, "\r\nHost: 192.168.1.90\r\n") != null);
+ // Connection: close is the framing for a body with no Content-Length.
+ try testing.expect(std.mem.indexOf(u8, req.data, "\r\nConnection: close\r\n") != null);
+ try testing.expect(std.mem.endsWith(u8, req.data, "\r\n\r\n"));
+ const req_len = req.data.len;
+
+ // ---- the peer acknowledges the request and sends the whole response in one segment
+ clearCapture();
+ const body = "hello, world";
+ const response = "HTTP/1.1 200 OK\r\nServer: test\r\nContent-Length: 12\r\n\r\n" ++ body;
+ var resp = peer.segment(t.ack_f | t.psh, iss +% 1 +% @as(u32, @intCast(req_len)), response, false);
+ s.onFrame(resp.bytes());
+ peer.seq +%= @intCast(response.len);
+
+ // The body is complete, so the stack half-closes: the FIN is the acknowledgement too.
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const fin = try decode(sent(0));
+ try testing.expectEqual(t.fin | t.ack_f, fin.flags);
+ try testing.expectEqual(peer.seq, fin.ack);
+ try testing.expectEqual(ip.TcpState.fin_wait_1, s.tcpState());
+
+ // ---- the peer acknowledges our FIN and sends its own
+ clearCapture();
+ var peer_fin = peer.segment(t.fin | t.ack_f, fin.seq +% 1, &.{}, false);
+ s.onFrame(peer_fin.bytes());
+ peer.seq +%= 1;
+ const last = try decode(lastSent());
+ try testing.expectEqual(t.ack_f, last.flags);
+ try testing.expectEqual(peer.seq, last.ack);
+ try testing.expectEqual(ip.TcpState.time_wait, s.tcpState());
+
+ // ---- and the body comes out
+ const n = try s.httpGet(peer.ip, peer.port, "/index.html", &out);
+ try testing.expectEqual(@as(usize, 12), n);
+ try testing.expectEqualStrings(body, out[0..n]);
+ try testing.expectEqual(@as(u16, 200), s.httpStatus());
+
+ // TIME_WAIT is short by design; it ends on the clock, not on a frame.
+ s.tick(1_000_000);
+ try testing.expectEqual(ip.TcpState.closed, s.tcpState());
+}
+
+test "TCP: the SYN is retransmitted with its MSS option, on a doubling timer" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ clearCapture();
+
+ // Nothing before the RTO.
+ s.tick(1_999);
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ s.tick(2_000);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const again = try decode(sent(0));
+ try testing.expectEqual(t.syn, again.flags);
+ try testing.expectEqual(syn.seq, again.seq);
+ // The MSS option must be repeated: a peer that only ever sees the retransmission would
+ // otherwise fall back to 536.
+ try testing.expectEqual(@as(?u16, 1460), again.mss);
+
+ // The next timeout is twice as long: 2 s, not 1 s.
+ s.tick(3_999);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ s.tick(4_000);
+ try testing.expectEqual(@as(usize, 2), cap_n);
+ try testing.expectEqual(@as(u32, 2), s.counters.tcp_retx);
+}
+
+test "TCP: retransmission after a dropped data segment resends the identical bytes" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/drop", &out);
+ const iss = syn.seq;
+
+ _ = try handshake(&s, &peer, iss);
+ const first = try decode(sent(0));
+ try testing.expect(first.data.len > 0);
+
+ // Pretend the segment was lost: never acknowledge it, just let time pass.
+ clearCapture();
+ s.tick(1_500);
+ try testing.expectEqual(@as(usize, 0), cap_n); // handshake completed at t=1000, RTO at t=2000
+ s.tick(2_000);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ try testing.expectEqual(@as(u32, 1), s.counters.tcp_retx);
+
+ const again = try decode(sent(0));
+ try testing.expectEqual(first.seq, again.seq);
+ try testing.expectEqualSlices(u8, first.data, again.data);
+ try testing.expectEqual(first.flags, again.flags);
+
+ // Now it gets through, and the connection carries on from the same place.
+ clearCapture();
+ const response = "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n";
+ var resp = peer.segment(t.ack_f, iss +% 1 +% @as(u32, @intCast(first.data.len)), response, false);
+ s.onFrame(resp.bytes());
+ try testing.expectEqual(@as(usize, 0), try s.httpGet(peer.ip, peer.port, "/drop", &out));
+ try testing.expectEqual(@as(u16, 204), s.httpStatus());
+}
+
+test "TCP: retransmission eventually gives up with TimedOut" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ _ = try startGet(&s, &peer, "/", &out);
+
+ // Six retransmissions with a doubling, capped backoff, then failure. Ticking well past every
+ // deadline in one step is enough: the deadline is absolute.
+ var now: u64 = 1000;
+ var k: usize = 0;
+ while (k < 8) : (k += 1) {
+ now += 60_000;
+ s.tick(now);
+ }
+ try testing.expectEqual(ip.TcpState.closed, s.tcpState());
+ try testing.expectError(error.TimedOut, s.httpGet(peer.ip, peer.port, "/", &out));
+ try testing.expectEqual(@as(u32, 6), s.counters.tcp_retx);
+}
+
+test "TCP: an out-of-order segment is not accepted, and provokes a duplicate ACK" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ const iss = syn.seq;
+ const our_next = try handshake(&s, &peer, iss);
+ const in_order_seq = peer.seq;
+
+ // The second half of the response arrives first.
+ const head = "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\n";
+ clearCapture();
+ peer.seq = in_order_seq +% @as(u32, @intCast(head.len));
+ var late = peer.segment(t.ack_f, our_next, "abcd", false);
+ s.onFrame(late.bytes());
+
+ // A duplicate ACK for what we are still waiting for, and nothing consumed.
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const dup = try decode(sent(0));
+ try testing.expectEqual(t.ack_f, dup.flags);
+ try testing.expectEqual(in_order_seq, dup.ack);
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/", &out));
+
+ // The missing piece arrives.
+ clearCapture();
+ peer.seq = in_order_seq;
+ var missing = peer.segment(t.ack_f, our_next, head, false);
+ s.onFrame(missing.bytes());
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/", &out));
+ try testing.expectEqual(@as(u16, 200), s.httpStatus());
+
+ // And the retransmission of the tail completes it.
+ peer.seq = in_order_seq +% @as(u32, @intCast(head.len));
+ var tail = peer.segment(t.ack_f, our_next, "abcd", false);
+ s.onFrame(tail.bytes());
+ try testing.expectEqual(@as(usize, 4), try s.httpGet(peer.ip, peer.port, "/", &out));
+ try testing.expectEqualStrings("abcd", out[0..4]);
+}
+
+test "TCP: a retransmission overlapping data already received is trimmed, not rejected" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ const iss = syn.seq;
+ const our_next = try handshake(&s, &peer, iss);
+
+ // Headers first, so the overlap lands squarely in the body where duplicated bytes cannot hide
+ // in a header line the parser would have skipped anyway.
+ const head = "HTTP/1.1 200 OK\r\nContent-Length: 16\r\n\r\n";
+ var h = peer.segment(t.ack_f, our_next, head, false);
+ s.onFrame(h.bytes());
+ peer.seq +%= @intCast(head.len);
+ const base = peer.seq;
+
+ // Ten body bytes.
+ var a = peer.segment(t.ack_f, our_next, "0123456789", false);
+ s.onFrame(a.bytes());
+
+ // Then a retransmission that starts four bytes before what we now expect and carries six new
+ // bytes past it. Without trimming, `6789` is written twice, `rcv_nxt` runs four ahead of the
+ // truth, and the final six bytes are then rejected as old - so the request never completes.
+ peer.seq = base +% 6;
+ var b = peer.segment(t.ack_f, our_next, "6789abcdef", false);
+ s.onFrame(b.bytes());
+
+ try testing.expectEqual(@as(usize, 16), try s.httpGet(peer.ip, peer.port, "/", &out));
+ try testing.expectEqualStrings("0123456789abcdef", out[0..16]);
+}
+
+test "TCP: a SYN-ACK that does not acknowledge our SYN is reset, not accepted" {
+ // RFC 793 3.4: an old duplicate SYN-ACK, or one aimed at a previous incarnation of this
+ // 4-tuple, is answered with a reset. Accepting it would establish a connection whose sequence
+ // space the peer does not agree with, and every subsequent segment would be discarded.
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ clearCapture();
+
+ var wrong = peer.segment(t.syn | t.ack_f, syn.seq +% 999, &.{}, true);
+ s.onFrame(wrong.bytes());
+ try testing.expectEqual(ip.TcpState.syn_sent, s.tcpState());
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const rst = try decode(sent(0));
+ try testing.expectEqual(t.rst, rst.flags);
+ try testing.expectEqual(syn.seq +% 999, rst.seq); // RST carries the offending ACK number
+
+ // The right one still works.
+ clearCapture();
+ var right = peer.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true);
+ s.onFrame(right.bytes());
+ try testing.expectEqual(ip.TcpState.established, s.tcpState());
+}
+
+test "TCP: a FIN ahead of the data we have is not honoured" {
+ // A FIN whose sequence number is past `rcv_nxt` closes the connection over a hole. Honouring it
+ // would report a complete body that is missing its middle.
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ const iss = syn.seq;
+ const our_next = try handshake(&s, &peer, iss);
+ const base = peer.seq;
+
+ const head = "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\n";
+ var h = peer.segment(t.ack_f, our_next, head, false);
+ s.onFrame(h.bytes());
+ peer.seq +%= @intCast(head.len);
+
+ // A FIN 100 bytes into the future, as though a segment we never saw preceded it.
+ clearCapture();
+ peer.seq = base +% @as(u32, @intCast(head.len)) +% 100;
+ var early = peer.segment(t.fin | t.ack_f, our_next, &.{}, false);
+ s.onFrame(early.bytes());
+ // Not closed, not completed: the body is still outstanding.
+ try testing.expectEqual(ip.TcpState.established, s.tcpState());
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/", &out));
+
+ // The real body arrives and completes it.
+ peer.seq = base +% @as(u32, @intCast(head.len));
+ var body = peer.segment(t.ack_f, our_next, "wxyz", false);
+ s.onFrame(body.bytes());
+ try testing.expectEqual(@as(usize, 4), try s.httpGet(peer.ip, peer.port, "/", &out));
+ try testing.expectEqualStrings("wxyz", out[0..4]);
+}
+
+test "TCP: an in-window RST tears the connection down; an out-of-window one does not" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ const iss = syn.seq;
+ _ = try handshake(&s, &peer, iss);
+
+ // RFC 5961 3: a RST whose sequence number is not the next one expected gets a challenge ACK
+ // and is otherwise ignored. This is what stops a blind off-path reset.
+ clearCapture();
+ const good_seq = peer.seq;
+ peer.seq = good_seq +% 5000;
+ var bogus = peer.segment(t.rst, 0, &.{}, false);
+ s.onFrame(bogus.bytes());
+ try testing.expectEqual(ip.TcpState.established, s.tcpState());
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ try testing.expectEqual(t.ack_f, (try decode(sent(0))).flags);
+
+ // The real thing.
+ peer.seq = good_seq;
+ var reset = peer.segment(t.rst, 0, &.{}, false);
+ s.onFrame(reset.bytes());
+ try testing.expectEqual(ip.TcpState.closed, s.tcpState());
+ try testing.expectError(error.ConnectionReset, s.httpGet(peer.ip, peer.port, "/", &out));
+ try testing.expectEqual(@as(u32, 2), s.counters.tcp_rst_rx);
+}
+
+test "TCP: a segment for a different port is not mistaken for this connection" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ clearCapture();
+ const real_port = peer.stack_port;
+ peer.stack_port = real_port ^ 1;
+ var stray = peer.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true);
+ s.onFrame(stray.bytes());
+ try testing.expectEqual(ip.TcpState.syn_sent, s.tcpState());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+}
+
+test "TCP: a segment from a different host is not mistaken for this connection" {
+ // The whole 4-tuple has to match, not just the ports. A stack that checks only the ports can
+ // have its connection completed - or reset - by any host on the segment that guesses a
+ // 16-bit number.
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ clearCapture();
+
+ // Same ports, different source address.
+ var impostor: Peer = .{ .ip = gw_ip, .port = 80, .mac = gw_mac, .seq = 0x7000_0000 };
+ impostor.stack_port = peer.stack_port;
+ var stray = impostor.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true);
+ s.onFrame(stray.bytes());
+ try testing.expectEqual(ip.TcpState.syn_sent, s.tcpState());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+
+ // And a reset from the same impostor is ignored too.
+ var reset = impostor.segment(t.rst, 0, &.{}, false);
+ s.onFrame(reset.bytes());
+ try testing.expectEqual(ip.TcpState.syn_sent, s.tcpState());
+ try testing.expectEqual(@as(u32, 0), s.counters.tcp_rst_rx);
+}
+
+test "TCP: the peer's MSS is honoured, and the request is split across segments" {
+ // The MSS option only matters when the request is bigger than it, which for a GET means a long
+ // path. A stack that ignores the option sends one oversized segment that a peer with a small
+ // MSS - a tunnel, a PPPoE link, anything with encapsulation overhead - drops silently.
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac, .mss = 100 };
+ var out: [64]u8 = undefined;
+ const path: [300]u8 = @splat('q');
+ var full_path: [301]u8 = undefined;
+ full_path[0] = '/';
+ @memcpy(full_path[1..], &path);
+
+ const syn = try startGet(&s, &peer, &full_path, &out);
+ const iss = syn.seq;
+ clearCapture();
+ var synack = peer.segment(t.syn | t.ack_f, iss +% 1, &.{}, true);
+ s.onFrame(synack.bytes());
+ peer.seq +%= 1;
+
+ // Reassemble the request from however many segments it takes, acknowledging each one: with a
+ // window of one segment, nothing more is sent until the previous is acknowledged.
+ var assembled: [512]u8 = undefined;
+ var got: usize = 0;
+ var rounds: usize = 0;
+ while (true) : (rounds += 1) {
+ try testing.expect(rounds < 16); // termination, so a stall fails rather than hangs
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const seg = try decode(sent(0));
+ try testing.expect(seg.data.len <= 100); // the peer's MSS, honoured
+ try testing.expectEqual(iss +% 1 +% @as(u32, @intCast(got)), seg.seq);
+ @memcpy(assembled[got..][0..seg.data.len], seg.data);
+ got += seg.data.len;
+ if (seg.flags & t.fin != 0) break;
+ clearCapture();
+ var ack = peer.segment(t.ack_f, seg.seq +% @as(u32, @intCast(seg.data.len)), &.{}, false);
+ s.onFrame(ack.bytes());
+ if (cap_n == 0) break; // request fully sent and acknowledged
+ }
+ try testing.expect(rounds >= 3); // 400-odd bytes at 100 per segment
+ try testing.expect(std.mem.startsWith(u8, assembled[0..got], "GET /qqq"));
+ try testing.expect(std.mem.endsWith(u8, assembled[0..got], "\r\n\r\n"));
+ try testing.expect(std.mem.indexOf(u8, assembled[0..got], &path) != null);
+}
+
+test "TCP: sequence numbers wrap across 2^32 without stalling" {
+ var s = newStack();
+ // A peer whose ISN is chosen so its data crosses the wrap. This is the case a `<` comparison
+ // instead of RFC 1982 serial arithmetic breaks, and it breaks by hanging forever.
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac, .seq = 0xffff_ffe0 };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ const iss = syn.seq;
+ const our_next = try handshake(&s, &peer, iss);
+
+ const head = "HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\n";
+ clearCapture();
+ var a = peer.segment(t.ack_f, our_next, head, false); // 38 bytes: crosses the wrap
+ s.onFrame(a.bytes());
+ peer.seq +%= @intCast(head.len);
+ try testing.expect(peer.seq < 0x1000); // we really did wrap
+
+ var b = peer.segment(t.ack_f, our_next, "12345678", false);
+ s.onFrame(b.bytes());
+ try testing.expectEqual(@as(usize, 8), try s.httpGet(peer.ip, peer.port, "/", &out));
+ try testing.expectEqualStrings("12345678", out[0..8]);
+}
+
+test "TCP: an unresolvable peer fails with HostUnreachable after ARP gives up" {
+ var s = newStack();
+ s.tick(0);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var out: [64]u8 = undefined;
+ // Nothing in the cache, and nothing ever answers.
+ try testing.expectError(error.WouldBlock, s.httpGet(peer_ip, 80, "/", &out));
+ try testing.expectEqual(ip.TcpState.arp_wait, s.tcpState());
+ var now: u64 = 0;
+ var k: usize = 0;
+ while (k < 8) : (k += 1) {
+ now += 1000;
+ s.tick(now);
+ }
+ try testing.expectError(error.HostUnreachable, s.httpGet(peer_ip, 80, "/", &out));
+ // Every attempt was a broadcast ARP request for the peer.
+ try testing.expect(s.counters.arp_tx >= 5);
+}
+
+test "TCP: an off-net destination is sent to the gateway's MAC" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, gw_mac, gw_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+ var out: [64]u8 = undefined;
+ try testing.expectError(error.WouldBlock, s.httpGet(off_net_ip, 80, "/", &out));
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const syn = lastSent();
+ try testing.expectEqualSlices(u8, &gw_mac, syn[0..6]); // to the gateway...
+ try testing.expectEqualSlices(u8, &off_net_ip, syn[14 + 16 ..][0..4]); // ...for the peer
+}
+
+// ===================================================================================== HTTP
+
+/// Handshake, then feed the response in the given pieces, one segment each.
+fn runResponse(s: *ip.Stack, peer: *Peer, path: []const u8, out: []u8, pieces: []const []const u8) !void {
+ const syn = try startGet(s, peer, path, out);
+ const iss = syn.seq;
+ const our_next = try handshake(s, peer, iss);
+ for (pieces) |piece| {
+ clearCapture();
+ var seg = peer.segment(t.ack_f, our_next, piece, false);
+ s.onFrame(seg.bytes());
+ peer.seq +%= @intCast(piece.len);
+ }
+}
+
+test "HTTP: headers split across two segments" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ // The split falls inside the `Content-Length` field name, and the second piece carries the
+ // blank line and the start of the body. This is the ordinary case on a real server, and it is
+ // the one a parser that assumes headers arrive whole gets wrong.
+ try runResponse(&s, &peer, "/split", &out, &.{
+ "HTTP/1.1 200 OK\r\nServer: nginx\r\nContent-Len",
+ "gth: 11\r\nETag: \"x\"\r\n\r\nhello wor",
+ "ld",
+ });
+ const n = try s.httpGet(peer.ip, peer.port, "/split", &out);
+ try testing.expectEqual(@as(usize, 11), n);
+ try testing.expectEqualStrings("hello world", out[0..n]);
+ try testing.expectEqual(@as(u16, 200), s.httpStatus());
+}
+
+test "HTTP: the status line and blank line split one byte at a time" {
+ // The pathological segmentation: every byte its own segment. If any offset in the parser is
+ // off by one, one of these iterations lands on it.
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ const response = "HTTP/1.1 201 Created\r\nContent-Length: 3\r\nX: y\r\n\r\nabc";
+ var pieces: [response.len][]const u8 = undefined;
+ for (&pieces, 0..) |*p, i| p.* = response[i .. i + 1];
+ try runResponse(&s, &peer, "/bytes", &out, &pieces);
+ try testing.expectEqual(@as(usize, 3), try s.httpGet(peer.ip, peer.port, "/bytes", &out));
+ try testing.expectEqualStrings("abc", out[0..3]);
+ try testing.expectEqual(@as(u16, 201), s.httpStatus());
+}
+
+test "HTTP: a header name's case is not significant" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ try runResponse(&s, &peer, "/case", &out, &.{
+ "HTTP/1.0 200 OK\r\ncOnTeNt-LeNgTh: 7 \r\n\r\n1234567",
+ });
+ try testing.expectEqual(@as(usize, 7), try s.httpGet(peer.ip, peer.port, "/case", &out));
+ try testing.expectEqualStrings("1234567", out[0..7]);
+}
+
+test "HTTP: a body with no Content-Length is terminated by the peer's FIN" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/stream", &out);
+ const iss = syn.seq;
+ const our_next = try handshake(&s, &peer, iss);
+
+ var a = peer.segment(t.ack_f, our_next, "HTTP/1.1 200 OK\r\nServer: x\r\n\r\npart one ", false);
+ s.onFrame(a.bytes());
+ peer.seq +%= 39;
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/stream", &out));
+
+ var b = peer.segment(t.ack_f, our_next, "part two", false);
+ s.onFrame(b.bytes());
+ peer.seq +%= 8;
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/stream", &out));
+
+ // RFC 7230 3.3.3 case 7: with no Content-Length and no chunking, the connection close is the
+ // framing. That is why the request said `Connection: close`.
+ clearCapture();
+ var fin = peer.segment(t.fin | t.ack_f, our_next, &.{}, false);
+ s.onFrame(fin.bytes());
+ const n = try s.httpGet(peer.ip, peer.port, "/stream", &out);
+ try testing.expectEqualStrings("part one part two", out[0..n]);
+
+ // The peer closed first, so this is RFC 793's CLOSE-WAIT -> LAST-ACK: our FIN goes out
+ // acknowledging theirs, and the connection is not finished until that FIN is acknowledged.
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const ours = try decode(sent(0));
+ try testing.expectEqual(t.fin | t.ack_f, ours.flags);
+ try testing.expectEqual(peer.seq +% 1, ours.ack); // their FIN consumed one sequence number
+ try testing.expectEqual(ip.TcpState.last_ack, s.tcpState());
+
+ // Their ACK of our FIN finishes it.
+ clearCapture();
+ peer.seq +%= 1;
+ var final = peer.segment(t.ack_f, ours.seq +% 1, &.{}, false);
+ s.onFrame(final.bytes());
+ try testing.expectEqual(ip.TcpState.time_wait, s.tcpState());
+ try testing.expectEqual(@as(usize, 0), cap_n); // a bare ACK needs no answer
+
+ // The peer's FIN again, because our ACK of it was lost. It has already been consumed, so it is
+ // "old" by one sequence number - and a stack that only accepts an exactly-in-order FIN answers
+ // nothing, leaving the peer retransmitting until it gives up and resets.
+ clearCapture();
+ var again: Peer = peer;
+ again.seq = peer.seq -% 1; // the sequence number their FIN actually carried
+ var dup = again.segment(t.fin | t.ack_f, ours.seq +% 1, &.{}, false);
+ s.onFrame(dup.bytes());
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const reack = try decode(sent(0));
+ try testing.expectEqual(t.ack_f, reack.flags);
+ try testing.expectEqual(peer.seq, reack.ack); // still the sequence number past their FIN
+ try testing.expectEqual(ip.TcpState.time_wait, s.tcpState());
+}
+
+// ============================================================================= HTTP chunked
+//
+// RFC 7230 4.1. The framing is a size in hex, CRLF, that many bytes, CRLF, repeated, ended by a
+// zero size, an optional trailer section and one more CRLF. Two things make it worth this many
+// cases: the caller must see the decoded bytes and none of the framing, and a segment boundary
+// may fall anywhere - including inside a size, inside a CRLF, and inside a chunk whose *data*
+// contains CRLFs of its own.
+
+/// The example from RFC 7230's own appendix, by way of the one everybody quotes. Its third chunk
+/// carries `\r\n\r\n` as data, which is the trap: a decoder that scans for a delimiter instead of
+/// counting the size it was given loses the rest of the body here, and reports success.
+const chunked_head = "HTTP/1.1 200 OK\r\nServer: cloudflare\r\nTransfer-Encoding: chunked\r\n\r\n";
+const chunked_wire = "4\r\nWiki\r\n5\r\npedia\r\nE\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n";
+const chunked_want = "Wikipedia in\r\n\r\nchunks.";
+
+/// Drive a response through a fresh connection, cut into `pieces`, and return the decoded body.
+fn decodeChunked(out: []u8, pieces: []const []const u8) ![]const u8 {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ try runResponse(&s, &peer, "/c", out, pieces);
+ const n = try s.httpGet(peer.ip, peer.port, "/c", out);
+ return out[0..n];
+}
+
+/// The same, expecting a named failure rather than a body.
+fn expectChunkedError(want: anyerror, out: []u8, pieces: []const []const u8) !void {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ try runResponse(&s, &peer, "/c", out, pieces);
+ try testing.expectError(want, s.httpGet(peer.ip, peer.port, "/c", out));
+}
+
+test "HTTP chunked: a whole response in one segment decodes, framing bytes and all removed" {
+ var out: [256]u8 = undefined;
+ const got = try decodeChunked(&out, &.{chunked_head ++ chunked_wire});
+ try testing.expectEqualStrings(chunked_want, got);
+ // Said the other way round, because it is the property that matters: no size, no CRLF and no
+ // terminator reached the caller.
+ try testing.expect(std.mem.indexOf(u8, got, "\r\nE\r\n") == null);
+ try testing.expect(std.mem.indexOf(u8, got, "0\r\n") == null);
+}
+
+test "HTTP chunked: the response split at every single offset, two segments" {
+ // The decoder has to resume from wherever the cut landed: mid-size, between the CR and the LF
+ // of a chunk header, mid-data, mid-terminator. This walks every one of those positions.
+ const response = chunked_head ++ chunked_wire;
+ var split: usize = 1;
+ while (split < response.len) : (split += 1) {
+ var out: [256]u8 = undefined;
+ const got = try decodeChunked(&out, &.{ response[0..split], response[split..] });
+ try testing.expectEqualStrings(chunked_want, got);
+ }
+}
+
+test "HTTP chunked: the response split one byte at a time" {
+ // The pathological segmentation. Every state in the machine is entered with an empty input
+ // and re-entered with one byte, which is where a decoder that peeks at `b[1]` dies.
+ const response = chunked_head ++ chunked_wire;
+ var pieces: [response.len][]const u8 = undefined;
+ for (&pieces, 0..) |*p, i| p.* = response[i .. i + 1];
+ var out: [256]u8 = undefined;
+ const got = try decodeChunked(&out, &pieces);
+ try testing.expectEqualStrings(chunked_want, got);
+}
+
+test "HTTP chunked: the body arrives across three segments cut inside one chunk's data" {
+ var out: [256]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ chunked_head ++ "4\r\nWi",
+ "ki\r\n5\r\npe",
+ "dia\r\nE\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n",
+ });
+ try testing.expectEqualStrings(chunked_want, got);
+}
+
+test "HTTP chunked: chunk extensions are skipped, not delivered" {
+ var out: [64]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" ++
+ "5;name=value;flag\r\nhello\r\n0;last\r\n\r\n",
+ });
+ try testing.expectEqualStrings("hello", got);
+}
+
+test "HTTP chunked: an extension split across segments is still skipped" {
+ var out: [64]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5;na",
+ "me=val",
+ "ue\r\nhello\r\n0\r\n\r\n",
+ });
+ try testing.expectEqualStrings("hello", got);
+}
+
+test "HTTP chunked: a trailer section is skipped and only its final CRLF completes the body" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/c", &out);
+ const our_next = try handshake(&s, &peer, syn.seq);
+
+ // Everything up to but not including the CRLF that ends the trailer section.
+ const piece =
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\nExpires: now\r\n";
+ var a = peer.segment(t.ack_f, our_next, piece, false);
+ s.onFrame(a.bytes());
+ peer.seq +%= @intCast(piece.len);
+
+ // The zero chunk is in and every body byte is here, and it is still not complete: the trailer
+ // section is part of the message, and a decoder that finished at the zero chunk would hand
+ // the caller a body while leaving the connection mid-message.
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/c", &out));
+
+ var b = peer.segment(t.ack_f, our_next, "\r\n", false);
+ s.onFrame(b.bytes());
+ peer.seq +%= 2;
+ try testing.expectEqual(@as(usize, 5), try s.httpGet(peer.ip, peer.port, "/c", &out));
+ try testing.expectEqualStrings("hello", out[0..5]);
+}
+
+test "HTTP chunked: sizes in upper case hex, and with leading zeros" {
+ var out: [64]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" ++
+ "00000A\r\n0123456789\r\nB\r\nabcdefghijk\r\n000\r\n\r\n",
+ });
+ try testing.expectEqualStrings("0123456789abcdefghijk", got);
+}
+
+test "HTTP chunked: an empty body is the terminator alone" {
+ var out: [64]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ "HTTP/1.1 204 No Content\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n",
+ });
+ try testing.expectEqual(@as(usize, 0), got.len);
+}
+
+test "HTTP chunked: Content-Length beside chunked is ignored, not obeyed" {
+ // RFC 7230 3.3.3 case 3. A response carrying both is the request-smuggling disagreement, and
+ // the framing that wins is the chunked one. Obeying the length here would stop after 2 bytes
+ // and report success on a fifth of the body.
+ var out: [64]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nTransfer-Encoding: chunked\r\n\r\n" ++
+ "5\r\nhello\r\n0\r\n\r\n",
+ });
+ try testing.expectEqualStrings("hello", got);
+}
+
+test "HTTP chunked: the header order does not decide which framing wins" {
+ var out: [64]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Length: 2\r\n\r\n" ++
+ "5\r\nhello\r\n0\r\n\r\n",
+ });
+ try testing.expectEqualStrings("hello", got);
+}
+
+test "HTTP chunked: a size with no hex digits is refused, never read as the terminator" {
+ // The dangerous misparse: a stray CRLF where a size belongs is a zero-length chunk to a
+ // decoder with no `1*HEXDIG` check, and a zero-length chunk ends the body. That is a
+ // truncated response reported as a complete one.
+ var out: [64]u8 = undefined;
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n\r\nhello\r\n0\r\n\r\n",
+ });
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nxyz\r\nhello\r\n0\r\n\r\n",
+ });
+}
+
+test "HTTP chunked: a chunk not followed by CRLF is refused" {
+ var out: [64]u8 = undefined;
+ // Data, then a bare LF where the CRLF belongs.
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\n0\r\n\r\n",
+ });
+ // A chunk header whose CR is not followed by LF.
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\rhello\r\n0\r\n\r\n",
+ });
+ // The final CRLF of the message, mangled.
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\rx",
+ });
+}
+
+test "HTTP chunked: each half of each CRLF is required in its own position" {
+ // The three cases above are all refused by a decoder that merely skips *two* bytes wherever a
+ // CRLF belongs; these are not. Each one is a well-framed message to such a decoder - it
+ // returns `hello` and reports success - and a malformed one to this stack. That is the
+ // difference between checking the delimiter and counting past it.
+ var out: [64]u8 = undefined;
+ // LF where the chunk's closing CR belongs, and the real LF behind it.
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\n\n0\r\n\r\n",
+ });
+ // CR in place, then a byte that is not the LF.
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\rZ0\r\n\r\n",
+ });
+ // And in the chunk header: CR in place, junk where the LF belongs.
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\rZhello\r\n0\r\n\r\n",
+ });
+}
+
+test "HTTP chunked: a second chunk with an empty size is refused, not read as the terminator" {
+ // The first chunk's size sets the "a digit was seen" flag, and it has to be cleared for the
+ // next one. Left set, the CRLF below reads as a zero-length chunk - the terminator - and the
+ // response ends silently five bytes in.
+ var out: [64]u8 = undefined;
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n\r\nmore\r\n0\r\n\r\n",
+ });
+}
+
+test "HTTP chunked: an impossible Content-Length beside chunked does not fail the request" {
+ // The other half of "chunked wins": the length is not merely unused for framing, it is not
+ // consulted at all - including by the check that refuses a body too big for `out`. A server
+ // that sends both is already not to be believed about the length.
+ var out: [64]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ "HTTP/1.1 200 OK\r\nContent-Length: 100000\r\nTransfer-Encoding: chunked\r\n\r\n" ++
+ "5\r\nhello\r\n0\r\n\r\n",
+ });
+ try testing.expectEqualStrings("hello", got);
+}
+
+test "HTTP chunked: a body that exactly fills out still leaves window for its terminator" {
+ // The deadlock this pins: the advertised window is the room left in `out`, and chunked
+ // framing is consumed without going there. A body that fills `out` to the last byte closes
+ // the window, the terminator can never be accepted, and the request stalls against a peer
+ // that is behaving perfectly - until the RTO calls it a timeout.
+ var out: [5]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n",
+ "0\r\n\r\n",
+ });
+ try testing.expectEqualStrings("hello", got);
+}
+
+test "HTTP chunked: a size that overflows usize is refused, not wrapped" {
+ // Seventeen f's. Wrapped, this is a small number and the response looks well framed.
+ var out: [64]u8 = undefined;
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nfffffffffffffffff\r\n",
+ });
+}
+
+test "HTTP chunked: a chunk larger than the caller's buffer fails on the header, before any copy" {
+ var out: [8]u8 = undefined;
+ try expectChunkedError(error.StreamTooLong, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n64\r\n",
+ });
+}
+
+test "HTTP chunked: chunks that together outgrow the buffer fail, and do not truncate" {
+ var out: [8]u8 = undefined;
+ try expectChunkedError(error.StreamTooLong, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n5\r\nworld\r\n0\r\n\r\n",
+ });
+}
+
+test "HTTP chunked: an endless chunk extension is bounded" {
+ const pad: [http_framing_over]u8 = @splat('x');
+ var out: [4096]u8 = undefined;
+ try expectChunkedError(error.HttpHeadersTooLong, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5;",
+ &pad,
+ });
+}
+
+test "HTTP chunked: an endless trailer section is bounded" {
+ const pad: [http_framing_over]u8 = @splat('x');
+ var out: [4096]u8 = undefined;
+ try expectChunkedError(error.HttpHeadersTooLong, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\nX: ",
+ &pad,
+ });
+}
+
+/// One byte past the framing budget, so the bound is tested at the bound and not far above it.
+const http_framing_over = ip.http_framing_max + 1;
+
+test "HTTP chunked: a close before the terminator is an error, not the body that did arrive" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/c", &out);
+ const our_next = try handshake(&s, &peer, syn.seq);
+
+ const piece = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n";
+ var a = peer.segment(t.ack_f, our_next, piece, false);
+ s.onFrame(a.bytes());
+ peer.seq +%= @intCast(piece.len);
+
+ var fin = peer.segment(t.fin | t.ack_f, our_next, &.{}, false);
+ s.onFrame(fin.bytes());
+ // Five bytes of body are sitting in `out`, and they are not the answer: chunked framing says
+ // the message ends at the zero chunk, so a close before it truncated the response.
+ try testing.expectError(error.ConnectionClosed, s.httpGet(peer.ip, peer.port, "/c", &out));
+}
+
+test "HTTP: a transfer coding that is neither identity nor chunked is still refused" {
+ for ([_][]const u8{ "gzip", "deflate", "chunked, gzip", "gzip, chunked" }) |coding| {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ var head: [128]u8 = undefined;
+ const resp = try std.fmt.bufPrint(
+ &head,
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: {s}\r\n\r\n5\r\nhello\r\n0\r\n\r\n",
+ .{coding},
+ );
+ try runResponse(&s, &peer, "/tc", &out, &.{resp});
+ try testing.expectError(
+ error.UnsupportedTransferEncoding,
+ s.httpGet(peer.ip, peer.port, "/tc", &out),
+ );
+ try testing.expectEqual(ip.TcpState.closed, s.tcpState());
+ }
+}
+
+test "HTTP: Transfer-Encoding: identity is accepted" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ try runResponse(&s, &peer, "/id", &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: identity\r\nContent-Length: 2\r\n\r\nok",
+ });
+ try testing.expectEqual(@as(usize, 2), try s.httpGet(peer.ip, peer.port, "/id", &out));
+}
+
+test "HTTP: a malformed status line is refused" {
+ for ([_][]const u8{
+ "ICY 200 OK\r\nContent-Length: 0\r\n\r\n",
+ "HTTP/1.1 200 OK\r\n\r\n",
+ "HTTP/1.1 2xx OK\r\n\r\n",
+ // The right shape, the wrong protocol. HTTP/2 has no textual status line at all, so a
+ // server answering this over a cleartext HTTP/1.1 request is not something to guess at.
+ "HTTP/2.0 200 OK\r\nContent-Length: 0\r\n\r\n",
+ "ICE/1.0 200 OK\r\nContent-Length: 0\r\n\r\n",
+ "HTTP/1.1\r\n\r\n",
+ }) |bad| {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ try runResponse(&s, &peer, "/bad", &out, &.{bad});
+ try testing.expectError(error.HttpMalformed, s.httpGet(peer.ip, peer.port, "/bad", &out));
+ }
+}
+
+test "HTTP: a Content-Length larger than the caller's buffer fails before any body is copied" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [8]u8 = undefined;
+ try runResponse(&s, &peer, "/big", &out, &.{
+ "HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n0123456789",
+ });
+ try testing.expectError(error.StreamTooLong, s.httpGet(peer.ip, peer.port, "/big", &out));
+}
+
+test "HTTP: an impossible Content-Length fails at once, not after a partial body" {
+ // 100 promised bytes into an 8-byte buffer, and only five of them ever arrive. The request is
+ // already impossible when the headers are parsed, and saying so then is the difference between
+ // an immediate error and a request that hangs until the peer closes.
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [8]u8 = undefined;
+ try runResponse(&s, &peer, "/early", &out, &.{
+ "HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n01234",
+ });
+ try testing.expectError(error.StreamTooLong, s.httpGet(peer.ip, peer.port, "/early", &out));
+ try testing.expectEqual(ip.TcpState.closed, s.tcpState());
+}
+
+test "HTTP: a body longer than the caller's buffer with no Content-Length fails" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4]u8 = undefined;
+ try runResponse(&s, &peer, "/big2", &out, &.{
+ "HTTP/1.1 200 OK\r\n\r\n0123456789",
+ });
+ try testing.expectError(error.StreamTooLong, s.httpGet(peer.ip, peer.port, "/big2", &out));
+}
+
+test "HTTP: an oversized header block fails rather than truncating" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ // One header line per segment until the head buffer is full. No blank line ever arrives.
+ var pieces: [40][]const u8 = undefined;
+ for (&pieces) |*p| p.* = "X-Padding: 0123456789012345678901234567890123456789\r\n";
+ var first: [2][]const u8 = .{ "HTTP/1.1 200 OK\r\n", pieces[0] };
+ _ = &first;
+ try runResponse(&s, &peer, "/hdr", &out, &pieces);
+ try testing.expectError(error.HttpHeadersTooLong, s.httpGet(peer.ip, peer.port, "/hdr", &out));
+}
+
+test "HTTP: a Content-Length: 0 response completes on the headers alone" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ try runResponse(&s, &peer, "/empty", &out, &.{
+ "HTTP/1.1 304 Not Modified\r\nContent-Length: 0\r\n\r\n",
+ });
+ try testing.expectEqual(@as(usize, 0), try s.httpGet(peer.ip, peer.port, "/empty", &out));
+ try testing.expectEqual(@as(u16, 304), s.httpStatus());
+ // Completing the body half-closes, whatever the length was.
+ try testing.expect(s.tcpState() != .established);
+}
+
+test "HTTP: a truncated body - FIN before Content-Length is met - is an error, not a short read" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/trunc", &out);
+ const iss = syn.seq;
+ const our_next = try handshake(&s, &peer, iss);
+
+ const piece = "HTTP/1.1 200 OK\r\nContent-Length: 20\r\n\r\nshort";
+ var a = peer.segment(t.ack_f, our_next, piece, false);
+ s.onFrame(a.bytes());
+ peer.seq +%= @intCast(piece.len);
+ var fin = peer.segment(t.fin | t.ack_f, our_next, &.{}, false);
+ s.onFrame(fin.bytes());
+ try testing.expectError(error.ConnectionClosed, s.httpGet(peer.ip, peer.port, "/trunc", &out));
+}
+
+test "HTTP: a non-default port appears in the Host header" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 8080, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ var synack = peer.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true);
+ clearCapture();
+ s.onFrame(synack.bytes());
+ const req = try decode(sent(0));
+ try testing.expect(std.mem.indexOf(u8, req.data, "\r\nHost: 192.168.1.90:8080\r\n") != null);
+}
+
+// ========================================================================= the Host: header
+//
+// A name-based virtual host - which is what everything behind a CDN is - chooses the site from
+// this header alone. `Host: 104.21.46.8` reaches Cloudflare and gets Cloudflare's error page; the
+// site is only reachable by name. But a bare address in a lab is only reachable by address, so
+// both spellings have to be exactly right.
+
+/// Start a request, complete the handshake, and return the request segment the stack sent.
+fn requestFor(s: *ip.Stack, peer: *Peer, name: ?[]const u8, path: []const u8, out: []u8) !Seg {
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer.mac, peer.ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ try testing.expectError(error.WouldBlock, s.httpGetHost(peer.ip, name, peer.port, path, out));
+ const syn = try decode(sent(0));
+ peer.stack_port = syn.src_port;
+ clearCapture();
+ var synack = peer.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true);
+ s.onFrame(synack.bytes());
+ return try decode(sent(0));
+}
+
+test "HTTP Host: a supplied name is sent instead of the address" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ const req = try requestFor(&s, &peer, "0x4200.cafe", "/", &out);
+ try testing.expect(std.mem.indexOf(u8, req.data, "\r\nHost: 0x4200.cafe\r\n") != null);
+ // The address is still where the connection went; the name is only ever a header.
+ try testing.expect(std.mem.indexOf(u8, req.data, "192.168.1.90") == null);
+}
+
+test "HTTP Host: a name keeps the rule that only a non-default port is appended" {
+ var s80 = newStack();
+ var peer80: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out80: [64]u8 = undefined;
+ const req80 = try requestFor(&s80, &peer80, "0x4200.cafe", "/", &out80);
+ try testing.expect(std.mem.indexOf(u8, req80.data, "\r\nHost: 0x4200.cafe\r\n") != null);
+
+ var s8080 = newStack();
+ var peer8080: Peer = .{ .ip = peer_ip, .port = 8080, .mac = peer_mac };
+ var out8080: [64]u8 = undefined;
+ const req8080 = try requestFor(&s8080, &peer8080, "0x4200.cafe", "/", &out8080);
+ try testing.expect(std.mem.indexOf(u8, req8080.data, "\r\nHost: 0x4200.cafe:8080\r\n") != null);
+}
+
+test "HTTP Host: no name is byte for byte what httpGet has always sent" {
+ // The working test against a bare address depends on this, so it is asserted on the bytes and
+ // not on a substring: two stacks with the same MAC and the same tick draw the same ephemeral
+ // port and the same ISN, so the two requests must be identical octet for octet.
+ var a = newStack();
+ var peer_a: Peer = .{ .ip = peer_ip, .port = 8080, .mac = peer_mac };
+ var out_a: [64]u8 = undefined;
+ const req_a = try requestFor(&a, &peer_a, null, "/index.html", &out_a);
+ var kept: [512]u8 = undefined;
+ @memcpy(kept[0..req_a.data.len], req_a.data);
+ const first = kept[0..req_a.data.len];
+
+ var b = newStack();
+ var peer_b: Peer = .{ .ip = peer_ip, .port = 8080, .mac = peer_mac };
+ var out_b: [64]u8 = undefined;
+ b.tick(1000);
+ b.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer_b.mac, peer_b.ip, zero_mac, our_ip, bcast_mac);
+ b.onFrame(probe.bytes());
+ clearCapture();
+ try testing.expectError(error.WouldBlock, b.httpGet(peer_b.ip, peer_b.port, "/index.html", &out_b));
+ const syn = try decode(sent(0));
+ peer_b.stack_port = syn.src_port;
+ clearCapture();
+ var synack = peer_b.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true);
+ b.onFrame(synack.bytes());
+ const req_b = try decode(sent(0));
+
+ try testing.expectEqualSlices(u8, first, req_b.data);
+ try testing.expect(std.mem.indexOf(u8, req_b.data, "\r\nHost: 192.168.1.90:8080\r\n") != null);
+}
+
+test "HTTP Host: the name is part of the request's identity, so changing it is Busy" {
+ var s = newStack();
+ const peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer.mac, peer.ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+
+ try testing.expectError(error.WouldBlock, s.httpGetHost(peer.ip, "0x4200.cafe", 80, "/", &out));
+ // The same call again is the protocol.
+ try testing.expectError(error.WouldBlock, s.httpGetHost(peer.ip, "0x4200.cafe", 80, "/", &out));
+ // A different virtual host on the same address for the same path is a different request, and
+ // riding on this connection would fetch the wrong site under the right name.
+ try testing.expectError(error.Busy, s.httpGetHost(peer.ip, "example.com", 80, "/", &out));
+ // And "no name" is not the same request as any name.
+ try testing.expectError(error.Busy, s.httpGetHost(peer.ip, null, 80, "/", &out));
+ try testing.expectError(error.Busy, s.httpGet(peer.ip, 80, "/", &out));
+}
+
+test "HTTP: httpGet before an address exists is refused" {
+ var s = newStack();
+ var out: [64]u8 = undefined;
+ try testing.expectError(error.NoAddress, s.httpGet(peer_ip, 80, "/", &out));
+}
+
+test "HTTP: re-entering with different arguments is refused rather than silently switching" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ var other: [64]u8 = undefined;
+ _ = try startGet(&s, &peer, "/one", &out);
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, 80, "/one", &out));
+ try testing.expectError(error.Busy, s.httpGet(peer.ip, 80, "/two", &out));
+ try testing.expectError(error.Busy, s.httpGet(peer.ip, 81, "/one", &out));
+ try testing.expectError(error.Busy, s.httpGet(gw_ip, 80, "/one", &out));
+ // A different output buffer is the dangerous one: the body is written as it arrives, so the
+ // stack is holding a pointer into the first.
+ try testing.expectError(error.Busy, s.httpGet(peer.ip, 80, "/one", &other));
+ // Same buffer, shorter: `Content-Length` was already checked against the original length, and
+ // the body is written through the original slice, so a shrunk view is just as wrong.
+ try testing.expectError(error.Busy, s.httpGet(peer.ip, 80, "/one", out[0..32]));
+ try testing.expectError(error.Busy, s.httpGet(peer.ip, 80, "/one", out[1..]));
+ // The original arguments still work.
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, 80, "/one", &out));
+}
+
+test "HTTP: a path longer than the request buffer is refused" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var out: [64]u8 = undefined;
+ const long: [600]u8 = @splat('a');
+ try testing.expectError(error.RequestTooLong, s.httpGet(peer_ip, 80, &long, &out));
+}
+
+test "HTTP: two requests in sequence use different ephemeral ports" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ try runResponse(&s, &peer, "/a", &out, &.{"HTTP/1.1 200 OK\r\nContent-Length: 1\r\na\r\n\r\na"});
+ _ = try s.httpGet(peer.ip, peer.port, "/a", &out);
+ const first_port = peer.stack_port;
+
+ const peer2: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ clearCapture();
+ try testing.expectError(error.WouldBlock, s.httpGet(peer2.ip, peer2.port, "/b", &out));
+ const syn = try decode(sent(0));
+ try testing.expect(syn.src_port != first_port);
+}
+
+// ====================================================================================== DNS
+//
+// RFC 1035. The header offsets and the name encoding below are written out again from the RFC,
+// like every other wire format in this file. The parts that need testing are not the header -
+// six 16-bit fields - but the two that are easy to get wrong and impossible to see when they are:
+// matching the *question* as well as the id, and following compression pointers under a bound.
+
+/// RFC 1035 4.1.1, re-derived.
+const q = struct {
+ const id = 0;
+ const flags = 2;
+ const qdcount = 4;
+ const ancount = 6;
+ const nscount = 8;
+ const arcount = 10;
+ const hlen = 12;
+};
+
+/// The resolver this network's DHCP server hands out: the gateway itself.
+const dns_ip: ip.Ip4 = .{ 192, 168, 1, 1 };
+
+/// RFC 1035 4.1.2 name encoding. No validation, deliberately: a test that shared the encoder's
+/// checks could not write a malformed name to see the stack reject it.
+fn wireName(buf: []u8, name: []const u8) usize {
+ var o: usize = 0;
+ var labels = std.mem.splitScalar(u8, name, '.');
+ while (labels.next()) |label| {
+ buf[o] = @intCast(label.len);
+ @memcpy(buf[o + 1 ..][0..label.len], label);
+ o += 1 + label.len;
+ }
+ buf[o] = 0;
+ return o + 1;
+}
+
+/// A DNS message under construction.
+const Msg = struct {
+ buf: [512]u8 = @splat(0),
+ len: usize = 0,
+
+ fn header(self: *Msg, id: u16, flags: u16, qd: u16, an: u16) void {
+ put16(&self.buf, q.id, id);
+ put16(&self.buf, q.flags, flags);
+ put16(&self.buf, q.qdcount, qd);
+ put16(&self.buf, q.ancount, an);
+ put16(&self.buf, q.nscount, 0);
+ put16(&self.buf, q.arcount, 0);
+ self.len = q.hlen;
+ }
+
+ fn question(self: *Msg, name: []const u8, qtype: u16, qclass: u16) void {
+ self.len += wireName(self.buf[self.len..], name);
+ self.be(qtype);
+ self.be(qclass);
+ }
+
+ /// Append one big-endian 16-bit field.
+ fn be(self: *Msg, v: u16) void {
+ put16(&self.buf, self.len, v);
+ self.len += 2;
+ }
+
+ fn bytes(self: *Msg, b: []const u8) void {
+ @memcpy(self.buf[self.len..][0..b.len], b);
+ self.len += b.len;
+ }
+
+ /// A resource record whose owner name is a compression pointer to `name_off`, which is what a
+ /// real server emits for every record after the first: the question's name is at offset 12,
+ /// and every answer points at it.
+ fn rr(self: *Msg, name_off: u16, rtype: u16, rclass: u16, rdata: []const u8) void {
+ self.be(0xc000 | name_off);
+ self.be(rtype);
+ self.be(rclass);
+ put32(&self.buf, self.len, 300); // TTL
+ self.len += 4;
+ self.be(@intCast(rdata.len));
+ self.bytes(rdata);
+ }
+
+ fn slice(self: *const Msg) []const u8 {
+ return self.buf[0..self.len];
+ }
+};
+
+/// A UDP datagram from `src`:`sport` to our address at `dport`.
+fn udpFrame(src: ip.Ip4, sport: u16, dport: u16, payload: []const u8) Frame {
+ var f: Frame = .{};
+ f.eth(our_mac, gw_mac, 0x0800);
+ const seg_len = 8 + payload.len;
+ const p = f.ip4(src, our_ip, 17, seg_len);
+ put16(p, 0, sport);
+ put16(p, 2, dport);
+ put16(p, 4, @intCast(seg_len));
+ put16(p, 6, 0);
+ @memcpy(p[8..], payload);
+ f.sealTransport(6);
+ return f;
+}
+
+/// A stack with an address, a resolver, and the resolver's MAC already learnt.
+fn newResolverStack() ip.Stack {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ s.setDnsServer(dns_ip);
+ var probe = arpFrame(1, gw_mac, dns_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+ return s;
+}
+
+/// The DNS payload of a captured query, with both checksums verified independently. Also returns
+/// the source port, which is the other half of what an off-path spoofer has to guess.
+fn queryOut(frame: []const u8) !struct { msg: []const u8, sport: u16 } {
+ try testing.expectEqual(@as(u16, 0x0800), be16(frame, 12));
+ const h = frame[14..34];
+ try testing.expectEqual(@as(u8, 17), h[9]); // UDP
+ try verify(h);
+ try testing.expectEqualSlices(u8, &dns_ip, h[16..20]);
+ const total = be16(h, 2);
+ const seg = frame[34 .. 14 + total];
+ try testing.expectEqual(@as(u16, 53), be16(seg, 2));
+ try testing.expectEqual(@as(u16, @intCast(seg.len)), be16(seg, 4));
+ try verifyTransport(h[12..16].*, h[16..20].*, 17, seg);
+ return .{ .msg = seg[8..], .sport = be16(seg, 0) };
+}
+
+/// Answer the outstanding query with `an` answer records built by `fill`, and return the address
+/// `resolve` then produces - or the error it produces.
+fn answerWith(s: *ip.Stack, name: []const u8, m: *Msg) !ip.Ip4 {
+ var f = udpFrame(dns_ip, 53, dns_query_port, m.slice());
+ s.onFrame(f.bytes());
+ return s.resolve(name);
+}
+
+/// The source port of the query most recently captured, filled in by `startResolve`.
+var dns_query_port: u16 = 0;
+
+/// Start a query and record its id and source port.
+fn startResolve(s: *ip.Stack, name: []const u8) !u16 {
+ try testing.expectError(error.WouldBlock, s.resolve(name));
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const out = try queryOut(sent(0));
+ dns_query_port = out.sport;
+ clearCapture();
+ return be16(out.msg, q.id);
+}
+
+test "DNS: the query is one A/IN question, recursion desired, from an ephemeral port" {
+ var s = newResolverStack();
+ try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe"));
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const out = try queryOut(sent(0));
+ const msg = out.msg;
+
+ try testing.expect(out.sport >= 49152); // RFC 6335 dynamic range
+ // QR=0, OPCODE=0, RD=1, and nothing else. RFC 1035 4.1.1.
+ try testing.expectEqual(@as(u16, 0x0100), be16(msg, q.flags));
+ try testing.expectEqual(@as(u16, 1), be16(msg, q.qdcount));
+ try testing.expectEqual(@as(u16, 0), be16(msg, q.ancount));
+ try testing.expectEqual(@as(u16, 0), be16(msg, q.nscount));
+ try testing.expectEqual(@as(u16, 0), be16(msg, q.arcount));
+
+ // The question: `6 0x4200 4 cafe 0`, then QTYPE=A, QCLASS=IN. Written out literally, because
+ // the length-prefixed encoding is the thing being checked.
+ const want = [_]u8{ 6, '0', 'x', '4', '2', '0', '0', 4, 'c', 'a', 'f', 'e', 0 };
+ try testing.expectEqualSlices(u8, &want, msg[q.hlen..][0..want.len]);
+ try testing.expectEqual(@as(u16, 1), be16(msg, q.hlen + want.len)); // QTYPE=A
+ try testing.expectEqual(@as(u16, 1), be16(msg, q.hlen + want.len + 2)); // QCLASS=IN
+ try testing.expectEqual(@as(usize, q.hlen + want.len + 4), msg.len);
+ try testing.expectEqual(@as(u32, 1), s.counters.dns_tx);
+}
+
+test "DNS: an answer resolves the name, and the query slot is released" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1); // QR, RD, RA, RCODE 0
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 });
+
+ const got = try answerWith(&s, "0x4200.cafe", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got);
+ try testing.expectEqual(@as(u32, 1), s.counters.dns_rx);
+ // The slot is free again: a second name resolves without an intervening reset.
+ try testing.expectError(error.WouldBlock, s.resolve("example.com"));
+}
+
+test "DNS: a CNAME ahead of the A record is stepped over, not read as an address" {
+ // This is the shape a CDN answers with, and a resolver that reads answer[0] gets a name where
+ // it wanted four octets. RDLENGTH would even be 4 for a short enough label.
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var cname: [32]u8 = undefined;
+ const cname_len = wireName(&cname, "edge.example");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 3);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 5, 1, cname[0..cname_len]); // CNAME
+ m.rr(q.hlen, 28, 1, &[_]u8{0} ** 16); // AAAA - also not an address this stack can use
+ m.rr(q.hlen, 1, 1, &[_]u8{ 172, 67, 221, 247 }); // and finally the A
+
+ const got = try answerWith(&s, "0x4200.cafe", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 172, 67, 221, 247 }, &got);
+}
+
+test "DNS: an owner name written out in full, not compressed, is skipped correctly" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ var full: [32]u8 = undefined;
+ m.bytes(full[0..wireName(&full, "0x4200.cafe")]);
+ m.be(1); // A
+ m.be(1); // IN
+ m.bytes(&[_]u8{ 0, 0, 1, 44 }); // TTL
+ m.be(4);
+ m.bytes(&[_]u8{ 104, 21, 46, 8 });
+
+ const got = try answerWith(&s, "0x4200.cafe", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got);
+}
+
+test "DNS: a compression pointer that loops is bounded, not followed forever" {
+ // The gadget: at the start of the answer section, a one-byte label followed by a pointer back
+ // to that label. Every jump goes strictly backwards - so the "pointers must point backwards"
+ // check that most parsers stop at passes it - and the walk still never ends, because stepping
+ // over the label moves forward again. Only counting the jumps terminates this.
+ //
+ // If this test hangs, it has failed. That is the whole point of it.
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ const gadget: u16 = @intCast(m.len);
+ m.bytes(&[_]u8{ 1, 'x' }); // a label...
+ m.be(0xc000 | gadget); // ...and a pointer back to it
+
+ try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m));
+}
+
+test "DNS: a compression pointer that points forward is rejected" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ // A forward pointer that a parser without the backwards rule would happily follow: it lands
+ // on a root label placed at the very end of this message, so the name resolves, the record
+ // behind it parses, and an address comes out. RFC 1035 4.1.4 only ever compresses against a
+ // *prior* occurrence, and the rule is what keeps `dnsSkipName`'s jumps monotone.
+ m.be(0xc000 | 0x002d); // -> offset 45, the root label appended below
+ m.be(1); // A
+ m.be(1); // IN
+ m.bytes(&[_]u8{ 0, 0, 1, 44 }); // TTL
+ m.be(4);
+ m.bytes(&[_]u8{ 6, 6, 6, 6 });
+ try testing.expectEqual(@as(usize, 45), m.len);
+ m.bytes(&[_]u8{0}); // the root label the pointer aims at
+ try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m));
+
+ // And one aimed past the end of the message entirely.
+ var s2 = newResolverStack();
+ const id2 = try startResolve(&s2, "0x4200.cafe");
+ var far: Msg = .{};
+ far.header(id2, 0x8180, 1, 1);
+ far.question("0x4200.cafe", 1, 1);
+ far.be(0xc000 | 0x00fa);
+ try testing.expectError(error.DnsMalformed, answerWith(&s2, "0x4200.cafe", &far));
+}
+
+test "DNS: a reserved label type is refused rather than guessed past" {
+ // RFC 1035 4.1.4 defines the two top bits of a length byte: 00 is a label, 11 is a pointer,
+ // 01 and 10 are reserved. A parser that treats 0x40 as "a label of 64 bytes" walks somewhere
+ // arbitrary and then keeps going - here, straight onto a well-formed A record.
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.bytes(&[_]u8{0x40}); // reserved type, low bits zero
+ m.bytes(&([_]u8{'z'} ** 64)); // what a 0x40-as-length parser would skip
+ m.bytes(&[_]u8{0}); // ...landing on a root label, so the name "parses"
+ m.be(1);
+ m.be(1);
+ m.bytes(&[_]u8{ 0, 0, 1, 44 });
+ m.be(4);
+ m.bytes(&[_]u8{ 6, 6, 6, 6 });
+ try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m));
+}
+
+test "DNS: a pointer to a self-referential offset in the question is bounded too" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ const here: u16 = @intCast(m.len);
+ // A pointer to itself: rejected by the backwards check alone, since the target is not less
+ // than the pointer's own offset.
+ m.be(0xc000 | here);
+ try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m));
+}
+
+test "DNS: a response with the wrong transaction id is ignored, and the query stays live" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id +% 1, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 1, 2, 3, 4 });
+ try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &m));
+ try testing.expectEqual(@as(u32, 0), s.counters.dns_rx);
+}
+
+test "DNS: a response echoing a different question is ignored" {
+ // The id alone is 16 bits. A resolver that checks only the id accepts an answer for any name
+ // an attacker likes, which is the entire cache-poisoning family.
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("evil.example", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 });
+ try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &m));
+
+ // The one that matters, and the one a length-blind check misses: a different name of exactly
+ // the same encoded length, so QTYPE and QCLASS still land where they are expected and every
+ // check but the name's own passes. `kafe` for `cafe`.
+ var lookalike: Msg = .{};
+ lookalike.header(id, 0x8180, 1, 1);
+ lookalike.question("0x4200.kafe", 1, 1);
+ lookalike.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 });
+ // The same encoded length as the question we actually asked, so nothing after the name moves.
+ var ours: Msg = .{};
+ ours.header(id, 0x8180, 1, 1);
+ ours.question("0x4200.cafe", 1, 1);
+ ours.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 });
+ try testing.expectEqual(ours.len, lookalike.len);
+ try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &lookalike));
+
+ // Nor a right name asked as the wrong type or class.
+ var wrong_type: Msg = .{};
+ wrong_type.header(id, 0x8180, 1, 1);
+ wrong_type.question("0x4200.cafe", 28, 1); // AAAA
+ wrong_type.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 });
+ try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &wrong_type));
+
+ var wrong_class: Msg = .{};
+ wrong_class.header(id, 0x8180, 1, 1);
+ wrong_class.question("0x4200.cafe", 1, 3); // CH
+ wrong_class.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 });
+ try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &wrong_class));
+}
+
+test "DNS: the echoed question is matched case-insensitively, as RFC 4343 requires" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0X4200.CAFE", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 });
+ const got = try answerWith(&s, "0x4200.cafe", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got);
+}
+
+test "DNS: a response from the wrong source, or the wrong port, is ignored" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 });
+
+ var wrong_src = udpFrame(.{ 192, 168, 1, 250 }, 53, dns_query_port, m.slice());
+ s.onFrame(wrong_src.bytes());
+ try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe"));
+
+ var wrong_port = udpFrame(dns_ip, 5353, dns_query_port, m.slice());
+ s.onFrame(wrong_port.bytes());
+ try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe"));
+
+ // And to a port that is not the one this query was sent from.
+ var wrong_dport = udpFrame(dns_ip, 53, dns_query_port +% 1, m.slice());
+ s.onFrame(wrong_dport.bytes());
+ try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe"));
+
+ // The right one still works, so the three rejections above are not rejecting everything.
+ const got = try answerWith(&s, "0x4200.cafe", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 6, 6, 6, 6 }, &got);
+}
+
+test "DNS: a query, not a response, on the right port is ignored" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x0100, 1, 1); // QR clear
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 });
+ try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &m));
+}
+
+test "DNS: NXDOMAIN and a refusal are distinct named errors" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var nx: Msg = .{};
+ nx.header(id, 0x8183, 1, 0); // RCODE 3
+ nx.question("0x4200.cafe", 1, 1);
+ try testing.expectError(error.NameNotFound, answerWith(&s, "0x4200.cafe", &nx));
+
+ var s2 = newResolverStack();
+ const id2 = try startResolve(&s2, "0x4200.cafe");
+ var refused: Msg = .{};
+ refused.header(id2, 0x8185, 1, 0); // RCODE 5, REFUSED
+ refused.question("0x4200.cafe", 1, 1);
+ try testing.expectError(error.DnsRefused, answerWith(&s2, "0x4200.cafe", &refused));
+}
+
+test "DNS: an answer with no A record in it is NameNotFound, not a hang" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 28, 1, &[_]u8{0} ** 16); // AAAA only
+ try testing.expectError(error.NameNotFound, answerWith(&s, "0x4200.cafe", &m));
+}
+
+test "DNS: an A record with the wrong RDLENGTH is not read as an address" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 2);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 1, 2, 3 }); // an A record three bytes long
+ m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 }); // the real one, behind it
+ const got = try answerWith(&s, "0x4200.cafe", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got);
+}
+
+test "DNS: an RDLENGTH that runs past the end of the message is refused, not read" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.be(0xc000 | q.hlen);
+ m.be(1);
+ m.be(1);
+ m.bytes(&[_]u8{ 0, 0, 1, 44 });
+ m.be(400); // RDLENGTH far past what follows
+ m.bytes(&[_]u8{ 104, 21, 46, 8 });
+ try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m));
+}
+
+test "DNS: every truncation of a good response is refused, and none is read off the end" {
+ // Every prefix of a well-formed answer, each against a *fresh* query - which is the part that
+ // matters. Feeding them all to one query would stop testing after the first prefix that
+ // decided it, because a decided query stops listening, and the prefixes that cut inside the
+ // resource record - exactly the ones whose bounds are worth checking - come last.
+ var cut: usize = 0;
+ while (cut < 45) : (cut += 1) {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 });
+ try testing.expectEqual(@as(usize, 45), m.len);
+
+ var f = udpFrame(dns_ip, 53, dns_query_port, m.buf[0..cut]);
+ s.onFrame(f.bytes());
+ // Ignored or refused, but never resolved: a prefix of the truth is not the truth.
+ if (s.resolve("0x4200.cafe")) |_| return error.TestUnexpectedResult else |_| {}
+ }
+ // ...and the whole thing does resolve, so the loop above is rejecting truncation and not
+ // simply rejecting everything.
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 });
+ const got = try answerWith(&s, "0x4200.cafe", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got);
+}
+
+test "DNS: two queries in sequence use different source ports" {
+ // The id is 16 bits and the port is the other 16. Reusing one port halves what an off-path
+ // spoofer has to guess, and makes a late answer to the previous query land on the live one.
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ const first_port = dns_query_port;
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 });
+ _ = try answerWith(&s, "0x4200.cafe", &m);
+
+ _ = try startResolve(&s, "example.com");
+ try testing.expect(dns_query_port != first_port);
+}
+
+test "DNS: an answer count larger than the answers present does not walk off the end" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 0xffff); // 65,535 answers promised, none delivered
+ m.question("0x4200.cafe", 1, 1);
+ try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m));
+}
+
+test "DNS: the query is retransmitted on a doubling timer and then times out" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ // Nothing before the first deadline. The query went out at t=1000 with a 1 s timer.
+ s.tick(1_999);
+ try testing.expectEqual(@as(usize, 0), cap_n);
+
+ s.tick(2_000);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const re = try queryOut(sent(0));
+ // The same id, so an answer to the first attempt still counts. Redrawing it is how a slow
+ // resolver turns into a timeout on a network that was working.
+ try testing.expectEqual(id, be16(re.msg, q.id));
+ clearCapture();
+
+ s.tick(3_999);
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ s.tick(4_000);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ clearCapture();
+
+ try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe"));
+ s.tick(8_000);
+ try testing.expectError(error.TimedOut, s.resolve("0x4200.cafe"));
+ try testing.expectEqual(@as(u32, 3), s.counters.dns_tx);
+ try testing.expectEqual(@as(u32, 2), s.counters.dns_retx);
+
+ // And the slot is free: the next call starts a new query rather than returning the old error.
+ try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe"));
+}
+
+test "DNS: a late answer to an abandoned query does not resolve a new one" {
+ var s = newResolverStack();
+ const first_id = try startResolve(&s, "0x4200.cafe");
+ const first_port = dns_query_port;
+ // The whole schedule: 1 s, 2 s, 4 s, then out of tries.
+ s.tick(2_000);
+ s.tick(4_000);
+ s.tick(8_000);
+ clearCapture();
+ try testing.expectError(error.TimedOut, s.resolve("0x4200.cafe"));
+ _ = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(first_id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 9, 9, 9, 9 });
+ var f = udpFrame(dns_ip, 53, first_port, m.slice());
+ s.onFrame(f.bytes());
+ try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe"));
+}
+
+test "DNS: a second name while a query is in flight is Busy, and the first is untouched" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ try testing.expectError(error.Busy, s.resolve("example.com"));
+ // The same name, spelled with a trailing root dot and in a different case, is the same query.
+ try testing.expectError(error.WouldBlock, s.resolve("0X4200.CAFE."));
+ try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe"));
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 });
+ const got = try answerWith(&s, "0x4200.cafe.", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got);
+}
+
+test "DNS: with no resolver and no address, resolve says which one is missing" {
+ var no_server = newStack();
+ no_server.tick(1000);
+ no_server.setStatic(our_ip, mask24, gw_ip);
+ clearCapture();
+ try testing.expectError(error.NoDnsServer, no_server.resolve("0x4200.cafe"));
+ try testing.expectEqual(@as(usize, 0), cap_n);
+
+ var no_addr = newStack();
+ no_addr.tick(1000);
+ no_addr.setDnsServer(dns_ip);
+ clearCapture();
+ try testing.expectError(error.NoAddress, no_addr.resolve("0x4200.cafe"));
+ try testing.expectEqual(@as(usize, 0), cap_n);
+}
+
+test "DNS: an unusable name is refused before a byte leaves, and says which way it was unusable" {
+ var s = newResolverStack();
+ const long: [ip.dns_name_max + 1]u8 = @splat('a');
+ try testing.expectError(error.NameTooLong, s.resolve(&long));
+ // A label over 63 bytes, inside a name that is itself short enough - so this is the label
+ // rule and not the name rule that rejects it.
+ const long_label = "b" ** 64;
+ for ([_][]const u8{ "", ".", "..", ".a", "a..b", long_label }) |bad| {
+ try testing.expectError(error.NameInvalid, s.resolve(bad));
+ }
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ // A name of exactly the maximum is fine, and is what proves the limit is off by nothing:
+ // 31 + 1 + 32 = 64 text bytes, encoding to 66 - which is `dns_qname_max` exactly.
+ const ok = "a" ** 31 ++ "." ++ "b" ** 32;
+ try testing.expectEqual(@as(usize, ip.dns_name_max), ok.len);
+ try testing.expectError(error.WouldBlock, s.resolve(ok));
+}
+
+test "DNS: the resolver DHCP supplied is the one resolve asks, with nothing configured" {
+ // The default path on this network: the lease carries option 6 and the caller does nothing.
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const discover = try dhcpOut(sent(0));
+ const xid = be32(discover, d.xid);
+
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(offer.bytes());
+ var ack = dhcpReply(5, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(ack.bytes());
+ try testing.expectEqual(ip.DhcpState.bound, s.dhcpState());
+ try testing.expectEqualSlices(u8, &dns_ip, &(s.dnsServer().?));
+
+ // The resolver's MAC, so the query can actually be addressed.
+ var probe = arpFrame(1, gw_mac, dns_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 });
+ const got = try answerWith(&s, "0x4200.cafe", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got);
+}
+
+test "DNS: a new lease abandons a query in flight rather than leaving it to time out" {
+ var s = newResolverStack();
+ _ = try startResolve(&s, "0x4200.cafe");
+ s.dhcpStart();
+ // No address and no resolver now, and the query is gone with them - so this is the error that
+ // names what is missing, not `Busy` from a query nobody can answer.
+ try testing.expectError(error.NoAddress, s.resolve("0x4200.cafe"));
+}
+
+test "identity: the clock stirs the transaction ids, so two boots do not collide" {
+ // Same MAC, same firmware, different moment of first tick. If `tick` did not mix `now_ms` into
+ // the entropy, both would draw identical DHCP transaction ids and identical initial sequence
+ // numbers, and a reboot would happily accept a reply meant for its previous incarnation.
+ var a = newStack();
+ a.tick(1234);
+ a.dhcpStart();
+ const xid_a = be32(sent(0)[42..], d.xid);
+
+ var b = newStack();
+ b.tick(9_876_543);
+ b.dhcpStart();
+ const xid_b = be32(sent(0)[42..], d.xid);
+
+ try testing.expect(xid_a != xid_b);
+}
+
+// ================================================================================ footprint
+
+test "footprint: the static cost of one Stack" {
+ // No printing. The test runner speaks a binary protocol over its own stdio under
+ // `zig build test`, and a diagnostic in the middle of it costs the whole suite's results for
+ // the sake of a number that an assertion states better anyway.
+ //
+ // 6 KiB is the ceiling, and it is not arbitrary: the image has ~128 KB of L2MEM, nothing
+ // initialises the 32 MB of PSRAM, and ESP-Hosted's queues and its task stacks compete for the
+ // same space. The stack is ~4,600 bytes today: 3,472 before chunked decoding and the resolver
+ // (104 bytes between them, mostly the encoded question), then 1,024 more when `http_head_max`
+ // went 1024 -> 2048 to fit a real CDN response head - measured at 1,043 bytes from the site this
+ // was pointed at, which failed the request by 19 bytes at the old size.
+ //
+ // A regression to 30 KiB would not announce itself any other way; it would show up as a stack
+ // overflow on the die. The heap in examples/http.zig was reduced by the same 2 KB this raise
+ // cost, so the image's total is unchanged.
+ const n = ip.Stack.footprint;
+ try testing.expect(n <= 6 * 1024);
+ // And a floor, so the ceiling cannot be met by quietly shrinking a buffer that the protocol
+ // needs: one full frame to build in, the request held for retransmission, the response head
+ // held while waiting for the blank line, and the DNS question held for the retransmissions
+ // and for the comparison against what the server echoes back.
+ try testing.expect(n >= ip.frame_max + ip.tcp_tx_max + ip.http_head_max + ip.dns_qname_max);
+}