diff --git a/CMakeLists.txt b/CMakeLists.txt index 2a894f6..1ddfcbd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -164,6 +164,7 @@ add_executable(secsgem_tests tests/test_metrics_prometheus.cpp tests/test_hsms_gs_integration.cpp tests/test_robustness_fuzz.cpp + tests/test_e5_kat.cpp ) target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest) target_compile_definitions(secsgem_tests PRIVATE diff --git a/tests/test_e5_kat.cpp b/tests/test_e5_kat.cpp new file mode 100644 index 0000000..9842f87 --- /dev/null +++ b/tests/test_e5_kat.cpp @@ -0,0 +1,386 @@ +// SEMI E5 known-answer tests for SECS-II encoding. +// +// Hex-string fixtures constructed directly from the SEMI E5 §9 +// format-byte encoding rules: +// +// format_byte = (format_code << 2) | length_byte_count +// length_byte_count ∈ {1, 2, 3} +// followed by length_byte_count big-endian length bytes +// followed by `length` body bytes (element-typed) +// +// Format codes (octal, §9.5 Table 5): +// L=000 B=010 BOOLEAN=011 A=020 J=021 C=022 +// I8=030 I1=031 I2=032 I4=034 +// F8=040 F4=044 +// U8=050 U1=051 U2=052 U4=054 +// +// This file is the strongest single proof of codec correctness: +// every other validator is one implementer's interpretation; KAT is +// the standard's own arithmetic. If our encoder matches these +// canonical bytes and our decoder reverses them to the same Item, +// our SECS-II layer is wire-compatible with anything else that obeys +// E5 §9. +// +// Coverage: +// - Every format code with at least one non-trivial value +// - Every length-byte-count variant (1, 2, 3 bytes) +// - Numeric edges: 0, ±1, MIN, MAX, ±Inf, NaN, multi-element vectors +// - Empty and single-element variants +// - Nested lists +// +// On a failure, do NOT change the fixture — fix the codec to match +// the spec. + +#include + +#include +#include +#include +#include +#include + +#include "secsgem/secs2/codec.hpp" +#include "secsgem/secs2/item.hpp" + +using namespace secsgem::secs2; + +namespace { + +// Convert "41 05 48 65 6C 6C 6F" → {0x41, 0x05, 0x48, 0x65, 0x6C, 0x6C, 0x6F}. +std::vector hex(const std::string& s) { + std::vector out; + uint8_t cur = 0; + bool nibble = false; + for (char c : s) { + if (c == ' ' || c == '\n' || c == '\t') continue; + int v; + if (c >= '0' && c <= '9') v = c - '0'; + else if (c >= 'a' && c <= 'f') v = 10 + (c - 'a'); + else if (c >= 'A' && c <= 'F') v = 10 + (c - 'A'); + else throw std::runtime_error(std::string("bad hex char: ") + c); + cur = static_cast((cur << 4) | v); + if (nibble) { + out.push_back(cur); + cur = 0; + } + nibble = !nibble; + } + if (nibble) throw std::runtime_error("odd nibble count"); + return out; +} + +std::string to_hex(const std::vector& b) { + static const char* digits = "0123456789ABCDEF"; + std::string s; + s.reserve(b.size() * 3); + for (std::size_t i = 0; i < b.size(); ++i) { + if (i) s += ' '; + s += digits[b[i] >> 4]; + s += digits[b[i] & 0xF]; + } + return s; +} + +// Round-trip a fixture: encode the Item, assert exact byte equality +// with the canonical; decode the canonical, assert equality with the +// Item. The label appears in failure messages. +void check_kat(const char* label, const Item& item, + const std::string& canonical_hex) { + const auto canonical = hex(canonical_hex); + const auto encoded = encode(item); + INFO(label); + CHECK_MESSAGE(encoded == canonical, + "encode mismatch:\n got " << to_hex(encoded) + << "\n expected " << canonical_hex); + const auto decoded = decode(canonical); + CHECK_MESSAGE(decoded == item, + "decode mismatch for " << label); +} + +// Float specials (NaN, ±Inf, -0.0) need bit-level checks because NaN +// doesn't compare equal to itself by IEEE rules. We decode the +// canonical bytes (no construction needed — the bit pattern IS the +// fixture), then re-encode and assert byte-identical output. +void check_kat_float_bits(const char* label, Format fmt, + const std::vector& canonical) { + const auto decoded = decode(canonical); + INFO(label); + REQUIRE(decoded.format() == fmt); + const auto re_encoded = encode(decoded); + CHECK_MESSAGE(re_encoded == canonical, + "round-trip mismatch:\n got " << to_hex(re_encoded) + << "\n canonical " << to_hex(canonical)); +} + +} // namespace + +// -------------------- List -------------------- +TEST_CASE("E5 KAT: List (format code 000)") { + // Empty list: fmt=(0<<2)|1=0x01, len=0 + check_kat("L empty", Item::list({}), "01 00"); + + // List of two U1 elements [1, 2]: fmt=0x01, len=0x02 (2 elements), + // then U1(1) = A5 01 01 and U1(2) = A5 01 02. + check_kat("L [U1(1), U1(2)]", + Item::list({Item::u1(1), Item::u1(2)}), + "01 02 A5 01 01 A5 01 02"); + + // Nested list: L [L[U1(1)], A "OK"] + // outer: 01 02 + // inner L[U1(1)]: 01 01 A5 01 01 + // A "OK": 41 02 4F 4B + check_kat("L nested", + Item::list({Item::list({Item::u1(1)}), Item::ascii("OK")}), + "01 02 01 01 A5 01 01 41 02 4F 4B"); +} + +// -------------------- Binary -------------------- +TEST_CASE("E5 KAT: Binary (format code 010)") { + // Empty: fmt=(8<<2)|1=0x21, len=0 + check_kat("B empty", Item::binary({}), "21 00"); + + // Single byte 0xDE: fmt=0x21, len=0x01, body=DE + check_kat("B {0xDE}", Item::binary({0xDE}), "21 01 DE"); + + // Multi-byte: fmt=0x21, len=0x04, body=DE AD BE EF + check_kat("B {0xDE, 0xAD, 0xBE, 0xEF}", + Item::binary({0xDE, 0xAD, 0xBE, 0xEF}), + "21 04 DE AD BE EF"); +} + +// -------------------- Boolean -------------------- +TEST_CASE("E5 KAT: Boolean (format code 011)") { + // TRUE: fmt=(9<<2)|1=0x25, len=0x01, body=01 + check_kat("BOOLEAN TRUE", Item::boolean(true), "25 01 01"); + // FALSE: 25 01 00 + check_kat("BOOLEAN FALSE", Item::boolean(false), "25 01 00"); + // Vector {true, false, true}: 25 03 01 00 01 + check_kat("BOOLEAN vec", + Item::boolean(std::vector{1, 0, 1}), + "25 03 01 00 01"); +} + +// -------------------- ASCII -------------------- +TEST_CASE("E5 KAT: ASCII (format code 020)") { + // Empty: fmt=(16<<2)|1=0x41, len=0 + check_kat("A empty", Item::ascii(""), "41 00"); + // "H": 41 01 48 + check_kat("A \"H\"", Item::ascii("H"), "41 01 48"); + // "Hello": 41 05 48 65 6C 6C 6F + check_kat("A \"Hello\"", Item::ascii("Hello"), "41 05 48 65 6C 6C 6F"); +} + +TEST_CASE("E5 KAT: ASCII length-byte transitions") { + // 255-byte body (max for 1-byte length): fmt=0x41, len=0xFF, body=255× 'A' + std::string s255(255, 'A'); + std::string expected255 = "41 FF"; + for (int i = 0; i < 255; ++i) expected255 += " 41"; + check_kat("A 255× 'A' (1-byte length)", Item::ascii(s255), expected255); + + // 256-byte body — transitions to 2-byte length: + // fmt=(16<<2)|2=0x42, len=0x01 0x00 (256), body=256× 'A' + std::string s256(256, 'A'); + std::string expected256 = "42 01 00"; + for (int i = 0; i < 256; ++i) expected256 += " 41"; + check_kat("A 256× 'A' (2-byte length transition)", + Item::ascii(s256), expected256); +} + +TEST_CASE("E5 KAT: ASCII 3-byte length") { + // 65 536-byte body: transitions to 3-byte length. + // fmt=(16<<2)|3=0x43, len=0x01 0x00 0x00 (65 536), body=65 536× 'X' + std::string s(65536, 'X'); + std::string expected = "43 01 00 00"; + // 65 536 bytes of 'X' (0x58) — build incrementally to avoid a giant literal. + expected.reserve(4 + 3 + 65536 * 3); + for (int i = 0; i < 65536; ++i) expected += " 58"; + check_kat("A 65536× 'X' (3-byte length transition)", + Item::ascii(s), expected); +} + +// -------------------- JIS-8 -------------------- +TEST_CASE("E5 KAT: JIS-8 (format code 021)") { + // Empty: fmt=(17<<2)|1=0x45, len=0 + check_kat("J empty", Item::jis8(""), "45 00"); + // 4 bytes (typical JIS-X-0201 katakana range): A1 A2 A3 A4 + check_kat("J 4 bytes", + Item::jis8(std::string{char(0xA1), char(0xA2), + char(0xA3), char(0xA4)}), + "45 04 A1 A2 A3 A4"); +} + +// -------------------- C2 (Unicode 2-byte) -------------------- +TEST_CASE("E5 KAT: C2 (format code 022)") { + // Empty: fmt=(18<<2)|1=0x49, len=0 + check_kat("C empty", Item::c2(std::vector{}), "49 00"); + // [0x0041, 0x0042] ('A', 'B' as BMP code points): + // fmt=0x49, len=0x04 (4 body bytes for 2 code points × 2 bytes BE) + check_kat("C [0x0041, 0x0042]", + Item::c2(std::vector{0x0041, 0x0042}), + "49 04 00 41 00 42"); +} + +// -------------------- I1 / I2 / I4 / I8 -------------------- +TEST_CASE("E5 KAT: I1 (format code 031)") { + // fmt=(25<<2)|1=0x65 + check_kat("I1 0", Item::i1(int8_t{0}), "65 01 00"); + check_kat("I1 1", Item::i1(int8_t{1}), "65 01 01"); + check_kat("I1 -1", Item::i1(int8_t{-1}), "65 01 FF"); + check_kat("I1 127", Item::i1(int8_t{127}), "65 01 7F"); + check_kat("I1 -128", Item::i1(int8_t{-128}), "65 01 80"); + check_kat("I1 vec [1,-1,2]", + Item::i1(std::vector{1, -1, 2}), + "65 03 01 FF 02"); +} + +TEST_CASE("E5 KAT: I2 (format code 032)") { + // fmt=(26<<2)|1=0x69 + check_kat("I2 0", Item::i2(int16_t{0}), "69 02 00 00"); + check_kat("I2 -1", Item::i2(int16_t{-1}), "69 02 FF FF"); + check_kat("I2 INT16_MIN", Item::i2(int16_t{-32768}), "69 02 80 00"); + check_kat("I2 INT16_MAX", Item::i2(int16_t{32767}), "69 02 7F FF"); +} + +TEST_CASE("E5 KAT: I4 (format code 034)") { + // fmt=(28<<2)|1=0x71 + check_kat("I4 0", Item::i4(int32_t{0}), "71 04 00 00 00 00"); + check_kat("I4 1", Item::i4(int32_t{1}), "71 04 00 00 00 01"); + check_kat("I4 -1", Item::i4(int32_t{-1}), "71 04 FF FF FF FF"); + check_kat("I4 0x01020304", + Item::i4(int32_t{0x01020304}), + "71 04 01 02 03 04"); + check_kat("I4 INT32_MIN", + Item::i4(int32_t{INT32_MIN}), + "71 04 80 00 00 00"); +} + +TEST_CASE("E5 KAT: I8 (format code 030)") { + // fmt=(24<<2)|1=0x61 + check_kat("I8 0", Item::i8(int64_t{0}), "61 08 00 00 00 00 00 00 00 00"); + check_kat("I8 -1", Item::i8(int64_t{-1}), "61 08 FF FF FF FF FF FF FF FF"); + check_kat("I8 INT64_MIN", + Item::i8(int64_t{INT64_MIN}), + "61 08 80 00 00 00 00 00 00 00"); +} + +// -------------------- U1 / U2 / U4 / U8 -------------------- +TEST_CASE("E5 KAT: U1 (format code 051)") { + // fmt=(41<<2)|1=0xA5 + check_kat("U1 0", Item::u1(uint8_t{0}), "A5 01 00"); + check_kat("U1 1", Item::u1(uint8_t{1}), "A5 01 01"); + check_kat("U1 0x7F", Item::u1(uint8_t{0x7F}), "A5 01 7F"); + check_kat("U1 0xFF", Item::u1(uint8_t{0xFF}), "A5 01 FF"); + check_kat("U1 vec [1,2,3]", + Item::u1(std::vector{1, 2, 3}), + "A5 03 01 02 03"); +} + +TEST_CASE("E5 KAT: U2 (format code 052)") { + // fmt=(42<<2)|1=0xA9 + check_kat("U2 0", Item::u2(uint16_t{0}), "A9 02 00 00"); + check_kat("U2 0x0102", Item::u2(uint16_t{0x0102}), "A9 02 01 02"); + check_kat("U2 0xFFFF", Item::u2(uint16_t{0xFFFF}), "A9 02 FF FF"); +} + +TEST_CASE("E5 KAT: U4 (format code 054)") { + // fmt=(44<<2)|1=0xB1 + check_kat("U4 0", + Item::u4(uint32_t{0}), + "B1 04 00 00 00 00"); + check_kat("U4 0x01020304", + Item::u4(uint32_t{0x01020304}), + "B1 04 01 02 03 04"); + check_kat("U4 0xFFFFFFFF", + Item::u4(uint32_t{0xFFFFFFFF}), + "B1 04 FF FF FF FF"); +} + +TEST_CASE("E5 KAT: U8 (format code 050)") { + // fmt=(40<<2)|1=0xA1 + check_kat("U8 0", + Item::u8(uint64_t{0}), + "A1 08 00 00 00 00 00 00 00 00"); + check_kat("U8 0x0102030405060708", + Item::u8(uint64_t{0x0102030405060708ULL}), + "A1 08 01 02 03 04 05 06 07 08"); + check_kat("U8 UINT64_MAX", + Item::u8(UINT64_MAX), + "A1 08 FF FF FF FF FF FF FF FF"); +} + +// -------------------- F4 (IEEE 754 single) -------------------- +TEST_CASE("E5 KAT: F4 (format code 044)") { + // fmt=(36<<2)|1=0x91 + check_kat("F4 0.0", Item::f4(0.0f), "91 04 00 00 00 00"); + check_kat("F4 1.0", Item::f4(1.0f), "91 04 3F 80 00 00"); + check_kat("F4 -1.0", Item::f4(-1.0f), "91 04 BF 80 00 00"); + check_kat("F4 2.0", Item::f4(2.0f), "91 04 40 00 00 00"); + + // Specials need bit-pattern compare (NaN != NaN, signed zero, etc.) + check_kat_float_bits("F4 +Inf", Format::F4, hex("91 04 7F 80 00 00")); + check_kat_float_bits("F4 -Inf", Format::F4, hex("91 04 FF 80 00 00")); + check_kat_float_bits("F4 quiet NaN", Format::F4, hex("91 04 7F C0 00 00")); + check_kat_float_bits("F4 -0.0", Format::F4, hex("91 04 80 00 00 00")); +} + +// -------------------- F8 (IEEE 754 double) -------------------- +TEST_CASE("E5 KAT: F8 (format code 040)") { + // fmt=(32<<2)|1=0x81 + check_kat("F8 0.0", Item::f8(0.0), "81 08 00 00 00 00 00 00 00 00"); + check_kat("F8 1.0", Item::f8(1.0), "81 08 3F F0 00 00 00 00 00 00"); + check_kat("F8 -1.0", Item::f8(-1.0), "81 08 BF F0 00 00 00 00 00 00"); + check_kat("F8 2.0", Item::f8(2.0), "81 08 40 00 00 00 00 00 00 00"); + + check_kat_float_bits("F8 +Inf", Format::F8, hex("81 08 7F F0 00 00 00 00 00 00")); + check_kat_float_bits("F8 -Inf", Format::F8, hex("81 08 FF F0 00 00 00 00 00 00")); + check_kat_float_bits("F8 quiet NaN", Format::F8, hex("81 08 7F F8 00 00 00 00 00 00")); +} + +// -------------------- Format byte table (regression tripwire) -------------- +// One-shot test that pins every format byte's bit-layout. If anyone +// edits the Format enum without updating the encoder this fires +// loudly. +TEST_CASE("E5 KAT: format byte layout per format code") { + struct Case { Format fmt; uint8_t code; }; + Case cases[] = { + {Format::List, 000}, {Format::Binary, 010}, {Format::Boolean, 011}, + {Format::ASCII, 020}, {Format::JIS8, 021}, {Format::C2, 022}, + {Format::I8, 030}, {Format::I1, 031}, {Format::I2, 032}, + {Format::I4, 034}, + {Format::F8, 040}, {Format::F4, 044}, + {Format::U8, 050}, {Format::U1, 051}, {Format::U2, 052}, + {Format::U4, 054}, + }; + auto empty_for = [](Format f) -> Item { + switch (f) { + case Format::List: return Item::list({}); + case Format::Binary: return Item::binary({}); + case Format::Boolean: return Item::boolean(std::vector{}); + case Format::ASCII: return Item::ascii(""); + case Format::JIS8: return Item::jis8(""); + case Format::C2: return Item::c2(std::vector{}); + case Format::U1: return Item::u1(std::vector{}); + case Format::U2: return Item::u2(std::vector{}); + case Format::U4: return Item::u4(std::vector{}); + case Format::U8: return Item::u8(std::vector{}); + case Format::I1: return Item::i1(std::vector{}); + case Format::I2: return Item::i2(std::vector{}); + case Format::I4: return Item::i4(std::vector{}); + case Format::I8: return Item::i8(std::vector{}); + case Format::F4: return Item::f4(std::vector{}); + case Format::F8: return Item::f8(std::vector{}); + } + return Item{}; + }; + for (auto c : cases) { + INFO("format=" << static_cast(c.fmt)); + CHECK(static_cast(c.fmt) == c.code); + // Per the spec the format byte for a 1-byte-length, empty body is + // (code << 2) | 1. Encoder must produce that. + const auto enc = encode(empty_for(c.fmt)); + REQUIRE(enc.size() >= 2); + const uint8_t expected_fmt_byte = static_cast((c.code << 2) | 1); + CHECK(enc[0] == expected_fmt_byte); + CHECK(enc[1] == 0); // length = 0 + } +}