From f37981ba697c334d9b6a7fce5c772677f97440e3 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 16:20:32 +1300 Subject: [PATCH 01/37] Added Catch2 and subprocess.h with unit and functional test targets --- .github/workflows/build.yml | 6 +- .gitmodules | 6 ++ CMakeLists.txt | 43 ++++++++++++ Dockerfile | 4 +- extern/Catch2 | 1 + extern/subprocess.h | 1 + test/CMakeLists.txt | 87 ++++++++++++++++++++++++ test/fixtures/test_environment.h | 30 +++++++++ test/functional/test_about.cpp | 58 ++++++++++++++++ test/functional/test_version.cpp | 34 ++++++++++ test/subprocess_helper.cpp | 112 +++++++++++++++++++++++++++++++ test/subprocess_helper.h | 91 +++++++++++++++++++++++++ test/unit/util/test_capacity.cpp | 36 ++++++++++ 13 files changed, 506 insertions(+), 3 deletions(-) create mode 160000 extern/Catch2 create mode 160000 extern/subprocess.h create mode 100644 test/CMakeLists.txt create mode 100644 test/fixtures/test_environment.h create mode 100644 test/functional/test_about.cpp create mode 100644 test/functional/test_version.cpp create mode 100644 test/subprocess_helper.cpp create mode 100644 test/subprocess_helper.h create mode 100644 test/unit/util/test_capacity.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 683d7cd..ceb8cc3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -92,11 +92,13 @@ jobs: submodules: true # Raw cmake rather than make: the Makefile needs a POSIX shell, and the - # shipped build gets its own tree rather than the everyday build/dev + # shipped build gets its own tree rather than the everyday build/dev. + # Testing is off because this job ships the binary; test.yml builds and + # runs the tests on Windows in a tree of its own. - name: Build shell: bash run: | - cmake -B build/release -G "Visual Studio 17 2022" -A x64 -DMPQCLI_BUILD_APP=ON + cmake -B build/release -G "Visual Studio 17 2022" -A x64 -DMPQCLI_BUILD_APP=ON -DMPQCLI_BUILD_TESTING=OFF cmake --build build/release --config Release mv build/release/bin/mpqcli.exe ./mpqcli-windows-x86_64.exe diff --git a/.gitmodules b/.gitmodules index 286ba9b..8e2d305 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,9 @@ [submodule "extern/hash-library"] path = extern/hash-library url = https://github.com/stbrumme/hash-library.git +[submodule "extern/Catch2"] + path = extern/Catch2 + url = https://github.com/catchorg/Catch2.git +[submodule "extern/subprocess.h"] + path = extern/subprocess.h + url = https://github.com/sheredom/subprocess.h.git diff --git a/CMakeLists.txt b/CMakeLists.txt index d3260c2..79b7305 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,6 +6,9 @@ project(mpqcli VERSION 0.13.0) # carry the project prefix; a bare BUILD_STATIC claims a name anyone might want. option(MPQCLI_BUILD_APP "Build the mpqcli binary" ON) option(MPQCLI_BUILD_STATIC "Build static binary" OFF) +# On only when mpqcli is the top-level project, so a consumer that adds it with +# add_subdirectory does not get its tests in their own CTest run +option(MPQCLI_BUILD_TESTING "Build mpqcli tests" ${PROJECT_IS_TOP_LEVEL}) # Off by default, so a new compiler release adding a warning cannot stop a # developer working. The Linux CI test builds turn it on, and check_lint holds # the same bar: clang-tidy replays these flags and .clang-tidy treats every @@ -107,6 +110,42 @@ add_library(mpqcli_hash_library STATIC target_include_directories(mpqcli_hash_library SYSTEM INTERFACE "${PROJECT_SOURCE_DIR}/extern") add_library(mpqcli::hash_library ALIAS mpqcli_hash_library) +# Catch2 runs every test layer, and subprocess.h lets the functional layer spawn +# the binary +if(MPQCLI_BUILD_TESTING) + if (NOT EXISTS "${PROJECT_SOURCE_DIR}/extern/Catch2/CMakeLists.txt") + message(FATAL_ERROR +"Missing dependency: Catch2 +mpqcli tests require the Catch2 testing framework. +It is provided as a submodule of this repository. +Did you forget to execute the following commands? + git submodule init + git submodule update") + endif() + + if (NOT EXISTS "${PROJECT_SOURCE_DIR}/extern/subprocess.h/subprocess.h") + message(FATAL_ERROR +"Missing dependency: subprocess.h +mpqcli functional tests require the subprocess.h library to spawn the binary. +It is provided as a submodule of this repository. +Did you forget to execute the following commands? + git submodule init + git submodule update") + endif() + + # enable_testing() rather than include(CTest): include(CTest) also declares a + # global BUILD_TESTING cache variable, which switches on the self-tests of any + # submodule that defers to it. It must run before the add_subdirectory that + # registers tests, or CTest finds none. + enable_testing() + + add_subdirectory(extern/Catch2) + mark_system(Catch2) + + list(APPEND CMAKE_MODULE_PATH "${PROJECT_SOURCE_DIR}/extern/Catch2/extras") + include(Catch) +endif() + # Unconditional: building the core without the app is what proves nothing in # src/ has picked up a dependency on app/. add_subdirectory(src) @@ -114,3 +153,7 @@ add_subdirectory(src) if(MPQCLI_BUILD_APP) add_subdirectory(app) endif() + +if(MPQCLI_BUILD_TESTING) + add_subdirectory(test) +endif() diff --git a/Dockerfile b/Dockerfile index 1cef2dc..28cca35 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,11 +13,13 @@ WORKDIR /src COPY . . # MPQCLI_BUILD_STATIC is what makes the scratch stage viable: the binary carries -# musl and libstdc++ with it and needs no loader at runtime +# musl and libstdc++ with it and needs no loader at runtime. The image ships the +# binary alone, so the tests are not built. RUN cmake -B build/release \ -DCMAKE_BUILD_TYPE=Release \ -DMPQCLI_BUILD_APP=ON \ -DMPQCLI_BUILD_STATIC=ON \ + -DMPQCLI_BUILD_TESTING=OFF \ && cmake --build build/release --parallel "$(nproc)" \ && strip build/release/bin/mpqcli diff --git a/extern/Catch2 b/extern/Catch2 new file mode 160000 index 0000000..317ac1e --- /dev/null +++ b/extern/Catch2 @@ -0,0 +1 @@ +Subproject commit 317ac1ed4c0bb6e6b91eafc817e05c488feffcb3 diff --git a/extern/subprocess.h b/extern/subprocess.h new file mode 160000 index 0000000..8a4715c --- /dev/null +++ b/extern/subprocess.h @@ -0,0 +1 @@ +Subproject commit 8a4715c126433c0f55abe7c10431830864a18e74 diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt new file mode 100644 index 0000000..bfd4f02 --- /dev/null +++ b/test/CMakeLists.txt @@ -0,0 +1,87 @@ +# Each test layer is a binary of its own, because each links something different. +# unit/ mirrors src/, one file per source file; functional/ has one file per +# subcommand. Fixtures shared by every layer live in fixtures/, one header each. +# +# Every target registers with a LABELS value, which is what lets ctest -L select a +# layer exactly, and with SKIP_RETURN_CODE 4, which is what makes Catch2's SKIP() +# a skip rather than a failure. + +# Unit tests link the core and call it directly, never recompiling its sources. +# The core's PUBLIC include directory gives them every header in src/, private +# helpers included. +add_executable(mpqcli_unit_tests + unit/util/test_capacity.cpp +) + +target_include_directories(mpqcli_unit_tests PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/fixtures" +) + +target_link_libraries(mpqcli_unit_tests PRIVATE + mpqcli::core + mpqcli::warnings + Catch2::Catch2WithMain +) + +catch_discover_tests(mpqcli_unit_tests + PROPERTIES LABELS "unit" SKIP_RETURN_CODE 4) + +# The functional layer spawns the binary, so without the app there is nothing +# for it to test +if(NOT MPQCLI_BUILD_APP) + return() +endif() + +add_executable(mpqcli_functional_tests + subprocess_helper.cpp + functional/test_about.cpp + functional/test_version.cpp +) + +# Project-owned test headers use PRIVATE without SYSTEM: the helper beside this +# file, and the fixtures every layer shares +target_include_directories(mpqcli_functional_tests PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}" + "${CMAKE_CURRENT_SOURCE_DIR}/fixtures" +) + +# extern/subprocess.h is the submodule directory (the repository is literally +# named "subprocess.h"). Marked SYSTEM in a call of its own, never combined with +# the project-owned paths above. +target_include_directories(mpqcli_functional_tests SYSTEM PRIVATE + "${PROJECT_SOURCE_DIR}/extern/subprocess.h" +) + +# Links neither the core nor the binary: the layer drives mpqcli through its CLI +# as a user would, and linking the core would let a test quietly call a function +# instead of running the command +target_link_libraries(mpqcli_functional_tests PRIVATE + mpqcli::warnings + Catch2::Catch2WithMain +) + +# A build order, not a link: building the test target on its own must still +# produce a fresh binary for it to spawn +add_dependencies(mpqcli_functional_tests mpqcli) + +if(WIN32) + set(MPQCLI_BINARY_PATH "${PROJECT_BINARY_DIR}/bin/mpqcli.exe") +else() + set(MPQCLI_BINARY_PATH "${PROJECT_BINARY_DIR}/bin/mpqcli") +endif() + +# MPQCLI_BINARY_PATH_OVERRIDE points the functional tests at a binary other than +# the one just built: a downloaded release asset, or an installed copy, to check +# a published artefact behaves. Unset, which is the normal case including in CI, +# the build-tree path above is used. +if(MPQCLI_BINARY_PATH_OVERRIDE) + set(MPQCLI_BINARY_PATH "${MPQCLI_BINARY_PATH_OVERRIDE}") +endif() + +target_compile_definitions(mpqcli_functional_tests PRIVATE + MPQCLI_BINARY_PATH="${MPQCLI_BINARY_PATH}" + MPQCLI_TEST_DIR="${CMAKE_CURRENT_SOURCE_DIR}" +) + +catch_discover_tests(mpqcli_functional_tests + PROPERTIES LABELS "functional" SKIP_RETURN_CODE 4) diff --git a/test/fixtures/test_environment.h b/test/fixtures/test_environment.h new file mode 100644 index 0000000..eebc639 --- /dev/null +++ b/test/fixtures/test_environment.h @@ -0,0 +1,30 @@ +#ifndef TEST_ENVIRONMENT_H +#define TEST_ENVIRONMENT_H + +#include + +namespace mpqcli::testing { + +/// Singleton that exposes the paths CMake baked into the test binary at configure time +/// +/// The one fixture that is not function-scoped: these values are constant for the whole +/// run and cannot vary per test. +struct TestEnvironment { + static const TestEnvironment &Instance() { + static TestEnvironment env; + return env; + } + + /// The mpqcli binary the functional tests spawn + const std::filesystem::path binary_path{MPQCLI_BINARY_PATH}; + + /// The test/ source directory, for inputs committed under test/data/ + const std::filesystem::path test_dir{MPQCLI_TEST_DIR}; + +private: + TestEnvironment() = default; +}; + +} // namespace mpqcli::testing + +#endif // TEST_ENVIRONMENT_H diff --git a/test/functional/test_about.cpp b/test/functional/test_about.cpp new file mode 100644 index 0000000..269d2d7 --- /dev/null +++ b/test/functional/test_about.cpp @@ -0,0 +1,58 @@ +#include +#include +#include +#include +#include + +#include "subprocess_helper.h" +#include "test_environment.h" + +using namespace mpqcli::testing; +using Catch::Matchers::ContainsSubstring; + +TEST_CASE("about: prints the program information", "[about]") { + const auto &env = TestEnvironment::Instance(); + + auto result = Run(env.binary_path, {"about"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + // The version line carries what the version subcommand prints, so the + // two cannot drift apart + auto version = Run(env.binary_path, {"version"}); + REQUIRE(version.returncode == 0); + // Fixed text in a fixed order: the lines are the contract, not a set + REQUIRE(LinesToVector(result.stdout_output) == + std::vector{ + "Name: mpqcli", + "Version: " + TrimTrailingNewlines(version.stdout_output), + "Author: Thomas Laurenson", + "License: MIT", + "GitHub: https://github.com/thegraydot/mpqcli", + "Dependencies:", + " - StormLib (https://github.com/ladislav-zezula/StormLib)", + " - CLI11 (https://github.com/CLIUtils/CLI11)", + " - hash-library (https://github.com/stbrumme/hash-library)", + }); +} + +TEST_CASE("about: prints its help with --help", "[about]") { + const auto &env = TestEnvironment::Instance(); + + auto result = Run(env.binary_path, {"about", "--help"}); + + REQUIRE(result.returncode == 0); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("about [OPTIONS]")); +} + +TEST_CASE("about: rejects extra arguments", "[about]") { + const auto &env = TestEnvironment::Instance(); + + auto result = Run(env.binary_path, {"about", "extra"}); + + // 109 is CLI11's ExtrasError, forwarded unchanged by app.exit() + REQUIRE(result.returncode == 109); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("The following argument was not expected: extra")); +} diff --git a/test/functional/test_version.cpp b/test/functional/test_version.cpp new file mode 100644 index 0000000..a257b4b --- /dev/null +++ b/test/functional/test_version.cpp @@ -0,0 +1,34 @@ +#include +#include +#include + +#include "subprocess_helper.h" +#include "test_environment.h" + +using namespace mpqcli::testing; + +TEST_CASE("version: prints the version and commit hash", "[version]") { + const auto &env = TestEnvironment::Instance(); + auto result = Run(env.binary_path, {"version"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + + // One line: the project version, a dash, then either the 40-digit commit + // hash or "unknown" for a build that had no git to ask + auto lines = LinesToSet(result.stdout_output); + REQUIRE(lines.size() == 1); + REQUIRE_THAT(*lines.begin(), + Catch::Matchers::Matches("[0-9]+\\.[0-9]+\\.[0-9]+-([0-9a-f]{40}|unknown)")); +} + +TEST_CASE("version: rejects extra arguments", "[version]") { + const auto &env = TestEnvironment::Instance(); + auto result = Run(env.binary_path, {"version", "extra"}); + + // 109 is CLI11's ExtrasError, forwarded unchanged by app.exit() + REQUIRE(result.returncode == 109); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, Catch::Matchers::ContainsSubstring( + "The following argument was not expected: extra")); +} diff --git a/test/subprocess_helper.cpp b/test/subprocess_helper.cpp new file mode 100644 index 0000000..310134c --- /dev/null +++ b/test/subprocess_helper.cpp @@ -0,0 +1,112 @@ +#include "subprocess_helper.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace mpqcli::testing { + +namespace { + +// Drains a pipe to EOF as raw bytes. fread rather than getline so binary output, +// such as a printed signature, comes back byte for byte +std::string ReadAll(FILE *stream) { + std::string output; + if (stream == nullptr) { + return output; + } + char buffer[4096]; + std::size_t count = 0; + while ((count = std::fread(buffer, 1, sizeof(buffer), stream)) > 0) { + output.append(buffer, count); + } + return output; +} + +} // namespace + +RunResult Run(const fs::path &binary, const std::vector &args, + const RunOptions &options) { + // subprocess.h wants a NULL-terminated argv of C strings, so the std::strings + // have to outlive the pointers taken into them + std::vector arguments; + arguments.reserve(args.size() + 1); + arguments.push_back(binary.string()); + arguments.insert(arguments.end(), args.begin(), args.end()); + std::vector argv; + argv.reserve(arguments.size() + 1); + for (const auto &argument : arguments) { + argv.push_back(argument.c_str()); + } + argv.push_back(nullptr); + + std::string working_dir; + const char *working_dir_ptr = nullptr; + if (options.working_dir.has_value()) { + working_dir = options.working_dir->string(); + working_dir_ptr = working_dir.c_str(); + } + + subprocess_s process{}; + const int spawn_options = subprocess_option_inherit_environment | subprocess_option_no_window; + if (subprocess_create_ex(argv.data(), spawn_options, nullptr, working_dir_ptr, &process) != 0) { + throw std::runtime_error("subprocess_helper: failed to spawn " + binary.string()); + } + + // Write any input, then close stdin so a command reading paths from stdin + // sees EOF instead of waiting forever. subprocess_join closes it too, but + // only after the output has been read, which is too late for such a command + FILE *stdin_stream = subprocess_stdin(&process); + if (stdin_stream != nullptr) { + if (options.stdin_input.has_value()) { + std::fwrite(options.stdin_input->data(), 1, options.stdin_input->size(), stdin_stream); + } + std::fclose(stdin_stream); + process.stdin_file = nullptr; + } + + RunResult result; + + // Watchdog: a hung binary is killed and reported rather than hanging the + // whole ctest run + std::mutex finished_mutex; + std::condition_variable finished_cv; + bool finished = false; + std::thread watchdog([&] { + std::unique_lock lock(finished_mutex); + if (!finished_cv.wait_for(lock, options.timeout, [&] { return finished; })) { + result.timed_out = true; + subprocess_terminate(&process); + } + }); + + // stderr is drained on its own thread: a process that fills one pipe while + // the parent is blocked reading the other would otherwise deadlock + std::thread stderr_reader([&] { result.stderr_output = ReadAll(subprocess_stderr(&process)); }); + result.stdout_output = ReadAll(subprocess_stdout(&process)); + stderr_reader.join(); + + int returncode = 0; + subprocess_join(&process, &returncode); + { + std::lock_guard lock(finished_mutex); + finished = true; + } + finished_cv.notify_all(); + watchdog.join(); + subprocess_destroy(&process); + + result.returncode = result.timed_out ? -1 : returncode; + return result; +} + +} // namespace mpqcli::testing diff --git a/test/subprocess_helper.h b/test/subprocess_helper.h new file mode 100644 index 0000000..d702109 --- /dev/null +++ b/test/subprocess_helper.h @@ -0,0 +1,91 @@ +#ifndef SUBPROCESS_HELPER_H +#define SUBPROCESS_HELPER_H + +#include +#include +#include +#include +#include +#include +#include + +namespace mpqcli::testing { + +/// How to run the binary: optional stdin content, a working directory and a wall-clock limit +struct RunOptions { + std::optional stdin_input; + std::optional working_dir; + std::chrono::seconds timeout{60}; +}; + +/// What the binary did: both streams as raw bytes, its exit code, and whether the limit hit +/// +/// returncode is -1 when the process was killed for exceeding the timeout. +struct RunResult { + std::string stdout_output; + std::string stderr_output; + int returncode = -1; + bool timed_out = false; +}; + +/// Spawns binary with args, waits for it to exit, and returns what it did +/// +/// Every functional test is written against this signature; how it is implemented is +/// the helper's own business. Throws std::runtime_error if the process cannot be spawned +/// at all, which is a broken test environment rather than a result to assert on. +/// +/// @param binary Path to the executable to run. +/// @param args Arguments after the program name, one string each, unquoted. +/// @param options Optional stdin content, working directory and timeout. +/// @return The captured streams and exit code. +RunResult Run(const std::filesystem::path &binary, const std::vector &args, + const RunOptions &options = {}); + +/// Splits output into a set of lines for order-independent comparison +/// +/// A trailing carriage return is stripped from each line, so output captured on Windows +/// compares equal to the same output on POSIX. +inline std::set LinesToSet(const std::string &output, bool skip_empty = false) { + std::set result; + std::istringstream stream(output); + std::string line; + while (std::getline(stream, line)) { + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + if (skip_empty && line.empty()) { + continue; + } + result.insert(line); + } + return result; +} + +/// Splits output into its lines in order, for output where order or repetition matters +/// +/// A trailing carriage return is stripped from each line, as LinesToSet does. An empty +/// output gives no lines. +inline std::vector LinesToVector(const std::string &output) { + std::vector result; + std::istringstream stream(output); + std::string line; + while (std::getline(stream, line)) { + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + result.push_back(line); + } + return result; +} + +/// Returns output with trailing newlines removed, for a command that prints one value +inline std::string TrimTrailingNewlines(std::string output) { + while (!output.empty() && (output.back() == '\n' || output.back() == '\r')) { + output.pop_back(); + } + return output; +} + +} // namespace mpqcli::testing + +#endif // SUBPROCESS_HELPER_H diff --git a/test/unit/util/test_capacity.cpp b/test/unit/util/test_capacity.cpp new file mode 100644 index 0000000..568ea60 --- /dev/null +++ b/test/unit/util/test_capacity.cpp @@ -0,0 +1,36 @@ +#include + +#include "util/capacity.h" + +using mpqcli::CalculateMpqMaxFileValue; +using mpqcli::NextPowerOfTwo; + +TEST_CASE("NextPowerOfTwo", "[capacity]") { + SECTION("returns an exact power of two unchanged") { + REQUIRE(NextPowerOfTwo(1U) == 1U); + REQUIRE(NextPowerOfTwo(2U) == 2U); + REQUIRE(NextPowerOfTwo(64U) == 64U); + REQUIRE(NextPowerOfTwo(0x80000000U) == 0x80000000U); + } + + SECTION("rounds anything else up to the next power of two") { + REQUIRE(NextPowerOfTwo(3U) == 4U); + REQUIRE(NextPowerOfTwo(33U) == 64U); + REQUIRE(NextPowerOfTwo(65U) == 128U); + REQUIRE(NextPowerOfTwo(0x40000001U) == 0x80000000U); + } +} + +TEST_CASE("CalculateMpqMaxFileValue", "[capacity]") { + SECTION("never declares fewer than 32 entries") { + REQUIRE(CalculateMpqMaxFileValue(0U) == 32U); + REQUIRE(CalculateMpqMaxFileValue(29U) == 32U); + } + + SECTION("counts three special entries before rounding up") { + // 30 files and the three special entries make 33, one more than fits in 32 + REQUIRE(CalculateMpqMaxFileValue(30U) == 64U); + REQUIRE(CalculateMpqMaxFileValue(61U) == 64U); + REQUIRE(CalculateMpqMaxFileValue(62U) == 128U); + } +} From f0f8d9c1a77ac7e89c2f1ec4ea5e642fb2afe8c6 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 20:11:01 +1300 Subject: [PATCH 02/37] Added the Catch2 functional layer ported from the pytest suite --- CMakeLists.txt | 4 + test/CMakeLists.txt | 23 +- test/archive_helper.cpp | 76 +++ test/archive_helper.h | 47 ++ test/fixtures/compressible_text.h | 30 + test/fixtures/integration_data.h | 56 ++ test/fixtures/many_locales_archive.h | 77 +++ test/fixtures/no_listfile_archive.h | 70 ++ test/fixtures/one_locale_archive.h | 42 ++ test/fixtures/read_file.h | 26 + test/fixtures/sample_archive.h | 70 ++ test/fixtures/sample_files.h | 72 ++ test/fixtures/temp_dir.h | 44 ++ test/fixtures/write_file.h | 23 + test/functional/test_add.cpp | 949 ++++++++++++++++++++++++++ test/functional/test_compact.cpp | 184 +++++ test/functional/test_completion.cpp | 379 +++++++++++ test/functional/test_create.cpp | 978 +++++++++++++++++++++++++++ test/functional/test_extract.cpp | 682 +++++++++++++++++++ test/functional/test_info.cpp | 317 +++++++++ test/functional/test_list.cpp | 606 +++++++++++++++++ test/functional/test_read.cpp | 358 ++++++++++ test/functional/test_remove.cpp | 470 +++++++++++++ test/functional/test_rename.cpp | 464 +++++++++++++ test/functional/test_verify.cpp | 166 +++++ 25 files changed, 6212 insertions(+), 1 deletion(-) create mode 100644 test/archive_helper.cpp create mode 100644 test/archive_helper.h create mode 100644 test/fixtures/compressible_text.h create mode 100644 test/fixtures/integration_data.h create mode 100644 test/fixtures/many_locales_archive.h create mode 100644 test/fixtures/no_listfile_archive.h create mode 100644 test/fixtures/one_locale_archive.h create mode 100644 test/fixtures/read_file.h create mode 100644 test/fixtures/sample_archive.h create mode 100644 test/fixtures/sample_files.h create mode 100644 test/fixtures/temp_dir.h create mode 100644 test/fixtures/write_file.h create mode 100644 test/functional/test_add.cpp create mode 100644 test/functional/test_compact.cpp create mode 100644 test/functional/test_completion.cpp create mode 100644 test/functional/test_create.cpp create mode 100644 test/functional/test_extract.cpp create mode 100644 test/functional/test_info.cpp create mode 100644 test/functional/test_list.cpp create mode 100644 test/functional/test_read.cpp create mode 100644 test/functional/test_remove.cpp create mode 100644 test/functional/test_rename.cpp create mode 100644 test/functional/test_verify.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 79b7305..5381822 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,6 +9,10 @@ option(MPQCLI_BUILD_STATIC "Build static binary" OFF) # On only when mpqcli is the top-level project, so a consumer that adds it with # add_subdirectory does not get its tests in their own CTest run option(MPQCLI_BUILD_TESTING "Build mpqcli tests" ${PROJECT_IS_TOP_LEVEL}) +# The real-world archives a few functional tests read. They are never committed; +# a test whose archive is absent is skipped rather than failed. +set(MPQCLI_INTEGRATION_DATA "${PROJECT_SOURCE_DIR}/test/data/integration" CACHE PATH + "Directory of real-world archives for the tests that need them") # Off by default, so a new compiler release adding a warning cannot stop a # developer working. The Linux CI test builds turn it on, and check_lint holds # the same bar: clang-tidy replays these flags and .clang-tidy treats every diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index bfd4f02..b3783af 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -33,8 +33,20 @@ if(NOT MPQCLI_BUILD_APP) endif() add_executable(mpqcli_functional_tests + archive_helper.cpp subprocess_helper.cpp functional/test_about.cpp + functional/test_add.cpp + functional/test_compact.cpp + functional/test_completion.cpp + functional/test_create.cpp + functional/test_extract.cpp + functional/test_info.cpp + functional/test_list.cpp + functional/test_read.cpp + functional/test_remove.cpp + functional/test_rename.cpp + functional/test_verify.cpp functional/test_version.cpp ) @@ -54,9 +66,11 @@ target_include_directories(mpqcli_functional_tests SYSTEM PRIVATE # Links neither the core nor the binary: the layer drives mpqcli through its CLI # as a user would, and linking the core would let a test quietly call a function -# instead of running the command +# instead of running the command. hash-library is vendored, not the core: the +# verify tests compare a printed signature by its MD5. target_link_libraries(mpqcli_functional_tests PRIVATE mpqcli::warnings + mpqcli::hash_library Catch2::Catch2WithMain ) @@ -83,5 +97,12 @@ target_compile_definitions(mpqcli_functional_tests PRIVATE MPQCLI_TEST_DIR="${CMAKE_CURRENT_SOURCE_DIR}" ) +# Cleared, the tests fall back to the MPQCLI_INTEGRATION_DATA environment +# variable at runtime, and skip when neither names a directory that exists +if(MPQCLI_INTEGRATION_DATA) + target_compile_definitions(mpqcli_functional_tests PRIVATE + MPQCLI_INTEGRATION_DATA="${MPQCLI_INTEGRATION_DATA}") +endif() + catch_discover_tests(mpqcli_functional_tests PROPERTIES LABELS "functional" SKIP_RETURN_CODE 4) diff --git a/test/archive_helper.cpp b/test/archive_helper.cpp new file mode 100644 index 0000000..8828924 --- /dev/null +++ b/test/archive_helper.cpp @@ -0,0 +1,76 @@ +#include "archive_helper.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "subprocess_helper.h" +#include "test_environment.h" + +namespace fs = std::filesystem; + +namespace mpqcli::testing { + +std::set ArchiveEntries(const fs::path &archive, bool all_files, + const std::optional &listfile) { + std::vector args = {"list", archive.string(), "-d", "-q", "locale"}; + if (all_files) { + args.push_back("-a"); + } + if (listfile.has_value()) { + args.push_back("-l"); + args.push_back(listfile->string()); + } + auto result = Run(TestEnvironment::Instance().binary_path, args); + REQUIRE(result.returncode == 0); + return LinesToSet(result.stdout_output); +} + +std::string InfoProperty(const fs::path &archive, const std::string &property) { + auto result = + Run(TestEnvironment::Instance().binary_path, {"info", "-q", property, archive.string()}); + REQUIRE(result.returncode == 0); + return TrimTrailingNewlines(result.stdout_output); +} + +std::string EntryProperty(const fs::path &archive, const std::string &name, + const std::string &property) { + auto result = Run(TestEnvironment::Instance().binary_path, + {"list", archive.string(), "-d", "-q", property}); + REQUIRE(result.returncode == 0); + for (const auto &line : LinesToSet(result.stdout_output)) { + std::istringstream fields(line); + std::string value; + std::string entry; + fields >> value >> entry; + if (entry == name) { + return value; + } + } + FAIL("no entry named " << name << " in " << archive); + return {}; +} + +std::uint64_t EntryNumber(const fs::path &archive, const std::string &name, + const std::string &property) { + return std::stoull(EntryProperty(archive, name, property)); +} + +std::set ReadEntry(const fs::path &archive, const std::string &name, + const std::optional &locale) { + std::vector args = {"read", archive.string(), name}; + if (locale.has_value()) { + args.push_back("--locale"); + args.push_back(*locale); + } + auto result = Run(TestEnvironment::Instance().binary_path, args); + REQUIRE(result.returncode == 0); + return LinesToSet(result.stdout_output, true); +} + +} // namespace mpqcli::testing diff --git a/test/archive_helper.h b/test/archive_helper.h new file mode 100644 index 0000000..83d56f7 --- /dev/null +++ b/test/archive_helper.h @@ -0,0 +1,47 @@ +#ifndef ARCHIVE_HELPER_H +#define ARCHIVE_HELPER_H + +#include +#include +#include +#include +#include + +namespace mpqcli::testing { + +// Queries every subcommand's tests use to read an archive back through the +// binary. Each spawns list, info or read and asserts that the query itself +// succeeded, so a test that reads a wrong value fails on the value rather than +// on a parse of empty output. They belong to the functional layer alone: +// asserting through the binary is the point, and nothing here links the core. + +/// Lists the archive as " " lines +/// +/// @param archive The archive to list. +/// @param all_files Include the special entries such as (listfile). +/// @param listfile An external listfile, for an archive without one of its own. +/// @return One line per entry, order-independent. +std::set ArchiveEntries(const std::filesystem::path &archive, bool all_files = false, + const std::optional &listfile = {}); + +/// Reads one info property back from the archive, as the bare value +std::string InfoProperty(const std::filesystem::path &archive, const std::string &property); + +/// Returns one list property for one entry +/// +/// Reads a "list -d -q " listing, whose lines are " ", and fails +/// the test when no entry has that name. +std::string EntryProperty(const std::filesystem::path &archive, const std::string &name, + const std::string &property); + +/// EntryProperty parsed as a number, for sizes and counts +std::uint64_t EntryNumber(const std::filesystem::path &archive, const std::string &name, + const std::string &property); + +/// Reads one entry back through the read subcommand, as the set of its non-empty lines +std::set ReadEntry(const std::filesystem::path &archive, const std::string &name, + const std::optional &locale = std::nullopt); + +} // namespace mpqcli::testing + +#endif // ARCHIVE_HELPER_H diff --git a/test/fixtures/compressible_text.h b/test/fixtures/compressible_text.h new file mode 100644 index 0000000..20d4efa --- /dev/null +++ b/test/fixtures/compressible_text.h @@ -0,0 +1,30 @@ +#ifndef COMPRESSIBLE_TEXT_H +#define COMPRESSIBLE_TEXT_H + +#include +#include +#include + +namespace mpqcli::testing { + +/// Text that compresses well but not trivially, the same bytes on every run +/// +/// A fixed word sequence from a small linear congruential generator, so different +/// compression algorithms produce visibly different stored sizes and a test can tell +/// them apart. 12000 words is about 68 KB, well over one archive sector. +inline std::string CompressibleText(std::size_t word_count) { + static const char *const words[] = {"alpha", "beta", "gamma", "delta", + "epsilon", "zeta", "eta", "theta"}; + std::uint32_t state = 12345; + std::string text; + for (std::size_t i = 0; i < word_count; ++i) { + state = state * 1664525U + 1013904223U; + text += words[(state >> 24) % 8]; + text += ' '; + } + return text; +} + +} // namespace mpqcli::testing + +#endif // COMPRESSIBLE_TEXT_H diff --git a/test/fixtures/integration_data.h b/test/fixtures/integration_data.h new file mode 100644 index 0000000..94ac90b --- /dev/null +++ b/test/fixtures/integration_data.h @@ -0,0 +1,56 @@ +#ifndef INTEGRATION_DATA_H +#define INTEGRATION_DATA_H + +#include +#include +#include +#include +#include + +namespace mpqcli::testing { + +/// Returns the directory holding the real-world archives, or an empty optional +/// +/// Checks in order: +/// 1. The MPQCLI_INTEGRATION_DATA compile-time define, which CMake bakes in from the +/// cache variable of the same name (by default test/data/integration/) +/// 2. The MPQCLI_INTEGRATION_DATA environment variable at runtime +/// +/// @return The directory, or nullopt if neither source names one that exists. +inline std::optional IntegrationDataPath() { +#ifdef MPQCLI_INTEGRATION_DATA + { + std::filesystem::path path{MPQCLI_INTEGRATION_DATA}; + if (std::filesystem::exists(path)) { + return path; + } + } +#endif + if (const char *env = std::getenv("MPQCLI_INTEGRATION_DATA")) { + std::filesystem::path path{env}; + if (std::filesystem::exists(path)) { + return path; + } + } + return std::nullopt; +} + +/// Returns one real-world archive from the integration data, skipping the test if absent +/// +/// The two Blizzard patches are the only archives carrying a genuine Blizzard signature, +/// one weak and one strong, since StormLib can only sign weakly. They are not committed, +/// so a checkout without them still runs green. SKIP() leaves the test case, so the +/// return after it is never reached. +inline std::filesystem::path IntegrationArchive(const std::string &name) { + const auto data = IntegrationDataPath(); + if (!data || !std::filesystem::exists(*data / name)) { + SKIP("integration archive " << name + << " is absent; set MPQCLI_INTEGRATION_DATA or run make " + "fetch_integration_data"); + } + return *data / name; +} + +} // namespace mpqcli::testing + +#endif // INTEGRATION_DATA_H diff --git a/test/fixtures/many_locales_archive.h b/test/fixtures/many_locales_archive.h new file mode 100644 index 0000000..1fc6b73 --- /dev/null +++ b/test/fixtures/many_locales_archive.h @@ -0,0 +1,77 @@ +#ifndef MANY_LOCALES_ARCHIVE_H +#define MANY_LOCALES_ARCHIVE_H + +#include +#include +#include +#include + +#include "subprocess_helper.h" +#include "temp_dir.h" +#include "test_environment.h" +#include "write_file.h" + +namespace mpqcli::testing { + +/// One locale variant of cats.txt; an empty locale is the default one +struct CatsVariant { + std::string locale; + std::string content; +}; + +/// The variants the locale fixtures store, in the order they go in +inline std::vector CatsVariants() { + return { + {"", "This is a file about cats."}, + {"041D", "Detta aer en fil om katter."}, + {"deDE", "Dies ist eine Datei ueber Katzen."}, + {"esES", "Este es un archivo sobre gatos."}, + }; +} + +/// The content stored under locale, or under the default locale when empty +inline std::string CatsContent(const std::string &locale) { + for (const auto &variant : CatsVariants()) { + if (variant.locale == locale) { + return variant.content; + } + } + throw std::runtime_error("CatsContent: no variant for locale " + locale); +} + +/// An archive holding cats.txt under the default locale and three others +/// +/// Created from the default variant, then each other variant is added under its +/// locale, one of them the hexadecimal 041D that the locale table does not name. What +/// the read, extract, remove and rename tests use to tell the variants apart. A failed +/// create or add throws, since that is a broken environment rather than a result. +struct ManyLocalesArchive { + TempDir dir; + std::filesystem::path files_dir; + std::filesystem::path archive; + + ManyLocalesArchive() + : files_dir(dir.Path() / "locale_files"), + archive(dir.Path() / "mpq_with_many_locales.mpq") { + const auto &binary = TestEnvironment::Instance().binary_path; + const auto cats = files_dir / "cats.txt"; + for (const auto &variant : CatsVariants()) { + WriteFile(cats, variant.content); + const auto result = + variant.locale.empty() + ? Run(binary, + {"create", "--format-version", "1", archive.string(), files_dir.string()}) + : Run(binary, + {"add", archive.string(), cats.string(), "--locale", variant.locale}); + if (result.returncode != 0) { + throw std::runtime_error("ManyLocalesArchive: storing locale '" + variant.locale + + "' exited " + std::to_string(result.returncode) + ": " + + result.stderr_output); + } + } + } +}; + +} // namespace mpqcli::testing + +#endif // MANY_LOCALES_ARCHIVE_H diff --git a/test/fixtures/no_listfile_archive.h b/test/fixtures/no_listfile_archive.h new file mode 100644 index 0000000..9523b07 --- /dev/null +++ b/test/fixtures/no_listfile_archive.h @@ -0,0 +1,70 @@ +#ifndef NO_LISTFILE_ARCHIVE_H +#define NO_LISTFILE_ARCHIVE_H + +#include +#include +#include +#include + +#include "subprocess_helper.h" +#include "temp_dir.h" +#include "test_environment.h" +#include "write_file.h" + +namespace mpqcli::testing { + +/// An archive with no internal (listfile) and one entry per locale +/// +/// Created with --file-flags1 0 so no (listfile) is written, then two more files are +/// added under a language code and a hexadecimal LCID. Without a listfile the entries +/// list as File0000000N.xxx, which is what the list, rename and compact tests need; a +/// test that wants the names back writes an external listfile of its own. A failed +/// create or add throws, since that is a broken environment rather than a result. +struct NoListfileArchive { + /// Name, locale and content of one entry; an empty locale is the default one + struct Entry { + std::string name; + std::string locale; + std::string content; + }; + + TempDir dir; + std::filesystem::path files_dir; + std::filesystem::path archive; + + /// The entries in the order they went in + static std::vector Entries() { + return { + {"capybaras.txt", "", "This is a file about capybaras."}, + {"cats.txt", "deDE", "Dies ist eine Datei ueber Katzen."}, + {"dogs.txt", "041D", "Detta aer en fil om hundar."}, + }; + } + + NoListfileArchive() + : files_dir(dir.Path() / "locale_files"), + archive(dir.Path() / "mpq_without_internal_listfile.mpq") { + const auto &binary = TestEnvironment::Instance().binary_path; + for (const auto &entry : Entries()) { + const auto path = files_dir / entry.name; + WriteFile(path, entry.content); + // The first entry creates the archive from the directory, which holds + // only that file at this point; the rest are added under their locale + const auto result = + entry.locale.empty() + ? Run(binary, + {"create", archive.string(), "--file-flags1", "0", files_dir.string()}) + : Run(binary, + {"add", archive.string(), path.string(), "--locale", entry.locale}); + if (result.returncode != 0) { + throw std::runtime_error("NoListfileArchive: building " + entry.name + " exited " + + std::to_string(result.returncode) + ": " + + result.stderr_output); + } + } + } +}; + +} // namespace mpqcli::testing + +#endif // NO_LISTFILE_ARCHIVE_H diff --git a/test/fixtures/one_locale_archive.h b/test/fixtures/one_locale_archive.h new file mode 100644 index 0000000..ecd2214 --- /dev/null +++ b/test/fixtures/one_locale_archive.h @@ -0,0 +1,42 @@ +#ifndef ONE_LOCALE_ARCHIVE_H +#define ONE_LOCALE_ARCHIVE_H + +#include +#include +#include + +#include "many_locales_archive.h" +#include "subprocess_helper.h" +#include "temp_dir.h" +#include "test_environment.h" +#include "write_file.h" + +namespace mpqcli::testing { + +/// An archive holding cats.txt under esES alone, so no default-locale variant exists +/// +/// What a lookup without a locale, or with the wrong one, falls back to when there is +/// nothing to fall back to. +struct OneLocaleArchive { + static inline const std::string locale = "esES"; + + TempDir dir; + std::filesystem::path archive; + + OneLocaleArchive() : archive(dir.Path() / "mpq_with_one_locale.mpq") { + const auto files_dir = dir.Path() / "locale_files"; + WriteFile(files_dir / "cats.txt", CatsContent(locale)); + const auto result = Run(TestEnvironment::Instance().binary_path, + {"create", "--format-version", "1", archive.string(), + files_dir.string(), "--locale", locale}); + if (result.returncode != 0) { + throw std::runtime_error("OneLocaleArchive: create exited " + + std::to_string(result.returncode) + ": " + + result.stderr_output); + } + } +}; + +} // namespace mpqcli::testing + +#endif // ONE_LOCALE_ARCHIVE_H diff --git a/test/fixtures/read_file.h b/test/fixtures/read_file.h new file mode 100644 index 0000000..b8e76cd --- /dev/null +++ b/test/fixtures/read_file.h @@ -0,0 +1,26 @@ +#ifndef READ_FILE_H +#define READ_FILE_H + +#include +#include +#include +#include +#include +#include + +namespace mpqcli::testing { + +/// Reads a file byte for byte; throws when it cannot be opened +/// +/// Binary mode on every platform, so what came back is what is on disk. +inline std::string ReadFile(const std::filesystem::path &path) { + std::ifstream in(path, std::ios::binary); + if (!in.is_open()) { + throw std::runtime_error("ReadFile: cannot open " + path.string()); + } + return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); +} + +} // namespace mpqcli::testing + +#endif // READ_FILE_H diff --git a/test/fixtures/sample_archive.h b/test/fixtures/sample_archive.h new file mode 100644 index 0000000..6044349 --- /dev/null +++ b/test/fixtures/sample_archive.h @@ -0,0 +1,70 @@ +#ifndef SAMPLE_ARCHIVE_H +#define SAMPLE_ARCHIVE_H + +#include +#include +#include +#include + +#include "sample_files.h" +#include "subprocess_helper.h" +#include "test_environment.h" + +namespace mpqcli::testing { + +/// An archive built by running create over SampleFiles, one per test +/// +/// The extra arguments go to create as given, so a test picks the game profile or +/// setting overrides its archive needs. Building it through the binary rather than by +/// writing bytes keeps the layer honest: every archive a test reads is one mpqcli made. +/// A failed create throws, since that is a broken environment rather than a result. +struct SampleArchive { + SampleFiles files; + std::filesystem::path archive; + + /// @param create_args Extra arguments for create, such as a game profile. + /// @param archive_name The archive's file name. The default output directory of an + /// extract is the archive's stem, so a test extracting beside the archive + /// picks a name whose stem is not the sample files' own directory. + explicit SampleArchive(const std::vector &create_args = {}, + const std::string &archive_name = "files.mpq") + : archive(files.dir.Path() / archive_name) { + std::vector args = {"create", archive.string()}; + args.insert(args.end(), create_args.begin(), create_args.end()); + args.push_back(files.files_dir.string()); + const auto result = Run(TestEnvironment::Instance().binary_path, args); + if (result.returncode != 0) { + throw std::runtime_error("SampleArchive: create exited " + + std::to_string(result.returncode) + ": " + + result.stderr_output); + } + } +}; + +/// Create arguments for an archive carrying a (listfile) and a full (attributes) block +/// +/// The recipe the pytest suite built its shared archives with, so the sizes, offsets +/// and keys that info, list and extract assert on are the ones it saw. 4294967295 is +/// MPQ_FILE_DEFAULT_INTERNAL, 15 is every attribute, and 66048 compresses and encrypts. +inline std::vector AttributedArchiveArgs(const std::string &version = "1") { + return {"--format-version", + version, + "--file-flags1", + "4294967295", + "--file-flags2", + "4294967295", + "--file-flags3", + "0", + "--attr-flags", + "15", + "--flags", + "66048", + "--compression", + "2", + "--compression-next", + "4294967295"}; +} + +} // namespace mpqcli::testing + +#endif // SAMPLE_ARCHIVE_H diff --git a/test/fixtures/sample_files.h b/test/fixtures/sample_files.h new file mode 100644 index 0000000..3a06107 --- /dev/null +++ b/test/fixtures/sample_files.h @@ -0,0 +1,72 @@ +#ifndef SAMPLE_FILES_H +#define SAMPLE_FILES_H + +#include +#include +#include +#include +#include +#include + +#include "temp_dir.h" +#include "write_file.h" + +namespace mpqcli::testing { + +/// The modification time every sample file carries: 2025-07-29 14:31:00 local time +/// +/// An archive's (attributes) block stores each file's time, so the archive sizes and +/// listings the tests assert only hold when the inputs always carry the same one. Local +/// time, because that is what mpqcli prints back in a detailed listing. +inline std::filesystem::file_time_type SampleFileTime() { + std::tm calendar{}; + calendar.tm_year = 2025 - 1900; + calendar.tm_mon = 7 - 1; + calendar.tm_mday = 29; + calendar.tm_hour = 14; + calendar.tm_min = 31; + calendar.tm_sec = 0; + calendar.tm_isdst = -1; + const auto system_time = std::chrono::system_clock::from_time_t(std::mktime(&calendar)); + // C++17 has no clock_cast, so the two clocks are bridged through their current + // readings, which land a few microseconds either side of the target. Rounding to + // the second is required, not tidying: mpqcli stores the whole seconds stat() + // reports, so a time just short of 14:31:00 is stored as 14:30:59 and every + // archive size shifts. Whether it falls short depends on which now() the compiler + // happens to evaluate first. Every standard library's file clock epoch is a whole + // number of seconds from the system clock's, so the rounded time stays exact. + const auto system_now = std::chrono::system_clock::now(); + const auto file_now = std::filesystem::file_time_type::clock::now(); + return std::chrono::round(file_now + (system_time - system_now)); +} + +/// The three files every archive-building test starts from, in a scratch directory +/// +/// cats.txt and dogs.txt are 27 bytes with LF endings, and bytes is the eight values +/// 0x00 to 0x07, matching what the pytest suite generated. +struct SampleFiles { + TempDir dir; + std::filesystem::path files_dir; + + SampleFiles() : files_dir(dir.Path() / "files") { + const auto time = SampleFileTime(); + for (const auto &[name, content] : Contents()) { + const auto path = files_dir / name; + WriteFile(path, content); + std::filesystem::last_write_time(path, time); + } + } + + /// Name and content of each sample file + static std::vector> Contents() { + return { + {"cats.txt", "This is a file about cats.\n"}, + {"dogs.txt", "This is a file about dogs.\n"}, + {"bytes", std::string("\x00\x01\x02\x03\x04\x05\x06\x07", 8)}, + }; + } +}; + +} // namespace mpqcli::testing + +#endif // SAMPLE_FILES_H diff --git a/test/fixtures/temp_dir.h b/test/fixtures/temp_dir.h new file mode 100644 index 0000000..78f4a0d --- /dev/null +++ b/test/fixtures/temp_dir.h @@ -0,0 +1,44 @@ +#ifndef TEMP_DIR_H +#define TEMP_DIR_H + +#include +#include +#include +#include +#include + +namespace mpqcli::testing { + +/// A uniquely named scratch directory, removed with everything in it when the fixture dies +/// +/// Every test that writes anything writes here. ctest runs test cases as separate +/// processes in parallel, so the name carries 64 random bits rather than a counter, +/// which would collide across processes. The destructor swallows the removal error: +/// throwing out of it would mask whichever assertion actually failed. +class TempDir { +public: + TempDir() { + std::random_device device; + std::ostringstream name; + name << "mpqcli_test_" << std::hex << device() << device(); + path_ = std::filesystem::temp_directory_path() / name.str(); + std::filesystem::create_directories(path_); + } + + TempDir(const TempDir &) = delete; + TempDir &operator=(const TempDir &) = delete; + + ~TempDir() { + std::error_code ec; + std::filesystem::remove_all(path_, ec); + } + + const std::filesystem::path &Path() const { return path_; } + +private: + std::filesystem::path path_; +}; + +} // namespace mpqcli::testing + +#endif // TEMP_DIR_H diff --git a/test/fixtures/write_file.h b/test/fixtures/write_file.h new file mode 100644 index 0000000..1b9f778 --- /dev/null +++ b/test/fixtures/write_file.h @@ -0,0 +1,23 @@ +#ifndef WRITE_FILE_H +#define WRITE_FILE_H + +#include +#include +#include +#include + +namespace mpqcli::testing { + +/// Writes content to path byte for byte, creating parent directories as needed +/// +/// Binary mode on every platform, so a "\n" in the content stays one byte on Windows +/// and the sizes a test expects hold everywhere. +inline void WriteFile(const std::filesystem::path &path, const std::string &content) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream out(path, std::ios::binary); + out.write(content.data(), static_cast(content.size())); +} + +} // namespace mpqcli::testing + +#endif // WRITE_FILE_H diff --git a/test/functional/test_add.cpp b/test/functional/test_add.cpp new file mode 100644 index 0000000..8b55c7f --- /dev/null +++ b/test/functional/test_add.cpp @@ -0,0 +1,949 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#endif + +#include "archive_helper.h" +#include "compressible_text.h" +#include "many_locales_archive.h" +#include "sample_archive.h" +#include "sample_files.h" +#include "subprocess_helper.h" +#include "temp_dir.h" +#include "test_environment.h" +#include "write_file.h" + +namespace fs = std::filesystem; + +using namespace mpqcli::testing; +using Catch::Matchers::ContainsSubstring; + +namespace { + +// The binary every case spawns: a reference into the singleton, so it is the +// baked path itself rather than a copy that could drift from it +const fs::path &mpqcli_binary = TestEnvironment::Instance().binary_path; + +// What a fresh SampleArchive lists, before any add +const std::set sample_entries = {"enUS bytes", "enUS cats.txt", "enUS dogs.txt"}; +const std::set original_cats = {"This is a file about cats."}; + +std::set Plus(std::set entries, + std::initializer_list extra) { + entries.insert(extra.begin(), extra.end()); + return entries; +} + +// The message add prints on stderr when it left pre-existing files alone +const std::string skipped_hint = + "[*] 1 file(s) already in the archive were skipped. Use --overwrite to replace them, or " + "--update to replace only the ones that changed."; + +// The line --update prints once it has walked a directory +std::string UpdateSummary(const fs::path &dir, int added, int skipped) { + return "[*] For " + dir.string() + ": " + std::to_string(added) + " files added, " + + std::to_string(skipped) + " files skipped, 0 files failed."; +} + +} // namespace + +TEST_CASE("add: prints its help with --help", "[add]") { + auto result = Run(mpqcli_binary, {"add", "--help"}); + + REQUIRE(result.returncode == 0); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("add [OPTIONS] archive files...")); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("--update")); + REQUIRE(result.stderr_output.empty()); +} + +TEST_CASE("add: archive does not exist", "[add]") { + SampleFiles files; + + auto result = + Run(mpqcli_binary, {"add", "no-such-archive.mpq", (files.files_dir / "cats.txt").string()}); + + // 105 is CLI11's ValidationError, from the CLI::ExistingFile check + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("File does not exist: no-such-archive.mpq")); +} + +TEST_CASE("add: fails when the archive is not an MPQ", "[add]") { + SampleFiles files; + + auto result = Run(mpqcli_binary, {"add", (files.files_dir / "cats.txt").string(), + (files.files_dir / "dogs.txt").string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("[!] Failed to open MPQ archive")); +} + +TEST_CASE("add: input path does not exist", "[add]") { + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), "no-such-file.txt"}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[!] Path does not exist: no-such-file.txt"}); + REQUIRE(ArchiveEntries(sample.archive) == sample_entries); +} + +TEST_CASE("add: continues past a missing input and still fails", "[add]") { + SampleArchive sample; + const auto real = sample.files.dir.Path() / "real.txt"; + WriteFile(real, "real file"); + + auto result = + Run(mpqcli_binary, {"add", sample.archive.string(), "no-such-file.txt", real.string()}); + + // The run carries on to the remaining inputs, but a missing one is still a failure + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[!] Path does not exist: no-such-file.txt", + "[+] Adding file: real.txt"}); + REQUIRE(ArchiveEntries(sample.archive) == Plus(sample_entries, {"enUS real.txt"})); +} + +#ifndef _WIN32 +// A FIFO exists but is neither a file nor a directory. Windows has no mkfifo, +// so the case is POSIX only. +TEST_CASE("add: rejects an input that is neither file nor directory", "[add]") { + SampleArchive sample; + const auto fifo = sample.files.dir.Path() / "pipe"; + REQUIRE(mkfifo(fifo.c_str(), 0600) == 0); + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), fifo.string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[!] Not a file or directory: " + fifo.string()}); + REQUIRE(ArchiveEntries(sample.archive) == sample_entries); +} +#endif + +TEST_CASE("add: a single file", "[add]") { + SampleArchive sample; + const auto file = sample.files.dir.Path() / "test.txt"; + WriteFile(file, "This is a test file for MPQ addition."); + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), file.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == std::set{"[+] Adding file: test.txt"}); + REQUIRE(ArchiveEntries(sample.archive) == Plus(sample_entries, {"enUS test.txt"})); + REQUIRE(ReadEntry(sample.archive, "test.txt") == + std::set{"This is a test file for MPQ addition."}); +} + +TEST_CASE("add: several files", "[add]") { + SampleArchive sample; + const auto alpha = sample.files.dir.Path() / "alpha.txt"; + const auto beta = sample.files.dir.Path() / "beta.txt"; + WriteFile(alpha, "Alpha file content."); + WriteFile(beta, "Beta file content."); + + auto result = + Run(mpqcli_binary, {"add", sample.archive.string(), alpha.string(), beta.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[+] Adding file: alpha.txt", "[+] Adding file: beta.txt"}); + REQUIRE(ArchiveEntries(sample.archive) == + Plus(sample_entries, {"enUS alpha.txt", "enUS beta.txt"})); +} + +TEST_CASE("add: a single file with --path", "[add]") { + const auto flag = GENERATE(as{}, "-p", "--path"); + CAPTURE(flag); + SampleArchive sample; + const auto file = sample.files.dir.Path() / "test.txt"; + WriteFile(file, "This is a test file for MPQ addition."); + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), file.string(), flag, + "important\\message.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[+] Adding file: important\\message.txt"}); + REQUIRE(ArchiveEntries(sample.archive) == + Plus(sample_entries, {"enUS important\\message.txt"})); +} + +TEST_CASE("add: several files with --path prefix", "[add]") { + SampleArchive sample; + const auto test0 = sample.files.dir.Path() / "test0.txt"; + const auto test1 = sample.files.dir.Path() / "test1.txt"; + WriteFile(test0, "This is a test file for MPQ addition."); + WriteFile(test1, "This is another test file for MPQ addition."); + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), test0.string(), + test1.string(), "--path", "texts"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[+] Adding file: texts\\test0.txt", + "[+] Adding file: texts\\test1.txt"}); + REQUIRE(ArchiveEntries(sample.archive) == + Plus(sample_entries, {"enUS texts\\test0.txt", "enUS texts\\test1.txt"})); +} + +TEST_CASE("add: a directory and a file together", "[add]") { + SampleArchive sample; + const auto extra_dir = sample.files.dir.Path() / "extra"; + WriteFile(extra_dir / "new.txt", "new file"); + const auto cats = sample.files.files_dir / "cats.txt"; + + // With a directory among the inputs, a single file takes the prefix and + // its own name rather than the whole --path as its archive path + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), extra_dir.string(), + cats.string(), "--path", "pre"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE( + LinesToSet(result.stderr_output) == + std::set{"[+] Adding file: pre\\new.txt", "[+] Adding file: pre\\cats.txt"}); + REQUIRE(ArchiveEntries(sample.archive) == + Plus(sample_entries, {"enUS pre\\new.txt", "enUS pre\\cats.txt"})); +} + +TEST_CASE("add: skips an existing file by default", "[add]") { + SampleArchive sample; + REQUIRE(ReadEntry(sample.archive, "cats.txt") == original_cats); + const auto cats = sample.files.dir.Path() / "cats.txt"; + WriteFile(cats, "Attempting to make this file about dogs."); + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), cats.string()}); + + // Leaving the archived copy alone is the documented default, not a failure + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[!] File already exists in MPQ archive: cats.txt - Skipping...", + skipped_hint}); + REQUIRE(ReadEntry(sample.archive, "cats.txt") == original_cats); +} + +TEST_CASE("add: replaces an existing file with --overwrite", "[add]") { + const auto flag = GENERATE(as{}, "-w", "--overwrite"); + CAPTURE(flag); + SampleArchive sample; + REQUIRE(ReadEntry(sample.archive, "cats.txt") == original_cats); + const auto cats = sample.files.dir.Path() / "cats.txt"; + WriteFile(cats, "This file is suddenly about dogs."); + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), cats.string(), flag}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE( + LinesToSet(result.stderr_output) == + std::set{"[~] File already exists in MPQ archive: cats.txt - Overwriting...", + "[+] Adding file: cats.txt"}); + REQUIRE(ReadEntry(sample.archive, "cats.txt") == + std::set{"This file is suddenly about dogs."}); +} + +TEST_CASE("add: --overwrite adds a file that is not in the archive", "[add]") { + SampleArchive sample; + const auto file = sample.files.dir.Path() / "test.txt"; + WriteFile(file, "This file is newly added."); + + auto result = + Run(mpqcli_binary, {"add", sample.archive.string(), file.string(), "--overwrite"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == std::set{"[+] Adding file: test.txt"}); + REQUIRE(ReadEntry(sample.archive, "test.txt") == + std::set{"This file is newly added."}); +} + +TEST_CASE("add: rejects an unknown locale", "[add]") { + SampleArchive sample; + const auto horses = sample.files.dir.Path() / "horses.txt"; + WriteFile(horses, "This is a file about horses."); + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), horses.string(), "--locale", + "illegal_locale"}); + + // 105 is CLI11's ValidationError, from the locale check + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("Locale must be nothing, or one of")); + REQUIRE(ArchiveEntries(sample.archive) == sample_entries); +} + +TEST_CASE("add: stores the file under the given locale", "[add]") { + const auto flag = GENERATE(as{}, "-e", "--locale"); + // A language code, and a locale given as a hexadecimal LCID + const auto locale = GENERATE(as{}, "esES", "041D"); + CAPTURE(flag, locale); + SampleArchive sample; + const auto cats = sample.files.dir.Path() / "cats.txt"; + WriteFile(cats, "Este es un archivo sobre gatos."); + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), cats.string(), flag, locale}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[+] Adding file for locale " + locale + ": cats.txt"}); + // A second variant beside the default one, not a replacement of it + REQUIRE(ArchiveEntries(sample.archive) == Plus(sample_entries, {locale + " cats.txt"})); + REQUIRE(ReadEntry(sample.archive, "cats.txt", locale) == + std::set{"Este es un archivo sobre gatos."}); + REQUIRE(ReadEntry(sample.archive, "cats.txt") == original_cats); +} + +TEST_CASE("add: replaces only the given locale's variant with --overwrite", "[add]") { + SampleArchive sample; + const auto cats = sample.files.dir.Path() / "cats.txt"; + WriteFile(cats, "Este es un archivo sobre gatos."); + REQUIRE(Run(mpqcli_binary, {"add", sample.archive.string(), cats.string(), "--locale", "esES"}) + .returncode == 0); + WriteFile(cats, "Este archivo ahora es sobre perros."); + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), cats.string(), "--locale", + "esES", "--overwrite"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{ + "[~] File for locale esES already exists in MPQ archive: cats.txt - Overwriting...", + "[+] Adding file for locale esES: cats.txt"}); + REQUIRE(ReadEntry(sample.archive, "cats.txt", "esES") == + std::set{"Este archivo ahora es sobre perros."}); + REQUIRE(ReadEntry(sample.archive, "cats.txt") == original_cats); +} + +TEST_CASE("add: re-adds a file after every locale variant was removed", "[add]") { + ManyLocalesArchive many; + for (const auto &variant : CatsVariants()) { + std::vector args = {"remove", many.archive.string(), "cats.txt"}; + if (!variant.locale.empty()) { + args.insert(args.end(), {"--locale", variant.locale}); + } + REQUIRE(Run(mpqcli_binary, args).returncode == 0); + } + REQUIRE(ArchiveEntries(many.archive).empty()); + const auto cats = many.files_dir / "cats.txt"; + WriteFile(cats, "This is a file about cats."); + + auto result = Run(mpqcli_binary, {"add", many.archive.string(), cats.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == std::set{"[+] Adding file: cats.txt"}); + REQUIRE(ReadEntry(many.archive, "cats.txt") == original_cats); +} + +TEST_CASE("add: applies the game profile's compression", "[add]") { + // Profile, and the flag letters a .txt file added under it gets + const auto [profile, expected_flags] = GENERATE(table({ + {"generic", "ce"}, + {"diablo1", "ie"}, + {"starcraft1", "ce2"}, + {"wow1", "c"}, + {"wow2", "cr"}, + {"starcraft2", "c"}, + {"diablo3", "c"}, + })); + CAPTURE(profile); + SampleArchive sample; + const auto name = "test_" + profile + ".txt"; + const auto file = sample.files.dir.Path() / name; + WriteFile(file, "Test file for " + profile + " profile."); + + auto result = + Run(mpqcli_binary, {"add", sample.archive.string(), file.string(), "--game", profile}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE( + LinesToSet(result.stderr_output) == + std::set{"[*] Using game profile: " + profile, "[+] Adding file: " + name}); + const auto flags = EntryProperty(sample.archive, name, "flags"); + CAPTURE(flags); + for (const char letter : expected_flags) { + CAPTURE(letter); + REQUIRE(flags.find(letter) != std::string::npos); + } +} + +TEST_CASE("add: rejects an unknown game profile", "[add]") { + SampleArchive sample; + const auto file = sample.files.dir.Path() / "test_invalid.txt"; + WriteFile(file, "Test file for invalid profile."); + + auto result = Run(mpqcli_binary, + {"add", sample.archive.string(), file.string(), "-g", "invalid_profile"}); + + // 105 is CLI11's ValidationError, from the game profile check + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("Game profile must be one of")); + REQUIRE(ArchiveEntries(sample.archive) == sample_entries); +} + +TEST_CASE("add: accepts every game profile", "[add]") { + const auto profile = + GENERATE(as{}, "generic", "diablo1", "lordsofmagic", "starcraft1", "warcraft2", + "diablo2", "warcraft3", "warcraft3-map", "wow1", "wow2", "wow3", "wow4", "wow5", + "starcraft2", "diablo3"); + CAPTURE(profile); + SampleArchive sample; + const auto name = "test_all_" + profile + ".txt"; + const auto file = sample.files.dir.Path() / name; + WriteFile(file, "Test file for " + profile + " profile."); + + auto result = + Run(mpqcli_binary, {"add", sample.archive.string(), file.string(), "-g", profile}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE( + LinesToSet(result.stderr_output) == + std::set{"[*] Using game profile: " + profile, "[+] Adding file: " + name}); + REQUIRE(ArchiveEntries(sample.archive) == Plus(sample_entries, {"enUS " + name})); + // Every profile compresses or implodes a text file + const auto flags = EntryProperty(sample.archive, name, "flags"); + CAPTURE(flags); + REQUIRE((flags.find('c') != std::string::npos || flags.find('i') != std::string::npos)); +} + +TEST_CASE("add: a directory keeps its structure", "[add]") { + SampleArchive sample; + const auto add_dir = sample.files.dir.Path() / "add_dir"; + WriteFile(add_dir / "sub" / "a.txt", "Sub file A."); + WriteFile(add_dir / "b.txt", "Root file B."); + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), add_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[+] Adding file: sub\\a.txt", "[+] Adding file: b.txt"}); + REQUIRE(ArchiveEntries(sample.archive) == + Plus(sample_entries, {"enUS sub\\a.txt", "enUS b.txt"})); +} + +TEST_CASE("add: a directory with --path prefix", "[add]") { + SampleArchive sample; + const auto add_dir = sample.files.dir.Path() / "add_dir_prefix"; + WriteFile(add_dir / "sub" / "a.txt", "Sub file A."); + WriteFile(add_dir / "b.txt", "Root file B."); + + auto result = Run(mpqcli_binary, + {"add", sample.archive.string(), add_dir.string(), "--path", "textures"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[+] Adding file: textures\\sub\\a.txt", + "[+] Adding file: textures\\b.txt"}); + REQUIRE(ArchiveEntries(sample.archive) == + Plus(sample_entries, {"enUS textures\\sub\\a.txt", "enUS textures\\b.txt"})); +} + +TEST_CASE("add: replaces existing files from a directory with --overwrite", "[add]") { + SampleArchive sample; + REQUIRE(ReadEntry(sample.archive, "cats.txt") == original_cats); + const auto add_dir = sample.files.dir.Path() / "add_dir_overwrite"; + WriteFile(add_dir / "cats.txt", "This is the replaced cats file."); + + auto result = + Run(mpqcli_binary, {"add", sample.archive.string(), add_dir.string(), "--overwrite"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE( + LinesToSet(result.stderr_output) == + std::set{"[~] File already exists in MPQ archive: cats.txt - Overwriting...", + "[+] Adding file: cats.txt"}); + REQUIRE(ReadEntry(sample.archive, "cats.txt") == + std::set{"This is the replaced cats file."}); +} + +TEST_CASE("add: skips existing files from a directory by default", "[add]") { + SampleArchive sample; + REQUIRE(ReadEntry(sample.archive, "cats.txt") == original_cats); + const auto add_dir = sample.files.dir.Path() / "add_dir_conflict"; + WriteFile(add_dir / "cats.txt", "Conflicting cats content."); + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), add_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[!] File already exists in MPQ archive: cats.txt - Skipping...", + skipped_hint}); + REQUIRE(ReadEntry(sample.archive, "cats.txt") == original_cats); +} + +TEST_CASE("add: skips special files inside a directory", "[add]") { + SampleArchive sample; + const auto add_dir = sample.files.dir.Path() / "special"; + WriteFile(add_dir / "(listfile)", "fake content"); + WriteFile(add_dir / "real.txt", "real file"); + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), add_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[*] Skipping special MPQ file: (listfile)", + "[+] Adding file: real.txt"}); + // The archive's own (listfile) is the only one, and the planted copy never went in + REQUIRE(ArchiveEntries(sample.archive, true) == + Plus(sample_entries, {"enUS (listfile)", "enUS real.txt"})); + REQUIRE(InfoProperty(sample.archive, "file-count") == "5"); +} + +TEST_CASE("add: skips the archive inside an input directory", "[add]") { + TempDir dir; + const auto input_dir = dir.Path() / "mod"; + WriteFile(input_dir / "a.txt", "A file."); + const auto other = dir.Path() / "b.txt"; + WriteFile(other, "B file."); + const auto archive = input_dir / "mod.mpq"; + REQUIRE(Run(mpqcli_binary, {"create", archive.string(), other.string()}).returncode == 0); + + auto result = Run(mpqcli_binary, {"add", archive.string(), input_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[*] Skipping the archive itself: " + archive.string(), + "[+] Adding file: a.txt"}); + REQUIRE(ArchiveEntries(archive) == std::set{"enUS a.txt", "enUS b.txt"}); +} + +TEST_CASE("add: skips the archive named as an input", "[add]") { + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), sample.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[*] Skipping the archive itself: " + sample.archive.string()}); + REQUIRE(ArchiveEntries(sample.archive) == sample_entries); +} + +TEST_CASE("add: grows the file table when the archive is full", "[add]") { + SampleArchive sample; + REQUIRE(InfoProperty(sample.archive, "max-files") == "64"); + // Three sample files and the (listfile) leave sixty free slots + const auto fill_dir = sample.files.dir.Path() / "fill"; + for (int i = 0; i < 60; ++i) { + WriteFile(fill_dir / ("f" + std::to_string(i) + ".txt"), "file " + std::to_string(i)); + } + REQUIRE(Run(mpqcli_binary, {"add", sample.archive.string(), fill_dir.string()}).returncode == + 0); + REQUIRE(InfoProperty(sample.archive, "file-count") == "64"); + REQUIRE(InfoProperty(sample.archive, "max-files") == "64"); + const auto extra = sample.files.dir.Path() / "extra.txt"; + WriteFile(extra, "one more"); + + // A second invocation rather than one run of sixty-one: filling and growing + // in the same run fails with "Disk full", a defect rather than behaviour to pin + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), extra.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[+] Adding file: extra.txt"}); + REQUIRE(InfoProperty(sample.archive, "file-count") == "65"); + REQUIRE(InfoProperty(sample.archive, "max-files") == "128"); +} + +TEST_CASE("add: --update skips unchanged files by MD5", "[add]") { + // wow1 stores CRC32, MD5 and FILETIME. The files are written fresh, so the + // timestamp check fails and the decision falls through to MD5 + SampleArchive sample({"--game", "wow1"}); + const auto update_dir = sample.files.dir.Path() / "update_dir_unchanged"; + WriteFile(update_dir / "cats.txt", "This is a file about cats.\n"); + WriteFile(update_dir / "dogs.txt", "This is a file about dogs.\n"); + + auto result = + Run(mpqcli_binary, {"add", sample.archive.string(), update_dir.string(), "--update"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[*] Skipping unchanged file: cats.txt (MD5 matches)", + "[*] Skipping unchanged file: dogs.txt (MD5 matches)", + UpdateSummary(update_dir, 0, 2)}); +} + +TEST_CASE("add: --update replaces changed files", "[add]") { + SampleArchive sample({"--game", "wow1"}); + const auto update_dir = sample.files.dir.Path() / "update_dir_changed"; + WriteFile(update_dir / "cats.txt", "This cat content is completely different and longer now."); + WriteFile(update_dir / "dogs.txt", "This is a file about dogs.\n"); + + auto result = + Run(mpqcli_binary, {"add", sample.archive.string(), update_dir.string(), "--update"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{ + "[~] File already exists in MPQ archive: cats.txt - Overwriting...", + "[+] Adding file: cats.txt", "[*] Skipping unchanged file: dogs.txt (MD5 matches)", + UpdateSummary(update_dir, 1, 1)}); + REQUIRE(ReadEntry(sample.archive, "cats.txt") == + std::set{"This cat content is completely different and longer now."}); +} + +TEST_CASE("add: --update is idempotent", "[add]") { + SampleArchive sample({"--game", "wow1"}); + const auto update_dir = sample.files.dir.Path() / "update_dir_idempotent"; + // Content that differs from the archived copy, so the first run replaces it + WriteFile(update_dir / "cats.txt", "This cat content is completely different and longer now."); + const std::vector args = {"add", sample.archive.string(), update_dir.string(), + "--update"}; + const auto first = Run(mpqcli_binary, args); + REQUIRE(first.returncode == 0); + REQUIRE_THAT(first.stderr_output, ContainsSubstring(UpdateSummary(update_dir, 1, 0))); + + auto result = Run(mpqcli_binary, args); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, !ContainsSubstring("[+] Adding file:")); + REQUIRE_THAT(result.stderr_output, ContainsSubstring(UpdateSummary(update_dir, 0, 1))); + REQUIRE(ReadEntry(sample.archive, "cats.txt") == + std::set{"This cat content is completely different and longer now."}); +} + +TEST_CASE("add: --update skips an unchanged single file", "[add]") { + const auto flag = GENERATE(as{}, "-u", "--update"); + CAPTURE(flag); + SampleArchive sample({"--game", "wow1"}); + const auto cats = sample.files.dir.Path() / "update_single_unchanged" / "cats.txt"; + WriteFile(cats, "This is a file about cats.\n"); + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), cats.string(), flag}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + // A single file gets the skip line alone: the summary is for directories + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[*] Skipping unchanged file: cats.txt (MD5 matches)"}); +} + +TEST_CASE("add: --update replaces a changed single file", "[add]") { + SampleArchive sample({"--game", "wow1"}); + const auto cats = sample.files.dir.Path() / "update_single_changed" / "cats.txt"; + WriteFile(cats, "This cat content is completely different and longer now."); + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), cats.string(), "--update"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE( + LinesToSet(result.stderr_output) == + std::set{"[~] File already exists in MPQ archive: cats.txt - Overwriting...", + "[+] Adding file: cats.txt"}); + REQUIRE(ReadEntry(sample.archive, "cats.txt") == + std::set{"This cat content is completely different and longer now."}); +} + +TEST_CASE("add: --update adds a file that is not in the archive", "[add]") { + SampleArchive sample({"--game", "wow1"}); + const auto file = sample.files.dir.Path() / "new.txt"; + WriteFile(file, "new file"); + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), file.string(), "--update"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == std::set{"[+] Adding file: new.txt"}); + REQUIRE(ReadEntry(sample.archive, "new.txt") == std::set{"new file"}); +} + +TEST_CASE("add: --update and --overwrite exclude each other", "[add]") { + SampleArchive sample; + const auto cats = sample.files.files_dir / "cats.txt"; + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), cats.string(), "-u", "-w"}); + + // 108 is CLI11's ExcludesError + REQUIRE(result.returncode == 108); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("--overwrite excludes --update")); +} + +TEST_CASE("add: --update skips unchanged files by CRC32", "[add]") { + // warcraft3 stores CRC32 and FILETIME but no MD5, and a fresh mtime rules + // the timestamp out, so CRC32 is the check that decides + SampleArchive sample({"--game", "warcraft3"}); + const auto update_dir = sample.files.dir.Path() / "update_dir_crc32_unchanged"; + WriteFile(update_dir / "cats.txt", "This is a file about cats.\n"); + + auto result = + Run(mpqcli_binary, {"add", sample.archive.string(), update_dir.string(), "--update"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[*] Skipping unchanged file: cats.txt (CRC32 matches)", + UpdateSummary(update_dir, 0, 1)}); +} + +TEST_CASE("add: --update replaces same-size changed files by CRC32", "[add]") { + SampleArchive sample({"--game", "warcraft3"}); + const auto update_dir = sample.files.dir.Path() / "update_dir_crc32_changed"; + // The same 27 bytes as the archived copy, so only the content check can tell + WriteFile(update_dir / "cats.txt", "This is a file about CATS.\n"); + + auto result = + Run(mpqcli_binary, {"add", sample.archive.string(), update_dir.string(), "--update"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE( + LinesToSet(result.stderr_output) == + std::set{"[~] File already exists in MPQ archive: cats.txt - Overwriting...", + "[+] Adding file: cats.txt", UpdateSummary(update_dir, 1, 0)}); + REQUIRE(ReadEntry(sample.archive, "cats.txt") == + std::set{"This is a file about CATS."}); +} + +TEST_CASE("add: --update skips unchanged files by timestamp", "[add]") { + // FILETIME only: attr-flags 2 leaves no checksum for the decision to fall to + SampleArchive sample({"--game", "warcraft3", "--attr-flags", "2"}); + const auto update_dir = sample.files.dir.Path() / "update_dir_ts_unchanged"; + const auto cats = update_dir / "cats.txt"; + WriteFile(cats, "This is a file about cats.\n"); + fs::last_write_time(cats, SampleFileTime()); + + auto result = + Run(mpqcli_binary, {"add", sample.archive.string(), update_dir.string(), "--update"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[*] Skipping unchanged file: cats.txt (Timestamp matches)", + UpdateSummary(update_dir, 0, 1)}); +} + +TEST_CASE("add: --update replaces files whose timestamp changed", "[add]") { + SampleArchive sample({"--game", "warcraft3", "--attr-flags", "2"}); + const auto update_dir = sample.files.dir.Path() / "update_dir_ts_changed"; + const auto cats = update_dir / "cats.txt"; + WriteFile(cats, "This is a file about cats.\n"); + fs::last_write_time(cats, SampleFileTime() + std::chrono::hours(1)); + + auto result = + Run(mpqcli_binary, {"add", sample.archive.string(), update_dir.string(), "--update"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE( + LinesToSet(result.stderr_output) == + std::set{"[~] File already exists in MPQ archive: cats.txt - Overwriting...", + "[+] Adding file: cats.txt", UpdateSummary(update_dir, 1, 0)}); +} + +TEST_CASE("add: --update always re-adds without attributes", "[add]") { + // The default profile writes no (attributes), so there is nothing to compare + SampleArchive sample; + const auto update_dir = sample.files.dir.Path() / "update_dir_no_attrs"; + WriteFile(update_dir / "cats.txt", "This is a file about cats.\n"); + + auto result = + Run(mpqcli_binary, {"add", sample.archive.string(), update_dir.string(), "--update"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE( + LinesToSet(result.stderr_output) == + std::set{"[~] File already exists in MPQ archive: cats.txt - Overwriting...", + "[+] Adding file: cats.txt", UpdateSummary(update_dir, 1, 0)}); +} + +TEST_CASE("add: reads input paths from stdin with -", "[add]") { + SampleArchive sample; + const auto stdin_a = sample.files.dir.Path() / "stdin_a.txt"; + const auto stdin_b = sample.files.dir.Path() / "stdin_b.txt"; + WriteFile(stdin_a, "Stdin file A."); + WriteFile(stdin_b, "Stdin file B."); + RunOptions options; + options.stdin_input = stdin_a.string() + "\n" + stdin_b.string() + "\n"; + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), "-"}, options); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[+] Adding file: stdin_a.txt", "[+] Adding file: stdin_b.txt"}); + REQUIRE(ArchiveEntries(sample.archive) == + Plus(sample_entries, {"enUS stdin_a.txt", "enUS stdin_b.txt"})); +} + +TEST_CASE("add: strips Windows line endings from paths on stdin", "[add]") { + SampleArchive sample; + const auto stdin_a = sample.files.dir.Path() / "stdin_a.txt"; + const auto stdin_b = sample.files.dir.Path() / "stdin_b.txt"; + WriteFile(stdin_a, "Stdin file A."); + WriteFile(stdin_b, "Stdin file B."); + RunOptions options; + options.stdin_input = stdin_a.string() + "\r\n" + stdin_b.string() + "\r\n"; + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), "-"}, options); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[+] Adding file: stdin_a.txt", "[+] Adding file: stdin_b.txt"}); + REQUIRE(ArchiveEntries(sample.archive) == + Plus(sample_entries, {"enUS stdin_a.txt", "enUS stdin_b.txt"})); +} + +TEST_CASE("add: - with empty stdin adds nothing", "[add]") { + SampleArchive sample; + RunOptions options; + options.stdin_input = ""; + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), "-"}, options); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(result.stderr_output.empty()); + REQUIRE(ArchiveEntries(sample.archive) == sample_entries); +} + +TEST_CASE("add: --flags sets the added file's flags", "[add]") { + // Flag value, then the letters the flags column must and must not carry + const auto [flags, present, absent] = GENERATE(table({ + {"0", "", "ce"}, // Stored raw: neither compressed nor encrypted + {"512", "c", "e"}, // MPQ_FILE_COMPRESS + {"66048", "ce", ""}, // MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED + })); + CAPTURE(flags); + SampleArchive sample; + const auto file = sample.files.dir.Path() / "text.txt"; + WriteFile(file, CompressibleText(12000)); + + auto result = + Run(mpqcli_binary, {"add", sample.archive.string(), file.string(), "--flags", flags}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == std::set{"[+] Adding file: text.txt"}); + const auto stored = EntryProperty(sample.archive, "text.txt", "flags"); + CAPTURE(stored); + for (const char letter : present) { + CAPTURE(letter); + REQUIRE(stored.find(letter) != std::string::npos); + } + for (const char letter : absent) { + CAPTURE(letter); + REQUIRE(stored.find(letter) == std::string::npos); + } +} + +TEST_CASE("add: --flags 0 stores the file uncompressed", "[add]") { + SampleArchive sample; + const auto file = sample.files.dir.Path() / "text.txt"; + WriteFile(file, CompressibleText(12000)); + + auto result = + Run(mpqcli_binary, {"add", sample.archive.string(), file.string(), "--flags", "0"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == std::set{"[+] Adding file: text.txt"}); + REQUIRE(EntryNumber(sample.archive, "text.txt", "compressed-size") == + EntryNumber(sample.archive, "text.txt", "file-size")); +} + +TEST_CASE("add: --compression selects the algorithm", "[add]") { + TempDir dir; + const auto file = dir.Path() / "text.txt"; + WriteFile(file, CompressibleText(12000)); + // A fresh archive each time, with the same algorithm for every sector so + // only --compression is under test + const auto add_with = [&](const std::string &compression) { + SampleFiles files; + const auto archive = dir.Path() / ("compression_" + compression + ".mpq"); + REQUIRE( + Run(mpqcli_binary, {"create", archive.string(), files.files_dir.string()}).returncode == + 0); + auto result = Run(mpqcli_binary, {"add", archive.string(), file.string(), "--compression", + compression, "--compression-next", compression}); + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[+] Adding file: text.txt"}); + return archive; + }; + + const auto zlib = add_with("2"); // MPQ_COMPRESSION_ZLIB + const auto bzip2 = add_with("16"); // MPQ_COMPRESSION_BZIP2 + + const auto raw_size = EntryNumber(zlib, "text.txt", "file-size"); + const auto zlib_size = EntryNumber(zlib, "text.txt", "compressed-size"); + const auto bzip2_size = EntryNumber(bzip2, "text.txt", "compressed-size"); + CAPTURE(raw_size, zlib_size, bzip2_size); + REQUIRE(zlib_size < raw_size); + REQUIRE(bzip2_size < raw_size); + REQUIRE(zlib_size != bzip2_size); +} + +TEST_CASE("add: --compression-next selects the algorithm after the first sector", "[add]") { + TempDir dir; + const auto file = dir.Path() / "text.txt"; + // Well over one sector, so there are subsequent sectors for the option to reach + WriteFile(file, CompressibleText(12000)); + const auto add_with = [&](const std::string &compression_next) { + SampleFiles files; + const auto archive = dir.Path() / ("next_" + compression_next + ".mpq"); + REQUIRE( + Run(mpqcli_binary, {"create", archive.string(), files.files_dir.string()}).returncode == + 0); + auto result = Run(mpqcli_binary, {"add", archive.string(), file.string(), "--compression", + "2", "--compression-next", compression_next}); + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[+] Adding file: text.txt"}); + return archive; + }; + + const auto all_zlib = add_with("2"); + const auto then_bzip2 = add_with("16"); + + const auto raw_size = EntryNumber(all_zlib, "text.txt", "file-size"); + const auto all_zlib_size = EntryNumber(all_zlib, "text.txt", "compressed-size"); + const auto then_bzip2_size = EntryNumber(then_bzip2, "text.txt", "compressed-size"); + CAPTURE(raw_size, all_zlib_size, then_bzip2_size); + REQUIRE(all_zlib_size < raw_size); + REQUIRE(then_bzip2_size < raw_size); + REQUIRE(all_zlib_size != then_bzip2_size); +} diff --git a/test/functional/test_compact.cpp b/test/functional/test_compact.cpp new file mode 100644 index 0000000..0f5822a --- /dev/null +++ b/test/functional/test_compact.cpp @@ -0,0 +1,184 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "archive_helper.h" +#include "no_listfile_archive.h" +#include "sample_archive.h" +#include "sample_files.h" +#include "subprocess_helper.h" +#include "temp_dir.h" +#include "test_environment.h" +#include "write_file.h" + +namespace fs = std::filesystem; + +using namespace mpqcli::testing; +using Catch::Matchers::ContainsSubstring; + +namespace { + +// The binary every case spawns: a reference into the singleton, so it is the +// baked path itself rather than a copy that could drift from it +const fs::path &mpqcli_binary = TestEnvironment::Instance().binary_path; + +const std::string compacting_notice = "[*] Compacting archive. This may take some time..."; +const std::string incomplete_listfile_error = + "[!] Failed to compact archive: (10007) At least one file name is unknown (listfile is " + "incomplete)"; + +// Bytes no compressor can shrink, the same on every run, so a removed file of +// this size leaves a gap compaction has to reclaim in full +std::string IncompressibleBytes(std::size_t size) { + std::mt19937 generator(0); + std::string bytes; + bytes.reserve(size); + while (bytes.size() < size) { + const auto word = static_cast(generator()); + for (int shift = 0; shift < 32 && bytes.size() < size; shift += 8) { + bytes.push_back(static_cast((word >> shift) & 0xFFU)); + } + } + return bytes; +} + +} // namespace + +TEST_CASE("compact: prints its help with --help", "[compact]") { + auto result = Run(mpqcli_binary, {"compact", "--help"}); + + REQUIRE(result.returncode == 0); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("compact [OPTIONS] archive")); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("--listfile")); +} + +TEST_CASE("compact: archive does not exist", "[compact]") { + auto result = Run(mpqcli_binary, {"compact", "no-such-archive.mpq"}); + + // 105 is CLI11's ValidationError, from the CLI::ExistingFile check + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("File does not exist: no-such-archive.mpq")); +} + +TEST_CASE("compact: listfile does not exist", "[compact]") { + SampleArchive sample; + + auto result = + Run(mpqcli_binary, {"compact", sample.archive.string(), "--listfile", "no-such-list.txt"}); + + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("--listfile: File does not exist: no-such-list.txt")); +} + +TEST_CASE("compact: fails when the archive is not an MPQ", "[compact]") { + SampleFiles files; + + auto result = Run(mpqcli_binary, {"compact", (files.files_dir / "cats.txt").string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output).size() == 1); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("[!] Failed to open MPQ archive")); +} + +TEST_CASE("compact: fails when the archive has no listfile", "[compact]") { + NoListfileArchive archive; + + auto result = Run(mpqcli_binary, {"compact", archive.archive.string()}); + + // Compaction rewrites every entry, so every name has to be known + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{compacting_notice, incomplete_listfile_error}); +} + +TEST_CASE("compact: fails with a partial external listfile", "[compact]") { + NoListfileArchive archive; + const auto listfile = archive.dir.Path() / "listfile.txt"; + WriteFile(listfile, "cats.txt\n"); + + auto result = + Run(mpqcli_binary, {"compact", archive.archive.string(), "-l", listfile.string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{compacting_notice, incomplete_listfile_error}); +} + +TEST_CASE("compact: succeeds with a complete external listfile", "[compact]") { + const auto flag = GENERATE(as{}, "-l", "--listfile"); + CAPTURE(flag); + NoListfileArchive archive; + const auto listfile = archive.dir.Path() / "listfile.txt"; + WriteFile(listfile, "cats.txt\ndogs.txt\ncapybaras.txt\n"); + + auto result = + Run(mpqcli_binary, {"compact", archive.archive.string(), flag, listfile.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == std::vector{compacting_notice}); + // Every entry survives with its content and locale + for (const auto &entry : NoListfileArchive::Entries()) { + CAPTURE(entry.name); + const auto locale = entry.locale.empty() ? std::nullopt : std::optional(entry.locale); + REQUIRE(ReadEntry(archive.archive, entry.name, locale) == + std::set{entry.content}); + } +} + +TEST_CASE("compact: reclaims the space of removed files", "[compact]") { + TempDir dir; + const auto source_dir = dir.Path() / "compaction_files"; + WriteFile(source_dir / "small.txt", "This is a small text file.\n"); + WriteFile(source_dir / "large.bin", IncompressibleBytes(512 * 1024)); + const auto archive = dir.Path() / "mpq_for_compaction.mpq"; + REQUIRE(Run(mpqcli_binary, {"create", archive.string(), source_dir.string()}).returncode == 0); + REQUIRE(Run(mpqcli_binary, {"remove", archive.string(), "large.bin"}).returncode == 0); + const auto size_before = fs::file_size(archive); + + auto result = Run(mpqcli_binary, {"compact", archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == std::vector{compacting_notice}); + const auto size_after = fs::file_size(archive); + CAPTURE(size_before, size_after); + // Removing only marks the entry; compaction is what drops the half-megabyte + REQUIRE(size_after < size_before); + REQUIRE(size_before - size_after > size_before / 2); + REQUIRE(ArchiveEntries(archive) == std::set{"enUS small.txt"}); + REQUIRE(ReadEntry(archive, "small.txt") == std::set{"This is a small text file."}); +} + +TEST_CASE("compact: leaves an archive with nothing to reclaim as it was", "[compact]") { + SampleArchive sample; + const auto size_before = fs::file_size(sample.archive); + const auto entries_before = ArchiveEntries(sample.archive, true); + + auto result = Run(mpqcli_binary, {"compact", sample.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == std::vector{compacting_notice}); + REQUIRE(fs::file_size(sample.archive) == size_before); + REQUIRE(ArchiveEntries(sample.archive, true) == entries_before); + REQUIRE(ReadEntry(sample.archive, "cats.txt") == + std::set{"This is a file about cats."}); +} diff --git a/test/functional/test_completion.cpp b/test/functional/test_completion.cpp new file mode 100644 index 0000000..cb1662c --- /dev/null +++ b/test/functional/test_completion.cpp @@ -0,0 +1,379 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "read_file.h" +#include "subprocess_helper.h" +#include "temp_dir.h" +#include "test_environment.h" + +namespace fs = std::filesystem; + +using namespace mpqcli::testing; +using Catch::Matchers::ContainsSubstring; + +namespace { + +// The binary every case spawns: a reference into the singleton, so it is the +// baked path itself rather than a copy that could drift from it +const fs::path &mpqcli_binary = TestEnvironment::Instance().binary_path; + +// A shell the completion subcommand supports, and the script it embeds +struct Shell { + std::string name; + std::string script; +}; + +const std::vector shells = { + {"bash", "mpqcli.bash"}, + {"zsh", "mpqcli.zsh"}, + {"fish", "mpqcli.fish"}, + {"powershell", "mpqcli.ps1"}, +}; + +// The scripts live in the source tree beside test/, reached through the baked path +fs::path ScriptPath(const Shell &shell) { + return TestEnvironment::Instance().test_dir.parent_path() / "completion" / shell.script; +} + +// The scripts are committed with LF endings while stdout is text mode on +// Windows, so both sides drop carriage returns before comparing +std::string WithoutCarriageReturns(std::string text) { + text.erase(std::remove(text.begin(), text.end(), '\r'), text.end()); + return text; +} + +std::string ScriptOutput(const std::string &shell) { + auto result = Run(mpqcli_binary, {"completion", shell}); + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + return result.stdout_output; +} + +// True when token stands on its own in text, not run into a neighbouring name +// character: "--compression" inside "--compression-next" does not count, and +// nor does "info" inside "_mpqcli_info" +bool HasToken(const std::string &text, const std::string &token) { + const auto is_name_char = [](char c) { + return std::isalnum(static_cast(c)) != 0 || c == '-' || c == '_'; + }; + for (auto pos = text.find(token); pos != std::string::npos; pos = text.find(token, pos + 1)) { + const bool clean_before = pos == 0 || !is_name_char(text[pos - 1]); + const auto end = pos + token.size(); + const bool clean_after = end == text.size() || !is_name_char(text[end]); + if (clean_before && clean_after) { + return true; + } + } + return false; +} + +// How a shell's script spells an option: fish declares "-l name" or "-s n" +// rather than writing the option itself +std::string OptionToken(const Shell &shell, const std::string &option) { + if (shell.name != "fish") { + return option; + } + return option.rfind("--", 0) == 0 ? "-l " + option.substr(2) : "-s " + option.substr(1); +} + +// True when line opens the part of shell's script that completes subcommand +bool OpensSection(const Shell &shell, const std::string &line, const std::string &subcommand) { + const auto start = line.find_first_not_of(" \t"); + if (start == std::string::npos) { + return false; + } + const auto text = line.substr(start); + if (shell.name == "bash") { + // The subcommand's case label + return text == subcommand + ")"; + } + if (shell.name == "zsh") { + // The function that calls _arguments for the subcommand + return text.rfind("_mpqcli_" + subcommand + "()", 0) == 0; + } + if (shell.name == "fish") { + // Each complete command conditioned on the subcommand + return HasToken(text, "__fish_seen_subcommand_from " + subcommand); + } + // The subcommand's table in $optionSpec, not its description in $subcommands + return text.rfind("'" + subcommand + "'", 0) == 0 && text.find("@{") != std::string::npos; +} + +// The part of script that completes subcommand: each line that opens it, with +// the lines below it indented deeper, such as a case body or a fish +// continuation. Fish opens one such block per complete command, the other +// shells a single block. +std::string SubcommandSection(const Shell &shell, const std::string &script, + const std::string &subcommand) { + std::istringstream lines(WithoutCarriageReturns(script)); + std::string section; + std::string line; + bool inside = false; + std::size_t opener_indent = 0; + while (std::getline(lines, line)) { + const auto indent = line.find_first_not_of(" \t"); + if (inside && indent != std::string::npos && indent <= opener_indent) { + inside = false; + } + if (!inside && OpensSection(shell, line, subcommand)) { + inside = true; + opener_indent = indent; + } + if (inside) { + section += line + "\n"; + } + } + return section; +} + +// The subcommands the binary's own help lists, so a subcommand added to +// main.cpp is demanded of every script without this file changing +std::vector Subcommands() { + auto result = Run(mpqcli_binary, {"--help"}); + REQUIRE(result.returncode == 0); + std::vector names; + std::istringstream lines(result.stdout_output); + std::string line; + bool in_subcommands = false; + while (std::getline(lines, line)) { + if (line.rfind("SUBCOMMANDS:", 0) == 0) { + in_subcommands = true; + continue; + } + std::istringstream fields(line); + std::string name; + if (in_subcommands && fields >> name) { + names.push_back(name); + } + } + REQUIRE_FALSE(names.empty()); + return names; +} + +// Every capture of pattern's first group in a subcommand's help +std::set HelpMatches(const std::string &subcommand, const std::regex &pattern) { + auto result = Run(mpqcli_binary, {subcommand, "--help"}); + REQUIRE(result.returncode == 0); + std::set matches; + std::sregex_iterator match(result.stdout_output.begin(), result.stdout_output.end(), pattern); + const std::sregex_iterator end; + for (; match != end; ++match) { + matches.insert((*match)[1].str()); + } + return matches; +} + +// The long options a subcommand's help lists, without --help itself +std::set LongOptions(const std::string &subcommand) { + static const std::regex option_pattern("(--[a-z][a-z0-9-]*)"); + auto options = HelpMatches(subcommand, option_pattern); + options.erase("--help"); + return options; +} + +// The short options a subcommand's help lists, without -h itself. CLI11 +// writes each before its long name, as "-q, --property". +std::set ShortOptions(const std::string &subcommand) { + static const std::regex option_pattern("(?:^|\\s)(-[a-zA-Z]),"); + auto options = HelpMatches(subcommand, option_pattern); + options.erase("-h"); + return options; +} + +// The values a validator lists after prefix on one line: space-separated after +// "one of: ", comma-separated inside braces after "not in {" +std::set ValuesAfter(const std::string &text, const std::string &prefix) { + const auto start = text.find(prefix); + REQUIRE(start != std::string::npos); + auto rest = text.substr(start + prefix.size()); + rest = rest.substr(0, rest.find_first_of("}\r\n")); + std::replace(rest.begin(), rest.end(), ',', ' '); + std::istringstream fields(rest); + return std::set(std::istream_iterator(fields), + std::istream_iterator()); +} + +// A named value set the CLI accepts, read back from the binary's own rejection +// message so the scripts are held to what the binary actually takes +struct ValueSet { + std::string what; + std::set values; +}; + +std::vector ValueSets() { + // Any existing file, and for create a new archive name, so the value + // validator is what fails and not an archive or files check + const auto existing = ScriptPath(shells.front()).string(); + const TempDir dir; + const auto new_archive = (dir.Path() / "new.mpq").string(); + const auto rejection = [&](const std::vector &args) { + auto result = Run(mpqcli_binary, args); + // 105 is CLI11's ValidationError + REQUIRE(result.returncode == 105); + return result.stderr_output; + }; + return { + {"locales", + ValuesAfter(rejection({"create", "--locale", "nope", new_archive, existing}), "one of: ")}, + {"game profiles", + ValuesAfter(rejection({"create", "-g", "nope", new_archive, existing}), "one of: ")}, + {"info properties", ValuesAfter(rejection({"info", "-q", "nope", existing}), "not in {")}, + {"list properties", ValuesAfter(rejection({"list", "-q", "nope", existing}), "not in {")}, + }; +} + +} // namespace + +TEST_CASE("completion: prints its help with --help", "[completion]") { + auto result = Run(mpqcli_binary, {"completion", "--help"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + for (const auto &shell : shells) { + CAPTURE(shell.name); + REQUIRE_THAT(result.stdout_output, ContainsSubstring(shell.name)); + } +} + +TEST_CASE("completion: fails without a shell", "[completion]") { + auto result = Run(mpqcli_binary, {"completion"}); + + // 106 is CLI11's RequiredError: each shell is a subcommand of completion, + // which requires one + REQUIRE(result.returncode == 106); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("A subcommand is required")); +} + +TEST_CASE("completion: rejects an unknown shell", "[completion]") { + auto result = Run(mpqcli_binary, {"completion", "nushell"}); + + // 106 is CLI11's RequiredError: no subcommand matches the name, and CLI11 + // reports the missing subcommand before the unexpected argument + REQUIRE(result.returncode == 106); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("A subcommand is required")); +} + +TEST_CASE("completion: rejects an argument after the shell", "[completion]") { + auto result = Run(mpqcli_binary, {"completion", "bash", "extra"}); + + // 109 is CLI11's ExtrasError + REQUIRE(result.returncode == 109); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("not expected: extra")); +} + +TEST_CASE("completion: prints each shell's script byte for byte", "[completion]") { + const auto shell = GENERATE(from_range(shells)); + CAPTURE(shell.name); + const auto expected = ReadFile(ScriptPath(shell)); + REQUIRE_FALSE(expected.empty()); + + const auto output = ScriptOutput(shell.name); + + // The embedded copy is the committed script, whole: a truncated raw string + // literal or a stale configure would show up here + REQUIRE(WithoutCarriageReturns(output) == WithoutCarriageReturns(expected)); +} + +TEST_CASE("completion: each script registers itself for its shell", "[completion]") { + // The line each shell needs to pick the script up at all + const auto [shell, registration] = GENERATE(table({ + {"bash", "complete -F _mpqcli mpqcli"}, + {"zsh", "#compdef mpqcli"}, + {"fish", "complete -c mpqcli"}, + {"powershell", "Register-ArgumentCompleter -Native -CommandName 'mpqcli'"}, + })); + CAPTURE(shell); + + const auto output = ScriptOutput(shell); + + REQUIRE_THAT(output, ContainsSubstring(registration)); + if (shell == "zsh") { + // zsh autoloads a completion only when #compdef is the first line + REQUIRE(output.rfind(registration, 0) == 0); + } +} + +TEST_CASE("completion: every script covers every subcommand and option", "[completion]") { + const auto shell = GENERATE(from_range(shells)); + CAPTURE(shell.name); + + const auto script = ScriptOutput(shell.name); + + // A subcommand or option wired into the CLI but into only some of the + // hand-written scripts, or under the wrong subcommand, is the failure this + // guards against + for (const auto &subcommand : Subcommands()) { + CAPTURE(subcommand); + REQUIRE(HasToken(script, subcommand)); + auto options = LongOptions(subcommand); + const auto short_options = ShortOptions(subcommand); + options.insert(short_options.begin(), short_options.end()); + if (options.empty()) { + continue; + } + const auto section = SubcommandSection(shell, script, subcommand); + REQUIRE_FALSE(section.empty()); + for (const auto &option : options) { + CAPTURE(option); + REQUIRE(HasToken(section, OptionToken(shell, option))); + } + } +} + +TEST_CASE("completion: no script offers a subcommand a short option it rejects", "[completion]") { + // Short options a script could plausibly offer the subcommand: -p is + // create's and add's --path, -o is extract's --output, and -f and -k are + // tar's file and keep flags + const auto [subcommand, option] = GENERATE(table({ + {"info", "-p"}, + {"list", "-p"}, + {"verify", "-p"}, + {"create", "-o"}, + {"extract", "-f"}, + {"extract", "-k"}, + })); + CAPTURE(subcommand, option); + // The subcommand's own help agrees it takes no such option + REQUIRE(ShortOptions(subcommand).count(option) == 0); + + for (const auto &shell : shells) { + CAPTURE(shell.name); + const auto section = SubcommandSection(shell, ScriptOutput(shell.name), subcommand); + REQUIRE_FALSE(section.empty()); + REQUIRE_FALSE(HasToken(section, OptionToken(shell, option))); + } +} + +TEST_CASE("completion: every script offers every locale, game profile and property", + "[completion]") { + const auto shell = GENERATE(from_range(shells)); + CAPTURE(shell.name); + + const auto script = ScriptOutput(shell.name); + + for (const auto &set : ValueSets()) { + CAPTURE(set.what); + REQUIRE_FALSE(set.values.empty()); + for (const auto &value : set.values) { + CAPTURE(value); + REQUIRE(HasToken(script, value)); + } + } +} diff --git a/test/functional/test_create.cpp b/test/functional/test_create.cpp new file mode 100644 index 0000000..92690e9 --- /dev/null +++ b/test/functional/test_create.cpp @@ -0,0 +1,978 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#endif + +#include "archive_helper.h" +#include "compressible_text.h" +#include "read_file.h" +#include "sample_files.h" +#include "subprocess_helper.h" +#include "temp_dir.h" +#include "test_environment.h" +#include "write_file.h" + +namespace fs = std::filesystem; + +using namespace mpqcli::testing; +using Catch::Matchers::ContainsSubstring; + +namespace { + +// The binary every case spawns: a reference into the singleton, so it is the +// baked path itself rather than a copy that could drift from it +const fs::path &mpqcli_binary = TestEnvironment::Instance().binary_path; + +// The entry names an archive made from SampleFiles holds +const std::set sample_entries = {"bytes", "cats.txt", "dogs.txt"}; + +// TMPQHeader field offsets, from StormLib. wSectorSize holds an exponent, the +// sector being 512 << value bytes; dwRawChunkSize exists in v4 headers only. +constexpr std::streamoff header_sector_size_offset = 14; +constexpr std::streamoff header_raw_chunk_size_offset = 108; + +// Reads a little-endian field of the MPQ header, which starts at byte 0 of an +// archive mpqcli creates +std::uint64_t HeaderField(const fs::path &archive, std::streamoff offset, int width) { + std::ifstream in(archive, std::ios::binary); + REQUIRE(in.is_open()); + in.seekg(offset); + std::uint64_t value = 0; + for (int i = 0; i < width; ++i) { + const int byte = in.get(); + REQUIRE(byte != std::char_traits::eof()); + value |= static_cast(byte) << (8 * i); + } + return value; +} + +// The line create prints on stderr before it adds anything. The archive path +// it names is absolute, so archive must be the path the binary was given, +// resolved against the same working directory. +std::string ProfileLine(const fs::path &archive, const std::string &profile = "default") { + return "[*] Game profile: " + profile + ", Output file: " + fs::absolute(archive).string(); +} + +// Everything create prints on stderr when it stores each entry without a locale +std::set ProgressLines(const fs::path &archive, const std::set &entries, + const std::string &profile = "default") { + std::set lines = {ProfileLine(archive, profile)}; + for (const auto &entry : entries) { + lines.insert("[+] Adding file: " + entry); + } + return lines; +} + +// The names directly inside a directory, as the pytest suite compared them +std::set DirectoryEntries(const fs::path &dir) { + std::set names; + for (const auto &entry : fs::directory_iterator(dir)) { + names.insert(entry.path().filename().string()); + } + return names; +} + +} // namespace + +TEST_CASE("create: prints its help with --help", "[create]") { + auto result = Run(mpqcli_binary, {"create", "--help"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("create [OPTIONS] archive files...")); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("--sector-size")); +} + +TEST_CASE("create: fails when its input does not exist", "[create]") { + TempDir dir; + const auto archive = dir.Path() / "x.mpq"; + const auto missing = dir.Path() / "missing"; + + auto result = Run(mpqcli_binary, {"create", archive.string(), missing.string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[!] Path does not exist: " + missing.string()}); + REQUIRE(DirectoryEntries(dir.Path()).empty()); +} + +TEST_CASE("create: creates nothing when one of its inputs does not exist", "[create]") { + TempDir dir; + const auto input = dir.Path() / "a.txt"; + WriteFile(input, "A file."); + const auto archive = dir.Path() / "missing.mpq"; + const auto typo = dir.Path() / "typo"; + + auto result = Run(mpqcli_binary, {"create", archive.string(), input.string(), typo.string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[!] Path does not exist: " + typo.string()}); + REQUIRE(DirectoryEntries(dir.Path()) == std::set{"a.txt"}); +} + +#ifndef _WIN32 +// A FIFO exists but is neither a file nor a directory, which no other case can +// reach. Windows has no mkfifo, so the case is POSIX only. +TEST_CASE("create: rejects an input that is neither file nor directory", "[create]") { + TempDir dir; + const auto fifo = dir.Path() / "pipe"; + REQUIRE(mkfifo(fifo.c_str(), 0600) == 0); + const auto archive = dir.Path() / "pipe.mpq"; + + auto result = Run(mpqcli_binary, {"create", archive.string(), fifo.string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[!] Not a file or directory: " + fifo.string()}); + REQUIRE(DirectoryEntries(dir.Path()) == std::set{"pipe"}); +} +#endif + +TEST_CASE("create: requires at least one input after the archive", "[create]") { + TempDir dir; + WriteFile(dir.Path() / "files" / "a.txt", "A file."); + RunOptions options; + options.working_dir = dir.Path(); + + auto result = Run(mpqcli_binary, {"create", "files"}, options); + + // 106 is CLI11's RequiredError: the directory fills the archive positional, + // which leaves the files positional empty + REQUIRE(result.returncode == 106); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("files is required")); + REQUIRE(DirectoryEntries(dir.Path()) == std::set{"files"}); +} + +TEST_CASE("create: rejects --output as an unknown option", "[create]") { + const auto flag = GENERATE(as{}, "-o", "--output"); + CAPTURE(flag); + TempDir dir; + WriteFile(dir.Path() / "files" / "a.txt", "A file."); + RunOptions options; + options.working_dir = dir.Path(); + + auto result = Run(mpqcli_binary, {"create", flag, "out.mpq", "files"}, options); + + // 109 is CLI11's ExtrasError: create has no such option + REQUIRE(result.returncode == 109); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("The following argument was not expected: " + flag)); + REQUIRE(DirectoryEntries(dir.Path()) == std::set{"files"}); +} + +TEST_CASE("create: rejects --version as an unknown option", "[create]") { + TempDir dir; + WriteFile(dir.Path() / "files" / "a.txt", "A file."); + RunOptions options; + options.working_dir = dir.Path(); + + auto result = Run(mpqcli_binary, {"create", "--version", "1", "files"}, options); + + // 109 is CLI11's ExtrasError: the format version is set with --format-version + REQUIRE(result.returncode == 109); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("The following argument was not expected: --version")); + REQUIRE(DirectoryEntries(dir.Path()) == std::set{"files"}); +} + +TEST_CASE("create: fails when the archive's directory does not exist", "[create]") { + SampleFiles files; + const auto archive = files.dir.Path() / "missing" / "dir" / "out.mpq"; + + auto result = Run(mpqcli_binary, {"create", archive.string(), files.files_dir.string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("[!] Failed to create MPQ archive")); + REQUIRE(DirectoryEntries(files.dir.Path()) == std::set{"files"}); +} + +TEST_CASE("create: refuses to overwrite an existing file", "[create]") { + SampleFiles files; + const auto archive = files.dir.Path() / "existing.mpq"; + WriteFile(archive, "Not an archive"); + + auto result = Run(mpqcli_binary, {"create", archive.string(), files.files_dir.string()}); + + // 105 is CLI11's ValidationError: the CLI::NonexistentPath check rejects + // the archive before the command runs + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("Path already exists: " + archive.string())); + REQUIRE(ReadFile(archive) == "Not an archive"); +} + +TEST_CASE("create: from a single file", "[create]") { + TempDir dir; + const auto source = dir.Path() / "test.txt"; + WriteFile(source, "This is a test file for MPQ addition."); + const auto archive = dir.Path() / "test.mpq"; + + auto result = Run(mpqcli_binary, {"create", archive.string(), source.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, {"test.txt"})); + REQUIRE(fs::file_size(archive) > 0); + REQUIRE(ArchiveEntries(archive) == std::set{"enUS test.txt"}); +} + +TEST_CASE("create: from a single file with --path", "[create]") { + TempDir dir; + const auto source = dir.Path() / "test.txt"; + WriteFile(source, "This is a test file for MPQ addition."); + const auto archive = dir.Path() / "test.mpq"; + + auto result = Run(mpqcli_binary, {"create", archive.string(), source.string(), "--path", + "messages\\important.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + ProgressLines(archive, {"messages\\important.txt"})); + REQUIRE(ArchiveEntries(archive) == std::set{"enUS messages\\important.txt"}); +} + +TEST_CASE("create: from a directory with --path prefix", "[create]") { + TempDir dir; + const auto source_dir = dir.Path() / "files2"; + WriteFile(source_dir / "important.txt", "This is a test file for MPQ addition."); + const auto archive = dir.Path() / "files_test.mpq"; + + auto result = + Run(mpqcli_binary, {"create", archive.string(), source_dir.string(), "--path", "messages"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + ProgressLines(archive, {"messages\\important.txt"})); + REQUIRE(ArchiveEntries(archive) == std::set{"enUS messages\\important.txt"}); +} + +TEST_CASE("create: from several inputs", "[create]") { + TempDir dir; + const auto input_dir = dir.Path() / "textures"; + WriteFile(input_dir / "bear" / "bear.blp", "Bear."); + const auto input_file = dir.Path() / "readme.txt"; + WriteFile(input_file, "Read me."); + const auto archive = dir.Path() / "several.mpq"; + + auto result = + Run(mpqcli_binary, {"create", archive.string(), input_file.string(), input_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + ProgressLines(archive, {"readme.txt", "bear\\bear.blp"})); + REQUIRE(ArchiveEntries(archive) == + std::set{"enUS readme.txt", "enUS bear\\bear.blp"}); +} + +TEST_CASE("create: --path is a prefix for several inputs", "[create]") { + TempDir dir; + const auto file_a = dir.Path() / "a.txt"; + const auto file_b = dir.Path() / "b.txt"; + WriteFile(file_a, "A."); + WriteFile(file_b, "B."); + const auto input_dir = dir.Path() / "textures"; + WriteFile(input_dir / "bear" / "bear.blp", "Bear."); + const auto archive = dir.Path() / "prefixed.mpq"; + + auto result = Run(mpqcli_binary, {"create", archive.string(), file_a.string(), file_b.string(), + input_dir.string(), "--path", "data"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + ProgressLines(archive, {"data\\a.txt", "data\\b.txt", "data\\bear\\bear.blp"})); + REQUIRE(ArchiveEntries(archive) == std::set{"enUS data\\a.txt", + "enUS data\\b.txt", + "enUS data\\bear\\bear.blp"}); +} + +TEST_CASE("create: creates nothing when two inputs hold the same name", "[create]") { + TempDir dir; + WriteFile(dir.Path() / "first" / "a.txt", "First."); + WriteFile(dir.Path() / "second" / "a.txt", "Second."); + const auto archive = dir.Path() / "duplicate.mpq"; + + auto result = Run(mpqcli_binary, {"create", archive.string(), (dir.Path() / "first").string(), + (dir.Path() / "second").string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("[!] 1 file(s) were not added because an earlier input added " + "a file by the same name")); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("no archive was created")); + REQUIRE(DirectoryEntries(dir.Path()) == std::set{"first", "second"}); +} + +TEST_CASE("create: reads its input paths from stdin with -", "[create]") { + TempDir dir; + const auto file_a = dir.Path() / "a.txt"; + const auto file_b = dir.Path() / "b.txt"; + WriteFile(file_a, "A."); + WriteFile(file_b, "B."); + const auto archive = dir.Path() / "stdin.mpq"; + RunOptions options; + options.stdin_input = file_a.string() + "\n" + file_b.string() + "\n"; + + auto result = Run(mpqcli_binary, {"create", archive.string(), "-"}, options); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, {"a.txt", "b.txt"})); + REQUIRE(ArchiveEntries(archive) == std::set{"enUS a.txt", "enUS b.txt"}); +} + +TEST_CASE("create: creates nothing from an empty stdin", "[create]") { + TempDir dir; + const auto archive = dir.Path() / "empty.mpq"; + RunOptions options; + options.stdin_input = ""; + + auto result = Run(mpqcli_binary, {"create", archive.string(), "-"}, options); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[!] No files or directories to add"}); + REQUIRE(DirectoryEntries(dir.Path()).empty()); +} + +TEST_CASE("create: sizes the archive for paths from stdin", "[create]") { + // Enough files that the count matters: an archive sized for none of them, + // and grown as they are added, reports the same max-files as one made from + // a single file + TempDir dir; + const auto input_dir = dir.Path() / "files"; + std::string paths; + for (int i = 0; i < 30; ++i) { + const auto path = input_dir / ("file" + std::to_string(i) + ".txt"); + WriteFile(path, "File " + std::to_string(i) + "."); + paths += path.string() + "\n"; + } + const auto create_and_read_max_files = [&](const std::string &name, const std::string &input, + const RunOptions &options) { + const auto archive = dir.Path() / name; + auto result = Run(mpqcli_binary, {"create", archive.string(), input}, options); + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + return std::stoul(InfoProperty(archive, "max-files")); + }; + RunOptions from_stdin_options; + from_stdin_options.stdin_input = paths; + + const auto from_stdin = create_and_read_max_files("stdin.mpq", "-", from_stdin_options); + const auto from_directory = create_and_read_max_files("directory.mpq", input_dir.string(), {}); + const auto from_one_file = + create_and_read_max_files("one.mpq", (input_dir / "file0.txt").string(), {}); + + CAPTURE(from_stdin, from_directory, from_one_file); + REQUIRE(from_stdin == from_directory); + REQUIRE(from_stdin > from_one_file); +} + +#ifndef _WIN32 +// Windows has no permission bit that stops the owner reading a file, so the +// case is POSIX only +TEST_CASE("create: creates nothing when a file cannot be read", "[create]") { + if (geteuid() == 0) { + SKIP("root reads a file whatever its permissions"); + } + TempDir dir; + const auto input_dir = dir.Path() / "mod"; + WriteFile(input_dir / "a.txt", "A file."); + const auto locked = input_dir / "locked.txt"; + WriteFile(locked, "Locked."); + const auto other = dir.Path() / "b.txt"; + WriteFile(other, "B file."); + const auto archive = dir.Path() / "unreadable.mpq"; + fs::permissions(locked, fs::perms::none, fs::perm_options::replace); + + auto result = + Run(mpqcli_binary, {"create", archive.string(), other.string(), input_dir.string()}); + + fs::permissions(locked, fs::perms::owner_read | fs::perms::owner_write, + fs::perm_options::replace); + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("[!] Failed to add: locked.txt")); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("no archive was created")); + // Neither the archive nor the .partial it was written to is left behind + REQUIRE(DirectoryEntries(dir.Path()) == std::set{"b.txt", "mod"}); +} +#endif + +TEST_CASE("create: skips the archive inside its own input directory", "[create]") { + TempDir dir; + const auto input_dir = dir.Path() / "mod"; + WriteFile(input_dir / "a.txt", "A file."); + RunOptions options; + options.working_dir = input_dir; + + auto result = Run(mpqcli_binary, {"create", "out.mpq", "."}, options); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("[*] Skipping the archive itself")); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("[+] Adding file: a.txt")); + REQUIRE(DirectoryEntries(input_dir) == std::set{"a.txt", "out.mpq"}); + REQUIRE(ArchiveEntries(input_dir / "out.mpq") == std::set{"enUS a.txt"}); +} + +TEST_CASE("create: keeps folder structure with a trailing slash on the input", "[create]") { + TempDir dir; + const auto source_dir = dir.Path() / "src"; + WriteFile(source_dir / "root.txt", "root file"); + WriteFile(source_dir / "sub" / "nested.txt", "nested file"); + const auto archive = dir.Path() / "output.mpq"; + + auto result = Run(mpqcli_binary, {"create", archive.string(), source_dir.string() + "/"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + ProgressLines(archive, {"root.txt", "sub\\nested.txt"})); + REQUIRE(ArchiveEntries(archive) == + std::set{"enUS root.txt", "enUS sub\\nested.txt"}); +} + +TEST_CASE("create: keeps folder structure with a dot-relative input", "[create]") { + TempDir dir; + WriteFile(dir.Path() / "src" / "sub" / "nested.txt", "nested file"); + const auto archive = dir.Path() / "output.mpq"; + RunOptions options; + options.working_dir = dir.Path(); + + auto result = Run(mpqcli_binary, {"create", archive.string(), "./src"}, options); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, {"sub\\nested.txt"})); + REQUIRE(ArchiveEntries(archive) == std::set{"enUS sub\\nested.txt"}); +} + +#ifndef _WIN32 +// Creating a symlink needs privileges on Windows, so this case is POSIX only +TEST_CASE("create: skips a dangling symlink", "[create]") { + TempDir dir; + const auto source_dir = dir.Path() / "src"; + WriteFile(source_dir / "real.txt", "real file"); + fs::create_symlink(dir.Path() / "missing.txt", source_dir / "dangling.txt"); + const auto archive = dir.Path() / "output.mpq"; + + auto result = Run(mpqcli_binary, {"create", archive.string(), source_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, {"real.txt"})); + REQUIRE(ArchiveEntries(archive) == std::set{"enUS real.txt"}); +} +#endif + +TEST_CASE("create: from an empty directory", "[create]") { + TempDir dir; + const auto source_dir = dir.Path() / "empty"; + fs::create_directories(source_dir); + const auto archive = dir.Path() / "empty.mpq"; + + auto result = Run(mpqcli_binary, {"create", archive.string(), source_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, {})); + REQUIRE(fs::file_size(archive) > 0); + REQUIRE(ArchiveEntries(archive).empty()); + REQUIRE(InfoProperty(archive, "file-count") == "1"); // The (listfile) alone + // The smallest max file count create gives an archive + REQUIRE(InfoProperty(archive, "max-files") == "64"); +} + +TEST_CASE("create: skips special files found in the source directory", "[create]") { + TempDir dir; + const auto source_dir = dir.Path() / "src"; + WriteFile(source_dir / "readme.txt", "hello"); + for (const auto &name : {"(listfile)", "(signature)", "(attributes)"}) { + WriteFile(source_dir / name, "fake content"); + } + const auto archive = dir.Path() / "output.mpq"; + + auto result = Run(mpqcli_binary, {"create", archive.string(), source_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + auto expected_progress = ProgressLines(archive, {"readme.txt"}); + for (const auto &name : {"(listfile)", "(signature)", "(attributes)"}) { + expected_progress.insert(std::string("[*] Skipping special MPQ file: ") + name); + } + REQUIRE(LinesToSet(result.stderr_output) == expected_progress); + // The default profile writes a (listfile) of its own and nothing else; a + // planted special file that was ingested would show up beside it + REQUIRE(ArchiveEntries(archive, true) == + std::set{"enUS readme.txt", "enUS (listfile)"}); + REQUIRE(InfoProperty(archive, "file-count") == "2"); + REQUIRE(InfoProperty(archive, "signature-type") == "None"); +} + +TEST_CASE("create: rejects an unknown locale", "[create]") { + SampleFiles files; + const auto archive = files.dir.Path() / "new_mpq.mpq"; + + auto result = Run(mpqcli_binary, {"create", archive.string(), files.files_dir.string(), + "--locale", "illegal_locale"}); + + // 105 is CLI11's ValidationError, raised by the locale validator + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("Locale must be nothing, or one of")); + REQUIRE_FALSE(fs::exists(archive)); +} + +TEST_CASE("create: stores files under the given locale", "[create]") { + const auto flag = GENERATE(as{}, "-e", "--locale"); + // A language code, and a locale given as a hexadecimal LCID + const auto locale = GENERATE(as{}, "esES", "041D"); + CAPTURE(flag, locale); + SampleFiles files; + const auto archive = files.dir.Path() / "mpq_with_one_locale.mpq"; + + auto result = + Run(mpqcli_binary, {"create", archive.string(), files.files_dir.string(), flag, locale}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + std::set expected_progress = {ProfileLine(archive)}; + for (const auto &entry : sample_entries) { + expected_progress.insert("[+] Adding file for locale " + locale + ": " + entry); + } + REQUIRE(LinesToSet(result.stderr_output) == expected_progress); + REQUIRE(ArchiveEntries(archive) == std::set{locale + " bytes", + locale + " cats.txt", + locale + " dogs.txt"}); +} + +TEST_CASE("create: rejects an unknown game profile", "[create]") { + SampleFiles files; + const auto archive = files.dir.Path() / "mpq_with_invalid_profile.mpq"; + + auto result = Run(mpqcli_binary, {"create", "-g", "invalid_profile", archive.string(), + files.files_dir.string()}); + + // 105 is CLI11's ValidationError, raised by the game profile validator + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("Game profile must be one of")); + REQUIRE_FALSE(fs::exists(archive)); +} + +TEST_CASE("create: applies the game profile's version and compression", "[create]") { + // Profile, the MPQ version it selects, and the flag letters a .txt file gets + const auto [profile, expected_version, expected_flags] = + GENERATE(table({ + {"generic", "1", "ce"}, // Compressed + encrypted + {"diablo1", "1", "ie"}, // Imploded + encrypted + {"starcraft1", "1", "ce2"}, // Compressed + encrypted + key v2 + {"warcraft3", "1", "ce2"}, // Compressed + encrypted + key v2 + {"diablo2", "1", "c"}, // Compressed, .txt files not encrypted + {"wow1", "1", "c"}, // Compressed + {"wow2", "2", "cr"}, // Compressed + sector CRC + {"starcraft2", "2", "c"}, // Compressed, small files as a single unit + {"diablo3", "4", "c"}, // Compressed + })); + CAPTURE(profile); + SampleFiles files; + const auto archive = files.dir.Path() / ("mpq_with_profile_" + profile + ".mpq"); + + auto result = + Run(mpqcli_binary, {"create", "-g", profile, archive.string(), files.files_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, sample_entries, profile)); + REQUIRE(fs::file_size(archive) > 0); + REQUIRE(InfoProperty(archive, "format-version") == expected_version); + const auto flags = EntryProperty(archive, "cats.txt", "flags"); + CAPTURE(flags); + for (const char letter : expected_flags) { + CAPTURE(letter); + REQUIRE(flags.find(letter) != std::string::npos); + } +} + +TEST_CASE("create: accepts every game profile", "[create]") { + const auto [profile, expected_version] = GENERATE(table({ + {"generic", "1"}, + {"diablo1", "1"}, + {"lordsofmagic", "1"}, + {"starcraft1", "1"}, + {"warcraft2", "1"}, + {"diablo2", "1"}, + {"warcraft3", "1"}, + {"warcraft3-map", "1"}, + {"wow1", "1"}, + {"wow2", "2"}, + {"wow3", "2"}, + {"wow4", "4"}, + {"wow5", "4"}, + {"starcraft2", "2"}, + {"diablo3", "4"}, + })); + CAPTURE(profile); + SampleFiles files; + const auto archive = files.dir.Path() / ("mpq_all_profiles_" + profile + ".mpq"); + + auto result = + Run(mpqcli_binary, {"create", "-g", profile, archive.string(), files.files_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, sample_entries, profile)); + REQUIRE(InfoProperty(archive, "format-version") == expected_version); +} + +TEST_CASE("create: marks only zero-size files for deletion", "[create]") { + TempDir dir; + const auto source_dir = dir.Path() / "deletion_marker_test"; + WriteFile(source_dir / "zero_size.txt", ""); + WriteFile(source_dir / "nonzero_size.txt", "Hello"); + REQUIRE(fs::file_size(source_dir / "zero_size.txt") == 0); + REQUIRE(fs::file_size(source_dir / "nonzero_size.txt") == 5); + const auto archive = dir.Path() / "test_deletion_marker.mpq"; + + // The starcraft2 profile has the deletion-marker rule for size 0 + auto result = + Run(mpqcli_binary, {"create", "-g", "starcraft2", archive.string(), source_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + ProgressLines(archive, {"zero_size.txt", "nonzero_size.txt"}, "starcraft2")); + const auto zero_size_flags = EntryProperty(archive, "zero_size.txt", "flags"); + const auto nonzero_size_flags = EntryProperty(archive, "nonzero_size.txt", "flags"); + CAPTURE(zero_size_flags, nonzero_size_flags); + REQUIRE(zero_size_flags.find('d') != std::string::npos); + REQUIRE(nonzero_size_flags.find('d') == std::string::npos); + REQUIRE(nonzero_size_flags.find('c') != std::string::npos); +} + +TEST_CASE("create: writes each MPQ format version", "[create]") { + const auto version = GENERATE(as{}, "1", "2", "3", "4"); + CAPTURE(version); + SampleFiles files; + const auto archive = files.dir.Path() / "files.mpq"; + + auto result = Run(mpqcli_binary, {"create", "--format-version", version, archive.string(), + files.files_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, sample_entries)); + REQUIRE(fs::file_size(archive) > 0); + REQUIRE(InfoProperty(archive, "format-version") == version); +} + +TEST_CASE("create: --format-version overrides the game profile's version", "[create]") { + SampleFiles files; + const auto archive = files.dir.Path() / "override.mpq"; + + // wow2 selects version 2 on its own; see the game profile case + auto result = Run(mpqcli_binary, {"create", "-g", "wow2", "--format-version", "1", + archive.string(), files.files_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, sample_entries, "wow2")); + REQUIRE(InfoProperty(archive, "format-version") == "1"); +} + +TEST_CASE("create: rejects a format version outside 1 to 4", "[create]") { + const auto version = GENERATE(as{}, "0", "5"); + CAPTURE(version); + SampleFiles files; + const auto archive = files.dir.Path() / "bad_version.mpq"; + + auto result = Run(mpqcli_binary, {"create", "--format-version", version, archive.string(), + files.files_dir.string()}); + + // 105 is CLI11's ValidationError, raised by the CLI::Range check + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("not in range [1 - 4]")); + REQUIRE_FALSE(fs::exists(archive)); +} + +TEST_CASE("create: --sector-size sets the header's sector exponent", "[create]") { + // Sector size in bytes, and the exponent the header stores for it. 512 is + // left out: create accepts it and exits 0, but StormLib then refuses to + // open the result with "Bad MPQ format", which is a defect to fix rather + // than a contract to pin here + const auto [sector_size, exponent] = GENERATE(table({ + {"1024", 1}, + {"4096", 3}, + {"8192", 4}, + {"65536", 7}, + })); + CAPTURE(sector_size); + SampleFiles files; + const auto archive = files.dir.Path() / "sector.mpq"; + + auto result = Run(mpqcli_binary, {"create", "--sector-size", sector_size, archive.string(), + files.files_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, sample_entries)); + REQUIRE(HeaderField(archive, header_sector_size_offset, 2) == exponent); + REQUIRE(ArchiveEntries(archive).size() == 3); +} + +TEST_CASE("create: --raw-chunk-size sets the v4 header's raw chunk size", "[create]") { + const auto chunk_size = GENERATE(as{}, "4096", "16384"); + CAPTURE(chunk_size); + SampleFiles files; + const auto archive = files.dir.Path() / "chunk.mpq"; + + auto result = Run(mpqcli_binary, {"create", "--format-version", "4", "--raw-chunk-size", + chunk_size, archive.string(), files.files_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, sample_entries)); + REQUIRE(HeaderField(archive, header_raw_chunk_size_offset, 4) == std::stoull(chunk_size)); + REQUIRE(ArchiveEntries(archive).size() == 3); +} + +TEST_CASE("create: --stream-flags reaches the archive stream", "[create]") { + SampleFiles files; + const auto archive = files.dir.Path() / "stream.mpq"; + + SECTION("a write-share stream creates a listable archive") { + auto result = Run(mpqcli_binary, {"create", "--stream-flags", "512", archive.string(), + files.files_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, sample_entries)); + REQUIRE(ArchiveEntries(archive).size() == 3); + } + + SECTION("a read-only stream cannot be created into") { + auto result = Run(mpqcli_binary, {"create", "--stream-flags", "256", archive.string(), + files.files_dir.string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("[!] Failed to create MPQ archive")); + // Neither the archive nor the .partial it was written to is left behind + REQUIRE(DirectoryEntries(files.dir.Path()) == std::set{"files"}); + } +} + +TEST_CASE("create: --file-flags1 0 writes no (listfile)", "[create]") { + SampleFiles files; + const auto archive = files.dir.Path() / "no_listfile.mpq"; + + auto result = Run(mpqcli_binary, + {"create", "--file-flags1", "0", archive.string(), files.files_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, sample_entries)); + // Without a listfile the entries have no names to list, only positions + REQUIRE(ArchiveEntries(archive, true) == std::set{"enUS File00000000.xxx", + "enUS File00000001.xxx", + "enUS File00000002.xxx"}); +} + +TEST_CASE("create: --file-flags2 and --attr-flags store file times", "[create]") { + SampleFiles files; + const auto archive = files.dir.Path() / "attributes.mpq"; + + // 4294967295 is MPQ_FILE_DEFAULT_INTERNAL, StormLib's default flags for an + // internal file; 15 is every attribute: CRC32, FILETIME, MD5 and PATCH_BIT + auto result = Run(mpqcli_binary, {"create", "--file-flags2", "4294967295", "--attr-flags", "15", + archive.string(), files.files_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, sample_entries)); + auto listing = Run(mpqcli_binary, {"list", "-a", "-d", "-q", "file-time", archive.string()}); + REQUIRE(listing.returncode == 0); + REQUIRE(LinesToSet(listing.stdout_output) == + std::set{"2025-07-29 14:31:00 bytes", "2025-07-29 14:31:00 cats.txt", + "2025-07-29 14:31:00 dogs.txt", + " (attributes)", + " (listfile)"}); +} + +TEST_CASE("create: --file-flags3 reserves a (signature) entry that is signed on close", + "[create]") { + SampleFiles files; + const auto archive = files.dir.Path() / "reserved_signature.mpq"; + + auto result = Run(mpqcli_binary, {"create", "--file-flags3", "4294967295", archive.string(), + files.files_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, sample_entries)); + REQUIRE(ArchiveEntries(archive, true).count("enUS (signature)") == 1); + REQUIRE(InfoProperty(archive, "signature-type") == "Weak"); + REQUIRE(Run(mpqcli_binary, {"verify", archive.string()}).returncode == 0); +} + +TEST_CASE("create: --flags sets the added files' flags", "[create]") { + // Flag value, then the letters the flags column must and must not carry + const auto [flags, present, absent] = GENERATE(table({ + {"0", "", "ce"}, // Stored raw: neither compressed nor encrypted + {"512", "c", "e"}, // MPQ_FILE_COMPRESS + {"66048", "ce", ""}, // MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED + })); + CAPTURE(flags); + TempDir dir; + const auto source_dir = dir.Path() / "src"; + WriteFile(source_dir / "text.txt", CompressibleText(12000)); + const auto archive = dir.Path() / "flags.mpq"; + + auto result = + Run(mpqcli_binary, {"create", "--flags", flags, archive.string(), source_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, {"text.txt"})); + const auto stored = EntryProperty(archive, "text.txt", "flags"); + CAPTURE(stored); + for (const char letter : present) { + CAPTURE(letter); + REQUIRE(stored.find(letter) != std::string::npos); + } + for (const char letter : absent) { + CAPTURE(letter); + REQUIRE(stored.find(letter) == std::string::npos); + } +} + +TEST_CASE("create: --flags 0 stores files uncompressed", "[create]") { + TempDir dir; + const auto source_dir = dir.Path() / "src"; + WriteFile(source_dir / "text.txt", CompressibleText(12000)); + const auto archive = dir.Path() / "raw.mpq"; + + auto result = + Run(mpqcli_binary, {"create", "--flags", "0", archive.string(), source_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, {"text.txt"})); + REQUIRE(EntryNumber(archive, "text.txt", "compressed-size") == + EntryNumber(archive, "text.txt", "file-size")); +} + +TEST_CASE("create: --compression selects the algorithm for added files", "[create]") { + TempDir dir; + const auto source_dir = dir.Path() / "src"; + WriteFile(source_dir / "text.txt", CompressibleText(12000)); + // Same algorithm for every sector, so only --compression is under test + const auto create_with = [&](const std::string &compression) { + const auto archive = dir.Path() / ("compression_" + compression + ".mpq"); + auto result = + Run(mpqcli_binary, {"create", "--compression", compression, "--compression-next", + compression, archive.string(), source_dir.string()}); + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, {"text.txt"})); + return archive; + }; + + const auto zlib = create_with("2"); // MPQ_COMPRESSION_ZLIB + const auto bzip2 = create_with("16"); // MPQ_COMPRESSION_BZIP2 + + const auto raw_size = EntryNumber(zlib, "text.txt", "file-size"); + const auto zlib_size = EntryNumber(zlib, "text.txt", "compressed-size"); + const auto bzip2_size = EntryNumber(bzip2, "text.txt", "compressed-size"); + CAPTURE(raw_size, zlib_size, bzip2_size); + REQUIRE(zlib_size < raw_size); + REQUIRE(bzip2_size < raw_size); + REQUIRE(zlib_size != bzip2_size); +} + +TEST_CASE("create: --compression-next selects the algorithm after the first sector", "[create]") { + TempDir dir; + const auto source_dir = dir.Path() / "src"; + // Well over one sector, so there are subsequent sectors for the option to reach + WriteFile(source_dir / "text.txt", CompressibleText(12000)); + const auto create_with = [&](const std::string &compression_next) { + const auto archive = dir.Path() / ("next_" + compression_next + ".mpq"); + auto result = Run(mpqcli_binary, {"create", "--compression", "2", "--compression-next", + compression_next, archive.string(), source_dir.string()}); + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, {"text.txt"})); + return archive; + }; + + const auto all_zlib = create_with("2"); + const auto then_bzip2 = create_with("16"); + + const auto raw_size = EntryNumber(all_zlib, "text.txt", "file-size"); + const auto all_zlib_size = EntryNumber(all_zlib, "text.txt", "compressed-size"); + const auto then_bzip2_size = EntryNumber(then_bzip2, "text.txt", "compressed-size"); + CAPTURE(raw_size, all_zlib_size, then_bzip2_size); + REQUIRE(all_zlib_size < raw_size); + REQUIRE(then_bzip2_size < raw_size); + REQUIRE(all_zlib_size != then_bzip2_size); +} + +TEST_CASE("create: signs the archive with --sign", "[create]") { + const auto flag = GENERATE(as{}, "-s", "--sign"); + CAPTURE(flag); + SampleFiles files; + const auto archive = files.dir.Path() / "mpq_with_weak_signature.mpq"; + + auto result = Run(mpqcli_binary, {"create", flag, archive.string(), files.files_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, sample_entries)); + REQUIRE(InfoProperty(archive, "signature-type") == "Weak"); + auto verify = Run(mpqcli_binary, {"verify", archive.string()}); + REQUIRE(verify.returncode == 0); + REQUIRE(verify.stdout_output.empty()); + REQUIRE(LinesToSet(verify.stderr_output) == std::set{"[*] Verify success"}); +} + +TEST_CASE("create: leaves the archive unsigned without --sign", "[create]") { + SampleFiles files; + const auto archive = files.dir.Path() / "mpq_no_sign.mpq"; + + auto result = Run(mpqcli_binary, {"create", archive.string(), files.files_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, sample_entries)); + REQUIRE(InfoProperty(archive, "signature-type") == "None"); + REQUIRE(ArchiveEntries(archive, true).count("enUS (signature)") == 0); +} diff --git a/test/functional/test_extract.cpp b/test/functional/test_extract.cpp new file mode 100644 index 0000000..53b5ead --- /dev/null +++ b/test/functional/test_extract.cpp @@ -0,0 +1,682 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "many_locales_archive.h" +#include "no_listfile_archive.h" +#include "one_locale_archive.h" +#include "read_file.h" +#include "sample_archive.h" +#include "sample_files.h" +#include "subprocess_helper.h" +#include "temp_dir.h" +#include "test_environment.h" +#include "write_file.h" + +namespace fs = std::filesystem; + +using namespace mpqcli::testing; +using Catch::Matchers::ContainsSubstring; + +namespace { + +// The binary every case spawns: a reference into the singleton, so it is the +// baked path itself rather than a copy that could drift from it +const fs::path &mpqcli_binary = TestEnvironment::Instance().binary_path; + +const std::string failed_all = "[!] Failed to extract all files."; + +// The names directly inside a directory, as the pytest suite compared them +std::set DirectoryEntries(const fs::path &dir) { + std::set names; + for (const auto &entry : fs::directory_iterator(dir)) { + names.insert(entry.path().filename().string()); + } + return names; +} + +// The stderr lines extract prints for a set of entry names +std::set ExtractedLines(const std::set &names) { + std::set lines; + for (const auto &name : names) { + lines.insert("[*] Extracted: " + name); + } + return lines; +} + +// The lines of output that start with prefix, for output that also carries +// lines the test does not pin +std::set LinesStartingWith(const std::string &output, const std::string &prefix) { + std::set lines; + for (const auto &line : LinesToSet(output)) { + if (line.rfind(prefix, 0) == 0) { + lines.insert(line); + } + } + return lines; +} + +// A nested entry's path as extract prints it: the archive's backslash turned +// into the platform's own separator +std::string Native(const std::string &archive_path) { + std::string native = archive_path; + for (auto &c : native) { + if (c == '\\') { + c = fs::path::preferred_separator; + } + } + return native; +} + +// Stores a second copy of cats.txt in the sample archive as texts\cats.txt +void AddNestedCats(const SampleArchive &sample) { + REQUIRE(Run(mpqcli_binary, + {"add", sample.archive.string(), (sample.files.files_dir / "cats.txt").string(), + "--path", "texts\\cats.txt"}) + .returncode == 0); +} + +// An archive whose only entries are safe.txt and a copy of it stored under a +// path that climbs two directories above wherever it is extracted +fs::path TraversalArchive(const TempDir &dir) { + const auto source_dir = dir.Path() / "traversal_files"; + const auto safe = source_dir / "safe.txt"; + WriteFile(safe, "This is a safe file.\n"); + const auto archive = dir.Path() / "mpq_with_path_traversal.mpq"; + REQUIRE(Run(mpqcli_binary, {"create", archive.string(), source_dir.string()}).returncode == 0); + REQUIRE(Run(mpqcli_binary, {"add", archive.string(), safe.string(), "-p", "../../sneaky.txt"}) + .returncode == 0); + return archive; +} + +} // namespace + +TEST_CASE("extract: prints its help with --help", "[extract]") { + auto result = Run(mpqcli_binary, {"extract", "--help"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("extract [OPTIONS] archive [files...]")); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("--flat")); +} + +TEST_CASE("extract: archive does not exist", "[extract]") { + auto result = Run(mpqcli_binary, {"extract", "no-such-archive.mpq"}); + + // 105 is CLI11's ValidationError, from the CLI::ExistingFile check + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("File does not exist: no-such-archive.mpq")); +} + +TEST_CASE("extract: listfile does not exist", "[extract]") { + SampleArchive sample; + + auto result = + Run(mpqcli_binary, {"extract", sample.archive.string(), "--listfile", "no-such-list.txt"}); + + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("--listfile: File does not exist: no-such-list.txt")); +} + +TEST_CASE("extract: fails when the archive is not an MPQ", "[extract]") { + SampleFiles files; + + auto result = Run(mpqcli_binary, {"extract", (files.files_dir / "cats.txt").string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("[!] Failed to open MPQ archive")); +} + +TEST_CASE("extract: all files to a directory named after the archive", "[extract]") { + SampleArchive sample(AttributedArchiveArgs(), "archive.mpq"); + const auto output_dir = sample.files.dir.Path() / "archive"; + const std::set expected = {"cats.txt", "dogs.txt", "bytes", "(listfile)", + "(attributes)"}; + + auto result = Run(mpqcli_binary, {"extract", sample.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ExtractedLines(expected)); + REQUIRE(fs::is_directory(output_dir)); + REQUIRE(DirectoryEntries(output_dir) == expected); + REQUIRE(ReadFile(output_dir / "cats.txt") == "This is a file about cats.\n"); + REQUIRE(ReadFile(output_dir / "dogs.txt") == "This is a file about dogs.\n"); + REQUIRE(fs::file_size(output_dir / "cats.txt") == 27); + REQUIRE(fs::file_size(output_dir / "bytes") == 8); +} + +TEST_CASE("extract: all files to --output", "[extract]") { + const auto flag = GENERATE(as{}, "-o", "--output"); + CAPTURE(flag); + SampleArchive sample(AttributedArchiveArgs()); + const auto output_dir = sample.files.dir.Path() / "extracted"; + const std::set expected = {"cats.txt", "dogs.txt", "bytes", "(listfile)", + "(attributes)"}; + + auto result = + Run(mpqcli_binary, {"extract", flag, output_dir.string(), sample.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ExtractedLines(expected)); + REQUIRE(fs::is_directory(output_dir)); + REQUIRE(DirectoryEntries(output_dir) == expected); + REQUIRE(ReadFile(output_dir / "cats.txt") == "This is a file about cats.\n"); + REQUIRE(ReadFile(output_dir / "dogs.txt") == "This is a file about dogs.\n"); +} + +TEST_CASE("extract: --output with a trailing separator", "[extract]") { + SampleArchive sample; + const auto output_dir = sample.files.dir.Path() / "extracted"; + const std::set expected = {"cats.txt", "dogs.txt", "bytes", "(listfile)"}; + + auto result = + Run(mpqcli_binary, {"extract", "-o", output_dir.string() + "/", sample.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ExtractedLines(expected)); + REQUIRE(DirectoryEntries(output_dir) == expected); +} + +TEST_CASE("extract: fails when --output is an existing file", "[extract]") { + SampleArchive sample; + const auto not_a_dir = sample.files.files_dir / "cats.txt"; + + auto result = + Run(mpqcli_binary, {"extract", "-o", not_a_dir.string(), sample.archive.string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("[!] Failed to create output directory")); +} + +TEST_CASE("extract: fails when the archive has no extension", "[extract]") { + // The default output directory is the archive's stem, which is then the + // archive itself + SampleArchive sample({}, "archive"); + + auto result = Run(mpqcli_binary, {"extract", sample.archive.string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("[!] Failed to create output directory")); +} + +TEST_CASE("extract: overwrites files from a previous extraction", "[extract]") { + SampleArchive sample; + const auto output_dir = sample.files.dir.Path() / "extracted"; + const std::vector args = {"extract", "-o", output_dir.string(), + sample.archive.string()}; + REQUIRE(Run(mpqcli_binary, args).returncode == 0); + WriteFile(output_dir / "cats.txt", "stale"); + + auto result = Run(mpqcli_binary, args); + + // No complaint about the files already there: the second run reports each + // one as the first did + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + ExtractedLines({"cats.txt", "dogs.txt", "bytes", "(listfile)"})); + REQUIRE(ReadFile(output_dir / "cats.txt") == "This is a file about cats.\n"); +} + +TEST_CASE("extract: a single named file", "[extract]") { + SampleArchive sample(AttributedArchiveArgs()); + const auto output_dir = sample.files.dir.Path() / "extracted_file"; + + auto result = Run(mpqcli_binary, + {"extract", "-o", output_dir.string(), sample.archive.string(), "cats.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == std::set{"[*] Extracted: cats.txt"}); + REQUIRE(DirectoryEntries(output_dir) == std::set{"cats.txt"}); + REQUIRE(ReadFile(output_dir / "cats.txt") == "This is a file about cats.\n"); + REQUIRE(fs::file_size(output_dir / "cats.txt") == 27); +} + +TEST_CASE("extract: several named files", "[extract]") { + SampleArchive sample; + const auto output_dir = sample.files.dir.Path() / "extracted"; + + auto result = Run(mpqcli_binary, {"extract", "-o", output_dir.string(), sample.archive.string(), + "cats.txt", "dogs.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ExtractedLines({"cats.txt", "dogs.txt"})); + REQUIRE(DirectoryEntries(output_dir) == std::set{"cats.txt", "dogs.txt"}); + REQUIRE(ReadFile(output_dir / "dogs.txt") == "This is a file about dogs.\n"); +} + +TEST_CASE("extract: reads the file names from stdin with -", "[extract]") { + // A list saved with Windows line endings names the same files + const auto newline = GENERATE(as{}, "\n", "\r\n"); + CAPTURE(newline.size()); + SampleArchive sample; + const auto output_dir = sample.files.dir.Path() / "extracted"; + RunOptions options; + options.stdin_input = "cats.txt" + newline + "dogs.txt" + newline; + + auto result = + Run(mpqcli_binary, {"extract", "-o", output_dir.string(), sample.archive.string(), "-"}, + options); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == ExtractedLines({"cats.txt", "dogs.txt"})); + REQUIRE(DirectoryEntries(output_dir) == std::set{"cats.txt", "dogs.txt"}); +} + +TEST_CASE("extract: an empty stdin extracts nothing", "[extract]") { + SampleArchive sample; + const auto output_dir = sample.files.dir.Path() / "extracted"; + RunOptions options; + options.stdin_input = ""; + + auto result = + Run(mpqcli_binary, {"extract", "-o", output_dir.string(), sample.archive.string(), "-"}, + options); + + // Nothing rather than everything, so a pipeline whose filter matched no + // names cannot unpack the whole archive + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == std::set{"[*] No files to extract"}); + REQUIRE_FALSE(fs::exists(output_dir)); +} + +TEST_CASE("extract: a named file that does not exist in the archive", "[extract]") { + SampleArchive sample; + const auto output_dir = sample.files.dir.Path() / "extracted_file"; + + auto result = Run(mpqcli_binary, + {"extract", "-o", output_dir.string(), sample.archive.string(), "nope.txt"}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[!] Failed: File doesn't exist for locale enUS: nope.txt", "", + failed_all}); + REQUIRE(DirectoryEntries(output_dir).empty()); +} + +TEST_CASE("extract: a named nested file keeps its folder", "[extract]") { + SampleArchive sample; + AddNestedCats(sample); + const auto output_dir = sample.files.dir.Path() / "extracted_nested"; + + auto result = Run(mpqcli_binary, {"extract", "-o", output_dir.string(), sample.archive.string(), + "texts\\cats.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[*] Extracted: " + Native("texts\\cats.txt")}); + REQUIRE(DirectoryEntries(output_dir) == std::set{"texts"}); + REQUIRE(ReadFile(output_dir / "texts" / "cats.txt") == "This is a file about cats.\n"); +} + +TEST_CASE("extract: a named nested file drops its folder with --flat", "[extract]") { + const auto flag = GENERATE(as{}, "-j", "--flat"); + CAPTURE(flag); + SampleArchive sample; + AddNestedCats(sample); + const auto output_dir = sample.files.dir.Path() / "extracted_nested"; + + auto result = Run(mpqcli_binary, {"extract", flag, "-o", output_dir.string(), + sample.archive.string(), "texts\\cats.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == std::set{"[*] Extracted: cats.txt"}); + REQUIRE(DirectoryEntries(output_dir) == std::set{"cats.txt"}); + REQUIRE(ReadFile(output_dir / "cats.txt") == "This is a file about cats.\n"); +} + +TEST_CASE("extract: all files keep their folder structure", "[extract]") { + SampleArchive sample; + AddNestedCats(sample); + const auto output_dir = sample.files.dir.Path() / "extracted"; + + auto result = + Run(mpqcli_binary, {"extract", "-o", output_dir.string(), sample.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE( + LinesToSet(result.stderr_output) == + ExtractedLines({"cats.txt", "dogs.txt", "bytes", "(listfile)", Native("texts\\cats.txt")})); + REQUIRE(DirectoryEntries(output_dir) == + std::set{"cats.txt", "dogs.txt", "bytes", "(listfile)", "texts"}); + REQUIRE(ReadFile(output_dir / "texts" / "cats.txt") == "This is a file about cats.\n"); +} + +TEST_CASE("extract: --flat extracts nothing when two names land on the same file", "[extract]") { + TempDir dir; + const auto input_dir = dir.Path() / "files"; + WriteFile(input_dir / "a" / "x.txt", "A"); + WriteFile(input_dir / "b" / "x.txt", "B"); + const auto archive = dir.Path() / "collision.mpq"; + REQUIRE(Run(mpqcli_binary, {"create", archive.string(), input_dir.string()}).returncode == 0); + const auto output_dir = dir.Path() / "extracted"; + + auto result = + Run(mpqcli_binary, {"extract", "--flat", "-o", output_dir.string(), archive.string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + // Which of the two the message names first follows the archive's own order + const std::set either_order = { + "[!] --flat would extract a\\x.txt and b\\x.txt to the same file", + "[!] --flat would extract b\\x.txt and a\\x.txt to the same file", + }; + const auto stderr_lines = LinesToVector(result.stderr_output); + REQUIRE(stderr_lines.size() == 1); + REQUIRE(either_order.count(stderr_lines[0]) == 1); + REQUIRE_FALSE(fs::exists(output_dir)); +} + +TEST_CASE("extract: --flat extracts one name stored under two locales", "[extract]") { + TempDir dir; + const auto input_dir = dir.Path() / "files"; + WriteFile(input_dir / "cats.txt", "Cats in the default locale."); + const auto german_file = dir.Path() / "cats.txt"; + WriteFile(german_file, "Cats in German."); + const auto archive = dir.Path() / "locales.mpq"; + REQUIRE(Run(mpqcli_binary, {"create", archive.string(), input_dir.string()}).returncode == 0); + REQUIRE(Run(mpqcli_binary, {"add", archive.string(), german_file.string(), "--locale", "deDE"}) + .returncode == 0); + const auto output_dir = dir.Path() / "extracted"; + + auto result = + Run(mpqcli_binary, {"extract", "--flat", "-o", output_dir.string(), archive.string()}); + + // cats.txt is listed once per locale, and both land on the same file without + // counting as a collision + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + const auto stderr_lines = LinesToVector(result.stderr_output); + REQUIRE(stderr_lines.size() == 3); + REQUIRE(std::count(stderr_lines.begin(), stderr_lines.end(), "[*] Extracted: cats.txt") == 2); + REQUIRE(LinesToSet(result.stderr_output) == ExtractedLines({"cats.txt", "(listfile)"})); + REQUIRE(DirectoryEntries(output_dir) == std::set{"cats.txt", "(listfile)"}); +} + +TEST_CASE("extract: --listfile cannot be combined with named files", "[extract]") { + // A name on the command line, or - with the name on stdin + const auto named = GENERATE(as{}, "cats.txt", "-"); + CAPTURE(named); + SampleArchive sample; + const auto listfile = sample.files.dir.Path() / "listfile.txt"; + WriteFile(listfile, "cats.txt\n"); + const auto output_dir = sample.files.dir.Path() / "extracted"; + RunOptions options; + options.stdin_input = "cats.txt\n"; + + auto result = Run(mpqcli_binary, + {"extract", "-o", output_dir.string(), "-l", listfile.string(), + sample.archive.string(), named}, + options); + + // 108 is CLI11's ExcludesError + REQUIRE(result.returncode == 108); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("files excludes --listfile")); + REQUIRE_FALSE(fs::exists(output_dir)); +} + +TEST_CASE("extract: has no -f option", "[extract]") { + SampleArchive sample; + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); + + auto result = + Run(mpqcli_binary, {"extract", "-f", "cats.txt", sample.archive.string()}, options); + + // The file name after -f is taken as the archive, and the working directory + // has no file by that name + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("archive: File does not exist: cats.txt")); + REQUIRE(DirectoryEntries(working_dir.Path()).empty()); +} + +TEST_CASE("extract: has no -k option", "[extract]") { + SampleArchive sample; + const auto output_dir = sample.files.dir.Path() / "extracted"; + + auto result = + Run(mpqcli_binary, {"extract", "-k", "-o", output_dir.string(), sample.archive.string()}); + + // 109 is CLI11's ExtrasError + REQUIRE(result.returncode == 109); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("The following argument was not expected: -k")); + REQUIRE_FALSE(fs::exists(output_dir)); +} + +TEST_CASE("extract: a single file under the given locale", "[extract]") { + const auto flag = GENERATE(as{}, "-e", "--locale"); + CAPTURE(flag); + ManyLocalesArchive locales; + const auto output_dir = locales.dir.Path() / "extracted_file"; + + auto result = Run(mpqcli_binary, {"extract", "-o", output_dir.string(), + locales.archive.string(), "cats.txt", flag, "esES"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == std::set{"[*] Extracted: cats.txt"}); + REQUIRE(ReadFile(output_dir / "cats.txt") == CatsContent("esES")); +} + +TEST_CASE("extract: a single file takes the default locale when none is given", "[extract]") { + ManyLocalesArchive locales; + const auto output_dir = locales.dir.Path() / "extracted_file"; + + auto result = Run(mpqcli_binary, + {"extract", "-o", output_dir.string(), locales.archive.string(), "cats.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == std::set{"[*] Extracted: cats.txt"}); + REQUIRE(ReadFile(output_dir / "cats.txt") == CatsContent("")); +} + +TEST_CASE("extract: rejects an unknown locale", "[extract]") { + ManyLocalesArchive locales; + const auto output_dir = locales.dir.Path() / "extracted_file"; + + auto result = + Run(mpqcli_binary, {"extract", "-o", output_dir.string(), locales.archive.string(), + "cats.txt", "--locale", "nosuchlocale"}); + + // 105 is CLI11's ValidationError, from the locale validator + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("--locale: Locale must be nothing, or one of:")); + REQUIRE_FALSE(fs::exists(output_dir)); +} + +TEST_CASE("extract: falls back to the default locale when the file has no such variant", + "[extract]") { + ManyLocalesArchive locales; + const auto output_dir = locales.dir.Path() / "extracted_file"; + + auto result = Run(mpqcli_binary, {"extract", "-o", output_dir.string(), + locales.archive.string(), "cats.txt", "--locale", "ptPT"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == std::set{"[*] Extracted: cats.txt"}); + REQUIRE(ReadFile(output_dir / "cats.txt") == CatsContent("")); +} + +TEST_CASE("extract: fails when neither the given nor the default locale has the file", + "[extract]") { + // No --locale asks for enUS; a wrong one asks for that locale. Neither is + // stored, and there is no default variant to fall back to + const auto [locale, reported] = GENERATE(table({ + {"", "enUS"}, + {"deDE", "deDE"}, + })); + CAPTURE(reported); + OneLocaleArchive one; + const auto output_dir = one.dir.Path() / "extracted_file"; + std::vector args = {"extract", "-o", output_dir.string(), one.archive.string(), + "cats.txt"}; + if (!locale.empty()) { + args.insert(args.end(), {"--locale", locale}); + } + + auto result = Run(mpqcli_binary, args); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[!] Failed: File doesn't exist for locale " + reported + + ": cats.txt", + "", failed_all}); + REQUIRE(DirectoryEntries(output_dir).empty()); +} + +TEST_CASE("extract: all files from an archive without a listfile", "[extract]") { + // External listfile content (none when empty), the locale asked for (the + // default when empty), and the names that come out. Entries under another + // locale fail, so every row exits 1; unnamed entries land as File0000000N.xxx + const auto [listfile_content, locale, expected] = + GENERATE(table>({ + {"", "", {"File00000000.xxx"}}, + {"", "041D", {"File00000000.xxx", "File00000002.xxx"}}, + {"capybaras.txt\n", "", {"capybaras.txt"}}, + {"cats.txt\n", "deDE", {"File00000000.xxx", "cats.txt"}}, + {"cats.txt\ndogs.txt\ncapybaras.txt\n", "", {"capybaras.txt"}}, + {"cats.txt\ndogs.txt\ncapybaras.txt\n", "041D", {"dogs.txt", "capybaras.txt"}}, + })); + CAPTURE(listfile_content, locale); + NoListfileArchive archive; + const auto output_dir = archive.dir.Path() / "extracted"; + std::vector args = {"extract", "-o", output_dir.string()}; + if (!listfile_content.empty()) { + const auto listfile = archive.dir.Path() / "listfile.txt"; + WriteFile(listfile, listfile_content); + args.insert(args.end(), {"-l", listfile.string()}); + } + args.push_back(archive.archive.string()); + if (!locale.empty()) { + args.insert(args.end(), {"--locale", locale}); + } + + auto result = Run(mpqcli_binary, args); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesStartingWith(result.stderr_output, "[*] Extracted: ") == ExtractedLines(expected)); + REQUIRE_THAT(result.stderr_output, ContainsSubstring(failed_all)); + REQUIRE(DirectoryEntries(output_dir) == expected); + // Whatever the name it came out under, the content is the stored one + const std::map contents = { + {"File00000000.xxx", "capybaras.txt"}, + {"File00000001.xxx", "cats.txt"}, + {"File00000002.xxx", "dogs.txt"}, + }; + for (const auto &name : expected) { + const auto stored = contents.count(name) != 0 ? contents.at(name) : name; + for (const auto &entry : NoListfileArchive::Entries()) { + if (entry.name == stored) { + CAPTURE(name); + REQUIRE(ReadFile(output_dir / name) == entry.content); + } + } + } +} + +TEST_CASE("extract: a single unnamed file from an archive without a listfile", "[extract]") { + NoListfileArchive archive; + const auto output_dir = archive.dir.Path() / "extracted_file"; + + auto result = Run(mpqcli_binary, {"extract", "-o", output_dir.string(), + archive.archive.string(), "File00000000.xxx"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{"[*] Extracted: File00000000.xxx"}); + REQUIRE(DirectoryEntries(output_dir) == std::set{"File00000000.xxx"}); + REQUIRE(ReadFile(output_dir / "File00000000.xxx") == NoListfileArchive::Entries()[0].content); +} + +TEST_CASE("extract: blocks an entry whose path climbs out of the output directory", "[extract]") { + TempDir dir; + const auto archive = TraversalArchive(dir); + // Two levels down, so the climb stays inside the scratch directory and the + // escape target can be checked without touching anything shared + const auto output_dir = dir.Path() / "nested" / "deeper" / "out"; + fs::create_directories(output_dir.parent_path()); + const auto escape_target = dir.Path() / "nested" / "sneaky.txt"; + + auto result = Run(mpqcli_binary, {"extract", "-o", output_dir.string(), archive.string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + auto expected_stderr = ExtractedLines({"safe.txt", "(listfile)"}); + expected_stderr.insert( + {"[!] Blocked: path traversal attempt detected: " + Native("..\\..\\sneaky.txt"), "", + failed_all}); + REQUIRE(LinesToSet(result.stderr_output) == expected_stderr); + REQUIRE_FALSE(fs::exists(escape_target)); + REQUIRE(DirectoryEntries(output_dir) == std::set{"safe.txt", "(listfile)"}); +} + +#ifndef _WIN32 +// Creating a symlink needs privileges on Windows, so this case is POSIX only +TEST_CASE("extract: blocks an entry that escapes through a symlink in the output directory", + "[extract]") { + TempDir dir; + const auto source_dir = dir.Path() / "src"; + const auto safe = source_dir / "safe.txt"; + WriteFile(safe, "This is a safe file.\n"); + const auto archive = dir.Path() / "mpq_with_symlink_escape.mpq"; + REQUIRE(Run(mpqcli_binary, {"create", archive.string(), source_dir.string()}).returncode == 0); + REQUIRE(Run(mpqcli_binary, {"add", archive.string(), safe.string(), "-p", "escape\\evil.txt"}) + .returncode == 0); + // The entry's path is lexically fine; only resolving the symlink shows it + // leaves the output directory + const auto outside = dir.Path() / "outside"; + const auto output_dir = dir.Path() / "out"; + fs::create_directories(outside); + fs::create_directories(output_dir); + fs::create_directory_symlink(outside, output_dir / "escape"); + + auto result = Run(mpqcli_binary, {"extract", "-o", output_dir.string(), archive.string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + auto expected_stderr = ExtractedLines({"safe.txt", "(listfile)"}); + expected_stderr.insert( + {"[!] Blocked: path traversal attempt detected: " + Native("escape\\evil.txt"), "", + failed_all}); + REQUIRE(LinesToSet(result.stderr_output) == expected_stderr); + REQUIRE(DirectoryEntries(outside).empty()); +} +#endif diff --git a/test/functional/test_info.cpp b/test/functional/test_info.cpp new file mode 100644 index 0000000..7786df3 --- /dev/null +++ b/test/functional/test_info.cpp @@ -0,0 +1,317 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "integration_data.h" +#include "read_file.h" +#include "sample_archive.h" +#include "sample_files.h" +#include "subprocess_helper.h" +#include "test_environment.h" +#include "write_file.h" + +namespace fs = std::filesystem; + +using namespace mpqcli::testing; +using Catch::Matchers::ContainsSubstring; + +namespace { + +// The binary every case spawns: a reference into the singleton, so it is the +// baked path itself rather than a copy that could drift from it +const fs::path &mpqcli_binary = TestEnvironment::Instance().binary_path; + +const std::string open_failure = "[!] Failed to open MPQ archive"; + +// The value of every property info knows, for one archive +struct Properties { + std::string format_version; + std::string header_offset; + std::string header_size; + std::string archive_size; + std::string file_count; + std::string max_files; + std::string signature_type; + + // Property name and value pairs, as --property takes and prints them + std::vector> Values() const { + return { + {"format-version", format_version}, {"header-offset", header_offset}, + {"header-size", header_size}, {"archive-size", archive_size}, + {"file-count", file_count}, {"max-files", max_files}, + {"signature-type", signature_type}, + }; + } + + // The lines the full listing prints, one labelled property each + std::set Lines() const { + return { + "Format version: " + format_version, "Header offset: " + header_offset, + "Header size: " + header_size, "Archive size: " + archive_size, + "File count: " + file_count, "Max files: " + max_files, + "Signature type: " + signature_type, + }; + } +}; + +// What the attributed archive recipe over the sample files produces under each +// MPQ format version. The header sizes are StormLib's fixed per-version header +// lengths. The archive sizes were read back from the binary and hold on every +// platform because the sample files carry LF endings and a pinned modification +// time, which the (attributes) block stores. +struct FormatVersion { + std::string version; + std::string header_size; + std::string archive_size; +}; + +const std::vector format_versions = { + {"1", "32", "1381"}, + {"2", "44", "1377"}, + {"3", "68", "1601"}, + {"4", "208", "1742"}, +}; + +// The properties of an attributed archive: five entries, the three sample files +// plus the (listfile) and (attributes) the recipe writes +Properties AttributedProperties(const FormatVersion &format) { + return {format.version, "0", format.header_size, format.archive_size, "5", "64", "None"}; +} + +// How create's arguments decide the special entries an archive carries, and so +// its file count and signature type +struct SpecialEntries { + std::vector create_args; + std::string file_count; + std::string signature_type; +}; + +const std::vector special_entries = { + {{}, "4", "None"}, // The three files and a (listfile) + {{"--file-flags1", "0"}, "3", "None"}, // No (listfile) + {{"-s"}, "5", "Weak"}, // A (signature) as well + {{"-s", "--format-version", "4"}, "5", "Weak"}, // Weak signing holds on the newest format + {AttributedArchiveArgs(), "5", "None"}, // An (attributes) block instead +}; + +// The two Blizzard patches make fetch_integration_data downloads: the only +// archives with a genuine strong signature, and with a weak one Blizzard made. +// The first is the archive the docs page's example output was taken from. The +// strong signature sits after the archive proper, so the archive size is 260 +// bytes short of the file's own. +struct PatchArchive { + std::string name; + Properties expected; +}; + +const std::vector patch_archives = { + {"v1_weak_signature_patch.mpq", {"1", "0", "32", "1798918", "65", "128", "Weak"}}, + {"v2_strong_signature_patch.mpq", {"2", "0", "44", "8720276", "70", "128", "Strong"}}, +}; + +} // namespace + +TEST_CASE("info: prints its help with --help", "[info]") { + auto result = Run(mpqcli_binary, {"info", "--help"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("info [OPTIONS] archive")); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("--property")); +} + +TEST_CASE("info: archive does not exist", "[info]") { + auto result = Run(mpqcli_binary, {"info", "no-such-archive.mpq"}); + + // 105 is CLI11's ValidationError, from the CLI::ExistingFile check + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("archive: File does not exist: no-such-archive.mpq")); +} + +TEST_CASE("info: rejects a directory as the archive", "[info]") { + SampleFiles files; + + auto result = Run(mpqcli_binary, {"info", files.files_dir.string()}); + + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("File is actually a directory")); +} + +TEST_CASE("info: rejects extra arguments", "[info]") { + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"info", sample.archive.string(), "extra"}); + + // 109 is CLI11's ExtrasError: a second archive is not silently ignored + REQUIRE(result.returncode == 109); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("The following argument was not expected: extra")); +} + +TEST_CASE("info: rejects an unknown property", "[info]") { + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"info", "-q", "not-real", sample.archive.string()}); + + // The CLI::IsMember check rejects the name before the archive is opened + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("--property: not-real not in {")); +} + +TEST_CASE("info: rejects -p, which is not one of its options", "[info]") { + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"info", "-p", "file-count", sample.archive.string()}); + + // The unknown -p is set aside and the property name fills the archive + // positional, whose CLI::ExistingFile check fails first: 105 is CLI11's + // ValidationError + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("archive: File does not exist: file-count")); +} + +TEST_CASE("info: fails when the archive cannot be opened as an MPQ", "[info]") { + SampleArchive sample; + + SECTION("a text file") { + auto result = Run(mpqcli_binary, {"info", (sample.files.files_dir / "cats.txt").string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output).size() == 1); + REQUIRE_THAT(result.stderr_output, ContainsSubstring(open_failure)); + // StormLib's error code and text are forwarded, not just the failure + REQUIRE_THAT(result.stderr_output, ContainsSubstring("(1000) Bad MPQ format")); + } + + SECTION("an archive cut short of its tables") { + const auto truncated = sample.files.dir.Path() / "truncated.mpq"; + WriteFile(truncated, ReadFile(sample.archive).substr(0, 700)); + + auto result = Run(mpqcli_binary, {"info", truncated.string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output).size() == 1); + REQUIRE_THAT(result.stderr_output, ContainsSubstring(open_failure)); + } +} + +TEST_CASE("info: prints every property with its name", "[info]") { + const auto format = GENERATE(from_range(format_versions)); + CAPTURE(format.version); + SampleArchive sample(AttributedArchiveArgs(format.version)); + + auto result = Run(mpqcli_binary, {"info", sample.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + REQUIRE(LinesToSet(result.stdout_output) == AttributedProperties(format).Lines()); +} + +TEST_CASE("info: prints one property's bare value with --property", "[info]") { + const auto flag = GENERATE(as{}, "-q", "--property"); + const auto format = GENERATE(from_range(format_versions)); + CAPTURE(flag, format.version); + SampleArchive sample(AttributedArchiveArgs(format.version)); + + for (const auto &[property, value] : AttributedProperties(format).Values()) { + CAPTURE(property); + + auto result = Run(mpqcli_binary, {"info", flag, property, sample.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + // The value alone on one line, with nothing repeated or labelled + REQUIRE(TrimTrailingNewlines(result.stdout_output) == value); + } +} + +TEST_CASE("info: file count and signature type follow the special entries", "[info]") { + const auto entries = GENERATE(from_range(special_entries)); + CAPTURE(entries.create_args); + SampleArchive sample(entries.create_args); + + auto result = Run(mpqcli_binary, {"info", sample.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + const auto lines = LinesToSet(result.stdout_output); + REQUIRE(lines.count("File count: " + entries.file_count) == 1); + REQUIRE(lines.count("Signature type: " + entries.signature_type) == 1); +} + +TEST_CASE("info: prints the documented values for a Blizzard patch archive", "[info]") { + const auto patch = GENERATE(from_range(patch_archives)); + CAPTURE(patch.name); + const auto archive = IntegrationArchive(patch.name); + + auto result = Run(mpqcli_binary, {"info", archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + REQUIRE(LinesToSet(result.stdout_output) == patch.expected.Lines()); + for (const auto &[property, value] : patch.expected.Values()) { + CAPTURE(property); + auto bare = Run(mpqcli_binary, {"info", "-q", property, archive.string()}); + REQUIRE(bare.returncode == 0); + REQUIRE(bare.stderr_output.empty()); + REQUIRE(TrimTrailingNewlines(bare.stdout_output) == value); + } +} + +TEST_CASE("info: reads an archive it has no permission to write", "[info]") { + SampleArchive sample; + fs::permissions(sample.archive, + fs::perms::owner_read | fs::perms::group_read | fs::perms::others_read, + fs::perm_options::replace); + + auto result = Run(mpqcli_binary, {"info", "-q", "file-count", sample.archive.string()}); + + // Windows cannot delete a read-only file, so give the bit back before the + // fixture removes its directory + fs::permissions(sample.archive, fs::perms::owner_write, fs::perm_options::add); + // The archive is opened read-only, so a lack of write access is no obstacle + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + REQUIRE(TrimTrailingNewlines(result.stdout_output) == "4"); +} + +TEST_CASE("info: reports the offset of an archive embedded after leading bytes", "[info]") { + // StormLib looks for the header at 512-byte steps, so both offsets are found + const auto prefix = GENERATE(as{}, 512, 1024); + CAPTURE(prefix); + SampleArchive sample; + const auto embedded = sample.files.dir.Path() / "embedded.mpq"; + WriteFile(embedded, std::string(prefix, '\0') + ReadFile(sample.archive)); + // Everything but the offset reads the same as the bare archive, including + // the archive size, which excludes the leading bytes + auto bare = Run(mpqcli_binary, {"info", sample.archive.string()}); + REQUIRE(bare.returncode == 0); + REQUIRE(bare.stderr_output.empty()); + auto expected = LinesToSet(bare.stdout_output); + REQUIRE(expected.erase("Header offset: 0") == 1); + expected.insert("Header offset: " + std::to_string(prefix)); + + auto result = Run(mpqcli_binary, {"info", embedded.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + REQUIRE(LinesToSet(result.stdout_output) == expected); +} diff --git a/test/functional/test_list.cpp b/test/functional/test_list.cpp new file mode 100644 index 0000000..f11e04e --- /dev/null +++ b/test/functional/test_list.cpp @@ -0,0 +1,606 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "integration_data.h" +#include "many_locales_archive.h" +#include "no_listfile_archive.h" +#include "sample_archive.h" +#include "sample_files.h" +#include "subprocess_helper.h" +#include "temp_dir.h" +#include "test_environment.h" +#include "write_file.h" + +namespace fs = std::filesystem; + +using namespace mpqcli::testing; +using Catch::Matchers::ContainsSubstring; + +namespace { + +// The binary every case spawns: a reference into the singleton, so it is the +// baked path itself rather than a copy that could drift from it +const fs::path &mpqcli_binary = TestEnvironment::Instance().binary_path; + +const std::string open_failure = "[!] Failed to open MPQ archive"; +const std::string enumeration_failure = "[!] Failed to find first file in MPQ archive."; + +// Asserts output is exactly these lines. Any order, since list prints entries +// in hash-table order, but none missing and none repeated, which a set alone +// would not catch +void RequireLines(const std::string &output, const std::set &expected) { + const auto lines = LinesToVector(output); + CAPTURE(lines); + REQUIRE(lines.size() == expected.size()); + REQUIRE(std::set(lines.begin(), lines.end()) == expected); +} + +// The entries of an archive built with AttributedArchiveArgs(): the sample +// files, the (listfile), and the (attributes) block that gives them a time +const std::set user_entries = {"bytes", "cats.txt", "dogs.txt"}; +const std::set all_entries = {"bytes", "cats.txt", "dogs.txt", "(listfile)", + "(attributes)"}; + +// The default detailed columns: size, locale and time. The time is the sample +// files' pinned modification time, set and printed in local time, so it reads +// the same in any zone. The special entries carry no time of their own. +const std::set detailed_entries = { + " 8 enUS 2025-07-29 14:31:00 bytes", + " 27 enUS 2025-07-29 14:31:00 cats.txt", + " 27 enUS 2025-07-29 14:31:00 dogs.txt", + " 27 enUS (listfile)", + " 149 enUS (attributes)", +}; + +// Every property list accepts, with the lines "-a -d -q " prints for +// the attributed archive. Read back from the binary; they hold everywhere for +// the reasons the info tests give. byte-offset prints in hexadecimal with no +// prefix, and name-hash3 is all zeros because a version 1 archive has no HET +// table. The (listfile) and (attributes) keys differ between encryption-key +// and its raw form because their f flag folds the offset into the key. +struct PropertyListing { + std::string property; + std::set lines; +}; + +const std::vector property_listings = { + {"hash-index", + {" 35 bytes", " 44 cats.txt", " 0 dogs.txt", " 25 (listfile)", + " 14 (attributes)"}}, + {"name-hash1", + {"147178ed bytes", "0fd58937 cats.txt", "eb30456b dogs.txt", "fd657910 (listfile)", + "d38437cb (attributes)"}}, + {"name-hash2", + {"c99b9ee2 bytes", "70ab788e cats.txt", "48345fbb dogs.txt", "4e9b98a7 (listfile)", + "07dfeaec (attributes)"}}, + {"name-hash3", + {"0000000000000000 bytes", "0000000000000000 cats.txt", "0000000000000000 dogs.txt", + "0000000000000000 (listfile)", "0000000000000000 (attributes)"}}, + {"locale", + {"enUS bytes", "enUS cats.txt", "enUS dogs.txt", "enUS (listfile)", "enUS (attributes)"}}, + {"file-index", + {" 0 bytes", " 1 cats.txt", " 2 dogs.txt", " 3 (listfile)", + " 4 (attributes)"}}, + {"byte-offset", + {" 20 bytes", " 30 cats.txt", " 53 dogs.txt", " 76 (listfile)", + " 99 (attributes)"}}, + {"file-time", + {"2025-07-29 14:31:00 bytes", "2025-07-29 14:31:00 cats.txt", + "2025-07-29 14:31:00 dogs.txt", " (listfile)", + " (attributes)"}}, + {"file-size", + {" 8 bytes", " 27 cats.txt", " 27 dogs.txt", " 27 (listfile)", + " 149 (attributes)"}}, + {"compressed-size", + {" 16 bytes", " 35 cats.txt", " 35 dogs.txt", " 35 (listfile)", + " 124 (attributes)"}}, + {"flags", + {" cexmn bytes", " cexmn cats.txt", " cexmn dogs.txt", " ce2xmnf (listfile)", + " ce2xmnf (attributes)"}}, + {"encryption-key", + {"eaa753f9 bytes", "935a7772 cats.txt", "a073c614 dogs.txt", "2d2f0b11 (listfile)", + "50e315dd (attributes)"}}, + {"encryption-key-raw", + {"eaa753f9 bytes", "935a7772 cats.txt", "a073c614 dogs.txt", "2d2f0a94 (listfile)", + "50e314af (attributes)"}}, +}; + +// The two Blizzard patches make fetch_integration_data downloads, with the +// entries each lists. A strong signature is appended after the archive rather +// than stored as an entry, so the strong-signed patch has no (signature). +struct PatchListing { + std::string name; + std::size_t user_entries; + std::set special_entries; +}; + +const std::vector patch_listings = { + {"v1_weak_signature_patch.mpq", 62, {"(listfile)", "(attributes)", "(signature)"}}, + {"v2_strong_signature_patch.mpq", 68, {"(listfile)", "(attributes)"}}, +}; + +} // namespace + +TEST_CASE("list: prints its help with --help", "[list]") { + auto result = Run(mpqcli_binary, {"list", "--help"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("list [OPTIONS] archive")); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("--detailed")); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("--listfile")); +} + +TEST_CASE("list: archive does not exist", "[list]") { + auto result = Run(mpqcli_binary, {"list", "no-such-archive.mpq"}); + + // 105 is CLI11's ValidationError, from the CLI::ExistingFile check + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("archive: File does not exist: no-such-archive.mpq")); +} + +TEST_CASE("list: listfile does not exist", "[list]") { + SampleArchive sample; + + auto result = + Run(mpqcli_binary, {"list", sample.archive.string(), "--listfile", "no-such-list.txt"}); + + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("--listfile: File does not exist: no-such-list.txt")); +} + +TEST_CASE("list: fails when the archive is not an MPQ", "[list]") { + SampleFiles files; + + auto result = Run(mpqcli_binary, {"list", (files.files_dir / "cats.txt").string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output).size() == 1); + REQUIRE_THAT(result.stderr_output, ContainsSubstring(open_failure)); +} + +TEST_CASE("list: rejects an unknown property", "[list]") { + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"list", "-q", "not-real", sample.archive.string()}); + + // The CLI::IsMember check rejects the name before the archive is opened + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("--property: not-real not in {")); +} + +TEST_CASE("list: rejects -p, which is not one of its options", "[list]") { + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"list", "-p", "locale", sample.archive.string()}); + + // The unknown -p is set aside and the property name fills the archive + // positional, whose CLI::ExistingFile check fails first: 105 is CLI11's + // ValidationError + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("archive: File does not exist: locale")); +} + +TEST_CASE("list: rejects extra arguments", "[list]") { + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"list", sample.archive.string(), "extra"}); + + // 109 is CLI11's ExtrasError: a second archive is not silently ignored + REQUIRE(result.returncode == 109); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("The following argument was not expected: extra")); +} + +TEST_CASE("list: prints the entry names alone by default", "[list]") { + SampleArchive sample(AttributedArchiveArgs()); + + auto result = Run(mpqcli_binary, {"list", sample.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + // The special entries stay hidden, as ls hides dotfiles + RequireLines(result.stdout_output, user_entries); +} + +TEST_CASE("list: includes the special entries with --all", "[list]") { + const auto flag = GENERATE(as{}, "-a", "--all"); + CAPTURE(flag); + // Signed as well, so every special entry an archive can carry is present + std::vector args = AttributedArchiveArgs(); + args.push_back("-s"); + SampleArchive sample(args); + + auto result = Run(mpqcli_binary, {"list", flag, sample.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + RequireLines(result.stdout_output, + {"bytes", "cats.txt", "dogs.txt", "(listfile)", "(attributes)", "(signature)"}); +} + +TEST_CASE("list: prints size, locale and time with --detailed", "[list]") { + const auto flag = GENERATE(as{}, "-d", "--detailed"); + CAPTURE(flag); + SampleArchive sample(AttributedArchiveArgs()); + + auto result = Run(mpqcli_binary, {"list", "-a", flag, sample.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + RequireLines(result.stdout_output, detailed_entries); +} + +TEST_CASE("list: shows the signature entry stored raw", "[list]") { + std::vector args = AttributedArchiveArgs(); + args.push_back("-s"); + SampleArchive sample(args); + + auto result = Run(mpqcli_binary, {"list", "-a", "-d", "-q", "flags", "-q", "compressed-size", + sample.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + // The (signature) is neither compressed nor encrypted, and signing adds a + // row to the (attributes) block, which is why it is larger than unsigned + RequireLines(result.stdout_output, { + " cexmn 16 bytes", + " cexmn 35 cats.txt", + " cexmn 35 dogs.txt", + " ce2xmnf 35 (listfile)", + " ce2xmnf 143 (attributes)", + " xn 72 (signature)", + }); +} + +TEST_CASE("list: prints each property with --property", "[list]") { + const auto listing = GENERATE(from_range(property_listings)); + CAPTURE(listing.property); + SampleArchive sample(AttributedArchiveArgs()); + + auto result = + Run(mpqcli_binary, {"list", "-a", "-d", sample.archive.string(), "-q", listing.property}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + RequireLines(result.stdout_output, listing.lines); +} + +TEST_CASE("list: prints several properties as columns", "[list]") { + SampleArchive sample(AttributedArchiveArgs()); + + auto result = + Run(mpqcli_binary, {"list", "-a", "-d", sample.archive.string(), "-q", "hash-index", "-q", + "name-hash1", "-q", "name-hash2", "-q", "name-hash3", "-q", + "compressed-size", "-q", "flags", "-q", "encryption-key-raw"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + RequireLines( + result.stdout_output, + { + " 35 147178ed c99b9ee2 0000000000000000 16 cexmn eaa753f9 bytes", + " 44 0fd58937 70ab788e 0000000000000000 35 cexmn 935a7772 cats.txt", + " 0 eb30456b 48345fbb 0000000000000000 35 cexmn a073c614 dogs.txt", + " 25 fd657910 4e9b98a7 0000000000000000 35 ce2xmnf 2d2f0a94 (listfile)", + " 14 d38437cb 07dfeaec 0000000000000000 124 ce2xmnf 50e314af (attributes)", + }); +} + +TEST_CASE("list: prints the properties in the order given", "[list]") { + SampleArchive sample; + + auto locale_first = + Run(mpqcli_binary, {"list", "-q", "locale", "-q", "file-size", sample.archive.string()}); + auto size_first = + Run(mpqcli_binary, {"list", "-q", "file-size", "-q", "locale", sample.archive.string()}); + + REQUIRE(locale_first.returncode == 0); + REQUIRE(locale_first.stderr_output.empty()); + RequireLines(locale_first.stdout_output, + {"enUS 8 bytes", "enUS 27 cats.txt", "enUS 27 dogs.txt"}); + REQUIRE(size_first.returncode == 0); + REQUIRE(size_first.stderr_output.empty()); + RequireLines(size_first.stdout_output, + {" 8 enUS bytes", " 27 enUS cats.txt", " 27 enUS dogs.txt"}); +} + +TEST_CASE("list: --property alone implies --detailed", "[list]") { + const auto flag = GENERATE(as{}, "-q", "--property"); + CAPTURE(flag); + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"list", flag, "locale", sample.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + // A column appears without -d, while the special entries stay hidden without -a + RequireLines(result.stdout_output, {"enUS bytes", "enUS cats.txt", "enUS dogs.txt"}); +} + +TEST_CASE("list: shows a file stored under several locales once per locale", "[list]") { + ManyLocalesArchive archive; + + SECTION("the name repeats without --detailed") { + auto result = Run(mpqcli_binary, {"list", archive.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + REQUIRE(LinesToVector(result.stdout_output) == std::vector(4, "cats.txt")); + } + + SECTION("each locale gets a row with --detailed") { + auto result = Run(mpqcli_binary, {"list", "-d", archive.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + // One row per variant, each with its own content's size; the archive was + // created without an (attributes) block, so there is no time column + RequireLines(result.stdout_output, { + " 26 enUS cats.txt", + " 27 041D cats.txt", + " 33 deDE cats.txt", + " 31 esES cats.txt", + }); + } + + SECTION("the locale column names an LCID the locale table lacks in hexadecimal") { + auto result = Run(mpqcli_binary, + {"list", "-q", "locale", "-q", "file-size", archive.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + RequireLines(result.stdout_output, {"enUS 26 cats.txt", "041D 27 cats.txt", + "deDE 33 cats.txt", "esES 31 cats.txt"}); + } +} + +TEST_CASE("list: prints nested entries with the archive's backslash separator", "[list]") { + TempDir dir; + const auto source_dir = dir.Path() / "src"; + WriteFile(source_dir / "root.txt", "root file"); + WriteFile(source_dir / "sub" / "nested.txt", "nested file"); + const auto archive = dir.Path() / "nested.mpq"; + REQUIRE(Run(mpqcli_binary, {"create", archive.string(), source_dir.string()}).returncode == 0); + + auto result = Run(mpqcli_binary, {"list", "-d", archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + RequireLines(result.stdout_output, {" 9 enUS root.txt", + " 11 enUS sub\\nested.txt"}); +} + +TEST_CASE("list: prints nothing for an archive with no user files", "[list]") { + TempDir dir; + const auto source_dir = dir.Path() / "empty"; + fs::create_directories(source_dir); + const auto archive = dir.Path() / "empty.mpq"; + REQUIRE(Run(mpqcli_binary, {"create", archive.string(), source_dir.string()}).returncode == 0); + + auto result = Run(mpqcli_binary, {"list", archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(result.stderr_output.empty()); + // Only the empty (listfile) is there to show + auto all = Run(mpqcli_binary, {"list", "-a", "-d", archive.string()}); + REQUIRE(all.returncode == 0); + REQUIRE(all.stderr_output.empty()); + RequireLines(all.stdout_output, {" 0 enUS (listfile)"}); +} + +TEST_CASE("list: names entries by position without a listfile", "[list]") { + NoListfileArchive archive; + const std::set positions = {"File00000000.xxx", "File00000001.xxx", + "File00000002.xxx"}; + + // With no names to know, --all changes nothing + for (const auto &args : std::vector>{{}, {"-a"}}) { + CAPTURE(args); + std::vector full = {"list"}; + full.insert(full.end(), args.begin(), args.end()); + full.push_back(archive.archive.string()); + + auto result = Run(mpqcli_binary, full); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + RequireLines(result.stdout_output, positions); + } + + // The size and locale of each entry are still known, in insertion order + auto detailed = Run(mpqcli_binary, {"list", "-ad", archive.archive.string()}); + + REQUIRE(detailed.returncode == 0); + REQUIRE(detailed.stderr_output.empty()); + RequireLines(detailed.stdout_output, { + " 31 enUS File00000000.xxx", + " 33 deDE File00000001.xxx", + " 27 041D File00000002.xxx", + }); +} + +TEST_CASE("list: names the entries a partial external listfile knows", "[list]") { + const auto flag = GENERATE(as{}, "-l", "--listfile"); + CAPTURE(flag); + NoListfileArchive archive; + const auto listfile = archive.dir.Path() / "listfile.txt"; + WriteFile(listfile, "cats.txt"); + + auto result = Run(mpqcli_binary, {"list", archive.archive.string(), flag, listfile.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + // The named entry gets its name back; the rest keep their positions + RequireLines(result.stdout_output, {"File00000000.xxx", "cats.txt", "File00000002.xxx"}); + auto detailed = + Run(mpqcli_binary, {"list", "-ad", archive.archive.string(), flag, listfile.string()}); + REQUIRE(detailed.returncode == 0); + REQUIRE(detailed.stderr_output.empty()); + RequireLines(detailed.stdout_output, { + " 31 enUS File00000000.xxx", + " 33 deDE cats.txt", + " 27 041D File00000002.xxx", + }); +} + +TEST_CASE("list: names every entry with a complete external listfile", "[list]") { + NoListfileArchive archive; + const auto listfile = archive.dir.Path() / "listfile.txt"; + WriteFile(listfile, "cats.txt\ndogs.txt\ncapybaras.txt"); + + auto result = Run(mpqcli_binary, {"list", archive.archive.string(), "-l", listfile.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + RequireLines(result.stdout_output, {"capybaras.txt", "cats.txt", "dogs.txt"}); + auto detailed = + Run(mpqcli_binary, {"list", "-ad", archive.archive.string(), "-l", listfile.string()}); + REQUIRE(detailed.returncode == 0); + REQUIRE(detailed.stderr_output.empty()); + RequireLines(detailed.stdout_output, { + " 31 enUS capybaras.txt", + " 33 deDE cats.txt", + " 27 041D dogs.txt", + }); +} + +TEST_CASE("list: ignores listfile names the archive lacks", "[list]") { + SampleArchive sample; + const auto listfile = sample.files.dir.Path() / "listfile.txt"; + WriteFile(listfile, "ghost.txt\ncats.txt\n"); + + auto result = Run(mpqcli_binary, {"list", sample.archive.string(), "-l", listfile.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + // A name with no entry behind it is not invented + RequireLines(result.stdout_output, user_entries); +} + +TEST_CASE("list: prints the encryption keys of entries listed without a name", "[list]") { + NoListfileArchive archive; + const auto listfile = archive.dir.Path() / "listfile.txt"; + WriteFile(listfile, "capybaras.txt\ncats.txt\ndogs.txt\n"); + const std::vector columns = {"-q", "file-index", "-q", "flags", + "-q", "encryption-key", "-q", "encryption-key-raw"}; + std::vector unnamed_args = {"list", archive.archive.string()}; + unnamed_args.insert(unnamed_args.end(), columns.begin(), columns.end()); + std::vector named_args = {"list", archive.archive.string(), "-l", + listfile.string()}; + named_args.insert(named_args.end(), columns.begin(), columns.end()); + + auto unnamed = Run(mpqcli_binary, unnamed_args); + auto named = Run(mpqcli_binary, named_args); + + // Every entry is encrypted with a key derived from its name. An unnamed + // entry's key is recovered from its data, so both listings agree. + REQUIRE(unnamed.returncode == 0); + REQUIRE(unnamed.stderr_output.empty()); + RequireLines(unnamed.stdout_output, { + " 0 cexmn 40526ca5 40526ca5 File00000000.xxx", + " 1 cexmn 935a7772 935a7772 File00000001.xxx", + " 2 cexmn a073c614 a073c614 File00000002.xxx", + }); + REQUIRE(named.returncode == 0); + REQUIRE(named.stderr_output.empty()); + RequireLines(named.stdout_output, { + " 0 cexmn 40526ca5 40526ca5 capybaras.txt", + " 1 cexmn 935a7772 935a7772 cats.txt", + " 2 cexmn a073c614 a073c614 dogs.txt", + }); +} + +TEST_CASE("list: fails when no entry can be enumerated", "[list]") { + SECTION("an empty external listfile") { + NoListfileArchive archive; + const auto listfile = archive.dir.Path() / "empty.txt"; + WriteFile(listfile, ""); + + auto result = + Run(mpqcli_binary, {"list", archive.archive.string(), "-l", listfile.string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == std::set{enumeration_failure}); + } + + SECTION("an archive with no entries at all") { + TempDir dir; + const auto source_dir = dir.Path() / "empty"; + fs::create_directories(source_dir); + const auto archive = dir.Path() / "bare.mpq"; + // No files and no (listfile) either, so the hash table is empty + REQUIRE(Run(mpqcli_binary, + {"create", archive.string(), source_dir.string(), "--file-flags1", "0"}) + .returncode == 0); + + auto result = Run(mpqcli_binary, {"list", "-a", archive.string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == std::set{enumeration_failure}); + } +} + +TEST_CASE("list: counts a Blizzard patch's entries and special files", "[list]") { + const auto patch = GENERATE(from_range(patch_listings)); + CAPTURE(patch.name); + const auto archive = IntegrationArchive(patch.name); + + auto result = Run(mpqcli_binary, {"list", archive.string()}); + auto all = Run(mpqcli_binary, {"list", "-a", archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + const auto names = LinesToVector(result.stdout_output); + REQUIRE(names.size() == patch.user_entries); + REQUIRE(all.returncode == 0); + REQUIRE(all.stderr_output.empty()); + const auto all_names = LinesToVector(all.stdout_output); + REQUIRE(all_names.size() == patch.user_entries + patch.special_entries.size()); + std::set specials; + for (const auto &name : all_names) { + if (!name.empty() && name.front() == '(') { + specials.insert(name); + } + } + REQUIRE(specials == patch.special_entries); +} + +TEST_CASE("list: prints the documented sizes for the weak-signed patch", "[list]") { + const auto archive = IntegrationArchive("v1_weak_signature_patch.mpq"); + + // Size and locale only: the docs page's detailed example carries file times + // in the zone it was written in, which no other machine reproduces + auto result = Run(mpqcli_binary, {"list", "-q", "file-size", "-q", "locale", archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + const auto lines = LinesToSet(result.stdout_output); + REQUIRE(lines.size() == 62); + for (const auto &line : + {" 88604 enUS BM_COKETENT01.BLP", " 243 enUS Blizzard_CraftUI.xml", + " 184 enUS Blizzard_CraftUI.lua", " 68 enUS realmlist.wtf"}) { + CAPTURE(line); + REQUIRE(lines.count(line) == 1); + } +} diff --git a/test/functional/test_read.cpp b/test/functional/test_read.cpp new file mode 100644 index 0000000..16609f9 --- /dev/null +++ b/test/functional/test_read.cpp @@ -0,0 +1,358 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "compressible_text.h" +#include "integration_data.h" +#include "many_locales_archive.h" +#include "one_locale_archive.h" +#include "sample_archive.h" +#include "sample_files.h" +#include "subprocess_helper.h" +#include "temp_dir.h" +#include "test_environment.h" +#include "write_file.h" + +namespace fs = std::filesystem; + +using namespace mpqcli::testing; +using Catch::Matchers::ContainsSubstring; + +namespace { + +// The binary every case spawns: a reference into the singleton, so it is the +// baked path itself rather than a copy that could drift from it +const fs::path &mpqcli_binary = TestEnvironment::Instance().binary_path; + +const std::string open_failure = "[!] Failed to open MPQ archive"; + +// The failure read prints when neither the asked-for locale nor the default +// one holds the entry; the locale named is the one asked for +std::string MissingUnderLocale(const std::string &locale, const std::string &name) { + return "[!] Failed: File doesn't exist for locale " + locale + ": " + name; +} + +// A locale request as the arguments that make it, with the locale name the +// failure message then carries: none given means the default, enUS +struct LocaleRequest { + std::vector args; + std::string named; +}; + +const std::vector locale_requests = { + {{}, "enUS"}, + {{"--locale", "deDE"}, "deDE"}, +}; + +// The sample files, whose bytes read must hand back unchanged +const std::vector> sample_contents = SampleFiles::Contents(); + +// The docs page's example: patch.cmd from the weak-signed Blizzard patch, which +// is 522 bytes of CRLF text +const std::vector documented_patch_cmd = { + "* set the product patch name", + "PatchVersion This patch upgrades World of Warcraft from version 1.10.0.5195 to version " + "1.10.1.5230.", + "* make sure that we don't patch version 1.10.1.5230 or greater", + "FileVersionEqualTo \"$(InstallPath)\\WoW.exe\" 1.10.0.5195 " + "0xffff.0xffff.0xffff.0xffff", + "* ProductVersionLessThan \"$(InstallPath)\\WoW.exe\" 0.0.0.256 0.0.0.0xffff", + "Language enGB", + "SetLauncher \"$(InstallPath)\\WoW.exe\"", + "* SetUninstall $(WinDir)\\WoWUnin.dat", + "Self \"World of Warcraft\"", + "WoWPatchIndex 2", + "PatchSize 1992294400", +}; + +} // namespace + +TEST_CASE("read: prints its help with --help", "[read]") { + auto result = Run(mpqcli_binary, {"read", "--help"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("read [OPTIONS] archive file")); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("--locale")); +} + +TEST_CASE("read: every subcommand with a locale offers it as -e", "[read]") { + // A short flag means one thing across the binary + const auto subcommand = + GENERATE(as{}, "add", "create", "extract", "read", "remove", "rename"); + CAPTURE(subcommand); + + auto result = Run(mpqcli_binary, {subcommand, "--help"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + const std::regex locale_option(R"(^\s+-e,\s+--locale\b)"); + const auto lines = LinesToVector(result.stdout_output); + REQUIRE(std::any_of(lines.begin(), lines.end(), [&](const std::string &line) { + return std::regex_search(line, locale_option); + })); +} + +TEST_CASE("read: archive does not exist", "[read]") { + auto result = Run(mpqcli_binary, {"read", "no-such-archive.mpq", "cats.txt"}); + + // 105 is CLI11's ValidationError, from the CLI::ExistingFile check + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("File does not exist: no-such-archive.mpq")); +} + +TEST_CASE("read: fails when the archive is not an MPQ", "[read]") { + SampleFiles files; + + auto result = Run(mpqcli_binary, {"read", (files.files_dir / "cats.txt").string(), "cats.txt"}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output).size() == 1); + REQUIRE_THAT(result.stderr_output, ContainsSubstring(open_failure)); +} + +TEST_CASE("read: rejects extra arguments", "[read]") { + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"read", sample.archive.string(), "cats.txt", "extra"}); + + // 109 is CLI11's ExtrasError + REQUIRE(result.returncode == 109); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("The following argument was not expected: extra")); +} + +TEST_CASE("read: fails when the archive has no such entry", "[read]") { + const auto request = GENERATE(from_range(locale_requests)); + CAPTURE(request.args); + SampleArchive sample; + std::vector args = {"read", sample.archive.string(), "does-not-exist.txt"}; + args.insert(args.end(), request.args.begin(), request.args.end()); + + auto result = Run(mpqcli_binary, args); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + // The message names the locale that was looked for, the default when none was given + REQUIRE(LinesToSet(result.stderr_output) == + std::set{MissingUnderLocale(request.named, "does-not-exist.txt")}); +} + +TEST_CASE("read: writes an entry's bytes to stdout unchanged", "[read]") { + const auto [name, content] = GENERATE(from_range(sample_contents)); + CAPTURE(name); + SampleArchive sample(AttributedArchiveArgs()); + + auto result = Run(mpqcli_binary, {"read", sample.archive.string(), name}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + // Byte for byte: the text keeps its own line ending and gains none, and the + // binary entry's NUL and control bytes come through untouched + REQUIRE(result.stdout_output == content); +} + +TEST_CASE("read: reads the special entries by name", "[read]") { + std::vector args = AttributedArchiveArgs(); + args.push_back("-s"); + SampleArchive sample(args); + + SECTION("the (listfile) names every user entry") { + auto result = Run(mpqcli_binary, {"read", sample.archive.string(), "(listfile)"}); + + REQUIRE(result.returncode == 0); + REQUIRE(LinesToSet(result.stdout_output) == + std::set{"bytes", "cats.txt", "dogs.txt"}); + } + + SECTION("the (attributes) block and the (signature) come back whole") { + // The sizes list reports for the same archive: 177 bytes of attributes + // for six entries, and a 64-byte weak signature with its 8-byte header + auto attributes = Run(mpqcli_binary, {"read", sample.archive.string(), "(attributes)"}); + auto signature = Run(mpqcli_binary, {"read", sample.archive.string(), "(signature)"}); + + REQUIRE(attributes.returncode == 0); + REQUIRE(attributes.stdout_output.size() == 177); + REQUIRE(signature.returncode == 0); + REQUIRE(signature.stdout_output.size() == 72); + } +} + +TEST_CASE("read: matches the entry name without regard to case", "[read]") { + const auto name = GENERATE(as{}, "CATS.TXT", "Cats.Txt"); + CAPTURE(name); + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"read", sample.archive.string(), name}); + + // MPQ name hashes are computed over the upper-cased name + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output == "This is a file about cats.\n"); +} + +TEST_CASE("read: takes a nested entry by its backslash path", "[read]") { + TempDir dir; + const auto source_dir = dir.Path() / "src"; + WriteFile(source_dir / "sub" / "nested.txt", "nested file"); + const auto archive = dir.Path() / "nested.mpq"; + REQUIRE(Run(mpqcli_binary, {"create", archive.string(), source_dir.string()}).returncode == 0); + + auto result = Run(mpqcli_binary, {"read", archive.string(), "sub\\nested.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output == "nested file"); +} + +TEST_CASE("read: hands back a file larger than one sector intact", "[read]") { + TempDir dir; + const auto source_dir = dir.Path() / "src"; + // About 68 KB, well over the 4 KB sector, so the read spans many sectors + const auto text = CompressibleText(12000); + WriteFile(source_dir / "text.txt", text); + const auto archive = dir.Path() / "big.mpq"; + REQUIRE(Run(mpqcli_binary, {"create", archive.string(), source_dir.string()}).returncode == 0); + + auto result = Run(mpqcli_binary, {"read", archive.string(), "text.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.size() == text.size()); + REQUIRE(result.stdout_output == text); +} + +TEST_CASE("read: prints nothing for an empty entry", "[read]") { + TempDir dir; + const auto source_dir = dir.Path() / "src"; + WriteFile(source_dir / "zero.txt", ""); + const auto archive = dir.Path() / "zero.mpq"; + REQUIRE(Run(mpqcli_binary, {"create", archive.string(), source_dir.string()}).returncode == 0); + + auto result = Run(mpqcli_binary, {"read", archive.string(), "zero.txt"}); + + // An empty entry is a success with no bytes, not a failure + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(result.stderr_output.empty()); +} + +TEST_CASE("read: the variant under the given locale", "[read]") { + // The locale as given, and the variant it selects: a language code, the + // hexadecimal LCID the locale table does not name, and the hexadecimal + // LCID of one it does (0407 is deDE) + const auto [locale, variant] = GENERATE(table({ + {"deDE", "deDE"}, + {"esES", "esES"}, + {"041D", "041D"}, + {"0407", "deDE"}, + })); + const auto flag = GENERATE(as{}, "-e", "--locale"); + CAPTURE(locale, flag); + ManyLocalesArchive locales; + + auto result = Run(mpqcli_binary, {"read", locales.archive.string(), "cats.txt", flag, locale}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + REQUIRE(result.stdout_output == CatsContent(variant)); +} + +TEST_CASE("read: takes the default locale when none is given", "[read]") { + ManyLocalesArchive locales; + + auto result = Run(mpqcli_binary, {"read", locales.archive.string(), "cats.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output == CatsContent("")); +} + +TEST_CASE("read: rejects an unknown locale", "[read]") { + ManyLocalesArchive locales; + + auto result = Run(mpqcli_binary, + {"read", locales.archive.string(), "cats.txt", "--locale", "nosuchlocale"}); + + // 105 is CLI11's ValidationError, from the locale validator + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("--locale: Locale must be nothing, or one of:")); +} + +TEST_CASE("read: falls back to the default locale when the file has no such variant", "[read]") { + ManyLocalesArchive locales; + + auto result = + Run(mpqcli_binary, {"read", locales.archive.string(), "cats.txt", "--locale", "ptPT"}); + + // Silently: a known locale the entry lacks is not worth a warning + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + REQUIRE(result.stdout_output == CatsContent("")); +} + +TEST_CASE("read: fails when neither the given nor the default locale has the file", "[read]") { + const auto request = GENERATE(from_range(locale_requests)); + CAPTURE(request.args); + OneLocaleArchive archive; + std::vector args = {"read", archive.archive.string(), "cats.txt"}; + args.insert(args.end(), request.args.begin(), request.args.end()); + + auto result = Run(mpqcli_binary, args); + + // The entry exists under esES alone, so there is nothing to fall back to + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == + std::set{MissingUnderLocale(request.named, "cats.txt")}); +} + +TEST_CASE("read: the only variant when its locale is given", "[read]") { + OneLocaleArchive archive; + + auto result = Run(mpqcli_binary, {"read", archive.archive.string(), "cats.txt", "--locale", + OneLocaleArchive::locale}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output == CatsContent(OneLocaleArchive::locale)); +} + +TEST_CASE("read: prints the documented patch.cmd from the weak-signed patch", "[read]") { + const auto archive = IntegrationArchive("v1_weak_signature_patch.mpq"); + + auto result = Run(mpqcli_binary, {"read", archive.string(), "patch.cmd"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + REQUIRE(result.stdout_output.size() == 522); + // The lines the docs page shows; the file's own CRLF endings are dropped by + // the split on both sides + REQUIRE(LinesToVector(result.stdout_output) == documented_patch_cmd); +} + +TEST_CASE("read: rejects the file name in the archive's place", "[read]") { + ManyLocalesArchive locales; + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); + + auto result = Run(mpqcli_binary, {"read", "cats.txt", locales.archive.string()}, options); + + // The file name is taken as the archive, and the working directory has no + // file by that name + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("archive: File does not exist: cats.txt")); +} diff --git a/test/functional/test_remove.cpp b/test/functional/test_remove.cpp new file mode 100644 index 0000000..ab1bf1b --- /dev/null +++ b/test/functional/test_remove.cpp @@ -0,0 +1,470 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "archive_helper.h" +#include "many_locales_archive.h" +#include "no_listfile_archive.h" +#include "sample_archive.h" +#include "sample_files.h" +#include "subprocess_helper.h" +#include "temp_dir.h" +#include "test_environment.h" +#include "write_file.h" + +namespace fs = std::filesystem; + +using namespace mpqcli::testing; +using Catch::Matchers::ContainsSubstring; + +namespace { + +// The binary every case spawns: a reference into the singleton, so it is the +// baked path itself rather than a copy that could drift from it +const fs::path &mpqcli_binary = TestEnvironment::Instance().binary_path; + +const std::string open_failure = "[!] Failed to open MPQ archive"; + +// What a fresh SampleArchive lists, before any remove +const std::set sample_entries = {"enUS bytes", "enUS cats.txt", "enUS dogs.txt"}; + +// What ManyLocalesArchive lists: cats.txt under the default locale and three others +const std::set all_variants = {"enUS cats.txt", "041D cats.txt", "deDE cats.txt", + "esES cats.txt"}; + +std::set Minus(std::set entries, + std::initializer_list gone) { + for (const auto &entry : gone) { + entries.erase(entry); + } + return entries; +} + +// The line remove prints to stderr for each name before trying it; a locale is +// named only when one was given +std::string Removing(const std::string &name, const std::string &locale = "") { + return "[-] Removing file" + (locale.empty() ? "" : " for locale " + locale) + ": " + name; +} + +// The failure when the entry is not stored under the locale looked for, which +// is named even when it is the default one +std::string MissingUnderLocale(const std::string &locale, const std::string &name) { + return "[!] Failed: File doesn't exist for locale " + locale + ": " + name; +} + +} // namespace + +TEST_CASE("remove: prints its help with --help", "[remove]") { + auto result = Run(mpqcli_binary, {"remove", "--help"}); + + REQUIRE(result.returncode == 0); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("remove [OPTIONS] archive files...")); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("--locale")); +} + +TEST_CASE("remove: archive does not exist", "[remove]") { + auto result = Run(mpqcli_binary, {"remove", "no-such-archive.mpq", "cats.txt"}); + + // 105 is CLI11's ValidationError, from the CLI::ExistingFile check + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("File does not exist: no-such-archive.mpq")); +} + +TEST_CASE("remove: fails when the archive is not an MPQ", "[remove]") { + SampleFiles files; + + auto result = + Run(mpqcli_binary, {"remove", (files.files_dir / "cats.txt").string(), "cats.txt"}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output).size() == 1); + REQUIRE_THAT(result.stderr_output, ContainsSubstring(open_failure)); +} + +TEST_CASE("remove: rejects an unknown locale", "[remove]") { + SampleArchive sample; + + // Like create and add, remove validates the locale at parse time + auto result = Run(mpqcli_binary, + {"remove", sample.archive.string(), "cats.txt", "--locale", "nosuchlocale"}); + + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("Locale must be nothing, or one of")); + REQUIRE(ArchiveEntries(sample.archive) == sample_entries); +} + +TEST_CASE("remove: fails when the archive has no such entry", "[remove]") { + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"remove", sample.archive.string(), "does-not-exist.txt"}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + // The attempt is announced before it fails + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Removing("does-not-exist.txt"), + MissingUnderLocale("enUS", "does-not-exist.txt")}); + REQUIRE(ArchiveEntries(sample.archive) == sample_entries); +} + +TEST_CASE("remove: a single file", "[remove]") { + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"remove", sample.archive.string(), "cats.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == std::vector{Removing("cats.txt")}); + REQUIRE(ArchiveEntries(sample.archive) == Minus(sample_entries, {"enUS cats.txt"})); + // Gone for read as well, not just from the listing + REQUIRE(Run(mpqcli_binary, {"read", sample.archive.string(), "cats.txt"}).returncode == 1); +} + +TEST_CASE("remove: several files in one run", "[remove]") { + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"remove", sample.archive.string(), "cats.txt", "dogs.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + // Announced in the order given + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Removing("cats.txt"), Removing("dogs.txt")}); + REQUIRE(ArchiveEntries(sample.archive) == std::set{"enUS bytes"}); +} + +TEST_CASE("remove: a name given twice is removed once", "[remove]") { + // The repeat is dropped rather than failing as a second, missing entry + SECTION("without a locale") { + SampleArchive sample; + + auto result = + Run(mpqcli_binary, {"remove", sample.archive.string(), "cats.txt", "cats.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Removing("cats.txt")}); + REQUIRE(ArchiveEntries(sample.archive) == Minus(sample_entries, {"enUS cats.txt"})); + } + + SECTION("with a locale") { + ManyLocalesArchive locales; + + auto result = Run(mpqcli_binary, {"remove", locales.archive.string(), "cats.txt", + "cats.txt", "--locale", "esES"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Removing("cats.txt", "esES")}); + REQUIRE(ArchiveEntries(locales.archive) == Minus(all_variants, {"esES cats.txt"})); + } +} + +TEST_CASE("remove: continues past a missing entry and still fails", "[remove]") { + SampleArchive sample; + + auto result = Run(mpqcli_binary, + {"remove", sample.archive.string(), "cats.txt", "ghost.txt", "dogs.txt"}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Removing("cats.txt"), Removing("ghost.txt"), + MissingUnderLocale("enUS", "ghost.txt"), + Removing("dogs.txt")}); + // The names on either side of the missing one are still removed + REQUIRE(ArchiveEntries(sample.archive) == std::set{"enUS bytes"}); +} + +TEST_CASE("remove: every user file leaves only the special entries", "[remove]") { + SampleArchive sample(AttributedArchiveArgs()); + + auto result = + Run(mpqcli_binary, {"remove", sample.archive.string(), "cats.txt", "dogs.txt", "bytes"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == std::vector{Removing("cats.txt"), + Removing("dogs.txt"), + Removing("bytes")}); + REQUIRE(ArchiveEntries(sample.archive).empty()); + REQUIRE(ArchiveEntries(sample.archive, true) == + std::set{"enUS (listfile)", "enUS (attributes)"}); + REQUIRE(InfoProperty(sample.archive, "file-count") == "2"); +} + +TEST_CASE("remove: refuses the special entries", "[remove]") { + const auto name = GENERATE(as{}, "(listfile)", "(attributes)", "(signature)"); + CAPTURE(name); + std::vector args = AttributedArchiveArgs(); + args.push_back("-s"); + SampleArchive sample(args); + const auto before = ArchiveEntries(sample.archive, true); + REQUIRE(before.count("enUS " + name) == 1); + + auto result = Run(mpqcli_binary, {"remove", sample.archive.string(), name}); + + // StormLib keeps its own entries: the name is found but the removal is refused + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{ + Removing(name), "[!] Failed: File cannot be removed for locale enUS: " + name}); + REQUIRE(ArchiveEntries(sample.archive, true) == before); + REQUIRE(InfoProperty(sample.archive, "signature-type") == "Weak"); +} + +TEST_CASE("remove: matches the entry name without regard to case", "[remove]") { + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"remove", sample.archive.string(), "CATS.TXT"}); + + // MPQ name hashes are computed over the upper-cased name; the message + // echoes the spelling given + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == std::vector{Removing("CATS.TXT")}); + REQUIRE(ArchiveEntries(sample.archive) == Minus(sample_entries, {"enUS cats.txt"})); +} + +TEST_CASE("remove: a nested entry by its backslash path", "[remove]") { + TempDir dir; + const auto source_dir = dir.Path() / "src"; + WriteFile(source_dir / "root.txt", "root file"); + WriteFile(source_dir / "sub" / "nested.txt", "nested file"); + const auto archive = dir.Path() / "nested.mpq"; + REQUIRE(Run(mpqcli_binary, {"create", archive.string(), source_dir.string()}).returncode == 0); + + auto result = Run(mpqcli_binary, {"remove", archive.string(), "sub\\nested.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Removing("sub\\nested.txt")}); + REQUIRE(ArchiveEntries(archive) == std::set{"enUS root.txt"}); +} + +TEST_CASE("remove: the default-locale variant when no locale is given", "[remove]") { + ManyLocalesArchive locales; + + auto result = Run(mpqcli_binary, {"remove", locales.archive.string(), "cats.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == std::vector{Removing("cats.txt")}); + REQUIRE(ArchiveEntries(locales.archive) == Minus(all_variants, {"enUS cats.txt"})); + // The other variants are untouched: the German one still reads, and a read + // with no locale has nothing left to fall back to + REQUIRE(ReadEntry(locales.archive, "cats.txt", "deDE") == + std::set{CatsContent("deDE")}); + auto fallback = Run(mpqcli_binary, {"read", locales.archive.string(), "cats.txt"}); + REQUIRE(fallback.returncode == 1); + REQUIRE(fallback.stdout_output.empty()); + REQUIRE(LinesToSet(fallback.stderr_output) == + std::set{MissingUnderLocale("enUS", "cats.txt")}); +} + +TEST_CASE("remove: the variant under the given locale", "[remove]") { + // The locale as given, how the message names it, and the entry that goes: + // a language code, the hexadecimal LCID the locale table does not name, + // the hexadecimal LCID of one it does (0407 is deDE), and the "default" + // alias the validator accepts, which names no locale in the message + const auto [locale, named, entry] = GENERATE(table({ + {"esES", "esES", "esES cats.txt"}, + {"041D", "041D", "041D cats.txt"}, + {"0407", "deDE", "deDE cats.txt"}, + {"default", "", "enUS cats.txt"}, + })); + const auto flag = GENERATE(as{}, "-e", "--locale"); + CAPTURE(locale, flag); + ManyLocalesArchive locales; + + auto result = + Run(mpqcli_binary, {"remove", locales.archive.string(), "cats.txt", flag, locale}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Removing("cats.txt", named)}); + REQUIRE(ArchiveEntries(locales.archive) == Minus(all_variants, {entry})); +} + +TEST_CASE("remove: each variant in turn until none is left", "[remove]") { + ManyLocalesArchive locales; + auto remaining = all_variants; + + for (const auto &[locale, entry] : + std::vector>{{"", "enUS cats.txt"}, + {"esES", "esES cats.txt"}, + {"041D", "041D cats.txt"}, + {"deDE", "deDE cats.txt"}}) { + CAPTURE(locale); + std::vector args = {"remove", locales.archive.string(), "cats.txt"}; + if (!locale.empty()) { + args.push_back("--locale"); + args.push_back(locale); + } + + auto result = Run(mpqcli_binary, args); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Removing("cats.txt", locale)}); + remaining.erase(entry); + REQUIRE(ArchiveEntries(locales.archive) == remaining); + } + REQUIRE(ArchiveEntries(locales.archive, true) == std::set{"enUS (listfile)"}); +} + +TEST_CASE("remove: fails for a locale the entry is not stored under", "[remove]") { + SECTION("an entry stored under one locale") { + NoListfileArchive archive; + const auto before = ArchiveEntries(archive.archive); + + for (const auto &entry : NoListfileArchive::Entries()) { + CAPTURE(entry.name); + + auto result = Run(mpqcli_binary, + {"remove", archive.archive.string(), entry.name, "--locale", "ptPT"}); + + // Whether the entry is under the default locale, a named one or a bare + // LCID, a locale it lacks is a miss with no fallback + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Removing(entry.name, "ptPT"), + MissingUnderLocale("ptPT", entry.name)}); + } + REQUIRE(ArchiveEntries(archive.archive) == before); + } + + SECTION("an entry stored under several locales") { + ManyLocalesArchive locales; + + auto result = Run(mpqcli_binary, + {"remove", locales.archive.string(), "cats.txt", "--locale", "ptPT"}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Removing("cats.txt", "ptPT"), + MissingUnderLocale("ptPT", "cats.txt")}); + REQUIRE(ArchiveEntries(locales.archive) == all_variants); + } +} + +TEST_CASE("remove: fails without a locale for an entry stored only under another", "[remove]") { + NoListfileArchive archive; + const auto listfile = archive.dir.Path() / "listfile.txt"; + WriteFile(listfile, "cats.txt\ndogs.txt\ncapybaras.txt"); + const std::set before = {"enUS capybaras.txt", "deDE cats.txt", + "041D dogs.txt"}; + REQUIRE(ArchiveEntries(archive.archive, false, listfile) == before); + + // dogs.txt is stored under 041D alone: the default locale has no such entry + auto without = Run(mpqcli_binary, {"remove", archive.archive.string(), "dogs.txt"}); + + REQUIRE(without.returncode == 1); + REQUIRE(without.stdout_output.empty()); + REQUIRE(LinesToVector(without.stderr_output) == + std::vector{Removing("dogs.txt"), MissingUnderLocale("enUS", "dogs.txt")}); + REQUIRE(ArchiveEntries(archive.archive, false, listfile) == before); + + auto with = + Run(mpqcli_binary, {"remove", archive.archive.string(), "dogs.txt", "--locale", "041D"}); + + REQUIRE(with.returncode == 0); + REQUIRE(with.stdout_output.empty()); + REQUIRE(LinesToVector(with.stderr_output) == + std::vector{Removing("dogs.txt", "041D")}); + REQUIRE(ArchiveEntries(archive.archive, false, listfile) == Minus(before, {"041D dogs.txt"})); +} + +TEST_CASE("remove: finds an entry by name in an archive without a listfile", "[remove]") { + NoListfileArchive archive; + const auto listfile = archive.dir.Path() / "listfile.txt"; + WriteFile(listfile, "cats.txt\ndogs.txt\ncapybaras.txt"); + + // The name hashes to its entry whether or not the archive can list it + auto result = Run(mpqcli_binary, {"remove", archive.archive.string(), "capybaras.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Removing("capybaras.txt")}); + REQUIRE(ArchiveEntries(archive.archive, false, listfile) == + std::set{"deDE cats.txt", "041D dogs.txt"}); +} + +TEST_CASE("remove: reads names from stdin with -", "[remove]") { + SampleArchive sample; + RunOptions options; + // Blank lines are skipped + options.stdin_input = "\ncats.txt\n\ndogs.txt\n"; + + auto result = Run(mpqcli_binary, {"remove", sample.archive.string(), "-"}, options); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Removing("cats.txt"), Removing("dogs.txt")}); + REQUIRE(ArchiveEntries(sample.archive) == std::set{"enUS bytes"}); +} + +TEST_CASE("remove: strips Windows line endings from names read from stdin", "[remove]") { + SampleArchive sample; + RunOptions options; + // A name that kept its carriage return would match no entry, and the blank + // line would become a name of its own + options.stdin_input = "cats.txt\r\n\r\ndogs.txt\r\n"; + + auto result = Run(mpqcli_binary, {"remove", sample.archive.string(), "-"}, options); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Removing("cats.txt"), Removing("dogs.txt")}); + REQUIRE(ArchiveEntries(sample.archive) == std::set{"enUS bytes"}); +} + +TEST_CASE("remove: - with empty stdin removes nothing", "[remove]") { + SampleArchive sample; + RunOptions options; + options.stdin_input = ""; + + auto result = Run(mpqcli_binary, {"remove", sample.archive.string(), "-"}, options); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(result.stderr_output.empty()); + REQUIRE(ArchiveEntries(sample.archive) == sample_entries); +} + +TEST_CASE("remove: mixes names given as arguments with names from stdin", "[remove]") { + SampleArchive sample; + RunOptions options; + options.stdin_input = "dogs.txt\n"; + + auto result = Run(mpqcli_binary, {"remove", sample.archive.string(), "cats.txt", "-"}, options); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Removing("cats.txt"), Removing("dogs.txt")}); + REQUIRE(ArchiveEntries(sample.archive) == std::set{"enUS bytes"}); +} diff --git a/test/functional/test_rename.cpp b/test/functional/test_rename.cpp new file mode 100644 index 0000000..a18d56b --- /dev/null +++ b/test/functional/test_rename.cpp @@ -0,0 +1,464 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "archive_helper.h" +#include "many_locales_archive.h" +#include "no_listfile_archive.h" +#include "sample_archive.h" +#include "sample_files.h" +#include "subprocess_helper.h" +#include "temp_dir.h" +#include "test_environment.h" +#include "write_file.h" + +namespace fs = std::filesystem; + +using namespace mpqcli::testing; +using Catch::Matchers::ContainsSubstring; + +namespace { + +// The binary every case spawns: a reference into the singleton, so it is the +// baked path itself rather than a copy that could drift from it +const fs::path &mpqcli_binary = TestEnvironment::Instance().binary_path; + +const std::string open_failure = "[!] Failed to open MPQ archive"; + +// What a fresh SampleArchive lists, before any rename +const std::set sample_entries = {"enUS bytes", "enUS cats.txt", "enUS dogs.txt"}; + +// What ManyLocalesArchive lists: cats.txt under the default locale and three others +const std::set all_variants = {"enUS cats.txt", "041D cats.txt", "deDE cats.txt", + "esES cats.txt"}; + +// The complete listfile for NoListfileArchive, with the names the cases rename to +const std::string complete_listfile = "cats.txt\ndogs.txt\ncapybaras.txt\nrenamed.txt\n"; + +// entries with one line swapped for another +std::set Renamed(std::set entries, const std::string &from, + const std::string &to) { + entries.erase(from); + entries.insert(to); + return entries; +} + +// The line rename prints to stderr before trying; a locale is named only when +// one was given, and the new name is shown as it will be stored +std::string Renaming(const std::string &from, const std::string &to, + const std::string &locale = "") { + return "[~] Renaming file" + (locale.empty() ? "" : " for locale " + locale) + ": " + from + + " -> " + to; +} + +// The failure when the entry is not stored under the locale looked for, which +// is named even when it is the default one +std::string MissingUnderLocale(const std::string &locale, const std::string &name) { + return "[!] Failed: File doesn't exist for locale " + locale + ": " + name; +} + +// The failure when StormLib refuses the rename itself +std::string CannotRename(const std::string &locale, const std::string &from, + const std::string &to) { + return "[!] Failed: File cannot be renamed for locale " + locale + ": " + from + " -> " + to; +} + +} // namespace + +TEST_CASE("rename: prints its help with --help", "[rename]") { + auto result = Run(mpqcli_binary, {"rename", "--help"}); + + REQUIRE(result.returncode == 0); + REQUIRE_THAT(result.stdout_output, + ContainsSubstring("rename [OPTIONS] archive old-file new-file")); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("--locale")); +} + +TEST_CASE("rename: archive does not exist", "[rename]") { + auto result = Run(mpqcli_binary, {"rename", "no-such-archive.mpq", "cats.txt", "felines.txt"}); + + // 105 is CLI11's ValidationError, from the CLI::ExistingFile check + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("File does not exist: no-such-archive.mpq")); +} + +TEST_CASE("rename: fails when the archive is not an MPQ", "[rename]") { + SampleFiles files; + + auto result = Run(mpqcli_binary, {"rename", (files.files_dir / "cats.txt").string(), "cats.txt", + "felines.txt"}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output).size() == 1); + REQUIRE_THAT(result.stderr_output, ContainsSubstring(open_failure)); +} + +TEST_CASE("rename: rejects an unknown locale", "[rename]") { + SampleArchive sample; + + // Like create, add and remove, rename validates the locale at parse time + auto result = Run(mpqcli_binary, {"rename", sample.archive.string(), "cats.txt", "felines.txt", + "--locale", "nosuchlocale"}); + + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("Locale must be nothing, or one of")); + REQUIRE(ArchiveEntries(sample.archive) == sample_entries); +} + +TEST_CASE("rename: rejects extra arguments", "[rename]") { + SampleArchive sample; + + auto result = + Run(mpqcli_binary, {"rename", sample.archive.string(), "cats.txt", "felines.txt", "extra"}); + + // 109 is CLI11's ExtrasError + REQUIRE(result.returncode == 109); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("The following argument was not expected: extra")); + REQUIRE(ArchiveEntries(sample.archive) == sample_entries); +} + +TEST_CASE("rename: fails when the archive has no such entry", "[rename]") { + SampleArchive sample; + + auto result = Run(mpqcli_binary, + {"rename", sample.archive.string(), "does-not-exist.txt", "renamed.txt"}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + // The attempt is announced before it fails + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Renaming("does-not-exist.txt", "renamed.txt"), + MissingUnderLocale("enUS", "does-not-exist.txt")}); + REQUIRE(ArchiveEntries(sample.archive) == sample_entries); +} + +TEST_CASE("rename: a single file", "[rename]") { + SampleArchive sample; + const auto size_before = fs::file_size(sample.archive); + + auto result = + Run(mpqcli_binary, {"rename", sample.archive.string(), "cats.txt", "felines.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Renaming("cats.txt", "felines.txt")}); + // The internal (listfile) follows, so the new name lists without help + REQUIRE(ArchiveEntries(sample.archive) == + Renamed(sample_entries, "enUS cats.txt", "enUS felines.txt")); + REQUIRE(ArchiveEntries(sample.archive, true).count("enUS (listfile)") == 1); + REQUIRE(ReadEntry(sample.archive, "felines.txt") == + std::set{"This is a file about cats."}); + REQUIRE(Run(mpqcli_binary, {"read", sample.archive.string(), "cats.txt"}).returncode == 1); + // The (listfile) sits at the archive's end and is rewritten in place, so + // the archive moves by the name-length difference alone, with no gap left + // for compact to reclaim: the no-fragmentation claim the docs page makes + REQUIRE(fs::file_size(sample.archive) == + size_before + std::string("felines.txt").size() - std::string("cats.txt").size()); +} + +TEST_CASE("rename: matches the old name without regard to case", "[rename]") { + SampleArchive sample; + + auto result = + Run(mpqcli_binary, {"rename", sample.archive.string(), "DOGS.TXT", "canines.txt"}); + + // MPQ name hashes are computed over the upper-cased name; the message + // echoes the spelling given + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Renaming("DOGS.TXT", "canines.txt")}); + REQUIRE(ArchiveEntries(sample.archive) == + Renamed(sample_entries, "enUS dogs.txt", "enUS canines.txt")); +} + +TEST_CASE("rename: refuses the same name or a change of case alone", "[rename]") { + const auto new_name = GENERATE(as{}, "cats.txt", "CATS.TXT"); + CAPTURE(new_name); + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"rename", sample.archive.string(), "cats.txt", new_name}); + + // Both names hash to the same entry, so StormLib sees a name already taken + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Renaming("cats.txt", new_name), + CannotRename("enUS", "cats.txt", new_name)}); + REQUIRE(ArchiveEntries(sample.archive) == sample_entries); +} + +TEST_CASE("rename: refuses a name another entry holds under the same locale", "[rename]") { + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"rename", sample.archive.string(), "cats.txt", "dogs.txt"}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Renaming("cats.txt", "dogs.txt"), + CannotRename("enUS", "cats.txt", "dogs.txt")}); + // Neither entry is touched + REQUIRE(ArchiveEntries(sample.archive) == sample_entries); + REQUIRE(ReadEntry(sample.archive, "dogs.txt") == + std::set{"This is a file about dogs."}); +} + +TEST_CASE("rename: allows a name another entry holds under a different locale", "[rename]") { + NoListfileArchive archive; + const auto listfile = archive.dir.Path() / "listfile.txt"; + WriteFile(listfile, complete_listfile); + + // cats.txt is stored under deDE alone, so the default locale is free to take it + auto result = + Run(mpqcli_binary, {"rename", archive.archive.string(), "capybaras.txt", "cats.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Renaming("capybaras.txt", "cats.txt")}); + REQUIRE(ArchiveEntries(archive.archive, false, listfile) == + std::set{"enUS cats.txt", "deDE cats.txt", "041D dogs.txt"}); + // Each variant keeps its own content + REQUIRE(ReadEntry(archive.archive, "cats.txt") == + std::set{"This is a file about capybaras."}); + REQUIRE(ReadEntry(archive.archive, "cats.txt", "deDE") == + std::set{"Dies ist eine Datei ueber Katzen."}); +} + +TEST_CASE("rename: refuses the special entries", "[rename]") { + const auto name = GENERATE(as{}, "(listfile)", "(attributes)", "(signature)"); + CAPTURE(name); + std::vector args = AttributedArchiveArgs(); + args.push_back("-s"); + SampleArchive sample(args); + const auto before = ArchiveEntries(sample.archive, true); + REQUIRE(before.count("enUS " + name) == 1); + + auto result = Run(mpqcli_binary, {"rename", sample.archive.string(), name, "other.txt"}); + + // StormLib keeps its own entries: the name is found but the rename is refused + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Renaming(name, "other.txt"), + CannotRename("enUS", name, "other.txt")}); + REQUIRE(ArchiveEntries(sample.archive, true) == before); +} + +TEST_CASE("rename: stores a new name given with forward slashes using backslashes", "[rename]") { + SampleArchive sample; + + auto result = + Run(mpqcli_binary, {"rename", sample.archive.string(), "cats.txt", "deep/renamed.txt"}); + + // As add and create store paths, and as the message already shows it + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Renaming("cats.txt", "deep\\renamed.txt")}); + REQUIRE(ArchiveEntries(sample.archive) == + Renamed(sample_entries, "enUS cats.txt", "enUS deep\\renamed.txt")); + REQUIRE(ReadEntry(sample.archive, "deep\\renamed.txt") == + std::set{"This is a file about cats."}); +} + +TEST_CASE("rename: a nested entry by its backslash path", "[rename]") { + TempDir dir; + const auto source_dir = dir.Path() / "src"; + WriteFile(source_dir / "root.txt", "root file"); + WriteFile(source_dir / "sub" / "nested.txt", "nested file"); + const auto archive = dir.Path() / "nested.mpq"; + REQUIRE(Run(mpqcli_binary, {"create", archive.string(), source_dir.string()}).returncode == 0); + + auto result = Run(mpqcli_binary, {"rename", archive.string(), "sub\\nested.txt", "top.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Renaming("sub\\nested.txt", "top.txt")}); + REQUIRE(ArchiveEntries(archive) == std::set{"enUS root.txt", "enUS top.txt"}); + REQUIRE(ReadEntry(archive, "top.txt") == std::set{"nested file"}); +} + +TEST_CASE("rename: the default-locale variant when no locale is given", "[rename]") { + ManyLocalesArchive locales; + + auto result = + Run(mpqcli_binary, {"rename", locales.archive.string(), "cats.txt", "renamed_enus.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Renaming("cats.txt", "renamed_enus.txt")}); + // The other variants keep their name + REQUIRE(ArchiveEntries(locales.archive) == + Renamed(all_variants, "enUS cats.txt", "enUS renamed_enus.txt")); + REQUIRE(ReadEntry(locales.archive, "renamed_enus.txt") == + std::set{CatsContent("")}); + REQUIRE(ReadEntry(locales.archive, "cats.txt", "deDE") == + std::set{CatsContent("deDE")}); +} + +TEST_CASE("rename: the variant under the given locale", "[rename]") { + // The locale as given, how the message names it, the listing's label for + // it, and the variant whose content must now read under the new name: a + // language code, the hexadecimal LCID the locale table does not name, the + // hexadecimal LCID of one it does (0407 is deDE), and the "default" alias + // the validator accepts, which names no locale in the message + const auto [locale, named, label, variant] = + GENERATE(table({ + {"esES", "esES", "esES", "esES"}, + {"041D", "041D", "041D", "041D"}, + {"0407", "deDE", "deDE", "deDE"}, + {"default", "", "enUS", ""}, + })); + const auto flag = GENERATE(as{}, "-e", "--locale"); + CAPTURE(locale, flag); + ManyLocalesArchive locales; + + auto result = Run(mpqcli_binary, {"rename", locales.archive.string(), "cats.txt", "renamed.txt", + flag, locale}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Renaming("cats.txt", "renamed.txt", named)}); + REQUIRE(ArchiveEntries(locales.archive) == + Renamed(all_variants, label + " cats.txt", label + " renamed.txt")); + REQUIRE(ReadEntry(locales.archive, "renamed.txt", locale) == + std::set{CatsContent(variant)}); +} + +TEST_CASE("rename: each variant in turn without touching the others", "[rename]") { + ManyLocalesArchive locales; + auto expected = all_variants; + + for (const auto &[locale, label] : std::vector>{ + {"", "enUS"}, {"esES", "esES"}, {"041D", "041D"}, {"deDE", "deDE"}}) { + CAPTURE(locale); + const auto new_name = "renamed_" + label + ".txt"; + std::vector args = {"rename", locales.archive.string(), "cats.txt", new_name}; + if (!locale.empty()) { + args.push_back("--locale"); + args.push_back(locale); + } + + auto result = Run(mpqcli_binary, args); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Renaming("cats.txt", new_name, locale)}); + expected = Renamed(expected, label + " cats.txt", label + " " + new_name); + REQUIRE(ArchiveEntries(locales.archive) == expected); + } +} + +TEST_CASE("rename: fails for a locale the entry is not stored under", "[rename]") { + SECTION("an entry stored under one locale") { + NoListfileArchive archive; + const auto before = ArchiveEntries(archive.archive); + + for (const auto &entry : NoListfileArchive::Entries()) { + CAPTURE(entry.name); + + auto result = Run(mpqcli_binary, {"rename", archive.archive.string(), entry.name, + "renamed.txt", "--locale", "ptPT"}); + + // Whether the entry is under the default locale, a named one or a bare + // LCID, a locale it lacks is a miss with no fallback + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Renaming(entry.name, "renamed.txt", "ptPT"), + MissingUnderLocale("ptPT", entry.name)}); + } + REQUIRE(ArchiveEntries(archive.archive) == before); + } + + SECTION("an entry stored under several locales") { + ManyLocalesArchive locales; + + auto result = Run(mpqcli_binary, {"rename", locales.archive.string(), "cats.txt", + "renamed.txt", "--locale", "ptPT"}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Renaming("cats.txt", "renamed.txt", "ptPT"), + MissingUnderLocale("ptPT", "cats.txt")}); + REQUIRE(ArchiveEntries(locales.archive) == all_variants); + } +} + +TEST_CASE("rename: fails without a locale for an entry stored only under another", "[rename]") { + NoListfileArchive archive; + const auto listfile = archive.dir.Path() / "listfile.txt"; + WriteFile(listfile, complete_listfile); + const std::set before = {"enUS capybaras.txt", "deDE cats.txt", + "041D dogs.txt"}; + REQUIRE(ArchiveEntries(archive.archive, false, listfile) == before); + + // dogs.txt is stored under 041D alone: the default locale has no such entry + auto without = + Run(mpqcli_binary, {"rename", archive.archive.string(), "dogs.txt", "renamed.txt"}); + + REQUIRE(without.returncode == 1); + REQUIRE(without.stdout_output.empty()); + REQUIRE(LinesToVector(without.stderr_output) == + std::vector{Renaming("dogs.txt", "renamed.txt"), + MissingUnderLocale("enUS", "dogs.txt")}); + REQUIRE(ArchiveEntries(archive.archive, false, listfile) == before); + + auto with = Run(mpqcli_binary, {"rename", archive.archive.string(), "dogs.txt", "renamed.txt", + "--locale", "041D"}); + + REQUIRE(with.returncode == 0); + REQUIRE(with.stdout_output.empty()); + REQUIRE(LinesToVector(with.stderr_output) == + std::vector{Renaming("dogs.txt", "renamed.txt", "041D")}); + REQUIRE(ArchiveEntries(archive.archive, false, listfile) == + Renamed(before, "041D dogs.txt", "041D renamed.txt")); + REQUIRE(ReadEntry(archive.archive, "renamed.txt", "041D") == + std::set{"Detta aer en fil om hundar."}); +} + +TEST_CASE("rename: finds an entry by name in an archive without a listfile", "[rename]") { + NoListfileArchive archive; + const auto listfile = archive.dir.Path() / "listfile.txt"; + WriteFile(listfile, complete_listfile); + const auto size_before = fs::file_size(archive.archive); + + // The name hashes to its entry whether or not the archive can list it + auto result = + Run(mpqcli_binary, {"rename", archive.archive.string(), "capybaras.txt", "renamed.txt"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{Renaming("capybaras.txt", "renamed.txt")}); + REQUIRE(ArchiveEntries(archive.archive, false, listfile) == + std::set{"enUS renamed.txt", "deDE cats.txt", "041D dogs.txt"}); + REQUIRE(ReadEntry(archive.archive, "renamed.txt") == + std::set{"This is a file about capybaras."}); + REQUIRE(Run(mpqcli_binary, {"read", archive.archive.string(), "capybaras.txt"}).returncode == + 1); + // With no (listfile) to rewrite, a rename is a hash-table edit alone and + // the archive keeps its size + REQUIRE(fs::file_size(archive.archive) == size_before); +} diff --git a/test/functional/test_verify.cpp b/test/functional/test_verify.cpp new file mode 100644 index 0000000..b91f782 --- /dev/null +++ b/test/functional/test_verify.cpp @@ -0,0 +1,166 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "archive_helper.h" +#include "integration_data.h" +#include "sample_archive.h" +#include "subprocess_helper.h" +#include "temp_dir.h" +#include "test_environment.h" +#include "write_file.h" + +namespace fs = std::filesystem; + +using namespace mpqcli::testing; +using Catch::Matchers::ContainsSubstring; + +namespace { + +// The binary every case spawns: a reference into the singleton, so it is the +// baked path itself rather than a copy that could drift from it +const fs::path &mpqcli_binary = TestEnvironment::Instance().binary_path; + +const std::vector verify_success = {"[*] Verify success"}; + +// Create arguments for a weakly signed archive whose files are compressed but +// not encrypted: 512 is MPQ_FILE_COMPRESS +const std::vector weak_signature_args = { + "-s", "--file-flags1", "4294967295", "--file-flags2", "4294967295", "--file-flags3", + "0", "--attr-flags", "15", "--flags", "512", "--compression", + "2", "--compression-next", "4294967295"}; + +// Long enough that its compressed data runs well past the sector offset table +// at its start, whose corruption fails the read rather than the signature +const std::string tamper_text = "This is a longer file about cats, with enough content to give us " + "several bytes of compressed data to safely flip a bit in.\n"; + +// Inverts the byte at offset in place +void FlipByte(const fs::path &file, std::streamoff offset) { + std::fstream stream(file, std::ios::in | std::ios::out | std::ios::binary); + REQUIRE(stream.is_open()); + stream.seekg(offset); + const int byte = stream.get(); + REQUIRE(byte != std::char_traits::eof()); + stream.seekp(offset); + stream.put(static_cast(byte ^ 0xFF)); + REQUIRE(stream.good()); +} + +} // namespace + +TEST_CASE("verify: prints its help with --help", "[verify]") { + auto result = Run(mpqcli_binary, {"verify", "--help"}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stderr_output.empty()); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("verify [OPTIONS] archive")); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("-x,")); + REQUIRE_THAT(result.stdout_output, ContainsSubstring("--print")); +} + +TEST_CASE("verify: archive does not exist", "[verify]") { + auto result = Run(mpqcli_binary, {"verify", "no-such-archive.mpq"}); + + // 105 is CLI11's ValidationError, from the CLI::ExistingFile check + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("archive: File does not exist: no-such-archive.mpq")); +} + +TEST_CASE("verify: rejects -p", "[verify]") { + SampleArchive sample; + + auto result = Run(mpqcli_binary, {"verify", "-p", sample.archive.string()}); + + // 109 is CLI11's ExtrasError: verify has no option spelled -p + REQUIRE(result.returncode == 109); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, ContainsSubstring("not expected: -p")); +} + +TEST_CASE("verify: fails when the archive has no signature", "[verify]") { + SampleArchive sample(AttributedArchiveArgs()); + + auto result = Run(mpqcli_binary, {"verify", sample.archive.string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{"[!] Verify failed: archive has no signature"}); +} + +TEST_CASE("verify: fails when the signature no longer matches the content", "[verify]") { + TempDir dir; + const auto files_dir = dir.Path() / "tamper_files"; + WriteFile(files_dir / "cats.txt", tamper_text); + const auto archive = dir.Path() / "mpq_with_tampered_signature.mpq"; + auto create = Run(mpqcli_binary, {"create", "-s", archive.string(), files_dir.string()}); + REQUIRE(create.returncode == 0); + // The midpoint of the entry's compressed data, clear of its sector offset table + const auto offset = EntryNumber(archive, "cats.txt", "byte-offset"); + const auto compressed_size = EntryNumber(archive, "cats.txt", "compressed-size"); + FlipByte(archive, static_cast(offset + compressed_size / 2)); + + auto result = Run(mpqcli_binary, {"verify", archive.string()}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == + std::vector{"[!] Verify failed: signature is present but invalid"}); +} + +TEST_CASE("verify: succeeds on a valid weak signature", "[verify]") { + SampleArchive sample(weak_signature_args); + + auto result = Run(mpqcli_binary, {"verify", sample.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == verify_success); +} + +TEST_CASE("verify: --print writes a weak signature's bytes to stdout", "[verify]") { + const auto archive = IntegrationArchive("v1_weak_signature_patch.mpq"); + + auto result = Run(mpqcli_binary, {"verify", "--print", archive.string()}); + + REQUIRE(result.returncode == 0); + // The (signature) entry, raw rather than as text + MD5 md5; + REQUIRE(md5(result.stdout_output) == "e434b57cdc6b730c17e18f2bb2fa8fd9"); + REQUIRE(LinesToVector(result.stderr_output) == verify_success); +} + +TEST_CASE("verify: --print writes a strong signature's bytes to stdout", "[verify]") { + const auto archive = IntegrationArchive("v2_strong_signature_patch.mpq"); + + auto result = Run(mpqcli_binary, {"verify", "--print", archive.string()}); + + REQUIRE(result.returncode == 0); + // The block that follows the archive in the file, raw rather than as text + MD5 md5; + REQUIRE(md5(result.stdout_output) == "931fc40bb6bff48b89d39b5acb169bbd"); + REQUIRE(LinesToVector(result.stderr_output) == verify_success); +} + +TEST_CASE("verify: -x prints the same signature as --print", "[verify]") { + SampleArchive sample({"-s"}); + + auto short_form = Run(mpqcli_binary, {"verify", "-x", sample.archive.string()}); + auto long_form = Run(mpqcli_binary, {"verify", "--print", sample.archive.string()}); + + REQUIRE(short_form.returncode == 0); + REQUIRE_FALSE(short_form.stdout_output.empty()); + REQUIRE(LinesToVector(short_form.stderr_output) == verify_success); + REQUIRE(long_form.returncode == 0); + REQUIRE(LinesToVector(long_form.stderr_output) == verify_success); + REQUIRE(short_form.stdout_output == long_form.stdout_output); +} From 488d566ac85f0fd3911bcf88b1525a1132fbdfdb Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 20:27:41 +1300 Subject: [PATCH 03/37] Added the Catch2 unit layer --- test/CMakeLists.txt | 20 ++ test/fixtures/created_archive.h | 52 +++++ test/unit/commands/test_add.cpp | 50 +++++ test/unit/commands/test_compact.cpp | 46 +++++ test/unit/commands/test_create.cpp | 51 +++++ test/unit/commands/test_extract.cpp | 52 +++++ test/unit/commands/test_info.cpp | 31 +++ test/unit/commands/test_list.cpp | 31 +++ test/unit/commands/test_read.cpp | 28 +++ test/unit/commands/test_remove.cpp | 33 ++++ test/unit/commands/test_rename.cpp | 25 +++ test/unit/commands/test_verify.cpp | 41 ++++ test/unit/gamerules/test_profiles.cpp | 117 ++++++++++++ test/unit/gamerules/test_rules.cpp | 253 +++++++++++++++++++++++++ test/unit/mpq/test_extract.cpp | 47 +++++ test/unit/mpq/test_flags.cpp | 69 +++++++ test/unit/test_errors.cpp | 124 ++++++++++++ test/unit/util/test_format.cpp | 95 ++++++++++ test/unit/util/test_hash.cpp | 110 +++++++++++ test/unit/util/test_locales.cpp | 116 ++++++++++++ test/unit/util/test_path.cpp | 263 ++++++++++++++++++++++++++ test/unit/util/test_string.cpp | 24 +++ 22 files changed, 1678 insertions(+) create mode 100644 test/fixtures/created_archive.h create mode 100644 test/unit/commands/test_add.cpp create mode 100644 test/unit/commands/test_compact.cpp create mode 100644 test/unit/commands/test_create.cpp create mode 100644 test/unit/commands/test_extract.cpp create mode 100644 test/unit/commands/test_info.cpp create mode 100644 test/unit/commands/test_list.cpp create mode 100644 test/unit/commands/test_read.cpp create mode 100644 test/unit/commands/test_remove.cpp create mode 100644 test/unit/commands/test_rename.cpp create mode 100644 test/unit/commands/test_verify.cpp create mode 100644 test/unit/gamerules/test_profiles.cpp create mode 100644 test/unit/gamerules/test_rules.cpp create mode 100644 test/unit/mpq/test_extract.cpp create mode 100644 test/unit/mpq/test_flags.cpp create mode 100644 test/unit/test_errors.cpp create mode 100644 test/unit/util/test_format.cpp create mode 100644 test/unit/util/test_hash.cpp create mode 100644 test/unit/util/test_locales.cpp create mode 100644 test/unit/util/test_path.cpp create mode 100644 test/unit/util/test_string.cpp diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index b3783af..0843636 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -10,7 +10,27 @@ # The core's PUBLIC include directory gives them every header in src/, private # helpers included. add_executable(mpqcli_unit_tests + unit/commands/test_add.cpp + unit/commands/test_compact.cpp + unit/commands/test_create.cpp + unit/commands/test_extract.cpp + unit/commands/test_info.cpp + unit/commands/test_list.cpp + unit/commands/test_read.cpp + unit/commands/test_remove.cpp + unit/commands/test_rename.cpp + unit/commands/test_verify.cpp + unit/gamerules/test_profiles.cpp + unit/gamerules/test_rules.cpp + unit/mpq/test_extract.cpp + unit/mpq/test_flags.cpp + unit/test_errors.cpp unit/util/test_capacity.cpp + unit/util/test_format.cpp + unit/util/test_hash.cpp + unit/util/test_locales.cpp + unit/util/test_path.cpp + unit/util/test_string.cpp ) target_include_directories(mpqcli_unit_tests PRIVATE diff --git a/test/fixtures/created_archive.h b/test/fixtures/created_archive.h new file mode 100644 index 0000000..a5c0577 --- /dev/null +++ b/test/fixtures/created_archive.h @@ -0,0 +1,52 @@ +#ifndef CREATED_ARCHIVE_H +#define CREATED_ARCHIVE_H + +#include +#include +#include +#include +#include + +#include + +#include "commands/create.h" +#include "mpq/archive.h" +#include "mpq/query.h" +#include "sample_files.h" +#include "util/locales.h" + +namespace mpqcli::testing { + +/// Reports whether the archive holds a file by that name under the default locale +/// +/// Opens the archive read-only for the one query and closes it again, so the test can +/// go on to change or remove the archive, which Windows refuses while a handle is open. +inline bool ArchiveHasFile(const std::filesystem::path &archive, const std::string &name) { + const Archive opened = Archive::Open(archive, MPQ_OPEN_READ_ONLY); + return FileExistsInArchiveForLocale(opened.Handle(), name, default_locale); +} + +/// An archive built over SampleFiles by calling Create directly, one per test +/// +/// The unit layer's counterpart to SampleArchive, for tests that link the core: the +/// archive holds each sample file at its root under the generic profile. A failed +/// create throws, since that is a broken fixture rather than a result. +struct CreatedArchive { + SampleFiles files; + std::filesystem::path archive; + + CreatedArchive() : archive(files.dir.Path() / "files.mpq") { + CreateOptions options; + options.archive = archive; + options.files = {files.files_dir}; + std::ostringstream err; + const std::atomic cancelled{false}; + if (!Create(options, err, cancelled)) { + throw std::runtime_error("CreatedArchive: create failed: " + err.str()); + } + } +}; + +} // namespace mpqcli::testing + +#endif // CREATED_ARCHIVE_H diff --git a/test/unit/commands/test_add.cpp b/test/unit/commands/test_add.cpp new file mode 100644 index 0000000..667a686 --- /dev/null +++ b/test/unit/commands/test_add.cpp @@ -0,0 +1,50 @@ +#include +#include +#include +#include +#include + +#include "commands/add.h" +#include "created_archive.h" +#include "errors.h" +#include "read_file.h" +#include "sample_files.h" +#include "write_file.h" + +namespace fs = std::filesystem; + +using mpqcli::Add; +using mpqcli::AddOptions; +using mpqcli::testing::ArchiveHasFile; +using mpqcli::testing::CreatedArchive; +using mpqcli::testing::ReadFile; +using mpqcli::testing::SampleFiles; +using mpqcli::testing::WriteFile; + +TEST_CASE("Add", "[add]") { + CreatedArchive created; + const fs::path new_file = created.files.dir.Path() / "more" / "birds.txt"; + WriteFile(new_file, "This is a file about birds.\n"); + AddOptions options; + options.archive = created.archive; + options.files = {new_file}; + std::ostringstream err; + + SECTION("adds a file to the archive") { + const std::atomic cancelled{false}; + REQUIRE(Add(options, err, cancelled)); + REQUIRE(ArchiveHasFile(created.archive, "birds.txt")); + } + + SECTION("throws Interrupted when already cancelled, leaving the archive as it was") { + const std::string before = ReadFile(created.archive); + const std::atomic cancelled{true}; + REQUIRE_THROWS_AS(Add(options, err, cancelled), mpqcli::Interrupted); + REQUIRE(ReadFile(created.archive) == before); + REQUIRE_FALSE(ArchiveHasFile(created.archive, "birds.txt")); + for (const auto &entry : SampleFiles::Contents()) { + CAPTURE(entry.first); + REQUIRE(ArchiveHasFile(created.archive, entry.first)); + } + } +} diff --git a/test/unit/commands/test_compact.cpp b/test/unit/commands/test_compact.cpp new file mode 100644 index 0000000..0f15e00 --- /dev/null +++ b/test/unit/commands/test_compact.cpp @@ -0,0 +1,46 @@ +#include +#include +#include + +#include + +#include "commands/compact.h" +#include "created_archive.h" +#include "mpq/archive.h" +#include "sample_files.h" + +namespace fs = std::filesystem; + +using mpqcli::Archive; +using mpqcli::Compact; +using mpqcli::CompactOptions; +using mpqcli::testing::ArchiveHasFile; +using mpqcli::testing::CreatedArchive; +using mpqcli::testing::SampleFiles; + +TEST_CASE("Compact", "[compact]") { + CreatedArchive created; + CompactOptions options; + options.archive = created.archive; + std::ostringstream err; + + SECTION("keeps every file") { + Compact(options, err); + for (const auto &entry : SampleFiles::Contents()) { + CAPTURE(entry.first); + REQUIRE(ArchiveHasFile(created.archive, entry.first)); + } + } + + SECTION("reclaims the space a removed file left behind") { + { + Archive archive = Archive::Open(created.archive, 0); + REQUIRE(SFileRemoveFile(archive.Handle(), "cats.txt", 0)); + archive.Close(); + } + const auto size_before = fs::file_size(created.archive); + Compact(options, err); + REQUIRE(fs::file_size(created.archive) < size_before); + REQUIRE(ArchiveHasFile(created.archive, "dogs.txt")); + } +} diff --git a/test/unit/commands/test_create.cpp b/test/unit/commands/test_create.cpp new file mode 100644 index 0000000..caa39b6 --- /dev/null +++ b/test/unit/commands/test_create.cpp @@ -0,0 +1,51 @@ +#include +#include +#include +#include +#include + +#include "commands/create.h" +#include "created_archive.h" +#include "errors.h" +#include "sample_files.h" + +namespace fs = std::filesystem; + +using mpqcli::Create; +using mpqcli::CreateOptions; +using mpqcli::testing::ArchiveHasFile; +using mpqcli::testing::SampleFiles; + +TEST_CASE("Create", "[create]") { + SampleFiles files; + CreateOptions options; + options.archive = files.dir.Path() / "out.mpq"; + options.files = {files.files_dir}; + fs::path partial = options.archive; + partial += ".partial"; + std::ostringstream err; + + SECTION("writes an archive holding every input file") { + const std::atomic cancelled{false}; + REQUIRE(Create(options, err, cancelled)); + REQUIRE(fs::exists(options.archive)); + REQUIRE_FALSE(fs::exists(partial)); + for (const auto &entry : SampleFiles::Contents()) { + CAPTURE(entry.first); + REQUIRE(ArchiveHasFile(options.archive, entry.first)); + } + } + + SECTION("throws Interrupted when already cancelled, leaving nothing behind") { + const std::atomic cancelled{true}; + REQUIRE_THROWS_AS(Create(options, err, cancelled), mpqcli::Interrupted); + REQUIRE_FALSE(fs::exists(options.archive)); + REQUIRE_FALSE(fs::exists(partial)); + + // Nothing at all is left beside the inputs, whatever name it might have had + const std::vector entries(fs::directory_iterator(files.dir.Path()), + fs::directory_iterator{}); + const std::vector expected = {files.files_dir}; + REQUIRE(entries == expected); + } +} diff --git a/test/unit/commands/test_extract.cpp b/test/unit/commands/test_extract.cpp new file mode 100644 index 0000000..6dd4742 --- /dev/null +++ b/test/unit/commands/test_extract.cpp @@ -0,0 +1,52 @@ +#include +#include +#include +#include +#include +#include + +#include "commands/extract.h" +#include "created_archive.h" +#include "errors.h" +#include "read_file.h" +#include "sample_files.h" + +namespace fs = std::filesystem; + +using mpqcli::Extract; +using mpqcli::ExtractOptions; +using mpqcli::testing::CreatedArchive; +using mpqcli::testing::ReadFile; +using mpqcli::testing::SampleFiles; + +TEST_CASE("Extract", "[extract]") { + CreatedArchive created; + const fs::path output = created.files.dir.Path() / "out"; + ExtractOptions options; + options.archive = created.archive; + options.output = output; + std::ostringstream err; + + SECTION("writes every file to the output directory byte for byte") { + const std::atomic cancelled{false}; + REQUIRE(Extract(options, err, cancelled)); + for (const auto &[name, content] : SampleFiles::Contents()) { + CAPTURE(name); + REQUIRE(ReadFile(output / name) == content); + } + } + + SECTION("throws Interrupted when cancelled while listing, creating no output directory") { + const std::atomic cancelled{true}; + REQUIRE_THROWS_AS(Extract(options, err, cancelled), mpqcli::Interrupted); + REQUIRE_FALSE(fs::exists(output)); + } + + SECTION("throws Interrupted when cancelled before the first named file, extracting nothing") { + options.files = std::vector{"cats.txt", "dogs.txt"}; + const std::atomic cancelled{true}; + REQUIRE_THROWS_AS(Extract(options, err, cancelled), mpqcli::Interrupted); + const bool nothing_extracted = !fs::exists(output) || fs::is_empty(output); + REQUIRE(nothing_extracted); + } +} diff --git a/test/unit/commands/test_info.cpp b/test/unit/commands/test_info.cpp new file mode 100644 index 0000000..c4b0ddb --- /dev/null +++ b/test/unit/commands/test_info.cpp @@ -0,0 +1,31 @@ +#include +#include +#include +#include + +#include "commands/info.h" +#include "created_archive.h" + +using Catch::Matchers::ContainsSubstring; +using mpqcli::Info; +using mpqcli::InfoOptions; +using mpqcli::testing::CreatedArchive; + +TEST_CASE("Info", "[info]") { + CreatedArchive created; + InfoOptions options; + options.archive = created.archive; + std::ostringstream out; + + SECTION("prints only the value of a named property") { + options.property = "format-version"; + Info(options, out); + REQUIRE(out.str() == "1\n"); + } + + SECTION("prints every property by name when none is named") { + Info(options, out); + REQUIRE_THAT(out.str(), ContainsSubstring("Format version: 1\n")); + REQUIRE_THAT(out.str(), ContainsSubstring("Signature type: None\n")); + } +} diff --git a/test/unit/commands/test_list.cpp b/test/unit/commands/test_list.cpp new file mode 100644 index 0000000..1e9a25e --- /dev/null +++ b/test/unit/commands/test_list.cpp @@ -0,0 +1,31 @@ +#include +#include +#include +#include + +#include "commands/list.h" +#include "created_archive.h" + +using mpqcli::List; +using mpqcli::ListOptions; +using mpqcli::testing::CreatedArchive; + +TEST_CASE("List", "[list]") { + CreatedArchive created; + std::ostringstream out; + std::ostringstream err; + + SECTION("prints each file's name on a line of its own") { + ListOptions options; + options.archive = created.archive; + List(options, out, err); + + std::set lines; + std::istringstream in(out.str()); + for (std::string line; std::getline(in, line);) { + lines.insert(line); + } + const std::set expected = {"bytes", "cats.txt", "dogs.txt"}; + REQUIRE(lines == expected); + } +} diff --git a/test/unit/commands/test_read.cpp b/test/unit/commands/test_read.cpp new file mode 100644 index 0000000..bf0ebdb --- /dev/null +++ b/test/unit/commands/test_read.cpp @@ -0,0 +1,28 @@ +#include +#include + +#include "commands/read.h" +#include "created_archive.h" +#include "sample_files.h" + +using mpqcli::Read; +using mpqcli::ReadOptions; +using mpqcli::testing::CreatedArchive; +using mpqcli::testing::SampleFiles; + +TEST_CASE("Read", "[read]") { + CreatedArchive created; + + SECTION("writes each file's bytes to out unchanged") { + for (const auto &[name, content] : SampleFiles::Contents()) { + CAPTURE(name); + ReadOptions options; + options.archive = created.archive; + options.file = name; + std::ostringstream out; + std::ostringstream err; + REQUIRE(Read(options, out, err)); + REQUIRE(out.str() == content); + } + } +} diff --git a/test/unit/commands/test_remove.cpp b/test/unit/commands/test_remove.cpp new file mode 100644 index 0000000..197ebc6 --- /dev/null +++ b/test/unit/commands/test_remove.cpp @@ -0,0 +1,33 @@ +#include +#include +#include + +#include "commands/remove.h" +#include "created_archive.h" +#include "errors.h" + +using mpqcli::Remove; +using mpqcli::RemoveOptions; +using mpqcli::testing::ArchiveHasFile; +using mpqcli::testing::CreatedArchive; + +TEST_CASE("Remove", "[remove]") { + CreatedArchive created; + RemoveOptions options; + options.archive = created.archive; + options.files = {"cats.txt"}; + std::ostringstream err; + + SECTION("removes the named file and keeps the rest") { + const std::atomic cancelled{false}; + REQUIRE(Remove(options, err, cancelled)); + REQUIRE_FALSE(ArchiveHasFile(created.archive, "cats.txt")); + REQUIRE(ArchiveHasFile(created.archive, "dogs.txt")); + } + + SECTION("throws Interrupted when already cancelled, removing nothing") { + const std::atomic cancelled{true}; + REQUIRE_THROWS_AS(Remove(options, err, cancelled), mpqcli::Interrupted); + REQUIRE(ArchiveHasFile(created.archive, "cats.txt")); + } +} diff --git a/test/unit/commands/test_rename.cpp b/test/unit/commands/test_rename.cpp new file mode 100644 index 0000000..b009785 --- /dev/null +++ b/test/unit/commands/test_rename.cpp @@ -0,0 +1,25 @@ +#include +#include + +#include "commands/rename.h" +#include "created_archive.h" + +using mpqcli::Rename; +using mpqcli::RenameOptions; +using mpqcli::testing::ArchiveHasFile; +using mpqcli::testing::CreatedArchive; + +TEST_CASE("Rename", "[rename]") { + CreatedArchive created; + std::ostringstream err; + + SECTION("gives the file its new name") { + RenameOptions options; + options.archive = created.archive; + options.old_file = "cats.txt"; + options.new_file = "felines.txt"; + REQUIRE(Rename(options, err)); + REQUIRE(ArchiveHasFile(created.archive, "felines.txt")); + REQUIRE_FALSE(ArchiveHasFile(created.archive, "cats.txt")); + } +} diff --git a/test/unit/commands/test_verify.cpp b/test/unit/commands/test_verify.cpp new file mode 100644 index 0000000..4bff670 --- /dev/null +++ b/test/unit/commands/test_verify.cpp @@ -0,0 +1,41 @@ +#include +#include +#include + +#include "commands/create.h" +#include "commands/verify.h" +#include "created_archive.h" +#include "sample_files.h" + +using mpqcli::Create; +using mpqcli::CreateOptions; +using mpqcli::Verify; +using mpqcli::VerifyOptions; +using mpqcli::testing::CreatedArchive; +using mpqcli::testing::SampleFiles; + +TEST_CASE("Verify", "[verify]") { + std::ostringstream out; + std::ostringstream err; + + SECTION("accepts an archive created with a signature") { + SampleFiles files; + CreateOptions create; + create.archive = files.dir.Path() / "signed.mpq"; + create.files = {files.files_dir}; + create.sign = true; + const std::atomic cancelled{false}; + REQUIRE(Create(create, err, cancelled)); + + VerifyOptions options; + options.archive = create.archive; + REQUIRE(Verify(options, out, err)); + } + + SECTION("rejects an archive with no signature") { + CreatedArchive created; + VerifyOptions options; + options.archive = created.archive; + REQUIRE_FALSE(Verify(options, out, err)); + } +} diff --git a/test/unit/gamerules/test_profiles.cpp b/test/unit/gamerules/test_profiles.cpp new file mode 100644 index 0000000..bb6bdc1 --- /dev/null +++ b/test/unit/gamerules/test_profiles.cpp @@ -0,0 +1,117 @@ +#include +#include +#include +#include +#include +#include + +#include "gamerules/profiles.h" +#include "gamerules/rules.h" + +using mpqcli::GameProfile; +using mpqcli::GameRules; + +TEST_CASE("GameRules::StringToProfile", "[profiles]") { + SECTION("resolves each canonical name") { + const std::vector> names = { + {"generic", GameProfile::GENERIC}, + {"diablo1", GameProfile::DIABLO1}, + {"lordsofmagic", GameProfile::LORDSOFMAGIC}, + {"starcraft1", GameProfile::STARCRAFT1}, + {"warcraft2", GameProfile::WARCRAFT2}, + {"diablo2", GameProfile::DIABLO2}, + {"warcraft3", GameProfile::WARCRAFT3}, + {"warcraft3-map", GameProfile::WARCRAFT3_MAP}, + {"wow-vanilla", GameProfile::WOW_1X}, + {"wow-tbc", GameProfile::WOW_2X}, + {"wow-wotlk", GameProfile::WOW_3X}, + {"wow-cataclysm", GameProfile::WOW_4X}, + {"wow-mop", GameProfile::WOW_5X}, + {"starcraft2", GameProfile::STARCRAFT2}, + {"diablo3", GameProfile::DIABLO3}, + }; + for (const auto &[name, profile] : names) { + CAPTURE(name); + REQUIRE(GameRules::StringToProfile(name) == profile); + } + } + + SECTION("resolves each alias") { + const std::vector> aliases = { + {"diablo", GameProfile::DIABLO1}, + {"d1", GameProfile::DIABLO1}, + {"lomse", GameProfile::LORDSOFMAGIC}, + {"starcraft", GameProfile::STARCRAFT1}, + {"sc", GameProfile::STARCRAFT1}, + {"sc1", GameProfile::STARCRAFT1}, + {"wc2", GameProfile::WARCRAFT2}, + {"war2", GameProfile::WARCRAFT2}, + {"d2", GameProfile::DIABLO2}, + {"wc3", GameProfile::WARCRAFT3}, + {"war3", GameProfile::WARCRAFT3}, + {"wc3-map", GameProfile::WARCRAFT3_MAP}, + {"war3-map", GameProfile::WARCRAFT3_MAP}, + {"wow1", GameProfile::WOW_1X}, + {"wow2", GameProfile::WOW_2X}, + {"wow3", GameProfile::WOW_3X}, + {"wow4", GameProfile::WOW_4X}, + {"wow5", GameProfile::WOW_5X}, + {"sc2", GameProfile::STARCRAFT2}, + {"d3", GameProfile::DIABLO3}, + }; + for (const auto &[alias, profile] : aliases) { + CAPTURE(alias); + REQUIRE(GameRules::StringToProfile(alias) == profile); + } + } + + SECTION("ignores case") { + REQUIRE(GameRules::StringToProfile("WarCraft3") == GameProfile::WARCRAFT3); + REQUIRE(GameRules::StringToProfile("WOW-TBC") == GameProfile::WOW_2X); + REQUIRE(GameRules::StringToProfile("D2") == GameProfile::DIABLO2); + } + + SECTION("falls back to the generic profile for an unknown name") { + REQUIRE(GameRules::StringToProfile("quake") == GameProfile::GENERIC); + REQUIRE(GameRules::StringToProfile("warcraft 3") == GameProfile::GENERIC); + REQUIRE(GameRules::StringToProfile("") == GameProfile::GENERIC); + } +} + +TEST_CASE("GameRules::GetCanonicalProfiles", "[profiles]") { + SECTION("lists one name per profile, in display order") { + const std::vector expected = { + "generic", "diablo1", "lordsofmagic", "starcraft1", "warcraft2", + "diablo2", "warcraft3", "warcraft3-map", "wow-vanilla", "wow-tbc", + "wow-wotlk", "wow-cataclysm", "wow-mop", "starcraft2", "diablo3", + }; + REQUIRE(GameRules::GetCanonicalProfiles() == expected); + } + + SECTION("lists names that each resolve to a different profile") { + const auto names = GameRules::GetCanonicalProfiles(); + std::set profiles; + for (const auto &name : names) { + profiles.insert(GameRules::StringToProfile(name)); + } + REQUIRE(profiles.size() == names.size()); + } +} + +TEST_CASE("GameRules::GetAvailableProfiles", "[profiles]") { + SECTION("joins the canonical names with a comma and a space") { + const auto names = GameRules::GetCanonicalProfiles(); + std::string expected = names.front(); + for (std::size_t i = 1; i < names.size(); ++i) { + expected += ", " + names[i]; + } + REQUIRE(GameRules::GetAvailableProfiles() == expected); + } + + SECTION("starts and ends with a name, not a separator") { + const std::string profiles = GameRules::GetAvailableProfiles(); + REQUIRE(profiles.rfind("generic, ", 0) == 0U); + REQUIRE(profiles.size() > 9U); + REQUIRE(profiles.substr(profiles.size() - 9U) == ", diablo3"); + } +} diff --git a/test/unit/gamerules/test_rules.cpp b/test/unit/gamerules/test_rules.cpp new file mode 100644 index 0000000..22d8cb7 --- /dev/null +++ b/test/unit/gamerules/test_rules.cpp @@ -0,0 +1,253 @@ +#include +#include +#include +#include +#include + +#include + +#include "gamerules/profiles.h" +#include "gamerules/rules.h" +#include "gamerules/settings.h" + +using mpqcli::CompressionSettings; +using mpqcli::GameProfile; +using mpqcli::GameRules; +using mpqcli::MpqCreateSettings; +using mpqcli::MpqCreateSettingsOverrides; + +namespace { + +// The three fields as one hex string, so a failed comparison shows all of them +std::string Describe(DWORD flags, DWORD compression_first, + DWORD compression_next = MPQ_COMPRESSION_NEXT_SAME) { + std::ostringstream text; + text << std::hex << std::setfill('0') << "flags 0x" << std::setw(8) << flags << ", first 0x" + << std::setw(8) << compression_first << ", next 0x" << std::setw(8) << compression_next; + return text.str(); +} + +std::string Describe(const CompressionSettings &settings) { + return Describe(settings.mpq_flags, settings.compression_first, settings.compression_next); +} + +} // namespace + +TEST_CASE("GameRules::GetCompressionSettings", "[rules]") { + SECTION("compresses and encrypts every file under the generic profile") { + const GameRules rules(GameProfile::GENERIC); + const std::string expected = + Describe(MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED, MPQ_COMPRESSION_PKWARE); + REQUIRE(Describe(rules.GetCompressionSettings("cats.txt", 27U)) == expected); + REQUIRE(Describe(rules.GetCompressionSettings("sound\\a.wav", 0x10000U)) == expected); + } + + SECTION("matches a mask by extension") { + const GameRules rules(GameProfile::DIABLO2); + REQUIRE(Describe(rules.GetCompressionSettings("data\\global\\excel\\armor.txt", 100U)) == + Describe(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE)); + } + + SECTION("matches a mask whatever the case of name or mask") { + const GameRules rules(GameProfile::DIABLO2); + REQUIRE(Describe(rules.GetCompressionSettings("DATA\\ARMOR.TXT", 100U)) == + Describe(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE)); + + const GameRules warcraft3(GameProfile::WARCRAFT3); + REQUIRE(Describe(warcraft3.GetCompressionSettings("abilities\\a.WAV", 100U)) == + Describe(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB, + MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_MONO)); + } + + SECTION("matches a mask written with either separator") { + const GameRules rules(GameProfile::WARCRAFT3); + const std::string expected = + Describe(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB, + MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_MONO); + REQUIRE(Describe(rules.GetCompressionSettings("Abilities\\a.wav", 100U)) == expected); + REQUIRE(Describe(rules.GetCompressionSettings("Abilities/a.wav", 100U)) == expected); + } + + SECTION("lets a wildcard span directory separators") { + const GameRules rules(GameProfile::WARCRAFT3); + REQUIRE(Describe(rules.GetCompressionSettings("Abilities\\Spells\\Human\\a.wav", 100U)) == + Describe(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB, + MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_MONO)); + } + + SECTION("matches a mask with no wildcard against the whole name only") { + const GameRules rules(GameProfile::DIABLO1); + const std::string exact = Describe(MPQ_FILE_IMPLODE, 0U, 0U); + const std::string fallback = + Describe(MPQ_FILE_IMPLODE | MPQ_FILE_ENCRYPTED, MPQ_COMPRESSION_PKWARE); + REQUIRE(Describe(rules.GetCompressionSettings("game", 100U)) == exact); + REQUIRE(Describe(rules.GetCompressionSettings("GAME", 100U)) == exact); + REQUIRE(Describe(rules.GetCompressionSettings("game.txt", 100U)) == fallback); + REQUIRE(Describe(rules.GetCompressionSettings("endgame", 100U)) == fallback); + } + + SECTION("applies the first matching mask") { + const GameRules rules(GameProfile::WARCRAFT3); + REQUIRE(Describe(rules.GetCompressionSettings("Abilities\\a.wav", 100U)) == + Describe(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB, + MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_MONO)); + REQUIRE(Describe(rules.GetCompressionSettings("Sound\\a.wav", 100U)) == + Describe(MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_ZLIB, + MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_MONO)); + + REQUIRE(Describe(rules.GetCompressionSettings("UI\\Glues\\Loading\\Backgrounds\\a.blp", + 100U)) == Describe(0U, 0U)); + REQUIRE(Describe(rules.GetCompressionSettings("UI\\Widgets\\a.blp", 100U)) == + Describe(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB)); + REQUIRE(Describe(rules.GetCompressionSettings("Textures\\a.blp", 100U)) == + Describe(0U, 0U)); + } + + SECTION("applies a size rule ahead of a later mask") { + const GameRules rules(GameProfile::STARCRAFT2); + REQUIRE(Describe(rules.GetCompressionSettings("a.wav", 100U)) == + Describe(MPQ_FILE_COMPRESS | MPQ_FILE_SINGLE_UNIT, MPQ_COMPRESSION_ZLIB)); + REQUIRE(Describe(rules.GetCompressionSettings("a.wav", 0x5000U)) == + Describe(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB)); + } + + SECTION("applies a mask ahead of a later size rule") { + const GameRules rules(GameProfile::WOW_4X); + REQUIRE(Describe(rules.GetCompressionSettings("a.mp3", 100U)) == Describe(0U, 0U)); + } + + SECTION("includes both bounds of a size rule") { + const GameRules rules(GameProfile::WOW_4X); + const std::string small = + Describe(MPQ_FILE_COMPRESS | MPQ_FILE_SINGLE_UNIT, MPQ_COMPRESSION_ZLIB); + const std::string fallback = + Describe(MPQ_FILE_COMPRESS | MPQ_FILE_SECTOR_CRC, MPQ_COMPRESSION_ZLIB); + REQUIRE(Describe(rules.GetCompressionSettings("a.dat", 1U)) == small); + REQUIRE(Describe(rules.GetCompressionSettings("a.dat", 0x4000U)) == small); + REQUIRE(Describe(rules.GetCompressionSettings("a.dat", 0x4001U)) == fallback); + REQUIRE(Describe(rules.GetCompressionSettings("a.dat", UINT32_MAX)) == fallback); + } + + SECTION("falls back to the profile's default rule") { + const GameRules rules(GameProfile::WARCRAFT3_MAP); + REQUIRE(Describe(rules.GetCompressionSettings("war3map.j", 100U)) == + Describe(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB)); + } +} + +TEST_CASE("GameRules::GetCreateSettings", "[rules]") { + SECTION("uses the defaults for the generic profile") { + const MpqCreateSettings defaults; + const GameRules rules(GameProfile::GENERIC); + const MpqCreateSettings &settings = rules.GetCreateSettings(); + REQUIRE(settings.mpq_version == defaults.mpq_version); + REQUIRE(settings.stream_flags == defaults.stream_flags); + REQUIRE(settings.file_flags1 == defaults.file_flags1); + REQUIRE(settings.file_flags2 == defaults.file_flags2); + REQUIRE(settings.file_flags3 == defaults.file_flags3); + REQUIRE(settings.attr_flags == defaults.attr_flags); + REQUIRE(settings.sector_size == defaults.sector_size); + REQUIRE(settings.raw_chunk_size == defaults.raw_chunk_size); + } + + SECTION("writes a version 1 archive with attributes for Warcraft III") { + const GameRules rules(GameProfile::WARCRAFT3); + const MpqCreateSettings &settings = rules.GetCreateSettings(); + REQUIRE(settings.mpq_version == static_cast(MPQ_FORMAT_VERSION_1)); + REQUIRE(settings.file_flags1 == (MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS)); + REQUIRE(settings.file_flags2 == (MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS)); + REQUIRE(settings.attr_flags == (MPQ_ATTRIBUTE_FILETIME | MPQ_ATTRIBUTE_CRC32)); + REQUIRE(settings.sector_size == 0x1000U); + REQUIRE(settings.raw_chunk_size == 0U); + } + + SECTION("writes a version 4 archive with raw chunks for Cataclysm") { + const GameRules rules(GameProfile::WOW_4X); + const MpqCreateSettings &settings = rules.GetCreateSettings(); + REQUIRE(settings.mpq_version == static_cast(MPQ_FORMAT_VERSION_4)); + REQUIRE(settings.attr_flags == (MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5)); + REQUIRE(settings.sector_size == 0x4000U); + REQUIRE(settings.raw_chunk_size == 0x4000U); + } +} + +TEST_CASE("GameRules::OverrideCreateSettings", "[rules]") { + SECTION("leaves the profile's settings alone when nothing is overridden") { + GameRules rules(GameProfile::WOW_4X); + const MpqCreateSettings before = rules.GetCreateSettings(); + rules.OverrideCreateSettings(MpqCreateSettingsOverrides{}); + const MpqCreateSettings &after = rules.GetCreateSettings(); + REQUIRE(after.mpq_version == before.mpq_version); + REQUIRE(after.stream_flags == before.stream_flags); + REQUIRE(after.file_flags1 == before.file_flags1); + REQUIRE(after.file_flags2 == before.file_flags2); + REQUIRE(after.file_flags3 == before.file_flags3); + REQUIRE(after.attr_flags == before.attr_flags); + REQUIRE(after.sector_size == before.sector_size); + REQUIRE(after.raw_chunk_size == before.raw_chunk_size); + } + + SECTION("replaces each field that is overridden") { + GameRules rules(GameProfile::GENERIC); + MpqCreateSettingsOverrides overrides; + overrides.mpq_version = 2U; + overrides.stream_flags = 3U; + overrides.file_flags1 = 4U; + overrides.file_flags2 = 5U; + overrides.file_flags3 = 6U; + overrides.attr_flags = 7U; + overrides.sector_size = 0x8000U; + overrides.raw_chunk_size = 0x9000U; + rules.OverrideCreateSettings(overrides); + + const MpqCreateSettings &settings = rules.GetCreateSettings(); + REQUIRE(settings.mpq_version == 2U); + REQUIRE(settings.stream_flags == 3U); + REQUIRE(settings.file_flags1 == 4U); + REQUIRE(settings.file_flags2 == 5U); + REQUIRE(settings.file_flags3 == 6U); + REQUIRE(settings.attr_flags == 7U); + REQUIRE(settings.sector_size == 0x8000U); + REQUIRE(settings.raw_chunk_size == 0x9000U); + } + + SECTION("gives attributes asked for without a storage flag the default one") { + GameRules rules(GameProfile::GENERIC); + MpqCreateSettingsOverrides overrides; + overrides.attr_flags = MPQ_ATTRIBUTE_CRC32; + rules.OverrideCreateSettings(overrides); + REQUIRE(rules.GetCreateSettings().file_flags2 == MPQ_FILE_DEFAULT_INTERNAL); + } + + SECTION("keeps a storage flag of zero the user gave explicitly") { + GameRules rules(GameProfile::GENERIC); + MpqCreateSettingsOverrides overrides; + overrides.attr_flags = MPQ_ATTRIBUTE_CRC32; + overrides.file_flags2 = 0U; + rules.OverrideCreateSettings(overrides); + REQUIRE(rules.GetCreateSettings().file_flags2 == 0U); + } + + SECTION("keeps the storage flag a profile already sets") { + GameRules rules(GameProfile::WARCRAFT3); + MpqCreateSettingsOverrides overrides; + overrides.attr_flags = MPQ_ATTRIBUTE_MD5; + rules.OverrideCreateSettings(overrides); + REQUIRE(rules.GetCreateSettings().file_flags2 == (MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS)); + } + + SECTION("adds no storage flag when no attributes are asked for") { + GameRules rules(GameProfile::GENERIC); + MpqCreateSettingsOverrides overrides; + overrides.sector_size = 0x4000U; + rules.OverrideCreateSettings(overrides); + REQUIRE(rules.GetCreateSettings().file_flags2 == 0U); + } +} + +TEST_CASE("GameRules::GetDefaultProfile", "[rules]") { + SECTION("is the generic profile") { + REQUIRE(GameRules::GetDefaultProfile() == GameProfile::GENERIC); + } +} diff --git a/test/unit/mpq/test_extract.cpp b/test/unit/mpq/test_extract.cpp new file mode 100644 index 0000000..9326633 --- /dev/null +++ b/test/unit/mpq/test_extract.cpp @@ -0,0 +1,47 @@ +#include +#include +#include + +#include "mpq/extract.h" + +using mpqcli::FindFlatCollision; + +TEST_CASE("FindFlatCollision", "[extract]") { + SECTION("finds nothing when every file name differs") { + REQUIRE_FALSE(FindFlatCollision({"a\\cats.txt", "b\\dogs.txt", "bytes"}).has_value()); + } + + SECTION("finds nothing in an empty list") { + REQUIRE_FALSE(FindFlatCollision({}).has_value()); + } + + SECTION("finds two folders holding the same name") { + const auto collision = FindFlatCollision({"a\\cats.txt", "b\\cats.txt"}); + REQUIRE(collision.has_value()); + REQUIRE(collision->first == "a\\cats.txt"); + REQUIRE(collision->second == "b\\cats.txt"); + } + + SECTION("finds names that differ only in case") { + const auto collision = FindFlatCollision({"a\\Cats.txt", "b\\cats.TXT"}); + REQUIRE(collision.has_value()); + REQUIRE(collision->first == "a\\Cats.txt"); + REQUIRE(collision->second == "b\\cats.TXT"); + } + + SECTION("pairs the colliding file with the first of that name") { + const auto collision = + FindFlatCollision({"a\\cats.txt", "b\\dogs.txt", "c\\dogs.txt", "d\\cats.txt"}); + REQUIRE(collision.has_value()); + REQUIRE(collision->first == "b\\dogs.txt"); + REQUIRE(collision->second == "c\\dogs.txt"); + } + + SECTION("finds nothing when the same file is named twice") { + REQUIRE_FALSE(FindFlatCollision({"a\\cats.txt", "a\\cats.txt"}).has_value()); + } + + SECTION("finds nothing when the same file is named twice in different cases") { + REQUIRE_FALSE(FindFlatCollision({"a\\cats.txt", "A\\CATS.TXT"}).has_value()); + } +} diff --git a/test/unit/mpq/test_flags.cpp b/test/unit/mpq/test_flags.cpp new file mode 100644 index 0000000..a66c55a --- /dev/null +++ b/test/unit/mpq/test_flags.cpp @@ -0,0 +1,69 @@ +#include +#include +#include +#include + +#include + +#include "mpq/flags.h" + +using mpqcli::GetFlagString; + +namespace { + +struct FlagLetter { + uint32_t flag; + char letter; +}; + +// The flags that each own a bit and a letter, in the order the letters are printed +const std::vector flag_letters = { + {MPQ_FILE_IMPLODE, 'i'}, {MPQ_FILE_COMPRESS, 'c'}, {MPQ_FILE_ENCRYPTED, 'e'}, + {MPQ_FILE_KEY_V2, '2'}, {MPQ_FILE_PATCH_FILE, 'p'}, {MPQ_FILE_SINGLE_UNIT, 'u'}, + {MPQ_FILE_DELETE_MARKER, 'd'}, {MPQ_FILE_SECTOR_CRC, 'r'}, {MPQ_FILE_SIGNATURE, 's'}, + {MPQ_FILE_EXISTS, 'x'}, +}; + +} // namespace + +TEST_CASE("GetFlagString", "[flags]") { + SECTION("is empty for no flags") { + REQUIRE(GetFlagString(0U).empty()); + } + + SECTION("prints each flag's letter") { + for (const auto &[flag, letter] : flag_letters) { + CAPTURE(flag, letter); + REQUIRE(GetFlagString(flag).find(letter) != std::string::npos); + } + } + + SECTION("prints no other flag's letter") { + for (const auto &[flag, letter] : flag_letters) { + const std::string flags = GetFlagString(flag); + for (const auto &other : flag_letters) { + if (other.flag == flag) { + continue; + } + CAPTURE(flag, other.letter); + REQUIRE(flags.find(other.letter) == std::string::npos); + } + } + } + + SECTION("prints the letters in a fixed order whatever the flags") { + uint32_t all_flags = 0U; + for (const auto &entry : flag_letters) { + all_flags |= entry.flag; + } + const std::string flags = GetFlagString(all_flags); + std::string::size_type previous = 0U; + for (const auto &[flag, letter] : flag_letters) { + CAPTURE(letter); + const std::string::size_type position = flags.find(letter); + REQUIRE(position != std::string::npos); + REQUIRE(position >= previous); + previous = position; + } + } +} diff --git a/test/unit/test_errors.cpp b/test/unit/test_errors.cpp new file mode 100644 index 0000000..2a595c7 --- /dev/null +++ b/test/unit/test_errors.cpp @@ -0,0 +1,124 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "errors.h" + +namespace fs = std::filesystem; + +using Catch::Matchers::Message; +using mpqcli::ArchiveCreateError; +using mpqcli::ArchiveError; +using mpqcli::ArchiveOpenError; +using mpqcli::Error; +using mpqcli::FileError; +using mpqcli::Interrupted; +using mpqcli::StormError; +using mpqcli::ThrowIfCancelled; + +namespace { + +constexpr uint32_t corrupt = ERROR_FILE_CORRUPT; + +} // namespace + +TEST_CASE("Error", "[errors]") { + SECTION("carries its message as a std::runtime_error") { + STATIC_REQUIRE(std::is_base_of_v); + REQUIRE(std::string(Error("Something failed").what()) == "Something failed"); + } + + SECTION("is the base of every exception mpqcli throws") { + STATIC_REQUIRE(std::is_base_of_v); + STATIC_REQUIRE(std::is_base_of_v); + STATIC_REQUIRE(std::is_base_of_v); + STATIC_REQUIRE(std::is_base_of_v); + STATIC_REQUIRE(std::is_base_of_v); + STATIC_REQUIRE(std::is_base_of_v); + } +} + +TEST_CASE("Interrupted", "[errors]") { + SECTION("says the command was interrupted") { + REQUIRE(std::string(Interrupted().what()) == "Interrupted"); + } +} + +TEST_CASE("ThrowIfCancelled", "[errors]") { + SECTION("returns while the flag is clear") { + const std::atomic cancelled{false}; + REQUIRE_NOTHROW(ThrowIfCancelled(cancelled)); + } + + SECTION("throws Interrupted once the flag is set") { + const std::atomic cancelled{true}; + REQUIRE_THROWS_MATCHES(ThrowIfCancelled(cancelled), Interrupted, Message("Interrupted")); + } +} + +TEST_CASE("FileError", "[errors]") { + const fs::path path = fs::path("dir") / "a.txt"; + + SECTION("ends its message with the code, the code's text and the path") { + const std::error_code ec = std::make_error_code(std::errc::no_such_file_or_directory); + const FileError error("Failed to open", path, ec); + REQUIRE(std::string(error.what()) == "Failed to open: (" + std::to_string(ec.value()) + + ") " + ec.message() + ": " + path.string()); + REQUIRE(error.Path() == path); + REQUIRE(error.ErrorCode() == ec); + } + + SECTION("ends its message with the path alone when there is no code") { + const FileError error("Not a file or directory", path); + REQUIRE(std::string(error.what()) == "Not a file or directory: " + path.string()); + REQUIRE(error.Path() == path); + REQUIRE_FALSE(error.ErrorCode()); + } +} + +TEST_CASE("ArchiveError", "[errors]") { + SECTION("carries its message unchanged") { + REQUIRE(std::string(ArchiveError("Bad archive").what()) == "Bad archive"); + } +} + +TEST_CASE("StormError", "[errors]") { + SECTION("ends its message with the code and the code's text") { + const StormError error("Failed to read", corrupt); + REQUIRE(std::string(error.what()) == + "Failed to read: (" + std::to_string(corrupt) + ") File is corrupt"); + REQUIRE(error.ErrorCode() == corrupt); + } +} + +TEST_CASE("ArchiveOpenError", "[errors]") { + SECTION("names the archive ahead of the code and its text") { + const fs::path path = fs::path("dir") / "a.mpq"; + const ArchiveOpenError error(path, corrupt); + REQUIRE(std::string(error.what()) == "Failed to open MPQ archive: " + path.string() + + ": (" + std::to_string(corrupt) + + ") File is corrupt"); + REQUIRE(error.Path() == path); + REQUIRE(error.ErrorCode() == corrupt); + } +} + +TEST_CASE("ArchiveCreateError", "[errors]") { + SECTION("names the archive ahead of the code and its text") { + const fs::path path = fs::path("dir") / "a.mpq"; + const ArchiveCreateError error(path, corrupt); + REQUIRE(std::string(error.what()) == "Failed to create MPQ archive: " + path.string() + + ": (" + std::to_string(corrupt) + + ") File is corrupt"); + REQUIRE(error.Path() == path); + REQUIRE(error.ErrorCode() == corrupt); + } +} diff --git a/test/unit/util/test_format.cpp b/test/unit/util/test_format.cpp new file mode 100644 index 0000000..6ac5605 --- /dev/null +++ b/test/unit/util/test_format.cpp @@ -0,0 +1,95 @@ +#include +#include +#include +#include +#include + +#include + +#include "util/format.h" + +using mpqcli::FileTimeToLsTime; +using mpqcli::StormErrorString; +using mpqcli::WriteBinary; + +namespace { + +// A Windows FILETIME counts 100-nanosecond intervals from 1601-01-01, which is this +// many seconds before the Unix epoch +constexpr int64_t intervals_per_second = 10000000; +constexpr int64_t filetime_epoch_offset = 11644473600; + +// The FILETIME of a local calendar time. Going through mktime, the inverse of the +// localtime the code under test calls, keeps the expected text right in any time zone. +int64_t LocalFileTime(int year, int month, int day, int hour, int minute, int second) { + std::tm calendar{}; + calendar.tm_year = year - 1900; + calendar.tm_mon = month - 1; + calendar.tm_mday = day; + calendar.tm_hour = hour; + calendar.tm_min = minute; + calendar.tm_sec = second; + calendar.tm_isdst = -1; + const std::time_t unix_time = std::mktime(&calendar); + return (static_cast(unix_time) + filetime_epoch_offset) * intervals_per_second; +} + +} // namespace + +TEST_CASE("FileTimeToLsTime", "[format]") { + SECTION("prints nothing for a zero time") { + REQUIRE(FileTimeToLsTime(0).empty()); + } + + SECTION("prints the local date and time") { + REQUIRE(FileTimeToLsTime(LocalFileTime(2025, 7, 29, 14, 31, 5)) == "2025-07-29 14:31:05"); + } + + SECTION("pads every field to a fixed width") { + REQUIRE(FileTimeToLsTime(LocalFileTime(2001, 2, 3, 4, 5, 6)) == "2001-02-03 04:05:06"); + } + + SECTION("drops the fraction of a second") { + const int64_t file_time = LocalFileTime(2025, 7, 29, 14, 31, 5) + intervals_per_second - 1; + REQUIRE(FileTimeToLsTime(file_time) == "2025-07-29 14:31:05"); + } +} + +TEST_CASE("StormErrorString", "[format]") { + SECTION("describes success") { + REQUIRE(StormErrorString(ERROR_SUCCESS) == "Success"); + } + + SECTION("describes the system codes StormLib reports") { + REQUIRE(StormErrorString(ERROR_FILE_NOT_FOUND) == "File not found"); + REQUIRE(StormErrorString(ERROR_DISK_FULL) == "Disk full"); + REQUIRE(StormErrorString(ERROR_BAD_FORMAT) == "Bad MPQ format"); + } + + SECTION("describes the codes StormLib defines for itself") { + REQUIRE(StormErrorString(ERROR_FILE_CORRUPT) == "File is corrupt"); + REQUIRE(StormErrorString(ERROR_UNKNOWN_FILE_KEY) == "Unknown file encryption key"); + REQUIRE(StormErrorString(ERROR_CHECKSUM_ERROR) == "Sector checksum mismatch"); + REQUIRE(StormErrorString(ERROR_FILE_DELETED) == "File contains delete marker"); + } +} + +TEST_CASE("WriteBinary", "[format]") { + std::ostringstream out; + + SECTION("passes every byte through unchanged, line endings and nulls included") { + const std::string bytes("a\n\r\0b\x0A\x00\xFF", 8); + WriteBinary(out, bytes.data(), static_cast(bytes.size())); + REQUIRE(out.str() == bytes); + } + + SECTION("writes only as many bytes as size counts") { + WriteBinary(out, "abcdef", 3U); + REQUIRE(out.str() == "abc"); + } + + SECTION("writes nothing for a size of zero") { + WriteBinary(out, "abc", 0U); + REQUIRE(out.str().empty()); + } +} diff --git a/test/unit/util/test_hash.cpp b/test/unit/util/test_hash.cpp new file mode 100644 index 0000000..2d59e7a --- /dev/null +++ b/test/unit/util/test_hash.cpp @@ -0,0 +1,110 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "temp_dir.h" +#include "util/hash.h" +#include "write_file.h" + +namespace fs = std::filesystem; + +using mpqcli::ComputeFileCrc32; +using mpqcli::ComputeFileMd5; +using mpqcli::testing::TempDir; +using mpqcli::testing::WriteFile; + +namespace { + +constexpr std::size_t md5_size = 16; + +// Every byte value in turn, 70000 of them: more than one 65536-byte read, and holding +// the 0x0A and 0x1A that a text-mode read would mangle on Windows +std::string LargeContent() { + std::string content(70000, '\0'); + for (std::size_t i = 0; i < content.size(); ++i) { + content[i] = static_cast(i % 256); + } + return content; +} + +// The digest as lowercase hex, so a failed comparison prints something readable +std::string Md5Hex(const fs::path &path) { + std::array digest{}; + REQUIRE(ComputeFileMd5(path, digest.data())); + std::ostringstream hex; + hex << std::hex << std::setfill('0'); + for (const uint8_t byte : digest) { + hex << std::setw(2) << static_cast(byte); + } + return hex.str(); +} + +} // namespace + +TEST_CASE("ComputeFileCrc32", "[hash]") { + TempDir dir; + const fs::path file = dir.Path() / "input"; + + SECTION("matches the published value for a known string") { + WriteFile(file, "The quick brown fox jumps over the lazy dog"); + const auto crc = ComputeFileCrc32(file); + REQUIRE(crc.has_value()); + REQUIRE(*crc == 0x414FA339U); + } + + SECTION("matches the standard check value") { + WriteFile(file, "123456789"); + const auto crc = ComputeFileCrc32(file); + REQUIRE(crc.has_value()); + REQUIRE(*crc == 0xCBF43926U); + } + + SECTION("is zero for an empty file") { + WriteFile(file, ""); + const auto crc = ComputeFileCrc32(file); + REQUIRE(crc.has_value()); + REQUIRE(*crc == 0U); + } + + SECTION("covers the whole of a file larger than one read") { + WriteFile(file, LargeContent()); + const auto crc = ComputeFileCrc32(file); + REQUIRE(crc.has_value()); + REQUIRE(*crc == 0x634F3D0DU); + } + + SECTION("returns nothing for a file that does not exist") { + REQUIRE_FALSE(ComputeFileCrc32(dir.Path() / "missing").has_value()); + } +} + +TEST_CASE("ComputeFileMd5", "[hash]") { + TempDir dir; + const fs::path file = dir.Path() / "input"; + + SECTION("matches the published value for a known string") { + WriteFile(file, "The quick brown fox jumps over the lazy dog"); + REQUIRE(Md5Hex(file) == "9e107d9d372bb6826bd81d3542a419d6"); + } + + SECTION("matches the published value for an empty file") { + WriteFile(file, ""); + REQUIRE(Md5Hex(file) == "d41d8cd98f00b204e9800998ecf8427e"); + } + + SECTION("covers the whole of a file larger than one read") { + WriteFile(file, LargeContent()); + REQUIRE(Md5Hex(file) == "bd6270ad16e95c85d32f9a518e1f76e7"); + } + + SECTION("returns false for a file that does not exist") { + std::array digest{}; + REQUIRE_FALSE(ComputeFileMd5(dir.Path() / "missing", digest.data())); + } +} diff --git a/test/unit/util/test_locales.cpp b/test/unit/util/test_locales.cpp new file mode 100644 index 0000000..6ab88db --- /dev/null +++ b/test/unit/util/test_locales.cpp @@ -0,0 +1,116 @@ +#include +#include +#include + +#include "util/locales.h" + +using mpqcli::default_locale; +using mpqcli::GetAllLocales; +using mpqcli::LangToLocale; +using mpqcli::LocaleToLang; +using mpqcli::ParseHexLocale; +using mpqcli::PrettyPrintLocale; + +TEST_CASE("LocaleToLang", "[locales]") { + SECTION("names a known locale") { + REQUIRE(LocaleToLang(0x407U) == "deDE"); + REQUIRE(LocaleToLang(0x409U) == "enUS"); + REQUIRE(LocaleToLang(0x809U) == "enGB"); + REQUIRE(LocaleToLang(0x80AU) == "esMX"); + } + + SECTION("names the default locale enUS") { + REQUIRE(LocaleToLang(default_locale) == "enUS"); + } + + SECTION("formats an unknown locale as four uppercase hex digits") { + REQUIRE(LocaleToLang(0x41DU) == "041D"); + REQUIRE(LocaleToLang(0x1U) == "0001"); + } + + SECTION("keeps every digit of a locale wider than four") { + REQUIRE(LocaleToLang(0x12345U) == "12345"); + } +} + +TEST_CASE("LangToLocale", "[locales]") { + SECTION("resolves a known language name") { + REQUIRE(LangToLocale("deDE") == 0x407U); + REQUIRE(LangToLocale("enGB") == 0x809U); + REQUIRE(LangToLocale("zhTW") == 0x404U); + } + + SECTION("resolves enUS to its own locale rather than the default") { + REQUIRE(LangToLocale("enUS") == 0x409U); + } + + SECTION("accepts a four-digit hex locale") { + REQUIRE(LangToLocale("041D") == 0x41DU); + } + + SECTION("returns the default for anything else") { + REQUIRE(LangToLocale("xxXX") == default_locale); + REQUIRE(LangToLocale("German") == default_locale); + REQUIRE(LangToLocale("") == default_locale); + } +} + +TEST_CASE("ParseHexLocale", "[locales]") { + SECTION("parses four hex digits") { + REQUIRE(ParseHexLocale("0407") == 0x407U); + REQUIRE(ParseHexLocale("FFFF") == 0xFFFFU); + } + + SECTION("accepts lowercase digits") { + REQUIRE(ParseHexLocale("041d") == 0x41DU); + } + + SECTION("returns the default for the wrong length") { + REQUIRE(ParseHexLocale("407") == default_locale); + REQUIRE(ParseHexLocale("04070") == default_locale); + REQUIRE(ParseHexLocale("") == default_locale); + } + + SECTION("returns the default for a character that is not a hex digit") { + REQUIRE(ParseHexLocale("04G7") == default_locale); + REQUIRE(ParseHexLocale("0x07") == default_locale); + REQUIRE(ParseHexLocale(" 407") == default_locale); + REQUIRE(ParseHexLocale("-407") == default_locale); + } +} + +TEST_CASE("GetAllLocales", "[locales]") { + SECTION("lists every language once, sorted") { + const std::vector expected = { + "csCZ", "deDE", "enGB", "enUS", "esES", "esMX", "frFR", "itIT", "jaJP", + "koKR", "nlNL", "plPL", "ptBR", "ptPT", "ruRU", "zhCN", "zhTW", + }; + REQUIRE(GetAllLocales() == expected); + } + + SECTION("lists only names that resolve back to themselves") { + for (const auto &lang : GetAllLocales()) { + CAPTURE(lang); + REQUIRE(LocaleToLang(LangToLocale(lang)) == lang); + } + } +} + +TEST_CASE("PrettyPrintLocale", "[locales]") { + SECTION("prints nothing for the default locale") { + REQUIRE(PrettyPrintLocale(default_locale, " for locale ").empty()); + } + + SECTION("prints the default locale when always_print is set") { + REQUIRE(PrettyPrintLocale(default_locale, " for locale ", true) == " for locale enUS"); + } + + SECTION("prints the prefix and name of any other locale") { + REQUIRE(PrettyPrintLocale(0x407U, " for locale ") == " for locale deDE"); + REQUIRE(PrettyPrintLocale(0x407U) == "deDE"); + } + + SECTION("prints an unknown locale as hex") { + REQUIRE(PrettyPrintLocale(0x41DU) == "041D"); + } +} diff --git a/test/unit/util/test_path.cpp b/test/unit/util/test_path.cpp new file mode 100644 index 0000000..bc1b113 --- /dev/null +++ b/test/unit/util/test_path.cpp @@ -0,0 +1,263 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "temp_dir.h" +#include "util/path.h" +#include "write_file.h" + +namespace fs = std::filesystem; + +using mpqcli::ExpandStdinMarker; +using mpqcli::IsWithinDirectory; +using mpqcli::ListFilesRecursive; +using mpqcli::LocalFileTimestamp; +using mpqcli::NormalizeFilePath; +using mpqcli::ResolveArchiveName; +using mpqcli::WindowsifyFilePath; +using mpqcli::testing::TempDir; +using mpqcli::testing::WriteFile; + +namespace { + +// A Windows FILETIME counts 100-nanosecond intervals from 1601-01-01, which is this +// many seconds before the Unix epoch +constexpr uint64_t intervals_per_second = 10000000U; +constexpr uint64_t filetime_epoch_offset = 11644473600U; + +} // namespace + +TEST_CASE("NormalizeFilePath", "[path]") { + SECTION("leaves forward slashes alone") { + REQUIRE(NormalizeFilePath(fs::path("dir/sub/file.txt")) == "dir/sub/file.txt"); + } + + SECTION("makes an archive name's backslashes separators the platform reads") { +#ifdef _WIN32 + REQUIRE(NormalizeFilePath(fs::path("dir\\sub\\file.txt")) == "dir\\sub\\file.txt"); +#else + REQUIRE(NormalizeFilePath(fs::path("dir\\sub\\file.txt")) == "dir/sub/file.txt"); +#endif + } + + SECTION("returns a name outside ASCII as UTF-8") { + REQUIRE(NormalizeFilePath(fs::u8path("caf\xC3\xA9.txt")) == "caf\xC3\xA9.txt"); + } +} + +TEST_CASE("WindowsifyFilePath", "[path]") { + SECTION("turns forward slashes into backslashes") { + REQUIRE(WindowsifyFilePath(fs::path("dir/sub/file.txt")) == "dir\\sub\\file.txt"); + } + + SECTION("leaves backslashes alone") { + REQUIRE(WindowsifyFilePath(fs::path("dir\\sub\\file.txt")) == "dir\\sub\\file.txt"); + } + + SECTION("gives a path joined with the native separator backslashes") { + REQUIRE(WindowsifyFilePath(fs::path("dir") / "sub" / "file.txt") == "dir\\sub\\file.txt"); + } + + SECTION("returns a name outside ASCII as UTF-8") { + REQUIRE(WindowsifyFilePath(fs::u8path("dir/caf\xC3\xA9.txt")) == "dir\\caf\xC3\xA9.txt"); + } +} + +TEST_CASE("IsWithinDirectory", "[path]") { + const fs::path base("out"); + + SECTION("accepts a file directly under base") { + REQUIRE(IsWithinDirectory(base, base / "a.txt")); + } + + SECTION("accepts a file nested several levels under base") { + REQUIRE(IsWithinDirectory(base, base / "sub" / "deeper" / "a.txt")); + } + + SECTION("rejects a sibling whose name begins with base's name") { + REQUIRE_FALSE(IsWithinDirectory(base, fs::path("outside") / "a.txt")); + REQUIRE_FALSE(IsWithinDirectory(base, fs::path("outside"))); + } + + SECTION("rejects a file in a parent of base") { + REQUIRE_FALSE(IsWithinDirectory(base / "sub", base / "a.txt")); + } + + // Components are compared as they stand, so ".." only counts once the caller has + // normalised the path, as ExtractFile does + SECTION("rejects a normalised path that climbs out of base") { + REQUIRE_FALSE(IsWithinDirectory(base, (base / ".." / "etc" / "passwd").lexically_normal())); + REQUIRE_FALSE( + IsWithinDirectory(base, (base / "sub" / ".." / ".." / "a.txt").lexically_normal())); + REQUIRE_FALSE( + IsWithinDirectory(base, (base / ".." / "outside" / "a.txt").lexically_normal())); + } + + SECTION("accepts a normalised path whose .. stays inside base") { + REQUIRE(IsWithinDirectory(base, (base / "sub" / ".." / "a.txt").lexically_normal())); + } + + SECTION("rejects an absolute path outside an absolute base") { + const fs::path root = fs::current_path().root_path(); + REQUIRE_FALSE(IsWithinDirectory(root / "out", root / "etc" / "passwd")); + REQUIRE(IsWithinDirectory(root / "out", root / "out" / "a.txt")); + } + + SECTION("rejects an absolute path against a relative base") { + REQUIRE_FALSE(IsWithinDirectory(base, fs::current_path().root_path() / "out" / "a.txt")); + } +} + +TEST_CASE("ResolveArchiveName", "[path]") { + const fs::path file = fs::path("inputs") / "animals" / "cats.txt"; + + SECTION("uses the file's own name when no path is given") { + REQUIRE(ResolveArchiveName(file, std::nullopt) == "cats.txt"); + } + + SECTION("stores a lone file at the given path") { + REQUIRE(ResolveArchiveName(file, "data/felines.txt") == "data\\felines.txt"); + } + + SECTION("puts the file's name under the path when treated as a directory") { + REQUIRE(ResolveArchiveName(file, "data/animals", true) == "data\\animals\\cats.txt"); + } + + SECTION("puts the file at the root when treated as a directory with no path") { + REQUIRE(ResolveArchiveName(file, std::nullopt, true) == "cats.txt"); + } + + SECTION("accepts a path written with backslashes") { + REQUIRE(ResolveArchiveName(file, "data\\animals", true) == "data\\animals\\cats.txt"); + } +} + +TEST_CASE("ListFilesRecursive", "[path]") { + TempDir dir; + std::error_code ec; + + SECTION("lists every file at any depth, sorted") { + WriteFile(dir.Path() / "b.txt", "b"); + WriteFile(dir.Path() / "a" / "c.txt", "c"); + WriteFile(dir.Path() / "a" / "b" / "d.txt", "d"); + + const std::vector expected = {dir.Path() / "a" / "b" / "d.txt", + dir.Path() / "a" / "c.txt", dir.Path() / "b.txt"}; + REQUIRE(ListFilesRecursive(dir.Path(), ec) == expected); + REQUIRE_FALSE(ec); + } + + SECTION("leaves out directories, empty or not") { + fs::create_directories(dir.Path() / "empty"); + WriteFile(dir.Path() / "full" / "a.txt", "a"); + + const std::vector expected = {dir.Path() / "full" / "a.txt"}; + REQUIRE(ListFilesRecursive(dir.Path(), ec) == expected); + REQUIRE_FALSE(ec); + } + + SECTION("returns nothing for an empty directory") { + REQUIRE(ListFilesRecursive(dir.Path(), ec).empty()); + REQUIRE_FALSE(ec); + } + + SECTION("reports a directory that does not exist") { + REQUIRE(ListFilesRecursive(dir.Path() / "missing", ec).empty()); + REQUIRE(ec); + } + +#ifndef _WIN32 + // Windows creates a symlink only with Developer Mode or elevation, which a test + // cannot count on + SECTION("skips a dangling symlink") { + WriteFile(dir.Path() / "a.txt", "a"); + fs::create_symlink(dir.Path() / "missing", dir.Path() / "dangling"); + + const std::vector expected = {dir.Path() / "a.txt"}; + REQUIRE(ListFilesRecursive(dir.Path(), ec) == expected); + REQUIRE_FALSE(ec); + } +#endif +} + +TEST_CASE("ExpandStdinMarker", "[path]") { + SECTION("returns the paths unchanged, without reading, when none is the marker") { + std::istringstream in("unread.txt\n"); + + const std::vector expected = {"a.txt", "b.txt"}; + REQUIRE(ExpandStdinMarker({"a.txt", "b.txt"}, in) == expected); + std::string rest; + std::getline(in, rest); + REQUIRE(rest == "unread.txt"); + } + + SECTION("replaces the marker in place with each line read") { + std::istringstream in("b.txt\nc.txt\n"); + + const std::vector expected = {"a.txt", "b.txt", "c.txt", "d.txt"}; + REQUIRE(ExpandStdinMarker({"a.txt", "-", "d.txt"}, in) == expected); + } + + SECTION("drops empty lines") { + std::istringstream in("\nb.txt\n\n\nc.txt\n"); + + const std::vector expected = {"b.txt", "c.txt"}; + REQUIRE(ExpandStdinMarker({"-"}, in) == expected); + } + + SECTION("strips a trailing carriage return, dropping a line left empty") { + std::istringstream in("b.txt\r\n\r\nc.txt\r\n"); + + const std::vector expected = {"b.txt", "c.txt"}; + REQUIRE(ExpandStdinMarker({"-"}, in) == expected); + } + + SECTION("keeps a last line with no newline") { + std::istringstream in("b.txt\nc.txt"); + + const std::vector expected = {"b.txt", "c.txt"}; + REQUIRE(ExpandStdinMarker({"-"}, in) == expected); + } + + SECTION("reads the stream once, so a later marker adds nothing") { + std::istringstream in("a.txt\nb.txt\n"); + + const std::vector expected = {"a.txt", "b.txt", "x.txt"}; + REQUIRE(ExpandStdinMarker({"-", "x.txt", "-"}, in) == expected); + } +} + +TEST_CASE("LocalFileTimestamp", "[path]") { + TempDir dir; + const fs::path file = dir.Path() / "a.txt"; + WriteFile(file, "a"); + + SECTION("converts the modification time to a FILETIME") { + // C++17 has no clock_cast, so the fixed time reaches the file through the two + // clocks' current readings + const std::time_t unix_time = 1700000000; + const auto system_time = std::chrono::system_clock::from_time_t(unix_time); + fs::last_write_time(file, fs::file_time_type::clock::now() + + std::chrono::duration_cast( + system_time - std::chrono::system_clock::now())); + + // The bridge can land a hair either side of the second, and only whole seconds + // are kept + const uint64_t expected = + (static_cast(unix_time) + filetime_epoch_offset) * intervals_per_second; + const uint64_t actual = LocalFileTimestamp(file); + REQUIRE(actual >= expected - intervals_per_second); + REQUIRE(actual <= expected + intervals_per_second); + } + + SECTION("returns 0 for a file that does not exist") { + REQUIRE(LocalFileTimestamp(dir.Path() / "missing.txt") == 0U); + } +} diff --git a/test/unit/util/test_string.cpp b/test/unit/util/test_string.cpp new file mode 100644 index 0000000..ed13f50 --- /dev/null +++ b/test/unit/util/test_string.cpp @@ -0,0 +1,24 @@ +#include + +#include "util/string.h" + +using mpqcli::ToLower; + +TEST_CASE("ToLower", "[string]") { + SECTION("lowercases every ASCII letter") { + REQUIRE(ToLower("ABCXYZ") == "abcxyz"); + REQUIRE(ToLower("MiXeD.TxT") == "mixed.txt"); + } + + SECTION("leaves digits, punctuation and separators alone") { + REQUIRE(ToLower("A1-B_2\\C/D.E") == "a1-b_2\\c/d.e"); + } + + SECTION("returns an empty string unchanged") { + REQUIRE(ToLower("").empty()); + } + + SECTION("leaves bytes outside ASCII alone") { + REQUIRE(ToLower("\xC3\x89T\xC3\x89") == "\xC3\x89t\xC3\x89"); + } +} From 219e6e55e9ddba8671d6acf308173160972894c8 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 20:27:55 +1300 Subject: [PATCH 04/37] Replaced the pytest Makefile targets and CI steps with ctest --- .github/workflows/test.yml | 33 ++++++++---------------- Makefile | 49 ++++++++++++++++++++++-------------- test/integration_data.sha256 | 2 ++ 3 files changed, 42 insertions(+), 42 deletions(-) create mode 100644 test/integration_data.sha256 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6d358f2..cb900bf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -54,18 +54,9 @@ jobs: - run: make configure CMAKE_ARGS="-DCMAKE_CXX_COMPILER=${{ matrix.cxx }} -DMPQCLI_WERROR=ON" - run: make build - - name: Setup python - uses: actions/setup-python@v6 - with: - python-version: 3.11 - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r test/requirements.txt - - - name: Run tests - run: python3 -m pytest test + # Separate steps, so a failure names the layer that broke + - run: make test + - run: make test_functional test_windows: runs-on: windows-2022 @@ -84,15 +75,11 @@ jobs: cmake -B build/dev -G "Visual Studio 17 2022" -A x64 -DMPQCLI_BUILD_APP=ON cmake --build build/dev --config Release --parallel - - name: Setup python - uses: actions/setup-python@v6 - with: - python-version: 3.11 - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r test/requirements.txt + # Separate steps, so a failure names the layer that broke + - name: Run unit tests + shell: bash + run: ctest --test-dir build/dev --output-on-failure -C Release -L unit - - name: Run tests - run: python -m pytest test + - name: Run functional tests + shell: bash + run: ctest --test-dir build/dev --output-on-failure -C Release -L functional diff --git a/Makefile b/Makefile index dced9a2..9a675d2 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,15 @@ GCC_INSTALL_DIR := $(shell f=$$(gcc -print-libgcc-file-name 2>/dev/null) && dir README := README.md PACKAGE_URL := https://github.com/thegraydot/mpqcli/pkgs/container/mpqcli +# The real Blizzard patches a few functional tests read, which are never +# committed. The default matches the MPQCLI_INTEGRATION_DATA cache variable in +# CMakeLists.txt, so fetching them is all a test run needs. They are shared from +# a Filen folder whose links decrypt client-side, so curl gets Filen's web app +# rather than the files; filenget does the fetching. +INTEGRATION_DATA ?= $(or $(MPQCLI_INTEGRATION_DATA),test/data/integration) +INTEGRATION_LINK := https://drive.filen.io/f/cedf490b-aa38-406a-ad50-df35b1f0c5a9\#4t-jw8y2G9nK2igPN0tdVbty-lABHqDY +FILENGET ?= filenget + # The version is declared once, in project(... VERSION X.Y.Z). The # cmake_minimum_required line also says VERSION and comes first, so it is skipped. VERSION_AWK := /cmake_minimum_required/ { next } \ @@ -63,27 +72,27 @@ build: configure ## Build the project ##@ TEST .PHONY: test -test: build test_mpqcli ## Run test suite (builds binary first) +test: build ## Run Catch2 unit tests + ctest --test-dir build/dev --output-on-failure --parallel $(JOBS) -L unit -.PHONY: test_create_venv -test_create_venv: ## Create Python venv and install test dependencies - python3 -m venv ./.venv - . ./.venv/bin/activate && \ - pip3 install -r test/requirements.txt +.PHONY: test_verbose +test_verbose: build ## Run unit tests with verbose Catch2 output + ctest --test-dir build/dev --verbose -L unit -.PHONY: test_mpqcli -test_mpqcli: ## Run pytest test suite - . ./.venv/bin/activate && \ - python3 -m pytest test -s +.PHONY: test_functional +test_functional: build ## Run Catch2 functional tests against the built binary + ctest --test-dir build/dev --output-on-failure --parallel $(JOBS) -L functional -.PHONY: test_clean -test_clean: ## Remove test data directory - rm -rf test/data +.PHONY: test_all +test_all: build ## Run every test layer built into the current configure + ctest --test-dir build/dev --output-on-failure --parallel $(JOBS) -.PHONY: test_lint -test_lint: ## Run ruff linter on test directory - . ./.venv/bin/activate && \ - ruff check ./test +# The checksums pin the bytes the tests' expected values were taken from, so a +# changed or truncated download fails here rather than as a puzzling test failure +.PHONY: fetch_integration_data +fetch_integration_data: ## Download the Blizzard patches some functional tests read (requires: filenget) + $(FILENGET) "$(INTEGRATION_DATA)" '$(INTEGRATION_LINK)' + cd "$(INTEGRATION_DATA)" && sha256sum --check "$(CURDIR)/test/integration_data.sha256" ##@ LINT @@ -211,8 +220,10 @@ get_changelog: ## Print release notes for TAG to stdout (TAG=v1.0.0) check_all: check_format check_lint check_embed ## Run every static check .PHONY: ci -ci: check_all test ## Run the checks CI runs +ci: check_all test test_functional ## Run the checks CI runs +# The fetched integration data is left alone: it is not build output, and only +# a network fetch brings it back .PHONY: clean -clean: test_clean docs_clean ## Remove all build, test, docs and release artefacts +clean: docs_clean ## Remove all build, docs and release artefacts rm -rf build dist install.sh install.ps1 checksums.txt checksums.txt.sigstore.json diff --git a/test/integration_data.sha256 b/test/integration_data.sha256 new file mode 100644 index 0000000..2bc7d4b --- /dev/null +++ b/test/integration_data.sha256 @@ -0,0 +1,2 @@ +782aa7883092ba40abb978eecf72378a473299158c7572c4694a6e8097fdeb08 v1_weak_signature_patch.mpq +fd616062b7e640f05379569cf96f94bbc4b7c13ad1932a673df5d13e8b9c6cc6 v2_strong_signature_patch.mpq From 70f5ef0685c0c60c98ac402781784ed3b1f09dbf Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 20:28:05 +1300 Subject: [PATCH 05/37] Removed the pytest suite --- .dockerignore | 1 - .github/dependabot.yml | 8 - test/conftest.py | 334 ----------- test/requirements.txt | 2 - test/test_add.py | 1268 --------------------------------------- test/test_compact.py | 169 ------ test/test_completion.py | 150 ----- test/test_create.py | 1095 --------------------------------- test/test_extract.py | 1088 --------------------------------- test/test_info.py | 200 ------ test/test_list.py | 600 ------------------ test/test_read.py | 311 ---------- test/test_remove.py | 335 ----------- test/test_rename.py | 395 ------------ test/test_verify.py | 216 ------- 15 files changed, 6172 deletions(-) delete mode 100644 test/conftest.py delete mode 100644 test/requirements.txt delete mode 100644 test/test_add.py delete mode 100644 test/test_compact.py delete mode 100644 test/test_completion.py delete mode 100644 test/test_create.py delete mode 100644 test/test_extract.py delete mode 100644 test/test_info.py delete mode 100644 test/test_list.py delete mode 100644 test/test_read.py delete mode 100644 test/test_remove.py delete mode 100644 test/test_rename.py delete mode 100644 test/test_verify.py diff --git a/.dockerignore b/.dockerignore index 7d4c5cf..40805f7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,6 @@ # Local build, docs and test output: large, stale and never read by the image build build/ book/ -.venv/ test/data/ # .git is deliberately not ignored. The root CMakeLists runs git rev-parse to diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 523adbc..a010bb5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,14 +9,6 @@ updates: assignees: - "thomaslaurenson" - - package-ecosystem: pip - directory: /test - schedule: - interval: weekly - open-pull-requests-limit: 0 - assignees: - - "thomaslaurenson" - - package-ecosystem: docker directory: / schedule: diff --git a/test/conftest.py b/test/conftest.py deleted file mode 100644 index 1f6b88a..0000000 --- a/test/conftest.py +++ /dev/null @@ -1,334 +0,0 @@ -import shutil -from datetime import datetime -import os -import platform -from pathlib import Path -import urllib.request -import subprocess - -import pytest - - -@pytest.fixture(scope="session") -def binary_path(): - script_dir = Path(__file__).parent - - if platform.system() == "Windows": - binary = script_dir.parent / "build" / "dev" / "bin" / "mpqcli.exe" - else: - binary = script_dir.parent / "build" / "dev" / "bin" / "mpqcli" - - if not binary.exists(): - pytest.fail(f"Binary not found at {binary}") - - return binary - - -@pytest.fixture(scope="session") -def generate_test_files(): - script_dir = Path(__file__).parent - - data_dir = script_dir / "data" - data_dir.mkdir(parents=True, exist_ok=True) - - files_dir = data_dir / "files" - files_dir.mkdir(parents=True, exist_ok=True) - - files = { - "cats.txt": "This is a file about cats.\n", - "dogs.txt": "This is a file about dogs.\n", - "bytes": b"\x00\x01\x02\x03\x04\x05\x06\x07", - } - - # Hard-coded timestamp: July 29, 2025, 14:31:00 - # This is needed because archive size changes based on the timestamp - # and we need to have a consistent test output for "detailed" listing - dt = datetime(2025, 7, 29, 14, 31, 0) - ts = dt.timestamp() - - created_files = [] - for filename, content in files.items(): - file_path = files_dir / filename - if isinstance(content, str): - if platform.system() == "Windows": - file_path.write_text(content, newline="\r\n") - else: - file_path.write_text(content, newline="\n") - if isinstance(content, bytes): - file_path.write_bytes(content) - os.utime(file_path, (ts, ts)) - created_files.append(file_path) - - yield created_files - - # Teardown: clean up generated files and directory - shutil.rmtree(files_dir, ignore_errors=True) - - -@pytest.fixture(scope="function") -def generate_locales_mpq_test_files(binary_path): - script_dir = Path(__file__).parent - - data_dir = script_dir / "data" - data_dir.mkdir(parents=True, exist_ok=True) - - locales_files_dir = data_dir / "locale_files" - shutil.rmtree(locales_files_dir, ignore_errors=True) - locales_files_dir.mkdir(parents=True, exist_ok=True) - - mpq_many_locales_file_name = data_dir / "mpq_with_many_locales.mpq" - mpq_one_locale_file_name = data_dir / "mpq_with_one_locale.mpq" - text_file_name = "cats.txt" - mpq_many_locales_file_name.unlink(missing_ok=True) - mpq_one_locale_file_name.unlink(missing_ok=True) - - locale_files = { - "": "This is a file about cats.", # Default locale - "041D": "Detta är en fil om katter.", # Swedish locale (not part of locales.cpp) - "deDE": "Dies ist eine Datei über Katzen.", # German locale - "esES": "Este es un archivo sobre gatos.", # Spanish locale - } - - # Put all items into mpq_many_locales_file_name with their locale - for locale, content in locale_files.items(): - file_path = locales_files_dir / text_file_name - file_path.write_text(content, newline="\n") - - if locale == "": # Default locale - create a new MPQ file - result = subprocess.run( - [str(binary_path), "create", "--format-version", "1", str(mpq_many_locales_file_name), str(locales_files_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - else: # Explicit locale - add to existing MPQ file - result = subprocess.run( - [str(binary_path), "add", str(mpq_many_locales_file_name), str(file_path), "--locale", locale], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - # Put the last item into mpq_one_locale_file_name with its locale - locale, content = list(locale_files.items())[-1] - file_path = locales_files_dir / text_file_name - file_path.write_text(content, newline="\n") - - result = subprocess.run( - [str(binary_path), "create", "--format-version", "1", str(mpq_one_locale_file_name), str(locales_files_dir), "--locale", locale], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - -@pytest.fixture(scope="function") -def generate_mpq_without_internal_listfile(binary_path): - script_dir = Path(__file__).parent - - data_dir = script_dir / "data" - data_dir.mkdir(parents=True, exist_ok=True) - - locales_files_dir = data_dir / "locale_files" - shutil.rmtree(locales_files_dir, ignore_errors=True) - locales_files_dir.mkdir(parents=True, exist_ok=True) - - mpq_file_name = data_dir / "mpq_without_internal_listfile.mpq" - mpq_file_name.unlink(missing_ok=True) - - content = [ - ("capybaras.txt", "", "This is a file about capybaras."), # Default locale - ("cats.txt", "deDE", "Dies ist eine Datei über Katzen."), # German locale - ("dogs.txt", "041D", "Detta är en fil om hundar."), # Swedish locale (not part of locales.cpp) - ] - - # Put all items into mpq_many_locales_file_name with their locale - for text_file_name, locale, text_content in content: - file_path = locales_files_dir / text_file_name - file_path.write_text(text_content, newline="\n") - - if locale == "": # Default locale - create a new MPQ file - result = subprocess.run( - [str(binary_path), "create", str(mpq_file_name), str(locales_files_dir), "--file-flags1", "0"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - else: # Explicit locale - add to existing MPQ file - result = subprocess.run( - [str(binary_path), "add", str(mpq_file_name), str(file_path), "--locale", locale], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - -@pytest.fixture(scope="function") -def generate_path_traversal_mpq(binary_path): - script_dir = Path(__file__).parent - - data_dir = script_dir / "data" - data_dir.mkdir(parents=True, exist_ok=True) - - traversal_files_dir = data_dir / "traversal_files" - shutil.rmtree(traversal_files_dir, ignore_errors=True) - traversal_files_dir.mkdir(parents=True, exist_ok=True) - - mpq_file = data_dir / "mpq_with_path_traversal.mpq" - mpq_file.unlink(missing_ok=True) - - safe_file = traversal_files_dir / "safe.txt" - safe_file.write_text("This is a safe file.\n", newline="\n") - - # Create a base MPQ containing the safe file - result = subprocess.run( - [str(binary_path), "create", str(mpq_file), str(traversal_files_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - # Embed a second entry whose archive path traverses above the output directory - result = subprocess.run( - [str(binary_path), "add", str(mpq_file), str(safe_file), "-p", "../../sneaky.txt"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - yield mpq_file - - -@pytest.fixture(scope="function") -def generate_cats_and_dogs_mpq(binary_path, tmp_path_factory): - # A directory of its own rather than test/data, so the test depends on no other test, - # and rather than the test's tmp_path, which some tests assert is left empty - archive_dir = tmp_path_factory.mktemp("cats_and_dogs") - files_dir = archive_dir / "files" - files_dir.mkdir() - (files_dir / "cats.txt").write_text("This is a file about cats.\n", newline="\n") - (files_dir / "dogs.txt").write_text("This is a file about dogs.\n", newline="\n") - - mpq_file = archive_dir / "cats_and_dogs.mpq" - result = subprocess.run( - [str(binary_path), "create", str(mpq_file), str(files_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - yield mpq_file - - -@pytest.fixture(scope="function") -def generate_tampered_signature_mpq(binary_path): - """ - Build a signed MPQ archive, then flip a single byte inside the file's - compressed data (well past the leading sector-offset-table bytes, which - are sensitive to corruption in ways unrelated to the signature). - - This produces an archive whose "(signature)" entry is present but no - longer matches the archive content, i.e. StormLib's - ERROR_WEAK_SIGNATURE_ERROR case. - """ - script_dir = Path(__file__).parent - - data_dir = script_dir / "data" - data_dir.mkdir(parents=True, exist_ok=True) - - tamper_files_dir = data_dir / "tamper_files" - shutil.rmtree(tamper_files_dir, ignore_errors=True) - tamper_files_dir.mkdir(parents=True, exist_ok=True) - - mpq_file = data_dir / "mpq_with_tampered_signature.mpq" - mpq_file.unlink(missing_ok=True) - - text_file = tamper_files_dir / "cats.txt" - text_file.write_text( - "This is a longer file about cats, with enough content to give us " - "several bytes of compressed data to safely flip a bit in.\n", - newline="\n", - ) - - result = subprocess.run( - [str(binary_path), "create", "-s", str(mpq_file), str(tamper_files_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - offset_result = subprocess.run( - [str(binary_path), "list", "--property", "byte-offset", str(mpq_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert offset_result.returncode == 0, f"mpqcli failed with error: {offset_result.stderr}" - byte_offset = int(offset_result.stdout.split()[0]) - - size_result = subprocess.run( - [str(binary_path), "list", "--property", "compressed-size", str(mpq_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert size_result.returncode == 0, f"mpqcli failed with error: {size_result.stderr}" - compressed_size = int(size_result.stdout.split()[0]) - - # Flip a byte at the midpoint of the compressed data, away from the - # sector-offset-table bytes at the start of the blob - flip_offset = byte_offset + compressed_size // 2 - with open(mpq_file, "r+b") as f: - f.seek(flip_offset) - original_byte = f.read(1) - f.seek(flip_offset) - f.write(bytes([original_byte[0] ^ 0xFF])) - - yield mpq_file - - -@pytest.fixture(scope="session") -def download_test_files(): - script_dir = Path(__file__).parent - - data_dir = script_dir / "data" - data_dir.mkdir(parents=True, exist_ok=True) - - file_urls = { - "v1_weak_signature_patch.mpq": ( - "https://archive.org/download/World_of_Warcraft_Client_and_Installation_Archive/" - "Patches/1.x/WoW-1.10.0-to-1.10.1-enGB-patch.zip/wow-patch.mpq" - ), - "v2_strong_signature_patch.mpq": ( - "https://archive.org/download/World_of_Warcraft_Client_and_Installation_Archive/" - "Patches/3.x/wow-3.2.2-to-3.3.0-enGB-Win-patch.zip/" - "wow-3.2.2-to-3.3.0-enGB-Win-patch%2Fwow-final.MPQ" - ) - } - - downloaded_files = [] - - for name, url in file_urls.items(): - file_path = data_dir / name - if file_path.exists(): - downloaded_files.append(file_path) - continue - - try: - urllib.request.urlretrieve(url, file_path) - downloaded_files.append(file_path) - except Exception as e: - pytest.skip(f"Could not download test file '{name}': {e}") - - return downloaded_files diff --git a/test/requirements.txt b/test/requirements.txt deleted file mode 100644 index 8df3271..0000000 --- a/test/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -pytest==9.0.3 -ruff==0.15.15 diff --git a/test/test_add.py b/test/test_add.py deleted file mode 100644 index 2ffd36f..0000000 --- a/test/test_add.py +++ /dev/null @@ -1,1268 +0,0 @@ -import os -import subprocess -import shutil -from pathlib import Path - - -def test_add_target_mpq_does_not_exist(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "does" / "not" / "exist.mpq" - target_file = script_dir / "data" / "files" / "cats.txt" - - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), str(target_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - - -def test_add_target_file_does_not_exist(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - - create_mpq_archive_for_test(binary_path, script_dir) - - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), str(script_dir / "does" / "not" / "exist.txt")], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - # A path that does not exist is a real failure, even though the run continues - # on to any remaining inputs. - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert "[!] Path does not exist:" in result.stderr - - -def test_add_file_to_mpq_archive(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "files.mpq" - - create_mpq_archive_for_test(binary_path, script_dir) - - test_file = script_dir / "data" / "test.txt" - test_file.write_text("This is a test file for MPQ addition.") - - result = subprocess.run( - [str(binary_path), "add", str(target_file), str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - expected_stdout_output = set() - assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" - - output_lines = set(result.stderr.splitlines()) - expected_stderr_output = { - "[+] Adding file: test.txt", - } - assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - expected_content = { - "enUS bytes", - "enUS dogs.txt", - "enUS cats.txt", - "enUS test.txt", - } - verify_archive_file_content(binary_path, target_file, expected_content) - - -def test_add_multiple_files_to_mpq_archive(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "files.mpq" - - create_mpq_archive_for_test(binary_path, script_dir) - - test_file_a = script_dir / "data" / "alpha.txt" - test_file_b = script_dir / "data" / "beta.txt" - test_file_a.write_text("Alpha file content.") - test_file_b.write_text("Beta file content.") - - result = subprocess.run( - [str(binary_path), "add", str(target_file), str(test_file_a), str(test_file_b)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: alpha.txt" in result.stderr - assert "[+] Adding file: beta.txt" in result.stderr - - expected_content = { - "enUS bytes", - "enUS dogs.txt", - "enUS cats.txt", - "enUS alpha.txt", - "enUS beta.txt", - } - verify_archive_file_content(binary_path, target_file, expected_content) - - -def test_add_file_with_path_to_mpq_archive(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "files.mpq" - - create_mpq_archive_for_test(binary_path, script_dir) - - test_file = script_dir / "data" / "test.txt" - test_file.write_text("This is a test file for MPQ addition.") - - result = subprocess.run( - [str(binary_path), "add", str(target_file), str(test_file), "--path", "important\\message.txt"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - result = subprocess.run( - [str(binary_path), "list", str(target_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - - expected_output = { - "cats.txt", - "dogs.txt", - "bytes", - "important\\message.txt", - } - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_add_several_files_with_path_to_mpq_archive(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "files.mpq" - - create_mpq_archive_for_test(binary_path, script_dir) - - test_file0 = script_dir / "data" / "test0.txt" - test_file0.write_text("This is a test file for MPQ addition.") - test_file1 = script_dir / "data" / "test1.txt" - test_file1.write_text("This is another test file for MPQ addition.") - - result = subprocess.run( - [str(binary_path), "add", str(target_file), str(test_file0), str(test_file1), "--path", "texts"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - result = subprocess.run( - [str(binary_path), "list", str(target_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - - expected_output = { - "cats.txt", - "dogs.txt", - "bytes", - "texts\\test0.txt", - "texts\\test1.txt", - } - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_add_existing_file_without_overwrite_should_skip(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "files.mpq" - - create_mpq_archive_for_test(binary_path, script_dir) - - expected_content = {"This is a file about cats."} - verify_file_in_mpq_has_content(binary_path, target_file, "cats.txt", expected_content) - - test_file = script_dir / "data" / "cats.txt" - test_file.write_text("Attempting to make this file about dogs.") - - result = subprocess.run( - [str(binary_path), "add", str(target_file), str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - assert output_lines == set(), f"Unexpected output: {output_lines}" - - output_lines = set(result.stderr.splitlines()) - expected_stderr_output = { - "[!] File already exists in MPQ archive: cats.txt - Skipping...", - "[*] 1 file(s) already in the archive were skipped. Use --overwrite to replace them, " - "or --update to replace only the ones that changed.", - } - assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" - - # Skipping a pre-existing file is the documented default, not a failure. - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - verify_file_in_mpq_has_content(binary_path, target_file, "cats.txt", expected_content) - - -def test_add_existing_file_with_overwrite_should_succeed(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "files.mpq" - - create_mpq_archive_for_test(binary_path, script_dir) - - verify_file_in_mpq_has_content(binary_path, target_file, "cats.txt", { "This is a file about cats." }) - - test_file = script_dir / "data" / "cats.txt" - test_file.write_text("This file is suddenly about dogs.") - - result = subprocess.run( - [str(binary_path), "add", str(target_file), str(test_file), "--overwrite"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - expected_stdout_output = set() - assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" - - output_lines = set(result.stderr.splitlines()) - expected_stderr_output = { - "[~] File already exists in MPQ archive: cats.txt - Overwriting...", - "[+] Adding file: cats.txt", - } - assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - verify_file_in_mpq_has_content(binary_path, target_file, "cats.txt", { "This file is suddenly about dogs." }) - - -def test_add_nonexisting_file_with_overwrite_should_succeed(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "files.mpq" - - create_mpq_archive_for_test(binary_path, script_dir) - - test_file = script_dir / "data" / "test.txt" - test_file.write_text("This file is newly added.") - - result = subprocess.run( - [str(binary_path), "add", str(target_file), str(test_file), "--overwrite"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - expected_stdout_output = set() - assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" - - output_lines = set(result.stderr.splitlines()) - expected_stderr_output = { - "[+] Adding file: test.txt", - } - assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - verify_file_in_mpq_has_content(binary_path, target_file, "test.txt", { "This file is newly added." }) - - -def test_create_mpq_with_illegal_locale(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "files.mpq" - - create_mpq_archive_for_test(binary_path, script_dir) - - expected_content = { - "enUS bytes", - "enUS dogs.txt", - "enUS cats.txt", - } - verify_archive_file_content(binary_path, target_file, expected_content) - - test_file = script_dir / "data" / "horses.txt" - test_file.write_text("This is a file about horses.") - - result = subprocess.run( - [str(binary_path), "add", str(target_file), str(test_file), "--locale", "illegal_locale"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - - expected_content = { - "enUS bytes", - "enUS dogs.txt", - "enUS cats.txt", - } - verify_archive_file_content(binary_path, target_file, expected_content) - - -def test_create_mpq_with_locale(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "files.mpq" - - create_mpq_archive_for_test(binary_path, script_dir) - - test_file = script_dir / "data" / "cats.txt" - test_file.write_text("Este es un archivo sobre gatos.") - - result = subprocess.run( - [str(binary_path), "add", str(target_file), str(test_file), "--locale", "esES"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - expected_stdout_output = set() - assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" - - output_lines = set(result.stderr.splitlines()) - expected_stderr_output = { - "[+] Adding file for locale esES: cats.txt", - } - assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - expected_content = { - "enUS bytes", - "enUS dogs.txt", - "enUS cats.txt", - "esES cats.txt", - } - verify_archive_file_content(binary_path, target_file, expected_content) - - -def test_add_file_with_game_profile(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "files.mpq" - - test_cases = [ - ("generic", "ce"), - ("diablo1", "ie"), - ("starcraft1", "ce2"), - ("wow1", "c"), - ("wow2", "cr"), - ("starcraft2", "c"), - ("diablo3", "c"), - ] - - for profile, expected_flags in test_cases: - create_mpq_archive_for_test(binary_path, script_dir) - - test_file = script_dir / "data" / f"test_{profile}.txt" - test_file.write_text(f"Test file for {profile} profile.") - - result = subprocess.run( - [str(binary_path), "add", str(target_file), str(test_file), "--game", profile], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed for profile {profile}: {result.stderr}" - assert f"Using game profile: {profile}" in result.stderr, f"Game profile message not found for {profile}" - assert f"Adding file: test_{profile}.txt" in result.stderr - - list_result = subprocess.run( - [str(binary_path), "list", str(target_file), "-d", "--property", "flags"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert list_result.returncode == 0, f"Failed to list files for {profile}" - - found_test_file = False - for line in list_result.stdout.splitlines(): - if f"test_{profile}.txt" in line: - found_test_file = True - flags = line.split()[0] - for flag in expected_flags: - assert flag in flags, f"Profile {profile}: expected flag '{flag}' in '{flags}' for added file" - - assert found_test_file, f"Profile {profile}: added file not found in archive" - - -def test_add_file_with_invalid_game_profile(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "files.mpq" - - create_mpq_archive_for_test(binary_path, script_dir) - - test_file = script_dir / "data" / "test_invalid.txt" - test_file.write_text("Test file for invalid profile.") - - result = subprocess.run( - [str(binary_path), "add", str(target_file), str(test_file), "-g", "invalid_profile"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode != 0, "mpqcli should have failed with invalid game profile" - - -def test_add_file_with_all_game_profiles(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "files.mpq" - - all_profiles = [ - "generic", "diablo1", "lordsofmagic", "starcraft1", "warcraft2", "diablo2", - "warcraft3", "warcraft3-map", "wow1", "wow2", "wow3", "wow4", "wow5", - "starcraft2", "diablo3" - ] - - for profile in all_profiles: - create_mpq_archive_for_test(binary_path, script_dir) - - test_file = script_dir / "data" / f"test_all_{profile}.txt" - test_file.write_text(f"Test file for {profile} profile.") - - result = subprocess.run( - [str(binary_path), "add", str(target_file), str(test_file), "-g", profile], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed for profile {profile}: {result.stderr}" - assert f"Using game profile: {profile}" in result.stderr, f"Game profile message not found for {profile}" - - list_result = subprocess.run( - [str(binary_path), "list", str(target_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert list_result.returncode == 0, f"Failed to list files for {profile}" - assert f"test_all_{profile}.txt" in list_result.stdout, f"Profile {profile}: added file not found in archive" - - flags_result = subprocess.run( - [str(binary_path), "list", str(target_file), "-d", "--property", "flags"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert flags_result.returncode == 0, f"Failed to get flags for {profile}" - - found_with_compression = False - for line in flags_result.stdout.splitlines(): - if f"test_all_{profile}.txt" in line: - flags = line.split()[0] - if 'c' in flags or 'i' in flags: - found_with_compression = True - break - - assert found_with_compression, f"Profile {profile}: no compression flag found on added file" - - -def test_add_existing_locale_file_with_overwrite_should_succeed(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "files.mpq" - - create_mpq_archive_for_test(binary_path, script_dir) - - test_file = script_dir / "data" / "cats.txt" - test_file.write_text("Este es un archivo sobre gatos.") - - result = subprocess.run( - [str(binary_path), "add", str(target_file), str(test_file), "--locale", "esES"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"Initial locale add failed: {result.stderr}" - - test_file.write_text("Este archivo ahora es sobre perros.") - - result = subprocess.run( - [str(binary_path), "add", str(target_file), str(test_file), - "--locale", "esES", "--overwrite"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - expected_stderr = { - "[~] File for locale esES already exists in MPQ archive: cats.txt - Overwriting...", - "[+] Adding file for locale esES: cats.txt", - } - output_lines = set(result.stderr.splitlines()) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" - - verify_file_in_mpq_has_content( - binary_path, target_file, "cats.txt", - {"Este archivo ahora es sobre perros."}, - locale="esES" - ) - - verify_file_in_mpq_has_content( - binary_path, target_file, "cats.txt", - {"This is a file about cats."} - ) - - -def test_add_file_after_all_locale_variants_removed(binary_path, generate_locales_mpq_test_files): - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "mpq_with_many_locales.mpq" - - for locale in ["", "deDE", "esES", "041D"]: - cmd = [str(binary_path), "remove", str(target_file), "cats.txt"] - if locale: - cmd += ["--locale", locale] - result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - assert result.returncode == 0, f"Failed to remove locale '{locale}': {result.stderr}" - - list_result = subprocess.run( - [str(binary_path), "list", str(target_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert "cats.txt" not in list_result.stdout, \ - "cats.txt still present after removing all locales" - - test_file = script_dir / "data" / "locale_files" / "cats.txt" - test_file.write_text("This is a file about cats.") - - result = subprocess.run( - [str(binary_path), "add", str(target_file), str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - expected_stderr = {"[+] Adding file: cats.txt"} - output_lines = set(result.stderr.splitlines()) - - assert result.returncode == 0, f"Re-add after full removal failed: {result.stderr}" - assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" - - verify_file_in_mpq_has_content( - binary_path, target_file, "cats.txt", - {"This is a file about cats."} - ) - - -def test_add_directory_to_mpq_archive(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - add_dir = script_dir / "data" / "add_dir" - - create_mpq_archive_for_test(binary_path, script_dir) - - sub_dir = add_dir / "sub" - sub_dir.mkdir(parents=True, exist_ok=True) - (sub_dir / "a.txt").write_text("Sub file A.") - (add_dir / "b.txt").write_text("Root file B.") - - try: - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), str(add_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: sub\\a.txt" in result.stderr - assert "[+] Adding file: b.txt" in result.stderr - - list_result = subprocess.run( - [str(binary_path), "list", str(target_mpq)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert "sub\\a.txt" in list_result.stdout - assert "b.txt" in list_result.stdout - finally: - shutil.rmtree(add_dir, ignore_errors=True) - - -def test_add_directory_with_path_prefix(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - add_dir = script_dir / "data" / "add_dir_prefix" - - create_mpq_archive_for_test(binary_path, script_dir) - - sub_dir = add_dir / "sub" - sub_dir.mkdir(parents=True, exist_ok=True) - (sub_dir / "a.txt").write_text("Sub file A.") - (add_dir / "b.txt").write_text("Root file B.") - - try: - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), str(add_dir), "--path", "textures"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - list_result = subprocess.run( - [str(binary_path), "list", str(target_mpq)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - output_lines = set(list_result.stdout.splitlines()) - assert "textures\\sub\\a.txt" in output_lines - assert "textures\\b.txt" in output_lines - assert "sub\\a.txt" not in output_lines - finally: - shutil.rmtree(add_dir, ignore_errors=True) - - -def test_add_directory_with_overwrite(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - add_dir = script_dir / "data" / "add_dir_overwrite" - - create_mpq_archive_for_test(binary_path, script_dir) - - verify_file_in_mpq_has_content(binary_path, target_mpq, "cats.txt", {"This is a file about cats."}) - - add_dir.mkdir(parents=True, exist_ok=True) - (add_dir / "cats.txt").write_text("This is the replaced cats file.") - - try: - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), str(add_dir), "--overwrite"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] File already exists in MPQ archive: cats.txt - Overwriting..." in result.stderr - - verify_file_in_mpq_has_content(binary_path, target_mpq, "cats.txt", {"This is the replaced cats file."}) - finally: - shutil.rmtree(add_dir, ignore_errors=True) - - -def test_add_directory_without_overwrite_skips_existing(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - add_dir = script_dir / "data" / "add_dir_conflict" - - create_mpq_archive_for_test(binary_path, script_dir) - - original_content = {"This is a file about cats."} - verify_file_in_mpq_has_content(binary_path, target_mpq, "cats.txt", original_content) - - add_dir.mkdir(parents=True, exist_ok=True) - (add_dir / "cats.txt").write_text("Conflicting cats content.") - - try: - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), str(add_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - # Skipping pre-existing files is the documented default, not a failure. - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[!] File already exists in MPQ archive: cats.txt - Skipping..." in result.stderr - assert "Use --overwrite to replace them" in result.stderr - - verify_file_in_mpq_has_content(binary_path, target_mpq, "cats.txt", original_content) - finally: - shutil.rmtree(add_dir, ignore_errors=True) - - -def test_add_update_skips_unchanged_files(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - update_dir = script_dir / "data" / "update_dir_unchanged" - - create_mpq_archive_with_attrs_for_test(binary_path, script_dir) - - update_dir.mkdir(parents=True, exist_ok=True) - (update_dir / "cats.txt").write_text("This is a file about cats.\n") - (update_dir / "dogs.txt").write_text("This is a file about dogs.\n") - - try: - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), str(update_dir), "--update"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[*] Skipping unchanged file: cats.txt (MD5 matches)" in result.stderr - assert "[*] Skipping unchanged file: dogs.txt (MD5 matches)" in result.stderr - assert "files added" in result.stderr - assert "files skipped" in result.stderr - finally: - shutil.rmtree(update_dir, ignore_errors=True) - - -def test_add_update_adds_changed_files(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - update_dir = script_dir / "data" / "update_dir_changed" - - create_mpq_archive_with_attrs_for_test(binary_path, script_dir) - - update_dir.mkdir(parents=True, exist_ok=True) - (update_dir / "cats.txt").write_text("This cat content is completely different and longer now.") - (update_dir / "dogs.txt").write_text("This is a file about dogs.\n") - - try: - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), str(update_dir), "--update"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: cats.txt" in result.stderr - assert "[*] Skipping unchanged file: dogs.txt" in result.stderr - assert "1 files added" in result.stderr - assert "1 files skipped" in result.stderr - - verify_file_in_mpq_has_content( - binary_path, target_mpq, "cats.txt", - {"This cat content is completely different and longer now."} - ) - finally: - shutil.rmtree(update_dir, ignore_errors=True) - - -def test_add_update_second_run_skips_all(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - update_dir = script_dir / "data" / "update_dir_idempotent" - - create_mpq_archive_with_attrs_for_test(binary_path, script_dir) - - update_dir.mkdir(parents=True, exist_ok=True) - (update_dir / "cats.txt").write_text("This is a file about cats.\n") - - try: - subprocess.run( - [str(binary_path), "add", str(target_mpq), str(update_dir), "--update"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True - ) - - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), str(update_dir), "--update"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file:" not in result.stderr - assert "0 files added" in result.stderr - finally: - shutil.rmtree(update_dir, ignore_errors=True) - - -def test_add_update_single_file_skips_when_unchanged(binary_path, generate_test_files): - """--update applies the same change detection to a single file as to a directory.""" - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - work_dir = script_dir / "data" / "update_single_unchanged" - - create_mpq_archive_with_attrs_for_test(binary_path, script_dir) - - # Same content as the archived copy, written fresh so the timestamp differs - # and the decision falls through to the MD5 comparison. - work_dir.mkdir(parents=True, exist_ok=True) - (work_dir / "cats.txt").write_text("This is a file about cats.\n") - - try: - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), str(work_dir / "cats.txt"), "--update"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[*] Skipping unchanged file: cats.txt (MD5 matches)" in result.stderr - assert "[+] Adding file: cats.txt" not in result.stderr - finally: - shutil.rmtree(work_dir, ignore_errors=True) - - -def test_add_update_single_file_replaces_when_changed(binary_path, generate_test_files): - """The single-file path must still replace a file whose content changed.""" - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - work_dir = script_dir / "data" / "update_single_changed" - - create_mpq_archive_with_attrs_for_test(binary_path, script_dir) - - work_dir.mkdir(parents=True, exist_ok=True) - (work_dir / "cats.txt").write_text("This cat content is completely different and longer now.") - - try: - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), str(work_dir / "cats.txt"), "--update"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: cats.txt" in result.stderr - - verify_file_in_mpq_has_content( - binary_path, target_mpq, "cats.txt", - {"This cat content is completely different and longer now."} - ) - finally: - shutil.rmtree(work_dir, ignore_errors=True) - - -def test_add_update_and_overwrite_are_mutually_exclusive(binary_path, generate_test_files): - """--overwrite and --update are two points on one axis, so CLI11 must reject both.""" - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - - create_mpq_archive_for_test(binary_path, script_dir) - - test_file = script_dir / "data" / "files" / "cats.txt" - - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), str(test_file), "--update", "--overwrite"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode != 0, "Expected mpqcli to reject --update with --overwrite" - assert "excludes" in result.stderr - - -def test_add_update_skips_unchanged_files_via_crc32(binary_path, generate_test_files): - """CRC32 branch: archive has CRC32+FILETIME but no MD5 (wc3 profile). - Unchanged file must be skipped with reason 'CRC32 matches'. - The file is written fresh (new mtime) so the timestamp check fails first, - forcing the code to fall through to the CRC32 comparison.""" - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - update_dir = script_dir / "data" / "update_dir_crc32_unchanged" - - create_mpq_archive_with_crc32_for_test(binary_path, script_dir) - - update_dir.mkdir(parents=True, exist_ok=True) - (update_dir / "cats.txt").write_text("This is a file about cats.\n") - - try: - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), str(update_dir), "--update"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[*] Skipping unchanged file: cats.txt (CRC32 matches)" in result.stderr - assert "files skipped" in result.stderr - finally: - shutil.rmtree(update_dir, ignore_errors=True) - - -def test_add_update_adds_changed_files_via_crc32(binary_path, generate_test_files): - """CRC32 branch: archive has CRC32+FILETIME but no MD5 (wc3 profile). - A file with the same size but different content (different CRC32) must be re-added.""" - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - update_dir = script_dir / "data" / "update_dir_crc32_changed" - - create_mpq_archive_with_crc32_for_test(binary_path, script_dir) - - update_dir.mkdir(parents=True, exist_ok=True) - # Same byte-length as "This is a file about cats.\n" (27 bytes) but different content. - (update_dir / "cats.txt").write_text("This is a file about CATS.\n") - - try: - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), str(update_dir), "--update"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: cats.txt" in result.stderr - assert "Skipping unchanged" not in result.stderr - finally: - shutil.rmtree(update_dir, ignore_errors=True) - - -def test_add_update_skips_unchanged_files_via_timestamp(binary_path, generate_test_files): - """Timestamp branch: archive has FILETIME only (no CRC32, no MD5). - Unchanged file (same mtime) must be skipped with reason 'Timestamp matches'.""" - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - update_dir = script_dir / "data" / "update_dir_ts_unchanged" - - create_mpq_archive_with_filetime_for_test(binary_path, script_dir) - - update_dir.mkdir(parents=True, exist_ok=True) - dst = update_dir / "cats.txt" - # Preserve the original mtime so the timestamp comparison matches. - shutil.copy2(script_dir / "data" / "files" / "cats.txt", dst) - - try: - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), str(update_dir), "--update"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[*] Skipping unchanged file: cats.txt (Timestamp matches)" in result.stderr - assert "files skipped" in result.stderr - finally: - shutil.rmtree(update_dir, ignore_errors=True) - - -def test_add_update_adds_changed_files_via_timestamp(binary_path, generate_test_files): - """Timestamp branch: archive has FILETIME only (no CRC32, no MD5). - A file with the same size but a different mtime must be re-added.""" - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - update_dir = script_dir / "data" / "update_dir_ts_changed" - - create_mpq_archive_with_filetime_for_test(binary_path, script_dir) - - update_dir.mkdir(parents=True, exist_ok=True) - dst = update_dir / "cats.txt" - shutil.copy2(script_dir / "data" / "files" / "cats.txt", dst) - # Shift the mtime by one hour so the timestamp no longer matches. - original_mtime = os.path.getmtime(dst) - os.utime(dst, (original_mtime + 3600, original_mtime + 3600)) - - try: - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), str(update_dir), "--update"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: cats.txt" in result.stderr - assert "Skipping unchanged" not in result.stderr - finally: - shutil.rmtree(update_dir, ignore_errors=True) - - -def test_add_update_always_adds_without_attributes(binary_path, generate_test_files): - """No-attributes branch: archive has no (attributes) file. - Even an identical file must be re-added because there is nothing to compare.""" - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - update_dir = script_dir / "data" / "update_dir_no_attrs" - - create_mpq_archive_for_test(binary_path, script_dir) - - update_dir.mkdir(parents=True, exist_ok=True) - (update_dir / "cats.txt").write_text("This is a file about cats.\n") - - try: - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), str(update_dir), "--update"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: cats.txt" in result.stderr - assert "Skipping unchanged" not in result.stderr - finally: - shutil.rmtree(update_dir, ignore_errors=True) - - -def test_add_files_via_stdin(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - - create_mpq_archive_for_test(binary_path, script_dir) - - test_file_a = script_dir / "data" / "stdin_a.txt" - test_file_b = script_dir / "data" / "stdin_b.txt" - test_file_a.write_text("Stdin file A.") - test_file_b.write_text("Stdin file B.") - - stdin_input = f"{test_file_a}\n{test_file_b}\n" - - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), "-"], - input=stdin_input, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: stdin_a.txt" in result.stderr - assert "[+] Adding file: stdin_b.txt" in result.stderr - - list_result = subprocess.run( - [str(binary_path), "list", str(target_mpq)], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True - ) - assert "stdin_a.txt" in list_result.stdout - assert "stdin_b.txt" in list_result.stdout - - -def test_add_files_via_stdin_with_windows_line_endings(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - target_mpq = script_dir / "data" / "files.mpq" - - create_mpq_archive_for_test(binary_path, script_dir) - - test_file_a = script_dir / "data" / "stdin_a.txt" - test_file_b = script_dir / "data" / "stdin_b.txt" - test_file_a.write_text("Stdin file A.") - test_file_b.write_text("Stdin file B.") - - stdin_input = f"{test_file_a}\r\n{test_file_b}\r\n" - - result = subprocess.run( - [str(binary_path), "add", str(target_mpq), "-"], - input=stdin_input, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: stdin_a.txt" in result.stderr - assert "[+] Adding file: stdin_b.txt" in result.stderr - - -def test_add_skips_the_archive_inside_an_input_directory(binary_path, tmp_path): - input_dir = tmp_path / "mod" - input_dir.mkdir() - (input_dir / "a.txt").write_text("A file.") - other_file = tmp_path / "b.txt" - other_file.write_text("B file.") - archive_file = input_dir / "mod.mpq" - - result = subprocess.run( - [str(binary_path), "create", str(archive_file), str(other_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"Failed to create archive: {result.stderr}" - - result = subprocess.run( - [str(binary_path), "add", str(archive_file), str(input_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "Skipping the archive itself" in result.stderr - - list_result = subprocess.run( - [str(binary_path), "list", str(archive_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert set(list_result.stdout.splitlines()) == {"a.txt", "b.txt"} - - -def test_add_skips_the_archive_named_as_an_input(binary_path, tmp_path): - input_file = tmp_path / "a.txt" - input_file.write_text("A file.") - archive_file = tmp_path / "a.mpq" - - result = subprocess.run( - [str(binary_path), "create", str(archive_file), str(input_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"Failed to create archive: {result.stderr}" - - result = subprocess.run( - [str(binary_path), "add", str(archive_file), str(archive_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "Skipping the archive itself" in result.stderr - - list_result = subprocess.run( - [str(binary_path), "list", str(archive_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert list_result.stdout.splitlines() == ["a.txt"] - - -def create_mpq_archive_for_test(binary_path, script_dir): - target_dir = script_dir / "data" / "files" - target_file = target_dir.with_suffix(".mpq") - target_file.unlink(missing_ok=True) - result = subprocess.run( - [str(binary_path), "create", "--format-version", "1", str(target_file), str(target_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert target_file.exists(), "MPQ file was not created" - assert target_file.stat().st_size > 0, "MPQ file is empty" - - -def create_mpq_archive_with_attrs_for_test(binary_path, script_dir): - """Like create_mpq_archive_for_test but uses the wow1 game profile so that - the archive includes a (attributes) file with CRC32, MD5, and FILETIME - checksums. Required by --update tests that rely on checksum comparison.""" - target_dir = script_dir / "data" / "files" - target_file = target_dir.with_suffix(".mpq") - target_file.unlink(missing_ok=True) - result = subprocess.run( - [str(binary_path), "create", "--game", "wow1", str(target_file), str(target_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert target_file.exists(), "MPQ file was not created" - assert target_file.stat().st_size > 0, "MPQ file is empty" - - -def create_mpq_archive_with_crc32_for_test(binary_path, script_dir): - """Creates an archive using the wc3 game profile, which stores CRC32 and - FILETIME attributes but no MD5. Used by --update tests that exercise the - CRC32 comparison branch.""" - target_dir = script_dir / "data" / "files" - target_file = target_dir.with_suffix(".mpq") - target_file.unlink(missing_ok=True) - result = subprocess.run( - [str(binary_path), "create", "--game", "wc3", str(target_file), str(target_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert target_file.exists(), "MPQ file was not created" - assert target_file.stat().st_size > 0, "MPQ file is empty" - - -def create_mpq_archive_with_filetime_for_test(binary_path, script_dir): - """Creates an archive with FILETIME attributes only (no CRC32, no MD5) by - using the wc3 game profile and overriding attr-flags to 2 (FILETIME only). - Used by --update tests that exercise the timestamp comparison branch.""" - target_dir = script_dir / "data" / "files" - target_file = target_dir.with_suffix(".mpq") - target_file.unlink(missing_ok=True) - result = subprocess.run( - [str(binary_path), "create", "--game", "wc3", "--attr-flags", "2", str(target_file), str(target_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert target_file.exists(), "MPQ file was not created" - assert target_file.stat().st_size > 0, "MPQ file is empty" - - -def verify_archive_file_content(binary_path, test_file, expected_output): - result = subprocess.run( - [str(binary_path), "list", str(test_file), "-d", "--property", "locale"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - -def verify_file_in_mpq_has_content(binary_path, mpq_archive, file_name, expected_content, locale=None): - cmd = [str(binary_path), "read", str(mpq_archive), file_name] - if locale is not None: - cmd += ["--locale", locale] - result = subprocess.run( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(line for line in result.stdout.splitlines() if line.strip() != "") - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_content, f"Unexpected output: {output_lines}" diff --git a/test/test_compact.py b/test/test_compact.py deleted file mode 100644 index 46ffcd9..0000000 --- a/test/test_compact.py +++ /dev/null @@ -1,169 +0,0 @@ -import random -import shutil -from pathlib import Path -import subprocess - - -def test_compact_nonexistent_file(binary_path): - """ - Test compacting non-existent MPQ file. - - This test checks: - - If the application exits correctly. - """ - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "non_existent_file.mpq" - - result = subprocess.run( - [str(binary_path), "compact", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode != 1, f"mpqcli failed with error: {result.stderr}" - - -def test_compact_nonmpq_file(binary_path): - """ - Test compacting illegal MPQ file. - - This test checks: - - If the application exits correctly. - """ - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "cats.txt" - - expected_prefix = "[!] Failed to open MPQ archive" - - result = subprocess.run( - [str(binary_path), "compact", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = result.stderr.splitlines() - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert len(output_lines) == 1, f"Unexpected output: {output_lines}" - assert output_lines[0].startswith(expected_prefix), f"Unexpected output: {output_lines}" - - -def test_compact_mpq_without_listfile(binary_path, generate_mpq_without_internal_listfile): - """ - Test compacting MPQ file with no internal listfile, - and no externally provided one. - - This test checks: - - That the application exits correctly and prints error message. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - - expected_prefix = "[!] Failed to compact archive: (10007) At least one file name is unknown (listfile is incomplete)" - - result = subprocess.run( - [str(binary_path), "compact", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = result.stderr.splitlines() - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert len(output_lines) == 2, f"Unexpected output: {output_lines}" - assert output_lines[1].startswith(expected_prefix), f"Unexpected output: {output_lines}" - - -def test_compact_mpq_with_listfile(binary_path, generate_mpq_without_internal_listfile): - """ - Test compacting MPQ file with no internal listfile, - instead providing an external one. - - This test checks: - - That compaction works when the user provides an external listfile. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - listfile = script_dir / "data" / "listfile.txt" - listfile.write_text("cats.txt\ndogs.txt\ncapybaras.txt") - - expected_output = ["[*] Compacting archive. This may take some time..."] - - result = subprocess.run( - [str(binary_path), "compact", str(test_file), "--listfile", str(listfile)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = result.stderr.splitlines() - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_compact_file(binary_path): - """ - Test compacting an MPQ archive. - - This test checks: - - Creating an archive with a small file and a ~512 KB incompressible file - - Removing the larger file from the archive - - Compacting the archive - - Verifying that the archive size shrinks significantly afterwards - """ - script_dir = Path(__file__).parent - data_dir = script_dir / "data" - - compaction_files_dir = data_dir / "compaction_files" - shutil.rmtree(compaction_files_dir, ignore_errors=True) - compaction_files_dir.mkdir(parents=True, exist_ok=True) - - mpq_file = data_dir / "mpq_for_compaction.mpq" - mpq_file.unlink(missing_ok=True) - - small_file = compaction_files_dir / "small.txt" - small_file.write_text("This is a small text file.\n", newline="\n") - - large_file_name = "large.bin" - large_file = compaction_files_dir / large_file_name - large_file_size = 512 * 1024 - large_file.write_bytes(random.Random(0).randbytes(large_file_size)) - - result = subprocess.run( - [str(binary_path), "create", str(mpq_file), str(compaction_files_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"create failed: {result.stderr}" - - result = subprocess.run( - [str(binary_path), "remove", str(mpq_file), large_file_name], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"remove failed: {result.stderr}" - - size_before = mpq_file.stat().st_size - - result = subprocess.run( - [str(binary_path), "compact", str(mpq_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"compact failed: {result.stderr}" - - size_after = mpq_file.stat().st_size - - assert size_before - size_after > size_before / 2, ( - f"Expected compaction to shrink the archive significantly, " - f"but size went from {size_before} bytes to {size_after} bytes" - ) diff --git a/test/test_completion.py b/test/test_completion.py deleted file mode 100644 index 67a18be..0000000 --- a/test/test_completion.py +++ /dev/null @@ -1,150 +0,0 @@ -import subprocess -from pathlib import Path - - -def test_completion_bash(binary_path): - result = subprocess.run( - [str(binary_path), "completion", "bash"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "_mpqcli()" in result.stdout, "Bash completion function not found in output" - assert "complete -F _mpqcli mpqcli" in result.stdout, "Bash complete registration not found in output" - - -def test_completion_zsh(binary_path): - result = subprocess.run( - [str(binary_path), "completion", "zsh"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "#compdef mpqcli" in result.stdout, "Zsh compdef directive not found in output" - assert "_mpqcli()" in result.stdout, "Zsh completion function not found in output" - - -def test_completion_bash_output_size(binary_path): - result = subprocess.run( - [str(binary_path), "completion", "bash"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - script_path = Path(__file__).parent.parent / "completion" / "mpqcli.bash" - assert len(result.stdout) == len(script_path.read_text()) - - -def test_completion_zsh_output_size(binary_path): - result = subprocess.run( - [str(binary_path), "completion", "zsh"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - script_path = Path(__file__).parent.parent / "completion" / "mpqcli.zsh" - assert len(result.stdout) == len(script_path.read_text()) - - -def test_completion_powershell(binary_path): - result = subprocess.run( - [str(binary_path), "completion", "powershell"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "Register-ArgumentCompleter" in result.stdout, "PS completer registration not found in output" - assert "mpqcli" in result.stdout, "mpqcli reference not found in output" - - -def test_completion_powershell_output_size(binary_path): - result = subprocess.run( - [str(binary_path), "completion", "powershell"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - script_path = Path(__file__).parent.parent / "completion" / "mpqcli.ps1" - assert len(result.stdout) == len(script_path.read_text()) - - -def test_completion_fish(binary_path): - result = subprocess.run( - [str(binary_path), "completion", "fish"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "complete -c mpqcli" in result.stdout, "Fish complete directive not found in output" - - -def test_completion_fish_output_size(binary_path): - result = subprocess.run( - [str(binary_path), "completion", "fish"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - script_path = Path(__file__).parent.parent / "completion" / "mpqcli.fish" - assert len(result.stdout) == len(script_path.read_text()) - - -def test_completion_no_shell(binary_path): - result = subprocess.run( - [str(binary_path), "completion"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode != 0, "Expected non-zero exit code when no shell is specified" - - -def test_completion_invalid_shell(binary_path): - result = subprocess.run( - [str(binary_path), "completion", "nushell"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode != 0, "Expected non-zero exit code for unsupported shell" - assert result.stderr, "Expected error output for unsupported shell" - - -def test_completion_scripts_cover_every_subcommand(binary_path): - """Every subcommand must appear in all four completion scripts. A new - subcommand wired into only some of them is the failure this guards against.""" - subcommands = [ - "version", "about", "info", "create", "add", "remove", - "rename", "list", "extract", "read", "verify", "compact", - "completion", - ] - - for shell in ["bash", "zsh", "powershell", "fish"]: - result = subprocess.run( - [str(binary_path), "completion", shell], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - missing = [s for s in subcommands if s not in result.stdout] - assert not missing, f"{shell} completion is missing subcommands: {missing}" diff --git a/test/test_create.py b/test/test_create.py deleted file mode 100644 index 9b6636f..0000000 --- a/test/test_create.py +++ /dev/null @@ -1,1095 +0,0 @@ -import os -import platform -import subprocess -import shutil -from pathlib import Path - -import pytest - - -def test_create_mpq_target_does_not_exist(binary_path, generate_test_files): - """ - Test MPQ file creation with a non-existent target. - - This test checks: - - If the application exits correctly when the target does not exist. - """ - _ = generate_test_files - script_dir = Path(__file__).parent - target_dir = script_dir / "does" / "not" / "exist" - - result = subprocess.run( - [str(binary_path), "create", "--format-version", "1", str(target_dir.with_suffix(".mpq")), str(target_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert not target_dir.with_suffix(".mpq").exists(), "MPQ file was created" - - -def test_create_mpq_versions(binary_path, generate_test_files): - """ - Test MPQ archive creation with different MPQ versions. - - This test checks: - - MPQ archive creation with version 1 and version 2. - - If the MPQ archive is created in the correct directory. - - If the MPQ archive is not empty. - """ - _ = generate_test_files - script_dir = Path(__file__).parent - target_dir = script_dir / "data" / "files" - - for version in [1, 2, 3, 4]: - target_file = target_dir.with_suffix(".mpq") - # Remove the target file if it exists - # Testing creation when file exists is handled: - # test_create_mpq_already_exists - target_file.unlink(missing_ok=True) - result = subprocess.run( - [str(binary_path), "create", "--format-version", str(version), str(target_file), str(target_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error (version {version}): {result.stderr}" - assert target_file.exists(), f"MPQ file was not created (version {version})" - assert target_file.stat().st_size > 0, f"MPQ file is empty (version {version})" - - # Verify the correct version was written to the archive - version_result = subprocess.run( - [str(binary_path), "info", "--property", "format-version", str(target_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert version_result.returncode == 0 - assert version_result.stdout.strip() == str(version), \ - f"Expected version {version}, got {version_result.stdout.strip()}" - - -def test_create_mpq_with_output(binary_path, generate_test_files): - """ - Test MPQ archive creation with output file argument. - - This test checks: - - If the MPQ archive is created in the correct directory. - - If the MPQ archive is not empty. - """ - _ = generate_test_files - script_dir = Path(__file__).parent - target_dir = script_dir / "data" / "files" - - for version in [1, 2]: - output_file = script_dir / "data" / f"mpq_with_output_v{version}.mpq" - # Remove the target file if it exists - # Testing creation when file exists is handled: - # test_create_mpq_already_exists - if output_file.exists(): - output_file.unlink() - - result = subprocess.run( - [ - str(binary_path), "create", - "--format-version", str(version), - "--file-flags1", "4294967295", - "--file-flags2", "4294967295", - "--file-flags3", "0", - "--attr-flags", "15", - "--flags", "66048", - "--compression", "2", - "--compression-next", "4294967295", - str(output_file), - str(target_dir), - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_file.exists(), "MPQ file was not created" - assert output_file.stat().st_size > 0, "MPQ file is empty" - - -def test_create_mpq_with_weak_signature(binary_path, generate_test_files): - """ - Test MPQ archive creation with signature and output file argument. - - This test checks: - - If the MPQ archive is created in the correct directory. - - If the MPQ archive is not empty. - - If the MPQ archive is signed correctly. - """ - _ = generate_test_files - script_dir = Path(__file__).parent - target_dir = script_dir / "data" / "files" - - output_file = script_dir / "data" / "mpq_with_weak_signature.mpq" - # Remove the target file if it exists - # Testing creation when file exists is handled in following function: - # test_create_mpq_already_exists - if output_file.exists(): - output_file.unlink() - - result = subprocess.run( - [ - str(binary_path), "create", "-s", - "--file-flags1", "4294967295", - "--file-flags2", "4294967295", - "--file-flags3", "0", - "--attr-flags", "15", - "--flags", "512", - "--compression", "2", - "--compression-next", "4294967295", - str(output_file), - str(target_dir), - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_file.exists(), "MPQ file was not created" - assert output_file.stat().st_size > 0, "MPQ file is empty" - - # Verify the signature is actually valid - verify_result = subprocess.run( - [str(binary_path), "verify", str(output_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert verify_result.returncode == 0, f"Signature verification failed: {verify_result.stderr}" - assert "[*] Verify success" in verify_result.stderr, \ - f"Unexpected verify output: {verify_result.stderr}" - - -def test_create_mpq_already_exists(binary_path, generate_test_files, tmp_path): - """ - Test MPQ file creation with an existing output file. - - This test checks: - - If the MPQ archive is not created (as archive exists already). - - That the existing file is left as it was. - """ - _ = generate_test_files - script_dir = Path(__file__).parent - target_dir = script_dir / "data" / "files" - - output_file = tmp_path / "existing.mpq" - output_file.write_bytes(b"Not an archive") - - result = subprocess.run( - [str(binary_path), "create", "--format-version", "1", str(output_file), str(target_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - assert output_file.read_bytes() == b"Not an archive" - - -def test_create_mpq_from_file(binary_path, generate_test_files): - """ - Test MPQ archive creation from a file rather than a directory. - - This test checks: - - MPQ archive creation from a file. - """ - _ = generate_test_files - script_dir = Path(__file__).parent - - # Create a new test file on the fly - test_file = script_dir / "data" / "test.txt" - test_file.write_text("This is a test file for MPQ addition.") - - target_file = test_file.with_suffix(".mpq") - # Remove the target file if it exists - # Testing creation when file exists is handled: - # test_create_mpq_already_exists - target_file.unlink(missing_ok=True) - result = subprocess.run( - [str(binary_path), "create", str(target_file), str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert target_file.exists(), "MPQ file was not created" - assert target_file.stat().st_size > 0, "MPQ file is empty" - - verify_archive_file_content(binary_path, target_file, {"enUS test.txt"}) - - -def test_create_mpq_from_file_with_path_parameter(binary_path, generate_test_files): - """ - Test MPQ archive creation from a file, with the --path parameter. - This test checks: - - MPQ archive creation from a file. - - That the --path parameter is correctly handled. - """ - _ = generate_test_files - script_dir = Path(__file__).parent - - # Create a new test file on the fly - test_file = script_dir / "data" / "test.txt" - test_file.write_text("This is a test file for MPQ addition.") - - target_file = test_file.with_suffix(".mpq") - # Remove the target file if it exists - # Testing creation when file exists is handled: - # test_create_mpq_already_exists - target_file.unlink(missing_ok=True) - result = subprocess.run( - [str(binary_path), "create", str(target_file), str(test_file), "--path", "messages\\important.txt"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert target_file.exists(), "MPQ file was not created" - assert target_file.stat().st_size > 0, "MPQ file is empty" - - verify_archive_file_content(binary_path, target_file, {"enUS messages\\important.txt"}) - - -def test_create_mpq_from_directory_with_path_parameter(binary_path, generate_test_files): - """ - Test MPQ archive creation from a directory, with the --path parameter. - This test checks: - - No MPQ archive is created. - """ - _ = generate_test_files - script_dir = Path(__file__).parent - target_dir = script_dir / "data" / "files2" - # Create test directory with files - target_dir.mkdir(exist_ok=True) - - # Create a new test file on the fly - test_file = script_dir / "data" / "files2" / "important.txt" - test_file.write_text("This is a test file for MPQ addition.") - - try: - target_file = target_dir.with_name("files_test").with_suffix(".mpq") - # Remove the target file if it exists - # Testing creation when file exists is handled: - # test_create_mpq_already_exists - target_file.unlink(missing_ok=True) - result = subprocess.run( - [str(binary_path), "create", str(target_file), str(target_dir), "--path", "messages"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert target_file.exists(), "MPQ file was not created" - assert target_file.stat().st_size > 0, "MPQ file is empty" - - verify_archive_file_content(binary_path, target_file, {"enUS messages\\important.txt"}) - finally: - # Clean up - if target_dir.exists(): - shutil.rmtree(target_dir) - - -def test_create_mpq_with_illegal_locale(binary_path, generate_test_files): - """ - Test MPQ file creation with illegal locale. - - This test checks: - - That the MPQ archive is not created - """ - _ = generate_test_files - script_dir = Path(__file__).parent - target_dir = script_dir / "data" / "files" - - output_file = script_dir / "data" / "new_mpq.mpq" - - result = subprocess.run( - [str(binary_path), "create", "--format-version", "1", str(output_file), str(target_dir), "--locale", "illegal_locale"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - assert not output_file.exists(), "MPQ file was created" - - -def test_create_mpq_with_locale(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ file creation with a given locale. - - This test checks: - - That the MPQ archive contains the expected file with the expected locale. - """ - - # Creating an MPQ with a given locale happens in conftest.py. This test only checks the output. - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - output_file = script_dir / "data" / "mpq_with_one_locale.mpq" - - verify_archive_file_content(binary_path, output_file, {"esES cats.txt"}) - - -def test_create_mpq_with_game_profile(binary_path, generate_test_files): - """ - Test MPQ archive creation with different game profiles. - - This test checks: - - If the MPQ archive is created with various game profiles. - - If the game profile is accepted and applied. - - If the correct MPQ version is used for each profile. - - If the correct compression flags are applied. - """ - _ = generate_test_files - script_dir = Path(__file__).parent - target_dir = script_dir / "data" / "files" - - # Test profiles with their expected MPQ versions and expected flags for .txt files - test_cases = [ - ("generic", "1", "ce"), # Generic: version 1, compressed + encrypted - ("diablo1", "1", "ie"), # Diablo1: version 1, imploded + encrypted - ("starcraft1", "1", "ce2"), # StarCraft: version 1, compressed + encrypted + key v2 - ("warcraft3", "1", "ce2"), # Warcraft3: version 1, compressed + encrypted + key v2 - ("diablo2", "1", "c"), # Diablo2: version 1, compressed (txt files not encrypted) - ("wow1", "1", "c"), # WoW 1.x: version 1, compressed - ("wow2", "2", "cr"), # WoW 2.x: version 2, compressed + sector CRC - ("starcraft2", "2", "c"), # StarCraft2: version 2, compressed (small files use single unit) - ("diablo3", "4", "c"), # Diablo3: version 4, compressed - ] - - for profile, expected_version, expected_flags in test_cases: - output_file = script_dir / "data" / f"mpq_with_profile_{profile}.mpq" - output_file.unlink(missing_ok=True) - - result = subprocess.run( - [str(binary_path), "create", "-g", profile, str(output_file), str(target_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error for profile {profile}: {result.stderr}" - assert output_file.exists(), f"MPQ file was not created for profile {profile}" - assert output_file.stat().st_size > 0, f"MPQ file is empty for profile {profile}" - assert f"[*] Game profile: {profile}" in result.stderr, f"Game profile message not found for {profile}" - - # Verify the MPQ version - version_result = subprocess.run( - [str(binary_path), "info", "--property", "format-version", str(output_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert version_result.returncode == 0, f"Failed to get version for {profile}" - actual_version = version_result.stdout.strip() - assert actual_version == expected_version, f"Profile {profile}: expected version {expected_version}, got {actual_version}" - - # Verify compression flags on a .txt file - list_result = subprocess.run( - [str(binary_path), "list", str(output_file), "-d", "--property", "flags"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert list_result.returncode == 0, f"Failed to list files for {profile}" - - # Check that at least one .txt file has the expected flags - found_txt_file = False - for line in list_result.stdout.splitlines(): - if "cats.txt" in line or "dogs.txt" in line: - found_txt_file = True - flags = line.split()[0] # Extract flags - # Check that expected flags are present in the actual flags - for flag in expected_flags: - assert flag in flags, f"Profile {profile}: expected flag '{flag}' in '{flags}' for .txt file" - - assert found_txt_file, f"Profile {profile}: no .txt file found in archive" - - -def test_create_mpq_with_invalid_game_profile(binary_path, generate_test_files): - """ - Test MPQ archive creation with an invalid game profile. - - This test checks: - - If the application exits correctly when an invalid game profile is provided. - """ - _ = generate_test_files - script_dir = Path(__file__).parent - target_dir = script_dir / "data" / "files" - output_file = script_dir / "data" / "mpq_with_invalid_profile.mpq" - - result = subprocess.run( - [str(binary_path), "create", "-g", "invalid_profile", str(output_file), str(target_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode != 0, "mpqcli should have failed with invalid game profile" - assert not output_file.exists(), "MPQ file should not be created with invalid profile" - - -def test_create_mpq_with_all_game_profiles(binary_path, generate_test_files): - """ - Test MPQ archive creation with all available game profiles. - - This test checks: - - If all game profiles are valid. - - If the MPQ archive is created successfully for each profile. - - If the correct MPQ version is used for each profile. - """ - _ = generate_test_files - script_dir = Path(__file__).parent - target_dir = script_dir / "data" / "files" - - # All profiles with their expected MPQ versions - profile_versions = { - "generic": "1", - "diablo1": "1", - "lordsofmagic": "1", - "starcraft1": "1", - "warcraft2": "1", - "diablo2": "1", - "warcraft3": "1", - "warcraft3-map": "1", - "wow1": "1", - "wow2": "2", - "wow3": "2", - "wow4": "4", - "wow5": "4", - "starcraft2": "2", - "diablo3": "4" - } - - for profile, expected_version in profile_versions.items(): - output_file = script_dir / "data" / f"mpq_all_profiles_{profile}.mpq" - output_file.unlink(missing_ok=True) - - result = subprocess.run( - [str(binary_path), "create", "-g", profile, str(output_file), str(target_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed for profile {profile}: {result.stderr}" - assert output_file.exists(), f"MPQ file was not created for profile {profile}" - assert f"[*] Game profile: {profile}" in result.stderr, f"Game profile message not found for {profile}" - - # Verify the MPQ version - version_result = subprocess.run( - [str(binary_path), "info", "--property", "format-version", str(output_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert version_result.returncode == 0, f"Failed to get version for {profile}" - actual_version = version_result.stdout.strip() - assert actual_version == expected_version, f"Profile {profile}: expected version {expected_version}, got {actual_version}" - - -def test_deletion_marker_only_for_zero_size_files(binary_path): - """ - Test that deletion marker is only applied to files with size 0, not all small files. - """ - script_dir = Path(__file__).parent - data_dir = script_dir / "data" - test_dir = data_dir / "deletion_marker_test" - output_file = data_dir / "test_deletion_marker.mpq" - - # Clean up from previous runs - output_file.unlink(missing_ok=True) - if test_dir.exists(): - shutil.rmtree(test_dir) - - # Create test directory with files - test_dir.mkdir(exist_ok=True) - - # Create test files: one with size 0, one with non-zero size - zero_size_file = test_dir / "zero_size.txt" - nonzero_size_file = test_dir / "nonzero_size.txt" - - # Create a truly empty file (0 bytes) - zero_size_file.touch() - nonzero_size_file.write_text("Hello") # 5 bytes - - # Verify file sizes - assert zero_size_file.stat().st_size == 0, f"zero_size.txt should be 0 bytes, got {zero_size_file.stat().st_size}" - assert nonzero_size_file.stat().st_size == 5, f"nonzero_size.txt should be 5 bytes, got {nonzero_size_file.stat().st_size}" - - try: - # Create MPQ with StarCraft2 profile (which has deletion marker rule for size 0) - result = subprocess.run( - [str(binary_path), "create", "-g", "starcraft2", str(output_file), str(test_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"Failed to create MPQ: {result.stderr}\nStdout: {result.stdout}" - - # Check flags for both files - list_result = subprocess.run( - [str(binary_path), "list", str(output_file), "-d", "--property", "flags"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert list_result.returncode == 0, f"Failed to list files: {list_result.stderr}" - - # Parse the output - zero_size_flags = None - nonzero_size_flags = None - - for line in list_result.stdout.splitlines(): - parts = line.split() - if len(parts) >= 2: - flags = parts[0] - filename = parts[1] - - if filename == "zero_size.txt": - zero_size_flags = flags - elif filename == "nonzero_size.txt": - nonzero_size_flags = flags - - # Verify that zero-size file has deletion marker - assert zero_size_flags is not None, "zero_size.txt not found in archive" - assert 'd' in zero_size_flags, f"Expected deletion marker 'd' in flags for zero_size.txt, got: {zero_size_flags}" - - # Verify that non-zero size file does NOT have deletion marker but has compression - assert nonzero_size_flags is not None, "nonzero_size.txt not found in archive" - assert 'd' not in nonzero_size_flags, f"Unexpected deletion marker 'd' in flags for nonzero_size.txt, got: {nonzero_size_flags}" - assert 'c' in nonzero_size_flags, f"Expected compression 'c' in flags for nonzero_size.txt, got: {nonzero_size_flags}" - - finally: - # Clean up - if test_dir.exists(): - shutil.rmtree(test_dir) - output_file.unlink(missing_ok=True) - - -def test_create_mpq_no_sign_flag_has_no_signature(binary_path, generate_test_files): - """ - Test that a newly created archive without --sign does not have a weak signature slot. - - This test checks: - - If the MPQ archive is created successfully. - - If the signature type is None (no signature slot pre-allocated). - """ - _ = generate_test_files - script_dir = Path(__file__).parent - target_dir = script_dir / "data" / "files" - - output_file = script_dir / "data" / "mpq_no_sign.mpq" - output_file.unlink(missing_ok=True) - - result = subprocess.run( - [str(binary_path), "create", str(output_file), str(target_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_file.exists(), "MPQ file was not created" - - info = subprocess.run( - [str(binary_path), "info", "--property", "signature-type", str(output_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert info.returncode == 0, f"mpqcli info failed with error: {info.stderr}" - assert info.stdout.strip() == "None", f"Expected signature type 'None', got: {info.stdout.strip()!r}" - - output_file.unlink(missing_ok=True) - - -def test_create_mpq_skips_special_files(binary_path, tmp_path): - """ - Test that special MPQ files in the source directory are skipped during archive creation. - - This test checks: - - That (listfile), (signature), and (attributes) are not added as regular files. - - That the signature type is None after creation (no false signature from (signature) file). - - That the special files do not appear in the archive file listing. - """ - source_dir = tmp_path / "src" - source_dir.mkdir() - (source_dir / "readme.txt").write_text("hello") - - # Plant all three special files in the source directory - for name in ["(listfile)", "(signature)", "(attributes)"]: - (source_dir / name).write_bytes(b"fake content") - - output_file = tmp_path / "output.mpq" - - result = subprocess.run( - [str(binary_path), "create", str(output_file), str(source_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_file.exists(), "MPQ file was not created" - - # Signature type must be None (signature) must not have been ingested - info = subprocess.run( - [str(binary_path), "info", "--property", "signature-type", str(output_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert info.returncode == 0, f"mpqcli info failed with error: {info.stderr}" - assert info.stdout.strip() == "None", f"Expected signature type 'None', got: {info.stdout.strip()!r}" - - # Special files must not appear in the regular file listing - listing = subprocess.run( - [str(binary_path), "list", str(output_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert listing.returncode == 0, f"mpqcli list failed with error: {listing.stderr}" - for name in ["(listfile)", "(signature)", "(attributes)"]: - assert name not in listing.stdout, f"Special file {name!r} unexpectedly found in archive listing" - - -def test_create_mpq_folder_structure_with_trailing_slash(binary_path, tmp_path): - """ - Test MPQ archive creation from a directory path with a trailing slash. - - Regression test for the lexical relative path handling: archive paths must - stay relative to the target directory even when the target path carries a - trailing separator. - - This test checks: - - The archive is created successfully. - - Nested files keep their folder structure in the archive. - """ - source_dir = tmp_path / "src" - (source_dir / "sub").mkdir(parents=True) - (source_dir / "root.txt").write_text("root file") - (source_dir / "sub" / "nested.txt").write_text("nested file") - - output_file = tmp_path / "output.mpq" - - result = subprocess.run( - [str(binary_path), "create", str(output_file), str(source_dir) + "/"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_file.exists(), "MPQ file was not created" - - verify_archive_file_content( - binary_path, output_file, {"enUS root.txt", "enUS sub\\nested.txt"} - ) - - -def test_create_mpq_folder_structure_with_dot_relative_path(binary_path, tmp_path): - """ - Test MPQ archive creation from a "./" prefixed relative path. - - Regression test for the lexical relative path handling: archive paths must - be relative to the target directory, with no leading "./" segments. - - This test checks: - - The archive is created successfully. - - Nested files keep their folder structure in the archive. - """ - source_dir = tmp_path / "src" - (source_dir / "sub").mkdir(parents=True) - (source_dir / "sub" / "nested.txt").write_text("nested file") - - output_file = tmp_path / "output.mpq" - - result = subprocess.run( - [str(binary_path), "create", str(output_file), "./src"], - cwd=str(tmp_path), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_file.exists(), "MPQ file was not created" - - verify_archive_file_content(binary_path, output_file, {"enUS sub\\nested.txt"}) - - -@pytest.mark.skipif(platform.system() == "Windows", reason="Symlink creation needs privileges on Windows") -def test_create_mpq_skips_dangling_symlink(binary_path, tmp_path): - """ - Test MPQ archive creation from a directory containing a dangling symlink. - - This test checks: - - The archive is created successfully. - - The dangling symlink is skipped and the regular files are added. - """ - source_dir = tmp_path / "src" - source_dir.mkdir() - (source_dir / "real.txt").write_text("real file") - (source_dir / "dangling.txt").symlink_to(tmp_path / "missing.txt") - - output_file = tmp_path / "output.mpq" - - result = subprocess.run( - [str(binary_path), "create", str(output_file), str(source_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_file.exists(), "MPQ file was not created" - - verify_archive_file_content(binary_path, output_file, {"enUS real.txt"}) - - -def verify_archive_file_content(binary_path, test_file, expected_output): - result = subprocess.run( - [str(binary_path), "list", str(test_file), "-d", "--property", "locale"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_create_mpq_from_empty_directory(binary_path, tmp_path): - empty_dir = tmp_path / "empty" - empty_dir.mkdir() - output_file = tmp_path / "empty.mpq" - - result = subprocess.run( - [str(binary_path), "create", str(output_file), str(empty_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_file.exists(), "MPQ file was not created" - assert output_file.stat().st_size > 0, "MPQ file is empty" - - # No user files should be listed - list_result = subprocess.run( - [str(binary_path), "list", str(output_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert list_result.returncode == 0 - assert list_result.stdout.strip() == "", \ - f"Expected no files listed, got: {list_result.stdout}" - - # max-files should be 64 (minimum value returned by CalculateMpqMaxFileValue for 0 files) - info_result = subprocess.run( - [str(binary_path), "info", "--property", "max-files", str(output_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert info_result.returncode == 0 - assert info_result.stdout.strip() == "64", \ - f"Expected max-files of 64, got: {info_result.stdout.strip()}" - - -def test_create_old_version_option_is_rejected(binary_path, tmp_path): - input_dir = tmp_path / "files" - input_dir.mkdir() - (input_dir / "a.txt").write_text("A file.") - - result = subprocess.run( - [str(binary_path), "create", "--version", "1", str(input_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - cwd=tmp_path, - ) - - assert result.returncode == 109, f"mpqcli failed with error: {result.stderr}" - assert sorted(p.name for p in tmp_path.iterdir()) == ["files"] - - -def test_create_old_directory_form_is_rejected(binary_path, tmp_path): - input_dir = tmp_path / "files" - input_dir.mkdir() - (input_dir / "a.txt").write_text("A file.") - - result = subprocess.run( - [str(binary_path), "create", str(input_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - cwd=tmp_path, - ) - - # The directory is taken as the archive, which leaves no files to add - assert result.returncode == 106, f"mpqcli failed with error: {result.stderr}" - assert sorted(p.name for p in tmp_path.iterdir()) == ["files"] - - -def test_create_old_output_option_is_rejected(binary_path, tmp_path): - input_dir = tmp_path / "files" - input_dir.mkdir() - (input_dir / "a.txt").write_text("A file.") - - result = subprocess.run( - [str(binary_path), "create", "-o", str(tmp_path / "out.mpq"), str(input_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - cwd=tmp_path, - ) - - assert result.returncode == 109, f"mpqcli failed with error: {result.stderr}" - assert sorted(p.name for p in tmp_path.iterdir()) == ["files"] - - -def test_create_from_several_inputs(binary_path, tmp_path): - input_dir = tmp_path / "textures" - (input_dir / "bear").mkdir(parents=True) - (input_dir / "bear" / "bear.blp").write_text("Bear.") - input_file = tmp_path / "readme.txt" - input_file.write_text("Read me.") - archive_file = tmp_path / "several.mpq" - - result = subprocess.run( - [str(binary_path), "create", str(archive_file), str(input_file), str(input_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - list_result = subprocess.run( - [str(binary_path), "list", str(archive_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert set(list_result.stdout.splitlines()) == {"readme.txt", "bear\\bear.blp"} - - -def test_create_two_inputs_with_the_same_name_creates_nothing(binary_path, tmp_path): - first_dir = tmp_path / "first" - second_dir = tmp_path / "second" - first_dir.mkdir() - second_dir.mkdir() - (first_dir / "a.txt").write_text("First.") - (second_dir / "a.txt").write_text("Second.") - archive_file = tmp_path / "duplicate.mpq" - - result = subprocess.run( - [str(binary_path), "create", str(archive_file), str(first_dir), str(second_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert "1 file(s) were not added because an earlier input added a file by the same name" \ - in result.stderr - assert "no archive was created" in result.stderr - assert sorted(p.name for p in tmp_path.iterdir()) == ["first", "second"] - - -def test_create_files_via_stdin(binary_path, tmp_path): - file_a = tmp_path / "a.txt" - file_b = tmp_path / "b.txt" - file_a.write_text("A.") - file_b.write_text("B.") - archive_file = tmp_path / "stdin.mpq" - - result = subprocess.run( - [str(binary_path), "create", str(archive_file), "-"], - input=f"{file_a}\n{file_b}\n", - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - list_result = subprocess.run( - [str(binary_path), "list", str(archive_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert set(list_result.stdout.splitlines()) == {"a.txt", "b.txt"} - - -def test_create_empty_stdin_creates_nothing(binary_path, tmp_path): - archive_file = tmp_path / "empty.mpq" - - result = subprocess.run( - [str(binary_path), "create", str(archive_file), "-"], - input="", - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert list(tmp_path.iterdir()) == [] - - -def test_create_missing_input_creates_nothing(binary_path, tmp_path): - input_file = tmp_path / "a.txt" - input_file.write_text("A file.") - archive_file = tmp_path / "missing.mpq" - - result = subprocess.run( - [str(binary_path), "create", str(archive_file), str(input_file), str(tmp_path / "typo")], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert "Path does not exist" in result.stderr - assert sorted(p.name for p in tmp_path.iterdir()) == ["a.txt"] - - -@pytest.mark.skipif( - platform.system() == "Windows" or os.geteuid() == 0, - reason="Needs a file permission that stops the user running the tests from reading it", -) -def test_create_unreadable_file_creates_nothing(binary_path, tmp_path): - input_dir = tmp_path / "mod" - input_dir.mkdir() - (input_dir / "a.txt").write_text("A file.") - locked_file = input_dir / "locked.txt" - locked_file.write_text("Locked.") - other_file = tmp_path / "b.txt" - other_file.write_text("B file.") - archive_file = tmp_path / "unreadable.mpq" - - locked_file.chmod(0) - try: - result = subprocess.run( - [str(binary_path), "create", str(archive_file), str(other_file), str(input_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - finally: - locked_file.chmod(0o644) - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert "Failed to add: locked.txt" in result.stderr - assert "no archive was created" in result.stderr - assert sorted(p.name for p in tmp_path.iterdir()) == ["b.txt", "mod"] - - -def test_create_skips_the_archive_inside_its_own_input_directory(binary_path, tmp_path): - input_dir = tmp_path / "mod" - input_dir.mkdir() - (input_dir / "a.txt").write_text("A file.") - - result = subprocess.run( - [str(binary_path), "create", "out.mpq", "."], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - cwd=input_dir, - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "Skipping the archive itself" in result.stderr - assert sorted(p.name for p in input_dir.iterdir()) == ["a.txt", "out.mpq"] - - list_result = subprocess.run( - [str(binary_path), "list", str(input_dir / "out.mpq")], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert list_result.stdout.splitlines() == ["a.txt"] - - -def test_create_path_is_a_prefix_for_several_inputs(binary_path, tmp_path): - file_a = tmp_path / "a.txt" - file_b = tmp_path / "b.txt" - file_a.write_text("A.") - file_b.write_text("B.") - input_dir = tmp_path / "textures" - (input_dir / "bear").mkdir(parents=True) - (input_dir / "bear" / "bear.blp").write_text("Bear.") - archive_file = tmp_path / "prefixed.mpq" - - result = subprocess.run( - [str(binary_path), "create", str(archive_file), str(file_a), str(file_b), str(input_dir), - "--path", "data"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - list_result = subprocess.run( - [str(binary_path), "list", str(archive_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert set(list_result.stdout.splitlines()) == { - "data\\a.txt", "data\\b.txt", "data\\bear\\bear.blp" - } - - -def test_create_sizes_the_archive_for_paths_from_stdin(binary_path, tmp_path): - # Enough files that the count matters: an archive sized for none of them, and grown - # as they are added, reports the same max-files as one made from a single file - input_dir = tmp_path / "files" - input_dir.mkdir() - for i in range(30): - (input_dir / f"file{i}.txt").write_text(f"File {i}.") - paths = "".join(f"{p}\n" for p in sorted(input_dir.iterdir())) - - def create_and_read_max_files(archive_file, inputs, stdin=None): - result = subprocess.run( - [str(binary_path), "create", str(archive_file), *inputs], - input=stdin, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - info_result = subprocess.run( - [str(binary_path), "info", "--property", "max-files", str(archive_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert info_result.returncode == 0, f"mpqcli failed with error: {info_result.stderr}" - return int(info_result.stdout.strip()) - - from_stdin = create_and_read_max_files(tmp_path / "stdin.mpq", ["-"], paths) - from_directory = create_and_read_max_files(tmp_path / "directory.mpq", [str(input_dir)]) - from_one_file = create_and_read_max_files( - tmp_path / "one.mpq", [str(input_dir / "file0.txt")]) - - assert from_stdin == from_directory - assert from_stdin > from_one_file diff --git a/test/test_extract.py b/test/test_extract.py deleted file mode 100644 index 627cf82..0000000 --- a/test/test_extract.py +++ /dev/null @@ -1,1088 +0,0 @@ -import os -import platform -import shutil -import subprocess -from pathlib import Path - - -def test_extract_mpq_target_does_not_exist(binary_path, generate_test_files): - """ - Test MPQ file extraction with a non-existent target. - - This test checks: - - If the application exits correctly when the target does not exist. - """ - _ = generate_test_files - script_dir = Path(__file__).parent - target_dir = script_dir / "does" / "not" / "exist" - - result = subprocess.run( - [str(binary_path), "extract", str(target_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - - -def test_extract_mpq_default_options(binary_path, generate_test_files): - """ - Test MPQ archive extraction with default options. - - This test checks: - - If the MPQ archive is extracted correctly. - - If the output files match the expected files. - - If the output directory is created. - """ - _ = generate_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - - # Create output_file path without suffix (default extract behavior is MPQ without extension) - output_dir = test_file.with_suffix("") - if output_dir.exists(): - shutil.rmtree(output_dir) - - expected_output = { - "cats.txt", - "dogs.txt", - "bytes", - "(listfile)", - "(attributes)", - } - - result = subprocess.run( - [str(binary_path), "extract", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stderr.splitlines()) - - # Create expected_lines set based on expected output with prefix - expected_lines = {f"[*] Extracted: {line}" for line in expected_output} - - # Create output_files set based on directory contents (not full path) - output_files = set(fi.name for fi in output_dir.glob("*")) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_lines, f"Unexpected output: {output_lines}" - assert output_dir.exists(), "Output directory was not created" - assert output_files == expected_output, f"Unexpected files: {output_files}" - - # Content and file size verification - import platform - cats_file = output_dir / "cats.txt" - dogs_file = output_dir / "dogs.txt" - expected_cats_content = "This is a file about cats.\n" - expected_dogs_content = "This is a file about dogs.\n" - expected_size = 28 if platform.system() == "Windows" else 27 - - assert cats_file.read_text(encoding="utf-8") == expected_cats_content, \ - "Unexpected content in cats.txt" - assert dogs_file.read_text(encoding="utf-8") == expected_dogs_content, \ - "Unexpected content in dogs.txt" - assert cats_file.stat().st_size == expected_size, \ - f"Unexpected size for cats.txt: {cats_file.stat().st_size}" - assert dogs_file.stat().st_size == expected_size, \ - f"Unexpected size for dogs.txt: {dogs_file.stat().st_size}" - - -def test_extract_mpq_output_directory_specified(binary_path, generate_test_files): - """ - Test MPQ archive extraction with specified output directory. - - This test checks: - - If the MPQ archive is extracted correctly. - - If the output files match the expected files. - - If the output directory is created. - """ - _ = generate_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - output_dir = script_dir / "data" / "extracted" - if output_dir.exists(): - shutil.rmtree(output_dir) - - expected_output = { - "cats.txt", - "dogs.txt", - "bytes", - "(listfile)", - "(attributes)", - } - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stderr.splitlines()) - - # Create expected_lines set based on expected output with prefix - expected_lines = {f"[*] Extracted: {line}" for line in expected_output} - - # Create output_files set based on directory contents (not full path) - output_files = set(fi.name for fi in output_dir.glob("*")) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_lines, f"Unexpected output: {output_lines}" - assert output_dir.exists(), "Output directory was not created" - assert output_files == expected_output, f"Unexpected files: {output_files}" - - # Content and file size verification - import platform - cats_file = output_dir / "cats.txt" - dogs_file = output_dir / "dogs.txt" - expected_cats_content = "This is a file about cats.\n" - expected_dogs_content = "This is a file about dogs.\n" - expected_size = 28 if platform.system() == "Windows" else 27 - - assert cats_file.read_text(encoding="utf-8") == expected_cats_content, \ - "Unexpected content in cats.txt" - assert dogs_file.read_text(encoding="utf-8") == expected_dogs_content, \ - "Unexpected content in dogs.txt" - assert cats_file.stat().st_size == expected_size, \ - f"Unexpected size for cats.txt: {cats_file.stat().st_size}" - assert dogs_file.stat().st_size == expected_size, \ - f"Unexpected size for dogs.txt: {dogs_file.stat().st_size}" - - -def test_extract_file_from_mpq_output_directory_specified(binary_path, generate_test_files): - """ - Test MPQ archive file extraction with specified output directory. - - This test checks: - - If the file is extracted correctly. - - If the output files match the expected files. - - If the output directory is created. - """ - _ = generate_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - output_dir = script_dir / "data" / "extracted_file" - if output_dir.exists(): - shutil.rmtree(output_dir) - - - expected_output = { - "cats.txt", - } - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), str(test_file), "".join(expected_output)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stderr.splitlines()) - - # Create expected_lines set based on expected output with prefix - expected_lines = {f"[*] Extracted: {line}" for line in expected_output} - - # Create output_files set based on directory contents (not full path) - output_files = set(fi.name for fi in output_dir.glob("*")) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_lines, f"Unexpected output: {output_lines}" - assert output_dir.exists(), "Output directory was not created" - assert output_files == expected_output, f"Unexpected files: {output_files}" - - # Content and file size verification (only cats.txt was extracted) - import platform - cats_file = output_dir / "cats.txt" - expected_cats_content = "This is a file about cats.\n" - expected_size = 28 if platform.system() == "Windows" else 27 - - assert cats_file.read_text(encoding="utf-8") == expected_cats_content, \ - "Unexpected content in cats.txt" - assert cats_file.stat().st_size == expected_size, \ - f"Unexpected size for cats.txt: {cats_file.stat().st_size}" - - -def test_extract_file_from_mpq_with_locale(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ archive file extraction with a specified locale. - - This test checks: - - If the file with the given locale is extracted correctly. - - If the output files match the expected files. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_many_locales.mpq" - output_dir = script_dir / "data" / "extracted_file" - file_to_extract = "cats.txt" - locale = "esES" - if output_dir.exists(): - shutil.rmtree(output_dir) - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), str(test_file), file_to_extract, "--locale", locale], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - expected_stderr = { - "[*] Extracted: " + file_to_extract - } - output_file = output_dir / file_to_extract - expected_content = "Este es un archivo sobre gatos." - - output_lines = set(result.stderr.splitlines()) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" - assert output_file.exists(), "Output directory was not created" - assert output_file.read_text(encoding="utf-8") == expected_content, "Unexpected file content" - - -def test_extract_file_from_mpq_with_default_locale(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ archive file extraction with no specified locale but many matching file names. - - This test checks: - - If the file with the default locale is extracted correctly. - - If the output files match the expected files. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_many_locales.mpq" - output_dir = script_dir / "data" / "extracted_file" - file_to_extract = "cats.txt" - if output_dir.exists(): - shutil.rmtree(output_dir) - - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), str(test_file), file_to_extract], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - expected_stderr = { - "[*] Extracted: " + file_to_extract - } - output_file = output_dir / file_to_extract - expected_content = "This is a file about cats." - - output_lines = set(result.stderr.splitlines()) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" - assert output_file.exists(), "Output directory was not created" - assert output_file.read_text(encoding="utf-8") == expected_content, "Unexpected file content" - - -def test_extract_file_from_mpq_with_illegal_locale(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ archive file extraction with an illegal locale. - - This test checks: - - That an unknown locale is rejected and nothing is extracted. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_many_locales.mpq" - output_dir = script_dir / "data" / "extracted_file" - file_to_extract = "cats.txt" - locale = "nosuchlocale" - if output_dir.exists(): - shutil.rmtree(output_dir) - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), str(test_file), file_to_extract, "--locale", locale], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - assert not output_dir.exists(), "Output directory was created" - - -def test_extract_file_from_mpq_with_locale_not_in_file(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ archive file extraction with a specified locale that is not in the file. - - This test checks: - - When a locale is given that does not exist in the file, the file with the default locale is extracted. - - If the output files match the expected files. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_many_locales.mpq" - output_dir = script_dir / "data" / "extracted_file" - file_to_extract = "cats.txt" - locale = "ptPT" # There is no file for this locale - if output_dir.exists(): - shutil.rmtree(output_dir) - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), str(test_file), file_to_extract, "--locale", locale], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - expected_stderr = { - "[*] Extracted: " + file_to_extract - } - output_file = output_dir / file_to_extract - expected_content = "This is a file about cats." - - output_lines = set(result.stderr.splitlines()) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" - assert output_file.exists(), "Output directory was not created" - assert output_file.read_text(encoding="utf-8") == expected_content, "Unexpected file content" - - -def test_extract_file_from_mpq_with_no_locale_argument_and_no_default_locale(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ archive file extraction without a specified locale, and no file with the default locale. - - This test checks: - - When no locale is given, and no file by that name exists for the default locale, - but one does for a different one, no file is extracted. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_one_locale.mpq" - output_dir = script_dir / "data" / "extracted_file" - file_to_extract = "cats.txt" # There is a file by this name, but for locale esES - if output_dir.exists(): - shutil.rmtree(output_dir) - - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), str(test_file), file_to_extract], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - expected_stdout_output = set() - expected_stderr_output = { - "[!] Failed: File doesn't exist for locale enUS: " + file_to_extract, - "", - "[!] Failed to extract all files.", - } - - stdout_output_lines = set(result.stdout.splitlines()) - stderr_output_lines = set(result.stderr.splitlines()) - - output_file = output_dir / file_to_extract - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert stdout_output_lines == expected_stdout_output, f"Unexpected output: {stdout_output_lines}" - assert stderr_output_lines == expected_stderr_output, f"Unexpected output: {stderr_output_lines}" - assert not output_file.exists(), "Output directory was not created" - - -def test_extract_file_from_mpq_with_wrong_locale_argument_and_no_default_locale(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ archive file extraction with a specified locale, and no file with the default locale. - - This test checks: - - When no locale is given, and no file by that name exists for the default locale, - but one does for a different one, no file is extracted. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_one_locale.mpq" - output_dir = script_dir / "data" / "extracted_file" - file_to_extract = "cats.txt" # There is a file by this name, but for locale esES - locale = "deDE" - if output_dir.exists(): - shutil.rmtree(output_dir) - - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), str(test_file), file_to_extract, "--locale", locale], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - expected_stdout_output = set() - expected_stderr_output = { - "[!] Failed: File doesn't exist for locale " + locale + ": " + file_to_extract, - "", - "[!] Failed to extract all files.", - } - - stdout_output_lines = set(result.stdout.splitlines()) - stderr_output_lines = set(result.stderr.splitlines()) - - output_file = output_dir / file_to_extract - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert stdout_output_lines == expected_stdout_output, f"Unexpected output: {stdout_output_lines}" - assert stderr_output_lines == expected_stderr_output, f"Unexpected output: {stderr_output_lines}" - assert not output_file.exists(), "Output directory was not created" - - -def test_extract_all_files_from_mpq_without_providing_listfile(binary_path, generate_mpq_without_internal_listfile): - """ - Test file extraction of all files from MPQ archive with no internal listfile, - when no external one is provided - - This test checks: - - That files from MPQs with no internal listfile can still be extracted. - - That files with different locales than the default are not extracted. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - output_dir = script_dir / "data" / "extracted_file" - if output_dir.exists(): - shutil.rmtree(output_dir) - - - expected_output = { - "File00000000.xxx", - } - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} - - # Create expected_lines set based on expected output with prefix - expected_lines = {f"[*] Extracted: {line}" for line in expected_output} - - # Create output_file path without suffix (default extract behavior is MPQ without extension) - output_file = output_dir.with_suffix("") - - # Create output_files set based on directory contents (not full path) - output_files = set(fi.name for fi in output_file.glob("*")) - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_lines, f"Unexpected output: {output_lines}" - assert output_file.exists(), "Output directory was not created" - assert output_files == expected_output, f"Unexpected files: {output_files}" - - -def test_extract_all_files_from_mpq_without_providing_listfile_and_with_given_locale(binary_path, generate_mpq_without_internal_listfile): - """ - Test file extraction of all files from MPQ archive with no internal listfile, - when no external one is provided - - This test checks: - - That files from MPQs with no internal listfile can still be extracted. - - That files with the default and the given locale are extracted. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - output_dir = script_dir / "data" / "extracted_file" - if output_dir.exists(): - shutil.rmtree(output_dir) - - - expected_output = { - "File00000000.xxx", - "File00000002.xxx", - } - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), str(test_file), "--locale", "041D"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} - - # Create expected_lines set based on expected output with prefix - expected_lines = {f"[*] Extracted: {line}" for line in expected_output} - - # Create output_file path without suffix (default extract behavior is MPQ without extension) - output_file = output_dir.with_suffix("") - - # Create output_files set based on directory contents (not full path) - output_files = set(fi.name for fi in output_file.glob("*")) - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_lines, f"Unexpected output: {output_lines}" - assert output_file.exists(), "Output directory was not created" - assert output_files == expected_output, f"Unexpected files: {output_files}" - - -def test_extract_single_file_from_mpq_without_providing_listfile(binary_path, generate_mpq_without_internal_listfile): - """ - Test file extraction for MPQ archive with no internal listfile and without providing an external one - - This test checks: - - That files from MPQs with no internal listfile can still be extracted. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - output_dir = script_dir / "data" / "extracted_file" - file_to_extract = "File00000000.xxx" - if output_dir.exists(): - shutil.rmtree(output_dir) - - - expected_output = { - "File00000000.xxx", - } - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), str(test_file), file_to_extract], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stderr.splitlines()) - - # Create expected_lines set based on expected output with prefix - expected_lines = {f"[*] Extracted: {line}" for line in expected_output} - - # Create output_file path without suffix (default extract behavior is MPQ without extension) - output_file = output_dir.with_suffix("") - - # Create output_files set based on directory contents (not full path) - output_files = set(fi.name for fi in output_file.glob("*")) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_lines, f"Unexpected output: {output_lines}" - assert output_file.exists(), "Output directory was not created" - assert output_files == expected_output, f"Unexpected files: {output_files}" - - -def test_extract_all_files_from_mpq_providing_partial_external_listfile(binary_path, generate_mpq_without_internal_listfile): - """ - Test file extraction of all files from MPQ archive containing no internal listfile, - when providing a partially complete external listfile - - This test checks: - - That files from MPQs with no internal listfile can still be extracted. - - That files with different locales than the default are not extracted. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - output_dir = script_dir / "data" / "extracted_file" - if output_dir.exists(): - shutil.rmtree(output_dir) - listfile = script_dir / "data" / "listfile.txt" - listfile.write_text("capybaras.txt") - - - expected_output = { - "capybaras.txt", - } - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), "-l", listfile, str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} - - # Create expected_lines set based on expected output with prefix - expected_lines = {f"[*] Extracted: {line}" for line in expected_output} - - # Create output_file path without suffix (default extract behavior is MPQ without extension) - output_file = output_dir.with_suffix("") - - # Create output_files set based on directory contents (not full path) - output_files = set(fi.name for fi in output_file.glob("*")) - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_lines, f"Unexpected output: {output_lines}" - assert output_file.exists(), "Output directory was not created" - assert output_files == expected_output, f"Unexpected files: {output_files}" - - -def test_extract_all_files_from_mpq_providing_partial_external_listfile_and_with_given_locale(binary_path, generate_mpq_without_internal_listfile): - """ - Test file extraction of all files from MPQ archive containing no internal listfile, - when providing a partially complete external listfile - - This test checks: - - That files from MPQs with no internal listfile can still be extracted. - - That files with the default and the given locale are extracted. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - output_dir = script_dir / "data" / "extracted_file" - if output_dir.exists(): - shutil.rmtree(output_dir) - listfile = script_dir / "data" / "listfile.txt" - listfile.write_text("cats.txt") - - - expected_output = { - "File00000000.xxx", - "cats.txt", - } - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), "-l", listfile, str(test_file), "--locale", "deDE"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} - - # Create expected_lines set based on expected output with prefix - expected_lines = {f"[*] Extracted: {line}" for line in expected_output} - - # Create output_file path without suffix (default extract behavior is MPQ without extension) - output_file = output_dir.with_suffix("") - - # Create output_files set based on directory contents (not full path) - output_files = set(fi.name for fi in output_file.glob("*")) - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_lines, f"Unexpected output: {output_lines}" - assert output_file.exists(), "Output directory was not created" - assert output_files == expected_output, f"Unexpected files: {output_files}" - - -def test_extract_all_files_from_mpq_providing_complete_external_listfile(binary_path, generate_mpq_without_internal_listfile): - """ - Test file extraction of all files from MPQ archive containing no internal listfile, - when providing a complete external listfile - - This test checks: - - That files from MPQs with no internal listfile can still be extracted. - - That files with different locales than the default are not extracted. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - output_dir = script_dir / "data" / "extracted_file" - if output_dir.exists(): - shutil.rmtree(output_dir) - listfile = script_dir / "data" / "listfile.txt" - listfile.write_text("cats.txt\ndogs.txt\ncapybaras.txt") - - - expected_output = { - "capybaras.txt", - } - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), "-l", listfile, str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} - - # Create expected_lines set based on expected output with prefix - expected_lines = {f"[*] Extracted: {line}" for line in expected_output} - - # Create output_file path without suffix (default extract behavior is MPQ without extension) - output_file = output_dir.with_suffix("") - - # Create output_files set based on directory contents (not full path) - output_files = set(fi.name for fi in output_file.glob("*")) - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_lines, f"Unexpected output: {output_lines}" - assert output_file.exists(), "Output directory was not created" - assert output_files == expected_output, f"Unexpected files: {output_files}" - - -def test_extract_all_files_from_mpq_providing_complete_external_listfile_and_with_given_locale(binary_path, generate_mpq_without_internal_listfile): - """ - Test file extraction of all files from MPQ archive containing no internal listfile, - when providing a complete external listfile - - This test checks: - - That files from an MPQs with no internal listfile can still be extracted. - - That files with the default and the given locale are extracted. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - output_dir = script_dir / "data" / "extracted_file" - if output_dir.exists(): - shutil.rmtree(output_dir) - listfile = script_dir / "data" / "listfile.txt" - listfile.write_text("cats.txt\ndogs.txt\ncapybaras.txt") - - - expected_output = { - "dogs.txt", - "capybaras.txt", - } - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), "-l", listfile, str(test_file), "--locale", "041D"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} - - # Create expected_lines set based on expected output with prefix - expected_lines = {f"[*] Extracted: {line}" for line in expected_output} - - # Create output_file path without suffix (default extract behavior is MPQ without extension) - output_file = output_dir.with_suffix("") - - # Create output_files set based on directory contents (not full path) - output_files = set(fi.name for fi in output_file.glob("*")) - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_lines, f"Unexpected output: {output_lines}" - assert output_file.exists(), "Output directory was not created" - assert output_files == expected_output, f"Unexpected files: {output_files}" - - -def test_extract_path_traversal_is_blocked(binary_path, generate_path_traversal_mpq): - """ - Test that extracting an MPQ containing a path traversal filename is blocked (zip-slip). - - This test checks: - - That the traversal entry is not written outside the output directory. - - That the extraction of the traversal entry is reported as blocked on stderr. - - That safe files in the same archive are still extracted correctly. - - That the overall exit code is non-zero. - """ - test_file = generate_path_traversal_mpq - script_dir = Path(__file__).parent - output_dir = script_dir / "data" / "extracted_traversal" - if output_dir.exists(): - shutil.rmtree(output_dir) - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - expected_stdout = set() - expected_stderr = { - "[*] Extracted: safe.txt", - "[*] Extracted: (listfile)", - "[!] Blocked: path traversal attempt detected: " + os.path.normpath("../../sneaky.txt"), - "", - "[!] Failed to extract all files.", - } - - stdout_lines = set(result.stdout.splitlines()) - stderr_lines = set(result.stderr.splitlines()) - - assert result.returncode == 1, f"Expected non-zero exit for traversal attempt, got: {result.returncode}" - assert stdout_lines == expected_stdout, f"Unexpected stdout: {stdout_lines}" - assert stderr_lines == expected_stderr, f"Unexpected stderr: {stderr_lines}" - - # Confirm no file escaped outside the intended output directory - assert not (output_dir.parent / "sneaky.txt").exists(), "Path traversal was not blocked: sneaky.txt escaped" - - -def test_extract_mpq_with_nonexistent_listfile(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - listfile = script_dir / "does" / "not" / "exist.txt" - - result = subprocess.run( - [str(binary_path), "extract", str(test_file), "--listfile", str(listfile)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - - -def test_extract_nested_file_from_mpq(binary_path, generate_test_files): - _ = generate_test_files - script_dir = Path(__file__).parent - archive_file = script_dir / "data" / "files.mpq" - output_dir = script_dir / "data" / "extracted_nested" - - if output_dir.exists(): - shutil.rmtree(output_dir) - - # Create a fresh MPQ archive - target_dir = script_dir / "data" / "files" - archive_file.unlink(missing_ok=True) - result = subprocess.run( - [str(binary_path), "create", "--format-version", "1", str(archive_file), str(target_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"Failed to create archive: {result.stderr}" - - # Add a file at a nested path - test_file = script_dir / "data" / "files" / "cats.txt" - nested_path = "texts\\cats.txt" - result = subprocess.run( - [str(binary_path), "add", str(archive_file), str(test_file), "--path", nested_path], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"Failed to add nested file: {result.stderr}" - - # Extract the nested file, which keeps its folder - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), str(archive_file), nested_path], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - separator = "\\" if platform.system() == "Windows" else "/" - expected_stderr = {f"[*] Extracted: texts{separator}cats.txt"} - output_lines = set(result.stderr.splitlines()) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" - - extracted_file = output_dir / "texts" / "cats.txt" - assert extracted_file.exists(), "Extracted nested file does not exist" - assert extracted_file.read_text(encoding="utf-8") == "This is a file about cats.\n", \ - "Unexpected content in extracted nested file" - - # Extract it again with --flat, which drops the folder - flat_dir = script_dir / "data" / "extracted_nested_flat" - if flat_dir.exists(): - shutil.rmtree(flat_dir) - result = subprocess.run( - [str(binary_path), "extract", "--flat", "-o", str(flat_dir), str(archive_file), nested_path], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert set(result.stderr.splitlines()) == {"[*] Extracted: cats.txt"} - assert set(fi.name for fi in flat_dir.iterdir()) == {"cats.txt"} - - -def test_extract_multiple_files(binary_path, generate_cats_and_dogs_mpq, tmp_path): - test_file = generate_cats_and_dogs_mpq - output_dir = tmp_path / "extracted" - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), str(test_file), "cats.txt", "dogs.txt"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert set(result.stderr.splitlines()) == {"[*] Extracted: cats.txt", "[*] Extracted: dogs.txt"} - assert set(fi.name for fi in output_dir.iterdir()) == {"cats.txt", "dogs.txt"} - - -def test_extract_files_via_stdin(binary_path, generate_cats_and_dogs_mpq, tmp_path): - test_file = generate_cats_and_dogs_mpq - output_dir = tmp_path / "extracted" - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), str(test_file), "-"], - input="cats.txt\ndogs.txt\n", - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert set(fi.name for fi in output_dir.iterdir()) == {"cats.txt", "dogs.txt"} - - -def test_extract_empty_stdin_extracts_nothing(binary_path, generate_cats_and_dogs_mpq, tmp_path): - test_file = generate_cats_and_dogs_mpq - output_dir = tmp_path / "extracted" - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), str(test_file), "-"], - input="", - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[*] No files to extract" in result.stderr - assert not output_dir.exists(), "Output directory was created" - - -def test_extract_flat_name_collision_extracts_nothing(binary_path, tmp_path): - input_dir = tmp_path / "files" - (input_dir / "a").mkdir(parents=True) - (input_dir / "b").mkdir() - (input_dir / "a" / "x.txt").write_text("A") - (input_dir / "b" / "x.txt").write_text("B") - archive_file = tmp_path / "collision.mpq" - output_dir = tmp_path / "extracted" - - result = subprocess.run( - [str(binary_path), "create", str(archive_file), str(input_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"Failed to create archive: {result.stderr}" - - result = subprocess.run( - [str(binary_path), "extract", "--flat", "-o", str(output_dir), str(archive_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert "to the same file" in result.stderr - assert not output_dir.exists(), "Output directory was created" - - -def test_extract_listfile_with_named_files_is_rejected(binary_path, generate_cats_and_dogs_mpq, tmp_path): - test_file = generate_cats_and_dogs_mpq - listfile = tmp_path / "listfile.txt" - listfile.write_text("cats.txt\n") - output_dir = tmp_path / "extracted" - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), "-l", str(listfile), str(test_file), "cats.txt"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 108, f"mpqcli failed with error: {result.stderr}" - assert not output_dir.exists(), "Output directory was created" - - -def test_extract_old_file_option_is_rejected(binary_path, generate_cats_and_dogs_mpq, tmp_path): - test_file = generate_cats_and_dogs_mpq - - result = subprocess.run( - [str(binary_path), "extract", "-f", "cats.txt", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - cwd=tmp_path, - ) - - # -f is not an extract option, so the file name is taken as the archive - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - assert "archive: File does not exist: cats.txt" in result.stderr - assert list(tmp_path.iterdir()) == [] - - -def test_extract_old_keep_flag_is_rejected(binary_path, generate_cats_and_dogs_mpq, tmp_path): - test_file = generate_cats_and_dogs_mpq - output_dir = tmp_path / "extracted" - - result = subprocess.run( - [str(binary_path), "extract", "-k", "-o", str(output_dir), str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 109, f"mpqcli failed with error: {result.stderr}" - assert not output_dir.exists(), "Output directory was created" - - -def test_extract_listfile_with_stdin_is_rejected(binary_path, generate_cats_and_dogs_mpq, tmp_path): - test_file = generate_cats_and_dogs_mpq - listfile = tmp_path / "listfile.txt" - listfile.write_text("cats.txt\n") - output_dir = tmp_path / "extracted" - - result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), "-l", str(listfile), str(test_file), "-"], - input="cats.txt\n", - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 108, f"mpqcli failed with error: {result.stderr}" - assert not output_dir.exists(), "Output directory was created" - - -def test_extract_flat_allows_one_name_stored_under_several_locales(binary_path, tmp_path): - input_dir = tmp_path / "files" - input_dir.mkdir() - (input_dir / "cats.txt").write_text("Cats in the default locale.") - german_file = tmp_path / "cats.txt" - german_file.write_text("Cats in German.") - archive_file = tmp_path / "locales.mpq" - output_dir = tmp_path / "extracted" - - result = subprocess.run( - [str(binary_path), "create", str(archive_file), str(input_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"Failed to create archive: {result.stderr}" - result = subprocess.run( - [str(binary_path), "add", str(archive_file), str(german_file), "--locale", "deDE"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"Failed to add the second locale: {result.stderr}" - - result = subprocess.run( - [str(binary_path), "extract", "--flat", "-o", str(output_dir), str(archive_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - # cats.txt is listed once per locale, and both land on the same file without - # counting as a collision - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert result.stderr.splitlines().count("[*] Extracted: cats.txt") == 2 - assert (output_dir / "cats.txt").exists() - - -def test_extract_short_flat_flag(binary_path, tmp_path): - input_dir = tmp_path / "files" - (input_dir / "texts").mkdir(parents=True) - (input_dir / "texts" / "cats.txt").write_text("Cats.") - archive_file = tmp_path / "nested.mpq" - output_dir = tmp_path / "extracted" - - result = subprocess.run( - [str(binary_path), "create", str(archive_file), str(input_dir)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"Failed to create archive: {result.stderr}" - - result = subprocess.run( - [str(binary_path), "extract", "-j", "-o", str(output_dir), str(archive_file), "texts\\cats.txt"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert set(fi.name for fi in output_dir.iterdir()) == {"cats.txt"} diff --git a/test/test_info.py b/test/test_info.py deleted file mode 100644 index 09c71b0..0000000 --- a/test/test_info.py +++ /dev/null @@ -1,200 +0,0 @@ -from pathlib import Path -import platform -import subprocess - - -def test_info_v1(binary_path): - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - - expected_output = { - "Format version: 1", - "Header offset: 0", - "Header size: 32", - "Archive size: 1381", - "File count: 5", - "Max files: 64", - "Signature type: None", - } - - # Adjust the expected output for Windows due to different line endings - if platform.system() == "Windows": - expected_output.remove("Archive size: 1381") - expected_output.add("Archive size: 1383") - - result = subprocess.run( - [str(binary_path), "info", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_info_v2(binary_path): - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v2.mpq" - - expected_output = { - "Format version: 2", - "Header offset: 0", - "Header size: 44", - "Archive size: 1377", - "File count: 5", - "Max files: 64", - "Signature type: None", - } - - # Adjust the expected output for Windows due to different line endings - if platform.system() == "Windows": - expected_output.remove("Archive size: 1377") - expected_output.add("Archive size: 1379") - - result = subprocess.run( - [str(binary_path), "info", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_info_v1_properties(binary_path): - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - - test_cases = [ - ("format-version", "1"), - ("header-offset", "0"), - ("header-size", "32"), - ("archive-size", "1381"), - ("file-count", "5"), - ("max-files", "64"), - ("signature-type", "None"), - ] - - # Adjust archive-size for Windows - if platform.system() == "Windows": - test_cases = [ - (k, "1383") if k == "archive-size" else (k, v) - for (k, v) in test_cases - ] - - for property_name, expected_output in test_cases: - result = subprocess.run( - [str(binary_path), "info", "--property", property_name, str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert result.stdout.strip() == expected_output, f"Unexpected output for {property_name}: {result.stdout}" - - -def test_info_v2_properties(binary_path): - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v2.mpq" - - test_cases = [ - ("format-version", "2"), - ("header-offset", "0"), - ("header-size", "44"), - ("archive-size", "1377"), - ("file-count", "5"), - ("max-files", "64"), - ("signature-type", "None"), - ] - - # Adjust archive-size for Windows - if platform.system() == "Windows": - test_cases = [ - (k, "1379") if k == "archive-size" else (k, v) - for (k, v) in test_cases - ] - - for property_name, expected_output in test_cases: - result = subprocess.run( - [str(binary_path), "info", "--property", property_name, str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert result.stdout.strip() == expected_output, f"Unexpected output for {property_name}: {result.stdout}" - - -def test_info_v1_invalid_property(binary_path): - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - - property_name = "not-real" - - result = subprocess.run( - [str(binary_path), "info", "--property", property_name, str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - - -def test_info_mpq_target_does_not_exist(binary_path): - script_dir = Path(__file__).parent - target_file = script_dir / "does" / "not" / "exist.mpq" - - result = subprocess.run( - [str(binary_path), "info", str(target_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - - -def test_info_short_property_flag_is_rejected(binary_path, generate_cats_and_dogs_mpq): - test_file = generate_cats_and_dogs_mpq - - result = subprocess.run( - [str(binary_path), "info", "-p", "file-count", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - # -p is not an info option, so the property name is taken as the archive - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - assert "archive: File does not exist: file-count" in result.stderr - assert result.stdout == "" - - -def test_info_property_short_flag(binary_path, generate_cats_and_dogs_mpq): - test_file = generate_cats_and_dogs_mpq - - short_form = subprocess.run( - [str(binary_path), "info", "-q", "file-count", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - long_form = subprocess.run( - [str(binary_path), "info", "--property", "file-count", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - assert short_form.returncode == 0, f"mpqcli failed with error: {short_form.stderr}" - assert short_form.stdout.strip() != "" - assert short_form.stdout == long_form.stdout diff --git a/test/test_list.py b/test/test_list.py deleted file mode 100644 index 360651f..0000000 --- a/test/test_list.py +++ /dev/null @@ -1,600 +0,0 @@ -import subprocess -from pathlib import Path -import platform - - -def test_list_mpq_with_output_v1(binary_path): - """ - Test MPQ file listing with no parameters. - - This test checks: - - That running the list command with no parameters renders a list of the files inside, with no details. - """ - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - - expected_output = { - "bytes", - "cats.txt", - "dogs.txt", - } - - result = subprocess.run( - [str(binary_path), "list", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - assert len(result.stdout.splitlines()) == len(expected_output) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_list_mpq_with_standard_details(binary_path): - """ - Test MPQ file listing with the standard details. - - This test checks: - - That the standard long listing parameters work as expected. - """ - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - - expected_output = { - " 8 enUS 2025-07-29 14:31:00 bytes", - " 27 enUS 2025-07-29 14:31:00 cats.txt", - " 27 enUS 2025-07-29 14:31:00 dogs.txt", - " 27 enUS (listfile)", - " 149 enUS (attributes)", - } - - # Adjust filesize for Windows - if platform.system() == "Windows": - expected_output.remove(" 27 enUS 2025-07-29 14:31:00 dogs.txt") - expected_output.add(" 28 enUS 2025-07-29 14:31:00 dogs.txt") - expected_output.remove(" 27 enUS 2025-07-29 14:31:00 cats.txt") - expected_output.add(" 28 enUS 2025-07-29 14:31:00 cats.txt") - - result = subprocess.run( - [str(binary_path), "list", "-a", "-d", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - assert len(result.stdout.splitlines()) == len(expected_output) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_list_mpq_with_specified_details(binary_path): - """ - Test MPQ file listing with specified details. - - This test checks: - - That providing parameters to be listed works as expected. - """ - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - - expected_output = { - " 35 147178ed c99b9ee2 0000000000000000 16 cexmn eaa753f9 bytes", - " 44 0fd58937 70ab788e 0000000000000000 35 cexmn 935a7772 cats.txt", - " 0 eb30456b 48345fbb 0000000000000000 35 cexmn a073c614 dogs.txt", - " 25 fd657910 4e9b98a7 0000000000000000 35 ce2xmnf 2d2f0a94 (listfile)", - " 14 d38437cb 07dfeaec 0000000000000000 124 ce2xmnf 50e314af (attributes)", - } - # Adjust filesize for Windows - if platform.system() == "Windows": - expected_output.remove(" 44 0fd58937 70ab788e 0000000000000000 35 cexmn 935a7772 cats.txt") - expected_output.add(" 44 0fd58937 70ab788e 0000000000000000 36 cexmn 935a7772 cats.txt") - expected_output.remove(" 0 eb30456b 48345fbb 0000000000000000 35 cexmn a073c614 dogs.txt") - expected_output.add(" 0 eb30456b 48345fbb 0000000000000000 36 cexmn a073c614 dogs.txt") - - result = subprocess.run( - [str(binary_path), "list", "-a", "-d", str(test_file), - "--property", "hash-index", "--property", "name-hash1", "--property", "name-hash2", "--property", "name-hash3", - "--property", "compressed-size", "--property", "flags", "--property", "encryption-key-raw"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - assert len(result.stdout.splitlines()) == len(expected_output) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_list_mpq_with_weak_signature(binary_path): - """ - Test MPQ file listing of MPQ with weak signature. - - This test checks: - - That handling MPQs with weak signatures generates the expected output. - """ - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_weak_signature.mpq" - - expected_output = { - "bytes", - "cats.txt", - "dogs.txt", - "(listfile)", # Only included as "-a" specified - "(signature)", # Only included as "-a" specified - "(attributes)", # Only included as "-a" specified - } - - result = subprocess.run( - [str(binary_path), "list", "-a", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - assert len(result.stdout.splitlines()) == len(expected_output) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_list_mpq_without_providing_listfile(binary_path, generate_mpq_without_internal_listfile): - """ - Test MPQ file listing of MPQ that contains no internal listfile. - - This test checks: - - That handling MPQs with no internal listfile generates the expected output. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - - ## No flags - expected_output = { - "File00000000.xxx", - "File00000001.xxx", - "File00000002.xxx", - } - result = subprocess.run( - [str(binary_path), "list", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - output_lines = set(result.stdout.splitlines()) - assert len(result.stdout.splitlines()) == len(expected_output) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - ## --all flag - expected_output = { - "File00000000.xxx", - "File00000001.xxx", - "File00000002.xxx", - } - result = subprocess.run( - [str(binary_path), "list", "-a", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - output_lines = set(result.stdout.splitlines()) - assert len(result.stdout.splitlines()) == len(expected_output) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - ## --all, --detailed flag - if platform.system() != "Windows": - expected_output = { - " 31 enUS File00000000.xxx", - " 33 deDE File00000001.xxx", - " 27 041D File00000002.xxx", - } - else: - expected_output = { - " 31 enUS File00000000.xxx", - " 32 deDE File00000001.xxx", - " 26 041D File00000002.xxx", - } - - result = subprocess.run( - [str(binary_path), "list", "-ad", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - output_lines = set(result.stdout.splitlines()) - assert len(result.stdout.splitlines()) == len(expected_output) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_list_mpq_providing_partial_external_listfile(binary_path, generate_mpq_without_internal_listfile): - """ - Test MPQ file listing of MPQ that contains no internal listfile, when providing a partially compete external listfile. - - This test checks: - - That handling MPQs with no internal listfile generates the expected output. - - That providing a partially complete external listfile shows the files it lists. - - That the files not listed in the external listfile still show up in the output. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - listfile = script_dir / "data" / "listfile.txt" - listfile.write_text("cats.txt") - - ## No flags - expected_output = { - "File00000000.xxx", - "File00000002.xxx", - "cats.txt", - } - result = subprocess.run( - [str(binary_path), "list", str(test_file), "--listfile", str(listfile)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - output_lines = set(result.stdout.splitlines()) - assert len(result.stdout.splitlines()) == len(expected_output) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - ## --all flag - expected_output = { - "File00000000.xxx", - "File00000002.xxx", - "cats.txt", - } - result = subprocess.run( - [str(binary_path), "list", "-a", str(test_file), "--listfile", str(listfile)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - output_lines = set(result.stdout.splitlines()) - assert len(result.stdout.splitlines()) == len(expected_output) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - ## --all, --detailed flag - if platform.system() != "Windows": - expected_output = { - " 31 enUS File00000000.xxx", - " 27 041D File00000002.xxx", - " 33 deDE cats.txt", - } - else: - expected_output = { - " 31 enUS File00000000.xxx", - " 26 041D File00000002.xxx", - " 32 deDE cats.txt", - } - result = subprocess.run( - [str(binary_path), "list", "-ad", str(test_file), "--listfile", str(listfile)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - output_lines = set(result.stdout.splitlines()) - assert len(result.stdout.splitlines()) == len(expected_output) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_list_mpq_providing_complete_external_listfile(binary_path, generate_mpq_without_internal_listfile): - """ - Test MPQ file listing of MPQ that contains no internal listfile, when providing a compete external listfile. - - This test checks: - - That handling MPQs with no internal listfile generates the expected output. - - That providing a complete external listfile shows the files it lists. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - listfile = script_dir / "data" / "listfile.txt" - listfile.write_text("cats.txt\ndogs.txt\ncapybaras.txt") - - ## No flags - expected_output = { - "capybaras.txt", - "cats.txt", - "dogs.txt", - } - result = subprocess.run( - [str(binary_path), "list", str(test_file), "--listfile", str(listfile)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - output_lines = set(result.stdout.splitlines()) - assert len(result.stdout.splitlines()) == len(expected_output) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - ## --all flag - expected_output = { - "capybaras.txt", - "cats.txt", - "dogs.txt", - } - result = subprocess.run( - [str(binary_path), "list", "-a", str(test_file), "--listfile", str(listfile)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - output_lines = set(result.stdout.splitlines()) - assert len(result.stdout.splitlines()) == len(expected_output) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - ## --all, --detailed flag - if platform.system() != "Windows": - expected_output = { - " 31 enUS capybaras.txt", - " 33 deDE cats.txt", - " 27 041D dogs.txt", - } - else: - expected_output = { - " 31 enUS capybaras.txt", - " 32 deDE cats.txt", - " 26 041D dogs.txt", - } - - result = subprocess.run( - [str(binary_path), "list", "-ad", str(test_file), "--listfile", str(listfile)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - output_lines = set(result.stdout.splitlines()) - assert len(result.stdout.splitlines()) == len(expected_output) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_list_mpq_target_does_not_exist(binary_path): - script_dir = Path(__file__).parent - target_file = script_dir / "does" / "not" / "exist.mpq" - - result = subprocess.run( - [str(binary_path), "list", str(target_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - - -def test_list_mpq_with_nonexistent_listfile(binary_path): - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - listfile = script_dir / "does" / "not" / "exist.txt" - - result = subprocess.run( - [str(binary_path), "list", str(test_file), "--listfile", str(listfile)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - - -def test_list_mpq_property_file_index(binary_path): - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - - expected_output = { - " 0 bytes", - " 1 cats.txt", - " 2 dogs.txt", - " 3 (listfile)", - " 4 (attributes)", - } - - result = subprocess.run( - [str(binary_path), "list", "-a", "-d", str(test_file), "--property", "file-index"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_list_mpq_property_byte_offset(binary_path): - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - - expected_output = { - " 20 bytes", - " 30 cats.txt", - " 53 dogs.txt", - " 76 (listfile)", - " 99 (attributes)", - } - if platform.system() == "Windows": - expected_output.discard(" 53 dogs.txt") - expected_output.add(" 54 dogs.txt") - expected_output.discard(" 76 (listfile)") - expected_output.add(" 78 (listfile)") - expected_output.discard(" 99 (attributes)") - expected_output.add(" 9b (attributes)") - - result = subprocess.run( - [str(binary_path), "list", "-a", "-d", str(test_file), "--property", "byte-offset"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_list_mpq_property_file_time(binary_path): - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - - expected_output = { - "2025-07-29 14:31:00 bytes", - "2025-07-29 14:31:00 cats.txt", - "2025-07-29 14:31:00 dogs.txt", - " (listfile)", - " (attributes)", - } - - result = subprocess.run( - [str(binary_path), "list", "-a", "-d", str(test_file), "--property", "file-time"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_list_mpq_property_encryption_key(binary_path): - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - - expected_output = { - "eaa753f9 bytes", - "935a7772 cats.txt", - "a073c614 dogs.txt", - "2d2f0b11 (listfile)", - "50e315dd (attributes)", - } - if platform.system() == "Windows": - expected_output.discard("2d2f0b11 (listfile)") - expected_output.add("2d2f0b17 (listfile)") - expected_output.discard("50e315dd (attributes)") - expected_output.add("50e315df (attributes)") - - result = subprocess.run( - [str(binary_path), "list", "-a", "-d", str(test_file), "--property", "encryption-key"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_list_mpq_property_locale(binary_path): - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - - expected_output = { - "enUS bytes", - "enUS cats.txt", - "enUS dogs.txt", - "enUS (listfile)", - "enUS (attributes)", - } - - result = subprocess.run( - [str(binary_path), "list", "-a", "-d", str(test_file), "--property", "locale"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_list_encryption_keys_of_files_listed_without_names(binary_path, generate_mpq_without_internal_listfile, tmp_path): - """ - Test MPQ file listing of encryption keys, for encrypted files whose names aren't known. - - This test checks: - - That an encrypted file listed without its name shows the same encryption-key and - encryption-key-raw as when it is listed by name, and not zero. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - listfile = tmp_path / "listfile.txt" - listfile.write_text("capybaras.txt\ncats.txt\ndogs.txt\n", newline="\n") - - def keys_by_file_index(*extra_args): - result = subprocess.run( - [str(binary_path), "list", "-d", str(test_file), *extra_args, - "--property", "file-index", "--property", "flags", "--property", "encryption-key", "--property", "encryption-key-raw"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - keys = {} - for line in result.stdout.splitlines(): - file_index, flags, key, key_raw, name = line.split(maxsplit=4) - if "e" in flags: # Only encrypted files have a key - (signature) isn't encrypted - keys[file_index] = (flags, key, key_raw, name) - return keys - - without_names = keys_by_file_index() - with_names = keys_by_file_index("-l", str(listfile)) - - assert len(without_names) == 3 - assert without_names.keys() == with_names.keys() - for file_index, (flags, key, key_raw, name) in without_names.items(): - assert name.startswith("File") and name.endswith(".xxx"), f"Unexpected name: {name}" - assert key != "00000000", f"No encryption key found for file {file_index}" - assert (key, key_raw) == with_names[file_index][1:3], \ - f"Keys of file {file_index} differ: {(key, key_raw)} vs {with_names[file_index][1:3]}" - - -def test_list_short_property_flag_is_rejected(binary_path, generate_cats_and_dogs_mpq): - test_file = generate_cats_and_dogs_mpq - - result = subprocess.run( - [str(binary_path), "list", "-p", "locale", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - # -p is not a list option, so the property name is taken as the archive - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - assert "archive: File does not exist: locale" in result.stderr - assert result.stdout == "" - - -def test_list_property_short_flag(binary_path, generate_cats_and_dogs_mpq): - test_file = generate_cats_and_dogs_mpq - - short_form = subprocess.run( - [str(binary_path), "list", "-q", "locale", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - long_form = subprocess.run( - [str(binary_path), "list", "--property", "locale", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert short_form.returncode == 0, f"mpqcli failed with error: {short_form.stderr}" - assert short_form.stdout.strip() != "" - assert short_form.stdout == long_form.stdout diff --git a/test/test_read.py b/test/test_read.py deleted file mode 100644 index b9566ea..0000000 --- a/test/test_read.py +++ /dev/null @@ -1,311 +0,0 @@ -import re -import subprocess -from pathlib import Path - -import pytest - - -def test_read_mpq_v1_plain(binary_path): - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - - expected_output = { - "This is a file about cats.", - } - - result = subprocess.run( - [str(binary_path), "read", str(test_file), "cats.txt"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - # Remove empty lines from output, Windows adds an extra empty line - output_lines = set(line for line in result.stdout.splitlines() if line.strip() != "") - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_read_mpq_v1_hex(binary_path): - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - - expected_output = { - "\x00\x01\x02\x03\x04\x05\x06\x07", - } - - result = subprocess.run( - [str(binary_path), "read", str(test_file), "bytes"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_read_file_from_mpq_with_locale(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ archive file reading with a specified locale. - - This test checks: - - If the file with the given locale is read correctly. - - That the file content is correct. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_many_locales.mpq" - file_to_read = "cats.txt" - locale = "deDE" - - result = subprocess.run( - [str(binary_path), "read", str(test_file), file_to_read, "--locale", locale], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - expected_content = { - "Dies ist eine Datei über Katzen.", - } - - output_lines = set(result.stdout.splitlines()) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_content, f"Unexpected output: {output_lines}" - - -def test_read_file_from_mpq_with_short_locale_flag(binary_path, generate_locales_mpq_test_files): - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_many_locales.mpq" - - result = subprocess.run( - [str(binary_path), "read", str(test_file), "cats.txt", "-e", "deDE"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert result.stdout.splitlines() == ["Dies ist eine Datei über Katzen."] - - -@pytest.mark.parametrize("subcommand", ["add", "create", "extract", "read", "remove", "rename"]) -def test_locale_short_flag_is_e_on_every_subcommand(binary_path, subcommand): - # A short flag means one thing across the binary, so every subcommand with a - # locale offers it as -e - result = subprocess.run( - [str(binary_path), subcommand, "--help"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert re.search(r"^\s+-e,\s+--locale\b", result.stdout, re.MULTILINE), result.stdout - - -def test_read_file_from_mpq_with_default_locale(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ archive file reading with no specified locale but many matching file names. - - This test checks: - - If the file with the default locale is read correctly. - - That the file content is correct. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_many_locales.mpq" - file_to_read = "cats.txt" - - - result = subprocess.run( - [str(binary_path), "read", str(test_file), file_to_read], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - expected_content = { - "This is a file about cats.", - } - - output_lines = set(result.stdout.splitlines()) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_content, f"Unexpected output: {output_lines}" - - -def test_read_file_from_mpq_with_illegal_locale(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ archive file reading with an illegal locale. - - This test checks: - - That an unknown locale is rejected and nothing is read. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_many_locales.mpq" - file_to_read = "cats.txt" - locale = "nosuchlocale" - - result = subprocess.run( - [str(binary_path), "read", str(test_file), file_to_read, "--locale", locale], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - assert result.stdout == "", f"Unexpected output: {result.stdout}" - - -def test_read_file_from_mpq_with_locale_not_in_file(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ archive file reading with a specified locale that is not in the file. - - This test checks: - - When a locale is given that does not exist in the file, the file with the default locale is read. - - That the file content is correct. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_many_locales.mpq" - file_to_read = "cats.txt" - locale = "ptPT" # There is no file for this locale - - result = subprocess.run( - [str(binary_path), "read", str(test_file), file_to_read, "--locale", locale], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - expected_content = { - "This is a file about cats.", - } - - output_lines = set(result.stdout.splitlines()) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_content, f"Unexpected output: {output_lines}" - - -def test_read_file_from_mpq_with_no_locale_argument_and_no_default_locale(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ archive file reading without a specified locale, and no file with the default locale. - - This test checks: - - When no locale is given, and no file by that name exists for the default locale, - but one does for a different one, no file is read. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_one_locale.mpq" - file_to_read = "cats.txt" # There is a file by this name, but for locale esES - - - result = subprocess.run( - [str(binary_path), "read", str(test_file), file_to_read], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - expected_stdout_output = set() - expected_stderr_output = { - "[!] Failed: File doesn't exist for locale enUS: " + file_to_read, - } - - stdout_output_lines = set(result.stdout.splitlines()) - stderr_output_lines = set(result.stderr.splitlines()) - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert stdout_output_lines == expected_stdout_output, f"Unexpected output: {stdout_output_lines}" - assert stderr_output_lines == expected_stderr_output, f"Unexpected output: {stderr_output_lines}" - - -def test_read_file_from_mpq_with_wrong_locale_argument_and_no_default_locale(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ archive file reading with a specified locale, and no file with the default locale. - - This test checks: - - When no locale is given, and no file by that name exists for the default locale, - but one does for a different one, no file is read. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_one_locale.mpq" - file_to_read = "cats.txt" # There is a file by this name, but for locale esES - locale = "deDE" - - - result = subprocess.run( - [str(binary_path), "read", str(test_file), file_to_read, "--locale", locale], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - expected_stdout_output = set() - expected_stderr_output = { - "[!] Failed: File doesn't exist for locale deDE: " + file_to_read, - } - - stdout_output_lines = set(result.stdout.splitlines()) - stderr_output_lines = set(result.stderr.splitlines()) - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert stdout_output_lines == expected_stdout_output, f"Unexpected output: {stdout_output_lines}" - assert stderr_output_lines == expected_stderr_output, f"Unexpected output: {stderr_output_lines}" - - -def test_read_mpq_target_does_not_exist(binary_path): - script_dir = Path(__file__).parent - target_file = script_dir / "does" / "not" / "exist.mpq" - - result = subprocess.run( - [str(binary_path), "read", str(target_file), "cats.txt"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - - -def test_read_file_does_not_exist_in_mpq(binary_path): - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - - result = subprocess.run( - [str(binary_path), "read", str(test_file), "does-not-exist.txt"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 1, f"Unexpected exit code: {result.returncode}" - assert result.stdout == "", f"Unexpected stdout: {result.stdout}" - assert "does-not-exist.txt" in result.stderr, f"Unexpected stderr: {result.stderr}" - - -def test_read_old_argument_order_is_rejected(binary_path, generate_locales_mpq_test_files, tmp_path): - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_many_locales.mpq" - - result = subprocess.run( - [str(binary_path), "read", "cats.txt", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - cwd=tmp_path, - ) - - # The file name is taken as the archive, and no local file has that name - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - assert "archive: File does not exist: cats.txt" in result.stderr - assert result.stdout == "" diff --git a/test/test_remove.py b/test/test_remove.py deleted file mode 100644 index b45482c..0000000 --- a/test/test_remove.py +++ /dev/null @@ -1,335 +0,0 @@ -import subprocess -from pathlib import Path - - -def test_remove_target_mpq_does_not_exist(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ file removal with a non-existent target. - - This test checks: - - If the application exits correctly when the target does not exist. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = "cats.txt" - target_file = script_dir / "does" / "not" / "exist.mpq" - - result = subprocess.run( - [str(binary_path), "remove", str(target_file), str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - - -def test_remove_target_file_does_not_exist(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ file removal with a non-existent file to remove. - - This test checks: - - If the application exits correctly when the target file to remove does not exist. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = "does-not-exist.txt" - target_file = script_dir / "data" / "mpq_with_many_locales.mpq" - - result = subprocess.run( - [str(binary_path), "remove", str(target_file), str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - expected_stdout_output = set() - assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" - - output_lines = set(result.stderr.splitlines()) - expected_stderr_output = { - "[-] Removing file: does-not-exist.txt", - "[!] Failed: File doesn't exist for locale enUS: does-not-exist.txt", - } - assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - - -def test_remove_file_from_mpq_archive_with_wrong_locale_given( - binary_path, - generate_locales_mpq_test_files, - generate_mpq_without_internal_listfile, -): - """ - Test MPQ file removal, with the wrong locale given. - - This test checks: - - When the user gives a locale that does not exist for the file, - the file is not deleted. - """ - _ = generate_locales_mpq_test_files - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - - permutations = [ - ("capybaras.txt", "mpq_without_internal_listfile.mpq"), # File exists only for the Default locale - ("cats.txt", "mpq_without_internal_listfile.mpq"), # File exists only for the German locale - ("dogs.txt", "mpq_without_internal_listfile.mpq"), # File exists only for the Swedish locale (which is not in locales.cpp) - ("cats.txt", "mpq_with_many_locales.mpq"), # File exists for many locales - ] - - for test_file, target_file_name in permutations: - target_file = script_dir / "data" / target_file_name - - result = subprocess.run( - [str(binary_path), "remove", str(target_file), str(test_file), "--locale", "ptPT"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - expected_output = set() - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - output_lines = set(result.stderr.splitlines()) - expected_stderr_output = { - "[-] Removing file for locale ptPT: " + test_file, - "[!] Failed: File doesn't exist for locale ptPT: " + test_file, - } - assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - - -def test_remove_default_locale_file_from_mpq_archive_unique_name(binary_path, generate_mpq_without_internal_listfile): - """ - Test MPQ file removal, with no locale given. - - This test checks: - - When there is only one file with the same name and default locale, - it should be deleted, when no locale is given by the user. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - test_file = "capybaras.txt" - target_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - - result = subprocess.run( - [str(binary_path), "remove", str(target_file), str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - expected_output = set() - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - output_lines = set(result.stderr.splitlines()) - expected_stderr_output = { - "[-] Removing file: capybaras.txt", - } - assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - -def test_remove_files_from_mpq_archive_shared_name(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ file removal with locale. - - This test checks: - - If the application correctly handles removing a file with - the given locale from an MPQ archive. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = "cats.txt" - target_file = script_dir / "data" / "mpq_with_many_locales.mpq" - - expected_output = { - "enUS cats.txt", - "deDE cats.txt", - "esES cats.txt", - "041D cats.txt", - } - verify_archive_content(binary_path, target_file, expected_output) - - # Removing without specifying locale means removing using locale 0 = enUS - result = subprocess.run( - [str(binary_path), "remove", str(target_file), str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - expected_output = { - "deDE cats.txt", - "esES cats.txt", - "041D cats.txt", - } - verify_archive_content(binary_path, target_file, expected_output) - - - result = subprocess.run( - [str(binary_path), "remove", str(target_file), str(test_file), "--locale", "esES"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - expected_output = { - "deDE cats.txt", - "041D cats.txt", - } - verify_archive_content(binary_path, target_file, expected_output) - - - result = subprocess.run( - [str(binary_path), "remove", str(target_file), str(test_file), "--locale", "041D"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - expected_output = { - "deDE cats.txt", - } - verify_archive_content(binary_path, target_file, expected_output) - - -def test_remove_files_from_mpq_archive_unique_name(binary_path, generate_mpq_without_internal_listfile): - """ - Test MPQ file removal with locale. - - This test checks: - - If the application correctly handles removing a file with - the given locale from an MPQ archive. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - test_file = "dogs.txt" - target_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - listfile = script_dir / "data" / "listfile.txt" - listfile.write_text("cats.txt\ndogs.txt\ncapybaras.txt") - - expected_output = { - "enUS capybaras.txt", - "deDE cats.txt", - "041D dogs.txt", - } - verify_archive_content(binary_path, target_file, expected_output, listfile) - - # Removing without specifying locale means removing using locale 0 = enUS - result = subprocess.run( - [str(binary_path), "remove", str(target_file), str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - - expected_output = { - "enUS capybaras.txt", - "deDE cats.txt", - "041D dogs.txt", - } - verify_archive_content(binary_path, target_file, expected_output, listfile) - - - result = subprocess.run( - [str(binary_path), "remove", str(target_file), str(test_file), "--locale", "041D"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - expected_output = { - "enUS capybaras.txt", - "deDE cats.txt", - } - verify_archive_content(binary_path, target_file, expected_output, listfile) - - -def test_remove_multiple_files_at_once(binary_path, generate_locales_mpq_test_files): - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "mpq_with_many_locales.mpq" - - result = subprocess.run( - [str(binary_path), "remove", str(target_file), "cats.txt", "--locale", "deDE"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"First remove failed: {result.stderr}" - - expected_output = { - "enUS cats.txt", - "esES cats.txt", - "041D cats.txt", - } - verify_archive_content(binary_path, target_file, expected_output) - - result = subprocess.run( - [str(binary_path), "remove", str(target_file), "cats.txt", "cats.txt", "--locale", "esES"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"Second remove failed: {result.stderr}" - - expected_output = { - "enUS cats.txt", - "041D cats.txt", - } - verify_archive_content(binary_path, target_file, expected_output) - - -def test_remove_files_via_stdin(binary_path, generate_locales_mpq_test_files): - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "mpq_with_many_locales.mpq" - - stdin_input = "cats.txt\n" - - result = subprocess.run( - [str(binary_path), "remove", str(target_file), "-"], - input=stdin_input, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[-] Removing file: cats.txt" in result.stderr - - expected_output = { - "deDE cats.txt", - "esES cats.txt", - "041D cats.txt", - } - verify_archive_content(binary_path, target_file, expected_output) - - -def verify_archive_content(binary_path, target_file, expected_output, listfile = Path()): - # Verify that the archive has the expected content - cmd = [str(binary_path), "list", "-d", str(target_file), "--property", "locale"] - if listfile != Path(): - cmd.extend(["--listfile", str(listfile)]) - - result = subprocess.run( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - output_lines = set(result.stdout.splitlines()) - assert output_lines == expected_output, f"Unexpected output: {output_lines}" diff --git a/test/test_rename.py b/test/test_rename.py deleted file mode 100644 index bda8797..0000000 --- a/test/test_rename.py +++ /dev/null @@ -1,395 +0,0 @@ -import subprocess -from pathlib import Path - - -def test_rename_file_in_mpq_archive(binary_path, generate_mpq_without_internal_listfile): - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - listfile = script_dir / "data" / "listfile.txt" - listfile.write_text("cats.txt\ndogs.txt\ncapybaras.txt\nrenamed.txt\n") - - result = subprocess.run( - [str(binary_path), "rename", str(target_file), "capybaras.txt", "renamed.txt"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Renaming file: capybaras.txt -> renamed.txt" in result.stderr - - result = subprocess.run( - [str(binary_path), "list", str(target_file), "--listfile", str(listfile)], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True - ) - assert result.returncode == 0 - assert "renamed.txt" in result.stdout - assert "capybaras.txt" not in result.stdout - - -def test_rename_missing_file_fails(binary_path, generate_mpq_without_internal_listfile): - target_file = Path(__file__).parent / "data" / "mpq_without_internal_listfile.mpq" - result = subprocess.run( - [str(binary_path), "rename", str(target_file), "missing.txt", "renamed.txt"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True - ) - - assert result.returncode == 1 - assert "[!] Failed: File doesn't exist" in result.stderr - - -def test_rename_target_mpq_does_not_exist(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ file rename with a non-existent target. - - This test checks: - - If the application exits correctly when the target does not exist. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = "cats.txt" - target_file = script_dir / "does" / "not" / "exist.mpq" - - result = subprocess.run( - [str(binary_path), "rename", str(target_file), test_file, "renamed.txt"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - - -def test_rename_target_file_does_not_exist(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ file rename with a non-existent file to rename. - - This test checks: - - If the application exits correctly when the target file to rename does not exist. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = "does-not-exist.txt" - target_file = script_dir / "data" / "mpq_with_many_locales.mpq" - - result = subprocess.run( - [str(binary_path), "rename", str(target_file), test_file, "renamed.txt"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - expected_stdout_output = set() - assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" - - output_lines = set(result.stderr.splitlines()) - expected_stderr_output = { - "[~] Renaming file: does-not-exist.txt -> renamed.txt", - "[!] Failed: File doesn't exist for locale enUS: does-not-exist.txt", - } - assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - - -def test_rename_file_from_mpq_archive_with_wrong_locale_given( - binary_path, - generate_locales_mpq_test_files, - generate_mpq_without_internal_listfile, -): - """ - Test MPQ file rename, with the wrong locale given. - - This test checks: - - When the user gives a locale that does not exist for the file, - the file is not renamed. - """ - _ = generate_locales_mpq_test_files - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - - permutations = [ - ("capybaras.txt", "mpq_without_internal_listfile.mpq"), # File exists only for the Default locale - ("cats.txt", "mpq_without_internal_listfile.mpq"), # File exists only for the German locale - ("dogs.txt", "mpq_without_internal_listfile.mpq"), # File exists only for the Swedish locale (which is not in locales.cpp) - ("cats.txt", "mpq_with_many_locales.mpq"), # File exists for many locales - ] - - for test_file, target_file_name in permutations: - target_file = script_dir / "data" / target_file_name - - result = subprocess.run( - [str(binary_path), "rename", str(target_file), test_file, "renamed.txt", "--locale", "ptPT"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - expected_output = set() - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - output_lines = set(result.stderr.splitlines()) - expected_stderr_output = { - "[~] Renaming file for locale ptPT: " + test_file + " -> renamed.txt", - "[!] Failed: File doesn't exist for locale ptPT: " + test_file, - } - assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - - -def test_rename_default_locale_file_from_mpq_archive_unique_name(binary_path, generate_mpq_without_internal_listfile): - """ - Test MPQ file rename, with no locale given. - - This test checks: - - When there is only one file with the same name and default locale, - it should be renamed, when no locale is given by the user. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - test_file = "capybaras.txt" - target_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - - result = subprocess.run( - [str(binary_path), "rename", str(target_file), test_file, "renamed.txt"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stdout.splitlines()) - expected_output = set() - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - output_lines = set(result.stderr.splitlines()) - expected_stderr_output = { - "[~] Renaming file: capybaras.txt -> renamed.txt", - } - assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - -def test_rename_files_from_mpq_archive_shared_name(binary_path, generate_locales_mpq_test_files): - """ - Test MPQ file rename with locale, for files sharing the same name across locales. - - This test checks: - - If the application correctly handles renaming a file with - the given locale from an MPQ archive, without affecting the other - locale copies that share the same name. - """ - _ = generate_locales_mpq_test_files - script_dir = Path(__file__).parent - test_file = "cats.txt" - target_file = script_dir / "data" / "mpq_with_many_locales.mpq" - - expected_output = { - "enUS cats.txt", - "deDE cats.txt", - "esES cats.txt", - "041D cats.txt", - } - verify_archive_content(binary_path, target_file, expected_output) - - # Renaming without specifying locale means renaming using locale 0 = enUS - result = subprocess.run( - [str(binary_path), "rename", str(target_file), test_file, "renamed_enus.txt"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - expected_output = { - "enUS renamed_enus.txt", - "deDE cats.txt", - "esES cats.txt", - "041D cats.txt", - } - verify_archive_content(binary_path, target_file, expected_output) - - - result = subprocess.run( - [str(binary_path), "rename", str(target_file), test_file, "renamed_eses.txt", "--locale", "esES"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - expected_output = { - "enUS renamed_enus.txt", - "deDE cats.txt", - "esES renamed_eses.txt", - "041D cats.txt", - } - verify_archive_content(binary_path, target_file, expected_output) - - - result = subprocess.run( - [str(binary_path), "rename", str(target_file), test_file, "renamed_041d.txt", "--locale", "041D"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - expected_output = { - "enUS renamed_enus.txt", - "deDE cats.txt", - "esES renamed_eses.txt", - "041D renamed_041d.txt", - } - verify_archive_content(binary_path, target_file, expected_output) - - -def test_rename_files_from_mpq_archive_unique_name(binary_path, generate_mpq_without_internal_listfile): - """ - Test MPQ file rename with locale, for a file with a unique name stored - under a non-default locale. - - This test checks: - - If the application correctly handles renaming a file with - the given locale from an MPQ archive. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - test_file = "dogs.txt" - target_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - listfile = script_dir / "data" / "listfile.txt" - listfile.write_text("cats.txt\ndogs.txt\ncapybaras.txt\nrenamed.txt") - - expected_output = { - "enUS capybaras.txt", - "deDE cats.txt", - "041D dogs.txt", - } - verify_archive_content(binary_path, target_file, expected_output, listfile) - - # Renaming without specifying locale means renaming using locale 0 = enUS; - # dogs.txt only exists for locale 041D, so this should fail. - result = subprocess.run( - [str(binary_path), "rename", str(target_file), test_file, "renamed.txt"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - - expected_output = { - "enUS capybaras.txt", - "deDE cats.txt", - "041D dogs.txt", - } - verify_archive_content(binary_path, target_file, expected_output, listfile) - - - result = subprocess.run( - [str(binary_path), "rename", str(target_file), test_file, "renamed.txt", "--locale", "041D"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - expected_output = { - "enUS capybaras.txt", - "deDE cats.txt", - "041D renamed.txt", - } - verify_archive_content(binary_path, target_file, expected_output, listfile) - - -def test_rename_to_existing_name_fails(binary_path, generate_mpq_without_internal_listfile, tmp_path): - """ - Test MPQ file rename to a name that already exists for the same locale. - - This test checks: - - If the application fails, and leaves the archive unchanged, when - renaming a file to a name that is already used by another file - under the same locale. - """ - _ = generate_mpq_without_internal_listfile - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - - # Add a second file under the default locale, so that "capybaras.txt" - # (also default locale) has a name collision to rename into. - extra_file = tmp_path / "elephants.txt" - extra_file.write_text("This is a file about elephants.\n", newline="\n") - - result = subprocess.run( - [str(binary_path), "add", str(target_file), str(extra_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - result = subprocess.run( - [str(binary_path), "rename", str(target_file), "capybaras.txt", "elephants.txt"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 1, f"mpqcli unexpectedly succeeded: {result.stdout}" - assert "[~] Renaming file: capybaras.txt -> elephants.txt" in result.stderr - assert "[!] Failed: File cannot be renamed for locale enUS: capybaras.txt -> elephants.txt" in result.stderr - - # Verify the archive is unchanged: both files should still exist under their original names. - listfile = script_dir / "data" / "listfile.txt" - listfile.write_text("cats.txt\ndogs.txt\ncapybaras.txt\nelephants.txt") - - result = subprocess.run( - [str(binary_path), "list", str(target_file), "--listfile", str(listfile)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0 - assert "capybaras.txt" in result.stdout - assert "elephants.txt" in result.stdout - - -def verify_archive_content(binary_path, target_file, expected_output, listfile=Path()): - # Verify that the archive has the expected content - cmd = [str(binary_path), "list", "-d", str(target_file), "--property", "locale"] - if listfile != Path(): - cmd.extend(["--listfile", str(listfile)]) - - result = subprocess.run( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - output_lines = set(result.stdout.splitlines()) - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_rename_converts_forward_slashes_to_backslashes(binary_path, generate_mpq_without_internal_listfile): - """A new name containing forward slashes is stored using the backslash - separator that MPQ archives use, matching how add and create store paths.""" - script_dir = Path(__file__).parent - target_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" - listfile = script_dir / "data" / "listfile.txt" - listfile.write_text("cats.txt\ndogs.txt\ncapybaras.txt\ndeep\\renamed.txt\n") - - result = subprocess.run( - [str(binary_path), "rename", str(target_file), "capybaras.txt", "deep/renamed.txt"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Renaming file: capybaras.txt -> deep\\renamed.txt" in result.stderr - - result = subprocess.run( - [str(binary_path), "list", str(target_file), "--listfile", str(listfile)], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True - ) - assert result.returncode == 0 - assert "deep\\renamed.txt" in result.stdout - assert "deep/renamed.txt" not in result.stdout diff --git a/test/test_verify.py b/test/test_verify.py deleted file mode 100644 index b894ccf..0000000 --- a/test/test_verify.py +++ /dev/null @@ -1,216 +0,0 @@ -import hashlib -from pathlib import Path -import subprocess - - -def test_verify_no_signature(binary_path): - """ - Test MPQ file verification with no signature. - - This test checks: - - If the application exits correctly when the MPQ file has no signature. - - If the failure message distinguishes "no signature" from other failures. - """ - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" - - expected_output = { - "[!] Verify failed: archive has no signature", - } - - result = subprocess.run( - [str(binary_path), "verify", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - stdout_lines = set(result.stdout.splitlines()) - stderr_lines = set(result.stderr.splitlines()) - - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert stdout_lines == set(), f"Unexpected stdout: {stdout_lines}" - assert stderr_lines == expected_output, f"Unexpected stderr: {stderr_lines}" - - -def test_verify_tampered_signature(binary_path, generate_tampered_signature_mpq): - """ - Test MPQ file verification with a signature that is present but invalid. - - This test checks: - - If the application detects an archive that was modified after signing - (signature present, but no longer valid for the current content). - - If the application exits with a non-zero code. - - If the failure message distinguishes "invalid signature" from the - "no signature" case, instead of printing the same generic failure. - """ - test_file = generate_tampered_signature_mpq - - expected_output = { - "[!] Verify failed: signature is present but invalid", - } - - result = subprocess.run( - [str(binary_path), "verify", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - stdout_lines = set(result.stdout.splitlines()) - stderr_lines = set(result.stderr.splitlines()) - - assert result.returncode == 1, f"mpqcli unexpectedly succeeded: {result.stdout}" - assert stdout_lines == set(), f"Unexpected stdout: {stdout_lines}" - assert stderr_lines == expected_output, f"Unexpected stderr: {stderr_lines}" - - -def test_verify_weak_signature(binary_path): - """ - Test MPQ file verification with weak signature. - - This test checks: - - If the application exits with 0 when the MPQ file has a valid weak signature. - - If the application prints the success message. - """ - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_weak_signature.mpq" - - expected_output = { - "[*] Verify success", - } - - result = subprocess.run( - [str(binary_path), "verify", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - output_lines = set(result.stderr.splitlines()) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_output, f"Unexpected output: {output_lines}" - - -def test_verify_weak_signature_patch(binary_path, download_test_files): - """ - Test MPQ file verification with and printing of weak signature. - - This test checks: - - If the application exits correctly when the MPQ file has a weak signature. - - If the application correctly prints a weak signature. - """ - _ = download_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "v1_weak_signature_patch.mpq" - - # For this test, we calculate the MD5 sum of the signature output - # This is because we cannot compare binary output - expected_md5 = "e434b57cdc6b730c17e18f2bb2fa8fd9" - - result = subprocess.run( - [str(binary_path), "verify", "--print", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=False # Get raw bytes to avoid UTF decode errors - ) - - output_md5 = hashlib.md5(result.stdout).hexdigest() - - # Decode stderr as text for error messages - stderr_text = result.stderr.decode('utf-8', errors='replace') - - assert result.returncode == 0, f"mpqcli failed with error: {stderr_text}" - assert output_md5 == expected_md5, f"Expected MD5: {expected_md5}, got: {output_md5}" - - -def test_verify_strong_signature_patch(binary_path, download_test_files): - """ - Test MPQ file verification with and printing of strong signature. - - This test checks: - - If the application exits correctly when the MPQ file has a strong signature. - - If the application correctly prints a strong signature. - """ - _ = download_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "v2_strong_signature_patch.mpq" - - # For this test, we calculate the MD5 sum of the signature output - # This is because we cannot compare binary output - expected_md5 = "931fc40bb6bff48b89d39b5acb169bbd" - - result = subprocess.run( - [str(binary_path), "verify", "--print", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=False # Get raw bytes to avoid UTF decode errors - ) - - output_md5 = hashlib.md5(result.stdout).hexdigest() - - # Decode stderr as text for error messages - stderr_text = result.stderr.decode('utf-8', errors='replace') - - assert result.returncode == 0, f"mpqcli failed with error: {stderr_text}" - assert output_md5 == expected_md5, f"Expected MD5: {expected_md5}, got: {output_md5}" - - -def test_verify_mpq_target_does_not_exist(binary_path): - script_dir = Path(__file__).parent - target_file = script_dir / "does" / "not" / "exist.mpq" - - result = subprocess.run( - [str(binary_path), "verify", str(target_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - - -def test_verify_short_print_flag_is_rejected(binary_path, generate_cats_and_dogs_mpq): - test_file = generate_cats_and_dogs_mpq - - result = subprocess.run( - [str(binary_path), "verify", "-p", str(test_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 109, f"mpqcli failed with error: {result.stderr}" - assert result.stdout == "" - - -def test_verify_print_short_flag(binary_path, tmp_path): - input_file = tmp_path / "a.txt" - input_file.write_text("A file.") - archive_file = tmp_path / "signed.mpq" - - result = subprocess.run( - [str(binary_path), "create", "-s", str(archive_file), str(input_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert result.returncode == 0, f"Failed to create archive: {result.stderr}" - - short_form = subprocess.run( - [str(binary_path), "verify", "-x", str(archive_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=False # The signature is raw bytes - ) - long_form = subprocess.run( - [str(binary_path), "verify", "--print", str(archive_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=False - ) - - assert short_form.returncode == 0, f"mpqcli failed with error: {short_form.stderr}" - assert short_form.stdout != b"" - assert short_form.stdout == long_form.stdout From a888f299918b517558c75b03a67179535af36fe0 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 20:28:13 +1300 Subject: [PATCH 06/37] Updated the testing docs for Catch2 --- CONTRIBUTING.md | 25 ++++++++++++++++--------- docs/building.md | 29 ++++++++++++++++++----------- docs/contributing.md | 25 ++++++++++++++++--------- 3 files changed, 50 insertions(+), 29 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b24cc96..eb79a2e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,15 +32,17 @@ Run `make help` to list all available targets. Common ones: |---|---| | `make configure` | Configure the cmake build (uses the default compiler) | | `make build` | Build the project into build/dev | -| `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | -| `make test_mpqcli` | Run the pytest test suite | +| `make test` | Run the unit tests | +| `make test_functional` | Run the functional tests against the built binary | +| `make test_all` | Run every test layer in build/dev | +| `make fetch_integration_data` | Download the real archives a few functional tests read (needs filenget) | | `make check_all` | Run every static check (clang-format, clang-tidy, completion scripts) | | `make check_format` | Check formatting only (dry run) | | `make format` | Auto-fix formatting in-place | | `make configure_lint` | Configure build/lint with clang++ for clang-tidy | | `make check_lint` | Run clang-tidy static analysis | | `make check_embed` | Syntax-check the embedded completion scripts | -| `make clean` | Remove all build, test and docs artefacts | +| `make clean` | Remove all build and docs artefacts | ## Requirements for a pull request @@ -61,15 +63,20 @@ A PR automatically triggers the CI build workflow, which compiles and tests acro Run the test suite before submitting: ```sh -make test_create_venv # first-time setup only -make test_mpqcli +make test +make test_functional ``` -All tests must pass without errors. +All tests must pass without errors. A few functional tests need two real Blizzard patch archives and are skipped until you fetch them with `make fetch_integration_data`, which needs [filenget](https://github.com/thomaslaurenson/filenget). The building page explains more. ### 3. New features should include tests -If your change adds or modifies user-facing functionality - such as a new subcommand flag or a change in output format - please include a corresponding test in the `test/` directory. The existing test files (`test_list.py`, `test_add.py`, etc.) are good references for the test style and fixtures used. +If your change adds or modifies user-facing functionality - such as a new subcommand flag or a change in output format - please include a corresponding test under `test/`. The tests use Catch2 and come in two layers: + +- `test/unit/` mirrors `src/`, with one `test_.cpp` per source file, and calls the core library directly. Prefer it for logic: it is faster, and a failure points at the code. +- `test/functional/` has one file per subcommand and runs the built `mpqcli` binary, asserting on its exit code, stdout and stderr. Use it for what only the command line shows: argument parsing, exit codes and output. + +Shared fixtures live in `test/fixtures/`, one header each. The existing files, such as `test/functional/test_list.cpp` and `test/unit/util/test_path.cpp`, are good references for the style. ### 4. Linting must pass @@ -89,7 +96,7 @@ Then re-run `make check_all` to confirm everything passes. ### 5. Match the existing code style -C++ formatting is enforced by `.clang-format` (LLVM style base). Static analysis is enforced by `.clang-tidy`. Both configs live in the repo root. Python tests should follow the style of the existing test files. +C++ formatting is enforced by `.clang-format` (LLVM style base). Static analysis is enforced by `.clang-tidy`. Both configs live in the repo root. The tests are held to the same formatting, but clang-tidy does not analyse them. #### Suppression policy @@ -134,5 +141,5 @@ If you add a new StormLib call that is locale-sensitive, follow the existing pat 3. Install the clang tools as shown above 4. Make your changes and verify they build: `make build` 5. Run `make check_all`, fixing any issues -6. Run `make test_mpqcli` and confirm all tests pass +6. Run `make test` and `make test_functional` and confirm all tests pass 7. Open a pull request with a clear description of what was changed and why diff --git a/docs/building.md b/docs/building.md index e39f0be..ac3bc25 100644 --- a/docs/building.md +++ b/docs/building.md @@ -42,23 +42,30 @@ This project also uses the [CLI11](https://github.com/CLIUtils/CLI11) command li ## Tests -This project implements End-to-end (E2E) testing, sometimes referred to as system testing or integration testing. This methodology is used because it verifies application functionality by simulating actual usage by an end user. Testing includes creating a variety of MPQ archives, as well as dynamically downloading some small (~1-5MB) MPQ archives from the Internet Archive. The Python programming language coupled with the [pytest framework](https://github.com/pytest-dev/pytest) is used to implement testing, mainly due to ease of implementation. +The tests are written with the [Catch2](https://github.com/catchorg/Catch2) framework. They come in two layers, each built as its own binary in `build/dev/bin`: -To configure the testing environment you will need Python installed, as well as the required `pytest` package. On a Debian-based Linux system, the following will configure the environment: +- **Unit tests** (`test/unit/`, built as `mpqcli_unit_tests`) call the core library directly, with one test file per source file in `src/`. +- **Functional tests** (`test/functional/`, built as `mpqcli_functional_tests`) run the built `mpqcli` binary as a user would, using [subprocess.h](https://github.com/sheredom/subprocess.h). There is one test file per subcommand, checking its exit code and output. + +Catch2 and subprocess.h are submodules like the other dependencies, and the tests are built by default. Build, then run each layer: ```bash -$ sudo apt install python3-venv python3-pip -$ python3 -m venv .venv -$ source .venv/bin/activate -$ pip3 install -r test/requirements.txt +$ cmake -B build/dev +$ cmake --build build/dev +$ ctest --test-dir build/dev -L unit +$ ctest --test-dir build/dev -L functional ``` -The tests run the binary in `build/dev`, so build it there and then run the tests using: +On Windows, add `-C Release` (or whichever configuration you built) to each `ctest` command. To build without the tests, configure with `-DMPQCLI_BUILD_TESTING=OFF`. + +With the Makefile, `make test` builds and runs the unit tests and `make test_functional` the functional tests; `make test_all` runs both. + +### Real archives + +A few functional tests read two genuine World of Warcraft patch archives, the only archives in the suite with a real Blizzard signature. They are not committed to the repository, and those tests are skipped until the archives are present. Fetch them with: ```bash -$ cmake -B build/dev -$ cmake --build build/dev -$ python3 -m pytest test -s +$ make fetch_integration_data ``` -`make test` does the same in one step. +This needs [filenget](https://github.com/thomaslaurenson/filenget) on your `PATH`, and checks each download against the checksums in `test/integration_data.sha256`. The archives go in `test/data/integration/`. To keep them somewhere else, set `MPQCLI_INTEGRATION_DATA` to that directory, either in the environment or as a CMake cache variable. diff --git a/docs/contributing.md b/docs/contributing.md index b24cc96..eb79a2e 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -32,15 +32,17 @@ Run `make help` to list all available targets. Common ones: |---|---| | `make configure` | Configure the cmake build (uses the default compiler) | | `make build` | Build the project into build/dev | -| `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | -| `make test_mpqcli` | Run the pytest test suite | +| `make test` | Run the unit tests | +| `make test_functional` | Run the functional tests against the built binary | +| `make test_all` | Run every test layer in build/dev | +| `make fetch_integration_data` | Download the real archives a few functional tests read (needs filenget) | | `make check_all` | Run every static check (clang-format, clang-tidy, completion scripts) | | `make check_format` | Check formatting only (dry run) | | `make format` | Auto-fix formatting in-place | | `make configure_lint` | Configure build/lint with clang++ for clang-tidy | | `make check_lint` | Run clang-tidy static analysis | | `make check_embed` | Syntax-check the embedded completion scripts | -| `make clean` | Remove all build, test and docs artefacts | +| `make clean` | Remove all build and docs artefacts | ## Requirements for a pull request @@ -61,15 +63,20 @@ A PR automatically triggers the CI build workflow, which compiles and tests acro Run the test suite before submitting: ```sh -make test_create_venv # first-time setup only -make test_mpqcli +make test +make test_functional ``` -All tests must pass without errors. +All tests must pass without errors. A few functional tests need two real Blizzard patch archives and are skipped until you fetch them with `make fetch_integration_data`, which needs [filenget](https://github.com/thomaslaurenson/filenget). The building page explains more. ### 3. New features should include tests -If your change adds or modifies user-facing functionality - such as a new subcommand flag or a change in output format - please include a corresponding test in the `test/` directory. The existing test files (`test_list.py`, `test_add.py`, etc.) are good references for the test style and fixtures used. +If your change adds or modifies user-facing functionality - such as a new subcommand flag or a change in output format - please include a corresponding test under `test/`. The tests use Catch2 and come in two layers: + +- `test/unit/` mirrors `src/`, with one `test_.cpp` per source file, and calls the core library directly. Prefer it for logic: it is faster, and a failure points at the code. +- `test/functional/` has one file per subcommand and runs the built `mpqcli` binary, asserting on its exit code, stdout and stderr. Use it for what only the command line shows: argument parsing, exit codes and output. + +Shared fixtures live in `test/fixtures/`, one header each. The existing files, such as `test/functional/test_list.cpp` and `test/unit/util/test_path.cpp`, are good references for the style. ### 4. Linting must pass @@ -89,7 +96,7 @@ Then re-run `make check_all` to confirm everything passes. ### 5. Match the existing code style -C++ formatting is enforced by `.clang-format` (LLVM style base). Static analysis is enforced by `.clang-tidy`. Both configs live in the repo root. Python tests should follow the style of the existing test files. +C++ formatting is enforced by `.clang-format` (LLVM style base). Static analysis is enforced by `.clang-tidy`. Both configs live in the repo root. The tests are held to the same formatting, but clang-tidy does not analyse them. #### Suppression policy @@ -134,5 +141,5 @@ If you add a new StormLib call that is locale-sensitive, follow the existing pat 3. Install the clang tools as shown above 4. Make your changes and verify they build: `make build` 5. Run `make check_all`, fixing any issues -6. Run `make test_mpqcli` and confirm all tests pass +6. Run `make test` and `make test_functional` and confirm all tests pass 7. Open a pull request with a clear description of what was changed and why From 84f4afa6095f1cb36b8528c0580c16b023104015 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 20:45:48 +1300 Subject: [PATCH 07/37] Fixed add failing part-way when one run fills the file table --- src/mpq/add.cpp | 48 ++++++++++++++++++++++-------------- test/functional/test_add.cpp | 25 +++++++++++++++++-- 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp index 5c22554..8aa1745 100644 --- a/src/mpq/add.cpp +++ b/src/mpq/add.cpp @@ -39,6 +39,19 @@ bool SkipIfArchiveFile(const fs::path &file, const fs::path &archive_file, std:: return true; } +/// Doubles the archive's file table, reporting a failure on err +bool GrowFileTable(HANDLE archive, std::ostream &err) { + const auto max_files = GetFileInfo(archive, SFileMpqMaxFileCount); + const uint32_t new_max_files = NextPowerOfTwo(max_files + 1); + if (!SFileSetMaxFileCount(archive, new_max_files)) { + const auto error = SErrGetLastError(); + err << "[!] Failed to increase new max file count to " << new_max_files << ": (" << error + << ") " << StormErrorString(error) << std::endl; + return false; + } + return true; +} + } // namespace int AddFiles(HANDLE archive, const std::vector &files, const fs::path &base_path, @@ -147,21 +160,6 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi err << "[+] Adding file" << PrettyPrintLocale(locale, " for locale ") << ": " << archive_file_path << std::endl; - // Verify that we are not exceeding maxFile size of the archive, and if we do, increase it - int32_t number_of_files = GetFileInfo(archive, SFileMpqNumberOfFiles); - int32_t max_files = GetFileInfo(archive, SFileMpqMaxFileCount); - - if (number_of_files + 1 > max_files) { - uint32_t new_max_files = NextPowerOfTwo(static_cast(number_of_files + 1)); - bool set_max_file_count = SFileSetMaxFileCount(archive, new_max_files); - if (!set_max_file_count) { - const auto error = SErrGetLastError(); - err << "[!] Failed to increase new max file count to " << new_max_files << ": (" - << error << ") " << StormErrorString(error) << std::endl; - return 1; - } - } - const std::uintmax_t raw_file_size = fs::file_size(local_file, ec); if (ec) { err << "[!] Failed to read file size: (" << ec.value() << ") " << ec.message() << ": " @@ -188,9 +186,23 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi flags |= MPQ_FILE_REPLACEEXISTING; } - bool added_file = - SFileAddFileEx(archive, local_file.string().c_str(), archive_file_path.c_str(), flags, - compression, compression_next); + const auto add = [&]() { + return SFileAddFileEx(archive, local_file.string().c_str(), archive_file_path.c_str(), + flags, compression, compression_next); + }; + + // The table grows once StormLib reports it full, not when the file count reaches + // the maximum: while the archive is open StormLib holds back a slot for each special + // file it writes on close, and does not report how many, so a count comparison + // fires a few files too late. A full table fails before any data is written, so + // the add is safe to retry. + bool added_file = add(); + if (!added_file && SErrGetLastError() == ERROR_DISK_FULL) { + if (!GrowFileTable(archive, err)) { + return 1; + } + added_file = add(); + } if (!added_file) { const auto error = SErrGetLastError(); diff --git a/test/functional/test_add.cpp b/test/functional/test_add.cpp index 8b55c7f..bcfb6c7 100644 --- a/test/functional/test_add.cpp +++ b/test/functional/test_add.cpp @@ -568,8 +568,8 @@ TEST_CASE("add: grows the file table when the archive is full", "[add]") { const auto extra = sample.files.dir.Path() / "extra.txt"; WriteFile(extra, "one more"); - // A second invocation rather than one run of sixty-one: filling and growing - // in the same run fails with "Disk full", a defect rather than behaviour to pin + // A second invocation, so the table is grown on reopening an archive that is + // already full auto result = Run(mpqcli_binary, {"add", sample.archive.string(), extra.string()}); REQUIRE(result.returncode == 0); @@ -580,6 +580,27 @@ TEST_CASE("add: grows the file table when the archive is full", "[add]") { REQUIRE(InfoProperty(sample.archive, "max-files") == "128"); } +TEST_CASE("add: grows the file table part-way through one run", "[add]") { + SampleArchive sample; + REQUIRE(InfoProperty(sample.archive, "max-files") == "64"); + // One more than the sixty free slots, so the table fills mid-run + const auto fill_dir = sample.files.dir.Path() / "fill"; + std::set expected; + for (int i = 0; i < 61; ++i) { + const auto name = "f" + std::to_string(i) + ".txt"; + WriteFile(fill_dir / name, "file " + std::to_string(i)); + expected.insert("[+] Adding file: " + name); + } + + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), fill_dir.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == expected); + REQUIRE(InfoProperty(sample.archive, "file-count") == "65"); + REQUIRE(InfoProperty(sample.archive, "max-files") == "128"); +} + TEST_CASE("add: --update skips unchanged files by MD5", "[add]") { // wow1 stores CRC32, MD5 and FILETIME. The files are written fresh, so the // timestamp check fails and the decision falls through to MD5 From 38305065f6483fca184d550962c00884d8c35774 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 20:46:00 +1300 Subject: [PATCH 08/37] Rejected sector sizes StormLib cannot write or read back --- app/create.cpp | 1 + app/validators.cpp | 16 ++++++++++++++++ app/validators.h | 1 + test/functional/test_create.cpp | 23 +++++++++++++++++++---- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/app/create.cpp b/app/create.cpp index 05518d5..2e68b97 100644 --- a/app/create.cpp +++ b/app/create.cpp @@ -51,6 +51,7 @@ void RegisterCreate(CLI::App &app, Context &context) { sub->add_option("--stream-flags", create.stream_flags, "Override stream flags") ->group("Game setting overrides"); sub->add_option("--sector-size", create.sector_size, "Override sector size") + ->check(sector_size_valid) ->group("Game setting overrides"); sub->add_option("--raw-chunk-size", create.raw_chunk_size, "Override raw chunk size for MPQ v4") ->group("Game setting overrides"); diff --git a/app/validators.cpp b/app/validators.cpp index ae9c495..61397b0 100644 --- a/app/validators.cpp +++ b/app/validators.cpp @@ -1,5 +1,6 @@ #include "validators.h" +#include #include #include "gamerules/rules.h" @@ -48,3 +49,18 @@ extern const CLI::Validator locale_valid = CLI::Validator( return std::string(); }, "", "LocaleValidator"); + +// StormLib's create stores a 512-byte sector as a shift of 0, which its own open +// rejects as a bad format, so 1024 is the smallest size that can be read back. A +// size that is not a power of two would otherwise fail inside StormLib with no more +// than "Invalid parameter". +extern const CLI::Validator sector_size_valid = CLI::Validator( + [](const std::string &str) { + // Parsed the way CLI11 parses the option itself, so hexadecimal input works + std::uint32_t size = 0; + if (!CLI::detail::lexical_cast(str, size) || size < 1024 || (size & (size - 1)) != 0) { + return std::string("Sector size must be a power of two of at least 1024"); + } + return std::string(); + }, + "", "SectorSizeValidator"); diff --git a/app/validators.h b/app/validators.h index e103b66..8e3f71a 100644 --- a/app/validators.h +++ b/app/validators.h @@ -5,5 +5,6 @@ extern const CLI::Validator game_profile_valid; extern const CLI::Validator locale_valid; +extern const CLI::Validator sector_size_valid; #endif diff --git a/test/functional/test_create.cpp b/test/functional/test_create.cpp index 92690e9..f2c994a 100644 --- a/test/functional/test_create.cpp +++ b/test/functional/test_create.cpp @@ -725,10 +725,7 @@ TEST_CASE("create: rejects a format version outside 1 to 4", "[create]") { } TEST_CASE("create: --sector-size sets the header's sector exponent", "[create]") { - // Sector size in bytes, and the exponent the header stores for it. 512 is - // left out: create accepts it and exits 0, but StormLib then refuses to - // open the result with "Bad MPQ format", which is a defect to fix rather - // than a contract to pin here + // Sector size in bytes, and the exponent the header stores for it const auto [sector_size, exponent] = GENERATE(table({ {"1024", 1}, {"4096", 3}, @@ -749,6 +746,24 @@ TEST_CASE("create: --sector-size sets the header's sector exponent", "[create]") REQUIRE(ArchiveEntries(archive).size() == 3); } +TEST_CASE("create: --sector-size rejects a size StormLib cannot write or read back", "[create]") { + // 512 is written as a sector shift of 0, which StormLib refuses to open + const auto sector_size = GENERATE(as{}, "0", "512", "3000"); + CAPTURE(sector_size); + SampleFiles files; + const auto archive = files.dir.Path() / "sector.mpq"; + + auto result = Run(mpqcli_binary, {"create", "--sector-size", sector_size, archive.string(), + files.files_dir.string()}); + + // 105 is CLI11's ValidationError, from sector_size_valid + REQUIRE(result.returncode == 105); + REQUIRE(result.stdout_output.empty()); + REQUIRE_THAT(result.stderr_output, + ContainsSubstring("Sector size must be a power of two of at least 1024")); + REQUIRE_FALSE(fs::exists(archive)); +} + TEST_CASE("create: --raw-chunk-size sets the v4 header's raw chunk size", "[create]") { const auto chunk_size = GENERATE(as{}, "4096", "16384"); CAPTURE(chunk_size); From 4b510ed2f9cadeed19f9a5b25ea638891a4b779d Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 20:46:04 +1300 Subject: [PATCH 09/37] Matched locale names regardless of case --- app/validators.cpp | 8 ++------ src/util/locales.cpp | 10 ++++++++-- test/functional/test_read.cpp | 9 ++++++--- test/unit/util/test_locales.cpp | 6 ++++++ 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/app/validators.cpp b/app/validators.cpp index 61397b0..9085fdc 100644 --- a/app/validators.cpp +++ b/app/validators.cpp @@ -34,12 +34,8 @@ extern const CLI::Validator locale_valid = CLI::Validator( if (str == "default") return std::string(); - if (ParseHexLocale(str) != default_locale) { - return std::string(); - } - - const LCID locale = LangToLocale(str); - if (locale == 0) { + // The same resolution every command uses, a name in any case or else four hex digits + if (LangToLocale(str) == default_locale) { std::string valid_locales = "Locale must be nothing, or one of:"; for (const auto &l : GetAllLocales()) { valid_locales += " " + l; diff --git a/src/util/locales.cpp b/src/util/locales.cpp index 56a251c..cd214b9 100644 --- a/src/util/locales.cpp +++ b/src/util/locales.cpp @@ -6,6 +6,8 @@ #include #include +#include "util/string.h" + namespace mpqcli { namespace { @@ -34,11 +36,12 @@ const std::map locale_to_lang_map = { {0x816, "ptPT"}, // Portuguese (Portugal) }; +// Keyed by the lower-cased name, so a name matches whatever its case const std::map lang_to_locale_map = []() { std::map reverse_map; for (const auto &[locale, lang] : locale_to_lang_map) { if (locale != default_locale) { // 0x000 prints as enUS but enUS resolves to 0x409 - reverse_map[lang] = locale; + reverse_map[ToLower(lang)] = locale; } } return reverse_map; @@ -81,7 +84,10 @@ std::string LocaleToLang(const LCID locale) { } LCID LangToLocale(const std::string &lang) { - auto it = lang_to_locale_map.find(lang); + // A name is tried before hexadecimal, so "dede" is deDE rather than the locale + // 0xDEDE. deDE is the only name made entirely of hex digits, and 0xDEDE is not a + // Windows locale, so nothing real is lost. + auto it = lang_to_locale_map.find(ToLower(lang)); if (it != lang_to_locale_map.end()) { return it->second; } diff --git a/test/functional/test_read.cpp b/test/functional/test_read.cpp index 16609f9..d40278d 100644 --- a/test/functional/test_read.cpp +++ b/test/functional/test_read.cpp @@ -249,12 +249,15 @@ TEST_CASE("read: prints nothing for an empty entry", "[read]") { } TEST_CASE("read: the variant under the given locale", "[read]") { - // The locale as given, and the variant it selects: a language code, the - // hexadecimal LCID the locale table does not name, and the hexadecimal - // LCID of one it does (0407 is deDE) + // The locale as given, and the variant it selects: a language code in any + // case, the hexadecimal LCID the locale table does not name, and the + // hexadecimal LCID of one it does (0407 is deDE). "dede" is a name before it + // is hexadecimal. const auto [locale, variant] = GENERATE(table({ {"deDE", "deDE"}, {"esES", "esES"}, + {"dede", "deDE"}, + {"ESES", "esES"}, {"041D", "041D"}, {"0407", "deDE"}, })); diff --git a/test/unit/util/test_locales.cpp b/test/unit/util/test_locales.cpp index 6ab88db..fdf493b 100644 --- a/test/unit/util/test_locales.cpp +++ b/test/unit/util/test_locales.cpp @@ -40,6 +40,12 @@ TEST_CASE("LangToLocale", "[locales]") { REQUIRE(LangToLocale("zhTW") == 0x404U); } + SECTION("resolves a name whatever its case") { + REQUIRE(LangToLocale("dede") == 0x407U); + REQUIRE(LangToLocale("DEDE") == 0x407U); + REQUIRE(LangToLocale("ENgb") == 0x809U); + } + SECTION("resolves enUS to its own locale rather than the default") { REQUIRE(LangToLocale("enUS") == 0x409U); } From 06838209d773ecb51ece933d224634fec058a34d Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 20:53:39 +1300 Subject: [PATCH 10/37] Ignored an empty listfile with a warning instead of failing --- src/commands/compact.cpp | 3 ++- src/commands/extract.cpp | 3 ++- src/commands/list.cpp | 5 ++-- src/mpq/query.cpp | 14 ++++++++++ src/mpq/query.h | 10 +++++++ test/CMakeLists.txt | 1 + test/functional/test_compact.cpp | 18 +++++++++++++ test/functional/test_extract.cpp | 19 ++++++++++++++ test/functional/test_list.cpp | 26 ++++++++++-------- test/unit/mpq/test_query.cpp | 45 ++++++++++++++++++++++++++++++++ 10 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 test/unit/mpq/test_query.cpp diff --git a/src/commands/compact.cpp b/src/commands/compact.cpp index 7226cfb..4fbdf5e 100644 --- a/src/commands/compact.cpp +++ b/src/commands/compact.cpp @@ -4,12 +4,13 @@ #include "mpq/archive.h" #include "mpq/compact.h" +#include "mpq/query.h" namespace mpqcli { void Compact(const CompactOptions &options, std::ostream &err) { Archive archive = Archive::Open(options.archive, 0); - CompactMpqArchive(archive.Handle(), options.listfile, err); + CompactMpqArchive(archive.Handle(), UsableListfile(options.listfile, err), err); archive.Close(); } diff --git a/src/commands/extract.cpp b/src/commands/extract.cpp index 3c7ec3a..c6c9679 100644 --- a/src/commands/extract.cpp +++ b/src/commands/extract.cpp @@ -12,6 +12,7 @@ #include "errors.h" #include "mpq/archive.h" #include "mpq/extract.h" +#include "mpq/query.h" #include "util/locales.h" namespace fs = std::filesystem; @@ -40,7 +41,7 @@ bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic const std::vector file_names = options.files.has_value() ? options.files.value() - : ListArchivedFiles(archive.Handle(), options.listfile, cancelled); + : ListArchivedFiles(archive.Handle(), UsableListfile(options.listfile, err), cancelled); if (file_names.empty()) { err << "[*] No files to extract" << std::endl; archive.Close(); diff --git a/src/commands/list.cpp b/src/commands/list.cpp index 7a844b8..f5877f6 100644 --- a/src/commands/list.cpp +++ b/src/commands/list.cpp @@ -6,13 +6,14 @@ #include "mpq/archive.h" #include "mpq/list.h" +#include "mpq/query.h" namespace mpqcli { void List(const ListOptions &options, std::ostream &out, std::ostream &err) { Archive archive = Archive::Open(options.archive, MPQ_OPEN_READ_ONLY); - ListFiles(archive.Handle(), options.listfile, options.all, options.detailed, options.properties, - out, err); + ListFiles(archive.Handle(), UsableListfile(options.listfile, err), options.all, + options.detailed, options.properties, out, err); archive.Close(); } diff --git a/src/mpq/query.cpp b/src/mpq/query.cpp index 32d1917..d8f3818 100644 --- a/src/mpq/query.cpp +++ b/src/mpq/query.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include #include @@ -91,4 +93,16 @@ bool ArchivedFileMatches(HANDLE archive, HANDLE file, const fs::path &local_file return false; } +std::optional UsableListfile(const std::optional &listfile, std::ostream &err) { + if (!listfile.has_value()) { + return std::nullopt; + } + std::error_code ec; + if (fs::file_size(*listfile, ec) == 0 && !ec) { + err << "[!] Ignoring the listfile, which is empty: " << listfile->string() << std::endl; + return std::nullopt; + } + return listfile; +} + } // namespace mpqcli diff --git a/src/mpq/query.h b/src/mpq/query.h index 5cc91dd..5888fca 100644 --- a/src/mpq/query.h +++ b/src/mpq/query.h @@ -2,6 +2,8 @@ #define MPQ_QUERY_H #include +#include +#include #include #include @@ -19,6 +21,14 @@ bool FileExistsInArchiveForLocale(HANDLE archive, const std::string &file_path, bool ArchivedFileMatches(HANDLE archive, HANDLE file, const std::filesystem::path &local_file, std::string &match_reason); +/// Returns the listfile to hand StormLib, leaving out one that is empty +/// +/// StormLib rejects an empty listfile as corrupt, yet it names nothing and so changes no +/// result. An empty one is reported on err and dropped, so the command goes on as if no +/// listfile had been given. +std::optional +UsableListfile(const std::optional &listfile, std::ostream &err); + template T GetFileInfo(HANDLE file, SFileInfoClass info_class) { T value{}; if (!SFileGetFileInfo(file, info_class, &value, sizeof(T), nullptr)) { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 0843636..d43608a 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -24,6 +24,7 @@ add_executable(mpqcli_unit_tests unit/gamerules/test_rules.cpp unit/mpq/test_extract.cpp unit/mpq/test_flags.cpp + unit/mpq/test_query.cpp unit/test_errors.cpp unit/util/test_capacity.cpp unit/util/test_format.cpp diff --git a/test/functional/test_compact.cpp b/test/functional/test_compact.cpp index 0f5822a..915deda 100644 --- a/test/functional/test_compact.cpp +++ b/test/functional/test_compact.cpp @@ -143,6 +143,24 @@ TEST_CASE("compact: succeeds with a complete external listfile", "[compact]") { } } +TEST_CASE("compact: ignores an empty listfile, with a warning", "[compact]") { + // An archive with its own (listfile), so compacting succeeds without an external one + SampleArchive sample; + const auto listfile = sample.files.dir.Path() / "empty.txt"; + WriteFile(listfile, ""); + + auto result = Run(mpqcli_binary, {"compact", sample.archive.string(), "-l", listfile.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE( + LinesToVector(result.stderr_output) == + std::vector{"[!] Ignoring the listfile, which is empty: " + listfile.string(), + compacting_notice}); + REQUIRE(ArchiveEntries(sample.archive) == + std::set{"enUS bytes", "enUS cats.txt", "enUS dogs.txt"}); +} + TEST_CASE("compact: reclaims the space of removed files", "[compact]") { TempDir dir; const auto source_dir = dir.Path() / "compaction_files"; diff --git a/test/functional/test_extract.cpp b/test/functional/test_extract.cpp index 53b5ead..f3dee58 100644 --- a/test/functional/test_extract.cpp +++ b/test/functional/test_extract.cpp @@ -193,6 +193,25 @@ TEST_CASE("extract: --output with a trailing separator", "[extract]") { REQUIRE(DirectoryEntries(output_dir) == expected); } +TEST_CASE("extract: ignores an empty listfile, with a warning", "[extract]") { + // An archive with its own (listfile), so every file still has a name + SampleArchive sample; + const auto listfile = sample.files.dir.Path() / "empty.txt"; + WriteFile(listfile, ""); + const auto output_dir = sample.files.dir.Path() / "extracted"; + const std::set expected = {"cats.txt", "dogs.txt", "bytes", "(listfile)"}; + + auto result = Run(mpqcli_binary, {"extract", "-o", output_dir.string(), "-l", listfile.string(), + sample.archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + auto lines = ExtractedLines(expected); + lines.insert("[!] Ignoring the listfile, which is empty: " + listfile.string()); + REQUIRE(LinesToSet(result.stderr_output) == lines); + REQUIRE(DirectoryEntries(output_dir) == expected); +} + TEST_CASE("extract: fails when --output is an existing file", "[extract]") { SampleArchive sample; const auto not_a_dir = sample.files.files_dir / "cats.txt"; diff --git a/test/functional/test_list.cpp b/test/functional/test_list.cpp index f11e04e..408c1f9 100644 --- a/test/functional/test_list.cpp +++ b/test/functional/test_list.cpp @@ -529,20 +529,24 @@ TEST_CASE("list: prints the encryption keys of entries listed without a name", " }); } -TEST_CASE("list: fails when no entry can be enumerated", "[list]") { - SECTION("an empty external listfile") { - NoListfileArchive archive; - const auto listfile = archive.dir.Path() / "empty.txt"; - WriteFile(listfile, ""); +TEST_CASE("list: ignores an empty listfile, with a warning", "[list]") { + NoListfileArchive archive; + const auto listfile = archive.dir.Path() / "empty.txt"; + WriteFile(listfile, ""); + auto without_listfile = Run(mpqcli_binary, {"list", archive.archive.string()}); + REQUIRE(without_listfile.returncode == 0); - auto result = - Run(mpqcli_binary, {"list", archive.archive.string(), "-l", listfile.string()}); + auto result = Run(mpqcli_binary, {"list", archive.archive.string(), "-l", listfile.string()}); - REQUIRE(result.returncode == 1); - REQUIRE(result.stdout_output.empty()); - REQUIRE(LinesToSet(result.stderr_output) == std::set{enumeration_failure}); - } + // An empty listfile names nothing, so the listing is the one without it + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output == without_listfile.stdout_output); + REQUIRE( + LinesToSet(result.stderr_output) == + std::set{"[!] Ignoring the listfile, which is empty: " + listfile.string()}); +} +TEST_CASE("list: fails when no entry can be enumerated", "[list]") { SECTION("an archive with no entries at all") { TempDir dir; const auto source_dir = dir.Path() / "empty"; diff --git a/test/unit/mpq/test_query.cpp b/test/unit/mpq/test_query.cpp new file mode 100644 index 0000000..b5a79b3 --- /dev/null +++ b/test/unit/mpq/test_query.cpp @@ -0,0 +1,45 @@ +#include +#include +#include +#include + +#include "mpq/query.h" +#include "temp_dir.h" +#include "write_file.h" + +namespace fs = std::filesystem; + +using mpqcli::UsableListfile; +using mpqcli::testing::TempDir; +using mpqcli::testing::WriteFile; + +TEST_CASE("UsableListfile", "[query]") { + TempDir dir; + std::ostringstream err; + + SECTION("passes no listfile through as none") { + REQUIRE_FALSE(UsableListfile(std::nullopt, err).has_value()); + REQUIRE(err.str().empty()); + } + + SECTION("returns a listfile with names in it unchanged") { + const auto listfile = dir.Path() / "listfile.txt"; + WriteFile(listfile, "cats.txt\n"); + REQUIRE(UsableListfile(listfile, err) == std::optional(listfile)); + REQUIRE(err.str().empty()); + } + + SECTION("drops an empty listfile with a warning") { + const auto listfile = dir.Path() / "empty.txt"; + WriteFile(listfile, ""); + REQUIRE_FALSE(UsableListfile(listfile, err).has_value()); + REQUIRE(err.str() == + "[!] Ignoring the listfile, which is empty: " + listfile.string() + "\n"); + } + + SECTION("leaves a missing listfile for StormLib to report") { + const auto listfile = dir.Path() / "missing.txt"; + REQUIRE(UsableListfile(listfile, err) == std::optional(listfile)); + REQUIRE(err.str().empty()); + } +} From cf014723d139472c0f5b37309eb81ebd5c903f5f Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 21:00:10 +1300 Subject: [PATCH 11/37] Treated an archive with no entries as empty in list and extract --- src/mpq/extract.cpp | 7 +++++- src/mpq/extract.h | 4 +++- src/mpq/list.cpp | 7 +++++- src/mpq/list.h | 4 +++- test/functional/test_extract.cpp | 19 ++++++++++++++++ test/functional/test_list.cpp | 37 +++++++++++++++++--------------- 6 files changed, 57 insertions(+), 21 deletions(-) diff --git a/src/mpq/extract.cpp b/src/mpq/extract.cpp index 5aff018..8968fa4 100644 --- a/src/mpq/extract.cpp +++ b/src/mpq/extract.cpp @@ -46,7 +46,12 @@ std::vector ListArchivedFiles(HANDLE archive, SFILE_FIND_DATA find_data; HANDLE find_handle = SFileFindFirstFile(archive, "*", &find_data, listfile); if (find_handle == nullptr) { - throw ArchiveError("Failed to find first file in MPQ archive."); + // ERROR_NO_MORE_FILES is an archive with no entries: nothing to name, not a failure + const auto error = SErrGetLastError(); + if (error == ERROR_NO_MORE_FILES) { + return {}; + } + throw StormError("Failed to find first file in MPQ archive", error); } std::vector file_names; diff --git a/src/mpq/extract.h b/src/mpq/extract.h index 9a65b20..84e8f8e 100644 --- a/src/mpq/extract.h +++ b/src/mpq/extract.h @@ -15,7 +15,9 @@ namespace mpqcli { /// Lists every file the archive can name, once per locale it is stored under /// -/// @throws ArchiveError if the archive's files cannot be enumerated +/// An archive with no entries gives an empty list. +/// +/// @throws StormError if StormLib cannot enumerate the archive's files std::vector ListArchivedFiles(HANDLE archive, const std::optional &listfile_name, const std::atomic &cancelled); diff --git a/src/mpq/list.cpp b/src/mpq/list.cpp index dccd7ec..dae873e 100644 --- a/src/mpq/list.cpp +++ b/src/mpq/list.cpp @@ -31,7 +31,12 @@ void ListFiles(HANDLE archive, const std::optional &listf SFILE_FIND_DATA find_data; HANDLE find_handle = SFileFindFirstFile(archive, "*", &find_data, listfile); if (find_handle == nullptr) { - throw ArchiveError("Failed to find first file in MPQ archive."); + // ERROR_NO_MORE_FILES is an archive with no entries: nothing to list, not a failure + const auto error = SErrGetLastError(); + if (error == ERROR_NO_MORE_FILES) { + return; + } + throw StormError("Failed to find first file in MPQ archive", error); } std::vector properties_to_print = diff --git a/src/mpq/list.h b/src/mpq/list.h index 8a0a81f..4957175 100644 --- a/src/mpq/list.h +++ b/src/mpq/list.h @@ -13,7 +13,9 @@ namespace mpqcli { /// Prints the archive's files to out, one per line, with the requested properties /// -/// @throws ArchiveError if the archive's files cannot be enumerated +/// An archive with no entries prints nothing. +/// +/// @throws StormError if StormLib cannot enumerate the archive's files void ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, bool list_detailed, const std::vector &properties, std::ostream &out, std::ostream &err); diff --git a/test/functional/test_extract.cpp b/test/functional/test_extract.cpp index f3dee58..5aa2542 100644 --- a/test/functional/test_extract.cpp +++ b/test/functional/test_extract.cpp @@ -321,6 +321,25 @@ TEST_CASE("extract: an empty stdin extracts nothing", "[extract]") { REQUIRE_FALSE(fs::exists(output_dir)); } +TEST_CASE("extract: an archive with no entries extracts nothing", "[extract]") { + TempDir dir; + const auto source_dir = dir.Path() / "empty"; + fs::create_directories(source_dir); + const auto archive = dir.Path() / "bare.mpq"; + // No files and no (listfile) either, so the hash table is empty + REQUIRE( + Run(mpqcli_binary, {"create", archive.string(), source_dir.string(), "--file-flags1", "0"}) + .returncode == 0); + const auto output_dir = dir.Path() / "extracted"; + + auto result = Run(mpqcli_binary, {"extract", "-o", output_dir.string(), archive.string()}); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToSet(result.stderr_output) == std::set{"[*] No files to extract"}); + REQUIRE_FALSE(fs::exists(output_dir)); +} + TEST_CASE("extract: a named file that does not exist in the archive", "[extract]") { SampleArchive sample; const auto output_dir = sample.files.dir.Path() / "extracted_file"; diff --git a/test/functional/test_list.cpp b/test/functional/test_list.cpp index 408c1f9..1c2c5ca 100644 --- a/test/functional/test_list.cpp +++ b/test/functional/test_list.cpp @@ -32,7 +32,6 @@ namespace { const fs::path &mpqcli_binary = TestEnvironment::Instance().binary_path; const std::string open_failure = "[!] Failed to open MPQ archive"; -const std::string enumeration_failure = "[!] Failed to find first file in MPQ archive."; // Asserts output is exactly these lines. Any order, since list prints entries // in hash-table order, but none missing and none repeated, which a set alone @@ -546,23 +545,27 @@ TEST_CASE("list: ignores an empty listfile, with a warning", "[list]") { std::set{"[!] Ignoring the listfile, which is empty: " + listfile.string()}); } -TEST_CASE("list: fails when no entry can be enumerated", "[list]") { - SECTION("an archive with no entries at all") { - TempDir dir; - const auto source_dir = dir.Path() / "empty"; - fs::create_directories(source_dir); - const auto archive = dir.Path() / "bare.mpq"; - // No files and no (listfile) either, so the hash table is empty - REQUIRE(Run(mpqcli_binary, - {"create", archive.string(), source_dir.string(), "--file-flags1", "0"}) - .returncode == 0); - - auto result = Run(mpqcli_binary, {"list", "-a", archive.string()}); - - REQUIRE(result.returncode == 1); - REQUIRE(result.stdout_output.empty()); - REQUIRE(LinesToSet(result.stderr_output) == std::set{enumeration_failure}); +TEST_CASE("list: an archive with no entries lists nothing", "[list]") { + const auto detailed = GENERATE(false, true); + CAPTURE(detailed); + TempDir dir; + const auto source_dir = dir.Path() / "empty"; + fs::create_directories(source_dir); + const auto archive = dir.Path() / "bare.mpq"; + // No files and no (listfile) either, so the hash table is empty + REQUIRE( + Run(mpqcli_binary, {"create", archive.string(), source_dir.string(), "--file-flags1", "0"}) + .returncode == 0); + std::vector args = {"list", "-a", archive.string()}; + if (detailed) { + args.push_back("-d"); } + + auto result = Run(mpqcli_binary, args); + + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.empty()); + REQUIRE(result.stderr_output.empty()); } TEST_CASE("list: counts a Blizzard patch's entries and special files", "[list]") { From b0363d8b1b0c2186e752b23bd9bbb28380ea6760 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 21:07:17 +1300 Subject: [PATCH 12/37] Left no empty directory behind when extract writes nothing --- src/commands/extract.cpp | 11 ++++++++- test/functional/test_extract.cpp | 38 ++++++++++++++++++++++---------- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/commands/extract.cpp b/src/commands/extract.cpp index c6c9679..15e7d14 100644 --- a/src/commands/extract.cpp +++ b/src/commands/extract.cpp @@ -57,6 +57,10 @@ bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic } } + // Remembered so a run that extracts nothing can take away the directory it made + std::error_code exists_ec; + const bool output_existed = fs::exists(effective_output, exists_ec); + fs::create_directory(effective_output, ec); if (ec) { std::error_code query_ec; @@ -79,7 +83,12 @@ bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic archive.Close(); if (result != 0) { - err << std::endl << "[!] Failed to extract all files." << std::endl; + err << "[!] Failed to extract all files." << std::endl; + // fs::remove takes away an empty directory only, so a partial extraction stays + std::error_code cleanup_ec; + if (!output_existed && fs::is_empty(effective_output, cleanup_ec)) { + fs::remove(effective_output, cleanup_ec); + } } return result == 0; } diff --git a/test/functional/test_extract.cpp b/test/functional/test_extract.cpp index 5aa2542..607f845 100644 --- a/test/functional/test_extract.cpp +++ b/test/functional/test_extract.cpp @@ -343,16 +343,30 @@ TEST_CASE("extract: an archive with no entries extracts nothing", "[extract]") { TEST_CASE("extract: a named file that does not exist in the archive", "[extract]") { SampleArchive sample; const auto output_dir = sample.files.dir.Path() / "extracted_file"; + const std::vector expected_stderr = { + "[!] Failed: File doesn't exist for locale enUS: nope.txt", failed_all}; - auto result = Run(mpqcli_binary, - {"extract", "-o", output_dir.string(), sample.archive.string(), "nope.txt"}); + SECTION("takes away the output directory it made") { + auto result = Run(mpqcli_binary, {"extract", "-o", output_dir.string(), + sample.archive.string(), "nope.txt"}); - REQUIRE(result.returncode == 1); - REQUIRE(result.stdout_output.empty()); - REQUIRE(LinesToSet(result.stderr_output) == - std::set{"[!] Failed: File doesn't exist for locale enUS: nope.txt", "", - failed_all}); - REQUIRE(DirectoryEntries(output_dir).empty()); + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == expected_stderr); + REQUIRE_FALSE(fs::exists(output_dir)); + } + + SECTION("leaves an output directory that was already there") { + fs::create_directories(output_dir); + + auto result = Run(mpqcli_binary, {"extract", "-o", output_dir.string(), + sample.archive.string(), "nope.txt"}); + + REQUIRE(result.returncode == 1); + REQUIRE(result.stdout_output.empty()); + REQUIRE(LinesToVector(result.stderr_output) == expected_stderr); + REQUIRE(fs::is_directory(output_dir)); + } } TEST_CASE("extract: a named nested file keeps its folder", "[extract]") { @@ -594,8 +608,8 @@ TEST_CASE("extract: fails when neither the given nor the default locale has the REQUIRE(LinesToSet(result.stderr_output) == std::set{"[!] Failed: File doesn't exist for locale " + reported + ": cats.txt", - "", failed_all}); - REQUIRE(DirectoryEntries(output_dir).empty()); + failed_all}); + REQUIRE_FALSE(fs::exists(output_dir)); } TEST_CASE("extract: all files from an archive without a listfile", "[extract]") { @@ -679,7 +693,7 @@ TEST_CASE("extract: blocks an entry whose path climbs out of the output director REQUIRE(result.stdout_output.empty()); auto expected_stderr = ExtractedLines({"safe.txt", "(listfile)"}); expected_stderr.insert( - {"[!] Blocked: path traversal attempt detected: " + Native("..\\..\\sneaky.txt"), "", + {"[!] Blocked: path traversal attempt detected: " + Native("..\\..\\sneaky.txt"), failed_all}); REQUIRE(LinesToSet(result.stderr_output) == expected_stderr); REQUIRE_FALSE(fs::exists(escape_target)); @@ -712,7 +726,7 @@ TEST_CASE("extract: blocks an entry that escapes through a symlink in the output REQUIRE(result.stdout_output.empty()); auto expected_stderr = ExtractedLines({"safe.txt", "(listfile)"}); expected_stderr.insert( - {"[!] Blocked: path traversal attempt detected: " + Native("escape\\evil.txt"), "", + {"[!] Blocked: path traversal attempt detected: " + Native("escape\\evil.txt"), failed_all}); REQUIRE(LinesToSet(result.stderr_output) == expected_stderr); REQUIRE(DirectoryEntries(outside).empty()); From f1c054309387bdea9176ba6f4c66a1effbce2e40 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 21:12:28 +1300 Subject: [PATCH 13/37] Described verify --print as writing raw bytes rather than hex --- app/verify.cpp | 3 ++- completion/mpqcli.fish | 2 +- completion/mpqcli.ps1 | 4 ++-- completion/mpqcli.zsh | 2 +- docs/commands/verify.md | 8 +++++++- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/app/verify.cpp b/app/verify.cpp index 3fbafc9..6d0abd4 100644 --- a/app/verify.cpp +++ b/app/verify.cpp @@ -13,7 +13,8 @@ void RegisterVerify(CLI::App &app, Context &context) { sub->add_option("archive", options->archive, "MPQ archive") ->required() ->check(CLI::ExistingFile); - sub->add_flag("-x,--print", options->print_signature, "Print the digital signature (in hex)"); + sub->add_flag("-x,--print", options->print_signature, + "Print the digital signature as raw bytes"); sub->callback([options, &context]() { context.exit_code = mpqcli::Verify(*options, context.out, context.err) ? 0 : 1; diff --git a/completion/mpqcli.fish b/completion/mpqcli.fish index 33d4c63..2698cc4 100644 --- a/completion/mpqcli.fish +++ b/completion/mpqcli.fish @@ -242,7 +242,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from read' \ complete -c mpqcli -n '__fish_seen_subcommand_from verify; and test (__mpqcli_positional_index -x --print) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from verify' \ - -s x -l print -d 'Print the digital signature (in hex)' + -s x -l print -d 'Print the digital signature as raw bytes' # compact # compact [-l/--listfile] diff --git a/completion/mpqcli.ps1 b/completion/mpqcli.ps1 index 16b0233..87aa382 100644 --- a/completion/mpqcli.ps1 +++ b/completion/mpqcli.ps1 @@ -147,8 +147,8 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--locale' = 'Preferred locale for read file' } 'verify' = [ordered]@{ - '-x' = 'Print the digital signature (in hex)' - '--print' = 'Print the digital signature (in hex)' + '-x' = 'Print the digital signature as raw bytes' + '--print' = 'Print the digital signature as raw bytes' } 'compact' = [ordered]@{ '-l' = 'File listing content of an MPQ archive' diff --git a/completion/mpqcli.zsh b/completion/mpqcli.zsh index 9fa9d6f..abc9902 100644 --- a/completion/mpqcli.zsh +++ b/completion/mpqcli.zsh @@ -179,7 +179,7 @@ _mpqcli_read() { _mpqcli_verify() { _arguments \ '1:archive:_mpqcli_archives' \ - '(-x --print)'{-x,--print}'[print the digital signature in hex]' + '(-x --print)'{-x,--print}'[print the digital signature as raw bytes]' } _mpqcli_compact() { diff --git a/docs/commands/verify.md b/docs/commands/verify.md index 1638b62..62da9ab 100644 --- a/docs/commands/verify.md +++ b/docs/commands/verify.md @@ -20,8 +20,14 @@ $ echo $? ## Verify an MPQ archive and print the digital signature -Check the digital signature of an MPQ archive, by verifying the signature in the archive and also printing the digital signature value in hexadecimal using the `-x` or `--print` argument. +Check the digital signature of an MPQ archive, and also write the signature's raw bytes to stdout, using the `-x` or `--print` argument. The verification result still goes to stderr, so the bytes can be redirected to a file on their own: ```bash $ mpqcli verify --print wow-patch.mpq > signature ``` + +To view the signature as hexadecimal, pipe it through a tool such as `xxd`: + +```bash +$ mpqcli verify --print wow-patch.mpq | xxd +``` From cafc1d978a22ff2977b8a985c1e24f975d09003e Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 21:18:24 +1300 Subject: [PATCH 14/37] Dropped the flag letters that never carried information --- docs/commands/list.md | 21 +++++++++---------- src/mpq/flags.cpp | 3 --- test/functional/test_list.cpp | 38 +++++++++++++++++------------------ test/unit/mpq/test_flags.cpp | 15 ++++++++++++++ 4 files changed, 43 insertions(+), 34 deletions(-) diff --git a/docs/commands/list.md b/docs/commands/list.md index fcd8d52..01c9eea 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -59,24 +59,21 @@ The `list` subcommand supports listing the following properties: * `r`: File has Sector CRC checksums for each sector. This is ignored if the file is not compressed or imploded. * `s`: Present on STANDARD.SNP\(signature). * `x`: File exists; this is reset if the file is deleted. - * `m`: Mask for a file being compressed. - * `n`: Use default flags for internal files. - * `f`: Fix key; This is obsolete. You can use the `-q` or `--property` argument with the `list` subcommand to print the given properties. Many properties can be given, and they will be printed in the order given. ```bash $ mpqcli list -d -a Patch_rt.mpq --property hash-index --property locale --property flags ... -10893 enUS ixmn glue\ScorePv\pMain.pcx -11173 enUS ixmn rez\gluAll.tbl -11174 deDE ixmn rez\gluAll.tbl -11175 esES ixmn rez\gluAll.tbl -11176 frFR ixmn rez\gluAll.tbl -11177 itIT ixmn rez\gluAll.tbl -11178 ptBR ixmn rez\gluAll.tbl -11726 enUS cxmn (attributes) -11884 enUS ixmn glue\ScoreTd\pMain.pcx +10893 enUS ix glue\ScorePv\pMain.pcx +11173 enUS ix rez\gluAll.tbl +11174 deDE ix rez\gluAll.tbl +11175 esES ix rez\gluAll.tbl +11176 frFR ix rez\gluAll.tbl +11177 itIT ix rez\gluAll.tbl +11178 ptBR ix rez\gluAll.tbl +11726 enUS cx (attributes) +11884 enUS ix glue\ScoreTd\pMain.pcx ... ``` diff --git a/src/mpq/flags.cpp b/src/mpq/flags.cpp index bae82ae..f500a06 100644 --- a/src/mpq/flags.cpp +++ b/src/mpq/flags.cpp @@ -22,9 +22,6 @@ std::string GetFlagString(uint32_t flags) { if (flags & MPQ_FILE_SECTOR_CRC) result += 'r'; if (flags & MPQ_FILE_SIGNATURE) result += 's'; if (flags & MPQ_FILE_EXISTS) result += 'x'; - if (flags & MPQ_FILE_COMPRESS_MASK) result += 'm'; - if (flags & MPQ_FILE_DEFAULT_INTERNAL) result += 'n'; - if (flags & MPQ_FILE_FIX_KEY) result += 'f'; // clang-format on return result; diff --git a/test/functional/test_list.cpp b/test/functional/test_list.cpp index 1c2c5ca..b5b381d 100644 --- a/test/functional/test_list.cpp +++ b/test/functional/test_list.cpp @@ -103,8 +103,8 @@ const std::vector property_listings = { {" 16 bytes", " 35 cats.txt", " 35 dogs.txt", " 35 (listfile)", " 124 (attributes)"}}, {"flags", - {" cexmn bytes", " cexmn cats.txt", " cexmn dogs.txt", " ce2xmnf (listfile)", - " ce2xmnf (attributes)"}}, + {" cex bytes", " cex cats.txt", " cex dogs.txt", " ce2x (listfile)", + " ce2x (attributes)"}}, {"encryption-key", {"eaa753f9 bytes", "935a7772 cats.txt", "a073c614 dogs.txt", "2d2f0b11 (listfile)", "50e315dd (attributes)"}}, @@ -260,12 +260,12 @@ TEST_CASE("list: shows the signature entry stored raw", "[list]") { // The (signature) is neither compressed nor encrypted, and signing adds a // row to the (attributes) block, which is why it is larger than unsigned RequireLines(result.stdout_output, { - " cexmn 16 bytes", - " cexmn 35 cats.txt", - " cexmn 35 dogs.txt", - " ce2xmnf 35 (listfile)", - " ce2xmnf 143 (attributes)", - " xn 72 (signature)", + " cex 16 bytes", + " cex 35 cats.txt", + " cex 35 dogs.txt", + " ce2x 35 (listfile)", + " ce2x 143 (attributes)", + " x 72 (signature)", }); } @@ -295,11 +295,11 @@ TEST_CASE("list: prints several properties as columns", "[list]") { RequireLines( result.stdout_output, { - " 35 147178ed c99b9ee2 0000000000000000 16 cexmn eaa753f9 bytes", - " 44 0fd58937 70ab788e 0000000000000000 35 cexmn 935a7772 cats.txt", - " 0 eb30456b 48345fbb 0000000000000000 35 cexmn a073c614 dogs.txt", - " 25 fd657910 4e9b98a7 0000000000000000 35 ce2xmnf 2d2f0a94 (listfile)", - " 14 d38437cb 07dfeaec 0000000000000000 124 ce2xmnf 50e314af (attributes)", + " 35 147178ed c99b9ee2 0000000000000000 16 cex eaa753f9 bytes", + " 44 0fd58937 70ab788e 0000000000000000 35 cex 935a7772 cats.txt", + " 0 eb30456b 48345fbb 0000000000000000 35 cex a073c614 dogs.txt", + " 25 fd657910 4e9b98a7 0000000000000000 35 ce2x 2d2f0a94 (listfile)", + " 14 d38437cb 07dfeaec 0000000000000000 124 ce2x 50e314af (attributes)", }); } @@ -515,16 +515,16 @@ TEST_CASE("list: prints the encryption keys of entries listed without a name", " REQUIRE(unnamed.returncode == 0); REQUIRE(unnamed.stderr_output.empty()); RequireLines(unnamed.stdout_output, { - " 0 cexmn 40526ca5 40526ca5 File00000000.xxx", - " 1 cexmn 935a7772 935a7772 File00000001.xxx", - " 2 cexmn a073c614 a073c614 File00000002.xxx", + " 0 cex 40526ca5 40526ca5 File00000000.xxx", + " 1 cex 935a7772 935a7772 File00000001.xxx", + " 2 cex a073c614 a073c614 File00000002.xxx", }); REQUIRE(named.returncode == 0); REQUIRE(named.stderr_output.empty()); RequireLines(named.stdout_output, { - " 0 cexmn 40526ca5 40526ca5 capybaras.txt", - " 1 cexmn 935a7772 935a7772 cats.txt", - " 2 cexmn a073c614 a073c614 dogs.txt", + " 0 cex 40526ca5 40526ca5 capybaras.txt", + " 1 cex 935a7772 935a7772 cats.txt", + " 2 cex a073c614 a073c614 dogs.txt", }); } diff --git a/test/unit/mpq/test_flags.cpp b/test/unit/mpq/test_flags.cpp index a66c55a..da4b0d5 100644 --- a/test/unit/mpq/test_flags.cpp +++ b/test/unit/mpq/test_flags.cpp @@ -38,6 +38,21 @@ TEST_CASE("GetFlagString", "[flags]") { } } + SECTION("prints exactly one letter for a single flag") { + for (const auto &[flag, letter] : flag_letters) { + CAPTURE(flag, letter); + REQUIRE(GetFlagString(flag) == std::string(1, letter)); + } + } + + SECTION("prints one letter per flag set, and nothing for the masks") { + uint32_t all_flags = 0U; + for (const auto &entry : flag_letters) { + all_flags |= entry.flag; + } + REQUIRE(GetFlagString(all_flags) == "ice2pudrsx"); + } + SECTION("prints no other flag's letter") { for (const auto &[flag, letter] : flag_letters) { const std::string flags = GetFlagString(flag); From 0a801accfe9a93d8437a9e892c84a2f5179b3651 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 21:25:11 +1300 Subject: [PATCH 15/37] Described system error codes with FormatMessage on Windows --- src/util/format.cpp | 38 +++++++++++++++++++++++++++++++++- test/unit/util/test_format.cpp | 16 ++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/util/format.cpp b/src/util/format.cpp index c65857c..5bdbe17 100644 --- a/src/util/format.cpp +++ b/src/util/format.cpp @@ -14,8 +14,44 @@ #include +// After StormLib.h, which includes it with NOMINMAX defined first +#ifdef _WIN32 +#include +#endif + namespace mpqcli { +namespace { + +/// Describes an error code StormLib passed through from the operating system +/// +/// StormLib reports the platform's own codes: errno values on POSIX, which strerror +/// describes, and Win32 codes on Windows, which strerror would mistake for errno values. +std::string SystemErrorString(uint32_t err) { +#ifdef _WIN32 + char *buffer = nullptr; + const DWORD length = FormatMessageA( + FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, err, 0, reinterpret_cast(&buffer), 0, nullptr); + if (length == 0 || buffer == nullptr) { + return "Unknown error " + std::to_string(err); + } + std::string message(buffer, length); + LocalFree(buffer); + // The system text ends in a full stop and CRLF, which would break the line it is + // printed into + while (!message.empty() && (message.back() == '\n' || message.back() == '\r' || + message.back() == ' ' || message.back() == '.')) { + message.pop_back(); + } + return message; +#else + return std::strerror(static_cast(err)); +#endif +} + +} // namespace + std::string FileTimeToLsTime(int64_t file_time) { if (file_time == 0) { return ""; @@ -65,7 +101,7 @@ std::string StormErrorString(uint32_t err) { case ERROR_CANT_FIND_PATCH_PREFIX: return "Cannot find patch prefix"; case ERROR_FAKE_MPQ_HEADER: return "Fake MPQ header at this position"; case ERROR_FILE_DELETED: return "File contains delete marker"; - default: return std::strerror(static_cast(err)); + default: return SystemErrorString(err); // clang-format on } } diff --git a/test/unit/util/test_format.cpp b/test/unit/util/test_format.cpp index 6ac5605..cd87517 100644 --- a/test/unit/util/test_format.cpp +++ b/test/unit/util/test_format.cpp @@ -1,5 +1,7 @@ #include +#include #include +#include #include #include #include @@ -72,6 +74,20 @@ TEST_CASE("StormErrorString", "[format]") { REQUIRE(StormErrorString(ERROR_CHECKSUM_ERROR) == "Sector checksum mismatch"); REQUIRE(StormErrorString(ERROR_FILE_DELETED) == "File contains delete marker"); } + + SECTION("describes a code it has no case for in the platform's own words") { +#ifdef _WIN32 + // 32 is ERROR_SHARING_VIOLATION. Windows localises the text, so the test checks + // where it came from rather than what it says: strerror(32) is "Broken pipe" + const std::string text = StormErrorString(32U); + REQUIRE_FALSE(text.empty()); + REQUIRE(text != std::strerror(32)); + REQUIRE(text.back() != '\n'); + REQUIRE(text.back() != '.'); +#else + REQUIRE(StormErrorString(EISDIR) == std::strerror(EISDIR)); +#endif + } } TEST_CASE("WriteBinary", "[format]") { From 2c6bff1c505a8e4fa8d5c030c4748f0b0c94b777 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 21:26:59 +1300 Subject: [PATCH 16/37] Ignored SIGPIPE in the test helper so a command that skips its stdin cannot kill the run --- test/CMakeLists.txt | 1 + test/functional/test_subprocess_helper.cpp | 23 ++++++++++++++++++++++ test/subprocess_helper.cpp | 11 +++++++++++ 3 files changed, 35 insertions(+) create mode 100644 test/functional/test_subprocess_helper.cpp diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d43608a..a1fb8bb 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -67,6 +67,7 @@ add_executable(mpqcli_functional_tests functional/test_read.cpp functional/test_remove.cpp functional/test_rename.cpp + functional/test_subprocess_helper.cpp functional/test_verify.cpp functional/test_version.cpp ) diff --git a/test/functional/test_subprocess_helper.cpp b/test/functional/test_subprocess_helper.cpp new file mode 100644 index 0000000..50f4921 --- /dev/null +++ b/test/functional/test_subprocess_helper.cpp @@ -0,0 +1,23 @@ +#include +#include +#include + +#include "subprocess_helper.h" +#include "test_environment.h" + +using namespace mpqcli::testing; + +TEST_CASE("Run: survives a command that exits without reading its stdin", "[helper]") { + // More than a pipe holds, so the write cannot finish before the child exits and is + // certain to meet a closed pipe; a few bytes would only race the exit + constexpr std::size_t more_than_a_pipe_holds = 1024 * 1024; + RunOptions options; + options.stdin_input = std::string(more_than_a_pipe_holds, 'x'); + + // version rejects the extra argument before it would ever read stdin + auto result = Run(TestEnvironment::Instance().binary_path, {"version", "extra"}, options); + + // 109 is CLI11's ExtrasError + REQUIRE(result.returncode == 109); + REQUIRE_FALSE(result.timed_out); +} diff --git a/test/subprocess_helper.cpp b/test/subprocess_helper.cpp index 310134c..aae7513 100644 --- a/test/subprocess_helper.cpp +++ b/test/subprocess_helper.cpp @@ -11,6 +11,10 @@ #include #include +#ifndef _WIN32 +#include +#endif + namespace fs = std::filesystem; namespace mpqcli::testing { @@ -67,6 +71,13 @@ RunResult Run(const fs::path &binary, const std::vector &args, // only after the output has been read, which is too late for such a command FILE *stdin_stream = subprocess_stdin(&process); if (stdin_stream != nullptr) { +#ifndef _WIN32 + // A command that rejects its arguments exits without reading stdin, so the + // write can meet a closed pipe. That raises SIGPIPE, whose default action + // kills the whole test binary rather than failing the write, so it is ignored + // and the write simply comes up short. Windows reports a write error instead. + std::signal(SIGPIPE, SIG_IGN); +#endif if (options.stdin_input.has_value()) { std::fwrite(options.stdin_input->data(), 1, options.stdin_input->size(), stdin_stream); } From ccd94196b7dc3a2c449e38b582757f54029946ff Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Mon, 19 Oct 2026 18:54:12 +1300 Subject: [PATCH 17/37] Reported crashed children from the test helper and wrote stdin beside the readers --- test/functional/test_subprocess_helper.cpp | 45 ++++++++++ test/subprocess_helper.cpp | 95 ++++++++++++++++------ test/subprocess_helper.h | 6 +- 3 files changed, 121 insertions(+), 25 deletions(-) diff --git a/test/functional/test_subprocess_helper.cpp b/test/functional/test_subprocess_helper.cpp index 50f4921..3ca2663 100644 --- a/test/functional/test_subprocess_helper.cpp +++ b/test/functional/test_subprocess_helper.cpp @@ -1,7 +1,12 @@ #include +#include #include #include +#ifndef _WIN32 +#include +#endif + #include "subprocess_helper.h" #include "test_environment.h" @@ -21,3 +26,43 @@ TEST_CASE("Run: survives a command that exits without reading its stdin", "[help REQUIRE(result.returncode == 109); REQUIRE_FALSE(result.timed_out); } + +// mpqcli cannot be made to crash or hang on demand, so a shell stands in for it. There is +// no /bin/sh on Windows, and no signals to report. +#ifndef _WIN32 +TEST_CASE("Run: reports a child killed by a signal as a shell does", "[helper]") { + // The expected message comes first, so only the exit code tells a crash from a failure + auto result = Run("/bin/sh", {"-c", "echo '[!] expected message' >&2; kill -SEGV $$"}); + + REQUIRE(result.returncode == 128 + SIGSEGV); + REQUIRE(result.stderr_output == "[!] expected message\n"); + REQUIRE_FALSE(result.timed_out); +} + +TEST_CASE("Run: kills a child that outlives its timeout", "[helper]") { + RunOptions options; + options.timeout = std::chrono::seconds(1); + + // exec, so the kill reaches sleep itself rather than a shell that would leave sleep + // holding the pipes open + auto result = Run("/bin/sh", {"-c", "exec sleep 30"}, options); + + REQUIRE(result.timed_out); + REQUIRE(result.returncode == -1); +} + +TEST_CASE("Run: reads the output while a large stdin is still being written", "[helper]") { + // The child ignores its stdin and fills its stdout, each with more than a pipe holds, + // which deadlocks a helper that writes all the input before reading any output + constexpr std::size_t more_than_a_pipe_holds = 1024 * 1024; + RunOptions options; + options.stdin_input = std::string(more_than_a_pipe_holds, 'x'); + options.timeout = std::chrono::seconds(10); + + auto result = Run("/bin/sh", {"-c", "exec head -c 1048576 /dev/zero"}, options); + + REQUIRE_FALSE(result.timed_out); + REQUIRE(result.returncode == 0); + REQUIRE(result.stdout_output.size() == more_than_a_pipe_holds); +} +#endif diff --git a/test/subprocess_helper.cpp b/test/subprocess_helper.cpp index aae7513..a61e42b 100644 --- a/test/subprocess_helper.cpp +++ b/test/subprocess_helper.cpp @@ -1,10 +1,13 @@ #include "subprocess_helper.h" +#include #include #include #include #include +#include #include +#include #include #include #include @@ -12,7 +15,10 @@ #include #ifndef _WIN32 +#include #include +#include +#include #endif namespace fs = std::filesystem; @@ -36,6 +42,44 @@ std::string ReadAll(FILE *stream) { return output; } +// Writes any input, then closes stdin so a command reading paths from stdin sees EOF +// instead of waiting forever +void WriteInput(subprocess_s &process, const std::optional &input) { + FILE *stdin_stream = subprocess_stdin(&process); + if (stdin_stream == nullptr) { + return; + } + if (input.has_value()) { + std::fwrite(input->data(), 1, input->size(), stdin_stream); + } + std::fclose(stdin_stream); + process.stdin_file = nullptr; +} + +// Reaps the child and returns its exit code, or nothing if it cannot be waited for. On +// POSIX a child killed by a signal gives 128 plus the signal number, as a shell reports +// it. subprocess_join is no use for that there: it reports any signal as EXIT_FAILURE, +// so a crash would pass for an expected failure. +std::optional WaitForExit(subprocess_s &process) { +#ifdef _WIN32 + int returncode = -1; + if (subprocess_join(&process, &returncode) != 0) { + return std::nullopt; + } + return returncode; +#else + int status = 0; + pid_t reaped = -1; + do { + reaped = waitpid(process.child, &status, 0); + } while (reaped == -1 && errno == EINTR); + if (reaped != process.child) { + return std::nullopt; + } + return WIFSIGNALED(status) ? 128 + WTERMSIG(status) : WEXITSTATUS(status); +#endif +} + } // namespace RunResult Run(const fs::path &binary, const std::vector &args, @@ -66,29 +110,18 @@ RunResult Run(const fs::path &binary, const std::vector &args, throw std::runtime_error("subprocess_helper: failed to spawn " + binary.string()); } - // Write any input, then close stdin so a command reading paths from stdin - // sees EOF instead of waiting forever. subprocess_join closes it too, but - // only after the output has been read, which is too late for such a command - FILE *stdin_stream = subprocess_stdin(&process); - if (stdin_stream != nullptr) { #ifndef _WIN32 - // A command that rejects its arguments exits without reading stdin, so the - // write can meet a closed pipe. That raises SIGPIPE, whose default action - // kills the whole test binary rather than failing the write, so it is ignored - // and the write simply comes up short. Windows reports a write error instead. - std::signal(SIGPIPE, SIG_IGN); + // A command that rejects its arguments exits without reading stdin, so the write + // can meet a closed pipe. That raises SIGPIPE, whose default action kills the whole + // test binary rather than failing the write, so it is ignored and the write simply + // comes up short. Windows reports a write error instead. + std::signal(SIGPIPE, SIG_IGN); #endif - if (options.stdin_input.has_value()) { - std::fwrite(options.stdin_input->data(), 1, options.stdin_input->size(), stdin_stream); - } - std::fclose(stdin_stream); - process.stdin_file = nullptr; - } RunResult result; - // Watchdog: a hung binary is killed and reported rather than hanging the - // whole ctest run + // Watchdog: a hung binary is killed and reported rather than hanging the whole + // ctest run. It starts before any I/O, so nothing below can block without a limit. std::mutex finished_mutex; std::condition_variable finished_cv; bool finished = false; @@ -100,23 +133,39 @@ RunResult Run(const fs::path &binary, const std::vector &args, } }); - // stderr is drained on its own thread: a process that fills one pipe while - // the parent is blocked reading the other would otherwise deadlock + // Input is written beside the readers rather than before them: a child that ignores + // its stdin and fills its stdout would otherwise block on the output while the + // helper blocked on the input. stderr gets its own thread for the same reason. + std::thread stdin_writer([&] { WriteInput(process, options.stdin_input); }); std::thread stderr_reader([&] { result.stderr_output = ReadAll(subprocess_stderr(&process)); }); result.stdout_output = ReadAll(subprocess_stdout(&process)); stderr_reader.join(); + stdin_writer.join(); - int returncode = 0; - subprocess_join(&process, &returncode); + // The watchdog stops before the child is reaped, never after: subprocess_terminate + // signals the child's pid, which is free for reuse once the child is reaped. Both + // pipes are closed by now, so the child has exited or is about to. { std::lock_guard lock(finished_mutex); finished = true; } finished_cv.notify_all(); watchdog.join(); + + const std::optional returncode = WaitForExit(process); subprocess_destroy(&process); + if (!returncode.has_value()) { + throw std::runtime_error("subprocess_helper: failed to wait for " + binary.string()); + } - result.returncode = result.timed_out ? -1 : returncode; + if (result.timed_out) { + // Said here because a test sees only the returncode of -1 + std::cerr << "subprocess_helper: killed " << binary.string() << " after " + << options.timeout.count() << " s" << std::endl; + result.returncode = -1; + } else { + result.returncode = *returncode; + } return result; } diff --git a/test/subprocess_helper.h b/test/subprocess_helper.h index d702109..7686e21 100644 --- a/test/subprocess_helper.h +++ b/test/subprocess_helper.h @@ -20,7 +20,9 @@ struct RunOptions { /// What the binary did: both streams as raw bytes, its exit code, and whether the limit hit /// -/// returncode is -1 when the process was killed for exceeding the timeout. +/// returncode is -1 when the process was killed for exceeding the timeout. On POSIX, a +/// process killed by a signal gives 128 plus the signal number, as a shell reports it, +/// so a crash never passes for an expected failure. struct RunResult { std::string stdout_output; std::string stderr_output; @@ -32,7 +34,7 @@ struct RunResult { /// /// Every functional test is written against this signature; how it is implemented is /// the helper's own business. Throws std::runtime_error if the process cannot be spawned -/// at all, which is a broken test environment rather than a result to assert on. +/// or waited for, which is a broken test environment rather than a result to assert on. /// /// @param binary Path to the executable to run. /// @param args Arguments after the program name, one string each, unquoted. From 0b80ed2f75bad12bd2a424fa918279db33ea1025 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Mon, 19 Oct 2026 19:31:48 +1300 Subject: [PATCH 18/37] Hardened the test fixtures and ran missing-path tests in an empty working directory --- test/archive_helper.cpp | 13 +++++++------ test/fixtures/sample_files.h | 5 ++++- test/fixtures/temp_dir.h | 18 ++++++++++++------ test/fixtures/write_file.h | 8 +++++++- test/functional/test_add.cpp | 18 ++++++++++++++---- test/functional/test_compact.cpp | 12 ++++++++++-- test/functional/test_extract.cpp | 12 ++++++++++-- test/functional/test_info.cpp | 13 +++++++++++-- test/functional/test_list.cpp | 18 ++++++++++++++---- test/functional/test_read.cpp | 6 +++++- test/functional/test_remove.cpp | 6 +++++- test/functional/test_rename.cpp | 7 ++++++- test/functional/test_verify.cpp | 6 +++++- 13 files changed, 110 insertions(+), 32 deletions(-) diff --git a/test/archive_helper.cpp b/test/archive_helper.cpp index 8828924..cb619b4 100644 --- a/test/archive_helper.cpp +++ b/test/archive_helper.cpp @@ -5,7 +5,6 @@ #include #include #include -#include #include #include @@ -43,12 +42,14 @@ std::string EntryProperty(const fs::path &archive, const std::string &name, auto result = Run(TestEnvironment::Instance().binary_path, {"list", archive.string(), "-d", "-q", property}); REQUIRE(result.returncode == 0); + // The name is matched at the end of the line, after the two spaces list puts before + // it, rather than split off at whitespace: a file time holds a space, and so may a name + const std::string name_field = " " + name; for (const auto &line : LinesToSet(result.stdout_output)) { - std::istringstream fields(line); - std::string value; - std::string entry; - fields >> value >> entry; - if (entry == name) { + if (line.size() >= name_field.size() && + line.compare(line.size() - name_field.size(), name_field.size(), name_field) == 0) { + std::string value = line.substr(0, line.size() - name_field.size()); + value.erase(0, value.find_first_not_of(' ')); return value; } } diff --git a/test/fixtures/sample_files.h b/test/fixtures/sample_files.h index 3a06107..a9b7289 100644 --- a/test/fixtures/sample_files.h +++ b/test/fixtures/sample_files.h @@ -17,7 +17,10 @@ namespace mpqcli::testing { /// /// An archive's (attributes) block stores each file's time, so the archive sizes and /// listings the tests assert only hold when the inputs always carry the same one. Local -/// time, because that is what mpqcli prints back in a detailed listing. +/// time, because that is what mpqcli prints back in a detailed listing. The block itself +/// stores UTC, so its compressed size depends in principle on the time zone the tests +/// run in. For this time and these files it comes out the same at every UTC offset in +/// use, but changing either can make one zone see different archive sizes. inline std::filesystem::file_time_type SampleFileTime() { std::tm calendar{}; calendar.tm_year = 2025 - 1900; diff --git a/test/fixtures/temp_dir.h b/test/fixtures/temp_dir.h index 78f4a0d..8b0e2d0 100644 --- a/test/fixtures/temp_dir.h +++ b/test/fixtures/temp_dir.h @@ -13,16 +13,22 @@ namespace mpqcli::testing { /// /// Every test that writes anything writes here. ctest runs test cases as separate /// processes in parallel, so the name carries 64 random bits rather than a counter, -/// which would collide across processes. The destructor swallows the removal error: -/// throwing out of it would mask whichever assertion actually failed. +/// which would collide across processes, and a name already taken is drawn again. The +/// destructor swallows the removal error: throwing out of it would mask whichever +/// assertion actually failed. class TempDir { public: TempDir() { std::random_device device; - std::ostringstream name; - name << "mpqcli_test_" << std::hex << device() << device(); - path_ = std::filesystem::temp_directory_path() / name.str(); - std::filesystem::create_directories(path_); + const std::filesystem::path base = std::filesystem::temp_directory_path(); + // create_directory says whether it made the directory, where create_directories + // succeeds on one that exists, and two tests would quietly share it + do { + const std::uint64_t bits = (static_cast(device()) << 32) | device(); + std::ostringstream name; + name << "mpqcli_test_" << std::hex << bits; + path_ = base / name.str(); + } while (!std::filesystem::create_directory(path_)); } TempDir(const TempDir &) = delete; diff --git a/test/fixtures/write_file.h b/test/fixtures/write_file.h index 1b9f778..8da14cf 100644 --- a/test/fixtures/write_file.h +++ b/test/fixtures/write_file.h @@ -4,6 +4,7 @@ #include #include #include +#include #include namespace mpqcli::testing { @@ -11,11 +12,16 @@ namespace mpqcli::testing { /// Writes content to path byte for byte, creating parent directories as needed /// /// Binary mode on every platform, so a "\n" in the content stays one byte on Windows -/// and the sizes a test expects hold everywhere. +/// and the sizes a test expects hold everywhere. Throws std::runtime_error when the file +/// cannot be written in full, since a short input would only fail some later assertion. inline void WriteFile(const std::filesystem::path &path, const std::string &content) { std::filesystem::create_directories(path.parent_path()); std::ofstream out(path, std::ios::binary); out.write(content.data(), static_cast(content.size())); + out.close(); + if (!out) { + throw std::runtime_error("WriteFile: cannot write " + path.string()); + } } } // namespace mpqcli::testing diff --git a/test/functional/test_add.cpp b/test/functional/test_add.cpp index bcfb6c7..41ccf59 100644 --- a/test/functional/test_add.cpp +++ b/test/functional/test_add.cpp @@ -69,9 +69,13 @@ TEST_CASE("add: prints its help with --help", "[add]") { TEST_CASE("add: archive does not exist", "[add]") { SampleFiles files; + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); auto result = - Run(mpqcli_binary, {"add", "no-such-archive.mpq", (files.files_dir / "cats.txt").string()}); + Run(mpqcli_binary, {"add", "no-such-archive.mpq", (files.files_dir / "cats.txt").string()}, + options); // 105 is CLI11's ValidationError, from the CLI::ExistingFile check REQUIRE(result.returncode == 105); @@ -93,8 +97,11 @@ TEST_CASE("add: fails when the archive is not an MPQ", "[add]") { TEST_CASE("add: input path does not exist", "[add]") { SampleArchive sample; + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); - auto result = Run(mpqcli_binary, {"add", sample.archive.string(), "no-such-file.txt"}); + auto result = Run(mpqcli_binary, {"add", sample.archive.string(), "no-such-file.txt"}, options); REQUIRE(result.returncode == 1); REQUIRE(result.stdout_output.empty()); @@ -107,9 +114,12 @@ TEST_CASE("add: continues past a missing input and still fails", "[add]") { SampleArchive sample; const auto real = sample.files.dir.Path() / "real.txt"; WriteFile(real, "real file"); + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); - auto result = - Run(mpqcli_binary, {"add", sample.archive.string(), "no-such-file.txt", real.string()}); + auto result = Run(mpqcli_binary, + {"add", sample.archive.string(), "no-such-file.txt", real.string()}, options); // The run carries on to the remaining inputs, but a missing one is still a failure REQUIRE(result.returncode == 1); diff --git a/test/functional/test_compact.cpp b/test/functional/test_compact.cpp index 915deda..bfbdeb3 100644 --- a/test/functional/test_compact.cpp +++ b/test/functional/test_compact.cpp @@ -63,7 +63,11 @@ TEST_CASE("compact: prints its help with --help", "[compact]") { } TEST_CASE("compact: archive does not exist", "[compact]") { - auto result = Run(mpqcli_binary, {"compact", "no-such-archive.mpq"}); + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); + + auto result = Run(mpqcli_binary, {"compact", "no-such-archive.mpq"}, options); // 105 is CLI11's ValidationError, from the CLI::ExistingFile check REQUIRE(result.returncode == 105); @@ -74,9 +78,13 @@ TEST_CASE("compact: archive does not exist", "[compact]") { TEST_CASE("compact: listfile does not exist", "[compact]") { SampleArchive sample; + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); auto result = - Run(mpqcli_binary, {"compact", sample.archive.string(), "--listfile", "no-such-list.txt"}); + Run(mpqcli_binary, {"compact", sample.archive.string(), "--listfile", "no-such-list.txt"}, + options); REQUIRE(result.returncode == 105); REQUIRE(result.stdout_output.empty()); diff --git a/test/functional/test_extract.cpp b/test/functional/test_extract.cpp index 607f845..7b9e1a0 100644 --- a/test/functional/test_extract.cpp +++ b/test/functional/test_extract.cpp @@ -109,7 +109,11 @@ TEST_CASE("extract: prints its help with --help", "[extract]") { } TEST_CASE("extract: archive does not exist", "[extract]") { - auto result = Run(mpqcli_binary, {"extract", "no-such-archive.mpq"}); + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); + + auto result = Run(mpqcli_binary, {"extract", "no-such-archive.mpq"}, options); // 105 is CLI11's ValidationError, from the CLI::ExistingFile check REQUIRE(result.returncode == 105); @@ -120,9 +124,13 @@ TEST_CASE("extract: archive does not exist", "[extract]") { TEST_CASE("extract: listfile does not exist", "[extract]") { SampleArchive sample; + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); auto result = - Run(mpqcli_binary, {"extract", sample.archive.string(), "--listfile", "no-such-list.txt"}); + Run(mpqcli_binary, {"extract", sample.archive.string(), "--listfile", "no-such-list.txt"}, + options); REQUIRE(result.returncode == 105); REQUIRE(result.stdout_output.empty()); diff --git a/test/functional/test_info.cpp b/test/functional/test_info.cpp index 7786df3..19d0b11 100644 --- a/test/functional/test_info.cpp +++ b/test/functional/test_info.cpp @@ -16,6 +16,7 @@ #include "sample_archive.h" #include "sample_files.h" #include "subprocess_helper.h" +#include "temp_dir.h" #include "test_environment.h" #include "write_file.h" @@ -130,7 +131,11 @@ TEST_CASE("info: prints its help with --help", "[info]") { } TEST_CASE("info: archive does not exist", "[info]") { - auto result = Run(mpqcli_binary, {"info", "no-such-archive.mpq"}); + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); + + auto result = Run(mpqcli_binary, {"info", "no-such-archive.mpq"}, options); // 105 is CLI11's ValidationError, from the CLI::ExistingFile check REQUIRE(result.returncode == 105); @@ -174,8 +179,12 @@ TEST_CASE("info: rejects an unknown property", "[info]") { TEST_CASE("info: rejects -p, which is not one of its options", "[info]") { SampleArchive sample; + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); - auto result = Run(mpqcli_binary, {"info", "-p", "file-count", sample.archive.string()}); + auto result = + Run(mpqcli_binary, {"info", "-p", "file-count", sample.archive.string()}, options); // The unknown -p is set aside and the property name fills the archive // positional, whose CLI::ExistingFile check fails first: 105 is CLI11's diff --git a/test/functional/test_list.cpp b/test/functional/test_list.cpp index b5b381d..3a367ed 100644 --- a/test/functional/test_list.cpp +++ b/test/functional/test_list.cpp @@ -140,7 +140,11 @@ TEST_CASE("list: prints its help with --help", "[list]") { } TEST_CASE("list: archive does not exist", "[list]") { - auto result = Run(mpqcli_binary, {"list", "no-such-archive.mpq"}); + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); + + auto result = Run(mpqcli_binary, {"list", "no-such-archive.mpq"}, options); // 105 is CLI11's ValidationError, from the CLI::ExistingFile check REQUIRE(result.returncode == 105); @@ -151,9 +155,12 @@ TEST_CASE("list: archive does not exist", "[list]") { TEST_CASE("list: listfile does not exist", "[list]") { SampleArchive sample; + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); - auto result = - Run(mpqcli_binary, {"list", sample.archive.string(), "--listfile", "no-such-list.txt"}); + auto result = Run(mpqcli_binary, + {"list", sample.archive.string(), "--listfile", "no-such-list.txt"}, options); REQUIRE(result.returncode == 105); REQUIRE(result.stdout_output.empty()); @@ -185,8 +192,11 @@ TEST_CASE("list: rejects an unknown property", "[list]") { TEST_CASE("list: rejects -p, which is not one of its options", "[list]") { SampleArchive sample; + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); - auto result = Run(mpqcli_binary, {"list", "-p", "locale", sample.archive.string()}); + auto result = Run(mpqcli_binary, {"list", "-p", "locale", sample.archive.string()}, options); // The unknown -p is set aside and the property name fills the archive // positional, whose CLI::ExistingFile check fails first: 105 is CLI11's diff --git a/test/functional/test_read.cpp b/test/functional/test_read.cpp index d40278d..28abb0e 100644 --- a/test/functional/test_read.cpp +++ b/test/functional/test_read.cpp @@ -104,7 +104,11 @@ TEST_CASE("read: every subcommand with a locale offers it as -e", "[read]") { } TEST_CASE("read: archive does not exist", "[read]") { - auto result = Run(mpqcli_binary, {"read", "no-such-archive.mpq", "cats.txt"}); + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); + + auto result = Run(mpqcli_binary, {"read", "no-such-archive.mpq", "cats.txt"}, options); // 105 is CLI11's ValidationError, from the CLI::ExistingFile check REQUIRE(result.returncode == 105); diff --git a/test/functional/test_remove.cpp b/test/functional/test_remove.cpp index ab1bf1b..6a17923 100644 --- a/test/functional/test_remove.cpp +++ b/test/functional/test_remove.cpp @@ -71,7 +71,11 @@ TEST_CASE("remove: prints its help with --help", "[remove]") { } TEST_CASE("remove: archive does not exist", "[remove]") { - auto result = Run(mpqcli_binary, {"remove", "no-such-archive.mpq", "cats.txt"}); + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); + + auto result = Run(mpqcli_binary, {"remove", "no-such-archive.mpq", "cats.txt"}, options); // 105 is CLI11's ValidationError, from the CLI::ExistingFile check REQUIRE(result.returncode == 105); diff --git a/test/functional/test_rename.cpp b/test/functional/test_rename.cpp index a18d56b..eb6c74f 100644 --- a/test/functional/test_rename.cpp +++ b/test/functional/test_rename.cpp @@ -82,7 +82,12 @@ TEST_CASE("rename: prints its help with --help", "[rename]") { } TEST_CASE("rename: archive does not exist", "[rename]") { - auto result = Run(mpqcli_binary, {"rename", "no-such-archive.mpq", "cats.txt", "felines.txt"}); + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); + + auto result = + Run(mpqcli_binary, {"rename", "no-such-archive.mpq", "cats.txt", "felines.txt"}, options); // 105 is CLI11's ValidationError, from the CLI::ExistingFile check REQUIRE(result.returncode == 105); diff --git a/test/functional/test_verify.cpp b/test/functional/test_verify.cpp index b91f782..de74366 100644 --- a/test/functional/test_verify.cpp +++ b/test/functional/test_verify.cpp @@ -66,7 +66,11 @@ TEST_CASE("verify: prints its help with --help", "[verify]") { } TEST_CASE("verify: archive does not exist", "[verify]") { - auto result = Run(mpqcli_binary, {"verify", "no-such-archive.mpq"}); + TempDir working_dir; + RunOptions options; + options.working_dir = working_dir.Path(); + + auto result = Run(mpqcli_binary, {"verify", "no-such-archive.mpq"}, options); // 105 is CLI11's ValidationError, from the CLI::ExistingFile check REQUIRE(result.returncode == 105); From 06221bc377b6eb36243ebd98f1016f8b6203ec2e Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Mon, 19 Oct 2026 19:49:37 +1300 Subject: [PATCH 19/37] Tested cancellation part-way through create, add, remove and extract --- test/fixtures/cancel_after.h | 61 +++++++++++++++++++++++++++++ test/unit/commands/test_add.cpp | 22 +++++++++++ test/unit/commands/test_create.cpp | 17 ++++++++ test/unit/commands/test_extract.cpp | 10 +++++ test/unit/commands/test_remove.cpp | 18 ++++++++- 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/cancel_after.h diff --git a/test/fixtures/cancel_after.h b/test/fixtures/cancel_after.h new file mode 100644 index 0000000..d863dad --- /dev/null +++ b/test/fixtures/cancel_after.h @@ -0,0 +1,61 @@ +#ifndef CANCEL_AFTER_H +#define CANCEL_AFTER_H + +#include +#include +#include +#include +#include +#include + +namespace mpqcli::testing { + +/// An error stream that raises a cancellation flag once a chosen line has been written +/// +/// Every looping command writes one line per item to its error stream and polls the flag +/// before the next item, so raising the flag as the first such line ends interrupts the +/// run part-way through. A flag set before the call stops the command before its loop +/// starts, which cannot tell a poll inside the loop from one ahead of it. +class CancelAfter : private std::streambuf { +public: + /// @param prefix The start of the line that raises the flag. + explicit CancelAfter(std::string prefix) : prefix_(std::move(prefix)), stream_(this) {} + + CancelAfter(const CancelAfter &) = delete; + CancelAfter &operator=(const CancelAfter &) = delete; + + /// The stream to pass to the command as its error stream + std::ostream &Err() { return stream_; } + + /// The flag to pass to the command, raised once a line starting with the prefix ends + const std::atomic &Cancelled() const { return cancelled_; } + + /// Everything written to the stream so far + const std::string &Text() const { return text_; } + +private: + int_type overflow(int_type ch) override { + if (traits_type::eq_int_type(ch, traits_type::eof())) { + return traits_type::not_eof(ch); + } + const char c = traits_type::to_char_type(ch); + text_.push_back(c); + if (c == '\n') { + if (text_.compare(line_start_, prefix_.size(), prefix_) == 0) { + cancelled_.store(true); + } + line_start_ = text_.size(); + } + return ch; + } + + std::string prefix_; + std::string text_; + std::size_t line_start_ = 0; + std::atomic cancelled_{false}; + std::ostream stream_; +}; + +} // namespace mpqcli::testing + +#endif // CANCEL_AFTER_H diff --git a/test/unit/commands/test_add.cpp b/test/unit/commands/test_add.cpp index 667a686..efce696 100644 --- a/test/unit/commands/test_add.cpp +++ b/test/unit/commands/test_add.cpp @@ -4,6 +4,7 @@ #include #include +#include "cancel_after.h" #include "commands/add.h" #include "created_archive.h" #include "errors.h" @@ -16,6 +17,7 @@ namespace fs = std::filesystem; using mpqcli::Add; using mpqcli::AddOptions; using mpqcli::testing::ArchiveHasFile; +using mpqcli::testing::CancelAfter; using mpqcli::testing::CreatedArchive; using mpqcli::testing::ReadFile; using mpqcli::testing::SampleFiles; @@ -47,4 +49,24 @@ TEST_CASE("Add", "[add]") { REQUIRE(ArchiveHasFile(created.archive, entry.first)); } } + + // Add changes the archive in place, so a file added before the interrupt stays + SECTION("stops after the first file of a directory when cancelled part-way") { + WriteFile(new_file.parent_path() / "fish.txt", "This is a file about fish.\n"); + options.files = {new_file.parent_path()}; + CancelAfter cancel("[+] Adding file"); + REQUIRE_THROWS_AS(Add(options, cancel.Err(), cancel.Cancelled()), mpqcli::Interrupted); + REQUIRE(ArchiveHasFile(created.archive, "birds.txt")); + REQUIRE_FALSE(ArchiveHasFile(created.archive, "fish.txt")); + } + + SECTION("stops after the first of several files when cancelled part-way") { + const fs::path fish = new_file.parent_path() / "fish.txt"; + WriteFile(fish, "This is a file about fish.\n"); + options.files = {new_file, fish}; + CancelAfter cancel("[+] Adding file"); + REQUIRE_THROWS_AS(Add(options, cancel.Err(), cancel.Cancelled()), mpqcli::Interrupted); + REQUIRE(ArchiveHasFile(created.archive, "birds.txt")); + REQUIRE_FALSE(ArchiveHasFile(created.archive, "fish.txt")); + } } diff --git a/test/unit/commands/test_create.cpp b/test/unit/commands/test_create.cpp index caa39b6..d820218 100644 --- a/test/unit/commands/test_create.cpp +++ b/test/unit/commands/test_create.cpp @@ -2,8 +2,10 @@ #include #include #include +#include #include +#include "cancel_after.h" #include "commands/create.h" #include "created_archive.h" #include "errors.h" @@ -14,6 +16,7 @@ namespace fs = std::filesystem; using mpqcli::Create; using mpqcli::CreateOptions; using mpqcli::testing::ArchiveHasFile; +using mpqcli::testing::CancelAfter; using mpqcli::testing::SampleFiles; TEST_CASE("Create", "[create]") { @@ -48,4 +51,18 @@ TEST_CASE("Create", "[create]") { const std::vector expected = {files.files_dir}; REQUIRE(entries == expected); } + + SECTION("throws Interrupted part-way through the inputs, leaving nothing behind") { + CancelAfter cancel("[+] Adding file"); + REQUIRE_THROWS_AS(Create(options, cancel.Err(), cancel.Cancelled()), mpqcli::Interrupted); + REQUIRE(cancel.Text().find("[+] Adding file: bytes") != std::string::npos); + REQUIRE(cancel.Text().find("[+] Adding file: cats.txt") == std::string::npos); + REQUIRE_FALSE(fs::exists(options.archive)); + REQUIRE_FALSE(fs::exists(partial)); + + const std::vector entries(fs::directory_iterator(files.dir.Path()), + fs::directory_iterator{}); + const std::vector expected = {files.files_dir}; + REQUIRE(entries == expected); + } } diff --git a/test/unit/commands/test_extract.cpp b/test/unit/commands/test_extract.cpp index 6dd4742..8dfd975 100644 --- a/test/unit/commands/test_extract.cpp +++ b/test/unit/commands/test_extract.cpp @@ -5,6 +5,7 @@ #include #include +#include "cancel_after.h" #include "commands/extract.h" #include "created_archive.h" #include "errors.h" @@ -15,6 +16,7 @@ namespace fs = std::filesystem; using mpqcli::Extract; using mpqcli::ExtractOptions; +using mpqcli::testing::CancelAfter; using mpqcli::testing::CreatedArchive; using mpqcli::testing::ReadFile; using mpqcli::testing::SampleFiles; @@ -49,4 +51,12 @@ TEST_CASE("Extract", "[extract]") { const bool nothing_extracted = !fs::exists(output) || fs::is_empty(output); REQUIRE(nothing_extracted); } + + SECTION("stops after the first named file when cancelled part-way") { + options.files = std::vector{"cats.txt", "dogs.txt"}; + CancelAfter cancel("[*] Extracted:"); + REQUIRE_THROWS_AS(Extract(options, cancel.Err(), cancel.Cancelled()), mpqcli::Interrupted); + REQUIRE(ReadFile(output / "cats.txt") == "This is a file about cats.\n"); + REQUIRE_FALSE(fs::exists(output / "dogs.txt")); + } } diff --git a/test/unit/commands/test_remove.cpp b/test/unit/commands/test_remove.cpp index 197ebc6..dc0d1cd 100644 --- a/test/unit/commands/test_remove.cpp +++ b/test/unit/commands/test_remove.cpp @@ -1,15 +1,20 @@ #include #include #include +#include +#include "cancel_after.h" #include "commands/remove.h" #include "created_archive.h" #include "errors.h" +#include "read_file.h" using mpqcli::Remove; using mpqcli::RemoveOptions; using mpqcli::testing::ArchiveHasFile; +using mpqcli::testing::CancelAfter; using mpqcli::testing::CreatedArchive; +using mpqcli::testing::ReadFile; TEST_CASE("Remove", "[remove]") { CreatedArchive created; @@ -25,9 +30,20 @@ TEST_CASE("Remove", "[remove]") { REQUIRE(ArchiveHasFile(created.archive, "dogs.txt")); } - SECTION("throws Interrupted when already cancelled, removing nothing") { + SECTION("throws Interrupted when already cancelled, leaving the archive as it was") { + const std::string before = ReadFile(created.archive); const std::atomic cancelled{true}; REQUIRE_THROWS_AS(Remove(options, err, cancelled), mpqcli::Interrupted); + REQUIRE(ReadFile(created.archive) == before); REQUIRE(ArchiveHasFile(created.archive, "cats.txt")); } + + // Remove changes the archive in place, so a file removed before the interrupt stays gone + SECTION("stops after the first name when cancelled part-way") { + options.files = {"cats.txt", "dogs.txt"}; + CancelAfter cancel("[-] Removing file"); + REQUIRE_THROWS_AS(Remove(options, cancel.Err(), cancel.Cancelled()), mpqcli::Interrupted); + REQUIRE_FALSE(ArchiveHasFile(created.archive, "cats.txt")); + REQUIRE(ArchiveHasFile(created.archive, "dogs.txt")); + } } From 584bc8617c70a6366a22012614045272a31b0c08 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Mon, 19 Oct 2026 20:02:14 +1300 Subject: [PATCH 20/37] Removed the empty output directory an interrupted extract left behind --- src/commands/extract.cpp | 27 +++++++++++++++++++++------ test/unit/commands/test_extract.cpp | 15 ++++++++++++--- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/commands/extract.cpp b/src/commands/extract.cpp index 15e7d14..b577951 100644 --- a/src/commands/extract.cpp +++ b/src/commands/extract.cpp @@ -19,6 +19,23 @@ namespace fs = std::filesystem; namespace mpqcli { +namespace { + +/// Removes the output directory on the way out if this run made it and nothing landed in it +struct OutputDirectory { + fs::path path; + bool made_here = false; + + ~OutputDirectory() { + std::error_code ec; + if (made_here && fs::is_empty(path, ec)) { + fs::remove(path, ec); + } + } +}; + +} // namespace + bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic &cancelled) { std::error_code ec; fs::path effective_output; @@ -57,7 +74,9 @@ bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic } } - // Remembered so a run that extracts nothing can take away the directory it made + // Remembered so a run that extracts nothing can take away the directory it made. + // The guard does that on every way out, a thrown Interrupted included, which a + // clean-up beside the return would miss. std::error_code exists_ec; const bool output_existed = fs::exists(effective_output, exists_ec); @@ -68,6 +87,7 @@ bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic throw FileError("Failed to create output directory", effective_output, ec); } } + const OutputDirectory output_directory{effective_output, !output_existed}; // ExtractFile can only check for symlink traversal where the OS can resolve // real paths; warn once up front on volumes where it cannot (RAM disks) @@ -84,11 +104,6 @@ bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic if (result != 0) { err << "[!] Failed to extract all files." << std::endl; - // fs::remove takes away an empty directory only, so a partial extraction stays - std::error_code cleanup_ec; - if (!output_existed && fs::is_empty(effective_output, cleanup_ec)) { - fs::remove(effective_output, cleanup_ec); - } } return result == 0; } diff --git a/test/unit/commands/test_extract.cpp b/test/unit/commands/test_extract.cpp index 8dfd975..b2936f9 100644 --- a/test/unit/commands/test_extract.cpp +++ b/test/unit/commands/test_extract.cpp @@ -44,12 +44,21 @@ TEST_CASE("Extract", "[extract]") { REQUIRE_FALSE(fs::exists(output)); } - SECTION("throws Interrupted when cancelled before the first named file, extracting nothing") { + SECTION("throws Interrupted when cancelled before the first named file, leaving no output " + "directory") { options.files = std::vector{"cats.txt", "dogs.txt"}; const std::atomic cancelled{true}; REQUIRE_THROWS_AS(Extract(options, err, cancelled), mpqcli::Interrupted); - const bool nothing_extracted = !fs::exists(output) || fs::is_empty(output); - REQUIRE(nothing_extracted); + REQUIRE_FALSE(fs::exists(output)); + } + + SECTION("keeps an output directory that already existed when cancelled") { + fs::create_directories(output); + options.files = std::vector{"cats.txt", "dogs.txt"}; + const std::atomic cancelled{true}; + REQUIRE_THROWS_AS(Extract(options, err, cancelled), mpqcli::Interrupted); + REQUIRE(fs::is_directory(output)); + REQUIRE(fs::is_empty(output)); } SECTION("stops after the first named file when cancelled part-way") { From 9d9bc4ba8f97b01c41a3615ce200104eeee2eb42 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Mon, 19 Oct 2026 20:19:51 +1300 Subject: [PATCH 21/37] Added unit tests for the table growth, empty archive, empty listfile and missing-file fixes --- test/fixtures/created_archive.h | 18 ++++++++++++++ test/unit/commands/test_add.cpp | 33 +++++++++++++++++++++++++ test/unit/commands/test_extract.cpp | 19 +++++++++++++++ test/unit/commands/test_list.cpp | 37 +++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+) diff --git a/test/fixtures/created_archive.h b/test/fixtures/created_archive.h index a5c0577..5c39c97 100644 --- a/test/fixtures/created_archive.h +++ b/test/fixtures/created_archive.h @@ -47,6 +47,24 @@ struct CreatedArchive { } }; +/// Creates an archive over input with no (listfile), by calling Create directly +/// +/// The entries then have no names, and an empty directory as input gives an archive with +/// no entries at all. A failed create throws, since that is a broken fixture rather than +/// a result. +inline void CreateWithoutListfile(const std::filesystem::path &archive, + const std::filesystem::path &input) { + CreateOptions options; + options.archive = archive; + options.files = {input}; + options.create_overrides.file_flags1 = 0U; + std::ostringstream err; + const std::atomic cancelled{false}; + if (!Create(options, err, cancelled)) { + throw std::runtime_error("CreateWithoutListfile: create failed: " + err.str()); + } +} + } // namespace mpqcli::testing #endif // CREATED_ARCHIVE_H diff --git a/test/unit/commands/test_add.cpp b/test/unit/commands/test_add.cpp index efce696..3dba255 100644 --- a/test/unit/commands/test_add.cpp +++ b/test/unit/commands/test_add.cpp @@ -1,19 +1,34 @@ #include #include +#include #include #include #include +#include + #include "cancel_after.h" #include "commands/add.h" #include "created_archive.h" #include "errors.h" +#include "mpq/archive.h" +#include "mpq/query.h" #include "read_file.h" #include "sample_files.h" #include "write_file.h" namespace fs = std::filesystem; +namespace { + +// The size of the archive's file table, which StormLib rounds up beyond what Create asks for +std::uint32_t MaxFileCount(const fs::path &archive) { + const mpqcli::Archive opened = mpqcli::Archive::Open(archive, MPQ_OPEN_READ_ONLY); + return mpqcli::GetFileInfo(opened.Handle(), SFileMpqMaxFileCount); +} + +} // namespace + using mpqcli::Add; using mpqcli::AddOptions; using mpqcli::testing::ArchiveHasFile; @@ -38,6 +53,24 @@ TEST_CASE("Add", "[add]") { REQUIRE(ArchiveHasFile(created.archive, "birds.txt")); } + SECTION("grows the file table part-way through one run") { + const std::uint32_t max_files = MaxFileCount(created.archive); + // As many new files as the table has slots, which is more than are free + const fs::path fill_dir = created.files.dir.Path() / "fill"; + for (std::uint32_t i = 0; i < max_files; ++i) { + WriteFile(fill_dir / ("f" + std::to_string(i) + ".txt"), "file " + std::to_string(i)); + } + options.files = {fill_dir}; + const std::atomic cancelled{false}; + REQUIRE(Add(options, err, cancelled)); + REQUIRE(MaxFileCount(created.archive) > max_files); + for (std::uint32_t i = 0; i < max_files; ++i) { + const std::string name = "f" + std::to_string(i) + ".txt"; + CAPTURE(name); + REQUIRE(ArchiveHasFile(created.archive, name)); + } + } + SECTION("throws Interrupted when already cancelled, leaving the archive as it was") { const std::string before = ReadFile(created.archive); const std::atomic cancelled{true}; diff --git a/test/unit/commands/test_extract.cpp b/test/unit/commands/test_extract.cpp index b2936f9..c37fb9c 100644 --- a/test/unit/commands/test_extract.cpp +++ b/test/unit/commands/test_extract.cpp @@ -18,6 +18,7 @@ using mpqcli::Extract; using mpqcli::ExtractOptions; using mpqcli::testing::CancelAfter; using mpqcli::testing::CreatedArchive; +using mpqcli::testing::CreateWithoutListfile; using mpqcli::testing::ReadFile; using mpqcli::testing::SampleFiles; @@ -38,6 +39,24 @@ TEST_CASE("Extract", "[extract]") { } } + SECTION("fails on a named file missing from the archive, leaving no output directory") { + options.files = std::vector{"nope.txt"}; + const std::atomic cancelled{false}; + REQUIRE_FALSE(Extract(options, err, cancelled)); + REQUIRE_FALSE(fs::exists(output)); + } + + SECTION("extracts nothing from an archive with no entries, creating no output directory") { + const fs::path empty_dir = created.files.dir.Path() / "empty"; + fs::create_directories(empty_dir); + options.archive = created.files.dir.Path() / "bare.mpq"; + CreateWithoutListfile(options.archive, empty_dir); + const std::atomic cancelled{false}; + REQUIRE(Extract(options, err, cancelled)); + REQUIRE(err.str() == "[*] No files to extract\n"); + REQUIRE_FALSE(fs::exists(output)); + } + SECTION("throws Interrupted when cancelled while listing, creating no output directory") { const std::atomic cancelled{true}; REQUIRE_THROWS_AS(Extract(options, err, cancelled), mpqcli::Interrupted); diff --git a/test/unit/commands/test_list.cpp b/test/unit/commands/test_list.cpp index 1e9a25e..9d66a62 100644 --- a/test/unit/commands/test_list.cpp +++ b/test/unit/commands/test_list.cpp @@ -1,14 +1,20 @@ #include +#include #include #include #include #include "commands/list.h" #include "created_archive.h" +#include "write_file.h" + +namespace fs = std::filesystem; using mpqcli::List; using mpqcli::ListOptions; using mpqcli::testing::CreatedArchive; +using mpqcli::testing::CreateWithoutListfile; +using mpqcli::testing::WriteFile; TEST_CASE("List", "[list]") { CreatedArchive created; @@ -28,4 +34,35 @@ TEST_CASE("List", "[list]") { const std::set expected = {"bytes", "cats.txt", "dogs.txt"}; REQUIRE(lines == expected); } + + SECTION("prints nothing for an archive with no entries") { + const fs::path empty_dir = created.files.dir.Path() / "empty"; + fs::create_directories(empty_dir); + const fs::path bare = created.files.dir.Path() / "bare.mpq"; + CreateWithoutListfile(bare, empty_dir); + ListOptions options; + options.archive = bare; + options.all = true; + List(options, out, err); + REQUIRE(out.str().empty()); + REQUIRE(err.str().empty()); + } + + SECTION("ignores an empty listfile, with a warning") { + const fs::path unnamed = created.files.dir.Path() / "unnamed.mpq"; + CreateWithoutListfile(unnamed, created.files.files_dir); + const fs::path listfile = created.files.dir.Path() / "empty.txt"; + WriteFile(listfile, ""); + ListOptions options; + options.archive = unnamed; + std::ostringstream without_listfile; + List(options, without_listfile, err); + REQUIRE_FALSE(without_listfile.str().empty()); + + options.listfile = listfile; + List(options, out, err); + REQUIRE(out.str() == without_listfile.str()); + REQUIRE(err.str() == + "[!] Ignoring the listfile, which is empty: " + listfile.string() + "\n"); + } } From 9d17af6d2006ecf3aca09538757e4abe6a6dd738 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Mon, 19 Oct 2026 20:33:08 +1300 Subject: [PATCH 22/37] Replaced checks that could not fail in the create, verify and extract tests --- test/functional/test_create.cpp | 4 ---- test/functional/test_extract.cpp | 12 ++++++------ test/functional/test_verify.cpp | 18 +++++++++--------- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/test/functional/test_create.cpp b/test/functional/test_create.cpp index f2c994a..bb888c8 100644 --- a/test/functional/test_create.cpp +++ b/test/functional/test_create.cpp @@ -234,7 +234,6 @@ TEST_CASE("create: from a single file", "[create]") { REQUIRE(result.returncode == 0); REQUIRE(result.stdout_output.empty()); REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, {"test.txt"})); - REQUIRE(fs::file_size(archive) > 0); REQUIRE(ArchiveEntries(archive) == std::set{"enUS test.txt"}); } @@ -504,7 +503,6 @@ TEST_CASE("create: from an empty directory", "[create]") { REQUIRE(result.returncode == 0); REQUIRE(result.stdout_output.empty()); REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, {})); - REQUIRE(fs::file_size(archive) > 0); REQUIRE(ArchiveEntries(archive).empty()); REQUIRE(InfoProperty(archive, "file-count") == "1"); // The (listfile) alone // The smallest max file count create gives an archive @@ -612,7 +610,6 @@ TEST_CASE("create: applies the game profile's version and compression", "[create REQUIRE(result.returncode == 0); REQUIRE(result.stdout_output.empty()); REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, sample_entries, profile)); - REQUIRE(fs::file_size(archive) > 0); REQUIRE(InfoProperty(archive, "format-version") == expected_version); const auto flags = EntryProperty(archive, "cats.txt", "flags"); CAPTURE(flags); @@ -690,7 +687,6 @@ TEST_CASE("create: writes each MPQ format version", "[create]") { REQUIRE(result.returncode == 0); REQUIRE(result.stdout_output.empty()); REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, sample_entries)); - REQUIRE(fs::file_size(archive) > 0); REQUIRE(InfoProperty(archive, "format-version") == version); } diff --git a/test/functional/test_extract.cpp b/test/functional/test_extract.cpp index 7b9e1a0..9984f4c 100644 --- a/test/functional/test_extract.cpp +++ b/test/functional/test_extract.cpp @@ -660,14 +660,14 @@ TEST_CASE("extract: all files from an archive without a listfile", "[extract]") {"File00000001.xxx", "cats.txt"}, {"File00000002.xxx", "dogs.txt"}, }; + const auto entries = NoListfileArchive::Entries(); for (const auto &name : expected) { + CAPTURE(name); const auto stored = contents.count(name) != 0 ? contents.at(name) : name; - for (const auto &entry : NoListfileArchive::Entries()) { - if (entry.name == stored) { - CAPTURE(name); - REQUIRE(ReadFile(output_dir / name) == entry.content); - } - } + const auto entry = std::find_if(entries.begin(), entries.end(), + [&](const auto &e) { return e.name == stored; }); + REQUIRE(entry != entries.end()); + REQUIRE(ReadFile(output_dir / name) == entry->content); } } diff --git a/test/functional/test_verify.cpp b/test/functional/test_verify.cpp index de74366..c2554ff 100644 --- a/test/functional/test_verify.cpp +++ b/test/functional/test_verify.cpp @@ -155,16 +155,16 @@ TEST_CASE("verify: --print writes a strong signature's bytes to stdout", "[verif REQUIRE(LinesToVector(result.stderr_output) == verify_success); } -TEST_CASE("verify: -x prints the same signature as --print", "[verify]") { +TEST_CASE("verify: -x prints the weak signature mpqcli wrote", "[verify]") { SampleArchive sample({"-s"}); - auto short_form = Run(mpqcli_binary, {"verify", "-x", sample.archive.string()}); - auto long_form = Run(mpqcli_binary, {"verify", "--print", sample.archive.string()}); + auto result = Run(mpqcli_binary, {"verify", "-x", sample.archive.string()}); - REQUIRE(short_form.returncode == 0); - REQUIRE_FALSE(short_form.stdout_output.empty()); - REQUIRE(LinesToVector(short_form.stderr_output) == verify_success); - REQUIRE(long_form.returncode == 0); - REQUIRE(LinesToVector(long_form.stderr_output) == verify_success); - REQUIRE(short_form.stdout_output == long_form.stdout_output); + REQUIRE(result.returncode == 0); + // The (signature) entry, raw. The generic profile stores no file times, so the signed + // bytes, and with them the signature, are the same in every time zone. + MD5 md5; + REQUIRE(result.stdout_output.size() == 72); + REQUIRE(md5(result.stdout_output) == "1d1947092567f94043bca01b1e1ac1c9"); + REQUIRE(LinesToVector(result.stderr_output) == verify_success); } From bd4b5b178dc0eec0e77f2eb47d042242b9849405 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Mon, 19 Oct 2026 20:44:42 +1300 Subject: [PATCH 23/37] Compared exact flag strings in the game profile tests --- test/functional/test_add.cpp | 23 +++++++++-------------- test/functional/test_create.cpp | 28 ++++++++++++---------------- 2 files changed, 21 insertions(+), 30 deletions(-) diff --git a/test/functional/test_add.cpp b/test/functional/test_add.cpp index 41ccf59..4c07381 100644 --- a/test/functional/test_add.cpp +++ b/test/functional/test_add.cpp @@ -371,15 +371,15 @@ TEST_CASE("add: re-adds a file after every locale variant was removed", "[add]") } TEST_CASE("add: applies the game profile's compression", "[add]") { - // Profile, and the flag letters a .txt file added under it gets + // Profile, and the exact flags a .txt file added under it gets, as for create const auto [profile, expected_flags] = GENERATE(table({ - {"generic", "ce"}, - {"diablo1", "ie"}, - {"starcraft1", "ce2"}, - {"wow1", "c"}, - {"wow2", "cr"}, - {"starcraft2", "c"}, - {"diablo3", "c"}, + {"generic", "cex"}, + {"diablo1", "iex"}, + {"starcraft1", "ce2x"}, + {"wow1", "cx"}, + {"wow2", "crx"}, + {"starcraft2", "cux"}, + {"diablo3", "cux"}, })); CAPTURE(profile); SampleArchive sample; @@ -395,12 +395,7 @@ TEST_CASE("add: applies the game profile's compression", "[add]") { REQUIRE( LinesToSet(result.stderr_output) == std::set{"[*] Using game profile: " + profile, "[+] Adding file: " + name}); - const auto flags = EntryProperty(sample.archive, name, "flags"); - CAPTURE(flags); - for (const char letter : expected_flags) { - CAPTURE(letter); - REQUIRE(flags.find(letter) != std::string::npos); - } + REQUIRE(EntryProperty(sample.archive, name, "flags") == expected_flags); } TEST_CASE("add: rejects an unknown game profile", "[add]") { diff --git a/test/functional/test_create.cpp b/test/functional/test_create.cpp index bb888c8..fa69b36 100644 --- a/test/functional/test_create.cpp +++ b/test/functional/test_create.cpp @@ -587,18 +587,19 @@ TEST_CASE("create: rejects an unknown game profile", "[create]") { } TEST_CASE("create: applies the game profile's version and compression", "[create]") { - // Profile, the MPQ version it selects, and the flag letters a .txt file gets + // Profile, the MPQ version it selects, and the exact flags a .txt file gets. Every + // entry ends in x, for a file that exists. const auto [profile, expected_version, expected_flags] = GENERATE(table({ - {"generic", "1", "ce"}, // Compressed + encrypted - {"diablo1", "1", "ie"}, // Imploded + encrypted - {"starcraft1", "1", "ce2"}, // Compressed + encrypted + key v2 - {"warcraft3", "1", "ce2"}, // Compressed + encrypted + key v2 - {"diablo2", "1", "c"}, // Compressed, .txt files not encrypted - {"wow1", "1", "c"}, // Compressed - {"wow2", "2", "cr"}, // Compressed + sector CRC - {"starcraft2", "2", "c"}, // Compressed, small files as a single unit - {"diablo3", "4", "c"}, // Compressed + {"generic", "1", "cex"}, // Compressed, encrypted + {"diablo1", "1", "iex"}, // Imploded, encrypted + {"starcraft1", "1", "ce2x"}, // Compressed, encrypted, key v2 + {"warcraft3", "1", "ce2x"}, // Compressed, encrypted, key v2 + {"diablo2", "1", "cx"}, // Compressed; .txt files are not encrypted + {"wow1", "1", "cx"}, // Compressed + {"wow2", "2", "crx"}, // Compressed, sector CRC + {"starcraft2", "2", "cux"}, // Compressed, small files as a single unit + {"diablo3", "4", "cux"}, // Compressed, small files as a single unit })); CAPTURE(profile); SampleFiles files; @@ -611,12 +612,7 @@ TEST_CASE("create: applies the game profile's version and compression", "[create REQUIRE(result.stdout_output.empty()); REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, sample_entries, profile)); REQUIRE(InfoProperty(archive, "format-version") == expected_version); - const auto flags = EntryProperty(archive, "cats.txt", "flags"); - CAPTURE(flags); - for (const char letter : expected_flags) { - CAPTURE(letter); - REQUIRE(flags.find(letter) != std::string::npos); - } + REQUIRE(EntryProperty(archive, "cats.txt", "flags") == expected_flags); } TEST_CASE("create: accepts every game profile", "[create]") { From 03803f2ba764470599f4f1c0a088c2157480252c Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Mon, 19 Oct 2026 20:56:19 +1300 Subject: [PATCH 24/37] Counted stderr lines in failure tests and skipped the read-only info test as root --- test/functional/test_add.cpp | 3 +++ test/functional/test_create.cpp | 10 ++++++++++ test/functional/test_extract.cpp | 3 +++ test/functional/test_info.cpp | 9 +++++++++ 4 files changed, 25 insertions(+) diff --git a/test/functional/test_add.cpp b/test/functional/test_add.cpp index 4c07381..5a14b7b 100644 --- a/test/functional/test_add.cpp +++ b/test/functional/test_add.cpp @@ -93,6 +93,7 @@ TEST_CASE("add: fails when the archive is not an MPQ", "[add]") { REQUIRE(result.returncode == 1); REQUIRE(result.stdout_output.empty()); REQUIRE_THAT(result.stderr_output, ContainsSubstring("[!] Failed to open MPQ archive")); + REQUIRE(LinesToVector(result.stderr_output).size() == 1); } TEST_CASE("add: input path does not exist", "[add]") { @@ -662,6 +663,8 @@ TEST_CASE("add: --update is idempotent", "[add]") { REQUIRE(result.stdout_output.empty()); REQUIRE_THAT(result.stderr_output, !ContainsSubstring("[+] Adding file:")); REQUIRE_THAT(result.stderr_output, ContainsSubstring(UpdateSummary(update_dir, 0, 1))); + // The skip and the summary + REQUIRE(LinesToVector(result.stderr_output).size() == 2); REQUIRE(ReadEntry(sample.archive, "cats.txt") == std::set{"This cat content is completely different and longer now."}); } diff --git a/test/functional/test_create.cpp b/test/functional/test_create.cpp index fa69b36..d6f246f 100644 --- a/test/functional/test_create.cpp +++ b/test/functional/test_create.cpp @@ -204,6 +204,8 @@ TEST_CASE("create: fails when the archive's directory does not exist", "[create] REQUIRE(result.returncode == 1); REQUIRE(result.stdout_output.empty()); REQUIRE_THAT(result.stderr_output, ContainsSubstring("[!] Failed to create MPQ archive")); + // The game profile line, then the failure + REQUIRE(LinesToVector(result.stderr_output).size() == 2); REQUIRE(DirectoryEntries(files.dir.Path()) == std::set{"files"}); } @@ -325,6 +327,9 @@ TEST_CASE("create: creates nothing when two inputs hold the same name", "[create ContainsSubstring("[!] 1 file(s) were not added because an earlier input added " "a file by the same name")); REQUIRE_THAT(result.stderr_output, ContainsSubstring("no archive was created")); + // The game profile line, the first a.txt added, the second skipped, the count of + // skips, and the summary + REQUIRE(LinesToVector(result.stderr_output).size() == 5); REQUIRE(DirectoryEntries(dir.Path()) == std::set{"first", "second"}); } @@ -420,6 +425,8 @@ TEST_CASE("create: creates nothing when a file cannot be read", "[create]") { REQUIRE(result.stdout_output.empty()); REQUIRE_THAT(result.stderr_output, ContainsSubstring("[!] Failed to add: locked.txt")); REQUIRE_THAT(result.stderr_output, ContainsSubstring("no archive was created")); + // The game profile line, three files added, the one failure, and the summary + REQUIRE(LinesToVector(result.stderr_output).size() == 6); // Neither the archive nor the .partial it was written to is left behind REQUIRE(DirectoryEntries(dir.Path()) == std::set{"b.txt", "mod"}); } @@ -438,6 +445,8 @@ TEST_CASE("create: skips the archive inside its own input directory", "[create]" REQUIRE(result.stdout_output.empty()); REQUIRE_THAT(result.stderr_output, ContainsSubstring("[*] Skipping the archive itself")); REQUIRE_THAT(result.stderr_output, ContainsSubstring("[+] Adding file: a.txt")); + // The game profile line, the skip and the one file added + REQUIRE(LinesToVector(result.stderr_output).size() == 3); REQUIRE(DirectoryEntries(input_dir) == std::set{"a.txt", "out.mpq"}); REQUIRE(ArchiveEntries(input_dir / "out.mpq") == std::set{"enUS a.txt"}); } @@ -793,6 +802,7 @@ TEST_CASE("create: --stream-flags reaches the archive stream", "[create]") { REQUIRE(result.returncode == 1); REQUIRE(result.stdout_output.empty()); REQUIRE_THAT(result.stderr_output, ContainsSubstring("[!] Failed to create MPQ archive")); + REQUIRE(LinesToVector(result.stderr_output).size() == 2); // Neither the archive nor the .partial it was written to is left behind REQUIRE(DirectoryEntries(files.dir.Path()) == std::set{"files"}); } diff --git a/test/functional/test_extract.cpp b/test/functional/test_extract.cpp index 9984f4c..b4a1662 100644 --- a/test/functional/test_extract.cpp +++ b/test/functional/test_extract.cpp @@ -146,6 +146,7 @@ TEST_CASE("extract: fails when the archive is not an MPQ", "[extract]") { REQUIRE(result.returncode == 1); REQUIRE(result.stdout_output.empty()); REQUIRE_THAT(result.stderr_output, ContainsSubstring("[!] Failed to open MPQ archive")); + REQUIRE(LinesToVector(result.stderr_output).size() == 1); } TEST_CASE("extract: all files to a directory named after the archive", "[extract]") { @@ -230,6 +231,7 @@ TEST_CASE("extract: fails when --output is an existing file", "[extract]") { REQUIRE(result.returncode == 1); REQUIRE(result.stdout_output.empty()); REQUIRE_THAT(result.stderr_output, ContainsSubstring("[!] Failed to create output directory")); + REQUIRE(LinesToVector(result.stderr_output).size() == 1); } TEST_CASE("extract: fails when the archive has no extension", "[extract]") { @@ -242,6 +244,7 @@ TEST_CASE("extract: fails when the archive has no extension", "[extract]") { REQUIRE(result.returncode == 1); REQUIRE(result.stdout_output.empty()); REQUIRE_THAT(result.stderr_output, ContainsSubstring("[!] Failed to create output directory")); + REQUIRE(LinesToVector(result.stderr_output).size() == 1); } TEST_CASE("extract: overwrites files from a previous extraction", "[extract]") { diff --git a/test/functional/test_info.cpp b/test/functional/test_info.cpp index 19d0b11..b0a4ded 100644 --- a/test/functional/test_info.cpp +++ b/test/functional/test_info.cpp @@ -11,6 +11,10 @@ #include #include +#ifndef _WIN32 +#include +#endif + #include "integration_data.h" #include "read_file.h" #include "sample_archive.h" @@ -286,6 +290,11 @@ TEST_CASE("info: prints the documented values for a Blizzard patch archive", "[i } TEST_CASE("info: reads an archive it has no permission to write", "[info]") { +#ifndef _WIN32 + if (geteuid() == 0) { + SKIP("root can write a file whatever its permissions"); + } +#endif SampleArchive sample; fs::permissions(sample.archive, fs::perms::owner_read | fs::perms::group_read | fs::perms::others_read, From 24ce3d7fc06487144bce3007cf6f251b9c628bd8 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Mon, 19 Oct 2026 21:05:33 +1300 Subject: [PATCH 25/37] Failed the completion tests when the help scrape finds no options --- test/functional/test_completion.cpp | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/test/functional/test_completion.cpp b/test/functional/test_completion.cpp index cb1662c..7e5f6a1 100644 --- a/test/functional/test_completion.cpp +++ b/test/functional/test_completion.cpp @@ -319,15 +319,22 @@ TEST_CASE("completion: every script covers every subcommand and option", "[compl // A subcommand or option wired into the CLI but into only some of the // hand-written scripts, or under the wrong subcommand, is the failure this // guards against + // Options come from scraping the help text, so an empty scrape is checked rather than + // skipped: after a change to CLI11's layout it would otherwise check nothing and pass + const std::set without_options = {"about", "completion", "version"}; for (const auto &subcommand : Subcommands()) { CAPTURE(subcommand); REQUIRE(HasToken(script, subcommand)); auto options = LongOptions(subcommand); const auto short_options = ShortOptions(subcommand); - options.insert(short_options.begin(), short_options.end()); - if (options.empty()) { + if (without_options.count(subcommand) != 0) { + REQUIRE(options.empty()); + REQUIRE(short_options.empty()); continue; } + REQUIRE_FALSE(options.empty()); + REQUIRE_FALSE(short_options.empty()); + options.insert(short_options.begin(), short_options.end()); const auto section = SubcommandSection(shell, script, subcommand); REQUIRE_FALSE(section.empty()); for (const auto &option : options) { @@ -350,8 +357,11 @@ TEST_CASE("completion: no script offers a subcommand a short option it rejects", {"extract", "-k"}, })); CAPTURE(subcommand, option); - // The subcommand's own help agrees it takes no such option - REQUIRE(ShortOptions(subcommand).count(option) == 0); + // The subcommand's own help agrees it takes no such option. The scrape must find its + // other short options, or the check that follows would pass on an empty set. + const auto short_options = ShortOptions(subcommand); + REQUIRE_FALSE(short_options.empty()); + REQUIRE(short_options.count(option) == 0); for (const auto &shell : shells) { CAPTURE(shell.name); From 8edf2b04bcd439187a3a39ebeb2fe7c6ac341518 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Mon, 19 Oct 2026 21:19:06 +1300 Subject: [PATCH 26/37] Looked for each real archive in every location and skipped once if any is missing --- test/fixtures/integration_data.h | 73 ++++++++++++++++++++------------ test/functional/test_info.cpp | 9 +++- test/functional/test_list.cpp | 9 +++- 3 files changed, 63 insertions(+), 28 deletions(-) diff --git a/test/fixtures/integration_data.h b/test/fixtures/integration_data.h index 94ac90b..40e035d 100644 --- a/test/fixtures/integration_data.h +++ b/test/fixtures/integration_data.h @@ -4,30 +4,35 @@ #include #include #include +#include #include #include +#include namespace mpqcli::testing { -/// Returns the directory holding the real-world archives, or an empty optional +/// Returns the path to one real-world archive, or an empty optional /// -/// Checks in order: -/// 1. The MPQCLI_INTEGRATION_DATA compile-time define, which CMake bakes in from the -/// cache variable of the same name (by default test/data/integration/) -/// 2. The MPQCLI_INTEGRATION_DATA environment variable at runtime +/// Looks for name in each place in turn: +/// 1. The directory the MPQCLI_INTEGRATION_DATA compile-time define names, which CMake +/// bakes in from the cache variable of the same name (by default test/data/integration/) +/// 2. The directory the MPQCLI_INTEGRATION_DATA environment variable names at runtime /// -/// @return The directory, or nullopt if neither source names one that exists. -inline std::optional IntegrationDataPath() { +/// The file is looked for in each, rather than the first directory that exists being taken, +/// so a default directory that exists without the archives does not hide the variable. +/// +/// @param name The archive's file name. +/// @return The archive's path, or nullopt if neither place holds it. +inline std::optional IntegrationDataPath(const std::string &name) { + std::vector directories; #ifdef MPQCLI_INTEGRATION_DATA - { - std::filesystem::path path{MPQCLI_INTEGRATION_DATA}; - if (std::filesystem::exists(path)) { - return path; - } - } + directories.emplace_back(MPQCLI_INTEGRATION_DATA); #endif if (const char *env = std::getenv("MPQCLI_INTEGRATION_DATA")) { - std::filesystem::path path{env}; + directories.emplace_back(env); + } + for (const auto &directory : directories) { + const auto path = directory / name; if (std::filesystem::exists(path)) { return path; } @@ -35,20 +40,36 @@ inline std::optional IntegrationDataPath() { return std::nullopt; } -/// Returns one real-world archive from the integration data, skipping the test if absent +/// Returns real-world archives from the integration data by name, skipping the test once if +/// any is absent /// -/// The two Blizzard patches are the only archives carrying a genuine Blizzard signature, -/// one weak and one strong, since StormLib can only sign weakly. They are not committed, -/// so a checkout without them still runs green. SKIP() leaves the test case, so the -/// return after it is never reached. -inline std::filesystem::path IntegrationArchive(const std::string &name) { - const auto data = IntegrationDataPath(); - if (!data || !std::filesystem::exists(*data / name)) { - SKIP("integration archive " << name - << " is absent; set MPQCLI_INTEGRATION_DATA or run make " - "fetch_integration_data"); +/// The two Blizzard patches are the only archives carrying a genuine Blizzard signature, one +/// weak and one strong, since StormLib can only sign weakly. They are not committed, so a +/// checkout without them still runs green. Every name is resolved before the test asserts +/// anything, so it runs in full or not at all, never as a smaller check. SKIP() leaves the +/// test case, so the return after it is never reached. +inline std::map +IntegrationArchives(const std::vector &names) { + std::map archives; + std::string missing; + for (const auto &name : names) { + if (const auto path = IntegrationDataPath(name)) { + archives.emplace(name, *path); + } else { + missing += (missing.empty() ? "" : ", ") + name; + } } - return *data / name; + if (!missing.empty()) { + SKIP("integration archives absent: " << missing + << "; set MPQCLI_INTEGRATION_DATA or run make " + "fetch_integration_data"); + } + return archives; +} + +/// Returns one real-world archive from the integration data, skipping the test if absent +inline std::filesystem::path IntegrationArchive(const std::string &name) { + return IntegrationArchives({name}).at(name); } } // namespace mpqcli::testing diff --git a/test/functional/test_info.cpp b/test/functional/test_info.cpp index b0a4ded..01710f3 100644 --- a/test/functional/test_info.cpp +++ b/test/functional/test_info.cpp @@ -271,9 +271,16 @@ TEST_CASE("info: file count and signature type follow the special entries", "[in } TEST_CASE("info: prints the documented values for a Blizzard patch archive", "[info]") { + // Both archives are resolved ahead of the generator, so a missing one skips the whole + // test rather than leaving the other half to pass on its own + std::vector archive_names; + for (const auto &entry : patch_archives) { + archive_names.push_back(entry.name); + } + const auto archives = IntegrationArchives(archive_names); const auto patch = GENERATE(from_range(patch_archives)); CAPTURE(patch.name); - const auto archive = IntegrationArchive(patch.name); + const auto archive = archives.at(patch.name); auto result = Run(mpqcli_binary, {"info", archive.string()}); diff --git a/test/functional/test_list.cpp b/test/functional/test_list.cpp index 3a367ed..b8fabfb 100644 --- a/test/functional/test_list.cpp +++ b/test/functional/test_list.cpp @@ -579,9 +579,16 @@ TEST_CASE("list: an archive with no entries lists nothing", "[list]") { } TEST_CASE("list: counts a Blizzard patch's entries and special files", "[list]") { + // Both archives are resolved ahead of the generator, so a missing one skips the whole + // test rather than leaving the other half to pass on its own + std::vector archive_names; + for (const auto &entry : patch_listings) { + archive_names.push_back(entry.name); + } + const auto archives = IntegrationArchives(archive_names); const auto patch = GENERATE(from_range(patch_listings)); CAPTURE(patch.name); - const auto archive = IntegrationArchive(patch.name); + const auto archive = archives.at(patch.name); auto result = Run(mpqcli_binary, {"list", archive.string()}); auto all = Run(mpqcli_binary, {"list", "-a", archive.string()}); From 15144919661ce813aefa9653e594e93475724c57 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Mon, 19 Oct 2026 21:31:47 +1300 Subject: [PATCH 27/37] Skipped the symlink tests at run time instead of compiling them out on Windows --- test/functional/test_create.cpp | 11 +++++++---- test/functional/test_extract.cpp | 11 +++++++---- test/unit/util/test_path.cpp | 11 ++++++----- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/test/functional/test_create.cpp b/test/functional/test_create.cpp index d6f246f..a78683b 100644 --- a/test/functional/test_create.cpp +++ b/test/functional/test_create.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #ifndef _WIN32 @@ -483,13 +484,16 @@ TEST_CASE("create: keeps folder structure with a dot-relative input", "[create]" REQUIRE(ArchiveEntries(archive) == std::set{"enUS sub\\nested.txt"}); } -#ifndef _WIN32 -// Creating a symlink needs privileges on Windows, so this case is POSIX only TEST_CASE("create: skips a dangling symlink", "[create]") { TempDir dir; const auto source_dir = dir.Path() / "src"; WriteFile(source_dir / "real.txt", "real file"); - fs::create_symlink(dir.Path() / "missing.txt", source_dir / "dangling.txt"); + // Windows creates a symlink only with Developer Mode or elevation + std::error_code link_ec; + fs::create_symlink(dir.Path() / "missing.txt", source_dir / "dangling.txt", link_ec); + if (link_ec) { + SKIP("cannot create a symlink here: " << link_ec.message()); + } const auto archive = dir.Path() / "output.mpq"; auto result = Run(mpqcli_binary, {"create", archive.string(), source_dir.string()}); @@ -499,7 +503,6 @@ TEST_CASE("create: skips a dangling symlink", "[create]") { REQUIRE(LinesToSet(result.stderr_output) == ProgressLines(archive, {"real.txt"})); REQUIRE(ArchiveEntries(archive) == std::set{"enUS real.txt"}); } -#endif TEST_CASE("create: from an empty directory", "[create]") { TempDir dir; diff --git a/test/functional/test_extract.cpp b/test/functional/test_extract.cpp index b4a1662..ecdd6e0 100644 --- a/test/functional/test_extract.cpp +++ b/test/functional/test_extract.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include "many_locales_archive.h" @@ -711,8 +712,6 @@ TEST_CASE("extract: blocks an entry whose path climbs out of the output director REQUIRE(DirectoryEntries(output_dir) == std::set{"safe.txt", "(listfile)"}); } -#ifndef _WIN32 -// Creating a symlink needs privileges on Windows, so this case is POSIX only TEST_CASE("extract: blocks an entry that escapes through a symlink in the output directory", "[extract]") { TempDir dir; @@ -729,7 +728,12 @@ TEST_CASE("extract: blocks an entry that escapes through a symlink in the output const auto output_dir = dir.Path() / "out"; fs::create_directories(outside); fs::create_directories(output_dir); - fs::create_directory_symlink(outside, output_dir / "escape"); + // Windows creates a symlink only with Developer Mode or elevation + std::error_code link_ec; + fs::create_directory_symlink(outside, output_dir / "escape", link_ec); + if (link_ec) { + SKIP("cannot create a symlink here: " << link_ec.message()); + } auto result = Run(mpqcli_binary, {"extract", "-o", output_dir.string(), archive.string()}); @@ -742,4 +746,3 @@ TEST_CASE("extract: blocks an entry that escapes through a symlink in the output REQUIRE(LinesToSet(result.stderr_output) == expected_stderr); REQUIRE(DirectoryEntries(outside).empty()); } -#endif diff --git a/test/unit/util/test_path.cpp b/test/unit/util/test_path.cpp index bc1b113..4757b97 100644 --- a/test/unit/util/test_path.cpp +++ b/test/unit/util/test_path.cpp @@ -173,18 +173,19 @@ TEST_CASE("ListFilesRecursive", "[path]") { REQUIRE(ec); } -#ifndef _WIN32 - // Windows creates a symlink only with Developer Mode or elevation, which a test - // cannot count on SECTION("skips a dangling symlink") { WriteFile(dir.Path() / "a.txt", "a"); - fs::create_symlink(dir.Path() / "missing", dir.Path() / "dangling"); + // Windows creates a symlink only with Developer Mode or elevation + std::error_code link_ec; + fs::create_symlink(dir.Path() / "missing", dir.Path() / "dangling", link_ec); + if (link_ec) { + SKIP("cannot create a symlink here: " << link_ec.message()); + } const std::vector expected = {dir.Path() / "a.txt"}; REQUIRE(ListFilesRecursive(dir.Path(), ec) == expected); REQUIRE_FALSE(ec); } -#endif } TEST_CASE("ExpandStdinMarker", "[path]") { From b6e1828ac21372c5b01d313d26727b47ea5d6ca5 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Mon, 19 Oct 2026 21:43:25 +1300 Subject: [PATCH 28/37] Tightened unit tags and assertions and made ToLower ASCII-only --- src/util/string.cpp | 8 +++++--- test/unit/commands/test_extract.cpp | 2 +- test/unit/commands/test_verify.cpp | 1 + test/unit/mpq/test_extract.cpp | 2 +- test/unit/util/test_format.cpp | 4 +++- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/util/string.cpp b/src/util/string.cpp index 1276ede..874cc2a 100644 --- a/src/util/string.cpp +++ b/src/util/string.cpp @@ -1,15 +1,17 @@ #include "util/string.h" #include -#include #include namespace mpqcli { std::string ToLower(const std::string &str) { std::string result = str; - std::transform(result.begin(), result.end(), result.begin(), - [](unsigned char c) { return std::tolower(c); }); + // Mapped by hand rather than with std::tolower, which follows the C locale and in a + // single-byte code page would lowercase letters outside ASCII as well + std::transform(result.begin(), result.end(), result.begin(), [](char c) { + return (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c; + }); return result; } diff --git a/test/unit/commands/test_extract.cpp b/test/unit/commands/test_extract.cpp index c37fb9c..b07a10e 100644 --- a/test/unit/commands/test_extract.cpp +++ b/test/unit/commands/test_extract.cpp @@ -22,7 +22,7 @@ using mpqcli::testing::CreateWithoutListfile; using mpqcli::testing::ReadFile; using mpqcli::testing::SampleFiles; -TEST_CASE("Extract", "[extract]") { +TEST_CASE("Extract", "[commands/extract]") { CreatedArchive created; const fs::path output = created.files.dir.Path() / "out"; ExtractOptions options; diff --git a/test/unit/commands/test_verify.cpp b/test/unit/commands/test_verify.cpp index 4bff670..1664eab 100644 --- a/test/unit/commands/test_verify.cpp +++ b/test/unit/commands/test_verify.cpp @@ -37,5 +37,6 @@ TEST_CASE("Verify", "[verify]") { VerifyOptions options; options.archive = created.archive; REQUIRE_FALSE(Verify(options, out, err)); + REQUIRE(err.str() == "[!] Verify failed: archive has no signature\n"); } } diff --git a/test/unit/mpq/test_extract.cpp b/test/unit/mpq/test_extract.cpp index 9326633..d5b57f7 100644 --- a/test/unit/mpq/test_extract.cpp +++ b/test/unit/mpq/test_extract.cpp @@ -6,7 +6,7 @@ using mpqcli::FindFlatCollision; -TEST_CASE("FindFlatCollision", "[extract]") { +TEST_CASE("FindFlatCollision", "[mpq/extract]") { SECTION("finds nothing when every file name differs") { REQUIRE_FALSE(FindFlatCollision({"a\\cats.txt", "b\\dogs.txt", "bytes"}).has_value()); } diff --git a/test/unit/util/test_format.cpp b/test/unit/util/test_format.cpp index cd87517..1cb122c 100644 --- a/test/unit/util/test_format.cpp +++ b/test/unit/util/test_format.cpp @@ -78,10 +78,12 @@ TEST_CASE("StormErrorString", "[format]") { SECTION("describes a code it has no case for in the platform's own words") { #ifdef _WIN32 // 32 is ERROR_SHARING_VIOLATION. Windows localises the text, so the test checks - // where it came from rather than what it says: strerror(32) is "Broken pipe" + // where it came from rather than what it says: strerror(32) is "Broken pipe", and + // "Unknown error 32" is what is left when FormatMessage fails const std::string text = StormErrorString(32U); REQUIRE_FALSE(text.empty()); REQUIRE(text != std::strerror(32)); + REQUIRE(text != "Unknown error 32"); REQUIRE(text.back() != '\n'); REQUIRE(text.back() != '.'); #else From 1727c1dbd797d5d923bcd21a014be4178bdba77e Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Wed, 21 Oct 2026 16:30:07 +1300 Subject: [PATCH 29/37] Renamed a test variable that clashed with the Windows small macro --- test/unit/gamerules/test_rules.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/gamerules/test_rules.cpp b/test/unit/gamerules/test_rules.cpp index 22d8cb7..340d4ab 100644 --- a/test/unit/gamerules/test_rules.cpp +++ b/test/unit/gamerules/test_rules.cpp @@ -119,12 +119,12 @@ TEST_CASE("GameRules::GetCompressionSettings", "[rules]") { SECTION("includes both bounds of a size rule") { const GameRules rules(GameProfile::WOW_4X); - const std::string small = + const std::string single_unit = Describe(MPQ_FILE_COMPRESS | MPQ_FILE_SINGLE_UNIT, MPQ_COMPRESSION_ZLIB); const std::string fallback = Describe(MPQ_FILE_COMPRESS | MPQ_FILE_SECTOR_CRC, MPQ_COMPRESSION_ZLIB); - REQUIRE(Describe(rules.GetCompressionSettings("a.dat", 1U)) == small); - REQUIRE(Describe(rules.GetCompressionSettings("a.dat", 0x4000U)) == small); + REQUIRE(Describe(rules.GetCompressionSettings("a.dat", 1U)) == single_unit); + REQUIRE(Describe(rules.GetCompressionSettings("a.dat", 0x4000U)) == single_unit); REQUIRE(Describe(rules.GetCompressionSettings("a.dat", 0x4001U)) == fallback); REQUIRE(Describe(rules.GetCompressionSettings("a.dat", UINT32_MAX)) == fallback); } From 70a51e41336e939ca2f67523f57512a4c50abf39 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Wed, 21 Oct 2026 16:43:09 +1300 Subject: [PATCH 30/37] Closed the parent's copy of the child's stdin pipe on Windows so an unread write cannot hang --- test/subprocess_helper.cpp | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/test/subprocess_helper.cpp b/test/subprocess_helper.cpp index a61e42b..b3bd294 100644 --- a/test/subprocess_helper.cpp +++ b/test/subprocess_helper.cpp @@ -110,11 +110,19 @@ RunResult Run(const fs::path &binary, const std::vector &args, throw std::runtime_error("subprocess_helper: failed to spawn " + binary.string()); } -#ifndef _WIN32 // A command that rejects its arguments exits without reading stdin, so the write - // can meet a closed pipe. That raises SIGPIPE, whose default action kills the whole - // test binary rather than failing the write, so it is ignored and the write simply - // comes up short. Windows reports a write error instead. + // can meet a closed pipe. +#ifdef _WIN32 + // subprocess.h keeps the parent's copy of the child's stdin read end open until + // join, so the pipe never closes: the write fills it and blocks forever, and the + // watchdog cannot free it by killing a child that has already exited. Closing that + // copy now, as POSIX does, leaves the child the only reader, so the write fails + // once the child is gone. + CloseHandle(process.hStdInput); + process.hStdInput = nullptr; +#else + // That raises SIGPIPE, whose default action kills the whole test binary rather + // than failing the write, so it is ignored and the write simply comes up short. std::signal(SIGPIPE, SIG_IGN); #endif From c204d72f4f03ee23e6ac177db56ca5dca9b4c1ce Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Wed, 21 Oct 2026 18:09:27 +1300 Subject: [PATCH 31/37] Left the platform-specific error code out of messages for errors mpqcli has its own text for --- src/errors.h | 7 +++---- src/mpq/add.cpp | 8 ++++---- src/mpq/extract.cpp | 3 +-- src/util/format.cpp | 2 +- src/util/format.h | 4 ++++ test/functional/test_compact.cpp | 3 +-- test/functional/test_info.cpp | 4 ++-- test/unit/test_errors.cpp | 19 ++++++++----------- test/unit/util/test_format.cpp | 18 +++++++++++------- 9 files changed, 35 insertions(+), 33 deletions(-) diff --git a/src/errors.h b/src/errors.h index e0e2bbc..6a2ad47 100644 --- a/src/errors.h +++ b/src/errors.h @@ -59,13 +59,12 @@ class ArchiveError : public Error { explicit ArchiveError(const std::string &message) : Error(message) {} }; -/// Thrown when a StormLib call fails; the message ends with the code and its text +/// Thrown when a StormLib call fails; the message ends with the error's text class StormError : public ArchiveError { public: StormError(const std::string &what_failed, uint32_t error_code) - : ArchiveError(what_failed + ": (" + std::to_string(error_code) + ") " + - StormErrorString(error_code)), - error_code_(error_code) {} + : ArchiveError(what_failed + ": " + StormErrorString(error_code)), error_code_(error_code) { + } uint32_t ErrorCode() const { return error_code_; } diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp index 8aa1745..6d301aa 100644 --- a/src/mpq/add.cpp +++ b/src/mpq/add.cpp @@ -45,8 +45,8 @@ bool GrowFileTable(HANDLE archive, std::ostream &err) { const uint32_t new_max_files = NextPowerOfTwo(max_files + 1); if (!SFileSetMaxFileCount(archive, new_max_files)) { const auto error = SErrGetLastError(); - err << "[!] Failed to increase new max file count to " << new_max_files << ": (" << error - << ") " << StormErrorString(error) << std::endl; + err << "[!] Failed to increase new max file count to " << new_max_files << ": " + << StormErrorString(error) << std::endl; return false; } return true; @@ -206,8 +206,8 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi if (!added_file) { const auto error = SErrGetLastError(); - err << "[!] Failed to add: " << archive_file_path << ": (" << error << ") " - << StormErrorString(error) << std::endl; + err << "[!] Failed to add: " << archive_file_path << ": " << StormErrorString(error) + << std::endl; return 1; } diff --git a/src/mpq/extract.cpp b/src/mpq/extract.cpp index 8968fa4..e762346 100644 --- a/src/mpq/extract.cpp +++ b/src/mpq/extract.cpp @@ -162,8 +162,7 @@ int ExtractFile(HANDLE archive, const fs::path &output, const std::string &file_ err << "[*] Extracted: " << file_name_string << std::endl; } else { const auto error = SErrGetLastError(); - err << "[!] Failed: (" << error << ") " << StormErrorString(error) << ": " << file_name - << std::endl; + err << "[!] Failed: " << StormErrorString(error) << ": " << file_name << std::endl; return 1; } diff --git a/src/util/format.cpp b/src/util/format.cpp index 5bdbe17..ae484bb 100644 --- a/src/util/format.cpp +++ b/src/util/format.cpp @@ -101,7 +101,7 @@ std::string StormErrorString(uint32_t err) { case ERROR_CANT_FIND_PATCH_PREFIX: return "Cannot find patch prefix"; case ERROR_FAKE_MPQ_HEADER: return "Fake MPQ header at this position"; case ERROR_FILE_DELETED: return "File contains delete marker"; - default: return SystemErrorString(err); + default: return "(" + std::to_string(err) + ") " + SystemErrorString(err); // clang-format on } } diff --git a/src/util/format.h b/src/util/format.h index 80aef84..bba74c3 100644 --- a/src/util/format.h +++ b/src/util/format.h @@ -8,6 +8,10 @@ namespace mpqcli { std::string FileTimeToLsTime(int64_t file_time); +/// Describes a StormLib error code in words that are the same on every platform +/// +/// The code's value differs by platform, so it is left out. A code with no words of its +/// own is described in the platform's words instead, behind the code that names it. std::string StormErrorString(uint32_t err); /// Writes size bytes of buffer to out without line-ending translation diff --git a/test/functional/test_compact.cpp b/test/functional/test_compact.cpp index bfbdeb3..be1ab07 100644 --- a/test/functional/test_compact.cpp +++ b/test/functional/test_compact.cpp @@ -34,8 +34,7 @@ const fs::path &mpqcli_binary = TestEnvironment::Instance().binary_path; const std::string compacting_notice = "[*] Compacting archive. This may take some time..."; const std::string incomplete_listfile_error = - "[!] Failed to compact archive: (10007) At least one file name is unknown (listfile is " - "incomplete)"; + "[!] Failed to compact archive: At least one file name is unknown (listfile is incomplete)"; // Bytes no compressor can shrink, the same on every run, so a removed file of // this size leaves a gap compaction has to reclaim in full diff --git a/test/functional/test_info.cpp b/test/functional/test_info.cpp index 01710f3..53c58b0 100644 --- a/test/functional/test_info.cpp +++ b/test/functional/test_info.cpp @@ -209,8 +209,8 @@ TEST_CASE("info: fails when the archive cannot be opened as an MPQ", "[info]") { REQUIRE(result.stdout_output.empty()); REQUIRE(LinesToSet(result.stderr_output).size() == 1); REQUIRE_THAT(result.stderr_output, ContainsSubstring(open_failure)); - // StormLib's error code and text are forwarded, not just the failure - REQUIRE_THAT(result.stderr_output, ContainsSubstring("(1000) Bad MPQ format")); + // StormLib's error is forwarded, not just the failure + REQUIRE_THAT(result.stderr_output, ContainsSubstring(": Bad MPQ format")); } SECTION("an archive cut short of its tables") { diff --git a/test/unit/test_errors.cpp b/test/unit/test_errors.cpp index 2a595c7..772b0d1 100644 --- a/test/unit/test_errors.cpp +++ b/test/unit/test_errors.cpp @@ -91,33 +91,30 @@ TEST_CASE("ArchiveError", "[errors]") { } TEST_CASE("StormError", "[errors]") { - SECTION("ends its message with the code and the code's text") { + SECTION("ends its message with the error's text") { const StormError error("Failed to read", corrupt); - REQUIRE(std::string(error.what()) == - "Failed to read: (" + std::to_string(corrupt) + ") File is corrupt"); + REQUIRE(std::string(error.what()) == "Failed to read: File is corrupt"); REQUIRE(error.ErrorCode() == corrupt); } } TEST_CASE("ArchiveOpenError", "[errors]") { - SECTION("names the archive ahead of the code and its text") { + SECTION("names the archive ahead of the error's text") { const fs::path path = fs::path("dir") / "a.mpq"; const ArchiveOpenError error(path, corrupt); - REQUIRE(std::string(error.what()) == "Failed to open MPQ archive: " + path.string() + - ": (" + std::to_string(corrupt) + - ") File is corrupt"); + REQUIRE(std::string(error.what()) == + "Failed to open MPQ archive: " + path.string() + ": File is corrupt"); REQUIRE(error.Path() == path); REQUIRE(error.ErrorCode() == corrupt); } } TEST_CASE("ArchiveCreateError", "[errors]") { - SECTION("names the archive ahead of the code and its text") { + SECTION("names the archive ahead of the error's text") { const fs::path path = fs::path("dir") / "a.mpq"; const ArchiveCreateError error(path, corrupt); - REQUIRE(std::string(error.what()) == "Failed to create MPQ archive: " + path.string() + - ": (" + std::to_string(corrupt) + - ") File is corrupt"); + REQUIRE(std::string(error.what()) == + "Failed to create MPQ archive: " + path.string() + ": File is corrupt"); REQUIRE(error.Path() == path); REQUIRE(error.ErrorCode() == corrupt); } diff --git a/test/unit/util/test_format.cpp b/test/unit/util/test_format.cpp index 1cb122c..a240f0f 100644 --- a/test/unit/util/test_format.cpp +++ b/test/unit/util/test_format.cpp @@ -75,19 +75,23 @@ TEST_CASE("StormErrorString", "[format]") { REQUIRE(StormErrorString(ERROR_FILE_DELETED) == "File contains delete marker"); } - SECTION("describes a code it has no case for in the platform's own words") { + SECTION("describes a code it has no case for by the code and the platform's own words") { #ifdef _WIN32 // 32 is ERROR_SHARING_VIOLATION. Windows localises the text, so the test checks // where it came from rather than what it says: strerror(32) is "Broken pipe", and // "Unknown error 32" is what is left when FormatMessage fails + const std::string prefix = "(32) "; const std::string text = StormErrorString(32U); - REQUIRE_FALSE(text.empty()); - REQUIRE(text != std::strerror(32)); - REQUIRE(text != "Unknown error 32"); - REQUIRE(text.back() != '\n'); - REQUIRE(text.back() != '.'); + REQUIRE(text.rfind(prefix, 0) == 0); + const std::string words = text.substr(prefix.size()); + REQUIRE_FALSE(words.empty()); + REQUIRE(words != std::strerror(32)); + REQUIRE(words != "Unknown error 32"); + REQUIRE(words.back() != '\n'); + REQUIRE(words.back() != '.'); #else - REQUIRE(StormErrorString(EISDIR) == std::strerror(EISDIR)); + REQUIRE(StormErrorString(EISDIR) == + "(" + std::to_string(EISDIR) + ") " + std::strerror(EISDIR)); #endif } } From 301f971107c27cc06bb26a3201fbb02aeb425f6b Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 27 Oct 2026 17:03:24 +1300 Subject: [PATCH 32/37] Fixed the MSVC C4996 warnings in the tests --- test/fixtures/integration_data.h | 25 +++++++++++++++++++++++-- test/unit/util/test_format.cpp | 7 +++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/test/fixtures/integration_data.h b/test/fixtures/integration_data.h index 40e035d..6d5ab67 100644 --- a/test/fixtures/integration_data.h +++ b/test/fixtures/integration_data.h @@ -11,6 +11,27 @@ namespace mpqcli::testing { +/// Returns the value of an environment variable, or an empty optional if it is unset +/// +/// MSVC deprecates std::getenv, so Windows copies the value out with _dupenv_s instead. +inline std::optional EnvironmentVariable(const char *name) { +#ifdef _WIN32 + char *value = nullptr; + std::size_t length = 0; + if (_dupenv_s(&value, &length, name) != 0 || value == nullptr) { + return std::nullopt; + } + std::string result(value); + std::free(value); + return result; +#else + if (const char *value = std::getenv(name)) { + return std::string(value); + } + return std::nullopt; +#endif +} + /// Returns the path to one real-world archive, or an empty optional /// /// Looks for name in each place in turn: @@ -28,8 +49,8 @@ inline std::optional IntegrationDataPath(const std::strin #ifdef MPQCLI_INTEGRATION_DATA directories.emplace_back(MPQCLI_INTEGRATION_DATA); #endif - if (const char *env = std::getenv("MPQCLI_INTEGRATION_DATA")) { - directories.emplace_back(env); + if (const auto env = EnvironmentVariable("MPQCLI_INTEGRATION_DATA")) { + directories.emplace_back(*env); } for (const auto &directory : directories) { const auto path = directory / name; diff --git a/test/unit/util/test_format.cpp b/test/unit/util/test_format.cpp index a240f0f..72fe368 100644 --- a/test/unit/util/test_format.cpp +++ b/test/unit/util/test_format.cpp @@ -79,13 +79,16 @@ TEST_CASE("StormErrorString", "[format]") { #ifdef _WIN32 // 32 is ERROR_SHARING_VIOLATION. Windows localises the text, so the test checks // where it came from rather than what it says: strerror(32) is "Broken pipe", and - // "Unknown error 32" is what is left when FormatMessage fails + // "Unknown error 32" is what is left when FormatMessage fails. MSVC deprecates + // strerror, so its text comes from strerror_s. const std::string prefix = "(32) "; const std::string text = StormErrorString(32U); REQUIRE(text.rfind(prefix, 0) == 0); const std::string words = text.substr(prefix.size()); REQUIRE_FALSE(words.empty()); - REQUIRE(words != std::strerror(32)); + char errno_text[256] = {}; + REQUIRE(strerror_s(errno_text, sizeof(errno_text), 32) == 0); + REQUIRE(words != errno_text); REQUIRE(words != "Unknown error 32"); REQUIRE(words.back() != '\n'); REQUIRE(words.back() != '.'); From fd57949609c76d1ff67a793c37c724298224d58f Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 27 Oct 2026 17:03:36 +1300 Subject: [PATCH 33/37] Turned on MPQCLI_WERROR in the Windows test job --- .github/workflows/test.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cb900bf..ff65c03 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -66,13 +66,12 @@ jobs: with: submodules: true - # Raw cmake: the Makefile needs a POSIX shell. MPQCLI_WERROR stays off here - # until the tree is known to compile clean under /W4, which nothing has yet - # checked; it is on in the Linux jobs. + # Raw cmake: the Makefile needs a POSIX shell. MPQCLI_WERROR holds MSVC to + # the same bar as the Linux jobs: /W4 /permissive- with warnings as errors. - name: Configure and build shell: bash run: | - cmake -B build/dev -G "Visual Studio 17 2022" -A x64 -DMPQCLI_BUILD_APP=ON + cmake -B build/dev -G "Visual Studio 17 2022" -A x64 -DMPQCLI_BUILD_APP=ON -DMPQCLI_WERROR=ON cmake --build build/dev --config Release --parallel # Separate steps, so a failure names the layer that broke From dc8b21ee493d23432ada70c8700cbc3889cd6991 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 27 Oct 2026 17:03:54 +1300 Subject: [PATCH 34/37] Added a Debug build to the Windows test job --- .github/workflows/test.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ff65c03..d0341c5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,6 +59,14 @@ jobs: - run: make test_functional test_windows: + name: test_windows (${{ matrix.build_type }}) + strategy: + fail-fast: false + matrix: + # Debug as well as Release: MSVC's Debug runtime adds checked iterators + # and CRT assertions that Release compiles out + build_type: [Debug, Release] + runs-on: windows-2022 steps: - name: Check out repository code @@ -72,13 +80,13 @@ jobs: shell: bash run: | cmake -B build/dev -G "Visual Studio 17 2022" -A x64 -DMPQCLI_BUILD_APP=ON -DMPQCLI_WERROR=ON - cmake --build build/dev --config Release --parallel + cmake --build build/dev --config ${{ matrix.build_type }} --parallel # Separate steps, so a failure names the layer that broke - name: Run unit tests shell: bash - run: ctest --test-dir build/dev --output-on-failure -C Release -L unit + run: ctest --test-dir build/dev --output-on-failure -C ${{ matrix.build_type }} -L unit - name: Run functional tests shell: bash - run: ctest --test-dir build/dev --output-on-failure -C Release -L functional + run: ctest --test-dir build/dev --output-on-failure -C ${{ matrix.build_type }} -L functional From bf6994adb7e37edc6f98496cb74be49445f3fa60 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 27 Oct 2026 17:05:43 +1300 Subject: [PATCH 35/37] Built StormLib's C sources as C11 --- CMakeLists.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 5381822..ceb80ab 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -79,6 +79,11 @@ set(STORM_USE_BUNDLED_LIBRARIES ON CACHE BOOL "" FORCE) add_subdirectory(extern/StormLib) mark_system(storm) +# GCC 15 defaults to C23, which drops the K&R function definitions in the bundled +# zlib. MSVC is left on its own default, which is not C23, so Windows is unchanged. +if(NOT MSVC) + set_target_properties(storm PROPERTIES C_STANDARD 11) +endif() # Only app/ parses arguments, so the core never sees CLI11 if(MPQCLI_BUILD_APP) From b34bc2d50694349f6570bab0563027d7366362da Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 27 Oct 2026 17:05:43 +1300 Subject: [PATCH 36/37] Removed macOS from the bug report template --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 73818c4..e04fbbc 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -24,7 +24,6 @@ body: - Linux - Windows - WSL (Windows Subsystem for Linux) - - macOS - Other validations: required: true From 325a5eae82ebc076f0230bffdb8d49e1dfb01ed2 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 27 Oct 2026 18:27:42 +1300 Subject: [PATCH 37/37] Removed an unreachable return that broke the Windows Debug build --- test/archive_helper.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/test/archive_helper.cpp b/test/archive_helper.cpp index cb619b4..2cc92c7 100644 --- a/test/archive_helper.cpp +++ b/test/archive_helper.cpp @@ -54,7 +54,6 @@ std::string EntryProperty(const fs::path &archive, const std::string &name, } } FAIL("no entry named " << name << " in " << archive); - return {}; } std::uint64_t EntryNumber(const fs::path &archive, const std::string &name,