//! 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); }