srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-05-14 01:42:00 +0200
committersrdusr <[email protected]>2024-05-14 01:42:00 +0200
commit08332a4195956611db80a2cfe3710d760cbd6acf (patch)
tree0cb5cdf9fdcfdd8dc8c129a33575ad9b182d5c01 /tests
downloadpacketeer-08332a4195956611db80a2cfe3710d760cbd6acf.tar.gz
packeteer-08332a4195956611db80a2cfe3710d760cbd6acf.zip
Initial commit: wireframe packet capture/analysis tool
Terminal packet capture and analysis tool built to learn the C++ memory model (byte layout, alignment, endianness, std::span over unowned buffers) via a real capture pipeline. - Hand-rolled L2-L4 decoders (Ethernet, IPv4, IPv6 with extension header walking, TCP, UDP) over std::span, no struct-casting - L7 dissector interface with DNS, HTTP, and TLS SNI implementations - pcapng read/write for Wireshark-compatible capture files - Bounded capture queue: drop-on-backpressure for live capture, blocking push for faithful file replay - Kernel-level BPF filtering (-f) and a separate display-only search (-g / interactive) that doesn't touch what's captured - Replay mode (-r) reads a saved pcapng file back through the same pipeline as live capture, no root or live device needed - pcap_stats() surfaces kernel/interface drops invisible to the capture queue's own counter - Three frontends sharing one CaptureSession setup path: CLI, TUI (FTXUI, primary), GUI (Dear ImGui + SDL3, secondary) - 89 unit tests (doctest) plus 9 libFuzzer harnesses covering every hand-rolled parser; fuzzing found and fixed a real OOM in the pcapng reader (unbounded allocation from an untrusted length field)
Diffstat (limited to 'tests')
-rw-r--r--tests/main.cpp2
-rw-r--r--tests/test_byteio.cpp23
-rw-r--r--tests/test_capture_queue.cpp122
-rw-r--r--tests/test_capture_session.cpp138
-rw-r--r--tests/test_dns.cpp82
-rw-r--r--tests/test_filter.cpp69
-rw-r--r--tests/test_http.cpp78
-rw-r--r--tests/test_ipv6.cpp169
-rw-r--r--tests/test_net.cpp124
-rw-r--r--tests/test_pcapng.cpp142
-rw-r--r--tests/test_search.cpp27
-rw-r--r--tests/test_summarize.cpp185
-rw-r--r--tests/test_tls.cpp132
13 files changed, 1293 insertions, 0 deletions
diff --git a/tests/main.cpp b/tests/main.cpp
new file mode 100644
index 0000000..0a3f254
--- /dev/null
+++ b/tests/main.cpp
@@ -0,0 +1,2 @@
+#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
+#include <doctest/doctest.h>
diff --git a/tests/test_byteio.cpp b/tests/test_byteio.cpp
new file mode 100644
index 0000000..81fa741
--- /dev/null
+++ b/tests/test_byteio.cpp
@@ -0,0 +1,23 @@
+#include <doctest/doctest.h>
+
+#include <vector>
+
+#include "wireframe/byteio.hpp"
+
+using namespace wireframe;
+
+TEST_CASE("read_be16 reads a big-endian 16-bit value") {
+ std::vector<unsigned char> bytes = {0x12, 0x34};
+ CHECK(read_be16(bytes, 0) == 0x1234);
+}
+
+TEST_CASE("read_be32 reads a big-endian 32-bit value") {
+ std::vector<unsigned char> bytes = {0xDE, 0xAD, 0xBE, 0xEF};
+ CHECK(read_be32(bytes, 0) == 0xDEADBEEFu);
+}
+
+TEST_CASE("read_be16/read_be32 read from a nonzero offset") {
+ std::vector<unsigned char> bytes = {0x00, 0x00, 0x12, 0x34, 0x56, 0x78};
+ CHECK(read_be16(bytes, 2) == 0x1234);
+ CHECK(read_be32(bytes, 2) == 0x12345678u);
+}
diff --git a/tests/test_capture_queue.cpp b/tests/test_capture_queue.cpp
new file mode 100644
index 0000000..da2da28
--- /dev/null
+++ b/tests/test_capture_queue.cpp
@@ -0,0 +1,122 @@
+#include <doctest/doctest.h>
+
+#include <atomic>
+#include <chrono>
+#include <thread>
+
+#include "wireframe/capture_queue.hpp"
+
+using namespace wireframe;
+
+TEST_CASE("try_push/pop returns packets in FIFO order") {
+ CaptureQueue queue(4);
+ for (std::uint32_t i = 0; i < 3; ++i) {
+ CapturedPacket p;
+ p.ts_sec = i;
+ p.data = {static_cast<unsigned char>(i)};
+ CHECK(queue.try_push(std::move(p)));
+ }
+ for (std::uint32_t i = 0; i < 3; ++i) {
+ auto p = queue.pop();
+ REQUIRE(p.has_value());
+ CHECK(p->ts_sec == i);
+ }
+ CHECK(queue.dropped() == 0);
+}
+
+TEST_CASE("try_push drops and counts once the queue is full") {
+ CaptureQueue queue(2);
+ CapturedPacket a, b, c;
+ CHECK(queue.try_push(std::move(a)));
+ CHECK(queue.try_push(std::move(b)));
+ CHECK_FALSE(queue.try_push(std::move(c))); // full: dropped, not blocked
+ CHECK(queue.dropped() == 1);
+}
+
+TEST_CASE("stop() drains items already queued before pop() returns nullopt") {
+ CaptureQueue queue(4);
+ CapturedPacket a, b;
+ queue.try_push(std::move(a));
+ queue.try_push(std::move(b));
+ queue.stop();
+
+ CHECK(queue.pop().has_value());
+ CHECK(queue.pop().has_value());
+ CHECK_FALSE(queue.pop().has_value()); // drained and stopped
+}
+
+TEST_CASE("pop() blocks until a packet is pushed") {
+ CaptureQueue queue(4);
+ std::thread producer([&] {
+ std::this_thread::sleep_for(std::chrono::milliseconds(50));
+ CapturedPacket p;
+ p.data = {0x42};
+ queue.try_push(std::move(p));
+ });
+
+ auto p = queue.pop();
+ REQUIRE(p.has_value());
+ CHECK(p->data[0] == 0x42);
+ producer.join();
+}
+
+TEST_CASE("push() succeeds immediately when there's room") {
+ CaptureQueue queue(4);
+ CapturedPacket p;
+ p.data = {0x01};
+ CHECK(queue.push(std::move(p)));
+ CHECK(queue.dropped() == 0);
+}
+
+TEST_CASE("push() blocks for room instead of dropping, unlike try_push()") {
+ CaptureQueue queue(1);
+ CapturedPacket a;
+ a.data = {0xAA};
+ CHECK(queue.try_push(std::move(a))); // fills the only slot
+
+ std::atomic<bool> pushed{false};
+ std::thread producer([&] {
+ CapturedPacket b;
+ b.data = {0xBB};
+ CHECK(queue.push(std::move(b))); // must block until the pop() below frees room
+ pushed.store(true);
+ });
+
+ std::this_thread::sleep_for(std::chrono::milliseconds(50));
+ CHECK_FALSE(pushed.load()); // still blocked: queue was full this whole time
+
+ auto first = queue.pop(); // frees a slot
+ REQUIRE(first.has_value());
+ CHECK(first->data[0] == 0xAA);
+
+ producer.join();
+ CHECK(pushed.load());
+ CHECK(queue.dropped() == 0); // never dropped - it waited instead
+
+ auto second = queue.pop();
+ REQUIRE(second.has_value());
+ CHECK(second->data[0] == 0xBB);
+}
+
+TEST_CASE("push() returns false without pushing if stop() is called while it's waiting") {
+ CaptureQueue queue(1);
+ CapturedPacket a;
+ a.data = {0xAA};
+ queue.try_push(std::move(a)); // fill the only slot
+
+ std::atomic<bool> result_ready{false};
+ std::atomic<bool> push_result{true};
+ std::thread producer([&] {
+ CapturedPacket b;
+ b.data = {0xBB};
+ push_result.store(queue.push(std::move(b)));
+ result_ready.store(true);
+ });
+
+ std::this_thread::sleep_for(std::chrono::milliseconds(50));
+ queue.stop();
+ producer.join();
+
+ CHECK(result_ready.load());
+ CHECK_FALSE(push_result.load());
+}
diff --git a/tests/test_capture_session.cpp b/tests/test_capture_session.cpp
new file mode 100644
index 0000000..691131a
--- /dev/null
+++ b/tests/test_capture_session.cpp
@@ -0,0 +1,138 @@
+#include <doctest/doctest.h>
+#include <pcap.h>
+#include <unistd.h>
+
+#include <cstdio>
+#include <string>
+#include <vector>
+
+#include "wireframe/capture_session.hpp"
+#include "wireframe/pcapng/writer.hpp"
+
+using namespace wireframe;
+
+namespace {
+
+// A real, named pcapng file on disk - CaptureSession::open() takes a
+// path, not a FILE*, so a std::tmpfile() (unnamed) doesn't work here
+// the way it does in test_pcapng.cpp. Cleans itself up via RAII.
+struct TempPcapngFile {
+ std::string path;
+
+ explicit TempPcapngFile(int link_type, const std::vector<std::vector<unsigned char>>& packets) {
+ char path_template[] = "/tmp/wireframe_test_XXXXXX";
+ int fd = mkstemp(path_template);
+ REQUIRE(fd != -1);
+ path = path_template;
+
+ std::FILE* f = fdopen(fd, "wb");
+ REQUIRE(f != nullptr);
+ pcapng::Writer writer(f);
+ writer.write_section_header();
+ writer.write_interface_description(65535, static_cast<std::uint16_t>(link_type));
+ std::uint32_t ts = 1700000000;
+ for (const auto& packet : packets) {
+ writer.write_packet(0, ts++, 0, packet,
+ static_cast<std::uint32_t>(packet.size()));
+ }
+ std::fclose(f);
+ }
+
+ ~TempPcapngFile() { std::remove(path.c_str()); }
+};
+
+} // namespace
+
+TEST_CASE("is_supported_datalink accepts EN10MB and RAW, rejects others") {
+ CHECK(is_supported_datalink(DLT_EN10MB));
+ CHECK(is_supported_datalink(DLT_RAW));
+ CHECK_FALSE(is_supported_datalink(DLT_IEEE802_11));
+}
+
+TEST_CASE("CaptureSession::open reports an error for a nonexistent device, without needing root") {
+ CaptureSession session;
+ CaptureSessionOptions options;
+ options.device = "this-device-does-not-exist-0xdeadbeef";
+
+ auto err = session.open(options);
+ REQUIRE(err.has_value());
+ CHECK_FALSE(err->empty());
+}
+
+TEST_CASE("CaptureSession::stats returns nullopt before open()") {
+ CaptureSession session;
+ CHECK_FALSE(session.stats().has_value());
+}
+
+TEST_CASE("CaptureSession::open replays a pcapng file without needing root or a live device") {
+ TempPcapngFile file(DLT_EN10MB, {{0xDE, 0xAD}, {0xBE, 0xEF}});
+
+ CaptureSession session;
+ CaptureSessionOptions options;
+ options.replay_input_path = file.path;
+
+ CHECK_FALSE(session.open(options).has_value());
+ CHECK(session.is_replay());
+ CHECK(session.datalink() == DLT_EN10MB);
+ CHECK(session.device() == file.path);
+ CHECK_FALSE(session.stats().has_value()); // pcap_stats() needs a live handle; replay has none
+}
+
+TEST_CASE("CaptureSession::open rejects combining -r (replay) with -f (capture filter)") {
+ TempPcapngFile file(DLT_EN10MB, {{0x01}});
+
+ CaptureSession session;
+ CaptureSessionOptions options;
+ options.replay_input_path = file.path;
+ options.filter_expr = "tcp";
+
+ auto err = session.open(options);
+ REQUIRE(err.has_value());
+ CHECK(err->find("-r") != std::string::npos);
+}
+
+TEST_CASE("CaptureSession::open reports an error for a nonexistent replay file") {
+ CaptureSession session;
+ CaptureSessionOptions options;
+ options.replay_input_path = "/tmp/this-file-does-not-exist-0xdeadbeef.pcapng";
+
+ auto err = session.open(options);
+ REQUIRE(err.has_value());
+ CHECK_FALSE(err->empty());
+}
+
+TEST_CASE("CaptureSession::open reports an error for a pcapng file with no packets") {
+ TempPcapngFile file(DLT_EN10MB, {}); // just SHB + IDB, no EPBs
+
+ CaptureSession session;
+ CaptureSessionOptions options;
+ options.replay_input_path = file.path;
+
+ auto err = session.open(options);
+ REQUIRE(err.has_value());
+}
+
+TEST_CASE("replayed packets reach the CaptureQueue in order, and the thread stops on its own") {
+ TempPcapngFile file(DLT_RAW, {{0x01, 0x02}, {0x03, 0x04}, {0x05, 0x06}});
+
+ CaptureSession session;
+ CaptureSessionOptions options;
+ options.replay_input_path = file.path;
+ REQUIRE_FALSE(session.open(options).has_value());
+ CHECK(session.datalink() == DLT_RAW);
+
+ CaptureQueue queue(4096);
+ auto capture_thread = session.start_capture_thread(queue);
+
+ std::vector<unsigned char> first_bytes;
+ int count = 0;
+ while (auto packet = queue.pop()) {
+ if (count == 0) first_bytes = packet->data;
+ ++count;
+ }
+ capture_thread.join();
+
+ CHECK(count == 3);
+ REQUIRE(first_bytes.size() == 2);
+ CHECK(first_bytes[0] == 0x01);
+}
diff --git a/tests/test_dns.cpp b/tests/test_dns.cpp
new file mode 100644
index 0000000..9a389bb
--- /dev/null
+++ b/tests/test_dns.cpp
@@ -0,0 +1,82 @@
+#include <doctest/doctest.h>
+
+#include <vector>
+
+#include "wireframe/l7/dns.hpp"
+
+using namespace wireframe::net;
+
+namespace {
+
+// "example.com" A query, id=0x129d - the same shape as the real query
+// captured live over tailscale0 while testing the DNS dissector against
+// tshark (id 0x129d / 4765 matched tshark's independent decode exactly).
+std::vector<unsigned char> example_com_query() {
+ return {
+ 0x12, 0x9d, // id = 4765
+ 0x01, 0x00, // flags: RD=1
+ 0x00, 0x01, // qdcount = 1
+ 0x00, 0x00, // ancount = 0
+ 0x00, 0x00, // nscount = 0
+ 0x00, 0x00, // arcount = 0
+ 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0,
+ 0x00, 0x01, // qtype = A
+ 0x00, 0x01, // qclass = IN
+ };
+}
+
+} // namespace
+
+TEST_CASE("parse_dns decodes a query") {
+ auto msg = parse_dns(example_com_query());
+ REQUIRE(msg.has_value());
+ CHECK(msg->header.id == 4765);
+ CHECK_FALSE(msg->header.is_response);
+ CHECK(msg->header.qdcount == 1);
+ REQUIRE(msg->question.has_value());
+ CHECK(msg->question->name == "example.com");
+ CHECK(msg->question->qtype == 1);
+}
+
+TEST_CASE("parse_dns decodes a response") {
+ std::vector<unsigned char> bytes = {
+ 0x12, 0x9d,
+ 0x81, 0x80, // flags: QR=1 (response), RD=1, RA=1
+ 0x00, 0x01, // qdcount = 1
+ 0x00, 0x02, // ancount = 2
+ 0x00, 0x00,
+ 0x00, 0x00,
+ 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0,
+ 0x00, 0x01, 0x00, 0x01,
+ };
+ auto msg = parse_dns(bytes);
+ REQUIRE(msg.has_value());
+ CHECK(msg->header.is_response);
+ CHECK(msg->header.ancount == 2);
+}
+
+TEST_CASE("parse_dns rejects a truncated header") {
+ std::vector<unsigned char> bytes(5, 0);
+ CHECK_FALSE(parse_dns(bytes).has_value());
+}
+
+TEST_CASE("read_dns_name rejects a compression pointer") {
+ std::vector<unsigned char> bytes = {0xC0, 0x0C}; // pointer: unsupported by design
+ CHECK_FALSE(read_dns_name(bytes, 0).has_value());
+}
+
+TEST_CASE("DnsDissector claims port 53 and its summary matches parse_dns") {
+ DnsDissector dissector;
+ CHECK(dissector.port() == kDnsPort);
+
+ auto summary = dissector.summarize(example_com_query());
+ REQUIRE(summary.has_value());
+ CHECK(summary->substr(0, 9) == "DNS query");
+ CHECK(summary->find("example.com") != std::string::npos);
+}
+
+TEST_CASE("DnsDissector::summarize returns nullopt for a truncated payload") {
+ DnsDissector dissector;
+ std::vector<unsigned char> bytes(5, 0);
+ CHECK_FALSE(dissector.summarize(bytes).has_value());
+}
diff --git a/tests/test_filter.cpp b/tests/test_filter.cpp
new file mode 100644
index 0000000..1dd9373
--- /dev/null
+++ b/tests/test_filter.cpp
@@ -0,0 +1,69 @@
+#include <doctest/doctest.h>
+#include <pcap.h>
+
+#include "wireframe/filter.hpp"
+
+namespace {
+
+// pcap_open_dead() creates a handle that isn't attached to any real
+// interface - exactly what pcap_compile() needs (linktype + snaplen)
+// without requiring root or a live device.
+struct DeadHandle {
+ pcap_t* handle;
+ explicit DeadHandle(int datalink) : handle(pcap_open_dead(datalink, 65535)) {}
+ ~DeadHandle() {
+ if (handle) pcap_close(handle);
+ }
+};
+
+} // namespace
+
+TEST_CASE("compile_filter accepts a valid tcpdump-style expression") {
+ DeadHandle dead(DLT_EN10MB);
+ REQUIRE(dead.handle != nullptr);
+
+ bpf_program prog{};
+ auto err = wireframe::compile_filter(dead.handle, "tcp port 80", &prog);
+ CHECK_FALSE(err.has_value());
+ pcap_freecode(&prog);
+}
+
+TEST_CASE("compile_filter accepts a compound expression") {
+ DeadHandle dead(DLT_EN10MB);
+ REQUIRE(dead.handle != nullptr);
+
+ bpf_program prog{};
+ auto err = wireframe::compile_filter(dead.handle, "host 10.0.0.1 and not icmp", &prog);
+ CHECK_FALSE(err.has_value());
+ pcap_freecode(&prog);
+}
+
+TEST_CASE("compile_filter rejects invalid syntax with an error message") {
+ DeadHandle dead(DLT_EN10MB);
+ REQUIRE(dead.handle != nullptr);
+
+ bpf_program prog{};
+ auto err = wireframe::compile_filter(dead.handle, "this is not a valid filter !!", &prog);
+ REQUIRE(err.has_value());
+ CHECK_FALSE(err->empty());
+}
+
+TEST_CASE("compile_filter works against DLT_RAW, not just Ethernet") {
+ DeadHandle dead(DLT_RAW);
+ REQUIRE(dead.handle != nullptr);
+
+ bpf_program prog{};
+ auto err = wireframe::compile_filter(dead.handle, "udp", &prog);
+ CHECK_FALSE(err.has_value());
+ pcap_freecode(&prog);
+}
+
+TEST_CASE("compile_filter rejects an Ethernet-only expression against DLT_RAW") {
+ DeadHandle dead(DLT_RAW);
+ REQUIRE(dead.handle != nullptr);
+
+ bpf_program prog{};
+ // "ether" primitives are meaningless without a link-layer header.
+ auto err = wireframe::compile_filter(dead.handle, "ether host 00:11:22:33:44:55", &prog);
+ CHECK(err.has_value());
+}
diff --git a/tests/test_http.cpp b/tests/test_http.cpp
new file mode 100644
index 0000000..7ccc9ff
--- /dev/null
+++ b/tests/test_http.cpp
@@ -0,0 +1,78 @@
+#include <doctest/doctest.h>
+
+#include <vector>
+
+#include "wireframe/l7/http.hpp"
+
+using namespace wireframe::net;
+
+namespace {
+
+std::vector<unsigned char> to_bytes(std::string_view text) {
+ return std::vector<unsigned char>(text.begin(), text.end());
+}
+
+} // namespace
+
+TEST_CASE("parse_http decodes a GET request with a Host header") {
+ auto bytes = to_bytes("GET /index.html HTTP/1.1\r\nHost: example.com\r\nUser-Agent: x\r\n\r\n");
+ auto msg = parse_http(bytes);
+ REQUIRE(msg.has_value());
+ CHECK(msg->is_request);
+ CHECK(msg->method_or_version == "GET");
+ CHECK(msg->target_or_status == "/index.html");
+ REQUIRE(msg->host.has_value());
+ CHECK(*msg->host == "example.com");
+}
+
+TEST_CASE("parse_http decodes a POST request without a Host header") {
+ auto bytes = to_bytes("POST /api/submit HTTP/1.1\r\nContent-Length: 0\r\n\r\n");
+ auto msg = parse_http(bytes);
+ REQUIRE(msg.has_value());
+ CHECK(msg->method_or_version == "POST");
+ CHECK(msg->target_or_status == "/api/submit");
+ CHECK_FALSE(msg->host.has_value());
+}
+
+TEST_CASE("parse_http decodes a status line as a response") {
+ auto bytes = to_bytes("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n");
+ auto msg = parse_http(bytes);
+ REQUIRE(msg.has_value());
+ CHECK_FALSE(msg->is_request);
+ CHECK(msg->method_or_version == "HTTP/1.1");
+ CHECK(msg->target_or_status == "404");
+}
+
+TEST_CASE("parse_http tolerates a bare LF request line") {
+ auto bytes = to_bytes("GET / HTTP/1.0\n\n");
+ auto msg = parse_http(bytes);
+ REQUIRE(msg.has_value());
+ CHECK(msg->target_or_status == "/");
+}
+
+TEST_CASE("parse_http rejects payloads that don't look like HTTP") {
+ auto bytes = to_bytes("this is not http traffic at all\r\n");
+ CHECK_FALSE(parse_http(bytes).has_value());
+}
+
+TEST_CASE("parse_http rejects an empty payload") {
+ std::vector<unsigned char> bytes;
+ CHECK_FALSE(parse_http(bytes).has_value());
+}
+
+TEST_CASE("parse_http rejects a request line with no target/version fields") {
+ auto bytes = to_bytes("GET\r\n\r\n");
+ CHECK_FALSE(parse_http(bytes).has_value());
+}
+
+TEST_CASE("HttpDissector claims port 80 and its summary matches parse_http") {
+ HttpDissector dissector;
+ CHECK(dissector.port() == kHttpPort);
+
+ auto bytes = to_bytes("GET /path HTTP/1.1\r\nHost: wireframe.test\r\n\r\n");
+ auto summary = dissector.summarize(bytes);
+ REQUIRE(summary.has_value());
+ CHECK(summary->substr(0, 4) == "HTTP");
+ CHECK(summary->find("GET /path") != std::string::npos);
+ CHECK(summary->find("wireframe.test") != std::string::npos);
+}
diff --git a/tests/test_ipv6.cpp b/tests/test_ipv6.cpp
new file mode 100644
index 0000000..438fadc
--- /dev/null
+++ b/tests/test_ipv6.cpp
@@ -0,0 +1,169 @@
+#include <doctest/doctest.h>
+
+#include <vector>
+
+#include "wireframe/net/ipv4.hpp" // for kProtoTcp
+#include "wireframe/net/ipv6.hpp"
+
+using namespace wireframe::net;
+
+namespace {
+
+Ipv6Address addr_from_groups(std::array<std::uint16_t, 8> groups) {
+ Ipv6Address addr{};
+ for (std::size_t i = 0; i < 8; ++i) {
+ addr.bytes[i * 2] = static_cast<unsigned char>(groups[i] >> 8);
+ addr.bytes[i * 2 + 1] = static_cast<unsigned char>(groups[i] & 0xFF);
+ }
+ return addr;
+}
+
+} // namespace
+
+TEST_CASE("parse_ipv6 decodes header fields and leaves the right payload") {
+ std::vector<unsigned char> bytes(40, 0);
+ bytes[0] = 0x60; // version 6, traffic class high nibble 0
+ bytes[1] = 0x00; // traffic class low nibble 0, flow label starts 0
+ bytes[4] = 0x00;
+ bytes[5] = 0x04; // payload_length = 4
+ bytes[6] = kProtoTcp;
+ bytes[7] = 64; // hop_limit
+ // src = 2001:0db8::1
+ bytes[8] = 0x20; bytes[9] = 0x01; bytes[10] = 0x0d; bytes[11] = 0xb8;
+ bytes[23] = 0x01;
+ // dst = ::1
+ bytes[39] = 0x01;
+ bytes.insert(bytes.end(), {0xAA, 0xBB, 0xCC, 0xDD});
+
+ auto ip6 = parse_ipv6(bytes);
+ REQUIRE(ip6.has_value());
+ CHECK(ip6->header.version == 6);
+ CHECK(ip6->header.payload_length == 4);
+ CHECK(ip6->header.next_header == kProtoTcp);
+ CHECK(ip6->header.hop_limit == 64);
+ REQUIRE(ip6->payload.size() == 4);
+ CHECK(ip6->payload[0] == 0xAA);
+}
+
+TEST_CASE("parse_ipv6 rejects a non-IPv6 version") {
+ std::vector<unsigned char> bytes(40, 0);
+ bytes[0] = 0x45; // version 4
+ CHECK_FALSE(parse_ipv6(bytes).has_value());
+}
+
+TEST_CASE("parse_ipv6 rejects a buffer shorter than the 40-byte header") {
+ std::vector<unsigned char> bytes(39, 0);
+ bytes[0] = 0x60;
+ CHECK_FALSE(parse_ipv6(bytes).has_value());
+}
+
+TEST_CASE("ipv6_to_string compresses the loopback address") {
+ CHECK(ipv6_to_string(addr_from_groups({0, 0, 0, 0, 0, 0, 0, 1})) == "::1");
+}
+
+TEST_CASE("ipv6_to_string compresses the unspecified address") {
+ CHECK(ipv6_to_string(addr_from_groups({0, 0, 0, 0, 0, 0, 0, 0})) == "::");
+}
+
+TEST_CASE("ipv6_to_string compresses a zero run in the middle") {
+ CHECK(ipv6_to_string(addr_from_groups({0x2001, 0x0db8, 0, 0, 0, 0, 0, 1})) == "2001:db8::1");
+}
+
+TEST_CASE("ipv6_to_string does not compress a lone zero group") {
+ CHECK(ipv6_to_string(addr_from_groups({0x2001, 0, 0x0db8, 1, 1, 1, 1, 1})) ==
+ "2001:0:db8:1:1:1:1:1");
+}
+
+TEST_CASE("ipv6_to_string picks the leftmost run when two runs tie in length") {
+ // Two runs of length 2: groups[1..2] and groups[5..6]. Leftmost wins.
+ CHECK(ipv6_to_string(addr_from_groups({1, 0, 0, 2, 3, 0, 0, 4})) == "1::2:3:0:0:4");
+}
+
+TEST_CASE("ipv6_to_string leaves an address with no zero run untouched") {
+ CHECK(ipv6_to_string(addr_from_groups({1, 2, 3, 4, 5, 6, 7, 8})) == "1:2:3:4:5:6:7:8");
+}
+
+TEST_CASE("walk_ipv6_extension_headers passes a direct transport protocol through unchanged") {
+ std::vector<unsigned char> payload = {0xAA, 0xBB, 0xCC};
+ auto result = walk_ipv6_extension_headers(kProtoTcp, payload);
+ CHECK(result.final_next_header == kProtoTcp);
+ CHECK_FALSE(result.stopped_at_esp);
+ REQUIRE(result.payload.size() == 3);
+ CHECK(result.payload[0] == 0xAA);
+}
+
+TEST_CASE("walk_ipv6_extension_headers walks a single Hop-by-Hop header to reach TCP") {
+ // Hop-by-Hop: next_header(1)=TCP, hdr_ext_len(1)=0 -> total len (0+1)*8=8 bytes.
+ std::vector<unsigned char> payload = {static_cast<unsigned char>(kProtoTcp), 0x00,
+ 0, 0, 0, 0, 0, 0}; // 6 bytes of option padding
+ std::vector<unsigned char> tcp_marker = {0xDE, 0xAD};
+ payload.insert(payload.end(), tcp_marker.begin(), tcp_marker.end());
+
+ auto result = walk_ipv6_extension_headers(kNextHeaderHopByHop, payload);
+ CHECK(result.final_next_header == kProtoTcp);
+ CHECK_FALSE(result.stopped_at_esp);
+ REQUIRE(result.payload.size() == 2);
+ CHECK(result.payload[0] == 0xDE);
+}
+
+TEST_CASE("walk_ipv6_extension_headers walks a chain of two extension headers") {
+ // Hop-by-Hop (8 bytes) -> Destination Options (8 bytes) -> UDP.
+ std::vector<unsigned char> payload = {
+ kNextHeaderDestOptions, 0x00, 0, 0, 0, 0, 0, 0, // Hop-by-Hop, len 8
+ static_cast<unsigned char>(kProtoUdp), 0x00, 0, 0, 0, 0, 0, 0, // Dest Options, len 8
+ 0xFE, 0xED, // "UDP header" marker
+ };
+ auto result = walk_ipv6_extension_headers(kNextHeaderHopByHop, payload);
+ CHECK(result.final_next_header == kProtoUdp);
+ REQUIRE(result.payload.size() == 2);
+ CHECK(result.payload[0] == 0xFE);
+}
+
+TEST_CASE("walk_ipv6_extension_headers walks the fixed-size Fragment header") {
+ std::vector<unsigned char> payload = {static_cast<unsigned char>(kProtoTcp), 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // 8-byte fragment header
+ 0xCA, 0xFE};
+ auto result = walk_ipv6_extension_headers(kNextHeaderFragment, payload);
+ CHECK(result.final_next_header == kProtoTcp);
+ REQUIRE(result.payload.size() == 2);
+ CHECK(result.payload[0] == 0xCA);
+}
+
+TEST_CASE("walk_ipv6_extension_headers applies AH's 4-byte-unit length formula") {
+ // AH: next_header(1)=TCP, payload_len(1)=1 -> total len (1+2)*4=12 bytes.
+ std::vector<unsigned char> payload(12, 0);
+ payload[0] = static_cast<unsigned char>(kProtoTcp);
+ payload[1] = 0x01;
+ payload.push_back(0x11);
+ payload.push_back(0x22);
+
+ auto result = walk_ipv6_extension_headers(kNextHeaderAh, payload);
+ CHECK(result.final_next_header == kProtoTcp);
+ REQUIRE(result.payload.size() == 2);
+ CHECK(result.payload[0] == 0x11);
+}
+
+TEST_CASE("walk_ipv6_extension_headers stops at ESP without guessing past it") {
+ std::vector<unsigned char> payload = {0x01, 0x02, 0x03, 0x04};
+ auto result = walk_ipv6_extension_headers(kNextHeaderEsp, payload);
+ CHECK(result.stopped_at_esp);
+ CHECK(result.final_next_header == kNextHeaderEsp);
+ REQUIRE(result.payload.size() == 4);
+ CHECK(result.payload[0] == 0x01); // untouched: ESP payload starts right here
+}
+
+TEST_CASE("walk_ipv6_extension_headers stops gracefully on a truncated extension header") {
+ std::vector<unsigned char> payload = {static_cast<unsigned char>(kProtoTcp),
+ 0xFF}; // claims (255+1)*8 bytes; nowhere near present
+ auto result = walk_ipv6_extension_headers(kNextHeaderHopByHop, payload);
+ CHECK(result.final_next_header == kNextHeaderHopByHop); // never resolved past it
+ CHECK_FALSE(result.stopped_at_esp);
+}
+
+TEST_CASE("walk_ipv6_extension_headers passes an unknown next_header through untouched") {
+ std::vector<unsigned char> payload = {0x01, 0x02};
+ auto result = walk_ipv6_extension_headers(200, payload); // not a known extension type
+ CHECK(result.final_next_header == 200);
+ REQUIRE(result.payload.size() == 2);
+ CHECK(result.payload[0] == 0x01);
+}
diff --git a/tests/test_net.cpp b/tests/test_net.cpp
new file mode 100644
index 0000000..09de9b0
--- /dev/null
+++ b/tests/test_net.cpp
@@ -0,0 +1,124 @@
+#include <doctest/doctest.h>
+
+#include <vector>
+
+#include "wireframe/net/ethernet.hpp"
+#include "wireframe/net/ipv4.hpp"
+#include "wireframe/net/tcp.hpp"
+#include "wireframe/net/udp.hpp"
+
+using namespace wireframe::net;
+
+TEST_CASE("parse_ethernet decodes header fields and leaves the right payload") {
+ std::vector<unsigned char> bytes = {
+ 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, // dst mac
+ 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, // src mac
+ 0x08, 0x00, // ethertype: IPv4
+ 0xDE, 0xAD, 0xBE, 0xEF, // payload
+ };
+ auto frame = parse_ethernet(bytes);
+ REQUIRE(frame.has_value());
+ CHECK(frame->header.dst.bytes == std::array<unsigned char, 6>{0x11, 0x22, 0x33, 0x44, 0x55, 0x66});
+ CHECK(frame->header.src.bytes == std::array<unsigned char, 6>{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF});
+ CHECK(frame->header.ethertype == kEthertypeIPv4);
+ REQUIRE(frame->payload.size() == 4);
+ CHECK(frame->payload[0] == 0xDE);
+}
+
+TEST_CASE("parse_ethernet rejects a frame shorter than the header") {
+ std::vector<unsigned char> bytes(10, 0); // header is 14 bytes
+ CHECK_FALSE(parse_ethernet(bytes).has_value());
+}
+
+TEST_CASE("parse_ipv4 decodes header fields and leaves the right payload") {
+ std::vector<unsigned char> bytes(20, 0);
+ bytes[0] = 0x45; // version 4, IHL 5 (20-byte header, no options)
+ bytes[2] = 0x00;
+ bytes[3] = 0x28; // total_length = 40
+ bytes[8] = 64; // ttl
+ bytes[9] = kProtoTcp;
+ bytes[12] = 10; bytes[13] = 0; bytes[14] = 0; bytes[15] = 1; // src 10.0.0.1
+ bytes[16] = 10; bytes[17] = 0; bytes[18] = 0; bytes[19] = 2; // dst 10.0.0.2
+ bytes.push_back(0x01);
+ bytes.push_back(0x02);
+
+ auto ip = parse_ipv4(bytes);
+ REQUIRE(ip.has_value());
+ CHECK(ip->header.version == 4);
+ CHECK(ip->header.ihl == 5);
+ CHECK(ip->header.total_length == 40);
+ CHECK(ip->header.ttl == 64);
+ CHECK(ip->header.protocol == kProtoTcp);
+ CHECK(ip->header.src.bytes == std::array<unsigned char, 4>{10, 0, 0, 1});
+ CHECK(ip->header.dst.bytes == std::array<unsigned char, 4>{10, 0, 0, 2});
+ REQUIRE(ip->payload.size() == 2);
+ CHECK(ip->payload[0] == 0x01);
+}
+
+TEST_CASE("parse_ipv4 rejects a non-IPv4 version") {
+ std::vector<unsigned char> bytes(20, 0);
+ bytes[0] = 0x65; // version 6
+ CHECK_FALSE(parse_ipv4(bytes).has_value());
+}
+
+TEST_CASE("parse_ipv4 rejects a buffer shorter than the header") {
+ std::vector<unsigned char> bytes(10, 0);
+ CHECK_FALSE(parse_ipv4(bytes).has_value());
+}
+
+TEST_CASE("parse_ipv4 honors IHL > 5 (options present)") {
+ std::vector<unsigned char> bytes(24, 0); // IHL=6 -> 24-byte header
+ bytes[0] = 0x46;
+ bytes[9] = kProtoUdp;
+
+ auto ip = parse_ipv4(bytes);
+ REQUIRE(ip.has_value());
+ CHECK(ip->header.ihl == 6);
+ CHECK(ip->payload.empty());
+}
+
+TEST_CASE("parse_tcp decodes header fields and flags") {
+ std::vector<unsigned char> bytes(20, 0);
+ bytes[0] = 0x00; bytes[1] = 0x50; // src port 80
+ bytes[2] = 0x1F; bytes[3] = 0x90; // dst port 8080
+ bytes[4] = 0; bytes[5] = 0; bytes[6] = 0; bytes[7] = 1; // seq = 1
+ bytes[8] = 0; bytes[9] = 0; bytes[10] = 0; bytes[11] = 2; // ack = 2
+ bytes[12] = 5 << 4; // data_offset = 5 (20-byte header, no options)
+ bytes[13] = 0x12; // SYN | ACK
+ bytes[14] = 0xFF; bytes[15] = 0xFF; // window 65535
+
+ auto tcp = parse_tcp(bytes);
+ REQUIRE(tcp.has_value());
+ CHECK(tcp->header.src_port == 80);
+ CHECK(tcp->header.dst_port == 8080);
+ CHECK(tcp->header.seq == 1);
+ CHECK(tcp->header.ack == 2);
+ CHECK(tcp->header.data_offset == 5);
+ CHECK((tcp->header.flags & kTcpSyn) != 0);
+ CHECK((tcp->header.flags & kTcpAck) != 0);
+ CHECK((tcp->header.flags & kTcpFin) == 0);
+ CHECK(tcp->header.window == 65535);
+ CHECK(tcp->payload.empty());
+}
+
+TEST_CASE("parse_tcp rejects a buffer shorter than the header") {
+ std::vector<unsigned char> bytes(10, 0);
+ CHECK_FALSE(parse_tcp(bytes).has_value());
+}
+
+TEST_CASE("parse_udp decodes header fields and leaves the right payload") {
+ std::vector<unsigned char> bytes = {0x00, 0x35, 0x1F, 0x90, 0x00, 0x0A,
+ 0x00, 0x00, 'h', 'i'};
+ auto udp = parse_udp(bytes);
+ REQUIRE(udp.has_value());
+ CHECK(udp->header.src_port == 53);
+ CHECK(udp->header.dst_port == 8080);
+ CHECK(udp->header.length == 10);
+ REQUIRE(udp->payload.size() == 2);
+ CHECK(udp->payload[0] == 'h');
+}
+
+TEST_CASE("parse_udp rejects a buffer shorter than the header") {
+ std::vector<unsigned char> bytes(4, 0);
+ CHECK_FALSE(parse_udp(bytes).has_value());
+}
diff --git a/tests/test_pcapng.cpp b/tests/test_pcapng.cpp
new file mode 100644
index 0000000..f292d32
--- /dev/null
+++ b/tests/test_pcapng.cpp
@@ -0,0 +1,142 @@
+#include <doctest/doctest.h>
+
+#include <cstdio>
+#include <vector>
+
+#include "wireframe/pcapng/reader.hpp"
+#include "wireframe/pcapng/writer.hpp"
+
+using namespace wireframe::pcapng;
+
+TEST_CASE("pcapng writer/reader round-trip a single packet") {
+ std::FILE* f = std::tmpfile();
+ REQUIRE(f != nullptr);
+
+ Writer writer(f);
+ writer.write_section_header();
+ writer.write_interface_description(65535, kLinkTypeEthernet);
+
+ std::vector<unsigned char> packet_data = {0xDE, 0xAD, 0xBE, 0xEF, 0x00};
+ writer.write_packet(/*interface_id=*/0, /*ts_sec=*/1700000000, /*ts_usec=*/123456,
+ packet_data, /*original_len=*/5);
+
+ std::fflush(f);
+ std::fseek(f, 0, SEEK_SET);
+
+ Reader reader(f);
+ auto record = reader.next_packet();
+ REQUIRE(record.has_value());
+ CHECK(record->interface_id == 0);
+ CHECK(record->timestamp_us == 1700000000ULL * 1'000'000ULL + 123456ULL);
+ CHECK(record->original_len == 5);
+ CHECK(record->data == packet_data);
+
+ CHECK_FALSE(reader.next_packet().has_value()); // only one packet was written
+
+ std::fclose(f);
+}
+
+TEST_CASE("Reader::link_type reflects the IDB, populated by the time the first packet returns") {
+ std::FILE* f = std::tmpfile();
+ REQUIRE(f != nullptr);
+
+ Writer writer(f);
+ writer.write_section_header();
+ writer.write_interface_description(65535, /*link_type=*/12); // DLT_RAW, arbitrary for this test
+
+ std::vector<unsigned char> data = {0x01};
+ writer.write_packet(0, 1, 0, data, 1);
+
+ std::fflush(f);
+ std::fseek(f, 0, SEEK_SET);
+
+ Reader reader(f);
+ CHECK_FALSE(reader.link_type().has_value()); // nothing read yet
+ auto record = reader.next_packet();
+ REQUIRE(record.has_value());
+ REQUIRE(reader.link_type().has_value());
+ CHECK(*reader.link_type() == 12);
+
+ std::fclose(f);
+}
+
+TEST_CASE("pcapng writer/reader round-trip multiple packets in order") {
+ std::FILE* f = std::tmpfile();
+ REQUIRE(f != nullptr);
+
+ Writer writer(f);
+ writer.write_section_header();
+ writer.write_interface_description(65535, kLinkTypeEthernet);
+
+ for (unsigned char i = 0; i < 5; ++i) {
+ std::vector<unsigned char> data = {i};
+ writer.write_packet(0, 1700000000 + i, 0, data, 1);
+ }
+ std::fflush(f);
+ std::fseek(f, 0, SEEK_SET);
+
+ Reader reader(f);
+ int count = 0;
+ while (auto record = reader.next_packet()) {
+ REQUIRE(record->data.size() == 1);
+ CHECK(record->data[0] == static_cast<unsigned char>(count));
+ ++count;
+ }
+ CHECK(count == 5);
+
+ std::fclose(f);
+}
+
+TEST_CASE("pcapng writer pads packet data to a 4-byte boundary without corrupting the next block") {
+ std::FILE* f = std::tmpfile();
+ REQUIRE(f != nullptr);
+
+ Writer writer(f);
+ writer.write_section_header();
+ writer.write_interface_description(65535, kLinkTypeEthernet);
+
+ // 3 bytes of packet data forces padding - the case most likely to
+ // misalign the following block if the padding math is wrong.
+ std::vector<unsigned char> first = {0x01, 0x02, 0x03};
+ std::vector<unsigned char> second = {0xAA, 0xBB};
+ writer.write_packet(0, 1, 0, first, 3);
+ writer.write_packet(0, 2, 0, second, 2);
+
+ std::fflush(f);
+ std::fseek(f, 0, SEEK_SET);
+
+ Reader reader(f);
+ auto r1 = reader.next_packet();
+ REQUIRE(r1.has_value());
+ CHECK(r1->data == first);
+
+ auto r2 = reader.next_packet();
+ REQUIRE(r2.has_value());
+ CHECK(r2->data == second);
+
+ std::fclose(f);
+}
+
+TEST_CASE("Reader rejects a block claiming an implausibly large body instead of allocating it") {
+ // Found by fuzzing (fuzz/fuzz_pcapng_reader.cpp): total_len is an
+ // untrusted 32-bit value straight from the file. A block claiming
+ // ~4GB used to be handed straight to `std::vector` before a single
+ // body byte was read, OOM-crashing the process on a corrupt or
+ // hostile file. This constructs exactly that: a valid-looking
+ // block type, followed by a total_len far beyond anything our own
+ // writer would ever produce.
+ std::FILE* f = std::tmpfile();
+ REQUIRE(f != nullptr);
+
+ std::uint8_t block[8];
+ block[0] = 0x06; block[1] = 0x00; block[2] = 0x00; block[3] = 0x00; // EPB
+ block[4] = 0xFF; block[5] = 0xFF; block[6] = 0xFF; block[7] = 0x7F; // total_len ~2GB
+ std::fwrite(block, 1, sizeof(block), f);
+ std::fflush(f);
+ std::fseek(f, 0, SEEK_SET);
+
+ Reader reader(f);
+ CHECK_FALSE(reader.next_packet().has_value()); // rejected, not an OOM attempt
+
+ std::fclose(f);
+}
diff --git a/tests/test_search.cpp b/tests/test_search.cpp
new file mode 100644
index 0000000..ed687e0
--- /dev/null
+++ b/tests/test_search.cpp
@@ -0,0 +1,27 @@
+#include <doctest/doctest.h>
+
+#include "wireframe/search.hpp"
+
+using namespace wireframe;
+
+TEST_CASE("matches_search finds a substring") {
+ CHECK(matches_search("IPv4 10.0.0.1 -> 10.0.0.2 proto=6", "10.0.0.2"));
+}
+
+TEST_CASE("matches_search is case-insensitive") {
+ CHECK(matches_search("DNS query example.COM", "example.com"));
+ CHECK(matches_search("DNS query example.com", "EXAMPLE.COM"));
+}
+
+TEST_CASE("matches_search returns false when the term isn't present") {
+ CHECK_FALSE(matches_search("IPv4 10.0.0.1 -> 10.0.0.2", "192.168.1.1"));
+}
+
+TEST_CASE("matches_search treats an empty term as matching everything") {
+ CHECK(matches_search("anything at all", ""));
+ CHECK(matches_search("", ""));
+}
+
+TEST_CASE("matches_search returns false against an empty haystack with a nonempty term") {
+ CHECK_FALSE(matches_search("", "x"));
+}
diff --git a/tests/test_summarize.cpp b/tests/test_summarize.cpp
new file mode 100644
index 0000000..d10053d
--- /dev/null
+++ b/tests/test_summarize.cpp
@@ -0,0 +1,185 @@
+#include <doctest/doctest.h>
+#include <pcap.h>
+
+#include <string_view>
+#include <vector>
+
+#include "wireframe/summarize.hpp"
+
+namespace {
+
+// Ethernet + IPv4 + UDP + DNS query for "example.com", assembled the
+// same way the real capture path hands bytes to summarize_packet: one
+// contiguous frame, no struct-casting.
+std::vector<unsigned char> ethernet_ipv4_udp_dns_frame() {
+ std::vector<unsigned char> dns = {
+ 0x12, 0x9d, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0,
+ 0x00, 0x01, 0x00, 0x01,
+ };
+
+ std::vector<unsigned char> udp(8, 0);
+ udp[0] = 0xD4; udp[1] = 0x31; // src port 54321
+ udp[2] = 0x00; udp[3] = 0x35; // dst port 53
+ std::uint16_t udp_len = static_cast<std::uint16_t>(8 + dns.size());
+ udp[4] = static_cast<unsigned char>(udp_len >> 8);
+ udp[5] = static_cast<unsigned char>(udp_len & 0xFF);
+
+ std::vector<unsigned char> ip(20, 0);
+ ip[0] = 0x45;
+ ip[8] = 64; // ttl
+ ip[9] = wireframe::net::kProtoUdp; // proto
+ ip[12] = 10; ip[13] = 0; ip[14] = 0; ip[15] = 1; // src 10.0.0.1
+ ip[16] = 10; ip[17] = 0; ip[18] = 0; ip[19] = 2; // dst 10.0.0.2
+
+ std::vector<unsigned char> eth = {
+ 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, // dst mac
+ 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, // src mac
+ 0x08, 0x00, // ethertype IPv4
+ };
+
+ std::vector<unsigned char> frame = eth;
+ frame.insert(frame.end(), ip.begin(), ip.end());
+ frame.insert(frame.end(), udp.begin(), udp.end());
+ frame.insert(frame.end(), dns.begin(), dns.end());
+ return frame;
+}
+
+// Ethernet + IPv4 + TCP + an HTTP GET request. This is the only test
+// exercising L7Registry's TCP-payload path with a real registered
+// dissector - DNS only ever runs over UDP, so summarize_packet's TCP
+// branch calling into l7_summarize() was otherwise unverified.
+std::vector<unsigned char> ethernet_ipv4_tcp_http_frame() {
+ std::string_view request = "GET /index.html HTTP/1.1\r\nHost: example.com\r\n\r\n";
+ std::vector<unsigned char> http(request.begin(), request.end());
+
+ std::vector<unsigned char> tcp(20, 0);
+ tcp[0] = 0xC3; tcp[1] = 0x50; // src port 50000
+ tcp[2] = 0x00; tcp[3] = 0x50; // dst port 80
+ tcp[12] = 5 << 4; // data_offset = 5 (20-byte header)
+ tcp[13] = 0x18; // PSH | ACK
+
+ std::vector<unsigned char> ip(20, 0);
+ ip[0] = 0x45;
+ ip[8] = 64; // ttl
+ ip[9] = wireframe::net::kProtoTcp; // proto
+ ip[12] = 10; ip[13] = 0; ip[14] = 0; ip[15] = 1; // src 10.0.0.1
+ ip[16] = 10; ip[17] = 0; ip[18] = 0; ip[19] = 2; // dst 10.0.0.2
+
+ std::vector<unsigned char> eth = {
+ 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, // dst mac
+ 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, // src mac
+ 0x08, 0x00, // ethertype IPv4
+ };
+
+ std::vector<unsigned char> frame = eth;
+ frame.insert(frame.end(), ip.begin(), ip.end());
+ frame.insert(frame.end(), tcp.begin(), tcp.end());
+ frame.insert(frame.end(), http.begin(), http.end());
+ return frame;
+}
+
+// Ethernet + IPv6 + a Hop-by-Hop Options extension header + TCP. Proves
+// walk_ipv6_extension_headers() is actually wired into summarize_packet's
+// IPv6 branch, not just unit-tested in isolation - without it, this
+// packet's TCP layer (and any L7 behind it) would be silently invisible.
+std::vector<unsigned char> ethernet_ipv6_hopbyhop_tcp_frame() {
+ std::vector<unsigned char> tcp(20, 0);
+ tcp[0] = 0x00; tcp[1] = 0x50; // src port 80
+ tcp[2] = 0x00; tcp[3] = 0x51; // dst port 81
+ tcp[12] = 5 << 4; // data_offset = 5
+ tcp[13] = 0x02; // SYN
+
+ std::vector<unsigned char> hop_by_hop = {
+ static_cast<unsigned char>(wireframe::net::kProtoTcp),
+ 0x00, // hdr_ext_len = 0 -> total length (0+1)*8 = 8 bytes
+ 0, 0, 0, 0, 0, 0, // option padding
+ };
+
+ std::vector<unsigned char> ip6(40, 0);
+ ip6[0] = 0x60; // version 6
+ std::uint16_t payload_len = static_cast<std::uint16_t>(hop_by_hop.size() + tcp.size());
+ ip6[4] = static_cast<unsigned char>(payload_len >> 8);
+ ip6[5] = static_cast<unsigned char>(payload_len & 0xFF);
+ ip6[6] = wireframe::net::kNextHeaderHopByHop;
+ ip6[7] = 64; // hop_limit
+ ip6[23] = 0x01; // src = ::1
+ ip6[39] = 0x01; // dst = ::1
+
+ std::vector<unsigned char> eth = {
+ 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, // dst mac
+ 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, // src mac
+ 0x86, 0xDD, // ethertype IPv6
+ };
+
+ std::vector<unsigned char> frame = eth;
+ frame.insert(frame.end(), ip6.begin(), ip6.end());
+ frame.insert(frame.end(), hop_by_hop.begin(), hop_by_hop.end());
+ frame.insert(frame.end(), tcp.begin(), tcp.end());
+ return frame;
+}
+
+} // namespace
+
+TEST_CASE("summarize_packet walks a Hop-by-Hop extension header to reach TCP") {
+ auto line = wireframe::summarize_packet(ethernet_ipv6_hopbyhop_tcp_frame(), DLT_EN10MB);
+ CHECK(line ==
+ "ETH aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 ethertype=0x86dd"
+ " | IPv6 ::1 -> ::1 ttl=64 proto=6"
+ " | TCP 80 -> 81 [S] seq=0 ack=0 win=0");
+}
+
+TEST_CASE("summarize_packet decodes a full Ethernet/IPv4/TCP/HTTP frame end to end") {
+ auto line = wireframe::summarize_packet(ethernet_ipv4_tcp_http_frame(), DLT_EN10MB);
+ CHECK(line ==
+ "ETH aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 ethertype=0x0800"
+ " | IPv4 10.0.0.1 -> 10.0.0.2 ttl=64 proto=6"
+ " | TCP 50000 -> 80 [AP] seq=0 ack=0 win=0"
+ " | HTTP GET /index.html Host: example.com");
+}
+
+TEST_CASE("summarize_packet decodes a full Ethernet/IPv4/UDP/DNS frame end to end") {
+ auto line = wireframe::summarize_packet(ethernet_ipv4_udp_dns_frame(), DLT_EN10MB);
+ CHECK(line ==
+ "ETH aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 ethertype=0x0800"
+ " | IPv4 10.0.0.1 -> 10.0.0.2 ttl=64 proto=17"
+ " | UDP 54321 -> 53 len=37"
+ " | DNS query id=4765 example.com type=1");
+}
+
+TEST_CASE("summarize_packet on DLT_RAW skips the Ethernet line entirely") {
+ auto frame = ethernet_ipv4_udp_dns_frame();
+ std::vector<unsigned char> raw(frame.begin() + wireframe::net::kEthernetHeaderLen, frame.end());
+
+ auto line = wireframe::summarize_packet(raw, DLT_RAW);
+ CHECK(line.substr(0, 3) == "RAW");
+ CHECK(line.find("ETH") == std::string::npos);
+ CHECK(line.find("IPv4 10.0.0.1 -> 10.0.0.2") != std::string::npos);
+}
+
+TEST_CASE("summarize_packet reports a truncated Ethernet frame without decoding further") {
+ std::vector<unsigned char> bytes(10, 0); // shorter than the 14-byte header
+ auto line = wireframe::summarize_packet(bytes, DLT_EN10MB);
+ CHECK(line == "[10 bytes] truncated ethernet frame");
+}
+
+TEST_CASE("summarize_packet stops after the Ethernet line for a non-IP ethertype") {
+ std::vector<unsigned char> bytes = {
+ 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
+ 0x08, 0x06, // ARP, not IPv4/IPv6
+ };
+ auto line = wireframe::summarize_packet(bytes, DLT_EN10MB);
+ CHECK(line == "ETH aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 ethertype=0x0806");
+}
+
+TEST_CASE("hex_dump_lines produces one line per 16 bytes, with the right byte count") {
+ std::vector<unsigned char> bytes(20, 0);
+ for (std::size_t i = 0; i < bytes.size(); ++i) bytes[i] = static_cast<unsigned char>(i);
+
+ auto lines = wireframe::hex_dump_lines(bytes);
+ REQUIRE(lines.size() == 2);
+ CHECK(lines[0].substr(0, 6) == "000000");
+ CHECK(lines[1].substr(0, 6) == "000010");
+ CHECK(lines[0].find("00 01 02 03") != std::string::npos);
+ CHECK(lines[0].find('|') != std::string::npos);
+}
diff --git a/tests/test_tls.cpp b/tests/test_tls.cpp
new file mode 100644
index 0000000..65784a9
--- /dev/null
+++ b/tests/test_tls.cpp
@@ -0,0 +1,132 @@
+#include <doctest/doctest.h>
+
+#include <vector>
+
+#include "wireframe/l7/tls.hpp"
+
+using namespace wireframe::net;
+
+namespace {
+
+void append_be16(std::vector<unsigned char>& out, std::uint16_t v) {
+ out.push_back(static_cast<unsigned char>(v >> 8));
+ out.push_back(static_cast<unsigned char>(v & 0xFF));
+}
+
+// Builds a real, well-formed TLS record containing a ClientHello with
+// (optionally) a single SNI host_name extension. Every length field is
+// computed from the actual bytes assembled, not hand-counted - the
+// same lesson from this session's earlier UDP-length test typo.
+//
+// `corrupt_sni_ext_len`, when set, writes an oversized SNI extension
+// length instead of the real one (computed here, not via post-hoc
+// offset math into the finished buffer - equally fragile).
+std::vector<unsigned char> build_client_hello(const std::string& sni,
+ bool corrupt_sni_ext_len = false) {
+ std::vector<unsigned char> body;
+ body.push_back(0x03);
+ body.push_back(0x03); // client_version: TLS 1.2
+ body.insert(body.end(), 32, 0x00); // random
+ body.push_back(0x00); // session_id length: 0
+ append_be16(body, 2); // cipher_suites length
+ body.push_back(0x00);
+ body.push_back(0x2F); // one arbitrary cipher suite
+ body.push_back(0x01); // compression_methods length: 1
+ body.push_back(0x00); // null compression
+
+ std::vector<unsigned char> extensions;
+ if (!sni.empty()) {
+ std::vector<unsigned char> server_name_list;
+ server_name_list.push_back(0x00); // name_type: host_name
+ append_be16(server_name_list, static_cast<std::uint16_t>(sni.size()));
+ server_name_list.insert(server_name_list.end(), sni.begin(), sni.end());
+
+ std::vector<unsigned char> sni_ext_data;
+ append_be16(sni_ext_data, static_cast<std::uint16_t>(server_name_list.size()));
+ sni_ext_data.insert(sni_ext_data.end(), server_name_list.begin(), server_name_list.end());
+
+ append_be16(extensions, kTlsExtensionServerName);
+ std::uint16_t ext_len = corrupt_sni_ext_len
+ ? static_cast<std::uint16_t>(0xFFFF)
+ : static_cast<std::uint16_t>(sni_ext_data.size());
+ append_be16(extensions, ext_len);
+ extensions.insert(extensions.end(), sni_ext_data.begin(), sni_ext_data.end());
+ }
+ append_be16(body, static_cast<std::uint16_t>(extensions.size()));
+ body.insert(body.end(), extensions.begin(), extensions.end());
+
+ std::vector<unsigned char> handshake;
+ handshake.push_back(kTlsHandshakeTypeClientHello);
+ std::uint32_t hs_len = static_cast<std::uint32_t>(body.size());
+ handshake.push_back(static_cast<unsigned char>((hs_len >> 16) & 0xFF));
+ handshake.push_back(static_cast<unsigned char>((hs_len >> 8) & 0xFF));
+ handshake.push_back(static_cast<unsigned char>(hs_len & 0xFF));
+ handshake.insert(handshake.end(), body.begin(), body.end());
+
+ std::vector<unsigned char> record;
+ record.push_back(kTlsContentTypeHandshake);
+ record.push_back(0x03);
+ record.push_back(0x01); // record-layer version (legacy compat value)
+ append_be16(record, static_cast<std::uint16_t>(handshake.size()));
+ record.insert(record.end(), handshake.begin(), handshake.end());
+
+ return record;
+}
+
+} // namespace
+
+TEST_CASE("parse_tls_client_hello extracts a real SNI extension") {
+ auto record = build_client_hello("example.com");
+ auto hello = parse_tls_client_hello(record);
+ REQUIRE(hello.has_value());
+ REQUIRE(hello->server_name.has_value());
+ CHECK(*hello->server_name == "example.com");
+}
+
+TEST_CASE("parse_tls_client_hello succeeds with no SNI when there's no extensions block") {
+ auto record = build_client_hello("");
+ auto hello = parse_tls_client_hello(record);
+ REQUIRE(hello.has_value());
+ CHECK_FALSE(hello->server_name.has_value());
+}
+
+TEST_CASE("parse_tls_client_hello rejects a non-Handshake record") {
+ auto record = build_client_hello("example.com");
+ record[0] = 0x17; // application_data, not handshake
+ CHECK_FALSE(parse_tls_client_hello(record).has_value());
+}
+
+TEST_CASE("parse_tls_client_hello rejects a non-ClientHello handshake type") {
+ auto record = build_client_hello("example.com");
+ record[5] = 0x02; // ServerHello, not ClientHello
+ CHECK_FALSE(parse_tls_client_hello(record).has_value());
+}
+
+TEST_CASE("parse_tls_client_hello rejects a truncated record") {
+ auto record = build_client_hello("example.com");
+ record.resize(record.size() - 5); // claims more than it has
+ CHECK_FALSE(parse_tls_client_hello(record).has_value());
+}
+
+TEST_CASE("parse_tls_client_hello rejects a buffer shorter than the record header") {
+ std::vector<unsigned char> bytes(4, 0);
+ CHECK_FALSE(parse_tls_client_hello(bytes).has_value());
+}
+
+TEST_CASE("parse_tls_client_hello stops gracefully on a malformed extension length") {
+ auto record = build_client_hello("example.com", /*corrupt_sni_ext_len=*/true);
+ auto hello = parse_tls_client_hello(record);
+ REQUIRE(hello.has_value()); // still a structurally valid ClientHello otherwise
+ CHECK_FALSE(hello->server_name.has_value()); // SNI extension was malformed, so skipped
+}
+
+TEST_CASE("TlsSniDissector claims port 443 and its summary matches parse_tls_client_hello") {
+ TlsSniDissector dissector;
+ CHECK(dissector.port() == kTlsPort);
+
+ auto record = build_client_hello("wireframe.test");
+ auto summary = dissector.summarize(record);
+ REQUIRE(summary.has_value());
+ CHECK(summary->substr(0, 3) == "TLS");
+ CHECK(summary->find("SNI=wireframe.test") != std::string::npos);
+}