diff --git a/CMakeLists.txt b/CMakeLists.txt index 326be40..6f1d7e9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -96,6 +96,7 @@ FetchContent_MakeAvailable(doctest) add_executable(secsgem_tests tests/test_main.cpp tests/test_secs2.cpp + tests/test_fuzz.cpp tests/test_hsms.cpp tests/test_hsms_connection.cpp tests/test_hsms_timers.cpp diff --git a/tests/test_fuzz.cpp b/tests/test_fuzz.cpp new file mode 100644 index 0000000..bf70211 --- /dev/null +++ b/tests/test_fuzz.cpp @@ -0,0 +1,176 @@ +// Structured fuzz tests. +// +// Determinism-by-seed: each test uses a fixed PRNG seed so a failure is +// reproducible from the CI log alone. The contract being exercised is +// "no crash, no UB, throw or return cleanly". We deliberately don't +// assert what *kind* of result we get — the codecs are allowed to throw +// CodecError, FrameError, BlockError, or just decode whatever was +// representable; what they're not allowed to do is corrupt state or +// segfault. +// +// Coverage: +// secs2::decode — random bytes, truncations, oversize lengths +// hsms::Frame::decode — random payloads (after the length prefix) +// hsms::Header::decode — random 10-byte buffers +// secsi::Block::decode — random buffers with random lengths +// secs2::Item round-trip — encode-then-decode every Item we produce +// stays bit-identical +// +// Iteration counts kept low (a few thousand) so the test stays under +// a second; the property being tested is binary (crash / no crash) so +// thousands is plenty for regression coverage. + +#include + +#include +#include +#include + +#include "secsgem/hsms/header.hpp" +#include "secsgem/secs2/codec.hpp" +#include "secsgem/secs2/item.hpp" +#include "secsgem/secsi/block.hpp" + +namespace s2 = secsgem::secs2; +namespace hsms = secsgem::hsms; +namespace secsi = secsgem::secsi; + +namespace { + +// Try to decode and swallow any throw — this is the "no crash, no UB" +// invariant. doctest's CHECK_NOTHROW would print on success/failure but +// we don't care about the result, just that control returns. +template +void must_not_crash(F f) { + try { f(); } catch (...) { /* expected for malformed input */ } +} + +std::vector random_bytes(std::mt19937& rng, std::size_t n) { + std::vector v(n); + for (auto& b : v) b = static_cast(rng() & 0xFF); + return v; +} + +} // namespace + +TEST_CASE("fuzz: secs2::decode tolerates 2000 random buffers of random length") { + std::mt19937 rng(0xDEADBEEFu); + std::uniform_int_distribution len_dist(0, 256); + for (int i = 0; i < 2000; ++i) { + auto buf = random_bytes(rng, len_dist(rng)); + must_not_crash([&] { (void)s2::decode(buf); }); + } +} + +TEST_CASE("fuzz: secs2::decode_at on truncated valid encodings") { + // Take a real-shaped encoding and lop off random suffixes; the decoder + // must signal the truncation cleanly rather than read past the end. + std::mt19937 rng(0xCAFEBABEu); + s2::Item full = s2::Item::list({ + s2::Item::ascii("HELLO"), + s2::Item::u4(std::vector{1, 2, 3, 4}), + s2::Item::list({s2::Item::boolean(std::vector{1, 0, 1})}), + }); + auto bytes = s2::encode(full); + for (std::size_t cut = 0; cut < bytes.size(); ++cut) { + std::vector truncated(bytes.begin(), bytes.begin() + cut); + must_not_crash([&] { (void)s2::decode(truncated); }); + } + // And random one-byte flips on the full encoding. + for (int i = 0; i < 500; ++i) { + auto mutated = bytes; + if (!mutated.empty()) { + mutated[rng() % mutated.size()] ^= static_cast(rng() & 0xFF); + } + must_not_crash([&] { (void)s2::decode(mutated); }); + } +} + +TEST_CASE("fuzz: hsms::Frame::decode on random payloads") { + std::mt19937 rng(0xFEEDFACEu); + std::uniform_int_distribution len_dist(10, 200); // header + body + for (int i = 0; i < 1000; ++i) { + auto buf = random_bytes(rng, len_dist(rng)); + must_not_crash([&] { (void)hsms::Frame::decode(buf.data(), buf.size()); }); + } +} + +TEST_CASE("fuzz: hsms::Header::decode on every 10-byte buffer pattern (seeded)") { + std::mt19937 rng(0xBADC0FFEu); + for (int i = 0; i < 2000; ++i) { + auto buf = random_bytes(rng, 10); + // decode is documented to read exactly 10 bytes; we hand it a + // freshly-randomized buffer. No exception promised — Header + // decode is total in the byte-pattern domain (every byte maps to + // some interpretation) — but we still assert no crash. + must_not_crash([&] { (void)hsms::Header::decode(buf.data()); }); + } +} + +TEST_CASE("fuzz: secsi::Block::decode on random buffers with varying sizes") { + std::mt19937 rng(0xC0FFEE00u); + std::uniform_int_distribution len_dist(0, 300); + for (int i = 0; i < 2000; ++i) { + auto buf = random_bytes(rng, len_dist(rng)); + must_not_crash([&] { + std::size_t consumed = 0; + (void)secsi::Block::decode(buf.data(), buf.size(), consumed); + }); + } +} + +TEST_CASE("fuzz: secs2 encode/decode round-trip for a battery of items") { + // For every Item we can synthesize, encode then decode should produce + // bit-identical bytes the second time around. Catches any encode-path + // asymmetry the existing test_secs2.cpp examples miss. + std::vector items = { + s2::Item::list({}), + s2::Item::ascii(""), + s2::Item::ascii("Hello, world!"), + s2::Item::binary({0x00, 0xFF, 0x80, 0x7F}), + s2::Item::boolean(true), + s2::Item::boolean(std::vector{0, 0, 1, 1}), + s2::Item::u1(std::vector{0, 1, 2, 3, 0xFF}), + s2::Item::u2(std::vector{0, 1, 0xFFFF}), + s2::Item::u4(std::vector{0, 1, 0xFFFFFFFFu}), + s2::Item::i1(std::vector{-128, 0, 127}), + s2::Item::i2(std::vector{-32768, 0, 32767}), + s2::Item::i4(std::vector{-2147483647 - 1, 0, 2147483647}), + s2::Item::f4(std::vector{0.0f, -0.0f, 3.14f}), + s2::Item::f8(std::vector{0.0, -0.0, 3.141592653589793}), + s2::Item::list({s2::Item::ascii("nested"), + s2::Item::u4(uint32_t{42}), + s2::Item::list({s2::Item::boolean(true)})}), + }; + for (const auto& it : items) { + auto enc1 = s2::encode(it); + s2::Item decoded; + must_not_crash([&] { decoded = s2::decode(enc1); }); + auto enc2 = s2::encode(decoded); + CHECK(enc1 == enc2); + } +} + +TEST_CASE("fuzz: oversize length prefix on secs2 items") { + // Construct a buffer that *claims* a huge length but is actually + // short. decode must signal truncation, not allocate gigabytes. + std::vector oversize{ + // Format = ASCII (0x41), length-bytes-count=3 (so | 0x3), + // followed by 3 length bytes saying 0xFFFFFF, then no payload. + 0x41 | 0x03, 0xFF, 0xFF, 0xFF, + }; + must_not_crash([&] { (void)s2::decode(oversize); }); +} + +TEST_CASE("fuzz: deeply nested list doesn't blow the stack") { + // Build a 64-level deep list and round-trip it. The recursive decoder + // is the obvious blast-radius for stack overflow. + s2::Item leaf = s2::Item::ascii("leaf"); + for (int i = 0; i < 64; ++i) leaf = s2::Item::list({std::move(leaf)}); + auto bytes = s2::encode(leaf); + must_not_crash([&] { + auto round = s2::decode(bytes); + auto bytes2 = s2::encode(round); + CHECK(bytes == bytes2); + }); +}