From 44afc44db85dc2142aa0a6138371b9154ab5ee08 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Mon, 1 Jun 2026 16:49:23 +1200 Subject: [PATCH 01/10] Added multi-file, directory, and stdin support to add and remove subcommands --- src/commands.cpp | 94 +++++-- src/commands.h | 6 +- src/main.cpp | 63 ++++- src/mpq.cpp | 65 +++-- src/mpq.h | 6 +- test/conftest.py | 13 +- test/test_add.py | 646 ++++++++++++++++++++++++++++++++------------ test/test_remove.py | 79 +++++- 8 files changed, 729 insertions(+), 243 deletions(-) diff --git a/src/commands.cpp b/src/commands.cpp index 384d3e3..056ff02 100644 --- a/src/commands.cpp +++ b/src/commands.cpp @@ -120,7 +120,7 @@ int HandleCreate(const std::string &target, const std::optional &na } AddFile(hArchive, target, archivePath, lcid, gameRules, addOverrides); } else { - AddFiles(hArchive, target, lcid, gameRules, addOverrides); + AddFiles(hArchive, target, "", lcid, gameRules, addOverrides); } if (signArchive) { SignMpqArchive(hArchive); @@ -134,45 +134,46 @@ int HandleCreate(const std::string &target, const std::optional &na return 0; } -int HandleAdd(const std::string &file, const std::string &target, +int HandleAdd(const std::vector &files, const std::string &target, const std::optional &path, const std::optional &dirInArchive, - const std::optional &nameInArchive, bool overwrite, + const std::optional &nameInArchive, bool overwrite, bool update, const std::optional &locale, const std::optional &gameProfile, int64_t fileDwFlags, int64_t fileDwCompression, int64_t fileDwCompressionNext) { HANDLE hArchive; - // Open the MPQ archive for writing (this is why we set flag as 0) if (!OpenMpqArchive(target, &hArchive, 0)) { std::cerr << "[!] Failed to open MPQ archive." << std::endl; return 1; } - // Path to file on disk - fs::path filePath = fs::path(file); + bool hasDirectory = false; + for (const auto &f : files) { + if (fs::is_directory(f)) { + hasDirectory = true; + break; + } + } + bool multipleInputs = files.size() > 1; - std::string archivePath = - filePath.filename() - .u8string(); // Default: use the filename as path, saves file to root of MPQ + if ((hasDirectory || multipleInputs) && (dirInArchive.has_value() || nameInArchive.has_value())) { + std::cerr << "[!] --directory-in-archive and --filename-in-archive are only valid when " + "adding a single file." + << std::endl; + CloseMpqArchive(hArchive); + return 1; + } + if (multipleInputs && path.has_value()) { + std::cerr << "[!] --path is only valid when adding a single file or directory." << std::endl; + CloseMpqArchive(hArchive); + return 1; + } if (path.has_value() && (dirInArchive.has_value() || nameInArchive.has_value())) { - // Return error since providing --path together with --name-in-archive or - // --directory-in-archive makes no sense and is a user error std::cerr << "[!] Cannot specify --path together with --name-in-archive or " "--directory-in-archive." << std::endl; CloseMpqArchive(hArchive); return 1; - - } else if (path.has_value()) { // Optional: specified whole path inside archive - filePath = fs::path(path.value()); - archivePath = WindowsifyFilePath(filePath); // Normalise path for MPQ - - } else if (dirInArchive.has_value() || - nameInArchive.has_value()) { // Optional: specified filename inside archive - std::string effectiveDir = dirInArchive.value_or(fs::path(file).parent_path().u8string()); - std::string effectiveName = nameInArchive.value_or(archivePath); - filePath = fs::path(effectiveDir) / fs::path(effectiveName); - archivePath = WindowsifyFilePath(filePath); // Normalise path for MPQ } LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : defaultLocale; @@ -186,31 +187,68 @@ int HandleAdd(const std::string &file, const std::string &target, } GameRules gameRules(profile); - // Apply AddFileSettings overrides if provided CompressionSettingsOverrides addOverrides; if (fileDwFlags >= 0) addOverrides.dwFlags = static_cast(fileDwFlags); if (fileDwCompression >= 0) addOverrides.dwCompression = static_cast(fileDwCompression); if (fileDwCompressionNext >= 0) addOverrides.dwCompressionNext = static_cast(fileDwCompressionNext); - AddFile(hArchive, file, archivePath, lcid, gameRules, addOverrides, overwrite); + if (update && !hasDirectory) { + std::cerr << "[!] Warning: --update is only meaningful when adding a directory" << std::endl; + } + + for (const auto &f : files) { + if (!fs::exists(f)) { + std::cerr << "[!] Path does not exist: " << f << std::endl; + continue; + } + + if (fs::is_directory(f)) { + std::string prefix = path.value_or(""); + AddFiles(hArchive, f, prefix, lcid, gameRules, addOverrides, overwrite, update); + } else if (fs::is_regular_file(f)) { + fs::path filePath = fs::path(f); + std::string archivePath = filePath.filename().u8string(); + + if (path.has_value()) { + filePath = fs::path(path.value()); + archivePath = WindowsifyFilePath(filePath); + } else if (dirInArchive.has_value() || nameInArchive.has_value()) { + std::string effectiveDir = + dirInArchive.value_or(fs::path(f).parent_path().u8string()); + std::string effectiveName = nameInArchive.value_or(archivePath); + filePath = fs::path(effectiveDir) / fs::path(effectiveName); + archivePath = WindowsifyFilePath(filePath); + } + + AddFile(hArchive, f, archivePath, lcid, gameRules, addOverrides, overwrite); + } else { + std::cerr << "[!] Not a file or directory: " << f << std::endl; + } + } + CloseMpqArchive(hArchive); return 0; } -int HandleRemove(const std::string &file, const std::string &target, +int HandleRemove(const std::vector &files, const std::string &target, const std::optional &locale) { HANDLE hArchive; - // Open the MPQ archive for writing (this is why we set flag as 0) if (!OpenMpqArchive(target, &hArchive, 0)) { std::cerr << "[!] Failed to open MPQ archive." << std::endl; return 1; } LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : defaultLocale; - int result = RemoveFile(hArchive, file, lcid); + int overallResult = 0; + for (const auto &f : files) { + int result = RemoveFile(hArchive, f, lcid); + if (result != 0) { + overallResult = result; + } + } CloseMpqArchive(hArchive); - return result; + return overallResult; } int HandleList(const std::string &target, const std::optional &listfileName, diff --git a/src/commands.h b/src/commands.h index 601e9d7..dc3c335 100644 --- a/src/commands.h +++ b/src/commands.h @@ -16,14 +16,14 @@ int HandleCreate(const std::string &target, const std::optional &na int64_t streamFlags, int64_t sectorSize, int64_t rawChunkSize, int64_t fileFlags1, int64_t fileFlags2, int64_t fileFlags3, int64_t attrFlags, int64_t fileDwFlags, int64_t fileDwCompression, int64_t fileDwCompressionNext); -int HandleAdd(const std::string &file, const std::string &target, +int HandleAdd(const std::vector &files, const std::string &target, const std::optional &path, const std::optional &dirInArchive, - const std::optional &nameInArchive, bool overwrite, + const std::optional &nameInArchive, bool overwrite, bool update, const std::optional &locale, const std::optional &gameProfile, int64_t fileDwFlags, int64_t fileDwCompression, int64_t fileDwCompressionNext); -int HandleRemove(const std::string &file, const std::string &target, +int HandleRemove(const std::vector &files, const std::string &target, const std::optional &locale); int HandleList(const std::string &target, const std::optional &listfileName, bool listAll, bool listDetailed, const std::vector &properties); diff --git a/src/main.cpp b/src/main.cpp index a293ab9..b392bdd 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -21,7 +21,7 @@ int main(int argc, char **argv) { // CLI: base // These are reused in multiple subcommands std::string baseTarget; // all subcommands - std::string baseFile; // add, remove, extract, read + std::string baseFile; // extract, read std::optional basePath; // add std::optional baseLocale; // create, add, remove, extract, read std::optional baseNameInArchive; // add, create @@ -33,6 +33,10 @@ int main(int argc, char **argv) { // CLI: add std::optional baseDirInArchive; // add bool addOverwrite = false; + bool addUpdate = false; + std::vector addFiles; + // CLI: remove + std::vector removeFiles; // CLI: extract bool extractKeepFolderStructure = false; // CLI: create @@ -145,17 +149,23 @@ int main(int argc, char **argv) { ->group("Game setting overrides"); // Subcommand: Add - CLI::App *add = app.add_subcommand("add", "Add a file to an existing MPQ archive"); - add->add_option("file", baseFile, "File to add")->required()->check(CLI::ExistingFile); - add->add_option("target", baseTarget, "Target MPQ archive") + CLI::App *add = app.add_subcommand("add", "Add files to an existing MPQ archive"); + add->add_option("archive", baseTarget, "Target MPQ archive") ->required() ->check(CLI::ExistingFile); + add->add_option("files", addFiles, + "Files or directories to add; pass - to read paths from stdin") + ->required() + ->expected(-1); add->add_option("-p,--path", basePath, - "Full path (directory and filename) of the file within MPQ archive"); + "Archive path for a single file, or prefix for a directory add"); add->add_option("-d,--directory-in-archive", baseDirInArchive, - "Directory to put file inside within MPQ archive"); - add->add_option("-f,--filename-in-archive", baseNameInArchive, "Filename inside MPQ archive"); + "Directory to put file inside within MPQ archive (single file only)"); + add->add_option("-f,--filename-in-archive", baseNameInArchive, + "Filename inside MPQ archive (single file only)"); add->add_flag("-w,--overwrite", addOverwrite, "Overwrite file if it already is in MPQ archive"); + add->add_flag("-u,--update", addUpdate, + "Skip files whose archived size matches the on-disk size (directory add only)"); add->add_option("--locale", baseLocale, "Locale to use for added file")->check(LocaleValid); add->add_option("-g,--game", baseGameProfile, "Game profile for compression rules. Valid options:\n" + @@ -171,11 +181,14 @@ int main(int argc, char **argv) { ->group("Game setting overrides"); // Subcommand: Remove - CLI::App *remove = app.add_subcommand("remove", "Remove file from an existing MPQ archive"); - remove->add_option("file", baseFile, "File to remove")->required(); - remove->add_option("target", baseTarget, "Target MPQ archive") + CLI::App *remove = app.add_subcommand("remove", "Remove files from an existing MPQ archive"); + remove->add_option("archive", baseTarget, "Target MPQ archive") ->required() ->check(CLI::ExistingFile); + remove->add_option("files", removeFiles, + "Archive paths of files to remove; pass - to read paths from stdin") + ->required() + ->expected(-1); remove->add_option("--locale", baseLocale, "Locale of file to remove")->check(LocaleValid); // Subcommand: List @@ -253,13 +266,35 @@ int main(int argc, char **argv) { } if (app.got_subcommand(add)) { - return HandleAdd(baseFile, baseTarget, basePath, baseDirInArchive, baseNameInArchive, - addOverwrite, baseLocale, baseGameProfile, fileDwFlags, fileDwCompression, - fileDwCompressionNext); + std::vector resolvedAddFiles; + for (const auto &f : addFiles) { + if (f == "-") { + std::string line; + while (std::getline(std::cin, line)) { + if (!line.empty()) resolvedAddFiles.push_back(line); + } + } else { + resolvedAddFiles.push_back(f); + } + } + return HandleAdd(resolvedAddFiles, baseTarget, basePath, baseDirInArchive, baseNameInArchive, + addOverwrite, addUpdate, baseLocale, baseGameProfile, fileDwFlags, + fileDwCompression, fileDwCompressionNext); } if (app.got_subcommand(remove)) { - return HandleRemove(baseFile, baseTarget, baseLocale); + std::vector resolvedRemoveFiles; + for (const auto &f : removeFiles) { + if (f == "-") { + std::string line; + while (std::getline(std::cin, line)) { + if (!line.empty()) resolvedRemoveFiles.push_back(line); + } + } else { + resolvedRemoveFiles.push_back(f); + } + } + return HandleRemove(resolvedRemoveFiles, baseTarget, baseLocale); } if (app.got_subcommand(list)) { diff --git a/src/mpq.cpp b/src/mpq.cpp index 9e6e628..f460da7 100644 --- a/src/mpq.cpp +++ b/src/mpq.cpp @@ -179,31 +179,64 @@ HANDLE CreateMpqArchive(const std::string &outputArchiveName, const uint32_t fil return hMpq; } -int AddFiles(HANDLE hArchive, const std::string &inputPath, LCID locale, const GameRules &gameRules, - const CompressionSettingsOverrides &overrides) { - // We need to "clean" the target path to ensure it is a valid directory - // and to strip any directory structure from the files we add +int AddFiles(HANDLE hArchive, const std::string &inputPath, const std::string &pathPrefix, + LCID locale, const GameRules &gameRules, + const CompressionSettingsOverrides &overrides, bool overwrite, bool update) { fs::path targetPath = fs::path(inputPath); + int filesAdded = 0; + int filesSkipped = 0; + for (const auto &entry : fs::recursive_directory_iterator(inputPath)) { - if (fs::is_regular_file(entry.path())) { - // Strip the target path from the file name - fs::path inputFilePath = fs::relative(entry, targetPath); + if (!fs::is_regular_file(entry.path())) continue; - // Normalise path for MPQ - std::string archiveFilePath = WindowsifyFilePath(inputFilePath.u8string()); + fs::path inputFilePath = fs::relative(entry, targetPath); + std::string archiveFilePath; - // Skip special MPQ files that StormLib manages automatically - if (std::find(kSpecialMpqFiles.begin(), kSpecialMpqFiles.end(), archiveFilePath) != - kSpecialMpqFiles.end()) { - std::cout << "[*] Skipping special MPQ file: " << archiveFilePath << std::endl; - continue; + if (pathPrefix.empty()) { + archiveFilePath = WindowsifyFilePath(inputFilePath.u8string()); + } else { + archiveFilePath = WindowsifyFilePath((fs::path(pathPrefix) / inputFilePath).u8string()); + } + + if (std::find(kSpecialMpqFiles.begin(), kSpecialMpqFiles.end(), archiveFilePath) != + kSpecialMpqFiles.end()) { + std::cout << "[*] Skipping special MPQ file: " << archiveFilePath << std::endl; + continue; + } + + if (update) { + SFileSetLocale(locale); + HANDLE hFile; + if (SFileOpenFileEx(hArchive, archiveFilePath.c_str(), SFILE_OPEN_FROM_MPQ, &hFile)) { + int32_t fileLocale = GetFileInfo(hFile, SFileInfoLocale); + if (fileLocale == locale) { + DWORD archivedSize = SFileGetFileSize(hFile, nullptr); + SFileCloseFile(hFile); + uintmax_t diskSize = fs::file_size(entry.path()); + if (diskSize == static_cast(archivedSize)) { + std::cout << "[~] Skipping unchanged file: " << archiveFilePath << std::endl; + filesSkipped++; + continue; + } + } else { + SFileCloseFile(hFile); + } } + } - AddFile(hArchive, entry.path().u8string(), archiveFilePath, locale, gameRules, - overrides, false); + int result = + AddFile(hArchive, entry.path(), archiveFilePath, locale, gameRules, overrides, overwrite); + if (result == 0) { + filesAdded++; } } + + if (update) { + std::cout << "[*] " << filesAdded << " files added, " << filesSkipped << " files skipped" + << std::endl; + } + return 0; } diff --git a/src/mpq.h b/src/mpq.h index 00296b3..234360d 100644 --- a/src/mpq.h +++ b/src/mpq.h @@ -21,8 +21,10 @@ int ExtractFile(HANDLE hArchive, const std::string &output, const std::string &f bool keepFolderStructure, LCID preferredLocale); HANDLE CreateMpqArchive(const std::string &outputArchiveName, uint32_t fileCount, const GameRules &gameRules); -int AddFiles(HANDLE hArchive, const std::string &inputPath, LCID locale, const GameRules &gameRules, - const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides()); +int AddFiles(HANDLE hArchive, const std::string &inputPath, const std::string &pathPrefix, + LCID locale, const GameRules &gameRules, + const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), + bool overwrite = false, bool update = false); int AddFile(HANDLE hArchive, const fs::path &localFile, const std::string &archiveFilePath, LCID locale, const GameRules &gameRules, const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), diff --git a/test/conftest.py b/test/conftest.py index d68f000..f8bf64f 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -61,6 +61,9 @@ def generate_test_files(): 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): @@ -102,7 +105,7 @@ def generate_locales_mpq_test_files(binary_path): else: # Explicit locale - add to existing MPQ file result = subprocess.run( - [str(binary_path), "add", str(file_path), str(mpq_many_locales_file_name), "--locale", locale], + [str(binary_path), "add", str(mpq_many_locales_file_name), str(file_path), "--locale", locale], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -159,7 +162,7 @@ def generate_mpq_without_internal_listfile(binary_path): else: # Explicit locale - add to existing MPQ file result = subprocess.run( - [str(binary_path), "add", str(file_path), str(mpq_file_name), "--locale", locale], + [str(binary_path), "add", str(mpq_file_name), str(file_path), "--locale", locale], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -195,7 +198,7 @@ def generate_path_traversal_mpq(binary_path): # Embed a second entry whose archive path traverses above the output directory result = subprocess.run( - [str(binary_path), "add", str(safe_file), str(mpq_file), "-p", "../../sneaky.txt"], + [str(binary_path), "add", str(mpq_file), str(safe_file), "-p", "../../sneaky.txt"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -235,7 +238,7 @@ def download_test_files(): try: urllib.request.urlretrieve(url, file_path) downloaded_files.append(file_path) - except Exception: - exit(1) + except Exception as e: + pytest.skip(f"Could not download test file '{name}': {e}") return downloaded_files diff --git a/test/test_add.py b/test/test_add.py index 6a310f2..1c0c63c 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -4,19 +4,13 @@ def test_add_target_mpq_does_not_exist(binary_path, generate_test_files): - """ - Test MPQ file addition 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_mpq = script_dir / "does" / "not" / "exist" + 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_file), str(target_mpq)], + [str(binary_path), "add", str(target_mpq), str(target_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -26,48 +20,35 @@ def test_add_target_mpq_does_not_exist(binary_path, generate_test_files): def test_add_target_file_does_not_exist(binary_path, generate_test_files): - """ - Test MPQ file addition with a non-existent file to add. - - This test checks: - - If the application exits correctly when the target file to add does not exist. - """ _ = generate_test_files script_dir = Path(__file__).parent target_mpq = script_dir / "data" / "files.mpq" - target_file = script_dir / "does" / "not" / "exist" + + create_mpq_archive_for_test(binary_path, script_dir) result = subprocess.run( - [str(binary_path), "add", str(target_file), str(target_mpq)], + [str(binary_path), "add", str(target_mpq), str(script_dir / "does" / "not" / "exist.txt")], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + assert result.returncode == 0, 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): - """ - Test MPQ file addition. - - This test checks: - - If the application correctly handles adding a file to an MPQ archive. - """ _ = generate_test_files script_dir = Path(__file__).parent target_file = script_dir / "data" / "files.mpq" - # Start by creating an MPQ archive for this test create_mpq_archive_for_test(binary_path, script_dir) - # 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.") - result = subprocess.run( - [str(binary_path), "add", str(test_file), str(target_file)], + [str(binary_path), "add", str(target_file), str(test_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -94,29 +75,46 @@ def test_add_file_to_mpq_archive(binary_path, generate_test_files): 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.stdout + assert "[+] Adding file: beta.txt" in result.stdout + + 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_filenameinarchive_and_directoryinarchive_and_path_to_mpq_archive(binary_path, generate_test_files): - """ - Test MPQ file addition with filename-in-archive, directory-in-archive and path arguments. - This test checks: - - If the application correctly gives an error when using both - path and filename-in-archive or path and directory-in-archive arguments. - - If the application correctly handles adding a file to an MPQ archive with the - filename-in-archive argument. - - If the application correctly handles adding a file to an MPQ archive with the - directory-in-archive argument. - - If the application correctly handles adding a file to an MPQ archive with the - directory-in-archive and filename-in-archive argument. - - If the application correctly handles adding a file to an MPQ archive with the - path argument. - """ _ = generate_test_files script_dir = Path(__file__).parent target_file = script_dir / "data" / "files.mpq" - # Start by creating an MPQ archive for this test create_mpq_archive_for_test(binary_path, script_dir) - # Create new test files on the fly 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" @@ -126,14 +124,13 @@ def test_add_file_with_filenameinarchive_and_directoryinarchive_and_path_to_mpq_ test_file3 = script_dir / "data" / "test3.txt" test_file3.write_text("This is yet yet another test file for MPQ addition.") - # Copy test_file1 to bin_dir to avoid absolute paths in archive bin_dir = binary_path.parent test_file1_copy = bin_dir / "test1.txt" shutil.copy(test_file1, test_file1_copy) try: result = subprocess.run( - [str(binary_path), "add", str(test_file0), str(target_file), "--directory-in-archive", "directory", "--path", "important.txt"], + [str(binary_path), "add", str(target_file), str(test_file0), "--directory-in-archive", "directory", "--path", "important.txt"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -141,7 +138,7 @@ def test_add_file_with_filenameinarchive_and_directoryinarchive_and_path_to_mpq_ assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" result = subprocess.run( - [str(binary_path), "add", str(test_file0), str(target_file), "--filename-in-archive", "important.txt", "--path", "texts/important.txt"], + [str(binary_path), "add", str(target_file), str(test_file0), "--filename-in-archive", "important.txt", "--path", "texts/important.txt"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -149,7 +146,7 @@ def test_add_file_with_filenameinarchive_and_directoryinarchive_and_path_to_mpq_ assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" result = subprocess.run( - [str(binary_path), "add", str(test_file0), str(target_file), "--directory-in-archive", "directory"], + [str(binary_path), "add", str(target_file), str(test_file0), "--directory-in-archive", "directory"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -157,7 +154,7 @@ def test_add_file_with_filenameinarchive_and_directoryinarchive_and_path_to_mpq_ assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" result = subprocess.run( - [str(binary_path), "add", test_file1_copy.name, str(target_file), "--filename-in-archive", "msg.txt"], + [str(binary_path), "add", str(target_file), test_file1_copy.name, "--filename-in-archive", "msg.txt"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -166,7 +163,7 @@ def test_add_file_with_filenameinarchive_and_directoryinarchive_and_path_to_mpq_ assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" result = subprocess.run( - [str(binary_path), "add", str(test_file2), str(target_file), "--directory-in-archive", "texts", "--filename-in-archive", "info.txt"], + [str(binary_path), "add", str(target_file), str(test_file2), "--directory-in-archive", "texts", "--filename-in-archive", "info.txt"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -174,7 +171,7 @@ def test_add_file_with_filenameinarchive_and_directoryinarchive_and_path_to_mpq_ assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" result = subprocess.run( - [str(binary_path), "add", str(test_file3), str(target_file), "--path", "important\\message.txt"], + [str(binary_path), "add", str(target_file), str(test_file3), "--path", "important\\message.txt"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -191,11 +188,9 @@ def test_add_file_with_filenameinarchive_and_directoryinarchive_and_path_to_mpq_ output_lines = set(result.stdout.splitlines()) expected_output = { - # Files from before: "cats.txt", "dogs.txt", "bytes", - # Files added in this test: "directory\\test0.txt", "msg.txt", "texts\\info.txt", @@ -204,36 +199,24 @@ def test_add_file_with_filenameinarchive_and_directoryinarchive_and_path_to_mpq_ assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" assert output_lines == expected_output, f"Unexpected output: {output_lines}" finally: - # Clean up the copied file test_file1_copy.unlink(missing_ok=True) def test_add_existing_file_without_overwrite_should_fail(binary_path, generate_test_files): - """ - Test adding existing files to MPQ archive without the overwrite flag. - This test checks: - - If the application correctly prints an error when adding an existing file to an MPQ archive without the overwrite flag. - """ _ = generate_test_files script_dir = Path(__file__).parent target_file = script_dir / "data" / "files.mpq" - # Start by creating an MPQ archive for this test create_mpq_archive_for_test(binary_path, script_dir) - - # Verify that existing file has the expected content before attempting to add a new file with different content expected_content = {"This is a file about cats."} verify_file_in_mpq_has_content(binary_path, target_file, "cats.txt", expected_content) - - # Create new test files on the fly 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(test_file), str(target_file)], + [str(binary_path), "add", str(target_file), str(test_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -250,34 +233,23 @@ def test_add_existing_file_without_overwrite_should_fail(binary_path, generate_t assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - # Verify that the file content is unchanged 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): - """ - Test adding existing files to MPQ archive with the overwrite flag. - This test checks: - - If the application correctly overwrites an existing file to an MPQ archive with the overwrite flag set. - """ _ = generate_test_files script_dir = Path(__file__).parent target_file = script_dir / "data" / "files.mpq" - # Start by creating an MPQ archive for this test create_mpq_archive_for_test(binary_path, script_dir) - - # Verify that file has the expected content before overwriting verify_file_in_mpq_has_content(binary_path, target_file, "cats.txt", { "This is a file about cats." }) - - # Create new test files on the fly 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(test_file), str(target_file), "--overwrite"], + [str(binary_path), "add", str(target_file), str(test_file), "--overwrite"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -296,31 +268,21 @@ def test_add_existing_file_with_overwrite_should_succeed(binary_path, generate_t assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - # Verify that file has the expected content after overwriting 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): - """ - Test adding a non-existing file to MPQ archive with the overwrite flag. - This test checks: - - If the application correctly adds a non-existing file to an MPQ archive with the overwrite flag set. - """ _ = generate_test_files script_dir = Path(__file__).parent target_file = script_dir / "data" / "files.mpq" - # Start by creating an MPQ archive for this test create_mpq_archive_for_test(binary_path, script_dir) - - # Create new test files on the fly test_file = script_dir / "data" / "test.txt" test_file.write_text("This file is newly added.") result = subprocess.run( - [str(binary_path), "add", str(test_file), str(target_file), "--overwrite"], + [str(binary_path), "add", str(target_file), str(test_file), "--overwrite"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -338,26 +300,16 @@ def test_add_nonexisting_file_with_overwrite_should_succeed(binary_path, generat assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - # Verify that file has the expected content 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): - """ - Test MPQ file addition with illegal locale. - - This test checks: - - That when an illegal locale is provided, no file is added. - """ _ = generate_test_files script_dir = Path(__file__).parent target_file = script_dir / "data" / "files.mpq" - # Start by creating an MPQ archive for this test create_mpq_archive_for_test(binary_path, script_dir) - # Verify that the archive has the expected content expected_content = { "enUS bytes", "enUS dogs.txt", @@ -365,21 +317,17 @@ def test_create_mpq_with_illegal_locale(binary_path, generate_test_files): } verify_archive_file_content(binary_path, target_file, expected_content) - - # Create a new test file on the fly 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(test_file), str(target_file), "--locale", "illegal_locale"], + [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}" - - # Verify that the archive still has the same content expected_content = { "enUS bytes", "enUS dogs.txt", @@ -389,25 +337,17 @@ def test_create_mpq_with_illegal_locale(binary_path, generate_test_files): def test_create_mpq_with_locale(binary_path, generate_test_files): - """ - Test MPQ file addition. - - This test checks: - - If the application correctly handles adding a file to an MPQ archive. - """ _ = generate_test_files script_dir = Path(__file__).parent target_file = script_dir / "data" / "files.mpq" - # Start by creating an MPQ archive for this test create_mpq_archive_for_test(binary_path, script_dir) - # Create a new test file on the fly 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(test_file), str(target_file), "--locale", "esES"], + [str(binary_path), "add", str(target_file), str(test_file), "--locale", "esES"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -425,8 +365,6 @@ def test_create_mpq_with_locale(binary_path, generate_test_files): assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - - # Verify that the archive has the expected content expected_content = { "enUS bytes", "enUS dogs.txt", @@ -437,39 +375,28 @@ def test_create_mpq_with_locale(binary_path, generate_test_files): def test_add_file_with_game_profile(binary_path, generate_test_files): - """ - Test adding a file to an MPQ archive with different game profiles. - - This test checks: - - If files can be added with various game profiles. - - If the game profile is accepted and applied. - - If the correct compression flags are applied to added files. - """ _ = generate_test_files script_dir = Path(__file__).parent target_file = script_dir / "data" / "files.mpq" - # Test profiles with their expected flags for .txt files test_cases = [ - ("generic", "ce"), # Generic: compressed + encrypted - ("diablo1", "ie"), # Diablo1: imploded + encrypted - ("starcraft1", "ce2"), # StarCraft: compressed + encrypted + key v2 - ("wow1", "c"), # WoW 1.x: compressed - ("wow2", "cr"), # WoW 2.x: compressed + sector CRC - ("starcraft2", "c"), # StarCraft2: compressed (small files use single unit) - ("diablo3", "c"), # Diablo3: compressed + ("generic", "ce"), + ("diablo1", "ie"), + ("starcraft1", "ce2"), + ("wow1", "c"), + ("wow2", "cr"), + ("starcraft2", "c"), + ("diablo3", "c"), ] for profile, expected_flags in test_cases: - # Create a fresh MPQ archive for each test create_mpq_archive_for_test(binary_path, script_dir) - # Create a test file 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(test_file), str(target_file), "--game", profile], + [str(binary_path), "add", str(target_file), str(test_file), "--game", profile], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -479,7 +406,6 @@ def test_add_file_with_game_profile(binary_path, generate_test_files): assert f"Using game profile: {profile}" in result.stdout, f"Game profile message not found for {profile}" assert f"Adding file: test_{profile}.txt" in result.stdout - # Verify compression flags on the added file list_result = subprocess.run( [str(binary_path), "list", str(target_file), "-d", "-p", "flags"], stdout=subprocess.PIPE, @@ -488,13 +414,11 @@ def test_add_file_with_game_profile(binary_path, generate_test_files): ) assert list_result.returncode == 0, f"Failed to list files for {profile}" - # Check that the added file has the expected flags 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] # Extract flags - # Check that expected flags are present in the actual flags + flags = line.split()[0] for flag in expected_flags: assert flag in flags, f"Profile {profile}: expected flag '{flag}' in '{flags}' for added file" @@ -502,25 +426,17 @@ def test_add_file_with_game_profile(binary_path, generate_test_files): def test_add_file_with_invalid_game_profile(binary_path, generate_test_files): - """ - Test adding a file 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_file = script_dir / "data" / "files.mpq" - # Create a fresh MPQ archive create_mpq_archive_for_test(binary_path, script_dir) - # Create a test file 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(test_file), str(target_file), "-g", "invalid_profile"], + [str(binary_path), "add", str(target_file), str(test_file), "-g", "invalid_profile"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -530,18 +446,10 @@ def test_add_file_with_invalid_game_profile(binary_path, generate_test_files): def test_add_file_with_all_game_profiles(binary_path, generate_test_files): - """ - Test adding files with all available game profiles. - - This test checks: - - If all game profiles work with the add command. - - If files are actually added to the archive with compression applied. - """ _ = generate_test_files script_dir = Path(__file__).parent target_file = script_dir / "data" / "files.mpq" - # All profiles should be accepted all_profiles = [ "generic", "diablo1", "lordsofmagic", "starcraft1", "warcraft2", "diablo2", "warcraft3", "warcraft3-map", "wow1", "wow2", "wow3", "wow4", "wow5", @@ -549,15 +457,13 @@ def test_add_file_with_all_game_profiles(binary_path, generate_test_files): ] for profile in all_profiles: - # Create a fresh MPQ archive for each test create_mpq_archive_for_test(binary_path, script_dir) - # Create a test file 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(test_file), str(target_file), "-g", profile], + [str(binary_path), "add", str(target_file), str(test_file), "-g", profile], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -566,7 +472,6 @@ def test_add_file_with_all_game_profiles(binary_path, generate_test_files): assert result.returncode == 0, f"mpqcli failed for profile {profile}: {result.stderr}" assert f"Using game profile: {profile}" in result.stdout, f"Game profile message not found for {profile}" - # Verify the file was actually added list_result = subprocess.run( [str(binary_path), "list", str(target_file)], stdout=subprocess.PIPE, @@ -576,7 +481,6 @@ def test_add_file_with_all_game_profiles(binary_path, generate_test_files): 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" - # Verify that some compression flag is set (at least 'c' for compressed or 'i' for imploded) flags_result = subprocess.run( [str(binary_path), "list", str(target_file), "-d", "-p", "flags"], stdout=subprocess.PIPE, @@ -589,7 +493,6 @@ def test_add_file_with_all_game_profiles(binary_path, generate_test_files): for line in flags_result.stdout.splitlines(): if f"test_all_{profile}.txt" in line: flags = line.split()[0] - # Check that either compressed or imploded flag is present if 'c' in flags or 'i' in flags: found_with_compression = True break @@ -597,12 +500,421 @@ def test_add_file_with_all_game_profiles(binary_path, generate_test_files): 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_stdout = { + "[+] File for locale esES already exists in MPQ archive: cats.txt - Overwriting...", + "[+] Adding file for locale esES: cats.txt", + } + output_lines = set(result.stdout.splitlines()) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert output_lines == expected_stdout, 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_stdout = {"[+] Adding file: cats.txt"} + output_lines = set(result.stdout.splitlines()) + + assert result.returncode == 0, f"Re-add after full removal failed: {result.stderr}" + assert output_lines == expected_stdout, f"Unexpected output: {output_lines}" + + verify_file_in_mpq_has_content( + binary_path, target_file, "cats.txt", + {"This is a file about cats."} + ) + + +# ---- Directory add tests ---- + +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.stdout + assert "[+] Adding file: b.txt" in result.stdout + + 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 + ) + assert "textures\\sub\\a.txt" in list_result.stdout + assert "textures\\b.txt" in list_result.stdout + assert "sub\\a.txt" not in list_result.stdout + 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.stdout + + 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 + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert "[!] File already exists in MPQ archive: cats.txt - Skipping..." 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_dir_with_directory_in_archive_flag_errors(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_flag_error" + + create_mpq_archive_for_test(binary_path, script_dir) + add_dir.mkdir(parents=True, exist_ok=True) + (add_dir / "x.txt").write_text("x") + + try: + result = subprocess.run( + [str(binary_path), "add", str(target_mpq), str(add_dir), "--directory-in-archive", "subdir"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 1, f"Expected failure but got: {result.returncode}" + assert "--directory-in-archive" in result.stderr + + result = subprocess.run( + [str(binary_path), "add", str(target_mpq), str(add_dir), "--filename-in-archive", "name.txt"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 1, f"Expected failure but got: {result.returncode}" + assert "--filename-in-archive" in result.stderr + finally: + shutil.rmtree(add_dir, ignore_errors=True) + + +# ---- --update flag tests ---- + +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_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", "--overwrite"], + 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" in result.stdout + assert "[~] Skipping unchanged file: dogs.txt" in result.stdout + assert "files added" in result.stdout + assert "files skipped" in result.stdout + 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_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", "--overwrite"], + 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.stdout + assert "[~] Skipping unchanged file: dogs.txt" in result.stdout + assert "1 files added" in result.stdout + assert "1 files skipped" in result.stdout + + 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_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", "--overwrite"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + + result = subprocess.run( + [str(binary_path), "add", str(target_mpq), str(update_dir), "--update", "--overwrite"], + 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.stdout + assert "0 files added" in result.stdout + finally: + shutil.rmtree(update_dir, ignore_errors=True) + + +def test_add_update_single_file_emits_warning(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 = script_dir / "data" / "files" / "cats.txt" + + result = subprocess.run( + [str(binary_path), "add", str(target_mpq), str(test_file), "--update"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert "--update is only meaningful when adding a directory" in result.stderr + + +# ---- stdin tests ---- + +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.stdout + assert "[+] Adding file: stdin_b.txt" in result.stdout + + 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 + + +# ---- Helpers ---- + def create_mpq_archive_for_test(binary_path, script_dir): target_dir = script_dir / "data" / "files" 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", "--version", "1", str(target_dir)], @@ -629,15 +941,17 @@ def verify_archive_file_content(binary_path, test_file, expected_output): 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): +def verify_file_in_mpq_has_content(binary_path, mpq_archive, file_name, expected_content, locale=None): + cmd = [str(binary_path), "read", file_name, str(mpq_archive)] + if locale is not None: + cmd += ["--locale", locale] result = subprocess.run( - [str(binary_path), "read", file_name, str(mpq_archive)], + cmd, 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_content, f"Unexpected output: {output_lines}" diff --git a/test/test_remove.py b/test/test_remove.py index 22a6598..16da4f1 100644 --- a/test/test_remove.py +++ b/test/test_remove.py @@ -15,7 +15,7 @@ def test_remove_target_mpq_does_not_exist(binary_path, generate_locales_mpq_test target_file = script_dir / "does" / "not" / "exist.mpq" result = subprocess.run( - [str(binary_path), "remove", str(test_file), str(target_file)], + [str(binary_path), "remove", str(target_file), str(test_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -37,7 +37,7 @@ def test_remove_target_file_does_not_exist(binary_path, generate_locales_mpq_tes target_file = script_dir / "data" / "mpq_with_many_locales.mpq" result = subprocess.run( - [str(binary_path), "remove", str(test_file), str(target_file)], + [str(binary_path), "remove", str(target_file), str(test_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -85,7 +85,7 @@ def test_remove_file_from_mpq_archive_with_wrong_locale_given( target_file = script_dir / "data" / target_file_name result = subprocess.run( - [str(binary_path), "remove", str(test_file), str(target_file), "--locale", "ptPT"], + [str(binary_path), "remove", str(target_file), str(test_file), "--locale", "ptPT"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -120,7 +120,7 @@ def test_remove_default_locale_file_from_mpq_archive_unique_name(binary_path, ge target_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" result = subprocess.run( - [str(binary_path), "remove", str(test_file), str(target_file)], + [str(binary_path), "remove", str(target_file), str(test_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -162,7 +162,7 @@ def test_remove_files_from_mpq_archive_shared_name(binary_path, generate_locales # Removing without specifying locale means removing using locale 0 = enUS result = subprocess.run( - [str(binary_path), "remove", str(test_file), str(target_file)], + [str(binary_path), "remove", str(target_file), str(test_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -178,7 +178,7 @@ def test_remove_files_from_mpq_archive_shared_name(binary_path, generate_locales result = subprocess.run( - [str(binary_path), "remove", str(test_file), str(target_file), "--locale", "esES"], + [str(binary_path), "remove", str(target_file), str(test_file), "--locale", "esES"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -193,7 +193,7 @@ def test_remove_files_from_mpq_archive_shared_name(binary_path, generate_locales result = subprocess.run( - [str(binary_path), "remove", str(test_file), str(target_file), "--locale", "041D"], + [str(binary_path), "remove", str(target_file), str(test_file), "--locale", "041D"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -230,7 +230,7 @@ def test_remove_files_from_mpq_archive_unique_name(binary_path, generate_mpq_wit # Removing without specifying locale means removing using locale 0 = enUS result = subprocess.run( - [str(binary_path), "remove", str(test_file), str(target_file)], + [str(binary_path), "remove", str(target_file), str(test_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -246,7 +246,7 @@ def test_remove_files_from_mpq_archive_unique_name(binary_path, generate_mpq_wit result = subprocess.run( - [str(binary_path), "remove", str(test_file), str(target_file), "--locale", "041D"], + [str(binary_path), "remove", str(target_file), str(test_file), "--locale", "041D"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -260,6 +260,67 @@ def test_remove_files_from_mpq_archive_unique_name(binary_path, generate_mpq_wit 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.stdout + + 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), "-p", "locale"] From c763e2295911ec0969b7cae24614d6bc5d64a3be Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Mon, 1 Jun 2026 16:50:14 +1200 Subject: [PATCH 02/10] Adding some missing tests --- test/requirements.txt | 4 +- test/test_create.py | 67 ++++++++++++++++++- test/test_extract.py | 112 +++++++++++++++++++++++++++++++ test/test_info.py | 16 +++++ test/test_list.py | 149 ++++++++++++++++++++++++++++++++++++++++++ test/test_read.py | 30 +++++++++ test/test_verify.py | 14 ++++ 7 files changed, 387 insertions(+), 5 deletions(-) diff --git a/test/requirements.txt b/test/requirements.txt index 792348d..8df3271 100644 --- a/test/requirements.txt +++ b/test/requirements.txt @@ -1,2 +1,2 @@ -pytest==9.0.2 -ruff==0.14.10 +pytest==9.0.3 +ruff==0.15.15 diff --git a/test/test_create.py b/test/test_create.py index e75528f..4f98edb 100644 --- a/test/test_create.py +++ b/test/test_create.py @@ -37,7 +37,7 @@ def test_create_mpq_versions(binary_path, generate_test_files): script_dir = Path(__file__).parent target_dir = script_dir / "data" / "files" - for version in [1, 2]: + 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: @@ -54,6 +54,17 @@ def test_create_mpq_versions(binary_path, generate_test_files): 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", "-p", "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): """ @@ -141,6 +152,17 @@ def test_create_mpq_with_weak_signature(binary_path, generate_test_files): 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.stdout, \ + f"Unexpected verify output: {verify_result.stdout}" + def test_create_mpq_already_exists(binary_path, generate_test_files): """ @@ -610,7 +632,7 @@ def test_create_mpq_directory_with_trailing_slash(binary_path, generate_test_fil ) assert expected_output.stat().st_size > 0, "MPQ file is empty" assert not malformed_output.exists(), ( - f"Malformed archive path '{malformed_output}' was created — trailing slash bug is present" + f"Malformed archive path '{malformed_output}' was created, trailing slash bug is present" ) expected_output.unlink(missing_ok=True) @@ -645,7 +667,7 @@ def test_create_mpq_skips_special_files(binary_path, tmp_path): 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 + # Signature type must be None (signature) must not have been ingested info = subprocess.run( [str(binary_path), "info", "-p", "signature-type", str(output_file)], stdout=subprocess.PIPE, @@ -679,3 +701,42 @@ def verify_archive_file_content(binary_path, test_file, 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_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(empty_dir), "-o", str(output_file)], + 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", "-p", "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()}" diff --git a/test/test_extract.py b/test/test_extract.py index 497d165..fd12e73 100644 --- a/test/test_extract.py +++ b/test/test_extract.py @@ -71,6 +71,23 @@ def test_extract_mpq_default_options(binary_path, generate_test_files): 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, \ + f"Unexpected content in cats.txt" + assert dogs_file.read_text(encoding="utf-8") == expected_dogs_content, \ + f"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): """ @@ -116,6 +133,23 @@ def test_extract_mpq_output_directory_specified(binary_path, generate_test_files 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, \ + f"Unexpected content in cats.txt" + assert dogs_file.read_text(encoding="utf-8") == expected_dogs_content, \ + f"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): """ @@ -158,6 +192,17 @@ def test_extract_file_from_mpq_output_directory_specified(binary_path, generate_ 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, \ + f"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): """ @@ -761,3 +806,70 @@ def test_extract_path_traversal_is_blocked(binary_path, generate_path_traversal_ # 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", "--version", "1", str(target_dir), "-o", str(archive_file)], + 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(test_file), str(archive_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 + result = subprocess.run( + [str(binary_path), "extract", "-f", nested_path, "-o", str(output_dir), str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + expected_stdout = {"[*] Extracted: cats.txt"} + output_lines = set(result.stdout.splitlines()) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert output_lines == expected_stdout, f"Unexpected output: {output_lines}" + + extracted_file = output_dir / "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" diff --git a/test/test_info.py b/test/test_info.py index d185d6b..07476dc 100644 --- a/test/test_info.py +++ b/test/test_info.py @@ -77,6 +77,7 @@ def test_info_v1_properties(binary_path): ("header-size", "32"), ("archive-size", "1381"), ("file-count", "5"), + ("max-files", "64"), ("signature-type", "None"), ] @@ -109,6 +110,7 @@ def test_info_v2_properties(binary_path): ("header-size", "44"), ("archive-size", "1377"), ("file-count", "5"), + ("max-files", "64"), ("signature-type", "None"), ] @@ -145,3 +147,17 @@ def test_info_v1_invalid_property(binary_path): ) 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}" diff --git a/test/test_list.py b/test/test_list.py index 8d3fd1c..05ba44c 100644 --- a/test/test_list.py +++ b/test/test_list.py @@ -358,3 +358,152 @@ def test_list_mpq_providing_complete_external_listfile(binary_path, generate_mpq 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 = { + " 2 dogs.txt", + " 4 (attributes)", + " 3 (listfile)", + " 1 bytes", + " 0 cats.txt", + } + + result = subprocess.run( + [str(binary_path), "list", "-a", "-d", str(test_file), "-p", "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 = { + " 53 dogs.txt", + " 99 (attributes)", + " 76 (listfile)", + " 43 bytes", + " 20 cats.txt", + } + + result = subprocess.run( + [str(binary_path), "list", "-a", "-d", str(test_file), "-p", "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 dogs.txt", + " (attributes)", + " (listfile)", + "2025-07-29 14:31:00 bytes", + "2025-07-29 14:31:00 cats.txt", + } + + result = subprocess.run( + [str(binary_path), "list", "-a", "-d", str(test_file), "-p", "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 = { + "a073c614 dogs.txt", + "50e315dd (attributes)", + "2d2f0b11 (listfile)", + "eaa753f9 bytes", + "935a7772 cats.txt", + } + + result = subprocess.run( + [str(binary_path), "list", "-a", "-d", str(test_file), "-p", "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 dogs.txt", + "enUS (attributes)", + "enUS (listfile)", + "enUS bytes", + "enUS cats.txt", + } + + result = subprocess.run( + [str(binary_path), "list", "-a", "-d", str(test_file), "-p", "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}" diff --git a/test/test_read.py b/test/test_read.py index 6dafe92..e72a5bf 100644 --- a/test/test_read.py +++ b/test/test_read.py @@ -234,3 +234,33 @@ def test_read_file_from_mpq_with_wrong_locale_argument_and_no_default_locale(bin 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", "cats.txt", str(target_file)], + 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", "does-not-exist.txt", str(test_file)], + 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}" diff --git a/test/test_verify.py b/test/test_verify.py index 1bd493a..1196f93 100644 --- a/test/test_verify.py +++ b/test/test_verify.py @@ -120,3 +120,17 @@ def test_verify_strong_signature_patch(binary_path, download_test_files): 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}" From 0ab5dec7656348cd092595e53e961e7f89065a0d Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Mon, 1 Jun 2026 16:50:39 +1200 Subject: [PATCH 03/10] Updated docs for add and remove subcommand --- docs/commands/add.md | 118 +++++++++++++++++++++++++++++++--------- docs/commands/remove.md | 37 ++++++++++--- 2 files changed, 122 insertions(+), 33 deletions(-) diff --git a/docs/commands/add.md b/docs/commands/add.md index b5534c5..07b2e0e 100644 --- a/docs/commands/add.md +++ b/docs/commands/add.md @@ -1,69 +1,135 @@ # add -Add a file to an existing MPQ archive. +Add one or more files to an existing MPQ archive. -## Add a file to an existing archive +The archive is always the first positional argument, followed by one or more files or directories. +All inputs are processed in a single open/close cycle, which is significantly faster than +calling `add` once per file. -Add a local file to an already existing MPQ archive. +## Add a single file ```bash $ echo "For The Horde" > fth.txt -$ mpqcli add fth.txt wow-patch.mpq +$ mpqcli add wow-patch.mpq fth.txt [+] Adding file: fth.txt ``` -Alternatively, you can add a file under a specific file name using the `-f` or `--filename-in-archive` argument. +## Add multiple files at once + +Pass more than one file path after the archive. The archive is opened once for all files. + +```bash +$ mpqcli add wow-patch.mpq fth.txt fta.txt fts.txt +[+] Adding file: fth.txt +[+] Adding file: fta.txt +[+] Adding file: fts.txt +``` + +## Add files from stdin + +Pass `-` as the file argument to read paths from standard input. This works with `find`, +`ls`, or any other tool that produces file paths. + +```bash +$ find . -name "*.blp" | mpqcli add wow-patch.mpq - +[+] Adding file: textures\Creature\Bear\Bear.blp +[+] Adding file: textures\Creature\Wolf\Wolf.blp +... +``` + +## Add a directory + +Pass a directory path to recursively add all files within it. The directory structure is +preserved relative to the directory root. + +```bash +$ mpqcli add wow-patch.mpq textures/ +[+] Adding file: Creature\Bear\Bear.blp +[+] Adding file: Creature\Wolf\Wolf.blp +``` + +Use `--path` to add a prefix to every archived path: + +```bash +$ mpqcli add wow-patch.mpq textures/ --path textures +[+] Adding file: textures\Creature\Bear\Bear.blp +[+] Adding file: textures\Creature\Wolf\Wolf.blp +``` + +## Skip unchanged files with --update + +When adding a directory, the `--update` flag skips any file whose on-disk size matches the +size already stored in the archive. This is useful for incremental updates where only +changed files need to be re-added. ```bash -$ echo "For The Alliance" > fta.txt -$ mpqcli add fta.txt wow-patch.mpq --filename-in-archive "alliance.txt" +$ mpqcli add wow-patch.mpq textures/ --update --overwrite +[~] Skipping unchanged file: Creature\Bear\Bear.blp +[+] Adding file: Creature\Wolf\Wolf.blp +[*] 1 files added, 1 files skipped +``` + +Note: the skip check is size-based only. Files with the same size but different content +are not detected as changed. If precise change detection matters, pass `--overwrite` +without `--update` to unconditionally replace every file. + +## Control where a single file is stored + +These options apply to single-file adds only. + +Add a file under a specific name using `-f` or `--filename-in-archive`: + +```bash +$ mpqcli add wow-patch.mpq fta.txt --filename-in-archive "alliance.txt" [+] Adding file: alliance.txt ``` -Alternatively, you can add a file to a specific subdirectory using the `-d` or `--directory-in-archive` argument. +Add a file into a subdirectory using `-d` or `--directory-in-archive`: ```bash -$ echo "For The Swarm" > fts.txt -$ mpqcli add fts.txt wow-patch.mpq --directory-in-archive texts +$ mpqcli add wow-patch.mpq fts.txt --directory-in-archive texts [+] Adding file: texts\fts.txt ``` -Alternatively, you can add a file under a specific directory and filename using the `-p` or `--path` argument. +Specify both directory and filename in one step using `-p` or `--path`: ```bash -$ echo "For The Swarm" > fts.txt -$ mpqcli add fts.txt wow-patch.mpq --path "texts\swarm.txt" +$ mpqcli add wow-patch.mpq fts.txt --path "texts\swarm.txt" [+] Adding file: texts\swarm.txt ``` -To overwrite a file in an MPQ archive, set the `-w` or `--overwrite` flag: +## Overwrite existing files + +Without `--overwrite`, any file that already exists in the archive is skipped: ```bash -$ echo "For The Horde" > allegiance.txt -$ mpqcli add allegiance.txt wow-patch.mpq -[+] Adding file: allegiance.txt -$ echo "For The Alliance" > allegiance.txt -$ mpqcli add allegiance.txt wow-patch.mpq +$ mpqcli add wow-patch.mpq allegiance.txt [!] File already exists in MPQ archive: allegiance.txt - Skipping... -$ mpqcli add allegiance.txt wow-patch.mpq --overwrite +``` + +Set `-w` or `--overwrite` to replace it: + +```bash +$ mpqcli add wow-patch.mpq allegiance.txt --overwrite [+] File already exists in MPQ archive: allegiance.txt - Overwriting... [+] Adding file: allegiance.txt ``` -## Add a file to an MPQ archive with a given locale +## Add with a locale -Use the `--locale` argument to specify the locale that the added file will have in the archive. Note that subsequent added files will have the default locale unless the `--locale` argument is specified again. +Use `--locale` to store the file under a specific locale. Files added without `--locale` +use the default locale. ```bash -$ mpqcli add allianz.txt wow-patch.mpq --locale deDE +$ mpqcli add wow-patch.mpq allianz.txt --locale deDE [+] Adding file for locale deDE: allianz.txt ``` -## Add a file with game-specific properties +## Add with game-specific compression -Target a specific game version by using the `-g` or `--game` argument. This will automatically set the correct encryption rules and MPQ flags, although they can be overridden. +Use `-g` or `--game` to apply the compression and encryption rules for a specific game. ```bash -$ mpqcli add khwhat1.wav archive.mpq --game wc2 +$ mpqcli add archive.mpq khwhat1.wav --game warcraft2 [+] Adding file: khwhat1.wav ``` diff --git a/docs/commands/remove.md b/docs/commands/remove.md index 73bb009..d80f4f8 100644 --- a/docs/commands/remove.md +++ b/docs/commands/remove.md @@ -1,21 +1,44 @@ # remove -Remove a file from an existing MPQ archive. +Remove one or more files from an existing MPQ archive. -## Remove a file from an existing archive +The archive is always the first positional argument, followed by one or more archive paths +to remove. All removals happen in a single open/close cycle. -Remove a file from an existing MPQ archive. +## Remove a single file ```bash -$ mpqcli remove fth.txt wow-patch.mpq +$ mpqcli remove wow-patch.mpq fth.txt [-] Removing file: fth.txt ``` -## Remove a file from an MPQ archive with a given locale +## Remove multiple files at once -Use the `--locale` argument to specify the locale of the file to be removed. +Pass more than one archive path after the archive argument: ```bash -$ mpqcli remove alianza.txt wow-patch.mpq --locale esES +$ mpqcli remove wow-patch.mpq fth.txt fta.txt fts.txt +[-] Removing file: fth.txt +[-] Removing file: fta.txt +[-] Removing file: fts.txt +``` + +## Remove files from stdin + +Pass `-` to read archive paths from standard input: + +```bash +$ echo -e "fth.txt\nfta.txt" | mpqcli remove wow-patch.mpq - +[-] Removing file: fth.txt +[-] Removing file: fta.txt +``` + +## Remove a file with a given locale + +Use `--locale` to remove only the copy stored under a specific locale. Without `--locale`, +the file stored under the default locale is removed. + +```bash +$ mpqcli remove wow-patch.mpq alianza.txt --locale esES [-] Removing file for locale esES: alianza.txt ``` From 607acec0dbc6cd5c618fe9fbc451a8688554c1cc Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 2 Jun 2026 19:18:18 +1200 Subject: [PATCH 04/10] Fixed broken tests --- README.md | 2 +- src/commands.cpp | 5 +++++ src/main.cpp | 4 ++-- test/test_add.py | 7 ++++--- test/test_extract.py | 2 +- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 873c3cf..d6f733c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![Release Version](https://img.shields.io/github/v/release/TheGrayDot/mpqcli?style=flat) -![Release downloads](https://img.shields.io/github/downloads/thegraydot/mpqcli/total?label=release_downloads) ![Package downloads](https://img.shields.io/badge/package_downloads-647-green) +![Release downloads](https://img.shields.io/github/downloads/thegraydot/mpqcli/total?label=release_downloads) ![Package downloads](https://img.shields.io/badge/package_downloads-845-green) A command-line tool to create, add, remove, list, extract, read, and verify MPQ archives using the [StormLib library](https://github.com/ladislav-zezula/StormLib). diff --git a/src/commands.cpp b/src/commands.cpp index 056ff02..c0332e5 100644 --- a/src/commands.cpp +++ b/src/commands.cpp @@ -2,6 +2,7 @@ #include #include +#include #include @@ -240,8 +241,12 @@ int HandleRemove(const std::vector &files, const std::string &targe } LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : defaultLocale; + std::unordered_set seen; int overallResult = 0; for (const auto &f : files) { + if (!seen.insert(f).second) { + continue; + } int result = RemoveFile(hArchive, f, lcid); if (result != 0) { overallResult = result; diff --git a/src/main.cpp b/src/main.cpp index b392bdd..0e76713 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -22,7 +22,6 @@ int main(int argc, char **argv) { // These are reused in multiple subcommands std::string baseTarget; // all subcommands std::string baseFile; // extract, read - std::optional basePath; // add std::optional baseLocale; // create, add, remove, extract, read std::optional baseNameInArchive; // add, create std::optional baseOutput; // create, extract @@ -31,7 +30,8 @@ int main(int argc, char **argv) { // CLI: info std::optional infoProperty; // CLI: add - std::optional baseDirInArchive; // add + std::optional basePath; + std::optional baseDirInArchive; bool addOverwrite = false; bool addUpdate = false; std::vector addFiles; diff --git a/test/test_add.py b/test/test_add.py index 1c0c63c..92fc557 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -660,9 +660,10 @@ def test_add_directory_with_path_prefix(binary_path, generate_test_files): stderr=subprocess.PIPE, text=True ) - assert "textures\\sub\\a.txt" in list_result.stdout - assert "textures\\b.txt" in list_result.stdout - assert "sub\\a.txt" not in list_result.stdout + 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) diff --git a/test/test_extract.py b/test/test_extract.py index fd12e73..9b24471 100644 --- a/test/test_extract.py +++ b/test/test_extract.py @@ -848,7 +848,7 @@ def test_extract_nested_file_from_mpq(binary_path, generate_test_files): test_file = script_dir / "data" / "files" / "cats.txt" nested_path = "texts\\cats.txt" result = subprocess.run( - [str(binary_path), "add", str(test_file), str(archive_file), "--path", nested_path], + [str(binary_path), "add", str(archive_file), str(test_file), "--path", nested_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True From 5682d02fd28904e7555a8dab3056444af659648e Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 2 Jun 2026 19:36:16 +1200 Subject: [PATCH 05/10] Fixed inconsistent organisation name formatting --- CONTRIBUTING.md | 2 +- README.md | 4 ++-- docs/building.md | 4 ++-- docs/commands/about.md | 2 +- docs/contributing.md | 2 +- docs/installation.md | 2 +- src/commands.cpp | 11 +++++++---- 7 files changed, 15 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 89453f0..3225ef2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ If you are unsure whether a feature fits the project, or whether an existing too Clone the repository and initialise submodules: ``` -git clone https://github.com/TheGrayDot/mpqcli.git +git clone https://github.com/thegraydot/mpqcli.git cd mpqcli git submodule update --init --recursive ``` diff --git a/README.md b/README.md index d6f733c..20d2b8f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # mpqcli -![Build Status](https://img.shields.io/github/actions/workflow/status/TheGrayDot/mpqcli/tag.yml?style=flat) ![Test Status](https://img.shields.io/github/actions/workflow/status/TheGrayDot/mpqcli/tag.yml?style=flat&label=test) +![Build Status](https://img.shields.io/github/actions/workflow/status/thegraydot/mpqcli/tag.yml?style=flat) ![Test Status](https://img.shields.io/github/actions/workflow/status/thegraydot/mpqcli/tag.yml?style=flat&label=test) -![Release Version](https://img.shields.io/github/v/release/TheGrayDot/mpqcli?style=flat) +![Release Version](https://img.shields.io/github/v/release/thegraydot/mpqcli?style=flat) ![Release downloads](https://img.shields.io/github/downloads/thegraydot/mpqcli/total?label=release_downloads) ![Package downloads](https://img.shields.io/badge/package_downloads-845-green) diff --git a/docs/building.md b/docs/building.md index 4d23b07..da554d3 100644 --- a/docs/building.md +++ b/docs/building.md @@ -10,7 +10,7 @@ ## Linux ```bash -$ git clone --recursive https://github.com/TheGrayDot/mpqcli.git +$ git clone --recursive https://github.com/thegraydot/mpqcli.git $ cd mpqcli $ cmake -B build $ cmake --build build @@ -21,7 +21,7 @@ The `mpqcli` binary will be available in: `./build/bin/mpqcli` ## Windows ```bash -$ git clone --recursive https://github.com/TheGrayDot/mpqcli.git +$ git clone --recursive https://github.com/thegraydot/mpqcli.git $ cd mpqcli $ cmake -B build $ cmake --build build --config Release diff --git a/docs/commands/about.md b/docs/commands/about.md index bf9393d..7e36b2b 100644 --- a/docs/commands/about.md +++ b/docs/commands/about.md @@ -8,7 +8,7 @@ Name: mpqcli Version: 0.9.8-041480a92e698514d7938426587e93582b336b7d Author: Thomas Laurenson License: MIT -GitHub: https://github.com/TheGrayDot/mpqcli +GitHub: https://github.com/thegraydot/mpqcli Dependencies: - StormLib (https://github.com/ladislav-zezula/StormLib) - CLI11 (https://github.com/CLIUtils/CLI11) diff --git a/docs/contributing.md b/docs/contributing.md index 72fa163..2e520c6 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -13,7 +13,7 @@ If you are unsure whether a feature fits the project, or whether an existing too Clone the repository and initialise submodules: ``` -git clone https://github.com/TheGrayDot/mpqcli.git +git clone https://github.com/thegraydot/mpqcli.git cd mpqcli git submodule update --init --recursive ``` diff --git a/docs/installation.md b/docs/installation.md index cc72796..d2b4584 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -16,7 +16,7 @@ Microsoft Windows: PS> irm https://raw.githubusercontent.com/thegraydot/mpqcli/main/scripts/install.ps1 | iex ``` -Check the [latest release with binaries](https://github.com/TheGrayDot/mpqcli/releases). +Check the [latest release with binaries](https://github.com/thegraydot/mpqcli/releases). ## Docker Image diff --git a/src/commands.cpp b/src/commands.cpp index c0332e5..3c3d8eb 100644 --- a/src/commands.cpp +++ b/src/commands.cpp @@ -24,7 +24,7 @@ int HandleAbout() { std::cout << "Version: " << MPQCLI_VERSION << "-" << GIT_COMMIT_HASH << std::endl; std::cout << "Author: Thomas Laurenson" << std::endl; std::cout << "License: MIT" << std::endl; - std::cout << "GitHub: https://github.com/TheGrayDot/mpqcli" << std::endl; + std::cout << "GitHub: https://github.com/thegraydot/mpqcli" << std::endl; std::cout << "Dependencies:" << std::endl; std::cout << " - StormLib (https://github.com/ladislav-zezula/StormLib)" << std::endl; std::cout << " - CLI11 (https://github.com/CLIUtils/CLI11)" << std::endl; @@ -157,7 +157,8 @@ int HandleAdd(const std::vector &files, const std::string &target, } bool multipleInputs = files.size() > 1; - if ((hasDirectory || multipleInputs) && (dirInArchive.has_value() || nameInArchive.has_value())) { + if ((hasDirectory || multipleInputs) && + (dirInArchive.has_value() || nameInArchive.has_value())) { std::cerr << "[!] --directory-in-archive and --filename-in-archive are only valid when " "adding a single file." << std::endl; @@ -165,7 +166,8 @@ int HandleAdd(const std::vector &files, const std::string &target, return 1; } if (multipleInputs && path.has_value()) { - std::cerr << "[!] --path is only valid when adding a single file or directory." << std::endl; + std::cerr << "[!] --path is only valid when adding a single file or directory." + << std::endl; CloseMpqArchive(hArchive); return 1; } @@ -195,7 +197,8 @@ int HandleAdd(const std::vector &files, const std::string &target, addOverrides.dwCompressionNext = static_cast(fileDwCompressionNext); if (update && !hasDirectory) { - std::cerr << "[!] Warning: --update is only meaningful when adding a directory" << std::endl; + std::cerr << "[!] Warning: --update is only meaningful when adding a directory" + << std::endl; } for (const auto &f : files) { From 2562e36e204bade34594a6f6416dd558e3341d6f Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 2 Jun 2026 19:44:26 +1200 Subject: [PATCH 06/10] Tidied up main source file --- src/main.cpp | 62 +++++++++++++++++++--------------------------------- 1 file changed, 22 insertions(+), 40 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 0e76713..1855f37 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -15,23 +15,25 @@ int main(int argc, char **argv) { "A command line tool to create, add, remove, list, extract, read, and verify MPQ archives " "using the StormLib library"}; - // Require at least one subcommand app.require_subcommand(1); // CLI: base // These are reused in multiple subcommands std::string baseTarget; // all subcommands std::string baseFile; // extract, read - std::optional baseLocale; // create, add, remove, extract, read + std::optional baseLocale; // add, create, extract, read, remove std::optional baseNameInArchive; // add, create std::optional baseOutput; // create, extract - std::optional baseListfileName; // list, extract - std::optional baseGameProfile; // create, add + std::optional baseListfileName; // extract, list + std::optional baseGameProfile; // add, create + int64_t fileDwFlags = -1; // add, create + int64_t fileDwCompression = -1; // add, create + int64_t fileDwCompressionNext = -1; // add, create // CLI: info std::optional infoProperty; // CLI: add - std::optional basePath; - std::optional baseDirInArchive; + std::optional addBasePath; + std::optional addBaseDirInArchive; bool addOverwrite = false; bool addUpdate = false; std::vector addFiles; @@ -49,10 +51,6 @@ int main(int argc, char **argv) { int64_t createFileFlags2 = -1; int64_t createFileFlags3 = -1; int64_t createAttrFlags = -1; - // CLI: add and create (compression overrides for files being added) - int64_t fileDwFlags = -1; - int64_t fileDwCompression = -1; - int64_t fileDwCompressionNext = -1; // CLI: list bool listDetailed = false; bool listAll = false; @@ -60,30 +58,15 @@ int main(int argc, char **argv) { // CLI: verify bool verifyPrintSignature = false; - // clang-format off: preserve vertical alignment of string set initialisers + // clang-format off -- preserve vertical alignment of string set initialisers std::set validInfoProperties = { - "format-version", - "header-offset", - "header-size", - "archive-size", - "file-count", - "max-files", - "signature-type", + "format-version", "header-offset", "header-size", "archive-size", + "file-count", "max-files", "signature-type", }; std::set validFileListProperties = { - "hash-index", - "name-hash1", - "name-hash2", - "name-hash3", - "locale", - "file-index", - "byte-offset", - "file-time", - "file-size", - "compressed-size", - "flags", - "encryption-key", - "encryption-key-raw", + "hash-index", "name-hash1", "name-hash2", "name-hash3", "locale", + "file-index", "byte-offset", "file-time", "file-size", "compressed-size", + "flags", "encryption-key", "encryption-key-raw", }; // clang-format on @@ -157,9 +140,9 @@ int main(int argc, char **argv) { "Files or directories to add; pass - to read paths from stdin") ->required() ->expected(-1); - add->add_option("-p,--path", basePath, + add->add_option("-p,--path", addBasePath, "Archive path for a single file, or prefix for a directory add"); - add->add_option("-d,--directory-in-archive", baseDirInArchive, + add->add_option("-d,--directory-in-archive", addBaseDirInArchive, "Directory to put file inside within MPQ archive (single file only)"); add->add_option("-f,--filename-in-archive", baseNameInArchive, "Filename inside MPQ archive (single file only)"); @@ -185,8 +168,9 @@ int main(int argc, char **argv) { remove->add_option("archive", baseTarget, "Target MPQ archive") ->required() ->check(CLI::ExistingFile); - remove->add_option("files", removeFiles, - "Archive paths of files to remove; pass - to read paths from stdin") + remove + ->add_option("files", removeFiles, + "Archive paths of files to remove; pass - to read paths from stdin") ->required() ->expected(-1); remove->add_option("--locale", baseLocale, "Locale of file to remove")->check(LocaleValid); @@ -232,7 +216,6 @@ int main(int argc, char **argv) { ->check(CLI::ExistingFile); verify->add_flag("-p,--print", verifyPrintSignature, "Print the digital signature (in hex)"); - // Parse command line arguments and handle errors try { app.parse(argc, argv); } catch (const CLI::ParseError &e) { @@ -241,7 +224,6 @@ int main(int argc, char **argv) { std::cout << app.help() << std::endl; return 0; } - // For other errors, use the default error handling return app.exit(e); } @@ -277,9 +259,9 @@ int main(int argc, char **argv) { resolvedAddFiles.push_back(f); } } - return HandleAdd(resolvedAddFiles, baseTarget, basePath, baseDirInArchive, baseNameInArchive, - addOverwrite, addUpdate, baseLocale, baseGameProfile, fileDwFlags, - fileDwCompression, fileDwCompressionNext); + return HandleAdd(resolvedAddFiles, baseTarget, addBasePath, addBaseDirInArchive, + baseNameInArchive, addOverwrite, addUpdate, baseLocale, baseGameProfile, + fileDwFlags, fileDwCompression, fileDwCompressionNext); } if (app.got_subcommand(remove)) { From b3e78ac4475f59c98e91ae336fc3dd347cb839d7 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 2 Jun 2026 19:45:55 +1200 Subject: [PATCH 07/10] Refactored Makefile --- .clang-tidy | 1 + .github/workflows/lint.yml | 9 +- .github/workflows/main.yml | 1 + Makefile | 188 +++++++++++++++++++++---------------- src/mpq.cpp | 11 ++- 5 files changed, 116 insertions(+), 94 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index baa8413..4ac2c0c 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -10,6 +10,7 @@ Checks: > cppcoreguidelines-no-malloc, -cppcoreguidelines-owning-memory, performance-unnecessary-value-param, + readability-identifier-naming, readability-inconsistent-declaration-parameter-name, readability-container-size-empty WarningsAsErrors: "*" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bfc6e5d..53acb34 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,11 +15,6 @@ jobs: - name: Install clang tools run: make install_clang_tools - - name: Check formatting - run: make lint + - run: make fmt_check - - name: Generate compile_commands.json - run: make build_lint/compile_commands.json - - - name: Run clang-tidy - run: make lint_cpp + - run: make lint_cpp diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2ac712d..64e43bd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,6 +6,7 @@ on: - "main" paths: - ".github/workflows/**" + - "Makefile" - ".clang-format" - ".clang-tidy" - "src/**" diff --git a/Makefile b/Makefile index 2699ac6..25ccde7 100644 --- a/Makefile +++ b/Makefile @@ -1,149 +1,173 @@ +SHELL := /bin/bash + CMAKE_BUILD_TYPE := Release -BUILD_MPQCLI := ON -CLANG_VERSION := 18 -VERSION := $(shell awk '/project\(MPQCLI VERSION/ {gsub(/\)/, "", $$3); print $$3}' CMakeLists.txt) -README := README.md -PACKAGE_URL := https://github.com/TheGrayDot/mpqcli/pkgs/container/mpqcli - -GCC_INSTALL_DIR := $(shell dirname "$(shell gcc -print-libgcc-file-name)") - -.PHONY: help \ - setup \ - build_linux build_windows build_clean build_lint_clean \ - docker_musl_build docker_musl_run docker_glibc_build docker_glibc_run \ - test_create_venv test_mpqcli test_clean test_lint \ - lint_format lint_format_fix lint_cpp lint \ - clean \ - bump_stormlib bump_cli11 bump_submodules \ - fetch_downloads tag_release - -## Show this help menu -help: - @awk 'BEGIN {FS = ":"; printf "\nUsage:\n make \033[36m\033[0m\n\nTargets:\n"} \ - /^## / {desc = substr($$0, 4); next} \ - /^[a-zA-Z0-9_-]+:/ {if (desc) printf " \033[36m%-22s\033[0m %s\n", $$1, desc; desc = ""; next} \ - {desc = ""}' $(MAKEFILE_LIST) - -## Install clang lint dependencies -install_clang_tools: - sudo apt-get install -y clang-format-$(CLANG_VERSION) clang-tidy-$(CLANG_VERSION) +BUILD_MPQCLI := ON +CLANG_VERSION := 18 +VERSION := $(shell awk '/project\(MPQCLI VERSION/ {gsub(/\)/, "", $$3); print $$3}' CMakeLists.txt) +README := README.md +PACKAGE_URL := https://github.com/thegraydot/mpqcli/pkgs/container/mpqcli +GCC_INSTALL_DIR := $(shell dirname "$(shell gcc -print-libgcc-file-name)") +TAG ?= $(shell git describe --tags --abbrev=0 2>/dev/null) + +.PHONY: help +help: ## Show this help message + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " %-22s %s\n", $$1, $$2}' # BUILD -## Build for Linux using cmake -build_linux: +.PHONY: install_clang_tools +install_clang_tools: ## Install clang lint dependencies + sudo apt-get install -y clang-format-$(CLANG_VERSION) clang-tidy-$(CLANG_VERSION) + +.PHONY: configure +configure: ## Configure cmake build (debug, with compile_commands.json) + cmake -B build \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DBUILD_MPQCLI=$(BUILD_MPQCLI) \ + -DCMAKE_CXX_COMPILER=clang++-$(CLANG_VERSION) \ + -DCMAKE_CXX_FLAGS="--gcc-install-dir=$(GCC_INSTALL_DIR)" + +build/compile_commands.json: CMakeLists.txt src/CMakeLists.txt + cmake -B build \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DBUILD_MPQCLI=$(BUILD_MPQCLI) \ + -DCMAKE_CXX_COMPILER=clang++-$(CLANG_VERSION) \ + -DCMAKE_CXX_FLAGS="--gcc-install-dir=$(GCC_INSTALL_DIR)" + +.PHONY: build +build: ## Build via cmake + cmake --build build + +.PHONY: build_linux +build_linux: ## Build for Linux using cmake cmake -B build \ -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \ -DBUILD_MPQCLI=$(BUILD_MPQCLI) cmake --build build -## Build for Windows using cmake -build_windows: +.PHONY: build_windows +build_windows: ## Build for Windows using cmake cmake -B build \ -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \ -DBUILD_MPQCLI=$(BUILD_MPQCLI) cmake --build build --config $(CMAKE_BUILD_TYPE) -## Remove cmake build directory -build_clean: +.PHONY: build_clean +build_clean: ## Remove cmake build directory rm -rf build -## Generate compile_commands.json for clang-tidy -build_lint/compile_commands.json: CMakeLists.txt src/CMakeLists.txt - cmake -B build_lint \ - -DCMAKE_BUILD_TYPE=Debug \ - -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ - -DBUILD_MPQCLI=ON \ - -DCMAKE_CXX_COMPILER=clang++-$(CLANG_VERSION) \ - -DCMAKE_CXX_FLAGS="--gcc-install-dir=$(GCC_INSTALL_DIR)" - -## Remove cmake lint build directory -build_lint_clean: - rm -rf build_lint - # DOCKER -## Build Docker image using musl -docker_musl_build: +.PHONY: docker_musl_build +docker_musl_build: ## Build Docker image using musl docker build -t mpqcli:$(VERSION) -f Dockerfile.musl . -## Run the musl Docker image -docker_musl_run: +.PHONY: docker_musl_run +docker_musl_run: ## Run the musl Docker image @docker run -it mpqcli:$(VERSION) version -## Build Docker image using glibc -docker_glibc_build: +.PHONY: docker_glibc_build +docker_glibc_build: ## Build Docker image using glibc docker build -t mpqcli:$(VERSION) -f Dockerfile.glibc . -## Run the glibc Docker image -docker_glibc_run: +.PHONY: docker_glibc_run +docker_glibc_run: ## Run the glibc Docker image @docker run -it mpqcli:$(VERSION) version # TEST -## Create Python venv and install test dependencies -test_create_venv: +.PHONY: test +test: build test_mpqcli ## Run test suite (builds binary first) + +.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 -## Run pytest test suite -test_mpqcli: +.PHONY: test_mpqcli +test_mpqcli: ## Run pytest test suite . ./.venv/bin/activate && \ python3 -m pytest test -s -## Remove test data directory -test_clean: +.PHONY: test_clean +test_clean: ## Remove test data directory rm -rf test/data -## Run ruff linter on test directory -test_lint: +.PHONY: test_lint +test_lint: ## Run ruff linter on test directory . ./.venv/bin/activate && \ ruff check ./test # LINT -## Check C++ formatting with clang-format -lint_format: +.PHONY: fmt_check +fmt_check: ## Check C++ formatting with clang-format find src \( -name "*.cpp" -o -name "*.h" \) \ | xargs clang-format-$(CLANG_VERSION) --dry-run --Werror -## Auto-fix C++ formatting with clang-format -lint_format_fix: +.PHONY: fmt +fmt: ## Auto-fix C++ formatting with clang-format find src \( -name "*.cpp" -o -name "*.h" \) \ | xargs clang-format-$(CLANG_VERSION) -i -## Run clang-tidy static analysis -lint_cpp: build_lint/compile_commands.json +.PHONY: lint_cpp +lint_cpp: build/compile_commands.json ## Run clang-tidy static analysis clang-tidy-$(CLANG_VERSION) \ - --quiet -p build_lint --header-filter="$(CURDIR)/src/.*" src/*.cpp + --quiet -p build --header-filter="$(CURDIR)/src/.*" src/*.cpp + +.PHONY: lint +lint: fmt_check lint_cpp ## Run all C++ linters -## Run all C++ linters -lint: lint_format lint_cpp +.PHONY: ci +ci: fmt_check lint_cpp test ## Run all CI checks locally # CLEAN -## Remove all build and test artifacts -clean: build_clean build_lint_clean test_clean +.PHONY: clean +clean: build_clean test_clean ## Remove all build and test artifacts # SUBMODULES -## Bump StormLib submodule to latest remote -bump_stormlib: +.PHONY: bump_stormlib +bump_stormlib: ## Bump StormLib submodule to latest remote @read -rp "[*] Bump StormLib? (y/N) " yn; \ case $$yn in \ [yY] ) git submodule update --init --remote extern/StormLib;; \ * ) echo "[*] Skipping...";; \ esac -## Bump CLI11 submodule to latest remote -bump_cli11: +.PHONY: bump_cli11 +bump_cli11: ## Bump CLI11 submodule to latest remote @read -rp "[*] Bump CLI11? (y/N) " yn; \ case $$yn in \ [yY] ) git submodule update --init --remote extern/CLI11;; \ * ) echo "[*] Skipping...";; \ esac -## Bump all submodules to latest remote -bump_submodules: bump_stormlib bump_cli11 +.PHONY: bump_submodules +bump_submodules: bump_stormlib bump_cli11 ## Bump all submodules to latest remote + +# GET +.PHONY: get_project_version +get_project_version: ## Print the project version from CMakeLists.txt + @grep -oE 'VERSION [0-9]+\.[0-9]+\.[0-9]+' CMakeLists.txt | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' + +.PHONY: get_changelog +get_changelog: ## Print release notes for TAG to stdout (default: latest tag; override with TAG=v1.0.0) + @if [ -z "$(TAG)" ]; then \ + echo "Error: no tag resolved. Create a git tag or pass TAG=v1.0.0" >&2; \ + exit 1; \ + fi + @notes=$$(awk -v tag="$(TAG)" \ + '/^## /{if(found)exit; if(index($$0,"## "tag" ")==1 || $$0=="## "tag)found=1; next} found{print}' \ + CHANGELOG.md); \ + if [ -z "$$notes" ]; then \ + echo "Error: no CHANGELOG entry found for $(TAG)" >&2; \ + exit 1; \ + fi; \ + echo "$$notes" # RELEASE -## Fetch package downloads and update README.md badge -fetch_downloads: +.PHONY: fetch_downloads +fetch_downloads: ## Fetch package downloads and update README.md badge @DOWNLOADS=$$(curl -s "$(PACKAGE_URL)" \ | grep -A2 "Total downloads" \ | grep -o '

[0-9]*

' \ diff --git a/src/mpq.cpp b/src/mpq.cpp index f460da7..26c481c 100644 --- a/src/mpq.cpp +++ b/src/mpq.cpp @@ -180,8 +180,8 @@ HANDLE CreateMpqArchive(const std::string &outputArchiveName, const uint32_t fil } int AddFiles(HANDLE hArchive, const std::string &inputPath, const std::string &pathPrefix, - LCID locale, const GameRules &gameRules, - const CompressionSettingsOverrides &overrides, bool overwrite, bool update) { + LCID locale, const GameRules &gameRules, const CompressionSettingsOverrides &overrides, + bool overwrite, bool update) { fs::path targetPath = fs::path(inputPath); int filesAdded = 0; @@ -215,7 +215,8 @@ int AddFiles(HANDLE hArchive, const std::string &inputPath, const std::string &p SFileCloseFile(hFile); uintmax_t diskSize = fs::file_size(entry.path()); if (diskSize == static_cast(archivedSize)) { - std::cout << "[~] Skipping unchanged file: " << archiveFilePath << std::endl; + std::cout << "[~] Skipping unchanged file: " << archiveFilePath + << std::endl; filesSkipped++; continue; } @@ -225,8 +226,8 @@ int AddFiles(HANDLE hArchive, const std::string &inputPath, const std::string &p } } - int result = - AddFile(hArchive, entry.path(), archiveFilePath, locale, gameRules, overrides, overwrite); + int result = AddFile(hArchive, entry.path(), archiveFilePath, locale, gameRules, overrides, + overwrite); if (result == 0) { filesAdded++; } From 947189328275dcd2d5c5205dd9caee95ea601e91 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 2 Jun 2026 19:51:40 +1200 Subject: [PATCH 08/10] Updated changelog --- .github/workflows/docs.yml | 4 ++-- CHANGELOG.md | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a477fcc..446d027 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -37,7 +37,7 @@ jobs: run: mdbook build - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: book/ @@ -50,4 +50,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d2d57d..27357b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog -## 0.9.10 - 2026-04-27 +## 0.9.10 - 2026-06-02 + +### Added + +- Support passing multiple files to the add and remove subcommands +- Support reading file paths from stdin in the add and remove subcommands +- Support adding entire directories with the add subcommand +- Add an update flag to the add subcommand to skip files whose archived size matches the on-disk size ### Fixed @@ -12,6 +19,11 @@ - Docker glibc image updated to ubuntu:24.04 +### Changed + +- The path flag on add now acts as an archive path prefix when a directory is given +- The directory-in-archive and filename-in-archive flags are now restricted to single-file add operations + ## 0.9.9 - 2026-04-05 ### Fixed From 89db9013c0e5d35aca1bb6a575bf73b1897e6ece Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 2 Jun 2026 20:14:59 +1200 Subject: [PATCH 09/10] Fix tests failing on multiplatform, file order --- CHANGELOG.md | 4 +--- src/mpq.cpp | 14 +++++++++++-- test/test_list.py | 50 +++++++++++++++++++++++------------------------ 3 files changed, 38 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27357b8..a5c1a86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,8 @@ - Extract command now reports an error when the output directory cannot be created - Path traversal guard in extract uses fully resolved paths, closing a potential bypass - Crash when reading strong signatures from malformed or truncated archives - -### Updated - - Docker glibc image updated to ubuntu:24.04 +- Adding files is now ordered and operating system agnostic ### Changed diff --git a/src/mpq.cpp b/src/mpq.cpp index 26c481c..a1822df 100644 --- a/src/mpq.cpp +++ b/src/mpq.cpp @@ -184,11 +184,21 @@ int AddFiles(HANDLE hArchive, const std::string &inputPath, const std::string &p bool overwrite, bool update) { fs::path targetPath = fs::path(inputPath); + std::vector entries; + for (const auto &entry : fs::recursive_directory_iterator(inputPath)) { + if (fs::is_regular_file(entry.path())) { + entries.push_back(entry); + } + } + std::sort(entries.begin(), entries.end(), + [](const fs::directory_entry &a, const fs::directory_entry &b) { + return a.path() < b.path(); + }); + int filesAdded = 0; int filesSkipped = 0; - for (const auto &entry : fs::recursive_directory_iterator(inputPath)) { - if (!fs::is_regular_file(entry.path())) continue; + for (const auto &entry : entries) { fs::path inputFilePath = fs::relative(entry, targetPath); std::string archiveFilePath; diff --git a/test/test_list.py b/test/test_list.py index 05ba44c..a941b91 100644 --- a/test/test_list.py +++ b/test/test_list.py @@ -14,9 +14,9 @@ def test_list_mpq_with_output_v1(binary_path): test_file = script_dir / "data" / "mpq_with_output_v1.mpq" expected_output = { + "bytes", "cats.txt", "dogs.txt", - "bytes", } result = subprocess.run( @@ -43,11 +43,11 @@ def test_list_mpq_with_standard_details(binary_path): test_file = script_dir / "data" / "mpq_with_output_v1.mpq" expected_output = { - " 27 enUS (listfile)", - " 149 enUS (attributes)", - " 27 enUS 2025-07-29 14:31:00 dogs.txt", " 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 @@ -81,9 +81,9 @@ def test_list_mpq_with_specified_details(binary_path): 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", - " 35 147178ed c99b9ee2 0000000000000000 16 cexmn eaa753f9 bytes", " 25 fd657910 4e9b98a7 0000000000000000 35 ce2xmnf 2d2f0a94 (listfile)", " 14 d38437cb 07dfeaec 0000000000000000 124 ce2xmnf 50e314af (attributes)", } @@ -120,9 +120,9 @@ def test_list_mpq_with_weak_signature(binary_path): test_file = script_dir / "data" / "mpq_with_weak_signature.mpq" expected_output = { + "bytes", "cats.txt", "dogs.txt", - "bytes", "(listfile)", # Only included as "-a" specified "(signature)", # Only included as "-a" specified "(attributes)", # Only included as "-a" specified @@ -302,9 +302,9 @@ def test_list_mpq_providing_complete_external_listfile(binary_path, generate_mpq ## No flags expected_output = { - "dogs.txt", - "cats.txt", "capybaras.txt", + "cats.txt", + "dogs.txt", } result = subprocess.run( [str(binary_path), "list", str(test_file), "--listfile", str(listfile)], @@ -319,9 +319,9 @@ def test_list_mpq_providing_complete_external_listfile(binary_path, generate_mpq ## --all flag expected_output = { - "dogs.txt", - "cats.txt", "capybaras.txt", + "cats.txt", + "dogs.txt", } result = subprocess.run( [str(binary_path), "list", "-a", str(test_file), "--listfile", str(listfile)], @@ -394,11 +394,11 @@ def test_list_mpq_property_file_index(binary_path): test_file = script_dir / "data" / "mpq_with_output_v1.mpq" expected_output = { + " 0 bytes", + " 1 cats.txt", " 2 dogs.txt", - " 4 (attributes)", " 3 (listfile)", - " 1 bytes", - " 0 cats.txt", + " 4 (attributes)", } result = subprocess.run( @@ -418,11 +418,11 @@ def test_list_mpq_property_byte_offset(binary_path): test_file = script_dir / "data" / "mpq_with_output_v1.mpq" expected_output = { + " 20 bytes", + " 30 cats.txt", " 53 dogs.txt", - " 99 (attributes)", " 76 (listfile)", - " 43 bytes", - " 20 cats.txt", + " 99 (attributes)", } result = subprocess.run( @@ -442,11 +442,11 @@ def test_list_mpq_property_file_time(binary_path): test_file = script_dir / "data" / "mpq_with_output_v1.mpq" expected_output = { - "2025-07-29 14:31:00 dogs.txt", - " (attributes)", - " (listfile)", "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( @@ -466,11 +466,11 @@ def test_list_mpq_property_encryption_key(binary_path): test_file = script_dir / "data" / "mpq_with_output_v1.mpq" expected_output = { - "a073c614 dogs.txt", - "50e315dd (attributes)", - "2d2f0b11 (listfile)", "eaa753f9 bytes", "935a7772 cats.txt", + "a073c614 dogs.txt", + "2d2f0b11 (listfile)", + "50e315dd (attributes)", } result = subprocess.run( @@ -490,11 +490,11 @@ def test_list_mpq_property_locale(binary_path): test_file = script_dir / "data" / "mpq_with_output_v1.mpq" expected_output = { - "enUS dogs.txt", - "enUS (attributes)", - "enUS (listfile)", "enUS bytes", "enUS cats.txt", + "enUS dogs.txt", + "enUS (listfile)", + "enUS (attributes)", } result = subprocess.run( From 9c42a047d5b602c34c9b579d118eab11ff683f0e Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 2 Jun 2026 20:22:24 +1200 Subject: [PATCH 10/10] Fix Windows specific byte offset test --- src/mpq.cpp | 1 - test/test_list.py | 12 ++++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/mpq.cpp b/src/mpq.cpp index a1822df..a787d69 100644 --- a/src/mpq.cpp +++ b/src/mpq.cpp @@ -199,7 +199,6 @@ int AddFiles(HANDLE hArchive, const std::string &inputPath, const std::string &p int filesSkipped = 0; for (const auto &entry : entries) { - fs::path inputFilePath = fs::relative(entry, targetPath); std::string archiveFilePath; diff --git a/test/test_list.py b/test/test_list.py index a941b91..83e3dc8 100644 --- a/test/test_list.py +++ b/test/test_list.py @@ -424,6 +424,13 @@ def test_list_mpq_property_byte_offset(binary_path): " 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), "-p", "byte-offset"], @@ -472,6 +479,11 @@ def test_list_mpq_property_encryption_key(binary_path): "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), "-p", "encryption-key"],