421 lines
16 KiB
C++
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);
|
|
}
|