diff options
Diffstat (limited to 'src/net/ip_test.zig')
| -rw-r--r-- | src/net/ip_test.zig | 3029 |
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); +} |
