diff --git a/CMakeLists.txt b/CMakeLists.txt index 62f2a23..eb70af0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -88,6 +88,7 @@ target_sources(dgram FILES src/dgram/error.cppm src/dgram/cmsg.cppm + src/dgram/hash.cppm src/dgram/address.cppm src/dgram/socket.cppm src/dgram/feature.cppm diff --git a/README.md b/README.md index f08ffb7..2e63628 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ compiled and run as a test, so none of them can rot. | `pktinfo` / `ecn` | Which local address a datagram arrived on, and its ECN marking. Both families. | | `gro` / `segment` | Hardware segmentation offload: many datagrams per slot in, one large buffer out. | | `txtime` / `pacer` | Per-datagram departure times via `SO_TXTIME`, and drift-free rate arithmetic. | -| `flow_table` / `route` | Open-addressing demultiplexing to protocol state, keyed by a caller-supplied projection. | +| `flow_table` / `route` | Open-addressing demultiplexing to protocol state, keyed by a caller-supplied projection and hashed under a per-table SipHash seed, so peers cannot flood it. | | `timer_wheel` | Hierarchical timing wheel for protocol timeouts: constant-time schedule, cancel and expiry. | | `result` | `std::expected` over `errno`, with left-to-right pipe combinators. | diff --git a/docs/demux.md b/docs/demux.md index 286e19e..fa0d545 100644 --- a/docs/demux.md +++ b/docs/demux.md @@ -93,13 +93,39 @@ and comparing it would make the same peer look new on every datagram, filling a table with duplicates. Scope *is* identity, since `fe80::1%eth0` and `fe80::1%eth1` are different destinations. +### Your own key type + +A `demux_key` is equality-comparable, trivially destructible, and has a +`hash_append` that feeds its identity to a hasher: + +```cpp +struct session_key { + dgram::endpoint peer{}; + std::uint32_t epoch{}; + + friend bool operator==(const session_key&, const session_key&) = default; + + template friend void hash_append(H& h, const session_key& k) noexcept { + hash_append(h, k.peer); + hash_append(h, k.epoch); + } +}; +``` + +Feed exactly the fields `operator==` compares, in an encoding where no key's +bytes are a prefix of another's. The provided keys already are: an endpoint's +family fixes its width, and a `byte_key` feeds its length first. Two unequal keys +that feed the same bytes collide under every seed, which is the one collision +seeding cannot fix. + ## The table ```cpp using table = dgram::flow_table; libmem::arena arena{table::footprint()}; -auto flows{table::carve(arena)}; +auto flows{table::carve(arena)}; // seed from getrandom +auto pinned{table::carve(arena, dgram::hash_seed{k0, k1})}; ``` Open addressing with linear probing and backward-shift deletion, carved from a @@ -107,7 +133,9 @@ resource like everything else. `Slots` must be a power of two. | Member | Description | |--------|-------------| -| `max_size` | Flows it will hold: seven eighths of `Slots`. | +| `static result carve(R& resource, hash_seed seed)` | Take the memory; buckets are SipHash-1-3 under `seed`. | +| `static result
carve(R& resource)` | Same, with a seed from `random_seed()`. Fails only if `getrandom` does. | +| `max_size` | Flows it will hold: three quarters of `Slots`. | | `Value* find(key)` | The flow, or `nullptr`. | | `Value* insert(key, value)` | Inserts or overwrites. Points at the stored value; `nullptr` means the table was full. | | `bool erase(key)` | `false` if it was not there, which is not an error. Not `[[nodiscard]]`. | @@ -118,6 +146,14 @@ rather than degrading: a nearly full open-addressed table turns every lookup into a linear scan. Size `Slots` for the peak flow count you intend to serve and treat a refusal as backpressure. +**Keys are attacker-chosen, so the hash is keyed.** A peer picks its source port, +most of an IPv6 address, and a QUIC client its own Initial connection id. Under +an unkeyed hash it could pick keys that share a bucket and turn every lookup into +a walk of one long probe chain. Each table hashes with SipHash-1-3 under its own +128-bit seed, so the layout is unpredictable and keys that collide in one table +spread in another. Pass a fixed seed only where reproducibility matters more, +such as tests and fuzzers, and never one a peer can learn. + Backward-shift deletion means no tombstones, so a long-lived table that churns connections does not slowly fill with dead markers. diff --git a/docs/testing.md b/docs/testing.md index d69cbac..52ba47c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -17,12 +17,12 @@ checkout. | Suite | Covers | |-------|--------| | `doc_examples.cpp` | Every snippet printed in `README.md` and `docs/`, compiled and run. A snippet in a document is untested code that looks authoritative; this is what stops one rotting. | -| `compile_checks.cpp` | Pure `static_assert`. Feature concepts, control-buffer sizing, batch geometry, which resources may be carved from, which metadata is readable. Compiling is the test; it is never run. | +| `compile_checks.cpp` | Pure `static_assert`. Feature concepts, control-buffer sizing, batch geometry, which resources may be carved from, which metadata is readable, and SipHash against its published vectors, split at every word boundary. Compiling is the test; it is never run. | | `loopback_tests.cpp` | Batch reuse, truncation reporting (including a caller-supplied `MSG_TRUNC`), nonblocking receive, staging limits, a failed flush leaving the batch staged, the error combinators. | | `metadata_tests.cpp` | Destination address and ECN over loopback, both families, including a feature that was never enabled and an undersized control buffer. | | `malformed_control_tests.cpp` | Control buffers the kernel would never write. Every case here was found by the fuzzer first. | | `offload_tests.cpp` | GRO and GSO over loopback, and the segmentation walk on its own: short tail, exact multiple, stride of zero, stride past the end, empty payload. | -| `demux_tests.cpp` | Key equality and hash agreement (including the flow-label and padding cases), the flow table under churn and at capacity, projections, routing a GRO slot whose datagrams belong to different flows, an `unmatched` callback claiming or declining a datagram, arguments forwarded to a sink that takes them and ignored by one that does not, and the per-slot count. | +| `demux_tests.cpp` | Key equality and hash agreement (including the flow-label and padding cases), keys crafted to collide under one seed spreading under another, the table bucketing by its own seed, the flow table under churn and at capacity, projections, routing a GRO slot whose datagrams belong to different flows, an `unmatched` callback claiming or declining a datagram, arguments forwarded to a sink that takes them and ignored by one that does not, and the per-slot count. | | `timer_tests.cpp` | The timing wheel: every delay across every level boundary, stepped one tick at a time and jumped, from several epochs, plus a differential run against a naive reference. `next_deadline` gets its own differential run under churn, the structural case of a timer wrapped onto the cursor's slot, and the property a waiting loop needs: waking at the deadline always finds work. Rearming and cancelling from inside the fire callback, including timers due on the same tick. | | `pacing_tests.cpp` | The rate arithmetic exhaustively (monotonicity, drift, extreme rates, constexpr), plus what the kernel side allows unprivileged. Real pacing needs `fq` and `CAP_NET_ADMIN`, so no test here asserts a datagram was actually delayed. | | `concurrency_tests.cpp` | Several workers, each with its own socket, arena and batches, running concurrently. Meaningful only under `-s thread`. | diff --git a/fuzz/fuzz_demux.cpp b/fuzz/fuzz_demux.cpp index d5fc934..9ee4a54 100644 --- a/fuzz/fuzz_demux.cpp +++ b/fuzz/fuzz_demux.cpp @@ -28,6 +28,9 @@ constexpr std::size_t slots{64}; using key_type = dgram::byte_key<4>; using table_type = dgram::flow_table; +/* Fixed, so a crash reproduces from its input alone. */ +constexpr dgram::hash_seed seed{0x243F6A8885A308D3ULL, 0x13198A2E03707344ULL}; + key_type key_of(const std::uint8_t id) noexcept { const std::array raw{static_cast(id)}; return key_type{raw}; @@ -47,7 +50,9 @@ void project(const std::span payload) noexcept { if (payload.size() < 11) { std::abort(); // and must only exist when the bytes were there } - (void)hash_value(*key); + dgram::siphash h{seed}; + hash_append(h, *key); + (void)h.finish(); } else if (payload.size() >= 11) { std::abort(); // a long enough payload must always yield a key } @@ -56,7 +61,7 @@ void project(const std::span payload) noexcept { /** Drive the table against a reference and check they agree at every step. */ void exercise_table(const std::span ops) noexcept { libmem::arena arena{table_type::footprint()}; - auto table{table_type::carve(arena)}; + auto table{table_type::carve(arena, seed)}; if (!table) { return; } diff --git a/src/dgram/address.cppm b/src/dgram/address.cppm index 561de68..6d3b96f 100644 --- a/src/dgram/address.cppm +++ b/src/dgram/address.cppm @@ -15,6 +15,7 @@ export module dgram:address; import std; import :error; +import :hash; namespace dgram { @@ -147,26 +148,17 @@ public: return std::ranges::equal(a.address_bytes(), b.address_bytes()); } - /** @brief Hash over exactly the fields `operator==` compares. */ - [[nodiscard]] friend std::size_t hash_value(const endpoint& e) noexcept { - // FNV-1a: no allocation, no table, and adequate for a demux key. - std::uint64_t h{0xCBF29CE484222325ULL}; - const auto mix = [&h](const std::uint8_t byte) noexcept { - h ^= byte; - h *= 0x100000001B3ULL; - }; - mix(static_cast(e.storage_.ss_family)); - const auto port{e.port()}; - mix(static_cast(port)); - mix(static_cast(port >> 8)); - for (const auto byte : e.address_bytes()) { - mix(static_cast(byte)); - } - const auto scope{e.scope_id()}; - for (int shift{}; shift < 32; shift += 8) { - mix(static_cast(scope >> shift)); - } - return h; + /** + * @brief Feed exactly the fields `operator==` compares. + * + * The family fixes the address width, so the encoding is prefix-free and a + * key built from two endpoints cannot collide by shifting bytes between them. + */ + template friend void hash_append(H& h, const endpoint& e) noexcept { + hash_append(h, e.storage_.ss_family); + hash_append(h, e.port()); + h(e.address_bytes()); + hash_append(h, e.scope_id()); } private: @@ -177,9 +169,13 @@ static_assert(std::is_trivially_destructible_v); } // namespace dgram -/** @brief So an `endpoint` can key a standard container as well as ours. */ +/** @brief So an `endpoint` can key a standard container as well as ours. Keyed per process. */ template <> struct std::hash { - [[nodiscard]] std::size_t operator()(const dgram::endpoint& e) const noexcept { return hash_value(e); } + [[nodiscard]] std::size_t operator()(const dgram::endpoint& e) const noexcept { + dgram::siphash h{dgram::detail::process_seed()}; + hash_append(h, e); + return static_cast(h.finish()); + } }; namespace dgram {} // namespace dgram diff --git a/src/dgram/demux.cppm b/src/dgram/demux.cppm index c79bf7b..a4d8b47 100644 --- a/src/dgram/demux.cppm +++ b/src/dgram/demux.cppm @@ -26,6 +26,7 @@ import libmem; import :address; import :error; import :feature; +import :hash; import :metadata; import :offload; import :pacing; @@ -93,16 +94,15 @@ public: [[nodiscard]] friend constexpr bool operator==(const byte_key& a, const byte_key& b) noexcept { return std::ranges::equal(a.bytes_, b.bytes_); } - [[nodiscard]] friend constexpr std::size_t hash_value(const byte_key& k) noexcept { - std::uint64_t h{0xCBF29CE484222325ULL}; - for (const auto byte : k.bytes_) { - h ^= static_cast(byte); - h *= 0x100000001B3ULL; - } - return h; + /** @brief Length first, so a key composed of this and more fields stays prefix-free. */ + template friend constexpr void hash_append(H& h, const byte_key& k) noexcept { + hash_append(h, static_cast(k.bytes_.size())); + h(k.bytes()); } private: + using length_type = std::conditional_t<(MaxBytes <= std::numeric_limits::max()), std::uint8_t, std::size_t>; + std::inplace_vector bytes_{}; }; @@ -111,7 +111,7 @@ export struct peer_key { endpoint remote{}; [[nodiscard]] friend bool operator==(const peer_key& a, const peer_key& b) noexcept { return a.remote == b.remote; } - [[nodiscard]] friend std::size_t hash_value(const peer_key& k) noexcept { return hash_value(k.remote); } + template friend void hash_append(H& h, const peer_key& k) noexcept { hash_append(h, k.remote); } }; /** @@ -127,18 +127,21 @@ export struct flow_key { [[nodiscard]] friend bool operator==(const flow_key& a, const flow_key& b) noexcept { return a.remote == b.remote && a.local == b.local; } - [[nodiscard]] friend std::size_t hash_value(const flow_key& k) noexcept { - const auto a{hash_value(k.remote)}; - const auto b{hash_value(k.local)}; - return a ^ (b + 0x9E3779B97F4A7C15ULL + (a << 6) + (a >> 2)); + template friend void hash_append(H& h, const flow_key& k) noexcept { + hash_append(h, k.remote); + hash_append(h, k.local); } }; -/** @brief A key usable by `flow_table`: equality-comparable and hashable. */ +/** + * @brief A key usable by `flow_table`: equality-comparable, and feeds its identity to a hasher. + * + * `hash_append` must feed exactly the fields `operator==` compares, in a + * prefix-free encoding: equal keys must hash equal, and two unequal keys that + * feed the same bytes collide under every seed. + */ export template -concept demux_key = std::equality_comparable && std::is_trivially_destructible_v && requires(const K& k) { - { hash_value(k) } -> std::convertible_to; -}; +concept demux_key = std::equality_comparable && std::is_trivially_destructible_v && requires(siphash& h, const K& k) { hash_append(h, k); }; static_assert(demux_key); static_assert(demux_key); @@ -225,9 +228,12 @@ concept flow_lookup = requires(T& t, const T& ct, const Key& k, Value v) { * * Linear probing with backward-shift deletion, so there are no tombstones to * accumulate and a long-lived table does not degrade as flows come and go. - * `Slots` must be a power of two; the table refuses to fill past seven eighths - * of it, because a nearly full open-addressed table degenerates into a linear - * scan and this one cannot grow. + * `Slots` must be a power of two; the table refuses to fill past three + * quarters of it, because a nearly full open-addressed table degenerates into a + * linear scan and this one cannot grow. + * + * Buckets come from SipHash under the table's own seed, so keys a peer picks to + * collide in one table spread out in another. * * Not thread-safe. One per thread, like everything else. */ @@ -240,14 +246,16 @@ public: using value_type = Value; /** @brief Flows the table will hold before refusing to grow. */ - static constexpr std::size_t max_size{Slots / 8 * 7}; + static constexpr std::size_t max_size{Slots / 4 * 3}; static constexpr std::size_t footprint() noexcept { return Slots * sizeof(Key) + alignof(Key) - 1 + Slots * sizeof(Value) + alignof(Value) - 1 + Slots + alignof(std::byte) - 1; } - template [[nodiscard]] static result carve(R& resource) noexcept { + /** @brief Carve the table, hashing under `seed`. A seed a peer can learn or guess makes it floodable. */ + template [[nodiscard]] static result carve(R& resource, const hash_seed seed) noexcept { flow_table t{}; + t.seed_ = seed; auto* keys{static_cast(resource.allocate(Slots * sizeof(Key), alignof(Key)))}; auto* values{static_cast(resource.allocate(Slots * sizeof(Value), alignof(Value)))}; auto* used{static_cast(resource.allocate(Slots, alignof(std::uint8_t)))}; @@ -263,6 +271,11 @@ public: return t; } + /** @brief Carve the table under a seed from `random_seed()`. */ + template [[nodiscard]] static result carve(R& resource) noexcept { + return random_seed() | then([&resource](const hash_seed seed) { return carve(resource, seed); }); + } + [[nodiscard]] Value* find(const Key& key) noexcept { const auto slot{locate(key)}; return slot ? &values_[*slot] : nullptr; @@ -349,7 +362,11 @@ public: private: flow_table() noexcept = default; - [[nodiscard]] std::size_t bucket(const Key& key) const noexcept { return hash_value(key) & (Slots - 1); } + [[nodiscard]] std::size_t bucket(const Key& key) const noexcept { + siphash h{seed_}; + hash_append(h, key); + return static_cast(h.finish()) & (Slots - 1); + } [[nodiscard]] static constexpr std::size_t next(const std::size_t index) noexcept { return (index + 1) & (Slots - 1); } [[nodiscard]] std::optional locate(const Key& key) const noexcept { @@ -370,6 +387,7 @@ private: std::span values_{}; std::span used_{}; std::size_t size_{}; + hash_seed seed_{}; }; /* ============================================================================ diff --git a/src/dgram/dgram.cppm b/src/dgram/dgram.cppm index 285a963..d71407e 100644 --- a/src/dgram/dgram.cppm +++ b/src/dgram/dgram.cppm @@ -12,6 +12,7 @@ export import :demux; export import :cmsg; export import :error; export import :feature; +export import :hash; export import :metadata; export import :offload; export import :pacing; diff --git a/src/dgram/hash.cppm b/src/dgram/hash.cppm new file mode 100644 index 0000000..2ce4be1 --- /dev/null +++ b/src/dgram/hash.cppm @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Aoto +// SPDX-License-Identifier: MIT +/** + * @file hash.cppm + * @brief Keyed hashing for demux keys: SipHash, its seed, and the append protocol. + */ +module; + +#include +#include + +export module dgram:hash; + +import std; +import :error; + +namespace dgram { + +/** @brief A 128-bit SipHash key. Two tables with different seeds share no collisions. */ +export struct hash_seed { + std::uint64_t k0{}; + std::uint64_t k1{}; + + // Not `= default`: GCC 16.2 crashes mangling a defaulted one when an importer uses it. + [[nodiscard]] friend constexpr bool operator==(const hash_seed& a, const hash_seed& b) noexcept { return a.k0 == b.k0 && a.k1 == b.k1; } +}; + +/** @brief A seed from `getrandom`. */ +export [[nodiscard]] inline result random_seed() noexcept { + std::array words{}; + const auto got{::getrandom(words.data(), sizeof(words), 0)}; + if (got < 0) [[unlikely]] { + return fail(); + } + // The kernel never shortens a read of at most 256 bytes; checked anyway. + if (static_cast(got) != sizeof(words)) [[unlikely]] { + return std::unexpected{errc{EIO}}; + } + return hash_seed{words[0], words[1]}; +} + +/** + * @brief Streaming SipHash-`C`-`D`. + * + * Feeding bytes in any split yields the same digest as feeding them at once. + */ +export template class basic_siphash { +public: + explicit constexpr basic_siphash(const hash_seed seed) noexcept + : v0_{seed.k0 ^ 0x736F6D6570736575ULL}, v1_{seed.k1 ^ 0x646F72616E646F6DULL}, v2_{seed.k0 ^ 0x6C7967656E657261ULL}, + v3_{seed.k1 ^ 0x7465646279746573ULL} {} + + constexpr void operator()(const std::span bytes) noexcept { + auto rest{bytes}; + while (!rest.empty() && (length_ & 7) != 0) { + absorb_byte(rest.front()); + rest = rest.subspan(1); + } + while (rest.size() >= 8) { + compress(load_le(rest.first<8>())); + length_ += 8; + rest = rest.subspan(8); + } + for (const auto byte : rest) { + absorb_byte(byte); + } + } + + /** @brief The digest. Call once: it runs the finalization rounds on this state. */ + [[nodiscard]] constexpr std::uint64_t finish() noexcept { + compress((static_cast(length_ & 0xFF) << 56) | tail_); + v2_ ^= 0xFF; + for (int i{}; i < D; ++i) { + round(); + } + return v0_ ^ v1_ ^ v2_ ^ v3_; + } + +private: + [[nodiscard]] static constexpr std::uint64_t load_le(const std::span bytes) noexcept { + std::uint64_t word{}; + for (std::size_t i{}; i < 8; ++i) { + word |= std::uint64_t{std::to_integer(bytes[i])} << (8 * i); + } + return word; + } + + constexpr void absorb_byte(const std::byte byte) noexcept { + tail_ |= std::uint64_t{std::to_integer(byte)} << (8 * (length_ & 7)); + if ((++length_ & 7) == 0) { + compress(tail_); + tail_ = 0; + } + } + + constexpr void compress(const std::uint64_t m) noexcept { + v3_ ^= m; + for (int i{}; i < C; ++i) { + round(); + } + v0_ ^= m; + } + + constexpr void round() noexcept { + v0_ += v1_; + v1_ = std::rotl(v1_, 13); + v1_ ^= v0_; + v0_ = std::rotl(v0_, 32); + v2_ += v3_; + v3_ = std::rotl(v3_, 16); + v3_ ^= v2_; + v0_ += v3_; + v3_ = std::rotl(v3_, 21); + v3_ ^= v0_; + v2_ += v1_; + v1_ = std::rotl(v1_, 17); + v1_ ^= v2_; + v2_ = std::rotl(v2_, 32); + } + + std::uint64_t v0_; + std::uint64_t v1_; + std::uint64_t v2_; + std::uint64_t v3_; + std::uint64_t tail_{}; ///< bytes since the last full word, little-endian + std::uint64_t length_{}; ///< total bytes fed +}; + +/** @brief The variant the flow table uses. */ +export using siphash = basic_siphash<1, 3>; + +/** @brief Anything a key can feed its identity bytes to. */ +export template +concept byte_hasher = requires(H& h, const std::span bytes) { h(bytes); }; + +/** @brief Feed an integer's object representation. */ +export template constexpr void hash_append(H& h, const T value) noexcept { + const auto bytes{std::bit_cast>(value)}; + h(std::span{bytes}); +} + +namespace detail { + +/** + * @brief One seed per process, for `std::hash`, which cannot carry its own. + * + * Zero if `getrandom` fails, which takes a kernel older than 3.17. + */ +[[nodiscard]] inline hash_seed process_seed() noexcept { + static const hash_seed seed{random_seed().value_or(hash_seed{})}; + return seed; +} + +} // namespace detail + +} // namespace dgram diff --git a/tests/compile_checks.cpp b/tests/compile_checks.cpp index b15a12b..87ce023 100644 --- a/tests/compile_checks.cpp +++ b/tests/compile_checks.cpp @@ -194,9 +194,56 @@ static_assert(std::is_trivially_destructible_v); using demux_table_type = dgram::flow_table; static_assert(dgram::flow_lookup); -static_assert(demux_table_type::max_size == 56, "seven eighths of the slots, so probing stays bounded"); +static_assert(demux_table_type::max_size == 48, "three quarters of the slots, so probing stays bounded"); static_assert(demux_table_type::max_size < 64, "an open-addressed table must never be allowed to fill"); +/* ============================================================================ + * Keyed hashing + * ============================================================================ */ + +/* The reference key and messages: bytes 00 01 02 ... */ +constexpr dgram::hash_seed reference_seed{0x0706050403020100ULL, 0x0F0E0D0C0B0A0908ULL}; + +template consteval std::uint64_t sip_of_prefix(const std::size_t n, const std::size_t chunk = 64) { + std::array message{}; + for (std::size_t i{}; i < message.size(); ++i) { + message[i] = static_cast(i); + } + dgram::basic_siphash h{reference_seed}; + for (std::size_t at{}; at < n; at += chunk) { + h(std::span{message}.subspan(at, std::min(chunk, n - at))); + } + return h.finish(); +} + +/* The published SipHash-2-4 vectors pin the round function. */ +static_assert(sip_of_prefix<2, 4>(0) == 0x726FDB47DD0E0E31ULL); +static_assert(sip_of_prefix<2, 4>(1) == 0x74F839C593DC67FDULL); +static_assert(sip_of_prefix<2, 4>(15) == 0xA129CA6149BE45E5ULL); + +/* SipHash-1-3, the variant the table uses, around every word boundary. */ +static_assert(dgram::siphash{reference_seed}.finish() == 0xABAC0158050FC4DCULL); +static_assert(sip_of_prefix<1, 3>(1) == 0xC9F49BF37D57CA93ULL); +static_assert(sip_of_prefix<1, 3>(7) == 0xD3927D989BB11140ULL); +static_assert(sip_of_prefix<1, 3>(8) == 0x369095118D299A8EULL); +static_assert(sip_of_prefix<1, 3>(9) == 0x25A48EB36C063DE4ULL); +static_assert(sip_of_prefix<1, 3>(15) == 0xD320D86D2A519956ULL); +static_assert(sip_of_prefix<1, 3>(16) == 0xCC4FDD1A7D908B66ULL); +static_assert(sip_of_prefix<1, 3>(63) == 0x9D199062B7BBB3A8ULL); + +/* Streaming: how the bytes are split must not change the digest. */ +static_assert(sip_of_prefix<1, 3>(63, 1) == sip_of_prefix<1, 3>(63)); +static_assert(sip_of_prefix<1, 3>(63, 3) == sip_of_prefix<1, 3>(63)); +static_assert(sip_of_prefix<1, 3>(63, 13) == sip_of_prefix<1, 3>(63)); + +/* A key hashed the old way, with only a hash_value, is not a demux key. */ +struct unkeyed { + int id{}; + friend bool operator==(const unkeyed&, const unkeyed&) = default; + friend std::size_t hash_value(const unkeyed& k) noexcept { return static_cast(k.id); } +}; +static_assert(!dgram::demux_key); + /* A sink is anything that can take an arrival, and nothing more. */ struct minimal_sink { void on_datagram(const dgram::arrival&) {} diff --git a/tests/demux_tests.cpp b/tests/demux_tests.cpp index 59f5697..92bdfa8 100644 --- a/tests/demux_tests.cpp +++ b/tests/demux_tests.cpp @@ -29,6 +29,14 @@ dgram::endpoint ep(const char* text, std::uint16_t port, dgram::family fam = dgr return *dgram::endpoint::parse(fam, text, port); } +constexpr dgram::hash_seed test_seed{0x0123456789ABCDEFULL, 0xFEDCBA9876543210ULL}; + +template std::uint64_t hash_of(const K& k, const dgram::hash_seed seed = test_seed) { + dgram::siphash h{seed}; + hash_append(h, k); + return h.finish(); +} + /** Records what it was given, so routing can be asserted on. */ struct recorder { std::vector seen{}; @@ -44,8 +52,8 @@ struct recorder { TEST(EndpointKey, EqualEndpointsHashEqual) { EXPECT_EQ(ep("192.0.2.1", 443), ep("192.0.2.1", 443)); - EXPECT_EQ(hash_value(ep("192.0.2.1", 443)), hash_value(ep("192.0.2.1", 443))); - EXPECT_EQ(std::hash{}(ep("192.0.2.1", 443)), hash_value(ep("192.0.2.1", 443))); + EXPECT_EQ(hash_of(ep("192.0.2.1", 443)), hash_of(ep("192.0.2.1", 443))); + EXPECT_EQ(std::hash{}(ep("192.0.2.1", 443)), std::hash{}(ep("192.0.2.1", 443))); } TEST(EndpointKey, DistinctEndpointsAreNotEqual) { @@ -63,7 +71,7 @@ TEST(EndpointKey, FlowLabelIsNotIdentity) { reinterpret_cast<::sockaddr_in6&>(b.mutable_storage()).sin6_flowinfo = 0x12345678; EXPECT_EQ(a, b) << "a flow label does not identify a peer"; - EXPECT_EQ(hash_value(a), hash_value(b)) << "the hash must agree with equality"; + EXPECT_EQ(hash_of(a), hash_of(b)) << "the hash must agree with equality"; } /* Scope, unlike flow label, is identity: fe80::1%eth0 is not fe80::1%eth1. */ @@ -73,7 +81,7 @@ TEST(EndpointKey, ScopeIdIsIdentity) { reinterpret_cast<::sockaddr_in6&>(b.mutable_storage()).sin6_scope_id = 3; EXPECT_NE(a, b); - EXPECT_NE(hash_value(a), hash_value(b)); + EXPECT_NE(hash_of(a), hash_of(b)); } /* v4 sockaddrs carry eight bytes of sin_zero padding that must not be compared. */ @@ -84,7 +92,7 @@ TEST(EndpointKey, V4PaddingIsNotIdentity) { std::memset(raw.sin_zero, 0xAB, sizeof(raw.sin_zero)); EXPECT_EQ(a, b); - EXPECT_EQ(hash_value(a), hash_value(b)); + EXPECT_EQ(hash_of(a), hash_of(b)); } TEST(ByteKey, ComparesAndHashesByContent) { @@ -97,11 +105,59 @@ TEST(ByteKey, ComparesAndHashesByContent) { const dgram::byte_key<8> c{other}; EXPECT_EQ(a, b); - EXPECT_EQ(hash_value(a), hash_value(b)); + EXPECT_EQ(hash_of(a), hash_of(b)); EXPECT_NE(a, c); EXPECT_EQ(a.bytes().size(), 4u); } +/* The length goes in first, so a short key followed by more fields cannot feed + the same bytes as a longer key followed by fewer. */ +TEST(ByteKey, FeedsItsLengthSoCompositesStayDistinct) { + const std::array ab{std::byte{'a'}, std::byte{'b'}}; + const std::array abc{std::byte{'a'}, std::byte{'b'}, std::byte{'c'}}; + const std::array c{std::byte{'c'}}; + + dgram::siphash split{test_seed}; + hash_append(split, dgram::byte_key<4>{ab}); + hash_append(split, dgram::byte_key<4>{c}); + dgram::siphash joined{test_seed}; + hash_append(joined, dgram::byte_key<4>{abc}); + hash_append(joined, dgram::byte_key<4>{}); + EXPECT_NE(split.finish(), joined.finish()); +} + +TEST(KeyedHash, TheSeedChangesTheDigest) { + const dgram::peer_key k{ep("192.0.2.1", 443)}; + EXPECT_NE(hash_of(k, test_seed), hash_of(k, dgram::hash_seed{1, 2})); +} + +TEST(KeyedHash, RandomSeedsDiffer) { + const auto a{dgram::random_seed()}; + const auto b{dgram::random_seed()}; + ASSERT_TRUE(a.has_value() && b.has_value()); + EXPECT_NE(*a, *b); +} + +/* The flooding case: keys chosen to share a bucket under one seed. Under another + seed they must spread, or a peer that learns one table's layout owns them all. */ +TEST(KeyedHash, KeysCraftedToCollideUnderOneSeedSpreadUnderAnother) { + constexpr std::size_t buckets{64}; + std::vector crafted{}; + for (std::uint32_t port{1}; crafted.size() < 32 && port <= 0xFFFF; ++port) { + const dgram::peer_key k{ep("192.0.2.1", static_cast(port))}; + if ((hash_of(k, test_seed) & (buckets - 1)) == 0) { + crafted.push_back(k); + } + } + ASSERT_EQ(crafted.size(), 32u); + + std::set spread{}; + for (const auto& k : crafted) { + spread.insert(hash_of(k, dgram::hash_seed{42, 7}) & (buckets - 1)); + } + EXPECT_GT(spread.size(), 16u) << "32 keys over 64 buckets should land in far more than half as many buckets"; +} + /* ============================================================================ * The flow table * ============================================================================ */ @@ -212,6 +268,28 @@ TEST(FlowTable, EntriesViewSeesEveryLiveFlow) { EXPECT_EQ(values, (std::vector{0, 1, 2, 3, 4, 6, 7, 8, 9})); } +/* The table must bucket by SipHash under its own seed: entries() walks slot + order, so keys whose seeded buckets are all distinct come back sorted by them. */ +TEST(FlowTable, BucketsBySipHashUnderItsSeed) { + libmem::arena arena{table_type::footprint()}; + auto t{table_type::carve(arena, test_seed)}; + ASSERT_TRUE(t.has_value()); + + std::map by_bucket{}; + for (int port{1}; by_bucket.size() < 12; ++port) { + by_bucket.try_emplace(hash_of(dgram::peer_key{ep("192.0.2.1", static_cast(port))}) & 63, port); + } + for (const auto& [bucket, port] : by_bucket | std::views::reverse) { + ASSERT_NE(t->insert(dgram::peer_key{ep("192.0.2.1", static_cast(port))}, port), nullptr); + } + + std::vector walked{}; + for (const auto& [key, value] : t->entries()) { + walked.push_back(value); + } + EXPECT_EQ(walked, (by_bucket | std::views::values | std::ranges::to())); +} + TEST(FlowTable, CarveFailsCleanlyOnASmallArena) { libmem::arena arena{64}; EXPECT_FALSE(table_type::carve(arena).has_value()); diff --git a/tests/doc_examples.cpp b/tests/doc_examples.cpp index 2a6ad84..b06f583 100644 --- a/tests/doc_examples.cpp +++ b/tests/doc_examples.cpp @@ -120,4 +120,37 @@ TEST(DocExamples, TruncationFilterCompiles) { SUCCEED(); } +/* ============================================================================ + * docs/demux.md: a custom key, and both ways to carve a table + * ============================================================================ */ + +struct session_key { + dgram::endpoint peer{}; + std::uint32_t epoch{}; + + friend bool operator==(const session_key&, const session_key&) = default; + + template friend void hash_append(H& h, const session_key& k) noexcept { + hash_append(h, k.peer); + hash_append(h, k.epoch); + } +}; + +static_assert(dgram::demux_key); + +TEST(DocExamples, CustomKeyInATableCarvedBothWays) { + using table = dgram::flow_table; + libmem::arena arena{2 * table::footprint()}; + auto flows{table::carve(arena)}; + auto pinned{table::carve(arena, dgram::hash_seed{1, 2})}; + ASSERT_TRUE(flows.has_value() && pinned.has_value()); + + const session_key k{*dgram::endpoint::parse(dgram::family::inet4, "192.0.2.1", 443), 7}; + ASSERT_NE(flows->insert(k, 1), nullptr); + ASSERT_NE(pinned->insert(k, 2), nullptr); + EXPECT_EQ(*flows->find(k), 1); + EXPECT_EQ(*pinned->find(k), 2); + EXPECT_EQ(flows->find(session_key{k.peer, 8}), nullptr) << "epoch is part of the identity"; +} + } // namespace