Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
stenzek
GitHub Repository: stenzek/duckstation
Path: blob/master/src/util-tests/object_archive_tests.cpp
14138 views
1
// SPDX-FileCopyrightText: 2019-2026 Connor McLaughlin <[email protected]>
2
// SPDX-License-Identifier: CC-BY-NC-ND-4.0
3
4
#include "util/object_archive.h"
5
6
#include "common/error.h"
7
#include "common/file_system.h"
8
#include "common/path.h"
9
#include "common/scoped_guard.h"
10
#include "common/types.h"
11
12
#include <gtest/gtest.h>
13
14
#include <algorithm>
15
#include <cstring>
16
#include <fmt/core.h>
17
#include <numeric>
18
#include <vector>
19
20
namespace {
21
22
/// RAII helper that creates a pair of temporary files suitable for use as ObjectArchive index/blob
23
/// files. The files are deleted when the helper goes out of scope.
24
class TempArchiveFiles
25
{
26
public:
27
TempArchiveFiles()
28
{
29
const std::string base = Path::Combine(FileSystem::GetWorkingDirectory(), "duckstation_oa_test");
30
m_index_file = FileSystem::OpenTemporaryCFile(base, &m_index_path);
31
m_blob_file = FileSystem::OpenTemporaryCFile(base, &m_blob_path);
32
}
33
34
~TempArchiveFiles()
35
{
36
if (m_index_file)
37
std::fclose(m_index_file);
38
if (m_blob_file)
39
std::fclose(m_blob_file);
40
if (!m_index_path.empty())
41
FileSystem::DeleteFile(m_index_path.c_str());
42
if (!m_blob_path.empty())
43
FileSystem::DeleteFile(m_blob_path.c_str());
44
}
45
46
bool IsValid() const { return m_index_file != nullptr && m_blob_file != nullptr; }
47
48
/// Releases ownership of the FILE pointers (caller takes ownership).
49
std::pair<std::FILE*, std::FILE*> Release()
50
{
51
auto result = std::make_pair(m_index_file, m_blob_file);
52
m_index_file = nullptr;
53
m_blob_file = nullptr;
54
return result;
55
}
56
57
/// Reopens the files for reading+writing (e.g. after an archive has closed them).
58
bool Reopen()
59
{
60
m_index_file = FileSystem::OpenCFile(m_index_path.c_str(), "r+b");
61
m_blob_file = FileSystem::OpenCFile(m_blob_path.c_str(), "r+b");
62
return IsValid();
63
}
64
65
std::FILE* IndexFile() const { return m_index_file; }
66
std::FILE* BlobFile() const { return m_blob_file; }
67
const std::string& IndexPath() const { return m_index_path; }
68
const std::string& BlobPath() const { return m_blob_path; }
69
70
private:
71
std::FILE* m_index_file = nullptr;
72
std::FILE* m_blob_file = nullptr;
73
std::string m_index_path;
74
std::string m_blob_path;
75
};
76
77
} // namespace
78
79
static constexpr u32 TEST_VERSION = 1;
80
81
// ---------------------------------------------------------------------------
82
// Basic lifecycle
83
// ---------------------------------------------------------------------------
84
85
TEST(ObjectArchive, CreateAndOpen)
86
{
87
TempArchiveFiles files;
88
ASSERT_TRUE(files.IsValid());
89
90
ObjectArchive archive;
91
auto [idx, blob] = files.Release();
92
Error error;
93
ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();
94
EXPECT_TRUE(archive.IsOpen());
95
EXPECT_EQ(archive.GetSize(), 0u);
96
}
97
98
TEST(ObjectArchive, InsertToClosedArchive)
99
{
100
ObjectArchive archive;
101
EXPECT_FALSE(archive.IsOpen());
102
103
const u8 data[] = {1, 2, 3};
104
Error error;
105
EXPECT_FALSE(archive.Insert("key", data, sizeof(data), ObjectArchive::CompressType::Uncompressed, &error));
106
}
107
108
TEST(ObjectArchive, EmptyKeyRejected)
109
{
110
TempArchiveFiles files;
111
ASSERT_TRUE(files.IsValid());
112
113
ObjectArchive archive;
114
auto [idx, blob] = files.Release();
115
Error error;
116
ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();
117
118
const u8 data[] = {0xAA};
119
EXPECT_FALSE(archive.Insert("", std::span<const u8>(data), ObjectArchive::CompressType::Uncompressed, &error));
120
}
121
122
// ---------------------------------------------------------------------------
123
// Round-trip (uncompressed)
124
// ---------------------------------------------------------------------------
125
126
TEST(ObjectArchive, InsertAndLookupRoundTrip)
127
{
128
TempArchiveFiles files;
129
ASSERT_TRUE(files.IsValid());
130
131
ObjectArchive archive;
132
auto [idx, blob] = files.Release();
133
Error error;
134
ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();
135
136
const u8 payload[] = {0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04};
137
ASSERT_TRUE(
138
archive.Insert("test_key", payload, sizeof(payload), ObjectArchive::CompressType::Uncompressed, &error))
139
<< error.GetDescription();
140
141
auto result = archive.Lookup("test_key", &error);
142
ASSERT_TRUE(result.has_value()) << error.GetDescription();
143
ASSERT_EQ(result->size(), sizeof(payload));
144
EXPECT_EQ(std::memcmp(result->data(), payload, sizeof(payload)), 0);
145
}
146
147
// ---------------------------------------------------------------------------
148
// Round-trip (compressed)
149
// ---------------------------------------------------------------------------
150
151
TEST(ObjectArchive, InsertAndLookupCompressed)
152
{
153
TempArchiveFiles files;
154
ASSERT_TRUE(files.IsValid());
155
156
ObjectArchive archive;
157
auto [idx, blob] = files.Release();
158
Error error;
159
ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();
160
161
// Create a payload large enough for compression to be meaningful.
162
std::vector<u8> payload(4096);
163
for (size_t i = 0; i < payload.size(); i++)
164
payload[i] = static_cast<u8>(i & 0xFF);
165
166
ASSERT_TRUE(archive.Insert("compressed_key", std::span<const u8>(payload),
167
ObjectArchive::CompressType::Zstandard, &error))
168
<< error.GetDescription();
169
170
auto result = archive.Lookup("compressed_key", &error);
171
ASSERT_TRUE(result.has_value()) << error.GetDescription();
172
ASSERT_EQ(result->size(), payload.size());
173
EXPECT_EQ(std::memcmp(result->data(), payload.data(), payload.size()), 0);
174
}
175
176
// ---------------------------------------------------------------------------
177
// Duplicate key rejection
178
// ---------------------------------------------------------------------------
179
180
TEST(ObjectArchive, DuplicateKeyRejected)
181
{
182
TempArchiveFiles files;
183
ASSERT_TRUE(files.IsValid());
184
185
ObjectArchive archive;
186
auto [idx, blob] = files.Release();
187
Error error;
188
ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();
189
190
const u8 data1[] = {1};
191
const u8 data2[] = {2};
192
ASSERT_TRUE(
193
archive.Insert("dup", std::span<const u8>(data1), ObjectArchive::CompressType::Uncompressed, &error))
194
<< error.GetDescription();
195
EXPECT_FALSE(
196
archive.Insert("dup", std::span<const u8>(data2), ObjectArchive::CompressType::Uncompressed, &error));
197
}
198
199
// ---------------------------------------------------------------------------
200
// Missing key
201
// ---------------------------------------------------------------------------
202
203
TEST(ObjectArchive, MissingKeyReturnsNullopt)
204
{
205
TempArchiveFiles files;
206
ASSERT_TRUE(files.IsValid());
207
208
ObjectArchive archive;
209
auto [idx, blob] = files.Release();
210
Error error;
211
ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();
212
213
// Insert one key so the index is non-empty.
214
const u8 data[] = {0x42};
215
ASSERT_TRUE(
216
archive.Insert("exists", std::span<const u8>(data), ObjectArchive::CompressType::Uncompressed, &error))
217
<< error.GetDescription();
218
219
auto result = archive.Lookup("does_not_exist", &error);
220
EXPECT_FALSE(result.has_value());
221
}
222
223
// ---------------------------------------------------------------------------
224
// Multiple keys with correct isolation
225
// ---------------------------------------------------------------------------
226
227
TEST(ObjectArchive, MultipleKeysCorrectIsolation)
228
{
229
TempArchiveFiles files;
230
ASSERT_TRUE(files.IsValid());
231
232
ObjectArchive archive;
233
auto [idx, blob] = files.Release();
234
Error error;
235
ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();
236
237
const u8 a_data[] = {0xAA};
238
const u8 m_data[] = {0xBB, 0xCC};
239
const u8 z_data[] = {0xDD, 0xEE, 0xFF};
240
241
ASSERT_TRUE(
242
archive.Insert("aaa", std::span<const u8>(a_data), ObjectArchive::CompressType::Uncompressed, &error))
243
<< error.GetDescription();
244
ASSERT_TRUE(
245
archive.Insert("mmm", std::span<const u8>(m_data), ObjectArchive::CompressType::Uncompressed, &error))
246
<< error.GetDescription();
247
ASSERT_TRUE(
248
archive.Insert("zzz", std::span<const u8>(z_data), ObjectArchive::CompressType::Uncompressed, &error))
249
<< error.GetDescription();
250
251
auto ra = archive.Lookup("aaa", &error);
252
ASSERT_TRUE(ra.has_value()) << error.GetDescription();
253
ASSERT_EQ(ra->size(), sizeof(a_data));
254
EXPECT_EQ((*ra)[0], 0xAA);
255
256
auto rm = archive.Lookup("mmm", &error);
257
ASSERT_TRUE(rm.has_value()) << error.GetDescription();
258
ASSERT_EQ(rm->size(), sizeof(m_data));
259
EXPECT_EQ((*rm)[0], 0xBB);
260
EXPECT_EQ((*rm)[1], 0xCC);
261
262
auto rz = archive.Lookup("zzz", &error);
263
ASSERT_TRUE(rz.has_value()) << error.GetDescription();
264
ASSERT_EQ(rz->size(), sizeof(z_data));
265
EXPECT_EQ((*rz)[0], 0xDD);
266
}
267
268
// ---------------------------------------------------------------------------
269
// Clear and re-insert
270
// ---------------------------------------------------------------------------
271
272
TEST(ObjectArchive, ClearAndReinsert)
273
{
274
TempArchiveFiles files;
275
ASSERT_TRUE(files.IsValid());
276
277
ObjectArchive archive;
278
auto [idx, blob] = files.Release();
279
Error error;
280
ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();
281
282
const u8 data1[] = {0x11, 0x22};
283
ASSERT_TRUE(
284
archive.Insert("key1", std::span<const u8>(data1), ObjectArchive::CompressType::Uncompressed, &error))
285
<< error.GetDescription();
286
EXPECT_EQ(archive.GetSize(), 1u);
287
288
ASSERT_TRUE(archive.Clear(&error)) << error.GetDescription();
289
EXPECT_EQ(archive.GetSize(), 0u);
290
291
// After clear, lookup should fail.
292
auto result = archive.Lookup("key1", &error);
293
EXPECT_FALSE(result.has_value());
294
295
// Re-insertion should succeed.
296
const u8 data2[] = {0x33, 0x44, 0x55};
297
ASSERT_TRUE(
298
archive.Insert("key2", std::span<const u8>(data2), ObjectArchive::CompressType::Uncompressed, &error))
299
<< error.GetDescription();
300
EXPECT_EQ(archive.GetSize(), 1u);
301
302
auto result2 = archive.Lookup("key2", &error);
303
ASSERT_TRUE(result2.has_value()) << error.GetDescription();
304
ASSERT_EQ(result2->size(), sizeof(data2));
305
EXPECT_EQ(std::memcmp(result2->data(), data2, sizeof(data2)), 0);
306
}
307
308
// ---------------------------------------------------------------------------
309
// Close and reopen (persistence)
310
// ---------------------------------------------------------------------------
311
312
TEST(ObjectArchive, CloseAndReopenPersistence)
313
{
314
TempArchiveFiles files;
315
ASSERT_TRUE(files.IsValid());
316
317
const u8 payload[] = {0xCA, 0xFE, 0xBA, 0xBE};
318
319
// Create and insert.
320
{
321
ObjectArchive archive;
322
auto [idx, blob] = files.Release();
323
Error error;
324
ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();
325
ASSERT_TRUE(archive.Insert("persist", std::span<const u8>(payload),
326
ObjectArchive::CompressType::Uncompressed, &error))
327
<< error.GetDescription();
328
archive.Close();
329
}
330
331
// Reopen and verify.
332
ASSERT_TRUE(files.Reopen());
333
{
334
ObjectArchive archive;
335
auto [idx, blob] = files.Release();
336
Error error;
337
ASSERT_TRUE(archive.OpenFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();
338
EXPECT_EQ(archive.GetSize(), 1u);
339
340
auto result = archive.Lookup("persist", &error);
341
ASSERT_TRUE(result.has_value()) << error.GetDescription();
342
ASSERT_EQ(result->size(), sizeof(payload));
343
EXPECT_EQ(std::memcmp(result->data(), payload, sizeof(payload)), 0);
344
}
345
}
346
347
// ---------------------------------------------------------------------------
348
// Version mismatch: open with wrong version, then create fresh
349
// ---------------------------------------------------------------------------
350
351
TEST(ObjectArchive, VersionMismatchCreatesEmpty)
352
{
353
TempArchiveFiles files;
354
ASSERT_TRUE(files.IsValid());
355
356
// Create an archive at version 1 with some data.
357
{
358
ObjectArchive archive;
359
auto [idx, blob] = files.Release();
360
Error error;
361
ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();
362
363
const u8 data[] = {0x01, 0x02};
364
ASSERT_TRUE(
365
archive.Insert("v1_key", std::span<const u8>(data), ObjectArchive::CompressType::Uncompressed, &error))
366
<< error.GetDescription();
367
archive.Close();
368
}
369
370
// Attempt to open with a different version — should fail.
371
ASSERT_TRUE(files.Reopen());
372
{
373
ObjectArchive archive;
374
auto [idx, blob] = files.Release();
375
Error error;
376
EXPECT_FALSE(archive.OpenFile(idx, blob, TEST_VERSION + 1, &error));
377
EXPECT_FALSE(archive.IsOpen());
378
}
379
380
// Now create a fresh archive at the new version — should be empty.
381
ASSERT_TRUE(files.Reopen());
382
{
383
ObjectArchive archive;
384
auto [idx, blob] = files.Release();
385
Error error;
386
ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION + 1, &error)) << error.GetDescription();
387
EXPECT_TRUE(archive.IsOpen());
388
EXPECT_EQ(archive.GetSize(), 0u);
389
}
390
}
391
392
// ---------------------------------------------------------------------------
393
// Large number of objects inserted and looked up in unsorted order
394
// ---------------------------------------------------------------------------
395
396
TEST(ObjectArchive, LargeNumberOfObjectsUnsorted)
397
{
398
TempArchiveFiles files;
399
ASSERT_TRUE(files.IsValid());
400
401
ObjectArchive archive;
402
auto [idx, blob] = files.Release();
403
Error error;
404
ASSERT_TRUE(archive.CreateFile(idx, blob, TEST_VERSION, &error)) << error.GetDescription();
405
406
static constexpr size_t NUM_OBJECTS = 200;
407
408
// Build keys in a deliberately unsorted order by shuffling indices.
409
std::vector<size_t> order(NUM_OBJECTS);
410
std::iota(order.begin(), order.end(), 0u);
411
412
// Simple deterministic shuffle (swap i with i*7+3 mod N).
413
for (size_t i = 0; i < NUM_OBJECTS; i++)
414
{
415
const size_t j = (i * 7 + 3) % NUM_OBJECTS;
416
std::swap(order[i], order[j]);
417
}
418
419
// Insert in shuffled order.
420
for (const size_t i : order)
421
{
422
const std::string key = fmt::format("object_{:04}", i);
423
// Each object's payload is 8 bytes encoding its index.
424
u8 payload[8];
425
std::memset(payload, 0, sizeof(payload));
426
std::memcpy(payload, &i, sizeof(i));
427
428
ASSERT_TRUE(archive.Insert(key, payload, sizeof(payload), ObjectArchive::CompressType::Uncompressed, &error))
429
<< "Failed to insert key '" << key << "': " << error.GetDescription();
430
}
431
432
EXPECT_EQ(archive.GetSize(), NUM_OBJECTS);
433
434
// Look up every object in a different shuffled order.
435
std::vector<size_t> lookup_order(NUM_OBJECTS);
436
std::iota(lookup_order.begin(), lookup_order.end(), 0u);
437
for (size_t i = 0; i < NUM_OBJECTS; i++)
438
{
439
const size_t j = (i * 13 + 7) % NUM_OBJECTS;
440
std::swap(lookup_order[i], lookup_order[j]);
441
}
442
443
for (const size_t i : lookup_order)
444
{
445
const std::string key = fmt::format("object_{:04}", i);
446
auto result = archive.Lookup(key, &error);
447
ASSERT_TRUE(result.has_value()) << "Lookup failed for key '" << key << "': " << error.GetDescription();
448
ASSERT_EQ(result->size(), 8u);
449
450
size_t stored_index = 0;
451
std::memcpy(&stored_index, result->data(), sizeof(stored_index));
452
EXPECT_EQ(stored_index, i) << "Data mismatch for key '" << key << "'";
453
}
454
}
455
456