// 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); }); }