yams/tests/unit/wal/wal_entry_catch2_test.cpp
Trevon 8000226a4c
fix(wal): encode the entry header field by field, never via padding (#274)
* test(wal): pin v1 entry encoding to a Clang-written fixture

store_block_entry_clang_v1.bin is one StoreBlock entry serialized by the
current wal_entry.cpp built with Clang 21, which zero-fills the 4 bytes of
trailing Header padding. The new cases decode and verify it, re-encode the
same logical entry and require identical bytes, and round-trip fresh
entries through serialize/deserialize/verifyChecksum.

GCC 16 leaves that padding uninitialized in stableHeaderBytes(), so these
cases fail there.

Signed-off-by: trvon <me@trevon.dev>

* fix(wal): encode entry header field by field, never via padding

stableHeaderBytes() built the checksummed header with `Header stable{}`
plus member assignments and then memcpy'd sizeof(Header) bytes. That is
aggregate initialization, so the 4 bytes of trailing padding (offsets
44..47) are not guaranteed to be zero. Clang 21 memsets the object first;
GCC 16 at -O0 stores only the members, so stack garbage reached both the
serialized entry and the CRC, and verifyChecksum() failed on GCC builds.

Encode and decode the header field by field into a zero-filled 48-byte
array at the existing offsets, with static_asserts pinning every offset
and size. The checksum is CRC32 over that canonical encoding plus the
payload, which is exactly what Clang builds have always written, so their
WAL files verify unchanged. deserialize() also accepts an entry whose CRC
covers nonzero padding as stored on disk (written by earlier GCC builds)
and normalizes its in-memory checksum. WALFile and WALManager recovery
read headers through the new WALEntry::decodeHeader().

TransactionData (uint64 + uint32) has 4 bytes of trailing padding and was
copied whole into the payload, relying on the same unguaranteed zeroing
(GCC 16 and Clang 21 happen to zero it today); it is now encoded field by
field too. The remaining payload structs are padding-free, which
appendObjectBytes/readObject now enforce with
has_unique_object_representations_v.

Signed-off-by: trvon <me@trevon.dev>

---------

Signed-off-by: trvon <me@trevon.dev>
2026-10-08 14:30:25 -06:00

660 lines
25 KiB
C++

#include <catch2/catch_test_macros.hpp>
#include <yams/common/crc32.h>
#include <yams/wal/wal_entry.h>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <sstream>
#include <string>
#include <vector>
using namespace yams::wal;
TEST_CASE("WALEntry::Header::isValid accepts OpTypes 1-8", "[wal][header][catch2]") {
WALEntry::Header h{};
h.sequenceNum = 1;
h.version = 1;
CHECK(WALEntry::Header::size() == sizeof(WALEntry::Header));
for (uint8_t opv = 1; opv <= 8; ++opv) {
h.operation = static_cast<WALEntry::OpType>(opv);
CHECK(h.isValid());
}
}
TEST_CASE("WALEntry::Header::isValid rejects bad magic", "[wal][header][catch2]") {
WALEntry::Header h{};
h.sequenceNum = 1;
h.version = 1;
h.operation = WALEntry::OpType::StoreBlock;
h.magic = 0;
CHECK_FALSE(h.isValid());
}
TEST_CASE("WALEntry::Header::isValid rejects bad version (not 1 or 2)", "[wal][header][catch2]") {
WALEntry::Header h{};
h.sequenceNum = 1;
h.operation = WALEntry::OpType::StoreBlock;
h.version = 0;
CHECK_FALSE(h.isValid());
h.version = 3;
CHECK_FALSE(h.isValid());
}
TEST_CASE("WALEntry::Header::isValid accepts version 2 (dual-read compat)",
"[wal][header][catch2]") {
WALEntry::Header h{};
h.sequenceNum = 1;
h.version = 2;
h.operation = WALEntry::OpType::StoreBlock;
CHECK(h.isValid());
}
TEST_CASE("WALEntry::Header::isValid rejects zero sequenceNum", "[wal][header][catch2]") {
WALEntry::Header h{};
h.sequenceNum = 0;
h.version = 1;
h.operation = WALEntry::OpType::StoreBlock;
CHECK_FALSE(h.isValid());
}
TEST_CASE("WALEntry::Header::isValid rejects out-of-range OpType (0 and 9)",
"[wal][header][catch2]") {
WALEntry::Header h{};
h.sequenceNum = 1;
h.version = 1;
h.operation = static_cast<WALEntry::OpType>(0);
CHECK_FALSE(h.isValid());
h.operation = static_cast<WALEntry::OpType>(9);
CHECK_FALSE(h.isValid());
}
TEST_CASE("WALEntry constructor sets header fields", "[wal][ctor][catch2]") {
std::vector<std::byte> payload;
payload.resize(3);
payload[0] = std::byte{0x01};
payload[1] = std::byte{0x02};
payload[2] = std::byte{0x03};
const auto op = WALEntry::OpType::UpdateReference;
const uint64_t seq = 42;
const uint64_t txn = 99;
WALEntry e(op, seq, txn, std::span<const std::byte>(payload.data(), payload.size()));
CHECK(e.header.magic == 0x57414C31);
CHECK(e.header.version == 1);
CHECK(e.header.sequenceNum == seq);
CHECK(e.header.transactionId == txn);
CHECK(e.header.operation == op);
CHECK(e.header.flags == 0);
CHECK(e.header.reserved == 0);
CHECK(e.header.dataSize == static_cast<uint32_t>(payload.size()));
CHECK(e.data == payload);
CHECK(e.header.timestamp != 0);
CHECK(e.verifyChecksum());
}
TEST_CASE("WALEntry::isTransactional true for Begin/Commit/Rollback", "[wal][ctor][catch2]") {
WALEntry begin(WALEntry::OpType::BeginTransaction, 1, 123);
WALEntry commit(WALEntry::OpType::CommitTransaction, 2, 123);
WALEntry rollback(WALEntry::OpType::Rollback, 3, 123);
CHECK(begin.isTransactional());
CHECK(commit.isTransactional());
CHECK(rollback.isTransactional());
WALEntry store(WALEntry::OpType::StoreBlock, 4, 123);
WALEntry del(WALEntry::OpType::DeleteBlock, 5, 123);
WALEntry ref(WALEntry::OpType::UpdateReference, 6, 123);
WALEntry meta(WALEntry::OpType::UpdateMetadata, 7, 123);
WALEntry cp(WALEntry::OpType::Checkpoint, 8, 123);
CHECK_FALSE(store.isTransactional());
CHECK_FALSE(del.isTransactional());
CHECK_FALSE(ref.isTransactional());
CHECK_FALSE(meta.isTransactional());
CHECK_FALSE(cp.isTransactional());
}
TEST_CASE("WALEntry serialize/deserialize round-trip with empty payload", "[wal][serde][catch2]") {
WALEntry e(WALEntry::OpType::StoreBlock, 1, 7);
REQUIRE(e.data.empty());
auto buf = e.serialize();
auto out = WALEntry::deserialize(std::span<const std::byte>(buf.data(), buf.size()));
REQUIRE(out.has_value());
CHECK(out->header.magic == e.header.magic);
CHECK(out->header.version == e.header.version);
CHECK(out->header.sequenceNum == e.header.sequenceNum);
CHECK(out->header.transactionId == e.header.transactionId);
CHECK(out->header.operation == e.header.operation);
CHECK(out->header.dataSize == 0);
CHECK(out->data.empty());
CHECK(out->verifyChecksum());
}
TEST_CASE("WALEntry serialize/deserialize round-trip with 128-byte payload",
"[wal][serde][catch2]") {
std::vector<std::byte> payload;
payload.resize(128);
for (size_t i = 0; i < payload.size(); ++i) {
payload[i] = std::byte{static_cast<unsigned char>(i & 0xFF)};
}
WALEntry e(WALEntry::OpType::UpdateMetadata, 2, 99,
std::span<const std::byte>(payload.data(), payload.size()));
auto buf = e.serialize();
auto out = WALEntry::deserialize(std::span<const std::byte>(buf.data(), buf.size()));
REQUIRE(out.has_value());
CHECK(out->header.operation == e.header.operation);
CHECK(out->header.sequenceNum == e.header.sequenceNum);
CHECK(out->header.transactionId == e.header.transactionId);
CHECK(out->header.dataSize == static_cast<uint32_t>(payload.size()));
CHECK(out->data == payload);
CHECK(out->verifyChecksum());
}
TEST_CASE("WALEntry serialize/deserialize round-trip for every OpType (1-8)",
"[wal][serde][catch2]") {
for (uint8_t opv = 1; opv <= 8; ++opv) {
auto op = static_cast<WALEntry::OpType>(opv);
WALEntry e(op, 100 + opv, 5000 + opv);
auto buf = e.serialize();
auto out = WALEntry::deserialize(std::span<const std::byte>(buf.data(), buf.size()));
REQUIRE(out.has_value());
CHECK(out->header.operation == op);
CHECK(out->header.sequenceNum == e.header.sequenceNum);
CHECK(out->header.transactionId == e.header.transactionId);
CHECK(out->verifyChecksum());
}
}
TEST_CASE("WALEntry::verifyChecksum succeeds on valid entry", "[wal][checksum][catch2]") {
std::vector<std::byte> payload;
payload.resize(4);
payload[0] = std::byte{0xDE};
payload[1] = std::byte{0xAD};
payload[2] = std::byte{0xBE};
payload[3] = std::byte{0xEF};
WALEntry e(WALEntry::OpType::StoreBlock, 1, 1,
std::span<const std::byte>(payload.data(), payload.size()));
CHECK(e.verifyChecksum());
}
TEST_CASE("WALEntry::verifyChecksum fails after corrupting data[0]", "[wal][checksum][catch2]") {
std::vector<std::byte> payload;
payload.resize(1);
payload[0] = std::byte{0xAA};
WALEntry e(WALEntry::OpType::StoreBlock, 10, 77,
std::span<const std::byte>(payload.data(), payload.size()));
REQUIRE_FALSE(e.data.empty());
CHECK(e.verifyChecksum());
e.data[0] = std::byte{0xAB};
CHECK_FALSE(e.verifyChecksum());
}
TEST_CASE("WALEntry updateChecksum then verify after mutation", "[wal][checksum][catch2]") {
std::vector<std::byte> payload;
payload.resize(2);
payload[0] = std::byte{0x10};
payload[1] = std::byte{0x20};
WALEntry e(WALEntry::OpType::DeleteBlock, 11, 88,
std::span<const std::byte>(payload.data(), payload.size()));
CHECK(e.verifyChecksum());
e.data[1] = std::byte{0x21};
CHECK_FALSE(e.verifyChecksum());
e.updateChecksum();
CHECK(e.verifyChecksum());
}
TEST_CASE("WALEntry::deserialize rejects buffer smaller than header",
"[wal][serde][error][catch2]") {
std::vector<std::byte> buf;
buf.resize(WALEntry::Header::size() - 1);
auto out = WALEntry::deserialize(std::span<const std::byte>(buf.data(), buf.size()));
CHECK_FALSE(out.has_value());
}
TEST_CASE("WALEntry::deserialize rejects invalid header (corrupt magic)",
"[wal][serde][error][catch2]") {
WALEntry e(WALEntry::OpType::StoreBlock, 1, 1);
auto buf = e.serialize();
REQUIRE(buf.size() >= 1);
buf[0] ^= std::byte{0xFF};
auto out = WALEntry::deserialize(std::span<const std::byte>(buf.data(), buf.size()));
CHECK_FALSE(out.has_value());
}
TEST_CASE("WALEntry::deserialize rejects truncated data region", "[wal][serde][error][catch2]") {
std::vector<std::byte> payload;
payload.resize(16);
for (size_t i = 0; i < payload.size(); ++i) {
payload[i] = std::byte{0x5A};
}
WALEntry e(WALEntry::OpType::UpdateReference, 2, 3,
std::span<const std::byte>(payload.data(), payload.size()));
auto buf = e.serialize();
REQUIRE(buf.size() == WALEntry::Header::size() + payload.size());
buf.resize(buf.size() - 1);
auto out = WALEntry::deserialize(std::span<const std::byte>(buf.data(), buf.size()));
CHECK_FALSE(out.has_value());
}
TEST_CASE("WALEntry::deserialize rejects corrupted checksum (flip a byte)",
"[wal][serde][error][catch2]") {
std::vector<std::byte> payload;
payload.resize(8);
for (size_t i = 0; i < payload.size(); ++i) {
payload[i] = std::byte{static_cast<unsigned char>(i)};
}
WALEntry e(WALEntry::OpType::UpdateMetadata, 3, 4,
std::span<const std::byte>(payload.data(), payload.size()));
auto buf = e.serialize();
REQUIRE(buf.size() > WALEntry::Header::size());
buf.back() ^= std::byte{0xFF};
auto out = WALEntry::deserialize(std::span<const std::byte>(buf.data(), buf.size()));
CHECK_FALSE(out.has_value());
}
namespace {
// tests/fixtures/wal/store_block_entry_clang_v1.bin: one StoreBlock entry serialized by the
// pre-fix wal_entry.cpp built with Clang 21 (which zero-fills header padding). Its logical
// content is reproduced by makeFixtureEntry().
std::vector<std::byte> readWalFixture(const char* name) {
const std::filesystem::path path = std::filesystem::path(YAMS_WAL_FIXTURE_DIR) / name;
std::ifstream in(path, std::ios::binary);
REQUIRE(in.good());
std::vector<char> raw((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
std::vector<std::byte> bytes(raw.size());
std::memcpy(bytes.data(), raw.data(), raw.size());
return bytes;
}
constexpr uint32_t kFixtureChecksum = 0x032B862E;
WALEntry makeFixtureEntry() {
auto payload = WALEntry::StoreBlockData::encode("0123456789abcdef0123456789abcdef", 4096, 1);
WALEntry e(WALEntry::OpType::StoreBlock, 0x1122334455667788ULL, 0x99,
std::span<const std::byte>(payload.data(), payload.size()));
e.header.timestamp = 1700000000000000ULL;
e.updateChecksum();
return e;
}
} // namespace
TEST_CASE("WALEntry decodes and verifies a Clang-written v1 entry fixture",
"[wal][serde][compat][catch2]") {
const auto buf = readWalFixture("store_block_entry_clang_v1.bin");
REQUIRE(buf.size() == WALEntry::Header::size() + sizeof(WALEntry::StoreBlockData));
auto out = WALEntry::deserialize(std::span<const std::byte>(buf.data(), buf.size()));
REQUIRE(out.has_value());
CHECK(out->header.magic == 0x57414C31);
CHECK(out->header.version == 1);
CHECK(out->header.sequenceNum == 0x1122334455667788ULL);
CHECK(out->header.timestamp == 1700000000000000ULL);
CHECK(out->header.transactionId == 0x99);
CHECK(out->header.operation == WALEntry::OpType::StoreBlock);
CHECK(out->header.flags == 0);
CHECK(out->header.reserved == 0);
CHECK(out->header.dataSize == sizeof(WALEntry::StoreBlockData));
CHECK(out->header.checksum == kFixtureChecksum);
CHECK(out->verifyChecksum());
auto block = WALEntry::StoreBlockData::decode(
std::span<const std::byte>(out->data.data(), out->data.size()));
REQUIRE(block.has_value());
CHECK(std::string(block->hash, sizeof(block->hash)) == "0123456789abcdef0123456789abcdef");
CHECK(block->size == 4096);
CHECK(block->refCount == 1);
}
TEST_CASE("WALEntry serialize reproduces the Clang-written v1 fixture byte for byte",
"[wal][serde][compat][catch2]") {
const auto expected = readWalFixture("store_block_entry_clang_v1.bin");
// Repeat so that differing stack contents between calls cannot hide nondeterminism.
for (int i = 0; i < 4; ++i) {
auto e = makeFixtureEntry();
CHECK(e.header.checksum == kFixtureChecksum);
CHECK(e.verifyChecksum());
const auto buf = e.serialize();
REQUIRE(buf.size() == expected.size());
// Bytes 44..47 are the Header's trailing alignment padding: always zero on disk.
for (size_t off = 44; off < WALEntry::Header::size(); ++off) {
INFO("header byte " << off);
CHECK(buf[off] == std::byte{0});
}
CHECK(buf == expected);
}
}
TEST_CASE("WALEntry fresh entries survive serialize/deserialize/verifyChecksum",
"[wal][serde][checksum][catch2]") {
for (uint64_t seq = 1; seq <= 64; ++seq) {
std::vector<std::byte> payload(seq % 17, std::byte{static_cast<unsigned char>(seq)});
WALEntry e(static_cast<WALEntry::OpType>(1 + seq % 8), seq, seq * 3,
std::span<const std::byte>(payload.data(), payload.size()));
INFO("seq " << seq);
REQUIRE(e.verifyChecksum());
const auto buf = e.serialize();
auto out = WALEntry::deserialize(std::span<const std::byte>(buf.data(), buf.size()));
REQUIRE(out.has_value());
CHECK(out->header.checksum == e.header.checksum);
CHECK(out->verifyChecksum());
CHECK(out->serialize() == buf);
}
}
TEST_CASE("WALEntry::deserialize accepts legacy entries with nonzero header padding",
"[wal][serde][compat][catch2]") {
// Older GCC builds left Header padding uninitialized, wrote it to disk and included it in
// the CRC. Such an entry must still recover; it is normalized to the canonical checksum.
auto buf = readWalFixture("store_block_entry_clang_v1.bin");
for (size_t off = 44; off < WALEntry::Header::size(); ++off) {
buf[off] = std::byte{static_cast<unsigned char>(0x40 + off)};
}
std::memset(buf.data() + 40, 0, sizeof(uint32_t));
const uint32_t legacyCrc = yams::common::crc32(std::span<const std::byte>(buf));
std::memcpy(buf.data() + 40, &legacyCrc, sizeof(legacyCrc));
REQUIRE(legacyCrc != kFixtureChecksum);
auto out = WALEntry::deserialize(std::span<const std::byte>(buf.data(), buf.size()));
REQUIRE(out.has_value());
CHECK(out->header.sequenceNum == 0x1122334455667788ULL);
CHECK(out->header.checksum == kFixtureChecksum);
CHECK(out->verifyChecksum());
CHECK(out->serialize() == readWalFixture("store_block_entry_clang_v1.bin"));
}
TEST_CASE("WALEntry::deserialize rejects corrupted header fields under both checksum forms",
"[wal][serde][error][catch2]") {
// Offsets 16 (timestamp), 24 (transactionId), 33 (flags), 34 (reserved).
for (size_t off : {size_t{16}, size_t{24}, size_t{33}, size_t{34}}) {
auto buf = readWalFixture("store_block_entry_clang_v1.bin");
buf[off] ^= std::byte{0x01};
INFO("header byte " << off);
auto out = WALEntry::deserialize(std::span<const std::byte>(buf.data(), buf.size()));
CHECK_FALSE(out.has_value());
}
}
TEST_CASE("WALEntry checksum ignores header padding contents", "[wal][serde][catch2]") {
// Padding carries no data; the canonical checksum treats it as zero.
auto buf = readWalFixture("store_block_entry_clang_v1.bin");
buf[45] = std::byte{0x01};
auto out = WALEntry::deserialize(std::span<const std::byte>(buf.data(), buf.size()));
REQUIRE(out.has_value());
CHECK(out->header.checksum == kFixtureChecksum);
CHECK(out->serialize() == readWalFixture("store_block_entry_clang_v1.bin"));
}
TEST_CASE("WALEntry::decodeHeader reads the fixed on-disk layout", "[wal][header][catch2]") {
const auto buf = readWalFixture("store_block_entry_clang_v1.bin");
CHECK_FALSE(WALEntry::decodeHeader(std::span<const std::byte>(buf.data(), 47)).has_value());
auto h = WALEntry::decodeHeader(std::span<const std::byte>(buf.data(), buf.size()));
REQUIRE(h.has_value());
CHECK(h->isValid());
CHECK(h->sequenceNum == 0x1122334455667788ULL);
CHECK(h->timestamp == 1700000000000000ULL);
CHECK(h->transactionId == 0x99);
CHECK(h->operation == WALEntry::OpType::StoreBlock);
CHECK(h->dataSize == sizeof(WALEntry::StoreBlockData));
CHECK(h->checksum == kFixtureChecksum);
}
TEST_CASE("TransactionData encodes 16 bytes with zeroed padding", "[wal][txn][catch2]") {
const auto enc = WALEntry::TransactionData::encode(0x0102030405060708ULL, 0x0A0B0C0DU);
REQUIRE(enc.size() == 16);
uint64_t id = 0;
uint32_t count = 0;
std::memcpy(&id, enc.data(), sizeof(id));
std::memcpy(&count, enc.data() + 8, sizeof(count));
CHECK(id == 0x0102030405060708ULL);
CHECK(count == 0x0A0B0C0DU);
for (size_t off = 12; off < enc.size(); ++off) {
INFO("byte " << off);
CHECK(enc[off] == std::byte{0});
}
}
TEST_CASE("StoreBlockData encode/decode round-trip", "[wal][store][catch2]") {
const std::string hash = "0123456789abcdef0123456789abcdef"; // 32 bytes
auto enc = WALEntry::StoreBlockData::encode(hash, 4096, 2);
auto dec = WALEntry::StoreBlockData::decode(std::span<const std::byte>(enc.data(), enc.size()));
REQUIRE(dec.has_value());
CHECK(std::memcmp(dec->hash, hash.data(), yams::HASH_SIZE) == 0);
CHECK(dec->size == 4096);
CHECK(dec->refCount == 2);
}
TEST_CASE("StoreBlockData short hash is zero-padded", "[wal][store][catch2]") {
const std::string hash = "abc";
auto enc = WALEntry::StoreBlockData::encode(hash, 1, 1);
auto dec = WALEntry::StoreBlockData::decode(std::span<const std::byte>(enc.data(), enc.size()));
REQUIRE(dec.has_value());
CHECK(dec->hash[0] == 'a');
CHECK(dec->hash[1] == 'b');
CHECK(dec->hash[2] == 'c');
for (size_t i = 3; i < yams::HASH_SIZE; ++i) {
CHECK(dec->hash[i] == 0);
}
}
TEST_CASE("StoreBlockData default refCount is 1", "[wal][store][catch2]") {
const std::string hash = "0123456789abcdef0123456789abcdef";
auto enc = WALEntry::StoreBlockData::encode(hash, 4096);
auto dec = WALEntry::StoreBlockData::decode(std::span<const std::byte>(enc.data(), enc.size()));
REQUIRE(dec.has_value());
CHECK(dec->refCount == 1);
}
TEST_CASE("StoreBlockData decode rejects too-small buffer", "[wal][store][catch2]") {
std::vector<std::byte> buf;
buf.resize(sizeof(WALEntry::StoreBlockData) - 1);
auto dec = WALEntry::StoreBlockData::decode(std::span<const std::byte>(buf.data(), buf.size()));
CHECK_FALSE(dec.has_value());
}
TEST_CASE("DeleteBlockData encode/decode round-trip", "[wal][delete][catch2]") {
const std::string hash = "0123456789abcdef0123456789abcdef";
auto enc = WALEntry::DeleteBlockData::encode(hash);
auto dec =
WALEntry::DeleteBlockData::decode(std::span<const std::byte>(enc.data(), enc.size()));
REQUIRE(dec.has_value());
CHECK(std::memcmp(dec->hash, hash.data(), yams::HASH_SIZE) == 0);
}
TEST_CASE("DeleteBlockData decode rejects too-small buffer", "[wal][delete][catch2]") {
std::vector<std::byte> buf;
buf.resize(sizeof(WALEntry::DeleteBlockData) - 1);
auto dec =
WALEntry::DeleteBlockData::decode(std::span<const std::byte>(buf.data(), buf.size()));
CHECK_FALSE(dec.has_value());
}
TEST_CASE("UpdateReferenceData encode/decode round-trip with positive delta",
"[wal][ref][catch2]") {
const std::string hash = "0123456789abcdef0123456789abcdef";
auto enc = WALEntry::UpdateReferenceData::encode(hash, 5);
auto dec =
WALEntry::UpdateReferenceData::decode(std::span<const std::byte>(enc.data(), enc.size()));
REQUIRE(dec.has_value());
CHECK(std::memcmp(dec->hash, hash.data(), yams::HASH_SIZE) == 0);
CHECK(dec->delta == 5);
}
TEST_CASE("UpdateReferenceData encode/decode round-trip with negative delta",
"[wal][ref][catch2]") {
const std::string hash = "0123456789abcdef0123456789abcdef";
auto enc = WALEntry::UpdateReferenceData::encode(hash, -3);
auto dec =
WALEntry::UpdateReferenceData::decode(std::span<const std::byte>(enc.data(), enc.size()));
REQUIRE(dec.has_value());
CHECK(std::memcmp(dec->hash, hash.data(), yams::HASH_SIZE) == 0);
CHECK(dec->delta == -3);
}
TEST_CASE("UpdateReferenceData decode rejects too-small buffer", "[wal][ref][catch2]") {
std::vector<std::byte> buf;
buf.resize(sizeof(WALEntry::UpdateReferenceData) - 1);
auto dec =
WALEntry::UpdateReferenceData::decode(std::span<const std::byte>(buf.data(), buf.size()));
CHECK_FALSE(dec.has_value());
}
TEST_CASE("UpdateMetadataData encode/decode round-trip with key/value sizes",
"[wal][meta][catch2]") {
const std::string hash = "0123456789abcdef0123456789abcdef";
const std::string key = "author";
const std::string value = "alice";
auto enc = WALEntry::UpdateMetadataData::encode(hash, key, value);
auto dec =
WALEntry::UpdateMetadataData::decode(std::span<const std::byte>(enc.data(), enc.size()));
REQUIRE(dec.has_value());
CHECK(std::memcmp(dec->hash, hash.data(), yams::HASH_SIZE) == 0);
CHECK(dec->keySize == static_cast<uint32_t>(key.size()));
CHECK(dec->valueSize == static_cast<uint32_t>(value.size()));
CHECK(enc.size() == sizeof(WALEntry::UpdateMetadataData) + key.size() + value.size());
const auto* keyPtr = enc.data() + sizeof(WALEntry::UpdateMetadataData);
const auto* valPtr = keyPtr + key.size();
CHECK(std::memcmp(keyPtr, key.data(), key.size()) == 0);
CHECK(std::memcmp(valPtr, value.data(), value.size()) == 0);
}
TEST_CASE("UpdateMetadataData decode rejects truncated key/value region", "[wal][meta][catch2]") {
const std::string hash = "0123456789abcdef0123456789abcdef";
const std::string key = "author";
const std::string value = "alice";
auto enc = WALEntry::UpdateMetadataData::encode(hash, key, value);
REQUIRE(enc.size() > sizeof(WALEntry::UpdateMetadataData));
enc.resize(enc.size() - 1);
auto dec =
WALEntry::UpdateMetadataData::decode(std::span<const std::byte>(enc.data(), enc.size()));
CHECK_FALSE(dec.has_value());
}
TEST_CASE("UpdateMetadataData decode rejects too-small buffer", "[wal][meta][catch2]") {
std::vector<std::byte> buf;
buf.resize(sizeof(WALEntry::UpdateMetadataData) - 1);
auto dec =
WALEntry::UpdateMetadataData::decode(std::span<const std::byte>(buf.data(), buf.size()));
CHECK_FALSE(dec.has_value());
}
TEST_CASE("TransactionData encode/decode round-trip", "[wal][txn][catch2]") {
auto enc = WALEntry::TransactionData::encode(12345, 10);
auto dec =
WALEntry::TransactionData::decode(std::span<const std::byte>(enc.data(), enc.size()));
REQUIRE(dec.has_value());
CHECK(dec->transactionId == 12345);
CHECK(dec->participantCount == 10);
}
TEST_CASE("TransactionData default count is 0", "[wal][txn][catch2]") {
auto enc = WALEntry::TransactionData::encode(12345);
auto dec =
WALEntry::TransactionData::decode(std::span<const std::byte>(enc.data(), enc.size()));
REQUIRE(dec.has_value());
CHECK(dec->transactionId == 12345);
CHECK(dec->participantCount == 0);
}
TEST_CASE("TransactionData decode rejects too-small buffer", "[wal][txn][catch2]") {
std::vector<std::byte> buf;
buf.resize(sizeof(WALEntry::TransactionData) - 1);
auto dec =
WALEntry::TransactionData::decode(std::span<const std::byte>(buf.data(), buf.size()));
CHECK_FALSE(dec.has_value());
}
TEST_CASE("CheckpointData encode/decode round-trip", "[wal][checkpoint][catch2]") {
auto enc = WALEntry::CheckpointData::encode(500, 1700000000);
auto dec = WALEntry::CheckpointData::decode(std::span<const std::byte>(enc.data(), enc.size()));
REQUIRE(dec.has_value());
CHECK(dec->sequenceNum == 500);
CHECK(dec->timestamp == 1700000000);
}
TEST_CASE("CheckpointData decode rejects too-small buffer", "[wal][checkpoint][catch2]") {
std::vector<std::byte> buf;
buf.resize(sizeof(WALEntry::CheckpointData) - 1);
auto dec = WALEntry::CheckpointData::decode(std::span<const std::byte>(buf.data(), buf.size()));
CHECK_FALSE(dec.has_value());
}
TEST_CASE("OpType stream operator prints all named values and Unknown", "[wal][stream][catch2]") {
struct Expected {
WALEntry::OpType op;
const char* s;
};
const Expected expected[] = {
{WALEntry::OpType::BeginTransaction, "BeginTransaction"},
{WALEntry::OpType::StoreBlock, "StoreBlock"},
{WALEntry::OpType::DeleteBlock, "DeleteBlock"},
{WALEntry::OpType::UpdateReference, "UpdateReference"},
{WALEntry::OpType::UpdateMetadata, "UpdateMetadata"},
{WALEntry::OpType::CommitTransaction, "CommitTransaction"},
{WALEntry::OpType::Rollback, "Rollback"},
{WALEntry::OpType::Checkpoint, "Checkpoint"},
};
for (const auto& e : expected) {
std::ostringstream oss;
oss << e.op;
CHECK(oss.str() == e.s);
}
{
std::ostringstream oss;
oss << static_cast<WALEntry::OpType>(9);
CHECK(oss.str() == "Unknown(9)");
}
}
TEST_CASE("WALEntry stream includes key fields", "[wal][stream][catch2]") {
WALEntry e(WALEntry::OpType::CommitTransaction, 123, 456);
std::ostringstream oss;
oss << e;
const auto s = oss.str();
CHECK(s.find("WALEntry{") != std::string::npos);
CHECK(s.find("seq=") != std::string::npos);
CHECK(s.find("txn=") != std::string::npos);
}