// SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // A struct that changed without a version bump must be refused with the // column difference in the message; so must files that are not tables, have // a damaged header, or have both commit records corrupted. import Crafter.DB; import std; using namespace Crafter; namespace fs = std::filesystem; namespace { int failures = 0; void Check(bool condition, std::string_view what) { if (!condition) { std::println("FAIL: {}", what); ++failures; } } struct Original { std::string name; std::int64_t amount; static constexpr std::uint32_t version = 1; }; struct AddedColumn { // same version, one more member std::string name; std::int64_t amount; std::string reference; static constexpr std::uint32_t version = 1; }; struct RenamedColumn { // same version, same layout, different name std::string name; std::int64_t total; static constexpr std::uint32_t version = 1; }; struct RetypedColumn { // same version, int64 -> int32 std::string name; std::int32_t amount; static constexpr std::uint32_t version = 1; }; struct NowOrdered { // same columns, but ordered std::string name; std::int64_t amount; static constexpr std::uint32_t version = 1; static constexpr auto orderedBy = &NowOrdered::amount; }; struct IntBlob { std::vector values; static constexpr std::uint32_t version = 1; }; struct FloatBlob { // same bytes per element, different meaning std::vector values; static constexpr std::uint32_t version = 1; }; template std::string ExpectSchemaError(const fs::path& path, std::string_view what) { try { DB::Table table(path, { .readOnly = true }); } catch (const DB::SchemaError& error) { return error.what(); } catch (const std::exception& error) { std::println("FAIL: {} threw {} instead of SchemaError", what, error.what()); ++failures; return ""; } std::println("FAIL: {} did not throw", what); ++failures; return ""; } void CheckOrFail(bool condition, std::string_view what) { if (!condition) { std::println("FAIL: {}", what); ++failures; } } template bool Throws(const fs::path& path) { try { DB::Table table(path, { .readOnly = true }); } catch (const E&) { return true; } catch (...) { return false; } return false; } void Patch(const fs::path& path, std::uint64_t offset, std::span bytes) { std::fstream file(path, std::ios::in | std::ios::out | std::ios::binary); file.seekp(static_cast(offset)); file.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); } } int main() { const fs::path dir = fs::temp_directory_path() / "crafter-db-schema"; fs::remove_all(dir); const fs::path path = dir / "o.cdb"; { DB::Table table(path); table.Append({ "x", 1 }); } const std::string added = ExpectSchemaError(path, "added member"); Check(added.find("reference") != std::string::npos && added.find("without bumping") != std::string::npos, std::format("message names the new column: {}", added)); const std::string renamed = ExpectSchemaError(path, "renamed member"); Check(renamed.find("total") != std::string::npos && renamed.find("amount") != std::string::npos, std::format("message shows both names: {}", renamed)); const std::string retyped = ExpectSchemaError(path, "retyped member"); Check(retyped.find("i32") != std::string::npos && retyped.find("i64") != std::string::npos, std::format("message shows both kinds: {}", retyped)); const std::string ordered = ExpectSchemaError(path, "orderedBy added"); Check(ordered.find("orderedBy") != std::string::npos && ordered.find("amount") != std::string::npos, std::format("message names the ordered column change: {}", ordered)); { const DB::Table fine(path, { .readOnly = true }); Check(fine.Size() == 1, "the original struct still opens"); } { const fs::path blob = dir / "blob.cdb"; { DB::Table ints(blob); ints.Append({ { 1, 2, 3, 1065353216 } }); } const std::string retypedBlob = ExpectSchemaError(blob, "blob element type changed"); Check(!retypedBlob.empty(), "vector -> vector is refused"); } { // CreateNew on an existing file must refuse without touching it. const DB::Inspection before = DB::TableFile::Inspect(path); bool threw = false; try { DB::Table again(path, { .mode = DB::OpenMode::CreateNew }); } catch (const DB::IOError& error) { threw = error.code == std::errc::file_exists; } Check(threw, "CreateNew on an existing table throws file_exists"); const DB::Inspection after = DB::TableFile::Inspect(path); CheckOrFail(before.commit.commitSeq == after.commit.commitSeq, "CreateNew wrote nothing to the existing file"); } // A descriptor whose presenceOffset points outside the row. const fs::path presence = dir / "presence.cdb"; fs::copy_file(path, presence); { const DB::Inspection info = DB::TableFile::Inspect(presence); // descriptor i starts at 4096 + 16 + i * 24; presenceOffset is at +6 const std::uint16_t bogus = 0xFFF0; Patch(presence, 4096 + 16 + 1 * 24 + 6, std::as_bytes(std::span(&bogus, 1))); Check(info.schema.columns.size() == 3, "fixture has three columns"); } Check(Throws(presence), "an out-of-row presenceOffset throws CorruptError"); // Damaged magic. const fs::path magic = dir / "magic.cdb"; fs::copy_file(path, magic); const std::array junk = { std::byte{ 'X' }, std::byte{ 'X' } }; Patch(magic, 0, junk); Check(Throws(magic), "bad magic throws CorruptError"); // Both commit records damaged (flip a byte in each slot's sequence number). const fs::path commits = dir / "commits.cdb"; fs::copy_file(path, commits); const std::array flip = { std::byte{ 0xFF } }; Patch(commits, 3072 + 1, flip); Patch(commits, 3584 + 1, flip); Check(Throws(commits), "two damaged commit records throw CorruptError"); // Not a table at all. const fs::path random = dir / "random.cdb"; { std::ofstream out(random, std::ios::binary); std::string bytes(8192, '\0'); for (std::size_t i = 0; i < bytes.size(); i++) bytes[i] = static_cast(i * 31 + 7); out.write(bytes.data(), static_cast(bytes.size())); } Check(Throws(random), "random bytes throw CorruptError"); // Too short. const fs::path tiny = dir / "tiny.cdb"; { std::ofstream(tiny) << "short"; } Check(Throws(tiny), "a tiny file throws CorruptError"); // Truncated below the committed length. const fs::path truncated = dir / "truncated.cdb"; fs::copy_file(path, truncated); fs::resize_file(truncated, 4096 * 2); Check(Throws(truncated), "a truncated file throws CorruptError"); fs::remove_all(dir); if (failures == 0) std::println("ok"); return failures == 0 ? 0 : 1; }