OverviewFilesCommitsIssuesDocsReleasesPeers

hyperswarm-peer / liboverlay / test / test_file_index.cpp

221 lines · cpp ref:
// 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;
}