// 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 #include #include #include #include #include #include #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& bytes) { fs::path p = dir / rel; fs::create_directories(p.parent_path()); std::ofstream(p, std::ios::binary).write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); return p; } fs::path write(const std::string& rel, std::string_view text) { return write(rel, std::vector(text.begin(), text.end())); } }; std::string as_text(const std::vector& v) { return std::string(v.begin(), v.end()); } std::vector 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 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 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 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("")); 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); }