diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_checksum.cpp | 136 | ||||
| -rw-r--r-- | tests/test_icmp.cpp | 85 | ||||
| -rw-r--r-- | tests/test_privileges.cpp | 18 | ||||
| -rw-r--r-- | tests/test_tcp_reassembly.cpp | 176 |
4 files changed, 415 insertions, 0 deletions
diff --git a/tests/test_checksum.cpp b/tests/test_checksum.cpp new file mode 100644 index 0000000..1295499 --- /dev/null +++ b/tests/test_checksum.cpp @@ -0,0 +1,136 @@ +#include <doctest/doctest.h> + +#include <vector> + +#include "wireframe/net/checksum.hpp" + +using namespace wireframe::net; + +namespace { + +// Mirrors checksum.hpp's own detail::build_ipv4_pseudo_header, kept +// separate here deliberately: constructing expected test vectors using +// the exact same private helper the code under test uses would make +// these tests circular. A few duplicated lines of test-only setup is +// the honest cost of testing independently. +std::vector<unsigned char> pseudo_header(const std::array<unsigned char, 4>& src, + const std::array<unsigned char, 4>& dst, + unsigned char protocol, + std::span<const unsigned char> segment) { + std::vector<unsigned char> buf; + buf.insert(buf.end(), src.begin(), src.end()); + buf.insert(buf.end(), dst.begin(), dst.end()); + buf.push_back(0); + buf.push_back(protocol); + std::uint16_t len = static_cast<std::uint16_t>(segment.size()); + buf.push_back(static_cast<unsigned char>(len >> 8)); + buf.push_back(static_cast<unsigned char>(len & 0xFF)); + buf.insert(buf.end(), segment.begin(), segment.end()); + return buf; +} + +} // namespace + +TEST_CASE("internet_checksum matches RFC 1071's own worked example") { + // The RFC's example data (0001 f203 f4f5 f6f7) computes to checksum + // 220d - an external reference, not derived from this code. + std::vector<unsigned char> data = {0x00, 0x01, 0xf2, 0x03, 0xf4, 0xf5, 0xf6, 0xf7}; + CHECK(internet_checksum(data) == 0x220d); +} + +TEST_CASE("internet_checksum of data with its own valid checksum appended is zero") { + // Direct consequence of the RFC 1071 example: appending that + // checksum as one more word should sum to all-ones, complementing + // to exactly zero - this is the actual verification technique + // verify_ipv4_checksum() etc. rely on. + std::vector<unsigned char> data = {0x00, 0x01, 0xf2, 0x03, 0xf4, 0xf5, 0xf6, 0xf7, 0x22, 0x0d}; + CHECK(internet_checksum(data) == 0); +} + +TEST_CASE("internet_checksum handles an odd-length buffer (trailing byte padded high)") { + std::vector<unsigned char> data = {0x00, 0x01, 0xf2}; // 3 bytes: one word + one odd byte + // 0x0001 + 0xf200 (odd byte in the high half) = 0xf201; ~0xf201 = 0x0dfe + CHECK(internet_checksum(data) == 0x0dfe); +} + +TEST_CASE("verify_ipv4_checksum accepts a header with a correctly computed checksum") { + std::vector<unsigned char> header(20, 0); + header[0] = 0x45; + header[8] = 64; // ttl + header[9] = kProtoTcp; + header[12] = 10; header[13] = 0; header[14] = 0; header[15] = 1; + header[16] = 10; header[17] = 0; header[18] = 0; header[19] = 2; + // checksum field (bytes 10-11) computed with itself still zeroed + std::uint16_t csum = internet_checksum(header); + header[10] = static_cast<unsigned char>(csum >> 8); + header[11] = static_cast<unsigned char>(csum & 0xFF); + + CHECK(verify_ipv4_checksum(header)); +} + +TEST_CASE("verify_ipv4_checksum rejects a header corrupted after the checksum was computed") { + std::vector<unsigned char> header(20, 0); + header[0] = 0x45; + header[9] = kProtoTcp; + std::uint16_t csum = internet_checksum(header); + header[10] = static_cast<unsigned char>(csum >> 8); + header[11] = static_cast<unsigned char>(csum & 0xFF); + + header[15] ^= 0xFF; // flip a source-address byte after the fact + CHECK_FALSE(verify_ipv4_checksum(header)); +} + +TEST_CASE("verify_tcp_checksum_ipv4 accepts a segment with a correctly computed checksum") { + std::array<unsigned char, 4> src = {10, 0, 0, 1}; + std::array<unsigned char, 4> dst = {10, 0, 0, 2}; + + std::vector<unsigned char> tcp(20, 0); + tcp[0] = 0; tcp[1] = 80; // src port + tcp[2] = 0x01; tcp[3] = 0xbb; // dst port 443 + tcp[12] = 5 << 4; // data_offset = 5 + + auto buf = pseudo_header(src, dst, kProtoTcp, tcp); + std::uint16_t csum = internet_checksum(buf); + tcp[16] = static_cast<unsigned char>(csum >> 8); + tcp[17] = static_cast<unsigned char>(csum & 0xFF); + + CHECK(verify_tcp_checksum_ipv4({src}, {dst}, tcp) == ChecksumResult::kValid); +} + +TEST_CASE("verify_tcp_checksum_ipv4 rejects a segment corrupted after the checksum was computed") { + std::array<unsigned char, 4> src = {10, 0, 0, 1}; + std::array<unsigned char, 4> dst = {10, 0, 0, 2}; + + std::vector<unsigned char> tcp(20, 0); + tcp[12] = 5 << 4; + auto buf = pseudo_header(src, dst, kProtoTcp, tcp); + std::uint16_t csum = internet_checksum(buf); + tcp[16] = static_cast<unsigned char>(csum >> 8); + tcp[17] = static_cast<unsigned char>(csum & 0xFF); + + tcp[0] ^= 0xFF; // corrupt the source port after the fact + CHECK(verify_tcp_checksum_ipv4({src}, {dst}, tcp) == ChecksumResult::kInvalid); +} + +TEST_CASE("verify_udp_checksum_ipv4 treats a transmitted checksum of 0x0000 as not present") { + std::array<unsigned char, 4> src = {10, 0, 0, 1}; + std::array<unsigned char, 4> dst = {10, 0, 0, 2}; + std::vector<unsigned char> udp = {0x00, 0x35, 0x00, 0x35, 0x00, 0x08, 0x00, 0x00}; // csum=0 + CHECK(verify_udp_checksum_ipv4({src}, {dst}, udp) == ChecksumResult::kNotPresent); +} + +TEST_CASE("verify_udp_checksum_ipv4 accepts a datagram with a correctly computed checksum") { + std::array<unsigned char, 4> src = {10, 0, 0, 1}; + std::array<unsigned char, 4> dst = {10, 0, 0, 2}; + + std::vector<unsigned char> udp = {0x00, 0x35, 0x00, 0x35, 0x00, 0x08, 0x00, 0x00}; + auto buf = pseudo_header(src, dst, kProtoUdp, udp); + std::uint16_t csum = internet_checksum(buf); + // A computed checksum of exactly 0 is itself sent as 0xFFFF per + // RFC 768, to keep it distinguishable from "no checksum" - not + // exercised by this test's specific values, but worth the note. + udp[6] = static_cast<unsigned char>(csum >> 8); + udp[7] = static_cast<unsigned char>(csum & 0xFF); + + CHECK(verify_udp_checksum_ipv4({src}, {dst}, udp) == ChecksumResult::kValid); +} diff --git a/tests/test_icmp.cpp b/tests/test_icmp.cpp new file mode 100644 index 0000000..3dd713e --- /dev/null +++ b/tests/test_icmp.cpp @@ -0,0 +1,85 @@ +#include <doctest/doctest.h> + +#include <vector> + +#include "wireframe/net/icmp.hpp" + +using namespace wireframe::net; + +TEST_CASE("parse_icmpv4 decodes an echo request with identifier/sequence") { + std::vector<unsigned char> bytes = {8, 0, 0x00, 0x00, 0x1c, 0x05, 0x00, 0x01}; + auto icmp = parse_icmpv4(bytes); + REQUIRE(icmp.has_value()); + CHECK(icmp->type == 8); + CHECK(icmp->code == 0); + REQUIRE(icmp->identifier.has_value()); + CHECK(*icmp->identifier == 0x1c05); + REQUIRE(icmp->sequence.has_value()); + CHECK(*icmp->sequence == 1); +} + +TEST_CASE("parse_icmpv4 decodes an echo reply the same way as a request") { + std::vector<unsigned char> bytes = {0, 0, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02}; + auto icmp = parse_icmpv4(bytes); + REQUIRE(icmp.has_value()); + CHECK(icmp->type == 0); + REQUIRE(icmp->identifier.has_value()); + CHECK(*icmp->identifier == 1); +} + +TEST_CASE("parse_icmpv4 decodes a non-echo type without an identifier/sequence") { + std::vector<unsigned char> bytes = {3, 1, 0x00, 0x00}; // dest unreachable, host unreachable + auto icmp = parse_icmpv4(bytes); + REQUIRE(icmp.has_value()); + CHECK(icmp->type == 3); + CHECK(icmp->code == 1); + CHECK_FALSE(icmp->identifier.has_value()); +} + +TEST_CASE("parse_icmpv4 rejects a buffer shorter than the fixed header") { + std::vector<unsigned char> bytes(3, 0); + CHECK_FALSE(parse_icmpv4(bytes).has_value()); +} + +TEST_CASE("icmpv4_type_name covers known types and falls back for unknown ones") { + CHECK(icmpv4_type_name(8) == "Echo Request"); + CHECK(icmpv4_type_name(0) == "Echo Reply"); + CHECK(icmpv4_type_name(3) == "Destination Unreachable"); + CHECK(icmpv4_type_name(200) == "type=200"); +} + +TEST_CASE("parse_icmpv6 decodes an echo request with identifier/sequence") { + std::vector<unsigned char> bytes = {128, 0, 0x00, 0x00, 0x1c, 0x05, 0x00, 0x01}; + auto icmp = parse_icmpv6(bytes); + REQUIRE(icmp.has_value()); + CHECK(icmp->type == 128); + REQUIRE(icmp->identifier.has_value()); + CHECK(*icmp->identifier == 0x1c05); +} + +TEST_CASE("parse_icmpv6 decodes a non-echo type (e.g. Neighbor Solicitation) without id/seq") { + std::vector<unsigned char> bytes = {135, 0, 0x00, 0x00}; + auto icmp = parse_icmpv6(bytes); + REQUIRE(icmp.has_value()); + CHECK(icmp->type == 135); + CHECK_FALSE(icmp->identifier.has_value()); +} + +TEST_CASE("icmpv6_type_name covers known types and falls back for unknown ones") { + CHECK(icmpv6_type_name(128) == "Echo Request"); + CHECK(icmpv6_type_name(135) == "Neighbor Solicitation"); + CHECK(icmpv6_type_name(134) == "Router Advertisement"); + CHECK(icmpv6_type_name(250) == "type=250"); +} + +TEST_CASE("the same type number means something different in each protocol's table") { + // The whole reason these are two separate tables, not one shared by + // number: ICMPv4's echo request is type 8, but ICMPv6's type 8 + // isn't in its table at all (echo request is 128 there instead). + CHECK(icmpv4_type_name(8) == "Echo Request"); + CHECK(icmpv6_type_name(8) == "type=8"); + // And type 4 means "Parameter Problem" in ICMPv6 but is unmapped + // (falls back) in the ICMPv4 table. + CHECK(icmpv6_type_name(4) == "Parameter Problem"); + CHECK(icmpv4_type_name(4) == "Source Quench"); +} diff --git a/tests/test_privileges.cpp b/tests/test_privileges.cpp new file mode 100644 index 0000000..287d654 --- /dev/null +++ b/tests/test_privileges.cpp @@ -0,0 +1,18 @@ +#include <doctest/doctest.h> +#include <unistd.h> + +#include "wireframe/privileges.hpp" + +// The actual drop sequence (setuid/setgid) can only be meaningfully +// exercised by literally running as root, which a unit test shouldn't +// do - permanently dropping the test runner's own privileges mid-suite +// would be a real, surprising side effect, not a safe thing to assert +// on. That path is verified live instead (running the real binary via +// sudo and checking the dropped-to UID actually took effect - see +// PLAN.md). This only covers the no-op path any non-root test run +// takes, which is still worth locking in: it must never attempt to +// touch privileges it doesn't have. +TEST_CASE("drop_privileges_if_root is a no-op when not running as root") { + if (geteuid() == 0) return; // this test only makes sense unprivileged + CHECK_FALSE(wireframe::drop_privileges_if_root().has_value()); +} diff --git a/tests/test_tcp_reassembly.cpp b/tests/test_tcp_reassembly.cpp new file mode 100644 index 0000000..b424610 --- /dev/null +++ b/tests/test_tcp_reassembly.cpp @@ -0,0 +1,176 @@ +#include <doctest/doctest.h> + +#include <string> +#include <vector> + +#include "wireframe/l7/http.hpp" +#include "wireframe/net/tcp.hpp" +#include "wireframe/net/tcp_reassembly.hpp" + +using namespace wireframe::net; + +namespace { + +Ipv4Address addr(unsigned char a, unsigned char b, unsigned char c, unsigned char d) { + return Ipv4Address{{a, b, c, d}}; +} + +std::vector<unsigned char> to_bytes(const std::string& s) { + return std::vector<unsigned char>(s.begin(), s.end()); +} + +} // namespace + +TEST_CASE("TcpReassembler ignores payload before SYN is seen") { + TcpReassembler r; + auto client = addr(10, 0, 0, 1); + auto server = addr(10, 0, 0, 2); + + auto data = to_bytes("data before syn"); + auto result = r.process_segment(client, 40000, server, 80, 1000, 0, data); + CHECK_FALSE(result.has_value()); +} + +TEST_CASE("TcpReassembler joins two in-order segments into one contiguous buffer") { + TcpReassembler r; + auto client = addr(10, 0, 0, 1); + auto server = addr(10, 0, 0, 2); + + // SYN: seq 1000, consumes seq 1000 itself, next data starts at 1001. + auto syn = r.process_segment(client, 40000, server, 80, 1000, kTcpSyn, {}); + CHECK_FALSE(syn.has_value()); + + auto part1 = to_bytes("GET /index.html HTTP/1.1\r\n"); + auto r1 = r.process_segment(client, 40000, server, 80, 1001, kTcpPsh | kTcpAck, part1); + REQUIRE(r1.has_value()); + CHECK(r1->size() == part1.size()); + + auto part2 = to_bytes("Host: example.com\r\n\r\n"); + std::uint32_t seq2 = 1001 + static_cast<std::uint32_t>(part1.size()); + auto r2 = r.process_segment(client, 40000, server, 80, seq2, kTcpPsh | kTcpAck, part2); + REQUIRE(r2.has_value()); + + std::string joined(r2->begin(), r2->end()); + CHECK(joined == "GET /index.html HTTP/1.1\r\nHost: example.com\r\n\r\n"); + + auto http = parse_http(*r2); + REQUIRE(http.has_value()); + CHECK(http->is_request); + CHECK(http->method_or_version == "GET"); + CHECK(http->target_or_status == "/index.html"); + REQUIRE(http->host.has_value()); + CHECK(*http->host == "example.com"); +} + +TEST_CASE("TcpReassembler drops an out-of-order segment rather than buffering it") { + TcpReassembler r; + auto client = addr(10, 0, 0, 1); + auto server = addr(10, 0, 0, 2); + + r.process_segment(client, 40000, server, 80, 1000, kTcpSyn, {}); + + auto part1 = to_bytes("first "); + r.process_segment(client, 40000, server, 80, 1001, kTcpAck, part1); + + // Skip ahead instead of continuing at 1001 + part1.size(): out of order. + auto part3 = to_bytes("third "); + auto result = r.process_segment(client, 40000, server, 80, 9999, kTcpAck, part3); + CHECK_FALSE(result.has_value()); +} + +TEST_CASE("TcpReassembler drops a retransmitted (already-seen) segment") { + TcpReassembler r; + auto client = addr(10, 0, 0, 1); + auto server = addr(10, 0, 0, 2); + + r.process_segment(client, 40000, server, 80, 1000, kTcpSyn, {}); + + auto part1 = to_bytes("hello"); + auto r1 = r.process_segment(client, 40000, server, 80, 1001, kTcpAck, part1); + REQUIRE(r1.has_value()); + + // Same seq again: a retransmission, not new data. + auto retransmit = r.process_segment(client, 40000, server, 80, 1001, kTcpAck, part1); + CHECK_FALSE(retransmit.has_value()); +} + +TEST_CASE("TcpReassembler tracks each direction of a flow independently") { + TcpReassembler r; + auto client = addr(10, 0, 0, 1); + auto server = addr(10, 0, 0, 2); + + r.process_segment(client, 40000, server, 80, 1000, kTcpSyn, {}); + r.process_segment(server, 80, client, 40000, 5000, kTcpSyn | kTcpAck, {}); + + auto request = to_bytes("GET / HTTP/1.1\r\n\r\n"); + auto req_result = r.process_segment(client, 40000, server, 80, 1001, kTcpPsh | kTcpAck, + request); + REQUIRE(req_result.has_value()); + CHECK(std::string(req_result->begin(), req_result->end()) == "GET / HTTP/1.1\r\n\r\n"); + + auto response = to_bytes("HTTP/1.1 200 OK\r\n\r\n"); + auto resp_result = r.process_segment(server, 80, client, 40000, 5001, kTcpPsh | kTcpAck, + response); + REQUIRE(resp_result.has_value()); + CHECK(std::string(resp_result->begin(), resp_result->end()) == "HTTP/1.1 200 OK\r\n\r\n"); + + // Requesting side's buffer should be untouched by the response. + CHECK(std::string(req_result->begin(), req_result->end()) == "GET / HTTP/1.1\r\n\r\n"); +} + +TEST_CASE("TcpReassembler canonicalizes both directions of a connection to the same flow") { + TcpReassembler r; + auto client = addr(10, 0, 0, 1); + auto server = addr(10, 0, 0, 2); + + r.process_segment(client, 40000, server, 80, 1000, kTcpSyn, {}); + CHECK(r.flow_count() == 1); + + // A segment in the reverse direction of the *same* connection must + // not create a second flow entry. + r.process_segment(server, 80, client, 40000, 5000, kTcpSyn | kTcpAck, {}); + CHECK(r.flow_count() == 1); +} + +TEST_CASE("TcpReassembler caps buffered bytes per direction and stops growing past the limit") { + TcpReassembler r(/*max_buffer_per_direction=*/10, /*max_flows=*/16); + auto client = addr(10, 0, 0, 1); + auto server = addr(10, 0, 0, 2); + + r.process_segment(client, 40000, server, 80, 1000, kTcpSyn, {}); + + auto part1 = to_bytes("12345"); // 5 bytes, fits + auto r1 = r.process_segment(client, 40000, server, 80, 1001, kTcpAck, part1); + REQUIRE(r1.has_value()); + CHECK(r1->size() == 5); + + // Next 5 bytes would land exactly at the 10-byte cap. + auto part2 = to_bytes("67890"); + auto r2 = r.process_segment(client, 40000, server, 80, 1006, kTcpAck, part2); + REQUIRE(r2.has_value()); + CHECK(r2->size() == 10); + + // A further segment would exceed the cap: sequence tracking still + // advances (so future in-order segments aren't misjudged), but the + // buffer itself does not grow past max_buffer_. + auto part3 = to_bytes("overflow"); + auto r3 = r.process_segment(client, 40000, server, 80, 1011, kTcpAck, part3); + REQUIRE(r3.has_value()); + CHECK(r3->size() == 10); +} + +TEST_CASE("TcpReassembler caps the number of tracked flows") { + TcpReassembler r(/*max_buffer_per_direction=*/1024, /*max_flows=*/1); + auto server = addr(10, 0, 0, 2); + + auto client1 = addr(10, 0, 0, 1); + r.process_segment(client1, 40000, server, 80, 1000, kTcpSyn, {}); + CHECK(r.flow_count() == 1); + + // A second, distinct flow should be refused: table is full. + auto client2 = addr(10, 0, 0, 3); + auto data = to_bytes("x"); + auto result = r.process_segment(client2, 40000, server, 80, 2000, kTcpSyn, data); + CHECK_FALSE(result.has_value()); + CHECK(r.flow_count() == 1); +} |