// Local index tests.
//
// There are no JS fixtures here on purpose: the wire never carries this, so the
// storage format is a local choice with no conformance surface. What DOES need
// proving is the behaviour the protocol driver will depend on, and above all the
// failure mode that actually happens in a game: the process is killed mid-append.
//
// Build:
// g++ -O2 -std=c++17 -I liboverlay/include $(pkg-config --cflags libsodium) \
// -o /tmp/ho_idx liboverlay/test/test_file_index.cpp $(pkg-config --libs libsodium)
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include "overlay/file_index.h"
using namespace hyperoverlay::index;
static int passed = 0, failed = 0;
static void check(bool c, const std::string &what) {
if (c) { passed++; printf(" ok %s\n", what.c_str()); }
else { failed++; printf(" FAIL %s\n", what.c_str()); }
}
static std::string tmp_path(const char *name) {
return std::string("/tmp/claude-1000/ho_idx_") + name + ".log";
}
int main() {
printf("hyper-overlay C++ file index\n");
// ---- basic put/get + persistence across reopen --------------------------
{
const std::string p = tmp_path("basic");
std::remove(p.c_str());
FileIndex ix;
check(ix.open(p), "opens a fresh index");
FileMeta m;
m.contentHash = "aa";
m.size = 1234;
m.mtime = 99;
m.executable = true;
check(ix.put_file("/assets/a.glb", m), "put_file");
check(ix.has_file("/assets/a.glb"), "has_file");
check(ix.get_file("/assets/a.glb") && ix.get_file("/assets/a.glb")->size == 1234, "get_file size");
check(ix.get_file("/assets/a.glb")->executable, "get_file executable flag");
check(ix.get_file("/nope") == nullptr, "missing file returns null");
std::vector<ChunkRef> cm = { {"h0", 0, 100}, {"h1", 100, 250} };
check(ix.put_chunk_map("/assets/a.glb", cm), "put_chunk_map");
check(ix.has_chunk_map("/assets/a.glb"), "has_chunk_map");
ix.close();
// The point of the log: state survives a restart.
FileIndex ix2;
check(ix2.open(p), "reopens");
check(ix2.has_file("/assets/a.glb"), "file survived reopen");
check(ix2.get_file("/assets/a.glb")->mtime == 99, "metadata survived reopen");
const std::vector<ChunkRef> *got = ix2.get_chunk_map("/assets/a.glb");
check(got && got->size() == 2 && (*got)[1].offset == 100 && (*got)[1].length == 250,
"chunk map survived reopen with offsets intact");
check(ix2.truncated_bytes() == 0, "clean log truncates nothing");
}
// ---- deletion, and that it also drops the chunk map ---------------------
{
const std::string p = tmp_path("del");
std::remove(p.c_str());
FileIndex ix;
ix.open(p);
FileMeta m; m.size = 1;
ix.put_file("/x", m);
ix.put_chunk_map("/x", { {"h", 0, 1} });
ix.del_file("/x");
check(!ix.has_file("/x"), "del_file removes the file");
check(!ix.has_chunk_map("/x"), "del_file also drops its chunk map");
ix.close();
FileIndex ix2; ix2.open(p);
check(!ix2.has_file("/x"), "deletion survives reopen (tombstone replayed)");
}
// ---- by-content-hash lookup + eviction ---------------------------------
{
const std::string p = tmp_path("content");
std::remove(p.c_str());
FileIndex ix; ix.open(p);
const std::string oid = "deadbeef";
FileMeta m; m.contentHash = oid; m.size = 42;
ix.put_file(FileIndex::content_register_key(oid), m);
ix.put_chunk_map_by_hash(oid, { {"c0", 0, 42} });
check(ix.has_file("/mir/deadbeef"), "content register key is /mir/<hash>");
check(ix.has_chunk_map_by_hash(oid), "chunk map addressable by content hash");
// The serve path looks up by HASH, since a contentRequest names no path.
check(ix.get_chunk_map_by_hash(oid)->at(0).length == 42, "by-hash chunk map contents");
ix.evict_content(oid);
check(!ix.has_chunk_map_by_hash(oid), "evict_content drops the by-hash map");
check(!ix.has_file("/mir/deadbeef"), "evict_content drops the register entry");
}
// ---- index meta ---------------------------------------------------------
{
const std::string p = tmp_path("meta");
std::remove(p.c_str());
FileIndex ix; ix.open(p);
ix.put_index_meta("version", "2");
ix.close();
FileIndex ix2; ix2.open(p);
check(ix2.get_index_meta("version") && *ix2.get_index_meta("version") == "2",
"index meta survives reopen");
}
// ---- THE ONE THAT MATTERS: a kill mid-append ---------------------------
//
// A game process gets killed. If a half-written tail record were replayed as
// data, the index would come back subtly wrong — far worse than coming back
// one entry short.
{
const std::string p = tmp_path("torn");
std::remove(p.c_str());
{
FileIndex ix; ix.open(p);
FileMeta m; m.size = 7;
ix.put_file("/good/1", m);
ix.put_file("/good/2", m);
ix.close();
}
// Simulate the kill: append a plausible-looking but incomplete record.
{
FILE *f = std::fopen(p.c_str(), "ab");
const uint32_t len = 999; // claims far more than follows
uint8_t hdr[12];
std::memcpy(hdr, &len, 4);
std::memset(hdr + 4, 0xab, 8);
std::fwrite(hdr, 1, 12, f);
const char partial[] = "\x01\x05/hal"; // a few bytes, then nothing
std::fwrite(partial, 1, sizeof(partial) - 1, f);
std::fclose(f);
}
FileIndex ix;
check(ix.open(p), "opens a log with a torn tail");
check(ix.has_file("/good/1") && ix.has_file("/good/2"), "records before the tear survive");
check(ix.file_count() == 2, "the torn record is NOT replayed as data");
check(ix.truncated_bytes() > 0, "the discarded byte count is reported, not hidden");
// And the index must be writable again afterwards, not wedged.
FileMeta m; m.size = 3;
check(ix.put_file("/after", m), "index is writable after recovering");
ix.close();
FileIndex ix2; ix2.open(p);
check(ix2.has_file("/after") && ix2.file_count() == 3, "post-recovery write persists");
}
// ---- corrupt (not torn) tail: checksum must catch it -------------------
{
const std::string p = tmp_path("corrupt");
std::remove(p.c_str());
{
FileIndex ix; ix.open(p);
FileMeta m; m.size = 1;
ix.put_file("/a", m);
ix.put_file("/b", m);
ix.close();
}
// Flip a byte inside the LAST record's body. Length is intact, so only the
// checksum can catch this.
{
FILE *f = std::fopen(p.c_str(), "r+b");
std::fseek(f, -1, SEEK_END);
int c = std::fgetc(f);
std::fseek(f, -1, SEEK_END);
std::fputc(c ^ 0xff, f);
std::fclose(f);
}
FileIndex ix; ix.open(p);
check(ix.file_count() == 1, "checksum rejects a corrupted tail record");
check(ix.has_file("/a"), "the intact earlier record is kept");
}
// ---- compaction ---------------------------------------------------------
{
const std::string p = tmp_path("compact");
std::remove(p.c_str());
FileIndex ix; ix.open(p);
FileMeta m; m.size = 1;
// Churn: the same path rewritten many times grows the log without
// growing the state.
for (int i = 0; i < 200; i++) { m.size = (uint64_t)i; ix.put_file("/churn", m); }
ix.put_file("/keep", m);
ix.close();
const long before = [&]{ FILE *f = std::fopen(p.c_str(), "rb"); std::fseek(f, 0, SEEK_END);
long n = std::ftell(f); std::fclose(f); return n; }();
FileIndex ix2; ix2.open(p);
check(ix2.file_count() == 2, "churn collapses to the live entries");
check(ix2.compact(), "compact runs");
ix2.close();
const long after = [&]{ FILE *f = std::fopen(p.c_str(), "rb"); std::fseek(f, 0, SEEK_END);
long n = std::ftell(f); std::fclose(f); return n; }();
check(after < before, "compaction shrinks the log (" + std::to_string(before) +
" -> " + std::to_string(after) + " bytes)");
FileIndex ix3; ix3.open(p);
check(ix3.file_count() == 2 && ix3.get_file("/churn")->size == 199,
"compaction preserves the LATEST value of a churned key");
}
printf(" %d assertions pass, %d fail\n", passed, failed);
printf("%s\n", failed == 0 ? "PASS" : "FAIL");
return failed == 0 ? 0 : 1;
}