// packutils' tests: the pure rules (JSON, records, the mirror plan, the junction buffer) and the // link engine against real directories, through its classes and through the C ABI with a mock // host. The engine cases are the Zig engine's, ported with it (ledger D-ZIG-LINK-2). // AI-authored (Claude). #include "packutils.h" #include "fs.hpp" #include "json.hpp" #include "links.hpp" #include "mirror.hpp" #include "records.hpp" #include "sha2.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifndef _WIN32 #include #endif namespace { int failures = 0; void expect(bool condition, const char *what, int line) { if (!condition) { std::fprintf(stderr, "engine_test.cpp:%d: %s\n", line, what); ++failures; } } #define EXPECT(condition) expect((condition), #condition, __LINE__) std::string hex(const std::uint8_t *bytes, std::size_t count) { static constexpr std::string_view digits = "0123456789abcdef"; std::string out; for (std::size_t i = 0; i < count; ++i) { // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic): a digest buffer. out.push_back(digits.at(bytes[i] >> 4U)); // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic): a digest buffer. out.push_back(digits.at(bytes[i] & 0x0FU)); } return out; } std::string_view view(pu_str text) { // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast): UTF-8 bytes as chars. return {reinterpret_cast(text.ptr), text.len}; } pu_str str(std::string_view text) { // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast): chars as UTF-8 bytes. return {.ptr = reinterpret_cast(text.data()), .len = text.size()}; } /// The host packutils is tested against: hashes from the test-only SHA-2, and a cancel byte. struct MockHost { std::uint8_t cancel = 0; pu_host host{}; MockHost() { host.size = sizeof(pu_host); host.ctx = this; host.cancel = &cancel; host.sha256 = sha256; host.sha512_file = sha512_file; } // NOLINTNEXTLINE(cppcoreguidelines-avoid-c-arrays,modernize-avoid-c-arrays): the C ABI's. static void sha256(void * /*ctx*/, pu_str bytes, std::uint8_t out[32]) { std::ranges::copy(sha2::sha256(view(bytes)), std::span(out, 32).begin()); } // NOLINTNEXTLINE(cppcoreguidelines-avoid-c-arrays,modernize-avoid-c-arrays): the C ABI's. static bool sha512_file(void * /*ctx*/, pu_str path, std::uint8_t out[64]) { auto bytes = packutils::fs::read(std::string(view(path)), std::size_t{1} << 30U); if (!bytes || !*bytes) { return false; } std::ranges::copy(sha2::sha512(**bytes), std::span(out, 64).begin()); return true; } }; /// Removes a tree without following a link: a recursive delete through a junction would empty /// what it points at. void wipe(const std::string &path) { const auto at = packutils::fs::state(path); if (!at || *at == packutils::fs::State::missing) { return; } if (*at == packutils::fs::State::directory) { if (auto entries = packutils::fs::list(path, false)) { for (const auto &entry : *entries) { wipe(packutils::fs::join(path, entry.name)); } } (void)packutils::fs::remove_dir(path); return; } #ifdef _WIN32 if (*at == packutils::fs::State::link && packutils::fs::remove_dir(path)) { return; } #endif (void)packutils::fs::remove_file(path); } /// A fresh directory with a pack and a game directory beside it. struct Fixture { std::string root; std::string pack; std::string instance; MockHost mock; Fixture() { static std::atomic counter{0}; const auto base = std::filesystem::temp_directory_path().u8string(); root = packutils::fs::join(std::string(base.begin(), base.end()), "packutils-test-" + std::to_string(counter.fetch_add(1)) + "-" + std::to_string(std::random_device{}())); auto real = packutils::fs::make_path(root); (void)real; root = *packutils::fs::real_path(root); pack = packutils::fs::join(root, "pack"); instance = packutils::fs::join(root, "instance"); for (const char *dir : {"pack/mods", "pack/config/sub", "pack/saves/w", "instance"}) { (void)packutils::fs::make_path(path(dir)); } put("pack/servers.dat", "x"); put("pack/mods/a.jar", "jar"); put("pack/config/sub/b.toml", "b = 1"); } Fixture(const Fixture &) = delete; Fixture &operator=(const Fixture &) = delete; Fixture(Fixture &&) = delete; Fixture &operator=(Fixture &&) = delete; ~Fixture() { wipe(root); } [[nodiscard]] std::string path(std::string_view relative) const { std::string out = root; out.push_back(packutils::fs::separator); for (const char c : relative) { out.push_back(c == '/' ? packutils::fs::separator : c); } return out; } void put(std::string_view relative, std::string_view bytes) const { (void)packutils::fs::write_atomic(path(relative), bytes); } [[nodiscard]] std::string read(std::string_view relative) const { auto bytes = packutils::fs::read(path(relative), std::size_t{1} << 20U); return bytes && *bytes ? **bytes : std::string(""); } [[nodiscard]] bool exists(std::string_view relative) const { auto at = packutils::fs::state(path(relative)); return at && *at != packutils::fs::State::missing; } [[nodiscard]] packutils::links::PackLinks links(packutils::links::Options options = {}) const { options.lock_files = options.lock_files && locks_work(); return {mock.host, pack, options}; } /// False only under Wine, whose NtLockFile is not implemented; real Windows locks. [[nodiscard]] bool locks_work() const { auto held = packutils::fs::FileLock::try_acquire(path("probe.lock")); return held.has_value(); } }; constexpr std::int64_t now = 1760097600; // 2025-10-10T12:00:00Z // SHA-2 against FIPS 180-4's examples, so the mock host's hashes are the real ones. void test_sha2() { const auto abc256 = sha2::sha256("abc"); EXPECT(hex(abc256.data(), abc256.size()) == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); const auto empty256 = sha2::sha256(""); EXPECT(hex(empty256.data(), empty256.size()) == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); const auto long256 = sha2::sha256("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"); EXPECT(hex(long256.data(), long256.size()) == "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"); const auto abc512 = sha2::sha512("abc"); EXPECT(hex(abc512.data(), abc512.size()) == "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a" "2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"); const auto long512 = sha2::sha512( "abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmnoijklmnopjklmnopqklmnopqrlm" "nopqrsmnopqrstnopqrstu"); EXPECT(hex(long512.data(), long512.size()) == "8e959b75dae313da8cf4f72814fc143f8f7779c6eb9f7fa17299aeadb6889018" "501d289e4900f7e4331b99dec4b5433ac7d329eeb6dd26545e96e55b874be909"); } void test_json() { using packutils::json::Value; auto parsed = packutils::json::parse(R"({"a":[1,{"b":"\u00e9\ud83d\ude00"}],"c":null,"d":true})"); EXPECT(parsed.has_value()); EXPECT(packutils::json::canonical(*parsed) == "{\n \"a\": [\n 1,\n {\n \"b\": \"\xc3\xa9\xf0\x9f\x98\x80\"\n }\n ],\n" " \"c\": null,\n \"d\": true\n}\n"); EXPECT(packutils::json::compact(*parsed) == "{\"a\":[1,{\"b\":\"\xc3\xa9\xf0\x9f\x98\x80\"}],\"c\":null,\"d\":true}"); // Only quote, backslash and control characters are escaped, \u in lowercase hex. EXPECT(packutils::json::compact(Value::of_string("q\" \\ \x01 \x7f \b\f\n\r\t")) == "\"q\\\" \\\\ \\u0001 \x7f \\b\\f\\n\\r\\t\""); EXPECT(packutils::json::canonical(Value::object()) == "{}\n"); for (const std::string_view bad : {R"({"a":1,"a":2})", "[1,]", "{} x", R"("\ud800")", "01", "[\"\x01\"]", "nul", "{\"a\" 1}"}) { EXPECT(!packutils::json::parse(bad).has_value()); } auto deep = packutils::json::parse(std::string(100, '[') + std::string(100, ']')); EXPECT(!deep.has_value()); EXPECT(packutils::json::parse("-12")->integer(-20, 0) == -12); EXPECT(!packutils::json::parse("1.5")->integer(0, 9).has_value()); } void test_records() { using packutils::records::Link; using packutils::records::Mode; const std::vector links{ {.instance = "/b", .folder = "mods", .mode = Mode::copy, .backup = {}}, {.instance = "/a", .folder = "mods", .mode = Mode::symlink, .backup = "mods-20261010T120000Z"}, {.instance = "/a", .folder = "config", .mode = Mode::junction, .backup = {}}, }; const std::string bytes = packutils::records::encode(links); auto back = packutils::records::parse(bytes, "links.json"); EXPECT(back.has_value() && back->size() == 3); EXPECT(back->at(0).folder == "config"); EXPECT(back->at(1).backup == "mods-20261010T120000Z"); EXPECT(back->at(2).mode == Mode::copy); EXPECT(packutils::records::encode(*back) == bytes); // The bytes Packwand's Zig engine wrote for the same records. EXPECT( bytes == "{\n \"version\": 1,\n \"links\": [\n {\n \"instance\": \"/a\",\n" " \"folder\": \"config\",\n \"mode\": \"junction\"\n },\n {\n" " \"instance\": \"/a\",\n \"folder\": \"mods\",\n \"mode\": \"symlink\",\n" " \"backup\": \"mods-20261010T120000Z\"\n },\n {\n \"instance\": \"/b\",\n" " \"folder\": \"mods\",\n \"mode\": \"copy\"\n }\n ]\n}\n"); for ( const std::string_view bad : { "[]", R"({"version":1})", R"({"version":1,"links":[],"extra":0})", R"({"version":0,"links":[]})", R"({"version":1,"links":[{"instance":"/a","folder":"../x","mode":"symlink"}]})", R"({"version":1,"links":[{"instance":"/a","folder":"mods","mode":"hardlink"}]})", R"({"version":1,"links":[{"instance":"/a","folder":"mods","mode":"copy","backup":"/etc"}]})", R"({"version":1,"links":[{"instance":"","folder":"mods","mode":"copy"}]})", R"({"version":1,"links":[{"instance":"/a","folder":"mods","mode":"copy","x":1}]})", R"({"version":1,"links":[{"instance":"/a","folder":"mods","mode":"copy"},{"instance":"/a","folder":"mods","mode":"symlink"}]})", }) { auto refused = packutils::records::parse(bad, "links.json"); EXPECT(!refused && refused.error().code == "LinksInvalid"); } auto newer = packutils::records::parse(R"({"version":2,"links":[]})", "links.json"); EXPECT(!newer && newer.error().code == "LinksTooNew" && newer.error().upgrade); for (const std::string_view good : {"mods", "config", "kubejs", "a b"}) { EXPECT(packutils::records::is_folder_name(good)); } for (const std::string_view bad : {"", ".", "..", "a/b", "a\\b", "C:"}) { EXPECT(!packutils::records::is_folder_name(bad)); } EXPECT(!packutils::records::is_folder_name(std::string_view("x\0", 2))); } packutils::mirror::Entry file(std::string_view path, std::uint64_t size, std::int64_t mtime) { return {.path = std::string(path), .kind = packutils::mirror::Kind::file, .stamp = {.size = size, .mtime_ns = mtime}}; } packutils::mirror::Entry dir(std::string_view path) { return {.path = std::string(path), .kind = packutils::mirror::Kind::directory, .stamp = {}}; } bool contains(const std::vector &list, std::string_view path) { return std::ranges::find(list, path) != list.end(); } /// What each side of a copy holds, by path: the fake hasher answers its content as the hash. struct Sides { std::map source; std::map target; [[nodiscard]] packutils::mirror::Hasher hasher() const { return [this](packutils::mirror::Side side, const std::string &path) -> std::optional { const auto &files = side == packutils::mirror::Side::source ? source : target; const auto found = files.find(path); if (found == files.end()) { return std::nullopt; } return found->second; }; } }; packutils::mirror::Placed placed(std::uint64_t size, std::int64_t mtime, std::uint64_t target_size, std::int64_t target_mtime, std::string_view sha512) { return {.source = {.size = size, .mtime_ns = mtime}, .target = {.size = target_size, .mtime_ns = target_mtime}, .sha512 = std::string(sha512), .legacy = false}; } packutils::mirror::Placed legacy(std::uint64_t size, std::int64_t mtime) { return { .source = {.size = size, .mtime_ns = mtime}, .target = {}, .sha512 = {}, .legacy = true}; } // Replaces the mirror tests ported from the Zig engine in M2, whose manifest knew only the // source's stamp (ledger D-ZIG-HARVEST-1): version 2 records what was placed, and a copy the // instance changed is never overwritten or deleted. void test_mirror() { using namespace packutils::mirror; const Sides none; { // A first pass makes every directory and copies every file. const std::vector source{file("a/b.txt", 1, 5), dir("a"), file("c.toml", 2, 5)}; const Plan p = plan(source, {}, {}, none.hasher()); EXPECT(p.make_directories.size() == 1 && p.copies.size() == 2 && p.delete_files.empty()); const std::map copied{{"a/b.txt", placed(1, 5, 1, 7, "b")}, {"c.toml", placed(2, 5, 2, 7, "c")}}; const Manifest after = next({}, source, p, copied, {}); EXPECT(after.files.size() == 2 && after.files.at("c.toml").sha512 == "c" && after.directories.contains("a")); } { // An unchanged pair copies nothing; a changed source copies again; a copy the instance // changed is kept, and so is one changed on both sides, as a conflict. Manifest manifest; manifest.files["c.toml"] = placed(2, 5, 2, 7, "pack"); const Sides edited{.source = {{"c.toml", "pack"}}, .target = {{"c.toml", "mine"}}}; EXPECT( plan({file("c.toml", 2, 5)}, {file("c.toml", 2, 7)}, manifest, none.hasher()).empty()); EXPECT(plan({file("c.toml", 2, 6)}, {file("c.toml", 2, 7)}, manifest, none.hasher()) .copies.size() == 1); EXPECT(plan({file("c.toml", 2, 5)}, {}, manifest, none.hasher()).copies.size() == 1); const Plan kept = plan({file("c.toml", 2, 5)}, {file("c.toml", 4, 9)}, manifest, edited.hasher()); EXPECT(kept.copies.empty() && kept.kept == std::vector{"c.toml"}); const Plan both = plan({file("c.toml", 2, 6)}, {file("c.toml", 4, 9)}, manifest, edited.hasher()); EXPECT(both.copies.empty() && both.conflicts == std::vector{"c.toml"}); // Both moved to the same bytes, as after a harvest: settled, not a conflict. const Sides agreed{.source = {{"c.toml", "mine"}}, .target = {{"c.toml", "mine"}}}; const Plan taken = plan({file("c.toml", 4, 6)}, {file("c.toml", 4, 9)}, manifest, agreed.hasher()); EXPECT(taken.copies.empty() && taken.conflicts.empty() && taken.refreshed.at("c.toml").sha512 == "mine"); // A copy whose stamp moved but whose bytes did not is the same; its stamp is refreshed. const Sides touched{.source = {}, .target = {{"c.toml", "pack"}}}; const Plan refreshed = plan({file("c.toml", 2, 5)}, {file("c.toml", 2, 8)}, manifest, touched.hasher()); EXPECT(refreshed.copies.empty() && refreshed.kept.empty() && refreshed.refreshed.at("c.toml").target.mtime_ns == 8); } { // A version 1 entry is classified by its bytes: the same becomes version 2; different is // unknown, kept and never overwritten, whichever side moved. Manifest manifest; manifest.files["a"] = legacy(1, 1); manifest.files["b"] = legacy(1, 1); const Sides sides{.source = {{"a", "x"}, {"b", "x"}}, .target = {{"a", "x"}, {"b", "y"}}}; const std::vector source{file("a", 1, 1), file("b", 1, 2)}; const Plan p = plan(source, {file("a", 1, 3), file("b", 1, 3)}, manifest, sides.hasher()); EXPECT(p.copies.empty() && p.unknown == std::vector{"b"}); const Manifest after = next(manifest, source, p, {}, {}); EXPECT(after.files.at("a").sha512 == "x" && !after.files.at("a").legacy); EXPECT(after.files.at("b").unknown() && after.files.at("b").target.mtime_ns == 3); // Unknown stays unknown, and is settled once the two sides agree again. EXPECT(plan(source, {file("a", 1, 3), file("b", 1, 3)}, after, sides.hasher()) .unknown.size() == 1); const Sides agreed{.source = {{"b", "x"}}, .target = {{"b", "x"}}}; const Plan settled = plan({file("b", 1, 2)}, {file("b", 1, 4)}, after, agreed.hasher()); EXPECT(settled.unknown.empty() && settled.refreshed.at("b").sha512 == "x"); } { // A file the instance has where the pack has one, never placed: kept unless it is the // same bytes, which are adopted. const Sides other{.source = {{"c", "pack"}}, .target = {{"c", "mine"}}}; const Plan theirs = plan({file("c", 1, 1)}, {file("c", 1, 2)}, {}, other.hasher()); EXPECT(theirs.copies.empty() && theirs.kept == std::vector{"c"}); const Sides same{.source = {{"c", "pack"}}, .target = {{"c", "pack"}}}; EXPECT( plan({file("c", 1, 1)}, {file("c", 1, 2)}, {}, same.hasher()).refreshed.contains("c")); } { // Only an unchanged manifest file is deleted, and only while it is a plain file; a changed // one is the instance's now, and a version 1 one is unknown. Manifest manifest; manifest.files["old.jar"] = placed(1, 1, 1, 1, "o"); manifest.files["edited.toml"] = placed(1, 1, 1, 1, "e"); manifest.files["linked.jar"] = placed(1, 1, 1, 1, "l"); manifest.files["v1.txt"] = legacy(1, 1); manifest.directories.insert("gone"); const std::vector target{ file("old.jar", 1, 1), file("edited.toml", 5, 5), file("game-wrote.json", 3, 3), {.path = "linked.jar", .kind = Kind::other, .stamp = {}}, file("v1.txt", 1, 1), dir("gone")}; const Sides sides{.source = {}, .target = {{"edited.toml", "mine"}}}; const Plan p = plan({}, target, manifest, sides.hasher()); EXPECT(p.delete_files == std::vector{"old.jar"}); EXPECT(p.kept == std::vector{"edited.toml"}); EXPECT(p.unknown == std::vector{"v1.txt"}); EXPECT(p.remove_directories.size() == 1); const Manifest after = next(manifest, {}, p, {}, {}); EXPECT(after.files.size() == 1 && after.files.contains("v1.txt")); } { // A source path whose target the copy did not place is blocked with everything under it. const std::vector source{dir("a"), file("a/x", 1, 1), file("b", 1, 1), file("c", 1, 1)}; const std::vector target{ file("a", 4, 4), dir("b"), {.path = "c", .kind = Kind::other, .stamp = {}}}; const Plan p = plan(source, target, {}, none.hasher()); EXPECT(p.blocked.size() == 3 && p.empty() && p.skipped.contains("a/x")); EXPECT(next({}, source, p, {}, {}).empty()); } { // A file the copy placed gives way to a directory, and a failed step is retried. Manifest manifest; manifest.files["a"] = placed(1, 1, 1, 1, "a"); manifest.files["b"] = placed(1, 1, 1, 1, "b"); const std::vector source{dir("a"), file("a/x", 2, 2)}; const Plan p = plan(source, {file("a", 1, 1), file("b", 1, 1)}, manifest, none.hasher()); EXPECT(contains(p.delete_files, "a") && contains(p.delete_files, "b")); EXPECT(contains(p.make_directories, "a")); const std::unordered_set failed{"b", "a/x"}; const Manifest after = next(manifest, source, p, {}, failed); EXPECT(after.files.contains("b") && !after.files.contains("a/x") && after.directories.contains("a")); } { // Harvest's view: what the instance edited, added, deleted, and could not be told. Manifest manifest; manifest.files["kept.toml"] = placed(1, 1, 1, 1, "k"); manifest.files["edited.toml"] = placed(1, 1, 1, 1, "e"); manifest.files["both.toml"] = placed(1, 1, 1, 1, "b"); manifest.files["deleted.toml"] = placed(1, 1, 1, 1, "d"); manifest.files["v1.toml"] = legacy(1, 1); const std::vector source{file("kept.toml", 1, 1), file("edited.toml", 1, 1), file("both.toml", 1, 2), file("deleted.toml", 1, 1), file("v1.toml", 1, 1)}; const std::vector target{file("kept.toml", 1, 1), file("edited.toml", 1, 9), file("both.toml", 1, 9), file("v1.toml", 1, 9), file("new.json", 1, 9)}; const Sides sides{.source = {{"v1.toml", "pack"}}, .target = {{"edited.toml", "x"}, {"both.toml", "x"}, {"v1.toml", "x"}}}; std::vector seen; for (const Finding &finding : findings(source, target, manifest, sides.hasher())) { static constexpr std::array names{"edited", "conflict", "unknown", "added", "deleted"}; seen.push_back(std::string(names.at(static_cast(finding.what))) + " " + finding.path); } EXPECT((seen == std::vector{"conflict both.toml", "deleted deleted.toml", "edited edited.toml", "added new.json", "unknown v1.toml"})); } { // A version 2 manifest round-trips with its bytes; version 1 still reads, as legacy. Manifest manifest; manifest.files["a/b.txt"] = placed(3, 1'760'000'000'123'456'789, 3, 7, std::string(128, 'a')); manifest.files["u"] = placed(1, 1, 1, 2, ""); manifest.directories.insert("a"); const std::string bytes = encode_manifest(manifest); EXPECT( bytes == "{\n \"version\": 2,\n \"files\": {\n \"a/b.txt\": {\n \"source\": [\n" " 3,\n 1760000000123456789\n ],\n \"target\": [\n" " 3,\n 7\n ],\n \"sha512\": \"" + std::string(128, 'a') + "\"\n },\n \"u\": {\n \"source\": [\n 1,\n 1\n ],\n" " \"target\": [\n 1,\n 2\n ],\n \"sha512\": null\n" " }\n },\n \"directories\": [\n \"a\"\n ]\n}\n"); auto back = parse_manifest(bytes, "m.json"); EXPECT(back && back->files.at("a/b.txt") == manifest.files.at("a/b.txt") && back->files.at("u").unknown() && back->directories.contains("a")); // The bytes the Zig engine wrote. auto v1 = parse_manifest( "{\n \"version\": 1,\n \"files\": {\n \"a/b.txt\": [\n 3,\n" " 1760000000123456789\n ]\n },\n \"directories\": [\n \"a\"\n ]\n}\n", "m.json"); EXPECT(v1 && v1->files.at("a/b.txt").legacy && v1->files.at("a/b.txt").source.mtime_ns == 1'760'000'000'123'456'789); for ( const std::string_view bad : { R"({"version":1,"files":{"../x":[1,"1"]},"directories":[]})", R"({"version":1,"files":{"/x":[1,"1"]},"directories":[]})", R"({"version":1,"files":{},"directories":[".."]})", R"({"version":1,"files":{},"directories":["a","a"]})", R"({"version":0,"files":{},"directories":[]})", R"({"version":1,"files":{"x":[1]},"directories":[]})", R"({"version":2,"files":{"x":[1,1]},"directories":[]})", R"({"version":2,"files":{"x":{"source":[1,1],"target":[1,1],"sha512":"zz"}},"directories":[]})", R"({"version":2,"files":{"x":{"source":[1,1],"target":[1,1]}},"directories":[]})", }) { auto refused = parse_manifest(bad, "m.json"); EXPECT(!refused && refused.error().code == "MirrorManifestInvalid"); } auto newer = parse_manifest(R"({"version":3,"files":{},"directories":[]})", "m.json"); EXPECT(!newer && newer.error().code == "MirrorManifestTooNew" && newer.error().upgrade); } // Paths sort by component, so a/b comes before a-b. EXPECT(path_less("a/b", "a-b") && !path_less("a-b", "a/b") && path_less("a", "a/b")); for (const std::string_view good : {"", "mods/sodium.bun.json", "config/a.b/c", "a..b", "console", "com10"}) { EXPECT(is_safe(good)); } for (const std::string_view bad : {"/abs", "a/../b", "..", "./a", "a//..", "a\\b", "C:x", "CON", "dir/nul.txt", "com1 .log", "LPT9.", "a/CONIN$"}) { EXPECT(!is_safe(bad)); } } void test_junction_buffer() { const std::u16string substitute = u"\\??\\C:\\p"; const std::u16string print = u"C:\\p"; const auto data = packutils::fs::junction_buffer(substitute, print); const std::vector expected{ 0x03, 0x00, 0x00, 0xA0, // IO_REPARSE_TAG_MOUNT_POINT 36, 0, // ReparseDataLength: 8 + (8 + 1 + 4 + 1) * 2 0, 0, // Reserved 0, 0, 16, 0, // SubstituteNameOffset, SubstituteNameLength 18, 0, 8, 0, // PrintNameOffset, PrintNameLength '\\', 0, '?', 0, '?', 0, '\\', 0, 'C', 0, ':', 0, '\\', 0, 'p', 0, 0, 0, 'C', 0, ':', 0, '\\', 0, 'p', 0, 0, 0, }; EXPECT(data.has_value() && *data == expected); EXPECT(!packutils::fs::junction_buffer(std::u16string(9000, u'x'), print).has_value()); EXPECT(packutils::fs::same_path("/a/b/", "/a/b")); #ifdef _WIN32 EXPECT(packutils::fs::same_path("C:\\Pack\\mods", "c:/pack/mods")); #else EXPECT(!packutils::fs::same_path("/a/B", "/a/b")); #endif EXPECT(packutils::links::backup_stem("mods", now) == "mods-20251010T120000Z"); } std::vector link_all(const Fixture &f, bool force, bool copy) { const auto links = f.links(); auto checks = links.check(f.instance, {"config", "mods"}); EXPECT(checks.has_value()); auto placed = links.link(f.instance, *checks, force, copy, now); if (!placed) { return {}; } return *placed; } #ifdef _WIN32 constexpr auto link_mode = packutils::records::Mode::junction; #else constexpr auto link_mode = packutils::records::Mode::symlink; #endif void test_link_unlink() { const Fixture f; const auto links = f.links(); const auto placed = link_all(f, false, false); EXPECT(placed.size() == 2 && placed[1].mode == link_mode && !placed[1].kept); EXPECT(f.read("instance/mods/a.jar") == "jar"); EXPECT(f.read("pack/.packwand/.gitignore") == "*\n"); const auto again = link_all(f, false, false); EXPECT(again.size() == 2 && again[0].kept && again[1].kept); EXPECT(links.load()->size() == 2); auto removed = links.unlink(std::nullopt); EXPECT(removed && removed->size() == 2); EXPECT(!f.exists("instance/mods")); EXPECT(f.read("pack/mods/a.jar") == "jar"); EXPECT(f.read("pack/config/sub/b.toml") == "b = 1"); EXPECT(links.load()->empty()); } void test_force_and_restore() { const Fixture f; const auto links = f.links(); (void)packutils::fs::make_path(f.path("instance/mods")); f.put("instance/mods/mine.jar", "mine"); (void)packutils::fs::make_path(f.path("instance/config")); auto checks = links.check(f.instance, {"config", "mods"}); EXPECT(checks && checks->at(0).slot == packutils::links::Slot::empty_directory && checks->at(1).slot == packutils::links::Slot::taken); auto refused = links.link(f.instance, *checks, false, false, now); EXPECT(!refused && refused.error().code == "LinkConflict" && refused.error().kind == PU_KIND_CONFLICT); EXPECT(f.read("instance/mods/mine.jar") == "mine"); const auto placed = link_all(f, true, false); EXPECT(placed.size() == 2 && !placed[0].backup && placed[1].backup == "mods-20251010T120000Z"); EXPECT(f.read("instance/mods/a.jar") == "jar"); EXPECT(links.unlink(std::nullopt).has_value()); EXPECT(f.read("instance/mods/mine.jar") == "mine"); EXPECT(!f.exists("instance/.packwand-backup")); EXPECT(f.read("pack/mods/a.jar") == "jar"); } void test_unlink_refuses_a_replaced_link() { const Fixture f; const auto links = f.links(); (void)link_all(f, false, false); wipe(f.path("instance/config")); (void)packutils::fs::make_path(f.path("instance/config")); f.put("instance/config/real.txt", "keep"); auto refused = links.unlink(std::nullopt); EXPECT(!refused && refused.error().code == "NotALink"); EXPECT(f.read("instance/config/real.txt") == "keep"); } void test_copy_follows_the_source() { const Fixture f; const auto links = f.links(); const auto placed = link_all(f, false, true); EXPECT(placed.size() == 2 && placed[1].mode == packutils::records::Mode::copy); EXPECT(f.read("instance/config/sub/b.toml") == "b = 1"); f.put("instance/config/game.json", "game"); f.put("pack/config/sub/b.toml", "b = 22"); (void)packutils::fs::remove_file(f.path("pack/mods/a.jar")); f.put("pack/mods/c.jar", "c"); std::size_t copied = 0; std::size_t deleted = 0; for (const auto &entry : *links.load()) { auto report = links.sync(entry); EXPECT(report.has_value()); copied += report->copied; deleted += report->deleted; } EXPECT(copied == 2 && deleted == 1); EXPECT(f.read("instance/config/sub/b.toml") == "b = 22"); EXPECT(!f.exists("instance/mods/a.jar")); EXPECT(f.read("instance/config/game.json") == "game"); auto removed = links.unlink(std::nullopt); EXPECT(removed && removed->size() == 2); EXPECT(removed->at(0).outcome == packutils::links::Removed::Outcome::left_in_place); EXPECT(removed->at(1).outcome == packutils::links::Removed::Outcome::removed); EXPECT(f.read("instance/config/game.json") == "game"); EXPECT(!f.exists("instance/config/sub") && !f.exists("instance/mods")); EXPECT(f.read("pack/mods/c.jar") == "c"); EXPECT(!f.exists("pack/.packwand/copies") || packutils::fs::is_empty_directory(f.path("pack/.packwand/copies"))); } void test_unavailable_links_fall_back_to_a_copy() { const Fixture f; const auto links = f.links({.lock_files = true, .lock_timeout_ms = 10'000, .links_unavailable = true, .lock_held = false}); auto checks = links.check(f.instance, {"mods"}); auto placed = links.link(f.instance, *checks, false, false, now); EXPECT(placed && placed->at(0).mode == packutils::records::Mode::copy); EXPECT(f.read("instance/mods/a.jar") == "jar"); auto at = packutils::fs::state(f.path("instance/mods")); EXPECT(at && *at == packutils::fs::State::directory); } void test_instance_inside_the_pack() { const Fixture f; const auto links = f.links(); auto inside = links.resolve_instance(f.path("pack/mods")); EXPECT(!inside && inside.error().code == "InstanceInvalid" && inside.error().kind == PU_KIND_INVALID_REQUEST); auto missing = links.resolve_instance(f.path("pack/missing")); EXPECT(!missing && missing.error().code == "InstanceInvalid"); auto apart = links.resolve_instance(f.instance); EXPECT(apart && apart->game == f.instance && !apart->launcher); } void test_lock_times_out() { const Fixture f; if (!f.locks_work()) { return; // Wine. } const auto links = f.links({.lock_files = true, .lock_timeout_ms = 60, .links_unavailable = false, .lock_held = false}); (void)packutils::fs::make_path(f.path("pack/.packwand")); auto held = packutils::fs::FileLock::try_acquire(f.path("pack/.packwand/lock")); EXPECT(held && *held); auto checks = links.check(f.instance, {"mods"}); auto refused = links.link(f.instance, *checks, false, false, now); EXPECT(!refused && refused.error().code == "LinksLockTimeout"); } std::string request(const Fixture &f, std::string_view members) { using packutils::json::Value; std::string out = R"({"pack":)" + packutils::json::compact(Value::of_string(f.pack)) + R"(,"lock_files":)" + (f.locks_work() ? "true" : "false"); if (!members.empty()) { out += ","; out += members; } return out + "}"; } std::string json_text(std::string_view text) { return packutils::json::compact(packutils::json::Value::of_string(text)); } void test_abi() { const Fixture f; pu_error err{}; pu_runtime *rt = nullptr; EXPECT(packutils_create(&f.mock.host, PACKUTILS_ABI + 1, &rt, &err) == PU_INVALID && rt == nullptr); pu_host partial = f.mock.host; partial.sha256 = nullptr; EXPECT(packutils_create(&partial, PACKUTILS_ABI, &rt, &err) == PU_INVALID && rt == nullptr); pu_host small = f.mock.host; small.size = sizeof(pu_host) - 1; EXPECT(packutils_create(&small, PACKUTILS_ABI, &rt, &err) == PU_INVALID); EXPECT(packutils_create(&f.mock.host, PACKUTILS_ABI, &rt, &err) == PU_OK && rt != nullptr); pu_result out{}; const std::string resolve = request(f, R"("instance":)" + json_text(f.instance)); EXPECT(packutils_resolve_instance(rt, str(resolve), &out, &err) == PU_OK); EXPECT(view(out.json) == R"({"instance":)" + json_text(f.instance) + "}"); const std::string check = request(f, R"("instance":)" + json_text(f.instance) + R"(,"folders":["mods"])"); EXPECT(packutils_link_check(rt, str(check), &out, &err) == PU_OK); const std::string checks = std::string(view(out.json)); EXPECT(checks == R"({"checks":[{"folder":"mods","slot":"free","path":)" + json_text(packutils::fs::join(f.instance, "mods")) + "}]}"); const std::string link = request(f, R"("instance":)" + json_text(f.instance) + R"(,"checks":)" + checks.substr(10, checks.size() - 11) + R"(,"copy":true,"now":1760097600)"); EXPECT(packutils_link(rt, str(link), &out, &err) == PU_OK); EXPECT(view(out.json) == R"({"placed":[{"folder":"mods","mode":"copy","kept":false,"blocked":[]}]})"); EXPECT(packutils_links_load(rt, str(request(f, "")), &out, &err) == PU_OK); EXPECT(view(out.json) == R"({"links":[{"instance":)" + json_text(f.instance) + R"(,"folder":"mods","mode":"copy"}]})"); f.put("pack/mods/b.jar", "b"); const std::string sync = request(f, R"("link":{"instance":)" + json_text(f.instance) + R"(,"folder":"mods","mode":"copy"})"); EXPECT(packutils_sync(rt, str(sync), &out, &err) == PU_OK); EXPECT( view(out.json) == R"({"copied":1,"deleted":0,"blocked":[],"failed":[],"kept":[],"conflicts":[],"unknown":[]})"); EXPECT(packutils_unlink(rt, str(request(f, R"("instance":null)")), &out, &err) == PU_OK); EXPECT(view(out.json) == R"({"removed":[{"link":{"instance":)" + json_text(f.instance) + R"(,"folder":"mods","mode":"copy"},"outcome":"removed","restored":false}]})"); // A refusal carries its kind and code; a file-system failure its cause and path. const std::string inside = request(f, R"("instance":)" + json_text(f.path("pack/mods"))); EXPECT(packutils_resolve_instance(rt, str(inside), &out, &err) == PU_FAILED); EXPECT(err.kind == PU_KIND_INVALID_REQUEST && view(err.code) == "InstanceInvalid"); f.put("pack/.packwand/links.json", R"({"version":9,"links":[]})"); EXPECT(packutils_links_load(rt, str(request(f, "")), &out, &err) == PU_FAILED); EXPECT(view(err.code) == "LinksTooNew" && err.upgrade); EXPECT(packutils_links_load(rt, str("[]"), &out, &err) == PU_INVALID); EXPECT(packutils_link(rt, str(request(f, R"("instance":"x")")), &out, &err) == PU_INVALID); packutils_free_error(rt, &err); packutils_free_result(rt, &out); packutils_destroy(rt); } void test_unlink_one_instance_and_nothing_recorded() { const Fixture f; const auto links = f.links(); auto none = links.unlink(std::nullopt); EXPECT(none && none->empty() && !f.exists("pack/.packwand")); (void)packutils::fs::make_path(f.path("other")); const std::string other = f.path("other"); (void)link_all(f, false, false); auto checks = links.check(other, {"mods"}); EXPECT(links.link(other, *checks, false, false, now).has_value()); auto removed = links.unlink(other); EXPECT(removed && removed->size() == 1 && removed->at(0).link.instance == other); EXPECT(links.load()->size() == 2 && f.exists("instance/mods") && !f.exists("other/mods")); } void test_sync_recreates_a_deleted_copy_and_refuses_a_file() { const Fixture f; const auto links = f.links(); (void)link_all(f, false, true); const auto recorded = *links.load(); wipe(f.path("instance/mods")); auto again = links.sync(recorded.at(1)); EXPECT(again && again->copied == 1 && f.read("instance/mods/a.jar") == "jar"); wipe(f.path("instance/mods")); f.put("instance/mods", "not a folder"); auto refused = links.sync(recorded.at(1)); EXPECT(!refused && refused.error().code == "LinkConflict"); // A corrupt manifest stops the pass rather than guessing what the copy placed. auto copies = packutils::fs::list(f.path("pack/.packwand/copies"), false); EXPECT(copies.has_value()); for (const auto &entry : *copies) { f.put("pack/.packwand/copies/" + entry.name, "{}"); } auto corrupt = links.sync(recorded.at(0)); EXPECT(!corrupt && corrupt.error().code == "MirrorManifestInvalid"); } void test_a_link_inside_a_copied_folder_is_never_followed() { #ifndef _WIN32 const Fixture f; EXPECT(packutils::fs::link_directory(f.path("pack/config/elsewhere"), f.path("pack/mods"))); (void)link_all(f, false, true); EXPECT(f.read("instance/config/sub/b.toml") == "b = 1"); EXPECT(!f.exists("instance/config/elsewhere")); #endif } void test_an_unrecorded_link_to_the_pack_is_adopted() { const Fixture f; const auto links = f.links(); EXPECT(packutils::fs::link_directory(f.path("instance/mods"), f.path("pack/mods"))); auto checks = links.check(f.instance, {"mods"}); EXPECT(checks && checks->at(0).slot == packutils::links::Slot::ours); auto placed = links.link(f.instance, *checks, false, false, now); EXPECT(placed && placed->at(0).kept && placed->at(0).mode == link_mode); EXPECT(links.load()->size() == 1); } void test_a_taken_backup_name_gets_a_suffix() { const Fixture f; const auto links = f.links(); f.put("instance/mods/mine.jar", "mine"); (void)packutils::fs::make_path(f.path("instance/.packwand-backup/mods-20251010T120000Z")); auto checks = links.check(f.instance, {"mods"}); auto placed = links.link(f.instance, *checks, true, false, now); EXPECT(placed && placed->at(0).backup == "mods-20251010T120000Z-2"); EXPECT(f.read("instance/.packwand-backup/mods-20251010T120000Z-2/mine.jar") == "mine"); } void test_slots_and_instances_that_are_files() { const Fixture f; const auto links = f.links(); f.put("instance/mods", "a file"); auto checks = links.check(f.instance, {"mods"}); EXPECT(checks && checks->at(0).slot == packutils::links::Slot::taken); auto file = links.resolve_instance(f.path("instance/mods")); EXPECT(!file && file.error().code == "InstanceInvalid" && file.error().message.ends_with("is not a directory")); } void test_without_locks_and_canceled() { Fixture f; const auto unlocked = f.links({.lock_files = false, .lock_timeout_ms = 10'000, .links_unavailable = false, .lock_held = false}); (void)link_all(f, false, false); EXPECT(unlocked.load()->size() == 2); EXPECT(unlocked.unlink(std::nullopt).has_value()); // A set cancel byte stops a link between folders and a copy between files. f.mock.cancel = 1; const auto links = f.links(); auto checks = links.check(f.instance, {"config", "mods"}); auto stopped = links.link(f.instance, *checks, false, true, now); EXPECT(!stopped && stopped.error().status == PU_CANCELED); f.mock.cancel = 0; } void test_an_unreadable_folder_is_a_file_system_failure() { #ifndef _WIN32 if (::geteuid() == 0) { return; // root reads everything. } const Fixture f; const auto links = f.links(); (void)link_all(f, false, true); const auto recorded = *links.load(); std::filesystem::permissions(f.path("pack/config/sub"), std::filesystem::perms::none); auto refused = links.sync(recorded.at(0)); std::filesystem::permissions(f.path("pack/config/sub"), std::filesystem::perms::owner_all); EXPECT(!refused && refused.error().code == "FileSystem" && refused.error().cause == "AccessDenied" && refused.error().directory && refused.error().path.ends_with("sub")); #endif } void test_a_prism_instance_names_its_game_directory() { const Fixture f; const auto links = f.links(); (void)packutils::fs::make_path(f.path("prism/instances/Pack")); f.put("prism/instances/Pack/instance.cfg", "name=Pack\n"); auto unstarted = links.resolve_instance(f.path("prism/instances/Pack")); EXPECT(!unstarted && unstarted.error().code == "InstanceInvalid" && unstarted.error().message.ends_with("start it once from the launcher")); // An older MultiMC instance keeps its game in `minecraft/`, and says nothing of its components. (void)packutils::fs::make_path(f.path("prism/instances/Pack/minecraft")); auto older = links.resolve_instance(f.path("prism/instances/Pack")); EXPECT(older && older->game == f.path("prism/instances/Pack/minecraft") && older->launcher && older->launcher->minecraft.empty()); (void)packutils::fs::make_path(f.path("prism/instances/Pack/.minecraft")); f.put( "prism/instances/Pack/mmc-pack.json", R"({"components":[{"uid":"org.lwjgl3","version":"3.3.3"},{"uid":"net.minecraft","version":"1.21.1"},)" R"({"uid":"net.fabricmc.intermediary","version":"1.21.1"},{"uid":"net.fabricmc.fabric-loader","version":"0.16.5"}],"formatVersion":1})"); auto prism = links.resolve_instance(f.path("prism/instances/Pack")); EXPECT(prism && prism->game == f.path("prism/instances/Pack/.minecraft")); EXPECT(prism->launcher && prism->launcher->launcher == "prism" && prism->launcher->minecraft == "1.21.1" && prism->launcher->loader == "fabric" && prism->launcher->loader_version == "0.16.5"); f.put("prism/instances/Pack/mmc-pack.json", "not json"); auto unreadable = links.resolve_instance(f.path("prism/instances/Pack")); EXPECT(unreadable && unreadable->launcher && unreadable->launcher->loader.empty()); } void test_abi_refuses_malformed_requests() { const Fixture f; pu_error err{}; pu_runtime *rt = nullptr; EXPECT(packutils_create(&f.mock.host, PACKUTILS_ABI, &rt, &err) == PU_OK); pu_result out{}; const std::string pack = R"("pack":)" + json_text(f.pack); for (const std::string &bad : { std::string("{}"), "{" + pack + R"(,"lock_timeout_ms":-1})", }) { EXPECT(packutils_links_load(rt, str(bad), &out, &err) == PU_INVALID); } EXPECT(packutils_resolve_instance(rt, str("{" + pack + "}"), &out, &err) == PU_INVALID); EXPECT(packutils_link_check(rt, str("{" + pack + R"(,"instance":"x","folders":[1]})"), &out, &err) == PU_INVALID); EXPECT(packutils_link_check(rt, str("{" + pack + R"(,"instance":"x"})"), &out, &err) == PU_INVALID); EXPECT(packutils_link( rt, str("{" + pack + R"(,"instance":"x","now":1,"checks":[{"folder":"m","slot":"odd","path":"p"}]})"), &out, &err) == PU_INVALID); EXPECT(packutils_unlink(rt, str("{" + pack + R"(,"instance":5})"), &out, &err) == PU_INVALID); EXPECT(packutils_sync( rt, str("{" + pack + R"(,"link":{"instance":"x","folder":"m","mode":"symlink"}})"), &out, &err) == PU_INVALID); EXPECT(packutils_sync(rt, str("{" + pack + "}"), &out, &err) == PU_INVALID); EXPECT(packutils_links_load(nullptr, str("{}"), &out, &err) == PU_INVALID); EXPECT(packutils_links_load(rt, str("{}"), nullptr, &err) == PU_INVALID); EXPECT(packutils_links_load(rt, str("{}"), &out, nullptr) == PU_INVALID); EXPECT(packutils_create(&f.mock.host, PACKUTILS_ABI, nullptr, &err) == PU_INVALID); EXPECT(packutils_create(&f.mock.host, PACKUTILS_ABI, &rt, nullptr) == PU_INVALID); packutils_destroy(rt); } /// The one copy manifest a fixture's pack holds for `folder`. std::string manifest_for(const Fixture &f, std::string_view folder) { auto copies = packutils::fs::list(f.path("pack/.packwand/copies"), false); if (!copies) { return {}; } for (const auto &entry : *copies) { if (entry.name.ends_with("-" + std::string(folder) + ".json")) { return "pack/.packwand/copies/" + entry.name; } } return {}; } void test_sync_never_overwrites_an_instance_edit() { const Fixture f; const auto links = f.links(); (void)link_all(f, false, true); const auto config = links.load()->at(0); f.put("instance/config/sub/b.toml", "b = mine"); auto kept = links.sync(config); EXPECT(kept && kept->copied == 0 && kept->kept == std::vector{"sub/b.toml"}); f.put("pack/config/sub/b.toml", "b = pack"); auto both = links.sync(config); EXPECT(both && both->copied == 0 && both->conflicts == std::vector{"sub/b.toml"}); EXPECT(f.read("instance/config/sub/b.toml") == "b = mine"); // Deleting the instance's copy hands the file back to the pack. (void)packutils::fs::remove_file(f.path("instance/config/sub/b.toml")); auto back = links.sync(config); EXPECT(back && back->copied == 1 && f.read("instance/config/sub/b.toml") == "b = pack"); // unlink leaves an edited copy in place, but not one whose bytes the pack holds too, as after // a harvest took it, nor a file the game added that the pack has the same of. f.put("instance/config/sub/b.toml", "b = mine again"); f.put("instance/config/game.json", "game"); f.put("pack/config/game.json", "game"); auto removed = links.unlink(std::nullopt); EXPECT(removed && f.read("instance/config/sub/b.toml") == "b = mine again" && !f.exists("instance/config/game.json")); wipe(f.path("instance/config")); (void)link_all(f, false, true); f.put("instance/config/sub/b.toml", "b = harvested"); f.put("pack/config/sub/b.toml", "b = harvested"); auto settled = links.unlink(std::nullopt); EXPECT(settled && settled->at(0).outcome == packutils::links::Removed::Outcome::removed && !f.exists("instance/config")); } void test_a_version_1_manifest_is_upgraded_without_losing_an_edit() { const Fixture f; const auto links = f.links(); (void)link_all(f, false, true); const auto config = links.load()->at(0); const std::string where = manifest_for(f, "config"); EXPECT(!where.empty()); auto stamp = packutils::fs::stamp(f.path("pack/config/sub/b.toml")); // What the Zig engine wrote: only the source's stamp. f.put(where, "{\n \"version\": 1,\n \"files\": {\n \"sub/b.toml\": [\n " + std::to_string(stamp->size) + ",\n " + std::to_string(stamp->mtime_ns) + "\n ]\n },\n \"directories\": [\n \"sub\"\n ]\n}\n"); f.put("instance/config/sub/b.toml", "b = mine"); auto upgraded = links.sync(config); EXPECT(upgraded && upgraded->copied == 0 && upgraded->unknown == std::vector{"sub/b.toml"}); EXPECT(f.read("instance/config/sub/b.toml") == "b = mine"); EXPECT(f.read(where).contains("\"version\": 2") && f.read(where).contains("\"sha512\": null")); } void test_harvest_finds_what_the_instance_changed() { const Fixture f; const auto links = f.links(); (void)link_all(f, false, true); f.put("instance/config/sub/b.toml", "b = mine"); f.put("instance/config/new.toml", "new"); f.put("instance/mods/extra.jar", "jar2"); f.put("instance/kubejs/startup.js", "js"); f.put("instance/options.txt", "fov:90"); (void)packutils::fs::make_path(f.path("instance/saves/w")); (void)packutils::fs::make_path(f.path("instance/.hidden")); packutils::links::HarvestRequest request{.instance = f.instance, .pack_folders = {"config", "mods"}, .never = {"saves", "logs"}, .folders = {"kubejs"}, .files = {"options.txt"}, .mods = "mods"}; auto found = links.harvest(request); EXPECT(found.has_value()); std::vector seen; for (const auto &finding : found->findings) { seen.push_back(finding.kind + " " + finding.path); EXPECT(finding.sha512.size() == 128); } EXPECT((seen == std::vector{"added config/new.toml", "edited config/sub/b.toml", "added kubejs/startup.js", "jar mods/extra.jar", "file options.txt"})); EXPECT(found->candidates == std::vector{"kubejs"}); request.folders = {"saves"}; auto folder = links.harvest(request); EXPECT(!folder && folder.error().code == "HarvestFolderInvalid"); request.folders = {}; request.files = {"servers.dat"}; auto missing = links.harvest(request); EXPECT(!missing && missing.error().code == "HarvestFileMissing" && missing.error().kind == PU_KIND_NOT_FOUND); // A harvest reads only. EXPECT(f.read("pack/config/sub/b.toml") == "b = 1" && !f.exists("pack/kubejs")); } void test_harvest_waits_for_the_links_lock() { const Fixture f; if (!f.locks_work()) { return; // Wine. } (void)link_all(f, false, true); auto held = packutils::fs::FileLock::try_acquire(f.path("pack/.packwand/lock")); EXPECT(held && *held); const auto links = f.links({.lock_files = true, .lock_timeout_ms = 60, .links_unavailable = false, .lock_held = false}); auto refused = links.harvest({.instance = f.instance, .pack_folders = {}, .never = {}, .folders = {}, .files = {}, .mods = "mods"}); EXPECT(!refused && refused.error().code == "LinksLockTimeout"); } void test_abi_holds_the_lock_between_calls() { const Fixture f; (void)link_all(f, false, true); pu_error err{}; pu_runtime *rt = nullptr; EXPECT(packutils_create(&f.mock.host, PACKUTILS_ABI, &rt, &err) == PU_OK); pu_result out{}; const std::string pack = request(f, R"("lock_timeout_ms":60)"); EXPECT(packutils_lock(rt, str(pack), &out, &err) == PU_OK); EXPECT(packutils_lock(rt, str(pack), &out, &err) == PU_INVALID); // Operations on the locked pack do not take the lock again, so they do not wait for it. const std::string plan = request(f, R"("instance":)" + json_text(f.instance) + R"(,"pack_folders":["config","mods"],"lock_timeout_ms":60)"); EXPECT(packutils_harvest_plan(rt, str(plan), &out, &err) == PU_OK); EXPECT(view(out.json) == R"({"findings":[],"candidates":[]})"); if (f.locks_work()) { // Another process (another runtime) does wait. pu_runtime *other = nullptr; EXPECT(packutils_create(&f.mock.host, PACKUTILS_ABI, &other, &err) == PU_OK); EXPECT(packutils_harvest_plan(other, str(plan), &out, &err) == PU_FAILED && view(err.code) == "LinksLockTimeout"); packutils_unlock(rt); EXPECT(packutils_harvest_plan(other, str(plan), &out, &err) == PU_OK); packutils_destroy(other); } packutils_unlock(rt); packutils_destroy(rt); } } // namespace int main() { test_sha2(); test_json(); test_records(); test_mirror(); test_junction_buffer(); test_link_unlink(); test_force_and_restore(); test_unlink_refuses_a_replaced_link(); test_copy_follows_the_source(); test_unavailable_links_fall_back_to_a_copy(); test_instance_inside_the_pack(); test_lock_times_out(); test_abi(); test_unlink_one_instance_and_nothing_recorded(); test_sync_recreates_a_deleted_copy_and_refuses_a_file(); test_a_link_inside_a_copied_folder_is_never_followed(); test_an_unrecorded_link_to_the_pack_is_adopted(); test_a_taken_backup_name_gets_a_suffix(); test_slots_and_instances_that_are_files(); test_without_locks_and_canceled(); test_an_unreadable_folder_is_a_file_system_failure(); test_abi_refuses_malformed_requests(); test_a_prism_instance_names_its_game_directory(); test_sync_never_overwrites_an_instance_edit(); test_a_version_1_manifest_is_upgraded_without_losing_an_edit(); test_harvest_finds_what_the_instance_changed(); test_harvest_waits_for_the_links_lock(); test_abi_holds_the_lock_between_calls(); if (failures != 0) { std::fprintf(stderr, "%d check(s) failed\n", failures); return 1; } return 0; }