sots-engine/tests/mars_vfs/unit_tests.cpp

421 lines
16 KiB
C++

// Unit tests for mars::vfs: ZIP container parsing, override precedence,
// path folding, error paths. Fixtures are built in-process (zip_builder.h);
// the one deflated fixture is produced by Python at build time and read
// from $MARS_VFS_FIXTURE (test SKIPs without it).
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
#include "mars/vfs/path.h"
#include "mars/vfs/vfs.h"
#include "mars/vfs/zip_archive.h"
#include "test_main.h"
#include "zip_builder.h"
namespace fs = std::filesystem;
using namespace mars::vfs;
namespace {
// A scratch directory per test run, removed on exit.
struct Scratch {
fs::path dir;
Scratch() {
static int counter = 0;
const auto tag = std::chrono::steady_clock::now().time_since_epoch().count();
dir = fs::temp_directory_path() / ("mars_vfs_test_" + std::to_string(tag) + "_" + std::to_string(counter++));
fs::create_directories(dir);
}
~Scratch() {
std::error_code ec;
fs::remove_all(dir, ec);
}
fs::path write(const std::string& rel, const std::vector<std::uint8_t>& bytes) {
fs::path p = dir / rel;
fs::create_directories(p.parent_path());
std::ofstream(p, std::ios::binary).write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
return p;
}
fs::path write(const std::string& rel, std::string_view text) {
return write(rel, std::vector<std::uint8_t>(text.begin(), text.end()));
}
};
std::string as_text(const std::vector<std::uint8_t>& v) { return std::string(v.begin(), v.end()); }
std::vector<std::uint8_t> sample_zip() {
ziptest::ZipBuilder b;
b.add_dir("Data");
b.add("Data/globals.txt", "MARS_DEFAULT_COLOR \"255 177 39\"\n");
b.add("Species/Human/sections/CruiserAIC.shipsection", "shipsection { health 2800 }");
b.add("Locale/EN/Strings.csv", "# id,text\nSOTS_HELLO,Hello\n");
b.add("empty.bin", "");
return b.build();
}
} // namespace
// ---------------------------------------------------------------- paths
TEST(path_normalisation) {
CHECK_EQ(normalize_key("Species\\Human\\sections\\CruiserAIC.shipsection"), std::string("species/human/sections/cruiseraic.shipsection"));
CHECK_EQ(normalize_key("./Data//globals.txt"), std::string("data/globals.txt"));
CHECK_EQ(normalize_key("/Data/"), std::string("data"));
CHECK_EQ(normalize_key(""), std::string(""));
CHECK_EQ(normalize_key("a/../b"), std::string("a/../b")); // kept literally
CHECK_EQ(normalize_name("Weapons\\Gauss.weapon"), std::string("Weapons/Gauss.weapon"));
CHECK_EQ(normalize_key("caf\xE9.txt"), std::string("caf\xE9.txt")); // high byte untouched
CHECK(key_has_prefix("weapons/x", "weapons/"));
CHECK(key_has_prefix("weapons/x", ""));
CHECK(!key_has_prefix("weapon", "weapons/"));
}
// ---------------------------------------------------------------- ZipArchive
TEST(zip_index_and_lookup) {
Scratch s;
fs::path p = s.write("a.zip", sample_zip());
Result<ZipArchive> r = ZipArchive::open(p.string());
CHECK(r.ok());
if (!r) { std::fprintf(stderr, " %s\n", r.error().message.c_str()); return; }
const ZipArchive& z = r.value();
CHECK_EQ(z.entries().size(), std::size_t{5});
CHECK_EQ(z.file_count(), std::size_t{4});
CHECK(z.entries()[0].is_directory);
CHECK_EQ(z.entries()[0].key, std::string("data"));
CHECK(z.find("Data") == nullptr); // directories are not files
CHECK(z.find("Data/globals.txt") != nullptr);
CHECK(z.find("DATA\\GLOBALS.TXT") != nullptr);
CHECK(z.find("./data//Globals.txt") != nullptr);
CHECK(z.find("Data/globals.txt.bak") == nullptr);
CHECK(z.find("globals.txt") == nullptr);
auto bytes = z.read("species/HUMAN/Sections/cruiseraic.SHIPSECTION");
CHECK(bytes.ok());
if (bytes) CHECK_EQ(as_text(bytes.value()), std::string("shipsection { health 2800 }"));
const ZipEntry* e = z.find("Data/globals.txt");
CHECK(e && e->method == 0 && e->uncompressed_size == 32 && e->compressed_size == 32);
CHECK(e && e->name == "Data/globals.txt");
auto empty = z.read("empty.bin");
CHECK(empty.ok() && empty->empty());
auto missing = z.read("nope.txt");
CHECK(!missing.ok() && missing.error().code == Error::Code::NotFound);
CHECK_EQ(z.comment(), std::string(""));
}
TEST(zip_local_header_lengths_are_used) {
// The local header carries a longer extra field than the central one;
// the data offset must come from the local header.
ziptest::ZipBuilder b;
ziptest::Item& it = b.add("x.txt", "payload");
it.local_extra = {1, 2, 3, 4, 5, 6, 7};
it.central_extra = {9};
Scratch s;
auto z = ZipArchive::open(s.write("a.zip", b.build()).string());
CHECK(z.ok());
auto bytes = z->read("x.txt");
CHECK(bytes.ok());
if (bytes) CHECK_EQ(as_text(bytes.value()), std::string("payload"));
}
TEST(zip_trailing_comment_and_junk) {
Scratch s;
ziptest::ZipBuilder b;
b.add("a.txt", "A");
auto with_comment = b.build("hello archive");
auto z1 = ZipArchive::open(s.write("c.zip", with_comment).string());
CHECK(z1.ok());
if (z1) CHECK_EQ(z1->comment(), std::string("hello archive"));
// Junk appended after a comment-less EOCD is tolerated (the scan finds the record).
auto junk = b.build();
for (int i = 0; i < 100; ++i) junk.push_back(0xAA);
auto z2 = ZipArchive::open(s.write("j.zip", junk).string());
CHECK(z2.ok());
if (z2) CHECK(z2->read("a.txt").ok());
}
TEST(zip_duplicate_names_first_wins) {
ziptest::ZipBuilder b;
b.add("Same.txt", "first");
b.add("same.TXT", "second");
Scratch s;
auto z = ZipArchive::open(s.write("d.zip", b.build()).string());
CHECK(z.ok());
CHECK_EQ(z->entries().size(), std::size_t{2});
auto bytes = z->read("SAME.txt");
CHECK(bytes.ok());
if (bytes) CHECK_EQ(as_text(bytes.value()), std::string("first"));
}
TEST(zip_bad_archives) {
Scratch s;
auto good = sample_zip();
auto missing = ZipArchive::open((s.dir / "absent.zip").string());
CHECK(!missing.ok() && missing.error().code == Error::Code::Io);
auto empty = ZipArchive::open(s.write("empty.zip", "").string());
CHECK(!empty.ok() && empty.error().code == Error::Code::BadArchive);
auto text = ZipArchive::open(s.write("text.zip", "this is not a zip file at all, just some text").string());
CHECK(!text.ok() && text.error().code == Error::Code::BadArchive);
auto truncated = good;
truncated.resize(truncated.size() - 10); // cuts into the EOCD
auto t = ZipArchive::open(s.write("trunc.zip", truncated).string());
CHECK(!t.ok() && t.error().code == Error::Code::BadArchive);
auto badsig = good;
badsig[badsig.size() - 22] ^= 0xFF; // EOCD signature
auto bs = ZipArchive::open(s.write("badsig.zip", badsig).string());
CHECK(!bs.ok() && bs.error().code == Error::Code::BadArchive);
auto badoff = good;
badoff[badoff.size() - 22 + 16] = 0xFF; // low byte of the CD offset -> past EOF
badoff[badoff.size() - 22 + 17] = 0xFF;
auto bo = ZipArchive::open(s.write("badoff.zip", badoff).string());
CHECK(!bo.ok() && bo.error().code == Error::Code::BadArchive);
auto badcd = good;
const std::uint32_t cd_off = badcd[badcd.size() - 6] | (badcd[badcd.size() - 5] << 8);
badcd[cd_off] ^= 0xFF; // first central header signature
auto bc = ZipArchive::open(s.write("badcd.zip", badcd).string());
CHECK(!bc.ok() && bc.error().code == Error::Code::BadArchive);
auto zip64 = good;
zip64[zip64.size() - 22 + 10] = 0xFF; // total entries = 0xFFFF
zip64[zip64.size() - 22 + 11] = 0xFF;
auto z64 = ZipArchive::open(s.write("z64.zip", zip64).string());
CHECK(!z64.ok() && z64.error().code == Error::Code::Unsupported);
}
TEST(zip_bad_entries) {
Scratch s;
{
ziptest::ZipBuilder b;
b.add("a.txt", "hello world");
auto bytes = b.build();
bytes[30 + 5 + 2] ^= 0x01; // flip a data byte: local header 30 + name 5, then payload
auto z = ZipArchive::open(s.write("crc.zip", bytes).string());
CHECK(z.ok());
auto r = z->read("a.txt");
CHECK(!r.ok() && r.error().code == Error::Code::Corrupt);
}
{
ziptest::ZipBuilder b;
b.add("a.txt", "hello world");
auto bytes = b.build();
bytes[0] ^= 0xFF; // local header signature
auto z = ZipArchive::open(s.write("lh.zip", bytes).string());
CHECK(z.ok());
auto r = z->read("a.txt");
CHECK(!r.ok() && r.error().code == Error::Code::Corrupt);
}
{
ziptest::ZipBuilder b;
b.add("enc.txt", "secret").flags = 0x0001;
b.add_raw("bz.txt", {1, 2, 3}, 12, 3, 0);
b.add_raw("short.txt", {1, 2, 3}, 0, 10, 0); // stored but sizes disagree
std::vector<std::uint8_t> garbage = {0xFF, 0xFF, 0xFF, 0xFF, 0x00};
b.add_raw("bad.deflate", garbage, 8, 100, 0);
auto z = ZipArchive::open(s.write("odd.zip", b.build()).string());
CHECK(z.ok());
auto enc = z->read("enc.txt");
CHECK(!enc.ok() && enc.error().code == Error::Code::Unsupported);
auto bz = z->read("bz.txt");
CHECK(!bz.ok() && bz.error().code == Error::Code::Unsupported);
auto sh = z->read("short.txt");
CHECK(!sh.ok() && sh.error().code == Error::Code::Corrupt);
auto bd = z->read("bad.deflate");
CHECK(!bd.ok() && bd.error().code == Error::Code::Corrupt);
}
}
TEST(zip_deflated_fixture) {
// Produced by build_and_run.sh / CMake with Python's zipfile (ZIP_DEFLATED):
// deflated.txt : 2000 lines "line N of the deflated fixture\n"
// stored.txt : "stored alongside\n" (ZIP_STORED)
const char* fixture = std::getenv("MARS_VFS_FIXTURE");
if (!fixture || !*fixture) {
std::printf(" zip_deflated_fixture: SKIP (MARS_VFS_FIXTURE not set)\n");
return;
}
auto z = ZipArchive::open(fixture);
CHECK(z.ok());
if (!z) { std::fprintf(stderr, " %s\n", z.error().message.c_str()); return; }
const ZipEntry* e = z->find("deflated.txt");
CHECK(e != nullptr);
if (!e) return;
CHECK_EQ(e->method, std::uint16_t{8});
CHECK(e->compressed_size < e->uncompressed_size);
std::string expect;
for (int i = 0; i < 2000; ++i) expect += "line " + std::to_string(i) + " of the deflated fixture\n";
auto got = z->read(*e);
CHECK(got.ok());
if (got) CHECK_EQ(got->size(), expect.size());
if (got) CHECK(as_text(got.value()) == expect);
auto st = z->read("STORED.TXT");
CHECK(st.ok());
if (st) CHECK_EQ(as_text(st.value()), std::string("stored alongside\n"));
Vfs v;
CHECK(v.mount_zip(fixture).ok());
auto s = v.stat("deflated.txt");
CHECK(s && s->compressed && s->size == expect.size());
}
// ---------------------------------------------------------------- Vfs
namespace {
// Two archives and a loose directory that overlap on "shared.txt":
// a.zip : shared.txt="A" only_a.txt Sub/In_A.txt
// b.zip : shared.txt="B" only_b.txt sub/in_b.txt
// loose : shared.txt="N" only_n.txt SUB/in_n.txt
struct Layered {
Scratch s;
fs::path a, b, loose;
Layered() {
ziptest::ZipBuilder ba;
ba.add("shared.txt", "A");
ba.add("only_a.txt", "a");
ba.add("Sub/In_A.txt", "a-sub");
a = s.write("a.zip", ba.build());
ziptest::ZipBuilder bb;
bb.add("shared.txt", "B");
bb.add("only_b.txt", "b");
bb.add("sub/in_b.txt", "b-sub");
b = s.write("b.zip", bb.build());
loose = s.dir / "loose";
s.write("loose/shared.txt", "N");
s.write("loose/only_n.txt", "n");
s.write("loose/SUB/in_n.txt", "n-sub");
}
};
std::string read_str(const Vfs& v, std::string_view rel) {
auto r = v.read_text(rel);
return r ? r.value() : ("<" + std::string(to_string(r.error().code)) + ">");
}
} // namespace
TEST(vfs_native_first_override) {
Layered L;
Vfs v; // Order::NativeFirst
auto ia = v.mount_zip(L.a.string());
auto ib = v.mount_zip(L.b.string());
auto in = v.mount_native(L.loose.string()); // registered last, still wins
CHECK(ia.ok() && ib.ok() && in.ok());
CHECK_EQ(v.mounts().size(), std::size_t{3});
CHECK_EQ(v.mounts()[2].file_count, std::size_t{3});
std::vector<MountId> so = v.search_order();
CHECK(so.size() == 3 && so[0] == in.value() && so[1] == ia.value() && so[2] == ib.value());
CHECK_EQ(read_str(v, "shared.txt"), std::string("N"));
CHECK_EQ(read_str(v, "only_a.txt"), std::string("a"));
CHECK_EQ(read_str(v, "only_b.txt"), std::string("b"));
CHECK_EQ(read_str(v, "only_n.txt"), std::string("n"));
CHECK_EQ(read_str(v, "nothing.txt"), std::string("<not found>"));
auto st = v.stat("SHARED.TXT");
CHECK(st && st->mount == in.value() && st->kind == MountKind::Native && st->size == 1 && st->name == "shared.txt");
auto sb = v.stat("only_b.txt");
CHECK(sb && sb->mount == ib.value() && sb->kind == MountKind::Zip && !sb->compressed);
CHECK(!v.stat("nothing.txt"));
CHECK(v.exists("sub\\IN_A.txt") && v.exists("Sub/in_n.txt") && !v.exists("sub"));
}
TEST(vfs_registration_order) {
Layered L;
{
Vfs v(Order::Registration);
auto ia = v.mount_zip(L.a.string());
v.mount_zip(L.b.string());
v.mount_native(L.loose.string());
CHECK_EQ(read_str(v, "shared.txt"), std::string("A"));
auto st = v.stat("shared.txt");
CHECK(st && st->mount == ia.value());
}
{
Vfs v(Order::Registration);
v.mount_zip(L.b.string());
v.mount_zip(L.a.string());
CHECK_EQ(read_str(v, "shared.txt"), std::string("B"));
}
{
Vfs v(Order::Registration);
v.mount_native(L.loose.string());
v.mount_zip(L.a.string());
CHECK_EQ(read_str(v, "shared.txt"), std::string("N"));
}
}
TEST(vfs_list) {
Layered L;
Vfs v;
v.mount_zip(L.a.string());
v.mount_zip(L.b.string());
MountId in = v.mount_native(L.loose.string()).value();
auto all = v.list();
CHECK_EQ(all.size(), std::size_t{7}); // shared once
auto sub = v.list("sub/");
CHECK_EQ(sub.size(), std::size_t{3});
if (sub.size() == 3) {
CHECK_EQ(sub[0].name, std::string("Sub/In_A.txt")); // original spelling from a.zip
CHECK_EQ(sub[1].name, std::string("sub/in_b.txt"));
CHECK_EQ(sub[2].name, std::string("SUB/in_n.txt"));
CHECK_EQ(sub[2].mount, in);
}
auto stem = v.list("ONLY_");
CHECK_EQ(stem.size(), std::size_t{3});
auto backslash = v.list("SUB\\in_");
CHECK_EQ(backslash.size(), std::size_t{3});
for (const auto& e : all)
if (normalize_key(e.name) == "shared.txt") CHECK(e.mount == in);
CHECK(v.list("zzz").empty());
}
TEST(vfs_native_mount_details) {
Scratch s;
s.write("root/Data/Globals.txt", "x");
Vfs v;
auto id = v.mount_native((s.dir / "root").string());
CHECK(id.ok());
CHECK(v.exists("data/globals.TXT"));
CHECK(v.exists("DATA\\Globals.txt"));
auto st = v.stat("data/globals.txt");
CHECK(st && st->name == "Data/Globals.txt");
CHECK(!v.exists("../root/Data/Globals.txt")); // '..' never resolves
CHECK(!v.exists("Data")); // directories are not files
// Files added after mounting are seen after a rescan.
s.write("root/Data/new.txt", "y");
CHECK(!v.exists("Data/new.txt"));
auto n = v.rescan_native(id.value());
CHECK(n.ok() && n.value() == 2);
CHECK(v.exists("Data/new.txt"));
CHECK_EQ(v.mounts()[0].file_count, std::size_t{2});
auto bad = v.mount_native((s.dir / "absent").string());
CHECK(!bad.ok() && bad.error().code == Error::Code::Io);
auto notzip = v.mount_zip((s.dir / "root/Data/Globals.txt").string());
CHECK(!notzip.ok() && notzip.error().code == Error::Code::BadArchive);
CHECK(!v.rescan_native(99).ok());
CHECK(v.archive(id.value()) == nullptr);
}
TEST(vfs_empty) {
Vfs v;
CHECK(!v.exists("anything"));
CHECK(!v.stat("anything"));
CHECK(v.list().empty());
auto r = v.read("anything");
CHECK(!r.ok() && r.error().code == Error::Code::NotFound);
}