Path: blob/master/src/util-tests/object_archive_tests.cpp
14138 views
// SPDX-FileCopyrightText: 2019-2026 Connor McLaughlin <[email protected]>1// SPDX-License-Identifier: CC-BY-NC-ND-4.023#include "util/object_archive.h"45#include "common/error.h"6#include "common/file_system.h"7#include "common/path.h"8#include "common/scoped_guard.h"9#include "common/types.h"1011#include <gtest/gtest.h>1213#include <algorithm>14#include <cstring>15#include <fmt/core.h>16#include <numeric>17#include <vector>1819namespace {2021/// RAII helper that creates a pair of temporary files suitable for use as ObjectArchive index/blob22/// files. The files are deleted when the helper goes out of scope.23class TempArchiveFiles24{25public:26TempArchiveFiles()27{28const std::string base = Path::Combine(FileSystem::GetWorkingDirectory(), "duckstation_oa_test");29m_index_file = FileSystem::OpenTemporaryCFile(base, &m_index_path);30m_blob_file = FileSystem::OpenTemporaryCFile(base, &m_blob_path);31}3233~TempArchiveFiles()34{35if (m_index_file)36std::fclose(m_index_file);37if (m_blob_file)38std::fclose(m_blob_file);39if (!m_index_path.empty())40FileSystem::DeleteFile(m_index_path.c_str());41if (!m_blob_path.empty())42FileSystem::DeleteFile(m_blob_path.c_str());43}4445bool IsValid() const { return m_index_file != nullptr && m_blob_file != nullptr; }4647/// Releases ownership of the FILE pointers (caller takes ownership).48std::pair<std::FILE*, std::FILE*> Release()49{50auto result = std::make_pair(m_index_file, m_blob_file);51m_index_file = nullptr;52m_blob_file = nullptr;53return result;54}5556/// Reopens the files for reading+writing (e.g. after an archive has closed them).57bool Reopen()58{59m_index_file = FileSystem::OpenCFile(m_index_path.c_str(), "r+b");60m_blob_file = FileSystem::OpenCFile(m_blob_path.c_str(), "r+b");61return IsValid();62}6364std::FILE* IndexFile() const { return m_index_file; }65std::FILE* BlobFile() const { return m_blob_file; }66const std::string& IndexPath() const { return m_index_path; }67const std::string& BlobPath() const { return m_blob_path; }6869private:70std::FILE* m_index_file = nullptr;71std::FILE* m_blob_file = nullptr;72std::string m_index_path;73std::string m_blob_path;74};7576} // namespace7778static constexpr u32 TEST_VERSION = 1;7980// ---------------------------------------------------------------------------81// Basic lifecycle82// ---------------------------------------------------------------------------8384TEST(ObjectArchive, CreateAndOpen)85{86TempArchiveFiles files;87ASSERT_TRUE(files.IsValid());8889ObjectArchive archive;90auto [idx, blob] = files.Release();91Error error;92ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();93EXPECT_TRUE(archive.IsOpen());94EXPECT_EQ(archive.GetSize(), 0u);95}9697TEST(ObjectArchive, InsertToClosedArchive)98{99ObjectArchive archive;100EXPECT_FALSE(archive.IsOpen());101102const u8 data[] = {1, 2, 3};103Error error;104EXPECT_FALSE(archive.Insert("key", data, sizeof(data), ObjectArchive::CompressType::Uncompressed, &error));105}106107TEST(ObjectArchive, EmptyKeyRejected)108{109TempArchiveFiles files;110ASSERT_TRUE(files.IsValid());111112ObjectArchive archive;113auto [idx, blob] = files.Release();114Error error;115ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();116117const u8 data[] = {0xAA};118EXPECT_FALSE(archive.Insert("", std::span<const u8>(data), ObjectArchive::CompressType::Uncompressed, &error));119}120121// ---------------------------------------------------------------------------122// Round-trip (uncompressed)123// ---------------------------------------------------------------------------124125TEST(ObjectArchive, InsertAndLookupRoundTrip)126{127TempArchiveFiles files;128ASSERT_TRUE(files.IsValid());129130ObjectArchive archive;131auto [idx, blob] = files.Release();132Error error;133ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();134135const u8 payload[] = {0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04};136ASSERT_TRUE(137archive.Insert("test_key", payload, sizeof(payload), ObjectArchive::CompressType::Uncompressed, &error))138<< error.GetDescription();139140auto result = archive.Lookup("test_key", &error);141ASSERT_TRUE(result.has_value()) << error.GetDescription();142ASSERT_EQ(result->size(), sizeof(payload));143EXPECT_EQ(std::memcmp(result->data(), payload, sizeof(payload)), 0);144}145146// ---------------------------------------------------------------------------147// Round-trip (compressed)148// ---------------------------------------------------------------------------149150TEST(ObjectArchive, InsertAndLookupCompressed)151{152TempArchiveFiles files;153ASSERT_TRUE(files.IsValid());154155ObjectArchive archive;156auto [idx, blob] = files.Release();157Error error;158ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();159160// Create a payload large enough for compression to be meaningful.161std::vector<u8> payload(4096);162for (size_t i = 0; i < payload.size(); i++)163payload[i] = static_cast<u8>(i & 0xFF);164165ASSERT_TRUE(archive.Insert("compressed_key", std::span<const u8>(payload),166ObjectArchive::CompressType::Zstandard, &error))167<< error.GetDescription();168169auto result = archive.Lookup("compressed_key", &error);170ASSERT_TRUE(result.has_value()) << error.GetDescription();171ASSERT_EQ(result->size(), payload.size());172EXPECT_EQ(std::memcmp(result->data(), payload.data(), payload.size()), 0);173}174175// ---------------------------------------------------------------------------176// Duplicate key rejection177// ---------------------------------------------------------------------------178179TEST(ObjectArchive, DuplicateKeyRejected)180{181TempArchiveFiles files;182ASSERT_TRUE(files.IsValid());183184ObjectArchive archive;185auto [idx, blob] = files.Release();186Error error;187ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();188189const u8 data1[] = {1};190const u8 data2[] = {2};191ASSERT_TRUE(192archive.Insert("dup", std::span<const u8>(data1), ObjectArchive::CompressType::Uncompressed, &error))193<< error.GetDescription();194EXPECT_FALSE(195archive.Insert("dup", std::span<const u8>(data2), ObjectArchive::CompressType::Uncompressed, &error));196}197198// ---------------------------------------------------------------------------199// Missing key200// ---------------------------------------------------------------------------201202TEST(ObjectArchive, MissingKeyReturnsNullopt)203{204TempArchiveFiles files;205ASSERT_TRUE(files.IsValid());206207ObjectArchive archive;208auto [idx, blob] = files.Release();209Error error;210ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();211212// Insert one key so the index is non-empty.213const u8 data[] = {0x42};214ASSERT_TRUE(215archive.Insert("exists", std::span<const u8>(data), ObjectArchive::CompressType::Uncompressed, &error))216<< error.GetDescription();217218auto result = archive.Lookup("does_not_exist", &error);219EXPECT_FALSE(result.has_value());220}221222// ---------------------------------------------------------------------------223// Multiple keys with correct isolation224// ---------------------------------------------------------------------------225226TEST(ObjectArchive, MultipleKeysCorrectIsolation)227{228TempArchiveFiles files;229ASSERT_TRUE(files.IsValid());230231ObjectArchive archive;232auto [idx, blob] = files.Release();233Error error;234ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();235236const u8 a_data[] = {0xAA};237const u8 m_data[] = {0xBB, 0xCC};238const u8 z_data[] = {0xDD, 0xEE, 0xFF};239240ASSERT_TRUE(241archive.Insert("aaa", std::span<const u8>(a_data), ObjectArchive::CompressType::Uncompressed, &error))242<< error.GetDescription();243ASSERT_TRUE(244archive.Insert("mmm", std::span<const u8>(m_data), ObjectArchive::CompressType::Uncompressed, &error))245<< error.GetDescription();246ASSERT_TRUE(247archive.Insert("zzz", std::span<const u8>(z_data), ObjectArchive::CompressType::Uncompressed, &error))248<< error.GetDescription();249250auto ra = archive.Lookup("aaa", &error);251ASSERT_TRUE(ra.has_value()) << error.GetDescription();252ASSERT_EQ(ra->size(), sizeof(a_data));253EXPECT_EQ((*ra)[0], 0xAA);254255auto rm = archive.Lookup("mmm", &error);256ASSERT_TRUE(rm.has_value()) << error.GetDescription();257ASSERT_EQ(rm->size(), sizeof(m_data));258EXPECT_EQ((*rm)[0], 0xBB);259EXPECT_EQ((*rm)[1], 0xCC);260261auto rz = archive.Lookup("zzz", &error);262ASSERT_TRUE(rz.has_value()) << error.GetDescription();263ASSERT_EQ(rz->size(), sizeof(z_data));264EXPECT_EQ((*rz)[0], 0xDD);265}266267// ---------------------------------------------------------------------------268// Clear and re-insert269// ---------------------------------------------------------------------------270271TEST(ObjectArchive, ClearAndReinsert)272{273TempArchiveFiles files;274ASSERT_TRUE(files.IsValid());275276ObjectArchive archive;277auto [idx, blob] = files.Release();278Error error;279ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();280281const u8 data1[] = {0x11, 0x22};282ASSERT_TRUE(283archive.Insert("key1", std::span<const u8>(data1), ObjectArchive::CompressType::Uncompressed, &error))284<< error.GetDescription();285EXPECT_EQ(archive.GetSize(), 1u);286287ASSERT_TRUE(archive.Clear(&error)) << error.GetDescription();288EXPECT_EQ(archive.GetSize(), 0u);289290// After clear, lookup should fail.291auto result = archive.Lookup("key1", &error);292EXPECT_FALSE(result.has_value());293294// Re-insertion should succeed.295const u8 data2[] = {0x33, 0x44, 0x55};296ASSERT_TRUE(297archive.Insert("key2", std::span<const u8>(data2), ObjectArchive::CompressType::Uncompressed, &error))298<< error.GetDescription();299EXPECT_EQ(archive.GetSize(), 1u);300301auto result2 = archive.Lookup("key2", &error);302ASSERT_TRUE(result2.has_value()) << error.GetDescription();303ASSERT_EQ(result2->size(), sizeof(data2));304EXPECT_EQ(std::memcmp(result2->data(), data2, sizeof(data2)), 0);305}306307// ---------------------------------------------------------------------------308// Close and reopen (persistence)309// ---------------------------------------------------------------------------310311TEST(ObjectArchive, CloseAndReopenPersistence)312{313TempArchiveFiles files;314ASSERT_TRUE(files.IsValid());315316const u8 payload[] = {0xCA, 0xFE, 0xBA, 0xBE};317318// Create and insert.319{320ObjectArchive archive;321auto [idx, blob] = files.Release();322Error error;323ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();324ASSERT_TRUE(archive.Insert("persist", std::span<const u8>(payload),325ObjectArchive::CompressType::Uncompressed, &error))326<< error.GetDescription();327archive.Close();328}329330// Reopen and verify.331ASSERT_TRUE(files.Reopen());332{333ObjectArchive archive;334auto [idx, blob] = files.Release();335Error error;336ASSERT_TRUE(archive.OpenFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();337EXPECT_EQ(archive.GetSize(), 1u);338339auto result = archive.Lookup("persist", &error);340ASSERT_TRUE(result.has_value()) << error.GetDescription();341ASSERT_EQ(result->size(), sizeof(payload));342EXPECT_EQ(std::memcmp(result->data(), payload, sizeof(payload)), 0);343}344}345346// ---------------------------------------------------------------------------347// Version mismatch: open with wrong version, then create fresh348// ---------------------------------------------------------------------------349350TEST(ObjectArchive, VersionMismatchCreatesEmpty)351{352TempArchiveFiles files;353ASSERT_TRUE(files.IsValid());354355// Create an archive at version 1 with some data.356{357ObjectArchive archive;358auto [idx, blob] = files.Release();359Error error;360ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();361362const u8 data[] = {0x01, 0x02};363ASSERT_TRUE(364archive.Insert("v1_key", std::span<const u8>(data), ObjectArchive::CompressType::Uncompressed, &error))365<< error.GetDescription();366archive.Close();367}368369// Attempt to open with a different version — should fail.370ASSERT_TRUE(files.Reopen());371{372ObjectArchive archive;373auto [idx, blob] = files.Release();374Error error;375EXPECT_FALSE(archive.OpenFile(idx, blob, TEST_VERSION + 1, &error));376EXPECT_FALSE(archive.IsOpen());377}378379// Now create a fresh archive at the new version — should be empty.380ASSERT_TRUE(files.Reopen());381{382ObjectArchive archive;383auto [idx, blob] = files.Release();384Error error;385ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION + 1, &error)) << error.GetDescription();386EXPECT_TRUE(archive.IsOpen());387EXPECT_EQ(archive.GetSize(), 0u);388}389}390391// ---------------------------------------------------------------------------392// Large number of objects inserted and looked up in unsorted order393// ---------------------------------------------------------------------------394395TEST(ObjectArchive, LargeNumberOfObjectsUnsorted)396{397TempArchiveFiles files;398ASSERT_TRUE(files.IsValid());399400ObjectArchive archive;401auto [idx, blob] = files.Release();402Error error;403ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();404405static constexpr size_t NUM_OBJECTS = 200;406407// Build keys in a deliberately unsorted order by shuffling indices.408std::vector<size_t> order(NUM_OBJECTS);409std::iota(order.begin(), order.end(), 0u);410411// Simple deterministic shuffle (swap i with i*7+3 mod N).412for (size_t i = 0; i < NUM_OBJECTS; i++)413{414const size_t j = (i * 7 + 3) % NUM_OBJECTS;415std::swap(order[i], order[j]);416}417418// Insert in shuffled order.419for (const size_t i : order)420{421const std::string key = fmt::format("object_{:04}", i);422// Each object's payload is 8 bytes encoding its index.423u8 payload[8];424std::memset(payload, 0, sizeof(payload));425std::memcpy(payload, &i, sizeof(i));426427ASSERT_TRUE(archive.Insert(key, payload, sizeof(payload), ObjectArchive::CompressType::Uncompressed, &error))428<< "Failed to insert key '" << key << "': " << error.GetDescription();429}430431EXPECT_EQ(archive.GetSize(), NUM_OBJECTS);432433// Look up every object in a different shuffled order.434std::vector<size_t> lookup_order(NUM_OBJECTS);435std::iota(lookup_order.begin(), lookup_order.end(), 0u);436for (size_t i = 0; i < NUM_OBJECTS; i++)437{438const size_t j = (i * 13 + 7) % NUM_OBJECTS;439std::swap(lookup_order[i], lookup_order[j]);440}441442for (const size_t i : lookup_order)443{444const std::string key = fmt::format("object_{:04}", i);445auto result = archive.Lookup(key, &error);446ASSERT_TRUE(result.has_value()) << "Lookup failed for key '" << key << "': " << error.GetDescription();447ASSERT_EQ(result->size(), 8u);448449size_t stored_index = 0;450std::memcpy(&stored_index, result->data(), sizeof(stored_index));451EXPECT_EQ(stored_index, i) << "Data mismatch for key '" << key << "'";452}453}454455456