diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/main.cpp | 2 | ||||
| -rw-r--r-- | tests/test_byteio.cpp | 23 | ||||
| -rw-r--r-- | tests/test_capture_queue.cpp | 122 | ||||
| -rw-r--r-- | tests/test_capture_session.cpp | 138 | ||||
| -rw-r--r-- | tests/test_dns.cpp | 82 | ||||
| -rw-r--r-- | tests/test_filter.cpp | 69 | ||||
| -rw-r--r-- | tests/test_http.cpp | 78 | ||||
| -rw-r--r-- | tests/test_ipv6.cpp | 169 | ||||
| -rw-r--r-- | tests/test_net.cpp | 124 | ||||
| -rw-r--r-- | tests/test_pcapng.cpp | 142 | ||||
| -rw-r--r-- | tests/test_search.cpp | 27 | ||||
| -rw-r--r-- | tests/test_summarize.cpp | 185 | ||||
| -rw-r--r-- | tests/test_tls.cpp | 132 |
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); +} |