From e94c5e1adb629f98d1c0eb34d302ac51739bd940 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 15 Oct 2026 23:06:10 +1300 Subject: [PATCH 01/20] Renamed target to archive in the positional arguments, options structs and help text --- app/add.cpp | 2 +- app/compact.cpp | 2 +- app/extract.cpp | 2 +- app/info.cpp | 2 +- app/list.cpp | 2 +- app/read.cpp | 2 +- app/remove.cpp | 2 +- app/rename.cpp | 2 +- app/verify.cpp | 2 +- completion/mpqcli.fish | 12 ++++++------ src/commands/compact.cpp | 2 +- src/commands/compact.h | 2 +- src/commands/extract.cpp | 8 ++++---- src/commands/extract.h | 2 +- src/commands/info.cpp | 2 +- src/commands/info.h | 2 +- src/commands/list.cpp | 2 +- src/commands/list.h | 2 +- src/commands/read.cpp | 2 +- src/commands/read.h | 2 +- src/commands/verify.cpp | 6 +++--- src/commands/verify.h | 2 +- src/mpq/verify.cpp | 10 +++++----- src/mpq/verify.h | 2 +- 24 files changed, 38 insertions(+), 38 deletions(-) diff --git a/app/add.cpp b/app/add.cpp index 54dabde..1ff8569 100644 --- a/app/add.cpp +++ b/app/add.cpp @@ -21,7 +21,7 @@ void RegisterAdd(CLI::App &app, Context &context) { auto options = std::make_shared(); auto *sub = app.add_subcommand("add", "Add files to an existing MPQ archive"); - sub->add_option("archive", options->add.archive, "Target MPQ archive") + sub->add_option("archive", options->add.archive, "MPQ archive") ->required() ->check(CLI::ExistingFile); sub->add_option("files", options->files, diff --git a/app/compact.cpp b/app/compact.cpp index 6f516ec..74ef392 100644 --- a/app/compact.cpp +++ b/app/compact.cpp @@ -10,7 +10,7 @@ void RegisterCompact(CLI::App &app, Context &context) { auto options = std::make_shared(); auto *sub = app.add_subcommand("compact", "Compact the MPQ archive"); - sub->add_option("target", options->target, "Target MPQ archive") + sub->add_option("archive", options->archive, "MPQ archive") ->required() ->check(CLI::ExistingFile); sub->add_option("-l,--listfile", options->listfile, "File listing content of an MPQ archive") diff --git a/app/extract.cpp b/app/extract.cpp index ad954cb..6217072 100644 --- a/app/extract.cpp +++ b/app/extract.cpp @@ -10,7 +10,7 @@ void RegisterExtract(CLI::App &app, Context &context) { auto options = std::make_shared(); auto *sub = app.add_subcommand("extract", "Extract files from the MPQ archive"); - sub->add_option("target", options->target, "Target MPQ archive") + sub->add_option("archive", options->archive, "MPQ archive") ->required() ->check(CLI::ExistingFile); sub->add_option("-o,--output", options->output, "Output directory"); diff --git a/app/info.cpp b/app/info.cpp index 9b014c1..43fa0e6 100644 --- a/app/info.cpp +++ b/app/info.cpp @@ -21,7 +21,7 @@ void RegisterInfo(CLI::App &app, Context &context) { auto options = std::make_shared(); auto *sub = app.add_subcommand("info", "Prints info about an MPQ archive"); - sub->add_option("target", options->target, "Target MPQ archive") + sub->add_option("archive", options->archive, "MPQ archive") ->required() ->check(CLI::ExistingFile); sub->add_option("-p,--property", options->property, "Prints only a specific property value") diff --git a/app/list.cpp b/app/list.cpp index ad2e2f1..1a816dd 100644 --- a/app/list.cpp +++ b/app/list.cpp @@ -22,7 +22,7 @@ void RegisterList(CLI::App &app, Context &context) { auto options = std::make_shared(); auto *sub = app.add_subcommand("list", "List files from the MPQ archive"); - sub->add_option("target", options->target, "Target MPQ archive") + sub->add_option("archive", options->archive, "MPQ archive") ->required() ->check(CLI::ExistingFile); sub->add_option("-l,--listfile", options->listfile, "File listing content of an MPQ archive") diff --git a/app/read.cpp b/app/read.cpp index 5d5997b..b195c03 100644 --- a/app/read.cpp +++ b/app/read.cpp @@ -11,7 +11,7 @@ void RegisterRead(CLI::App &app, Context &context) { auto *sub = app.add_subcommand("read", "Read a file from an MPQ archive"); sub->add_option("file", options->file, "File to read")->required(); - sub->add_option("target", options->target, "Target MPQ archive") + sub->add_option("archive", options->archive, "MPQ archive") ->required() ->check(CLI::ExistingFile); sub->add_option("--locale", options->locale, "Preferred locale for read file"); diff --git a/app/remove.cpp b/app/remove.cpp index 7e3a40a..0583af6 100644 --- a/app/remove.cpp +++ b/app/remove.cpp @@ -12,7 +12,7 @@ void RegisterRemove(CLI::App &app, Context &context) { auto options = std::make_shared(); auto *sub = app.add_subcommand("remove", "Remove files from an existing MPQ archive"); - sub->add_option("archive", options->archive, "Target MPQ archive") + sub->add_option("archive", options->archive, "MPQ archive") ->required() ->check(CLI::ExistingFile); sub->add_option("files", options->files, diff --git a/app/rename.cpp b/app/rename.cpp index 7dd2d95..17a7d5a 100644 --- a/app/rename.cpp +++ b/app/rename.cpp @@ -11,7 +11,7 @@ void RegisterRename(CLI::App &app, Context &context) { auto options = std::make_shared(); auto *sub = app.add_subcommand("rename", "Rename a file in an existing MPQ archive"); - sub->add_option("archive", options->archive, "Target MPQ archive") + sub->add_option("archive", options->archive, "MPQ archive") ->required() ->check(CLI::ExistingFile); sub->add_option("old-file", options->old_file, "Current archive path of the file")->required(); diff --git a/app/verify.cpp b/app/verify.cpp index 2f6bf57..70240f4 100644 --- a/app/verify.cpp +++ b/app/verify.cpp @@ -10,7 +10,7 @@ void RegisterVerify(CLI::App &app, Context &context) { auto options = std::make_shared(); auto *sub = app.add_subcommand("verify", "Verify the MPQ archive"); - sub->add_option("target", options->target, "Target MPQ archive") + sub->add_option("archive", options->archive, "MPQ archive") ->required() ->check(CLI::ExistingFile); sub->add_flag("-p,--print", options->print_signature, "Print the digital signature (in hex)"); diff --git a/completion/mpqcli.fish b/completion/mpqcli.fish index 48f3fc0..7311093 100644 --- a/completion/mpqcli.fish +++ b/completion/mpqcli.fish @@ -97,7 +97,7 @@ complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ -a completion -d 'Generate shell completion script' # info -# info [-p/--property ] +# info [-p/--property ] complete -c mpqcli -n '__fish_seen_subcommand_from info; and test (__mpqcli_positional_index) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from info' \ @@ -196,7 +196,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from rename' \ -r -f -a "$__mpqcli_locales" # list -# list [-l/--listfile] [-d/--detailed] [-a/--all] +# list [-l/--listfile] [-d/--detailed] [-a/--all] # [-p/--property ] complete -c mpqcli -n '__fish_seen_subcommand_from list; and test (__mpqcli_positional_index -d --detailed -a --all) -eq 1' -F @@ -211,7 +211,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from list' \ -r -f -a "$__mpqcli_list_properties" # extract -# extract [-o/--output] [-f/--file] [-k/--keep] +# extract [-o/--output] [-f/--file] [-k/--keep] # [-l/--listfile] [--locale] complete -c mpqcli -n '__fish_seen_subcommand_from extract; and test (__mpqcli_positional_index -k --keep) -eq 1' -F @@ -228,7 +228,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ -r -f -a "$__mpqcli_locales" # read -# read [--locale] +# read [--locale] # Positional 1 is an in-archive path, so it gets no filesystem completion; # positional 2 is the archive complete -c mpqcli -n '__fish_seen_subcommand_from read; and test (__mpqcli_positional_index) -eq 2' -F @@ -238,14 +238,14 @@ complete -c mpqcli -n '__fish_seen_subcommand_from read' \ -r -f -a "$__mpqcli_locales" # verify -# verify [-p/--print] +# verify [-p/--print] complete -c mpqcli -n '__fish_seen_subcommand_from verify; and test (__mpqcli_positional_index -p --print) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from verify' \ -s p -l print -d 'Print the digital signature (in hex)' # compact -# compact [-l/--listfile] +# compact [-l/--listfile] complete -c mpqcli -n '__fish_seen_subcommand_from compact; and test (__mpqcli_positional_index) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from compact' \ diff --git a/src/commands/compact.cpp b/src/commands/compact.cpp index 21a15a0..7226cfb 100644 --- a/src/commands/compact.cpp +++ b/src/commands/compact.cpp @@ -8,7 +8,7 @@ namespace mpqcli { void Compact(const CompactOptions &options, std::ostream &err) { - Archive archive = Archive::Open(options.target, 0); + Archive archive = Archive::Open(options.archive, 0); CompactMpqArchive(archive.Handle(), options.listfile, err); archive.Close(); } diff --git a/src/commands/compact.h b/src/commands/compact.h index 69d3116..20bb36a 100644 --- a/src/commands/compact.h +++ b/src/commands/compact.h @@ -9,7 +9,7 @@ namespace mpqcli { /// Settled arguments for the compact subcommand struct CompactOptions { - std::filesystem::path target; + std::filesystem::path archive; std::optional listfile; }; diff --git a/src/commands/extract.cpp b/src/commands/extract.cpp index fde7bb9..8185700 100644 --- a/src/commands/extract.cpp +++ b/src/commands/extract.cpp @@ -20,11 +20,11 @@ bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic std::error_code ec; fs::path effective_output; if (!options.output.has_value()) { - fs::path target_path = fs::absolute(options.target, ec); + fs::path archive_path = fs::absolute(options.archive, ec); if (ec) { - throw FileError("Failed to resolve archive path", options.target, ec); + throw FileError("Failed to resolve archive path", options.archive, ec); } - effective_output = target_path.parent_path() / target_path.stem(); + effective_output = archive_path.parent_path() / archive_path.stem(); } else { effective_output = options.output.value(); } @@ -45,7 +45,7 @@ bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic << effective_output.string() << std::endl; } - Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); + Archive archive = Archive::Open(options.archive, MPQ_OPEN_READ_ONLY); LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; if (options.locale.has_value() && lcid == default_locale) { diff --git a/src/commands/extract.h b/src/commands/extract.h index bfe997c..162df92 100644 --- a/src/commands/extract.h +++ b/src/commands/extract.h @@ -11,7 +11,7 @@ namespace mpqcli { /// Settled arguments for the extract subcommand struct ExtractOptions { - std::filesystem::path target; + std::filesystem::path archive; std::optional output; std::optional file; bool keep_folder_structure = false; diff --git a/src/commands/info.cpp b/src/commands/info.cpp index 9388a7e..7a6664e 100644 --- a/src/commands/info.cpp +++ b/src/commands/info.cpp @@ -10,7 +10,7 @@ namespace mpqcli { void Info(const InfoOptions &options, std::ostream &out) { - Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); + Archive archive = Archive::Open(options.archive, MPQ_OPEN_READ_ONLY); PrintMpqInfo(archive.Handle(), options.property, out); archive.Close(); } diff --git a/src/commands/info.h b/src/commands/info.h index 84ec9c5..570215d 100644 --- a/src/commands/info.h +++ b/src/commands/info.h @@ -10,7 +10,7 @@ namespace mpqcli { /// Settled arguments for the info subcommand struct InfoOptions { - std::filesystem::path target; + std::filesystem::path archive; std::optional property; }; diff --git a/src/commands/list.cpp b/src/commands/list.cpp index e5b4839..7a844b8 100644 --- a/src/commands/list.cpp +++ b/src/commands/list.cpp @@ -10,7 +10,7 @@ namespace mpqcli { void List(const ListOptions &options, std::ostream &out, std::ostream &err) { - Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); + Archive archive = Archive::Open(options.archive, MPQ_OPEN_READ_ONLY); ListFiles(archive.Handle(), options.listfile, options.all, options.detailed, options.properties, out, err); archive.Close(); diff --git a/src/commands/list.h b/src/commands/list.h index 14e490a..78e315d 100644 --- a/src/commands/list.h +++ b/src/commands/list.h @@ -11,7 +11,7 @@ namespace mpqcli { /// Settled arguments for the list subcommand struct ListOptions { - std::filesystem::path target; + std::filesystem::path archive; std::optional listfile; bool detailed = false; bool all = false; diff --git a/src/commands/read.cpp b/src/commands/read.cpp index 33c7c3c..6b1a239 100644 --- a/src/commands/read.cpp +++ b/src/commands/read.cpp @@ -13,7 +13,7 @@ namespace mpqcli { bool Read(const ReadOptions &options, std::ostream &out, std::ostream &err) { - Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); + Archive archive = Archive::Open(options.archive, MPQ_OPEN_READ_ONLY); LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; if (options.locale.has_value() && lcid == default_locale) { diff --git a/src/commands/read.h b/src/commands/read.h index 1a02663..01ecc63 100644 --- a/src/commands/read.h +++ b/src/commands/read.h @@ -11,7 +11,7 @@ namespace mpqcli { /// Settled arguments for the read subcommand struct ReadOptions { std::string file; - std::filesystem::path target; + std::filesystem::path archive; std::optional locale; }; diff --git a/src/commands/verify.cpp b/src/commands/verify.cpp index 26c71c1..f7fd765 100644 --- a/src/commands/verify.cpp +++ b/src/commands/verify.cpp @@ -10,14 +10,14 @@ namespace mpqcli { bool Verify(const VerifyOptions &options, std::ostream &out, std::ostream &err) { - Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); + Archive archive = Archive::Open(options.archive, MPQ_OPEN_READ_ONLY); bool verified; switch (VerifyMpqArchive(archive.Handle())) { case ERROR_WEAK_SIGNATURE_OK: case ERROR_STRONG_SIGNATURE_OK: if (options.print_signature) { - PrintMpqSignature(archive.Handle(), options.target, out, err); + PrintMpqSignature(archive.Handle(), options.archive, out, err); } err << "[*] Verify success" << std::endl; verified = true; @@ -28,7 +28,7 @@ bool Verify(const VerifyOptions &options, std::ostream &out, std::ostream &err) if (options.print_signature) { // Print the (invalid) signature bytes for forensic inspection, // but still fail: the archive content no longer matches it - PrintMpqSignature(archive.Handle(), options.target, out, err); + PrintMpqSignature(archive.Handle(), options.archive, out, err); } err << "[!] Verify failed: signature is present but invalid" << std::endl; verified = false; diff --git a/src/commands/verify.h b/src/commands/verify.h index 3deb2f8..f068e57 100644 --- a/src/commands/verify.h +++ b/src/commands/verify.h @@ -8,7 +8,7 @@ namespace mpqcli { /// Settled arguments for the verify subcommand struct VerifyOptions { - std::filesystem::path target; + std::filesystem::path archive; bool print_signature = false; }; diff --git a/src/mpq/verify.cpp b/src/mpq/verify.cpp index 45dbe4a..2b7f883 100644 --- a/src/mpq/verify.cpp +++ b/src/mpq/verify.cpp @@ -25,7 +25,7 @@ uint32_t VerifyMpqArchive(HANDLE archive) { return SFileVerifyArchive(archive); } -void PrintMpqSignature(HANDLE archive, const fs::path &target, std::ostream &out, +void PrintMpqSignature(HANDLE archive, const fs::path &archive_path, std::ostream &out, std::ostream &err) { int32_t signature_type = GetFileInfo(archive, SFileMpqSignatures); @@ -50,9 +50,9 @@ void PrintMpqSignature(HANDLE archive, const fs::path &target, std::ostream &out int64_t archive_offset = GetFileInfo(archive, SFileMpqHeaderOffset); std::error_code ec; - const auto file_size = static_cast(fs::file_size(target, ec)); + const auto file_size = static_cast(fs::file_size(archive_path, ec)); if (ec) { - throw FileError("Failed to read archive size", target, ec); + throw FileError("Failed to read archive size", archive_path, ec); } // fs::file_size returns uintmax_t, so without the cast the subtraction runs // unsigned and a legitimately negative length reaches the check below only @@ -64,12 +64,12 @@ void PrintMpqSignature(HANDLE archive, const fs::path &target, std::ostream &out } std::vector signature_content(static_cast(signature_length)); - std::ifstream file_mpq(target, std::ios::binary); + std::ifstream file_mpq(archive_path, std::ios::binary); file_mpq.seekg(archive_offset + archive_size, std::ios::beg); file_mpq.read(signature_content.data(), static_cast(signature_content.size())); if (!file_mpq) { - throw ArchiveError("Failed to read strong signature: " + target.string()); + throw ArchiveError("Failed to read strong signature: " + archive_path.string()); } WriteBinary(out, signature_content.data(), static_cast(signature_content.size())); diff --git a/src/mpq/verify.h b/src/mpq/verify.h index 87f0ba4..f37d0ad 100644 --- a/src/mpq/verify.h +++ b/src/mpq/verify.h @@ -15,7 +15,7 @@ uint32_t VerifyMpqArchive(HANDLE archive); /// /// @throws ArchiveError if the signature cannot be read /// @throws FileError if the archive's size on disk cannot be read -void PrintMpqSignature(HANDLE archive, const std::filesystem::path &target, std::ostream &out, +void PrintMpqSignature(HANDLE archive, const std::filesystem::path &archive_path, std::ostream &out, std::ostream &err); } // namespace mpqcli From 63dfe09957f729ffbbc52e89d8a90fb83b62a40a Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 15 Oct 2026 23:12:12 +1300 Subject: [PATCH 02/20] Stripped a trailing carriage return from each path read from stdin --- src/util/path.cpp | 4 ++++ src/util/path.h | 2 +- test/test_add.py | 27 +++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/util/path.cpp b/src/util/path.cpp index 45a2174..47d8d56 100644 --- a/src/util/path.cpp +++ b/src/util/path.cpp @@ -63,6 +63,10 @@ std::vector ExpandStdinMarker(const std::vector &paths } std::string line; while (std::getline(in, line)) { + // A list saved with Windows line endings keeps the \r when read on POSIX + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } if (!line.empty()) { expanded.push_back(line); } diff --git a/src/util/path.h b/src/util/path.h index 4e4560e..2e41c49 100644 --- a/src/util/path.h +++ b/src/util/path.h @@ -25,7 +25,7 @@ std::string ResolveArchiveName(const std::filesystem::path &f, std::vector ListFilesRecursive(const std::filesystem::path &directory, std::error_code &ec); -/// Replaces each "-" in paths with the non-empty lines read from in +/// Replaces each "-" in paths with the non-empty lines read from in, less any trailing \r std::vector ExpandStdinMarker(const std::vector &paths, std::istream &in); /// Returns the file's last-modification time as a Windows FILETIME value diff --git a/test/test_add.py b/test/test_add.py index 0b48fb4..fe78539 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -1074,6 +1074,33 @@ def test_add_files_via_stdin(binary_path, generate_test_files): assert "stdin_b.txt" in list_result.stdout +def test_add_files_via_stdin_with_windows_line_endings(binary_path, generate_test_files): + _ = generate_test_files + script_dir = Path(__file__).parent + target_mpq = script_dir / "data" / "files.mpq" + + create_mpq_archive_for_test(binary_path, script_dir) + + test_file_a = script_dir / "data" / "stdin_a.txt" + test_file_b = script_dir / "data" / "stdin_b.txt" + test_file_a.write_text("Stdin file A.") + test_file_b.write_text("Stdin file B.") + + stdin_input = f"{test_file_a}\r\n{test_file_b}\r\n" + + result = subprocess.run( + [str(binary_path), "add", str(target_mpq), "-"], + input=stdin_input, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert "[+] Adding file: stdin_a.txt" in result.stderr + assert "[+] Adding file: stdin_b.txt" in result.stderr + + def create_mpq_archive_for_test(binary_path, script_dir): target_dir = script_dir / "data" / "files" target_file = target_dir.with_suffix(".mpq") From 485fada844a4d596212ba948e17d6c28df8f4731 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 15 Oct 2026 23:29:02 +1300 Subject: [PATCH 03/20] Moved add's per-input loop into the core for create to share --- src/commands/add.cpp | 54 +++----------------------------------------- src/mpq/add.cpp | 52 ++++++++++++++++++++++++++++++++++++++++++ src/mpq/add.h | 11 +++++++++ 3 files changed, 66 insertions(+), 51 deletions(-) diff --git a/src/commands/add.cpp b/src/commands/add.cpp index 3a89e98..97a0665 100644 --- a/src/commands/add.cpp +++ b/src/commands/add.cpp @@ -1,22 +1,15 @@ #include "commands/add.h" #include -#include #include #include -#include -#include #include -#include "errors.h" #include "gamerules/rules.h" #include "mpq/add.h" #include "mpq/archive.h" #include "util/locales.h" -#include "util/path.h" - -namespace fs = std::filesystem; namespace mpqcli { @@ -34,51 +27,10 @@ bool Add(const AddOptions &options, std::ostream &err, const std::atomic & } GameRules game_rules(profile); - std::error_code ec; - bool has_directory = false; - for (const auto &f : options.files) { - if (fs::is_directory(f, ec)) { - has_directory = true; - break; - } - } - - int result = 0; int files_skipped = 0; - for (const auto &f : options.files) { - ThrowIfCancelled(cancelled); - - if (!fs::exists(f, ec)) { - err << "[!] Path does not exist: " << f.string() << std::endl; - result |= 1; - continue; - } - - if (fs::is_directory(f, ec)) { - std::vector directory_files = ListFilesRecursive(f, ec); - if (ec) { - err << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message() - << ": " << f.string() << std::endl; - result |= 1; - continue; - } - std::string prefix = options.path.value_or(""); - result |= AddFiles(archive.Handle(), directory_files, f, prefix, lcid, game_rules, err, - cancelled, options.compression_overrides, options.overwrite, - options.update, &files_skipped); - - } else if (fs::is_regular_file(f, ec)) { - const bool treat_as_directory = has_directory || options.files.size() > 1; - std::string archive_path = ResolveArchiveName(f, options.path, treat_as_directory); - result |= AddFile(archive.Handle(), f, archive_path, lcid, game_rules, err, - options.compression_overrides, options.overwrite, options.update, - &files_skipped); - - } else { - err << "[!] Not a file or directory: " << f.string() << std::endl; - result |= 1; - } - } + const int result = + AddInputs(archive.Handle(), options.files, options.path, lcid, game_rules, err, cancelled, + options.compression_overrides, options.overwrite, options.update, &files_skipped); // Skipping pre-existing files is the default, so point at the flags that change it // rather than letting the run look like it silently did nothing. diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp index 80631c5..267b54d 100644 --- a/src/mpq/add.cpp +++ b/src/mpq/add.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -185,4 +186,55 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi return 0; } +int AddInputs(HANDLE archive, const std::vector &inputs, + const std::optional &path, LCID locale, const GameRules &game_rules, + std::ostream &err, const std::atomic &cancelled, + const CompressionSettingsOverrides &overrides, bool overwrite, bool update, + int *skipped) { + std::error_code ec; + bool has_directory = false; + for (const auto &f : inputs) { + if (fs::is_directory(f, ec)) { + has_directory = true; + break; + } + } + + int result = 0; + for (const auto &f : inputs) { + ThrowIfCancelled(cancelled); + + if (!fs::exists(f, ec)) { + err << "[!] Path does not exist: " << f.string() << std::endl; + result |= 1; + continue; + } + + if (fs::is_directory(f, ec)) { + std::vector directory_files = ListFilesRecursive(f, ec); + if (ec) { + err << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message() + << ": " << f.string() << std::endl; + result |= 1; + continue; + } + std::string prefix = path.value_or(""); + result |= AddFiles(archive, directory_files, f, prefix, locale, game_rules, err, + cancelled, overrides, overwrite, update, skipped); + + } else if (fs::is_regular_file(f, ec)) { + const bool treat_as_directory = has_directory || inputs.size() > 1; + std::string archive_path = ResolveArchiveName(f, path, treat_as_directory); + result |= AddFile(archive, f, archive_path, locale, game_rules, err, overrides, + overwrite, update, skipped); + + } else { + err << "[!] Not a file or directory: " << f.string() << std::endl; + result |= 1; + } + } + + return result; +} + } // namespace mpqcli diff --git a/src/mpq/add.h b/src/mpq/add.h index 2bd07af..ea959a6 100644 --- a/src/mpq/add.h +++ b/src/mpq/add.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -25,6 +26,16 @@ int AddFile(HANDLE archive, const std::filesystem::path &local_file, const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), bool overwrite = false, bool update = false, int *skipped = nullptr); +/// Adds each local file or directory in inputs, returning 1 if any of them failed +/// +/// A directory's contents go under path, or at the archive root without it. A lone file is +/// stored at path when given; beside other inputs or any directory, path is a prefix. +int AddInputs(HANDLE archive, const std::vector &inputs, + const std::optional &path, LCID locale, const GameRules &game_rules, + std::ostream &err, const std::atomic &cancelled, + const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), + bool overwrite = false, bool update = false, int *skipped = nullptr); + } // namespace mpqcli #endif From 8c34c51b86b437b6590e1ed4e9b3992ccc582337 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 15 Oct 2026 23:38:07 +1300 Subject: [PATCH 04/20] Dropped the -p short flag from info, list and verify --- app/info.cpp | 2 +- app/list.cpp | 2 +- app/verify.cpp | 2 +- completion/mpqcli.bash | 12 ++++++------ completion/mpqcli.fish | 14 +++++++------- completion/mpqcli.ps1 | 9 ++------- completion/mpqcli.zsh | 6 +++--- docs/commands/info.md | 4 ++-- docs/commands/list.md | 4 ++-- docs/commands/verify.md | 4 ++-- test/conftest.py | 4 ++-- test/test_add.py | 6 +++--- test/test_create.py | 18 +++++++++--------- test/test_info.py | 22 +++++++++++++++++++--- test/test_list.py | 32 ++++++++++++++++++++++++-------- test/test_remove.py | 2 +- test/test_rename.py | 2 +- test/test_verify.py | 19 +++++++++++++++++-- 18 files changed, 103 insertions(+), 61 deletions(-) diff --git a/app/info.cpp b/app/info.cpp index 43fa0e6..1b776b3 100644 --- a/app/info.cpp +++ b/app/info.cpp @@ -24,7 +24,7 @@ void RegisterInfo(CLI::App &app, Context &context) { sub->add_option("archive", options->archive, "MPQ archive") ->required() ->check(CLI::ExistingFile); - sub->add_option("-p,--property", options->property, "Prints only a specific property value") + sub->add_option("--property", options->property, "Prints only a specific property value") ->check(CLI::IsMember(valid_properties)); sub->callback([options, &context]() { mpqcli::Info(*options, context.out); }); diff --git a/app/list.cpp b/app/list.cpp index 1a816dd..ae7d595 100644 --- a/app/list.cpp +++ b/app/list.cpp @@ -30,7 +30,7 @@ void RegisterList(CLI::App &app, Context &context) { sub->add_flag("-d,--detailed", options->detailed, "File listing with additional columns (default false)"); sub->add_flag("-a,--all", options->all, "File listing including hidden files (default false)"); - sub->add_option("-p,--property", options->properties, "Prints only specific property values") + sub->add_option("--property", options->properties, "Prints only specific property values") ->check(CLI::IsMember(valid_properties)); sub->callback([options, &context]() { mpqcli::List(*options, context.out, context.err); }); diff --git a/app/verify.cpp b/app/verify.cpp index 70240f4..407924f 100644 --- a/app/verify.cpp +++ b/app/verify.cpp @@ -13,7 +13,7 @@ void RegisterVerify(CLI::App &app, Context &context) { sub->add_option("archive", options->archive, "MPQ archive") ->required() ->check(CLI::ExistingFile); - sub->add_flag("-p,--print", options->print_signature, "Print the digital signature (in hex)"); + sub->add_flag("--print", options->print_signature, "Print the digital signature (in hex)"); sub->callback([options, &context]() { context.exit_code = mpqcli::Verify(*options, context.out, context.err) ? 0 : 1; diff --git a/completion/mpqcli.bash b/completion/mpqcli.bash index 5990f53..8548a9d 100644 --- a/completion/mpqcli.bash +++ b/completion/mpqcli.bash @@ -116,10 +116,10 @@ _mpqcli() { case "${words[1]}" in info) case "$prev" in - -p|--property) _mpqcli_words "${info_properties[*]}"; return ;; + --property) _mpqcli_words "${info_properties[*]}"; return ;; esac if [[ "$cur" == -* ]]; then - _mpqcli_words "-p --property" + _mpqcli_words "--property" else _mpqcli_positional_index if [[ $pos -eq 0 ]]; then @@ -206,10 +206,10 @@ _mpqcli() { list) case "$prev" in -l|--listfile) _mpqcli_filedir; return ;; - -p|--property) _mpqcli_words "${list_properties[*]}"; return ;; + --property) _mpqcli_words "${list_properties[*]}"; return ;; esac if [[ "$cur" == -* ]]; then - _mpqcli_words "-l --listfile -d --detailed -a --all -p --property" + _mpqcli_words "-l --listfile -d --detailed -a --all --property" else _mpqcli_positional_index -d --detailed -a --all if [[ $pos -eq 0 ]]; then @@ -253,9 +253,9 @@ _mpqcli() { verify) if [[ "$cur" == -* ]]; then - _mpqcli_words "-p --print" + _mpqcli_words "--print" else - _mpqcli_positional_index -p --print + _mpqcli_positional_index --print if [[ $pos -eq 0 ]]; then _mpqcli_filedir mpq fi diff --git a/completion/mpqcli.fish b/completion/mpqcli.fish index 7311093..3ab657d 100644 --- a/completion/mpqcli.fish +++ b/completion/mpqcli.fish @@ -97,11 +97,11 @@ complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ -a completion -d 'Generate shell completion script' # info -# info [-p/--property ] +# info [--property ] complete -c mpqcli -n '__fish_seen_subcommand_from info; and test (__mpqcli_positional_index) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from info' \ - -s p -l property -d 'Print only a specific property value' \ + -l property -d 'Print only a specific property value' \ -r -f -a "$__mpqcli_info_properties" # create @@ -197,7 +197,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from rename' \ # list # list [-l/--listfile] [-d/--detailed] [-a/--all] -# [-p/--property ] +# [--property ] complete -c mpqcli -n '__fish_seen_subcommand_from list; and test (__mpqcli_positional_index -d --detailed -a --all) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from list' \ @@ -207,7 +207,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from list' \ complete -c mpqcli -n '__fish_seen_subcommand_from list' \ -s a -l all -d 'Include hidden files' complete -c mpqcli -n '__fish_seen_subcommand_from list' \ - -s p -l property -d 'Print only specific property values' \ + -l property -d 'Print only specific property values' \ -r -f -a "$__mpqcli_list_properties" # extract @@ -238,11 +238,11 @@ complete -c mpqcli -n '__fish_seen_subcommand_from read' \ -r -f -a "$__mpqcli_locales" # verify -# verify [-p/--print] -complete -c mpqcli -n '__fish_seen_subcommand_from verify; and test (__mpqcli_positional_index -p --print) -eq 1' -F +# verify [--print] +complete -c mpqcli -n '__fish_seen_subcommand_from verify; and test (__mpqcli_positional_index --print) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from verify' \ - -s p -l print -d 'Print the digital signature (in hex)' + -l print -d 'Print the digital signature (in hex)' # compact # compact [-l/--listfile] diff --git a/completion/mpqcli.ps1 b/completion/mpqcli.ps1 index 30ae3ac..4e54315 100644 --- a/completion/mpqcli.ps1 +++ b/completion/mpqcli.ps1 @@ -75,7 +75,6 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl # short help string, in the order the help text lists them $optionSpec = @{ 'info' = [ordered]@{ - '-p' = 'Print only a specific property value' '--property' = 'Print only a specific property value' } 'create' = [ordered]@{ @@ -127,7 +126,6 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--detailed' = 'File listing with additional columns' '-a' = 'File listing including hidden files' '--all' = 'File listing including hidden files' - '-p' = 'Print only specific property values' '--property' = 'Print only specific property values' } 'extract' = [ordered]@{ @@ -145,7 +143,6 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--locale' = 'Preferred locale for read file' } 'verify' = [ordered]@{ - '-p' = 'Print the digital signature (in hex)' '--print' = 'Print the digital signature (in hex)' } 'compact' = [ordered]@{ @@ -236,7 +233,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl return & $emit $items 'ParameterValue' } # Property options: meaning depends on the subcommand - if ($prevToken -in @('-p', '--property')) { + if ($prevToken -eq '--property') { if ($subcommand -eq 'info') { $items = foreach ($v in $infoProperties) { [pscustomobject]@{ Value = $v; Tip = 'info property' } } @@ -247,13 +244,11 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl [pscustomobject]@{ Value = $v; Tip = 'list property' } } return & $emit $items 'ParameterValue' } - # For 'verify', -p/--print is a flag (no value) -> fall through } # Options that take a file/dir path -> let PowerShell complete paths $pathOptions = @('-o', '--output', '-l', '--listfile', '-f', '--file', '-p', '--path') - if ($prevToken -in $pathOptions -and - -not ($subcommand -in @('info', 'list') -and $prevToken -in @('-p', '--property'))) { + if ($prevToken -in $pathOptions) { return # empty -> native path completion takes over } } diff --git a/completion/mpqcli.zsh b/completion/mpqcli.zsh index b1f463e..b345608 100644 --- a/completion/mpqcli.zsh +++ b/completion/mpqcli.zsh @@ -90,7 +90,7 @@ _mpqcli_info() { local props="${_mpqcli_info_properties[*]}" _arguments \ '1:archive:_mpqcli_archives' \ - '(-p --property)'{-p,--property}'[print only a specific property]:property:('"$props"')' + '--property[print only a specific property]:property:('"$props"')' } _mpqcli_create() { @@ -154,7 +154,7 @@ _mpqcli_list() { '(-l --listfile)'{-l,--listfile}'[listfile path]:file:_files' \ '(-d --detailed)'{-d,--detailed}'[detailed listing with extra columns]' \ '(-a --all)'{-a,--all}'[include hidden files]' \ - '(-p --property)'{-p,--property}'[print specific property values]:property:('"$props"')' + '--property[print specific property values]:property:('"$props"')' } _mpqcli_extract() { @@ -179,7 +179,7 @@ _mpqcli_read() { _mpqcli_verify() { _arguments \ '1:archive:_mpqcli_archives' \ - '(-p --print)'{-p,--print}'[print the digital signature in hex]' + '--print[print the digital signature in hex]' } _mpqcli_compact() { diff --git a/docs/commands/info.md b/docs/commands/info.md index 5d726bd..eb25b78 100644 --- a/docs/commands/info.md +++ b/docs/commands/info.md @@ -29,9 +29,9 @@ The `info` subcommand supports the following properties: - `max-files` - `signature-type` -You can use the `-p` or `--property` argument with the `info` subcommand to print just the value of a specific property. This can be useful for automation, for example, to determine the signature type of a directory of MPQ archives. +You can use the `--property` argument with the `info` subcommand to print just the value of a specific property. This can be useful for automation, for example, to determine the signature type of a directory of MPQ archives. ```bash -$ mpqcli info -p file-count wow-patch.mpq +$ mpqcli info --property file-count wow-patch.mpq 65 ``` diff --git a/docs/commands/list.md b/docs/commands/list.md index 0048326..39c7a10 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -63,10 +63,10 @@ The `list` subcommand supports listing the following properties: * `n`: Use default flags for internal files. * `f`: Fix key; This is obsolete. -You can use the `-p` or `--property` argument with the `list` subcommand to print the given properties. Many properties can be given, and they will be printed in the order given. +You can use the `--property` argument with the `list` subcommand to print the given properties. Many properties can be given, and they will be printed in the order given. ```bash -$ mpqcli list -d -a Patch_rt.mpq -p hash-index -p locale -p flags +$ mpqcli list -d -a Patch_rt.mpq --property hash-index --property locale --property flags ... 10893 enUS ixmn glue\ScorePv\pMain.pcx 11173 enUS ixmn rez\gluAll.tbl diff --git a/docs/commands/verify.md b/docs/commands/verify.md index c5120a0..765eeba 100644 --- a/docs/commands/verify.md +++ b/docs/commands/verify.md @@ -20,8 +20,8 @@ $ echo $? ## Verify an MPQ archive and print the digital signature -Check the digital signature of an MPQ archive, by verifying the signature in the archive and also printing the digital signature value in hexadecimal using the `-p` or `--print` argument. +Check the digital signature of an MPQ archive, by verifying the signature in the archive and also printing the digital signature value in hexadecimal using the `--print` argument. ```bash -$ mpqcli verify -p wow-patch.mpq > signature +$ mpqcli verify --print wow-patch.mpq > signature ``` diff --git a/test/conftest.py b/test/conftest.py index d4efca6..f997b55 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -247,7 +247,7 @@ def generate_tampered_signature_mpq(binary_path): assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" offset_result = subprocess.run( - [str(binary_path), "list", "-p", "byte-offset", str(mpq_file)], + [str(binary_path), "list", "--property", "byte-offset", str(mpq_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -256,7 +256,7 @@ def generate_tampered_signature_mpq(binary_path): byte_offset = int(offset_result.stdout.split()[0]) size_result = subprocess.run( - [str(binary_path), "list", "-p", "compressed-size", str(mpq_file)], + [str(binary_path), "list", "--property", "compressed-size", str(mpq_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True diff --git a/test/test_add.py b/test/test_add.py index fe78539..7beabf4 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -396,7 +396,7 @@ def test_add_file_with_game_profile(binary_path, generate_test_files): assert f"Adding file: test_{profile}.txt" in result.stderr list_result = subprocess.run( - [str(binary_path), "list", str(target_file), "-d", "-p", "flags"], + [str(binary_path), "list", str(target_file), "-d", "--property", "flags"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -471,7 +471,7 @@ def test_add_file_with_all_game_profiles(binary_path, generate_test_files): assert f"test_all_{profile}.txt" in list_result.stdout, f"Profile {profile}: added file not found in archive" flags_result = subprocess.run( - [str(binary_path), "list", str(target_file), "-d", "-p", "flags"], + [str(binary_path), "list", str(target_file), "-d", "--property", "flags"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -1176,7 +1176,7 @@ def create_mpq_archive_with_filetime_for_test(binary_path, script_dir): def verify_archive_file_content(binary_path, test_file, expected_output): result = subprocess.run( - [str(binary_path), "list", str(test_file), "-d", "-p", "locale"], + [str(binary_path), "list", str(test_file), "-d", "--property", "locale"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True diff --git a/test/test_create.py b/test/test_create.py index 235e9c0..6d6bf4a 100644 --- a/test/test_create.py +++ b/test/test_create.py @@ -59,7 +59,7 @@ def test_create_mpq_versions(binary_path, generate_test_files): # Verify the correct version was written to the archive version_result = subprocess.run( - [str(binary_path), "info", "-p", "format-version", str(target_file)], + [str(binary_path), "info", "--property", "format-version", str(target_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -381,7 +381,7 @@ def test_create_mpq_with_game_profile(binary_path, generate_test_files): # Verify the MPQ version version_result = subprocess.run( - [str(binary_path), "info", "-p", "format-version", str(output_file)], + [str(binary_path), "info", "--property", "format-version", str(output_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -392,7 +392,7 @@ def test_create_mpq_with_game_profile(binary_path, generate_test_files): # Verify compression flags on a .txt file list_result = subprocess.run( - [str(binary_path), "list", str(output_file), "-d", "-p", "flags"], + [str(binary_path), "list", str(output_file), "-d", "--property", "flags"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -484,7 +484,7 @@ def test_create_mpq_with_all_game_profiles(binary_path, generate_test_files): # Verify the MPQ version version_result = subprocess.run( - [str(binary_path), "info", "-p", "format-version", str(output_file)], + [str(binary_path), "info", "--property", "format-version", str(output_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -536,7 +536,7 @@ def test_deletion_marker_only_for_zero_size_files(binary_path): # Check flags for both files list_result = subprocess.run( - [str(binary_path), "list", str(output_file), "-d", "-p", "flags"], + [str(binary_path), "list", str(output_file), "-d", "--property", "flags"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -601,7 +601,7 @@ def test_create_mpq_no_sign_flag_has_no_signature(binary_path, generate_test_fil assert output_file.exists(), "MPQ file was not created" info = subprocess.run( - [str(binary_path), "info", "-p", "signature-type", str(output_file)], + [str(binary_path), "info", "--property", "signature-type", str(output_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -686,7 +686,7 @@ def test_create_mpq_skips_special_files(binary_path, tmp_path): # Signature type must be None (signature) must not have been ingested info = subprocess.run( - [str(binary_path), "info", "-p", "signature-type", str(output_file)], + [str(binary_path), "info", "--property", "signature-type", str(output_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -802,7 +802,7 @@ def test_create_mpq_skips_dangling_symlink(binary_path, tmp_path): def verify_archive_file_content(binary_path, test_file, expected_output): result = subprocess.run( - [str(binary_path), "list", str(test_file), "-d", "-p", "locale"], + [str(binary_path), "list", str(test_file), "-d", "--property", "locale"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -843,7 +843,7 @@ def test_create_mpq_from_empty_directory(binary_path, tmp_path): # 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)], + [str(binary_path), "info", "--property", "max-files", str(output_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True diff --git a/test/test_info.py b/test/test_info.py index 07476dc..2c60257 100644 --- a/test/test_info.py +++ b/test/test_info.py @@ -90,7 +90,7 @@ def test_info_v1_properties(binary_path): for property_name, expected_output in test_cases: result = subprocess.run( - [str(binary_path), "info", "-p", property_name, str(test_file)], + [str(binary_path), "info", "--property", property_name, str(test_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -123,7 +123,7 @@ def test_info_v2_properties(binary_path): for property_name, expected_output in test_cases: result = subprocess.run( - [str(binary_path), "info", "-p", property_name, str(test_file)], + [str(binary_path), "info", "--property", property_name, str(test_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -140,7 +140,7 @@ def test_info_v1_invalid_property(binary_path): property_name = "not-real" result = subprocess.run( - [str(binary_path), "info", "-p", property_name, str(test_file)], + [str(binary_path), "info", "--property", property_name, str(test_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -161,3 +161,19 @@ def test_info_mpq_target_does_not_exist(binary_path): ) assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + + +def test_info_short_property_flag_is_rejected(binary_path): + script_dir = Path(__file__).parent + test_file = script_dir / "data" / "mpq_with_output_v1.mpq" + + result = subprocess.run( + [str(binary_path), "info", "-p", "file-count", str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + # -p is not an info option, so the property name is taken as the archive + assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + assert result.stdout == "" diff --git a/test/test_list.py b/test/test_list.py index ed4069b..3bb87bb 100644 --- a/test/test_list.py +++ b/test/test_list.py @@ -96,8 +96,8 @@ def test_list_mpq_with_specified_details(binary_path): result = subprocess.run( [str(binary_path), "list", "-a", "-d", str(test_file), - "-p", "hash-index", "-p", "name-hash1", "-p", "name-hash2", "-p", "name-hash3", - "-p", "compressed-size", "-p", "flags", "-p", "encryption-key-raw"], + "--property", "hash-index", "--property", "name-hash1", "--property", "name-hash2", "--property", "name-hash3", + "--property", "compressed-size", "--property", "flags", "--property", "encryption-key-raw"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -402,7 +402,7 @@ def test_list_mpq_property_file_index(binary_path): } result = subprocess.run( - [str(binary_path), "list", "-a", "-d", str(test_file), "-p", "file-index"], + [str(binary_path), "list", "-a", "-d", str(test_file), "--property", "file-index"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -433,7 +433,7 @@ def test_list_mpq_property_byte_offset(binary_path): expected_output.add(" 9b (attributes)") result = subprocess.run( - [str(binary_path), "list", "-a", "-d", str(test_file), "-p", "byte-offset"], + [str(binary_path), "list", "-a", "-d", str(test_file), "--property", "byte-offset"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -457,7 +457,7 @@ def test_list_mpq_property_file_time(binary_path): } result = subprocess.run( - [str(binary_path), "list", "-a", "-d", str(test_file), "-p", "file-time"], + [str(binary_path), "list", "-a", "-d", str(test_file), "--property", "file-time"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -486,7 +486,7 @@ def test_list_mpq_property_encryption_key(binary_path): expected_output.add("50e315df (attributes)") result = subprocess.run( - [str(binary_path), "list", "-a", "-d", str(test_file), "-p", "encryption-key"], + [str(binary_path), "list", "-a", "-d", str(test_file), "--property", "encryption-key"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -510,7 +510,7 @@ def test_list_mpq_property_locale(binary_path): } result = subprocess.run( - [str(binary_path), "list", "-a", "-d", str(test_file), "-p", "locale"], + [str(binary_path), "list", "-a", "-d", str(test_file), "--property", "locale"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -538,7 +538,7 @@ def test_list_encryption_keys_of_files_listed_without_names(binary_path, generat def keys_by_file_index(*extra_args): result = subprocess.run( [str(binary_path), "list", "-d", str(test_file), *extra_args, - "-p", "file-index", "-p", "flags", "-p", "encryption-key", "-p", "encryption-key-raw"], + "--property", "file-index", "--property", "flags", "--property", "encryption-key", "--property", "encryption-key-raw"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -561,3 +561,19 @@ def keys_by_file_index(*extra_args): assert key != "00000000", f"No encryption key found for file {file_index}" assert (key, key_raw) == with_names[file_index][1:3], \ f"Keys of file {file_index} differ: {(key, key_raw)} vs {with_names[file_index][1:3]}" + + +def test_list_short_property_flag_is_rejected(binary_path): + script_dir = Path(__file__).parent + test_file = script_dir / "data" / "mpq_with_output_v1.mpq" + + result = subprocess.run( + [str(binary_path), "list", "-p", "locale", str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + # -p is not a list option, so the property name is taken as the archive + assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + assert result.stdout == "" diff --git a/test/test_remove.py b/test/test_remove.py index 65aef92..b45482c 100644 --- a/test/test_remove.py +++ b/test/test_remove.py @@ -321,7 +321,7 @@ def test_remove_files_via_stdin(binary_path, generate_locales_mpq_test_files): 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"] + cmd = [str(binary_path), "list", "-d", str(target_file), "--property", "locale"] if listfile != Path(): cmd.extend(["--listfile", str(listfile)]) diff --git a/test/test_rename.py b/test/test_rename.py index acbe3b0..bda8797 100644 --- a/test/test_rename.py +++ b/test/test_rename.py @@ -356,7 +356,7 @@ def test_rename_to_existing_name_fails(binary_path, generate_mpq_without_interna 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"] + cmd = [str(binary_path), "list", "-d", str(target_file), "--property", "locale"] if listfile != Path(): cmd.extend(["--listfile", str(listfile)]) diff --git a/test/test_verify.py b/test/test_verify.py index 95ffd93..90b2520 100644 --- a/test/test_verify.py +++ b/test/test_verify.py @@ -110,7 +110,7 @@ def test_verify_weak_signature_patch(binary_path, download_test_files): expected_md5 = "e434b57cdc6b730c17e18f2bb2fa8fd9" result = subprocess.run( - [str(binary_path), "verify", "-p", str(test_file)], + [str(binary_path), "verify", "--print", str(test_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=False # Get raw bytes to avoid UTF decode errors @@ -142,7 +142,7 @@ def test_verify_strong_signature_patch(binary_path, download_test_files): expected_md5 = "931fc40bb6bff48b89d39b5acb169bbd" result = subprocess.run( - [str(binary_path), "verify", "-p", str(test_file)], + [str(binary_path), "verify", "--print", str(test_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=False # Get raw bytes to avoid UTF decode errors @@ -169,3 +169,18 @@ def test_verify_mpq_target_does_not_exist(binary_path): ) assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + + +def test_verify_short_print_flag_is_rejected(binary_path): + script_dir = Path(__file__).parent + test_file = script_dir / "data" / "mpq_with_output_v1.mpq" + + result = subprocess.run( + [str(binary_path), "verify", "-p", str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 109, f"mpqcli failed with error: {result.stderr}" + assert result.stdout == "" From 8bf4180bcae0635175e77d670f2ea1c56f8e15c9 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 15 Oct 2026 23:44:04 +1300 Subject: [PATCH 05/20] Renamed create --version to --format-version --- app/create.cpp | 9 +++++---- completion/mpqcli.bash | 4 ++-- completion/mpqcli.fish | 4 ++-- completion/mpqcli.ps1 | 10 +++++----- completion/mpqcli.zsh | 2 +- docs/commands/create.md | 4 ++-- test/conftest.py | 4 ++-- test/test_add.py | 2 +- test/test_create.py | 28 +++++++++++++++++++++++----- test/test_extract.py | 2 +- 10 files changed, 44 insertions(+), 25 deletions(-) diff --git a/app/create.cpp b/app/create.cpp index cb8f4ce..38c2182 100644 --- a/app/create.cpp +++ b/app/create.cpp @@ -15,7 +15,7 @@ void RegisterCreate(CLI::App &app, Context &context) { struct Options { mpqcli::CreateOptions create; // The documented numbering is 1 to 4; StormLib counts the same formats from 0 - std::optional version; + std::optional format_version; }; auto options = std::make_shared(); @@ -36,7 +36,8 @@ void RegisterCreate(CLI::App &app, Context &context) { ->check(game_profile_valid); mpqcli::MpqCreateSettingsOverrides &create = options->create.create_overrides; - sub->add_option("--version", options->version, "Override the MPQ archive version") + sub->add_option("--format-version", options->format_version, + "Override the MPQ archive format version") ->check(CLI::Range(1, 4)) ->group("Game setting overrides"); sub->add_option("--stream-flags", create.stream_flags, "Override stream flags") @@ -66,9 +67,9 @@ void RegisterCreate(CLI::App &app, Context &context) { ->group("Game setting overrides"); sub->callback([options, &context]() { - if (options->version.has_value()) { + if (options->format_version.has_value()) { options->create.create_overrides.mpq_version = - static_cast(options->version.value() - 1); + static_cast(options->format_version.value() - 1); } context.exit_code = mpqcli::Create(options->create, context.err, context.cancelled) ? 0 : 1; }); diff --git a/completion/mpqcli.bash b/completion/mpqcli.bash index 8548a9d..835779b 100644 --- a/completion/mpqcli.bash +++ b/completion/mpqcli.bash @@ -132,7 +132,7 @@ _mpqcli() { case "$prev" in --locale) _mpqcli_words "${locales[*]}"; return ;; -g|--game) _mpqcli_words "${games[*]}"; return ;; - --version) _mpqcli_words "1 2 3 4"; return ;; + --format-version) _mpqcli_words "1 2 3 4"; return ;; -o|--output) _mpqcli_filedir; return ;; -p|--path|--stream-flags|--sector-size|--raw-chunk-size|\ --file-flags1|--file-flags2|--file-flags3|--attr-flags|\ @@ -140,7 +140,7 @@ _mpqcli() { return ;; esac if [[ "$cur" == -* ]]; then - _mpqcli_words "-p --path -o --output -s --sign --locale -g --game --version \ + _mpqcli_words "-p --path -o --output -s --sign --locale -g --game --format-version \ --stream-flags --sector-size --raw-chunk-size --file-flags1 --file-flags2 --file-flags3 \ --attr-flags --flags --compression --compression-next" else diff --git a/completion/mpqcli.fish b/completion/mpqcli.fish index 3ab657d..1533401 100644 --- a/completion/mpqcli.fish +++ b/completion/mpqcli.fish @@ -107,7 +107,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from info' \ # create # create [-p/--path] [-o/--output] [-s/--sign] # [--locale] [-g/--game] -# [--version] [--stream-flags] [--sector-size] [--raw-chunk-size] +# [--format-version] [--stream-flags] [--sector-size] [--raw-chunk-size] # [--file-flags1/2/3] [--attr-flags] # [--flags] [--compression] [--compression-next] complete -c mpqcli -n '__fish_seen_subcommand_from create; and test (__mpqcli_positional_index -s --sign) -eq 1' -F @@ -126,7 +126,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from create' \ -r -f -a "$__mpqcli_game_profiles" # Game setting overrides complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l version -d 'Override MPQ archive version (1-4)' -r -f -a '1 2 3 4' + -l format-version -d 'Override MPQ archive format version (1-4)' -r -f -a '1 2 3 4' complete -c mpqcli -n '__fish_seen_subcommand_from create' \ -l stream-flags -d 'Override stream flags' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ diff --git a/completion/mpqcli.ps1 b/completion/mpqcli.ps1 index 4e54315..4cf796d 100644 --- a/completion/mpqcli.ps1 +++ b/completion/mpqcli.ps1 @@ -87,7 +87,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--locale' = 'Locale to use for added files' '-g' = 'Game profile for MPQ creation' '--game' = 'Game profile for MPQ creation' - '--version' = 'Override the MPQ archive version (1-4)' + '--format-version'= 'Override the MPQ archive format version (1-4)' '--stream-flags' = 'Override stream flags' '--sector-size' = 'Override sector size' '--raw-chunk-size'= 'Override raw chunk size for MPQ v4' @@ -156,10 +156,10 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl # Options whose *argument* should complete from a static value set $valueOptions = @{ - '--locale' = $locales - '-g' = $gameProfiles - '--game' = $gameProfiles - '--version' = @('1', '2', '3', '4') + '--locale' = $locales + '-g' = $gameProfiles + '--game' = $gameProfiles + '--format-version' = @('1', '2', '3', '4') } # Property options depend on the subcommand (info vs list), handled below diff --git a/completion/mpqcli.zsh b/completion/mpqcli.zsh index b345608..2a6d68f 100644 --- a/completion/mpqcli.zsh +++ b/completion/mpqcli.zsh @@ -102,7 +102,7 @@ _mpqcli_create() { '(-s --sign)'{-s,--sign}'[sign the archive]' \ '--locale[locale for added files]:locale:('"$locales"')' \ '(-g --game)'{-g,--game}'[game profile]:profile:('"$games"')' \ - '--version[MPQ archive version (1-4)]:version:(1 2 3 4)' \ + '--format-version[MPQ archive format version (1-4)]:version:(1 2 3 4)' \ '--stream-flags[override stream flags]:flags' \ '--sector-size[override sector size]:size' \ '--raw-chunk-size[override raw chunk size for MPQ v4]:size' \ diff --git a/docs/commands/create.md b/docs/commands/create.md index 4ec1f68..620cb1b 100644 --- a/docs/commands/create.md +++ b/docs/commands/create.md @@ -20,7 +20,7 @@ Target a specific game version by using the `-g` or `--game` argument. This will $ mpqcli create -g starcraft $ mpqcli create --game wow-wotlk \ --sector-size 16384 \ - --version 3 + --format-version 3 ``` ## Create an MPQ archive from a single file @@ -53,7 +53,7 @@ $ mpqcli create textures/ --output archive.mpq --path textures Use the `-s` or `--sign` argument to cryptographically sign an MPQ archive with the Blizzard weak signature. ```bash -$ mpqcli create --version 1 --sign +$ mpqcli create --format-version 1 --sign ``` ## Create an MPQ archive with a given locale diff --git a/test/conftest.py b/test/conftest.py index f997b55..870cde9 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -96,7 +96,7 @@ def generate_locales_mpq_test_files(binary_path): if locale == "": # Default locale - create a new MPQ file result = subprocess.run( - [str(binary_path), "create", "--version", "1", "-o", str(mpq_many_locales_file_name), str(locales_files_dir)], + [str(binary_path), "create", "--format-version", "1", "-o", str(mpq_many_locales_file_name), str(locales_files_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -118,7 +118,7 @@ def generate_locales_mpq_test_files(binary_path): file_path.write_text(content, newline="\n") result = subprocess.run( - [str(binary_path), "create", "--version", "1", "-o", str(mpq_one_locale_file_name), str(locales_files_dir), "--locale", locale], + [str(binary_path), "create", "--format-version", "1", "-o", str(mpq_one_locale_file_name), str(locales_files_dir), "--locale", locale], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True diff --git a/test/test_add.py b/test/test_add.py index 7beabf4..f1301aa 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -1106,7 +1106,7 @@ def create_mpq_archive_for_test(binary_path, script_dir): target_file = target_dir.with_suffix(".mpq") target_file.unlink(missing_ok=True) result = subprocess.run( - [str(binary_path), "create", "--version", "1", str(target_dir)], + [str(binary_path), "create", "--format-version", "1", str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True diff --git a/test/test_create.py b/test/test_create.py index 6d6bf4a..b048192 100644 --- a/test/test_create.py +++ b/test/test_create.py @@ -18,7 +18,7 @@ def test_create_mpq_target_does_not_exist(binary_path, generate_test_files): target_dir = script_dir / "does" / "not" / "exist" result = subprocess.run( - [str(binary_path), "create", "--version", "1", str(target_dir)], + [str(binary_path), "create", "--format-version", "1", str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -47,7 +47,7 @@ def test_create_mpq_versions(binary_path, generate_test_files): # test_create_mpq_already_exists target_file.unlink(missing_ok=True) result = subprocess.run( - [str(binary_path), "create", "--version", str(version), str(target_dir)], + [str(binary_path), "create", "--format-version", str(version), str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -92,7 +92,7 @@ def test_create_mpq_with_output(binary_path, generate_test_files): result = subprocess.run( [ str(binary_path), "create", - "--version", str(version), + "--format-version", str(version), "--file-flags1", "4294967295", "--file-flags2", "4294967295", "--file-flags3", "0", @@ -181,7 +181,7 @@ def test_create_mpq_already_exists(binary_path, generate_test_files): output_file = script_dir / "data" / "mpq_with_output_v1.mpq" result = subprocess.run( - [str(binary_path), "create", "--version", "1", "-o", str(output_file), str(target_dir)], + [str(binary_path), "create", "--format-version", "1", "-o", str(output_file), str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -310,7 +310,7 @@ def test_create_mpq_with_illegal_locale(binary_path, generate_test_files): output_file = script_dir / "data" / "new_mpq.mpq" result = subprocess.run( - [str(binary_path), "create", "--version", "1", "-o", str(output_file), str(target_dir), "--locale", "illegal_locale"], + [str(binary_path), "create", "--format-version", "1", "-o", str(output_file), str(target_dir), "--locale", "illegal_locale"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -851,3 +851,21 @@ def test_create_mpq_from_empty_directory(binary_path, tmp_path): assert info_result.returncode == 0 assert info_result.stdout.strip() == "64", \ f"Expected max-files of 64, got: {info_result.stdout.strip()}" + + +def test_create_old_version_option_is_rejected(binary_path, tmp_path): + input_dir = tmp_path / "files" + input_dir.mkdir() + (input_dir / "a.txt").write_text("A file.") + + result = subprocess.run( + [str(binary_path), "create", "--version", "1", str(input_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=tmp_path, + ) + + # --version is not a create option, so the version number is taken as the input + assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + assert list(tmp_path.glob("*.mpq")) == [] diff --git a/test/test_extract.py b/test/test_extract.py index 3fa6400..199b85b 100644 --- a/test/test_extract.py +++ b/test/test_extract.py @@ -836,7 +836,7 @@ def test_extract_nested_file_from_mpq(binary_path, generate_test_files): 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)], + [str(binary_path), "create", "--format-version", "1", str(target_dir), "-o", str(archive_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True From 89e433327f72c6c6f744aea7845d3e89d0c40b90 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 15 Oct 2026 23:50:42 +1300 Subject: [PATCH 06/20] Rejected an unknown --locale in extract and read --- app/extract.cpp | 4 +++- app/read.cpp | 4 +++- src/commands/extract.cpp | 4 ---- src/commands/read.cpp | 4 ---- test/test_extract.py | 18 +++--------------- test/test_read.py | 17 +++-------------- 6 files changed, 12 insertions(+), 39 deletions(-) diff --git a/app/extract.cpp b/app/extract.cpp index 6217072..20ee0cf 100644 --- a/app/extract.cpp +++ b/app/extract.cpp @@ -5,6 +5,7 @@ #include #include "commands.h" +#include "validators.h" void RegisterExtract(CLI::App &app, Context &context) { auto options = std::make_shared(); @@ -19,7 +20,8 @@ void RegisterExtract(CLI::App &app, Context &context) { "Keep folder structure (default false)"); sub->add_option("-l,--listfile", options->listfile, "File listing content of an MPQ archive") ->check(CLI::ExistingFile); - sub->add_option("--locale", options->locale, "Preferred locale for extracted file"); + sub->add_option("--locale", options->locale, "Preferred locale for extracted file") + ->check(locale_valid); sub->callback([options, &context]() { context.exit_code = mpqcli::Extract(*options, context.err, context.cancelled) ? 0 : 1; diff --git a/app/read.cpp b/app/read.cpp index b195c03..77db569 100644 --- a/app/read.cpp +++ b/app/read.cpp @@ -5,6 +5,7 @@ #include #include "commands.h" +#include "validators.h" void RegisterRead(CLI::App &app, Context &context) { auto options = std::make_shared(); @@ -14,7 +15,8 @@ void RegisterRead(CLI::App &app, Context &context) { sub->add_option("archive", options->archive, "MPQ archive") ->required() ->check(CLI::ExistingFile); - sub->add_option("--locale", options->locale, "Preferred locale for read file"); + sub->add_option("--locale", options->locale, "Preferred locale for read file") + ->check(locale_valid); sub->callback([options, &context]() { context.exit_code = mpqcli::Read(*options, context.out, context.err) ? 0 : 1; diff --git a/src/commands/extract.cpp b/src/commands/extract.cpp index 8185700..2579e9d 100644 --- a/src/commands/extract.cpp +++ b/src/commands/extract.cpp @@ -48,10 +48,6 @@ bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic Archive archive = Archive::Open(options.archive, MPQ_OPEN_READ_ONLY); LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; - if (options.locale.has_value() && lcid == default_locale) { - err << "[!] The locale '" << options.locale.value() - << "' is unknown. Will use default locale instead." << std::endl; - } int result; if (options.file.has_value()) { diff --git a/src/commands/read.cpp b/src/commands/read.cpp index 6b1a239..069fbcd 100644 --- a/src/commands/read.cpp +++ b/src/commands/read.cpp @@ -16,10 +16,6 @@ bool Read(const ReadOptions &options, std::ostream &out, std::ostream &err) { Archive archive = Archive::Open(options.archive, MPQ_OPEN_READ_ONLY); LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; - if (options.locale.has_value() && lcid == default_locale) { - err << "[!] The locale '" << options.locale.value() - << "' is unknown. Will use default locale instead." << std::endl; - } uint32_t file_size; auto file_content = diff --git a/test/test_extract.py b/test/test_extract.py index 199b85b..8d77e80 100644 --- a/test/test_extract.py +++ b/test/test_extract.py @@ -283,8 +283,7 @@ def test_extract_file_from_mpq_with_illegal_locale(binary_path, generate_locales Test MPQ archive file extraction with an illegal locale. This test checks: - - When a locale is given that does not exist in the file, the file with the default locale is extracted. - - If the output files match the expected files. + - That an unknown locale is rejected and nothing is extracted. """ _ = generate_locales_mpq_test_files script_dir = Path(__file__).parent @@ -295,7 +294,6 @@ def test_extract_file_from_mpq_with_illegal_locale(binary_path, generate_locales if output_dir.exists(): shutil.rmtree(output_dir) - result = subprocess.run( [str(binary_path), "extract", "-o", str(output_dir), "-f", file_to_extract, str(test_file), "--locale", locale], stdout=subprocess.PIPE, @@ -303,18 +301,8 @@ def test_extract_file_from_mpq_with_illegal_locale(binary_path, generate_locales text=True ) - expected_stderr = { - "[!] The locale 'nosuchlocale' is unknown. Will use default locale instead.", - "[*] Extracted: " + file_to_extract - } - output_file = output_dir / file_to_extract - expected_content = "This is a file about cats." - - output_lines = set(result.stderr.splitlines()) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" - assert output_file.exists(), "Output directory was not created" - assert output_file.read_text(encoding="utf-8") == expected_content, "Unexpected file content" + assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + assert not output_dir.exists(), "Output directory was created" def test_extract_file_from_mpq_with_locale_not_in_file(binary_path, generate_locales_mpq_test_files): diff --git a/test/test_read.py b/test/test_read.py index 8fbf02a..466de66 100644 --- a/test/test_read.py +++ b/test/test_read.py @@ -110,8 +110,7 @@ def test_read_file_from_mpq_with_illegal_locale(binary_path, generate_locales_mp Test MPQ archive file reading with an illegal locale. This test checks: - - When a locale is given that does not exist in the file, the file with the default locale is read. - - That the file content is correct. + - That an unknown locale is rejected and nothing is read. """ _ = generate_locales_mpq_test_files script_dir = Path(__file__).parent @@ -119,7 +118,6 @@ def test_read_file_from_mpq_with_illegal_locale(binary_path, generate_locales_mp file_to_read = "cats.txt" locale = "nosuchlocale" - result = subprocess.run( [str(binary_path), "read", file_to_read, str(test_file), "--locale", locale], stdout=subprocess.PIPE, @@ -127,17 +125,8 @@ def test_read_file_from_mpq_with_illegal_locale(binary_path, generate_locales_mp text=True ) - expected_content = { - "This is a file about cats.", - } - expected_warning = { - "[!] The locale 'nosuchlocale' is unknown. Will use default locale instead.", - } - - output_lines = set(result.stdout.splitlines()) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_content, f"Unexpected output: {output_lines}" - assert set(result.stderr.splitlines()) == expected_warning, f"Unexpected stderr: {result.stderr}" + assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + assert result.stdout == "", f"Unexpected output: {result.stdout}" def test_read_file_from_mpq_with_locale_not_in_file(binary_path, generate_locales_mpq_test_files): From bf36718c6aaeaa2a881483042d14fcc3b0a21023 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 15 Oct 2026 23:56:31 +1300 Subject: [PATCH 07/20] Reversed read to take the archive first --- app/read.cpp | 2 +- completion/mpqcli.bash | 6 +++--- completion/mpqcli.fish | 8 ++++---- completion/mpqcli.zsh | 4 ++-- docs/commands/read.md | 8 ++++---- src/commands/read.h | 2 +- test/test_add.py | 2 +- test/test_read.py | 38 ++++++++++++++++++++++++++++---------- 8 files changed, 44 insertions(+), 26 deletions(-) diff --git a/app/read.cpp b/app/read.cpp index 77db569..c37ee0f 100644 --- a/app/read.cpp +++ b/app/read.cpp @@ -11,10 +11,10 @@ void RegisterRead(CLI::App &app, Context &context) { auto options = std::make_shared(); auto *sub = app.add_subcommand("read", "Read a file from an MPQ archive"); - sub->add_option("file", options->file, "File to read")->required(); sub->add_option("archive", options->archive, "MPQ archive") ->required() ->check(CLI::ExistingFile); + sub->add_option("file", options->file, "File to read")->required(); sub->add_option("--locale", options->locale, "Preferred locale for read file") ->check(locale_valid); diff --git a/completion/mpqcli.bash b/completion/mpqcli.bash index 835779b..c495636 100644 --- a/completion/mpqcli.bash +++ b/completion/mpqcli.bash @@ -242,10 +242,10 @@ _mpqcli() { if [[ "$cur" == -* ]]; then _mpqcli_words "--locale" else - # Positional 1 is an in-archive path, so it gets no filesystem - # completion; positional 2 is the archive + # Positional 1 is the archive; positional 2 is an in-archive path, + # so it gets no filesystem completion _mpqcli_positional_index - if [[ $pos -eq 1 ]]; then + if [[ $pos -eq 0 ]]; then _mpqcli_filedir mpq fi fi diff --git a/completion/mpqcli.fish b/completion/mpqcli.fish index 1533401..7966c0c 100644 --- a/completion/mpqcli.fish +++ b/completion/mpqcli.fish @@ -228,10 +228,10 @@ complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ -r -f -a "$__mpqcli_locales" # read -# read [--locale] -# Positional 1 is an in-archive path, so it gets no filesystem completion; -# positional 2 is the archive -complete -c mpqcli -n '__fish_seen_subcommand_from read; and test (__mpqcli_positional_index) -eq 2' -F +# read [--locale] +# Positional 1 is the archive; positional 2 is an in-archive path, so it gets +# no filesystem completion +complete -c mpqcli -n '__fish_seen_subcommand_from read; and test (__mpqcli_positional_index) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from read' \ -l locale -d 'Preferred locale for read file' \ diff --git a/completion/mpqcli.zsh b/completion/mpqcli.zsh index 2a6d68f..803244c 100644 --- a/completion/mpqcli.zsh +++ b/completion/mpqcli.zsh @@ -171,8 +171,8 @@ _mpqcli_extract() { _mpqcli_read() { local locales="${_mpqcli_locales[*]}" _arguments \ - '1:file-in-archive' \ - '2:archive:_mpqcli_archives' \ + '1:archive:_mpqcli_archives' \ + '2:file-in-archive' \ '--locale[preferred locale for read file]:locale:('"$locales"')' } diff --git a/docs/commands/read.md b/docs/commands/read.md index 0d6952e..b577888 100644 --- a/docs/commands/read.md +++ b/docs/commands/read.md @@ -7,7 +7,7 @@ Read a specific file to stdout. Read the `patch.cmd` file from an MPQ archive and print the file contents to stdout. Even though the subcommand always outputs bytes, plaintext files will be human-readable. ```bash -$ mpqcli read patch.cmd wow-patch.mpq +$ mpqcli read wow-patch.mpq patch.cmd * set the product patch name PatchVersion This patch upgrades World of Warcraft from version 1.10.0.5195 to version 1.10.1.5230. * make sure that we don't patch version 1.10.1.5230 or greater @@ -24,13 +24,13 @@ PatchSize 1992294400 The tool will always print output in bytes and is designed to be "piped" or redirected. For example, redirect the binary `WoW.exe` file from a WoW patch to a local file: ```bash -$ mpqcli read "WoW.exe" wow-patch.mpq > WoW.exe +$ mpqcli read wow-patch.mpq "WoW.exe" > WoW.exe ``` Another example, piping the bytes to the `xxd` tool. ```bash -$ mpqcli read "WoW.exe" wow-patch.mpq | xxd +$ mpqcli read wow-patch.mpq "WoW.exe" | xxd 00000000: 1800 0404 de2a a5da 3240 4500 3250 4500 .....*..2@E.2PE. 00000010: 69ac 2703 0859 c601 0a7a 4500 8942 5344 i.'..Y...zE..BSD 00000020: 4946 4634 30a0 2905 8203 f244 0482 3250 IFF40.)....D..2P @@ -42,5 +42,5 @@ $ mpqcli read "WoW.exe" wow-patch.mpq | xxd Use the `--locale` argument to specify the locale of the file to read. If there is no file with the requested name and locale, the default locale will be used instead. ```bash -$ mpqcli read "rez\stat_txt.tbl" Patch_rt.mpq --locale ptPT +$ mpqcli read Patch_rt.mpq "rez\stat_txt.tbl" --locale ptPT ``` diff --git a/src/commands/read.h b/src/commands/read.h index 01ecc63..c930fb6 100644 --- a/src/commands/read.h +++ b/src/commands/read.h @@ -10,8 +10,8 @@ namespace mpqcli { /// Settled arguments for the read subcommand struct ReadOptions { - std::string file; std::filesystem::path archive; + std::string file; std::optional locale; }; diff --git a/test/test_add.py b/test/test_add.py index f1301aa..a0b44ac 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -1188,7 +1188,7 @@ def verify_archive_file_content(binary_path, test_file, expected_output): assert output_lines == expected_output, f"Unexpected output: {output_lines}" def verify_file_in_mpq_has_content(binary_path, mpq_archive, file_name, expected_content, locale=None): - cmd = [str(binary_path), "read", file_name, str(mpq_archive)] + cmd = [str(binary_path), "read", str(mpq_archive), file_name] if locale is not None: cmd += ["--locale", locale] result = subprocess.run( diff --git a/test/test_read.py b/test/test_read.py index 466de66..c201e5a 100644 --- a/test/test_read.py +++ b/test/test_read.py @@ -11,7 +11,7 @@ def test_read_mpq_v1_plain(binary_path): } result = subprocess.run( - [str(binary_path), "read", "cats.txt", str(test_file)], + [str(binary_path), "read", str(test_file), "cats.txt"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -33,7 +33,7 @@ def test_read_mpq_v1_hex(binary_path): } result = subprocess.run( - [str(binary_path), "read", "bytes", str(test_file)], + [str(binary_path), "read", str(test_file), "bytes"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -60,7 +60,7 @@ def test_read_file_from_mpq_with_locale(binary_path, generate_locales_mpq_test_f locale = "deDE" result = subprocess.run( - [str(binary_path), "read", file_to_read, str(test_file), "--locale", locale], + [str(binary_path), "read", str(test_file), file_to_read, "--locale", locale], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -90,7 +90,7 @@ def test_read_file_from_mpq_with_default_locale(binary_path, generate_locales_mp result = subprocess.run( - [str(binary_path), "read", file_to_read, str(test_file)], + [str(binary_path), "read", str(test_file), file_to_read], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -119,7 +119,7 @@ def test_read_file_from_mpq_with_illegal_locale(binary_path, generate_locales_mp locale = "nosuchlocale" result = subprocess.run( - [str(binary_path), "read", file_to_read, str(test_file), "--locale", locale], + [str(binary_path), "read", str(test_file), file_to_read, "--locale", locale], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -144,7 +144,7 @@ def test_read_file_from_mpq_with_locale_not_in_file(binary_path, generate_locale locale = "ptPT" # There is no file for this locale result = subprocess.run( - [str(binary_path), "read", file_to_read, str(test_file), "--locale", locale], + [str(binary_path), "read", str(test_file), file_to_read, "--locale", locale], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -174,7 +174,7 @@ def test_read_file_from_mpq_with_no_locale_argument_and_no_default_locale(binary result = subprocess.run( - [str(binary_path), "read", file_to_read, str(test_file)], + [str(binary_path), "read", str(test_file), file_to_read], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -209,7 +209,7 @@ def test_read_file_from_mpq_with_wrong_locale_argument_and_no_default_locale(bin result = subprocess.run( - [str(binary_path), "read", file_to_read, str(test_file), "--locale", locale], + [str(binary_path), "read", str(test_file), file_to_read, "--locale", locale], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -233,7 +233,7 @@ def test_read_mpq_target_does_not_exist(binary_path): target_file = script_dir / "does" / "not" / "exist.mpq" result = subprocess.run( - [str(binary_path), "read", "cats.txt", str(target_file)], + [str(binary_path), "read", str(target_file), "cats.txt"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -247,7 +247,7 @@ def test_read_file_does_not_exist_in_mpq(binary_path): test_file = script_dir / "data" / "mpq_with_output_v1.mpq" result = subprocess.run( - [str(binary_path), "read", "does-not-exist.txt", str(test_file)], + [str(binary_path), "read", str(test_file), "does-not-exist.txt"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -256,3 +256,21 @@ def test_read_file_does_not_exist_in_mpq(binary_path): assert result.returncode == 1, f"Unexpected exit code: {result.returncode}" assert result.stdout == "", f"Unexpected stdout: {result.stdout}" assert "does-not-exist.txt" in result.stderr, f"Unexpected stderr: {result.stderr}" + + +def test_read_old_argument_order_is_rejected(binary_path, generate_locales_mpq_test_files, tmp_path): + _ = generate_locales_mpq_test_files + script_dir = Path(__file__).parent + test_file = script_dir / "data" / "mpq_with_many_locales.mpq" + + result = subprocess.run( + [str(binary_path), "read", "cats.txt", str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=tmp_path, + ) + + # The file name is taken as the archive, and no local file has that name + assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + assert result.stdout == "" From 2de427610818e2e96baccd075d5cf071933d1a01 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 00:13:46 +1300 Subject: [PATCH 08/20] Made extract take files as positionals or stdin, replacing -f and --keep with --flat --- app/extract.cpp | 41 ++++++--- completion/mpqcli.bash | 7 +- completion/mpqcli.fish | 10 +-- completion/mpqcli.ps1 | 8 +- completion/mpqcli.zsh | 4 +- docs/advanced.md | 9 +- docs/commands/extract.md | 41 +++++---- src/commands/extract.cpp | 42 ++++++--- src/commands/extract.h | 8 +- src/mpq/extract.cpp | 71 ++++++++++++---- src/mpq/extract.h | 17 +++- test/test_extract.py | 179 ++++++++++++++++++++++++++++++++++++--- 12 files changed, 343 insertions(+), 94 deletions(-) diff --git a/app/extract.cpp b/app/extract.cpp index 20ee0cf..ea714b1 100644 --- a/app/extract.cpp +++ b/app/extract.cpp @@ -1,29 +1,50 @@ #include "commands/extract.h" #include +#include +#include #include #include "commands.h" +#include "util/path.h" #include "validators.h" void RegisterExtract(CLI::App &app, Context &context) { - auto options = std::make_shared(); + struct Options { + mpqcli::ExtractOptions extract; + // Names inside the archive, or - for the lines on stdin, settled into extract.files + // by the callback + std::vector files; + }; + + auto options = std::make_shared(); auto *sub = app.add_subcommand("extract", "Extract files from the MPQ archive"); - sub->add_option("archive", options->archive, "MPQ archive") + sub->add_option("archive", options->extract.archive, "MPQ archive") ->required() ->check(CLI::ExistingFile); - sub->add_option("-o,--output", options->output, "Output directory"); - sub->add_option("-f,--file", options->file, "Target file to extract"); - sub->add_flag("-k,--keep", options->keep_folder_structure, - "Keep folder structure (default false)"); - sub->add_option("-l,--listfile", options->listfile, "File listing content of an MPQ archive") - ->check(CLI::ExistingFile); - sub->add_option("--locale", options->locale, "Preferred locale for extracted file") + CLI::Option *files = + sub->add_option("files", options->files, + "Files to extract, or every file when omitted; pass - to read " + "names from stdin"); + sub->add_option("-o,--output", options->extract.output, "Output directory"); + sub->add_flag("--flat", options->extract.flat, + "Write files into the output directory without their folders"); + sub->add_option("-l,--listfile", options->extract.listfile, + "File listing content of an MPQ archive, when extracting every file") + ->check(CLI::ExistingFile) + ->excludes(files); + sub->add_option("--locale", options->extract.locale, "Preferred locale for extracted file") ->check(locale_valid); sub->callback([options, &context]() { - context.exit_code = mpqcli::Extract(*options, context.err, context.cancelled) ? 0 : 1; + // An empty stdin extracts nothing rather than everything, so a pipeline whose + // filter matched no names cannot unpack the whole archive + if (!options->files.empty()) { + options->extract.files = mpqcli::ExpandStdinMarker(options->files, context.in); + } + context.exit_code = + mpqcli::Extract(options->extract, context.err, context.cancelled) ? 0 : 1; }); } diff --git a/completion/mpqcli.bash b/completion/mpqcli.bash index c495636..f7ca3af 100644 --- a/completion/mpqcli.bash +++ b/completion/mpqcli.bash @@ -223,12 +223,13 @@ _mpqcli() { -o|--output) _mpqcli_filedir -d; return ;; -l|--listfile) _mpqcli_filedir; return ;; --locale) _mpqcli_words "${locales[*]}"; return ;; - -f|--file) return ;; esac if [[ "$cur" == -* ]]; then - _mpqcli_words "-o --output -f --file -k --keep -l --listfile --locale" + _mpqcli_words "-o --output --flat -l --listfile --locale" else - _mpqcli_positional_index -k --keep + # Positional 1 is the archive; the rest are in-archive paths, so + # they get no filesystem completion + _mpqcli_positional_index --flat if [[ $pos -eq 0 ]]; then _mpqcli_filedir mpq fi diff --git a/completion/mpqcli.fish b/completion/mpqcli.fish index 7966c0c..40bfa52 100644 --- a/completion/mpqcli.fish +++ b/completion/mpqcli.fish @@ -211,16 +211,16 @@ complete -c mpqcli -n '__fish_seen_subcommand_from list' \ -r -f -a "$__mpqcli_list_properties" # extract -# extract [-o/--output] [-f/--file] [-k/--keep] +# extract [files...] [-o/--output] [--flat] # [-l/--listfile] [--locale] -complete -c mpqcli -n '__fish_seen_subcommand_from extract; and test (__mpqcli_positional_index -k --keep) -eq 1' -F +# Positional 1 is the archive; the rest are in-archive paths, so they get no +# filesystem completion +complete -c mpqcli -n '__fish_seen_subcommand_from extract; and test (__mpqcli_positional_index --flat) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ -s o -l output -d 'Output directory' -r -f -a '(__fish_complete_directories)' complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ - -s f -l file -d 'Target file to extract' -r -f -complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ - -s k -l keep -d 'Keep folder structure' + -l flat -d 'Write files without their folders' complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ -s l -l listfile -d 'External file listing content of the archive' -r -F complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ diff --git a/completion/mpqcli.ps1 b/completion/mpqcli.ps1 index 4cf796d..7834fc1 100644 --- a/completion/mpqcli.ps1 +++ b/completion/mpqcli.ps1 @@ -131,10 +131,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl 'extract' = [ordered]@{ '-o' = 'Output directory' '--output' = 'Output directory' - '-f' = 'Target file to extract' - '--file' = 'Target file to extract' - '-k' = 'Keep folder structure' - '--keep' = 'Keep folder structure' + '--flat' = 'Write files into the output directory without their folders' '-l' = 'File listing content of an MPQ archive' '--listfile' = 'File listing content of an MPQ archive' '--locale' = 'Preferred locale for extracted file' @@ -246,8 +243,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl } } # Options that take a file/dir path -> let PowerShell complete paths - $pathOptions = @('-o', '--output', '-l', '--listfile', '-f', '--file', - '-p', '--path') + $pathOptions = @('-o', '--output', '-l', '--listfile', '-p', '--path') if ($prevToken -in $pathOptions) { return # empty -> native path completion takes over } diff --git a/completion/mpqcli.zsh b/completion/mpqcli.zsh index 803244c..4e998cb 100644 --- a/completion/mpqcli.zsh +++ b/completion/mpqcli.zsh @@ -161,9 +161,9 @@ _mpqcli_extract() { local locales="${_mpqcli_locales[*]}" _arguments \ '1:archive:_mpqcli_archives' \ + '*:archive paths' \ '(-o --output)'{-o,--output}'[output directory]:dir:_files -/' \ - '(-f --file)'{-f,--file}'[target file to extract]:file' \ - '(-k --keep)'{-k,--keep}'[keep folder structure]' \ + '--flat[write files without their folders]' \ '(-l --listfile)'{-l,--listfile}'[listfile path]:file:_files' \ '--locale[preferred locale for extracted file]:locale:('"$locales"')' } diff --git a/docs/advanced.md b/docs/advanced.md index d334761..7552929 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -7,7 +7,7 @@ The `mpqcli` tool has no native search feature - instead, it is designed to be i The following command lists all files in an MPQ archive, and each filename is filtered using `grep` - selecting files ending in `.exe` (note: `.` is a regex wildcard matching any character; use `\.exe` for a strictly literal match) - which is then passed back to `mpqcli extract`. The result: search and extract all `exe` files. ```bash -$ mpqcli list wow-patch.mpq | grep -i \.exe | xargs -I@ mpqcli extract -f "@" wow-patch.mpq +$ mpqcli list wow-patch.mpq | grep -i \.exe | mpqcli extract wow-patch.mpq - [*] Extracted: Launcher.exe [*] Extracted: BackgroundDownloader.exe [*] Extracted: WoW.exe @@ -16,11 +16,10 @@ $ mpqcli list wow-patch.mpq | grep -i \.exe | xargs -I@ mpqcli extract -f "@" wo [*] Extracted: WowError.exe ``` -Note that directories are specified with backslashes, which need to be escaped (due to how grep and xargs handles them). The following example extracts all `dat` files in the `arr` directory from the `StarDat.mpq` archive and handles escaping via `sed`. +Note that directories are specified with backslashes. The names reach `mpqcli extract` on stdin unchanged, so only the backslash in the `grep` pattern needs escaping. The following example extracts all `dat` files in the `arr` directory from the `StarDat.mpq` archive, keeping the folder. ```bash -$ mpqcli list -l scbw.txt StarDat.mpq | grep -i "arr\\\.*dat$" | sort | sed 's|\\|\\\\|g' | \ - xargs -I@ mpqcli extract -f "@" -k StarDat.mpq +$ mpqcli list -l scbw.txt StarDat.mpq | grep -i 'arr\\.*dat$' | sort | mpqcli extract StarDat.mpq - [*] Extracted: arr/flingy.dat [*] Extracted: arr/images.dat [*] Extracted: arr/mapdata.dat @@ -40,7 +39,7 @@ The following command lists all files in an MPQ archive, and each filename is fi ```powershell PS> mpqcli.exe list wow-patch.mpq | Select-String -Pattern \.exe | ` - ForEach-Object { mpqcli.exe extract -f $_ wow-patch.mpq } + ForEach-Object { mpqcli.exe extract wow-patch.mpq $_ } [*] Extracted: Launcher.exe [*] Extracted: BackgroundDownloader.exe [*] Extracted: WoW.exe diff --git a/docs/commands/extract.md b/docs/commands/extract.md index d8c0ee7..7b17ad2 100644 --- a/docs/commands/extract.md +++ b/docs/commands/extract.md @@ -1,10 +1,10 @@ # extract -Extract one or all files from a target MPQ archive. +Extract every file, or only the named files, from an MPQ archive. ## Extract all files from an MPQ archive -The output will be saved in a folder with the same name as the target MPQ file, without the extension. +The output will be saved in a folder with the same name as the MPQ archive, without the extension. Files keep the folder structure they have in the archive. ```bash $ mpqcli extract wow-patch.mpq @@ -12,27 +12,27 @@ $ mpqcli extract wow-patch.mpq [*] Extracted: Blizzard_CraftUI.xml [*] Extracted: CreatureSoundData.dbc ... -[*] Extracted: Blizzard_CraftUI.lua +[*] Extracted: Interface/FrameXML/BlizzardFrameXML.lua [*] Extracted: 30ee7bd3959906e358eff01332cf045e.blp [*] Extracted: realmlist.wtf ``` -## Extract all files to a target directory +## Extract all files to an output directory -Extract files to a specific target directory, which will be created if it doesn't already exist. In this example, `patch-1.10` is the user-specified output directory. +Extract files to a specific output directory, which will be created if it doesn't already exist. In this example, `patch-1.10` is the user-specified output directory. ```bash $ mpqcli extract -o patch-1.10 wow-patch.mpq ``` -## Extract all files keeping the folder structure +## Extract all files without the folder structure -Use the `-k` or `--keep` flag to preserve the directory structure from the archive when extracting. Without this flag, all files are placed flat in the output directory, with only the filename retained. +Use the `--flat` flag to write every file straight into the output directory, keeping only the filename. If two files would end up with the same name, nothing is extracted. ```bash -$ mpqcli extract -k wow-patch.mpq -[*] Extracted: Interface/FrameXML/BlizzardFrameXML.lua -[*] Extracted: Interface/FrameXML/BlizzardFrameXML.xml +$ mpqcli extract --flat wow-patch.mpq +[*] Extracted: BlizzardFrameXML.lua +[*] Extracted: BlizzardFrameXML.xml ``` ## Extract all files with an external listfile @@ -43,13 +43,24 @@ Older MPQ archives do not contain (complete) file paths of their content. By pro $ mpqcli extract -l path/to/listfile War2Dat.mpq ``` -## Extract one specific file +The listfile only applies when extracting every file, so it cannot be combined with named files. + +## Extract specific files + +Name one or more files after the archive to extract only those. A file nested in a directory needs its full path in the archive, and is extracted with its folders; add `--flat` to drop them. Similar to the examples above, you can use the `-o` argument to specify the output directory. + +```bash +$ mpqcli extract "World of Warcraft_1.12.1.5875/Data/base.MPQ" \ + "Documentation\Layout\Greeting.html" +[*] Extracted: Documentation/Layout/Greeting.html +``` + +## Extract files named on stdin -Extract a single file using the `-f` option. If the target file in the MPQ archive is nested (in a directory) you need to include the full path. Similar to the examples above, you can use the `-o` argument to specify the output directory. +Pass `-` in place of the file names to read them from stdin, one per line. If no names arrive, nothing is extracted. See the [advanced examples](../advanced.md) for searching an archive and extracting the matches. ```bash -$ mpqcli extract -f "Documentation\Layout\Greeting.html" \ - "World of Warcraft_1.12.1.5875/Data/base.MPQ" +$ mpqcli list wow-patch.mpq | grep -i '\.exe$' | mpqcli extract wow-patch.mpq - ``` ## Extract one specific file with locale @@ -57,5 +68,5 @@ $ mpqcli extract -f "Documentation\Layout\Greeting.html" \ Use the `--locale` argument to specify the locale of the file to extract. If there is no file with the requested name and locale, the default locale will be used instead. ```bash -$ mpqcli extract -f "rez\gluBNRes.res" Patch_rt.mpq --locale deDE +$ mpqcli extract Patch_rt.mpq "rez\gluBNRes.res" --locale deDE ``` diff --git a/src/commands/extract.cpp b/src/commands/extract.cpp index 2579e9d..3c7ec3a 100644 --- a/src/commands/extract.cpp +++ b/src/commands/extract.cpp @@ -3,7 +3,9 @@ #include #include #include +#include #include +#include #include @@ -28,6 +30,32 @@ bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic } else { effective_output = options.output.value(); } + + Archive archive = Archive::Open(options.archive, MPQ_OPEN_READ_ONLY); + + LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; + + // Every name is settled before the output directory is created, so a + // rejected --flat run leaves nothing on disk + const std::vector file_names = + options.files.has_value() + ? options.files.value() + : ListArchivedFiles(archive.Handle(), options.listfile, cancelled); + if (file_names.empty()) { + err << "[*] No files to extract" << std::endl; + archive.Close(); + return true; + } + if (options.flat) { + const auto collision = FindFlatCollision(file_names); + if (collision.has_value()) { + err << "[!] --flat would extract " << collision->first << " and " << collision->second + << " to the same file" << std::endl; + archive.Close(); + return false; + } + } + fs::create_directory(effective_output, ec); if (ec) { std::error_code query_ec; @@ -45,18 +73,8 @@ bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic << effective_output.string() << std::endl; } - Archive archive = Archive::Open(options.archive, MPQ_OPEN_READ_ONLY); - - LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; - - int result; - if (options.file.has_value()) { - result = ExtractFile(archive.Handle(), effective_output, options.file.value(), - options.keep_folder_structure, lcid, err); - } else { - result = ExtractFiles(archive.Handle(), effective_output, options.listfile, lcid, err, - cancelled); - } + const int result = ExtractFiles(archive.Handle(), effective_output, file_names, options.flat, + lcid, err, cancelled); archive.Close(); if (result != 0) { diff --git a/src/commands/extract.h b/src/commands/extract.h index 162df92..c3468e4 100644 --- a/src/commands/extract.h +++ b/src/commands/extract.h @@ -6,6 +6,7 @@ #include #include #include +#include namespace mpqcli { @@ -13,13 +14,14 @@ namespace mpqcli { struct ExtractOptions { std::filesystem::path archive; std::optional output; - std::optional file; - bool keep_folder_structure = false; + /// Files inside the archive; unset extracts every file the archive can name + std::optional> files; + bool flat = false; std::optional listfile; std::optional locale; }; -/// Extracts one file or every file, returning false if any of them failed +/// Extracts the named files, or every file, returning false if any of them failed bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic &cancelled); } // namespace mpqcli diff --git a/src/mpq/extract.cpp b/src/mpq/extract.cpp index 23f70fe..5aff018 100644 --- a/src/mpq/extract.cpp +++ b/src/mpq/extract.cpp @@ -1,12 +1,14 @@ #include "mpq/extract.h" #include -#include #include +#include #include #include #include #include +#include +#include #include @@ -15,15 +17,28 @@ #include "util/format.h" #include "util/locales.h" #include "util/path.h" +#include "util/string.h" namespace fs = std::filesystem; namespace mpqcli { -int ExtractFiles(HANDLE archive, const fs::path &output, - const std::optional &listfile_name, LCID preferred_locale, - std::ostream &err, const std::atomic &cancelled) { - SFileSetLocale(preferred_locale); +namespace { + +/// Returns the path, relative to the output directory, a file is extracted to +std::string ExtractedName(const std::string &file_name, bool flat) { + std::string name = NormalizeFilePath(fs::path(file_name)); + if (flat) { + name = fs::path(name).filename().u8string(); + } + return name; +} + +} // namespace + +std::vector ListArchivedFiles(HANDLE archive, + const std::optional &listfile_name, + const std::atomic &cancelled) { const std::string listfile_string = listfile_name.has_value() ? listfile_name->string() : std::string(); const char *listfile = listfile_name.has_value() ? listfile_string.c_str() : nullptr; @@ -34,7 +49,7 @@ int ExtractFiles(HANDLE archive, const fs::path &output, throw ArchiveError("Failed to find first file in MPQ archive."); } - int32_t result = 0; + std::vector file_names; do { // Closed by hand rather than through ThrowIfCancelled, so the find handle // does not leak on the way out @@ -42,17 +57,43 @@ int ExtractFiles(HANDLE archive, const fs::path &output, SFileFindClose(find_handle); throw Interrupted{}; } - result |= ExtractFile(archive, output, find_data.cFileName, - true, // Keep folder structure - preferred_locale, err); + file_names.emplace_back(find_data.cFileName); } while (SFileFindNextFile(find_handle, &find_data)); SFileFindClose(find_handle); + return file_names; +} + +std::optional> +FindFlatCollision(const std::vector &file_names) { + // Compared without case: MPQ names ignore it, and so do the default Windows + // and macOS filesystems, where the second file would overwrite the first + std::map first_by_extracted_name; + for (const auto &name : file_names) { + auto [it, inserted] = + first_by_extracted_name.emplace(ToLower(ExtractedName(name, true)), name); + // The same file named twice, or listed once per locale, lands on itself + if (!inserted && + ToLower(ExtractedName(it->second, false)) != ToLower(ExtractedName(name, false))) { + return std::make_pair(it->second, name); + } + } + return std::nullopt; +} + +int ExtractFiles(HANDLE archive, const fs::path &output, const std::vector &file_names, + bool flat, LCID preferred_locale, std::ostream &err, + const std::atomic &cancelled) { + int result = 0; + for (const auto &name : file_names) { + ThrowIfCancelled(cancelled); + result |= ExtractFile(archive, output, name, flat, preferred_locale, err); + } return result; } -int ExtractFile(HANDLE archive, const fs::path &output, const std::string &file_name, - bool keep_folder_structure, LCID preferred_locale, std::ostream &err) { +int ExtractFile(HANDLE archive, const fs::path &output, const std::string &file_name, bool flat, + LCID preferred_locale, std::ostream &err) { SFileSetLocale(preferred_locale); if (!FileExistsInArchiveForLocale(archive, file_name.c_str(), preferred_locale) && !FileExistsInArchiveForLocale(archive, file_name.c_str(), default_locale)) { @@ -62,13 +103,7 @@ int ExtractFile(HANDLE archive, const fs::path &output, const std::string &file_ return 1; } - fs::path file_name_path(file_name); - std::string file_name_string = NormalizeFilePath(file_name_path); - - if (!keep_folder_structure) { - file_name_path = fs::path(file_name_string); - file_name_string = file_name_path.filename().u8string(); - } + const std::string file_name_string = ExtractedName(file_name, flat); std::error_code ec; fs::path output_path_base = fs::absolute(output, ec).lexically_normal(); diff --git a/src/mpq/extract.h b/src/mpq/extract.h index 101afd5..9a65b20 100644 --- a/src/mpq/extract.h +++ b/src/mpq/extract.h @@ -6,19 +6,30 @@ #include #include #include +#include +#include #include namespace mpqcli { -/// Extracts every file the archive can name into output, returning non-zero if any failed +/// Lists every file the archive can name, once per locale it is stored under /// /// @throws ArchiveError if the archive's files cannot be enumerated +std::vector +ListArchivedFiles(HANDLE archive, const std::optional &listfile_name, + const std::atomic &cancelled); + +/// Returns two different files that a flat extraction would write to the same path +std::optional> +FindFlatCollision(const std::vector &file_names); + +/// Extracts each named file into output, returning non-zero if any failed int ExtractFiles(HANDLE archive, const std::filesystem::path &output, - const std::optional &listfile_name, LCID preferred_locale, + const std::vector &file_names, bool flat, LCID preferred_locale, std::ostream &err, const std::atomic &cancelled); int ExtractFile(HANDLE archive, const std::filesystem::path &output, const std::string &file_name, - bool keep_folder_structure, LCID preferred_locale, std::ostream &err); + bool flat, LCID preferred_locale, std::ostream &err); } // namespace mpqcli diff --git a/test/test_extract.py b/test/test_extract.py index 8d77e80..c918709 100644 --- a/test/test_extract.py +++ b/test/test_extract.py @@ -1,4 +1,5 @@ import os +import platform import shutil import subprocess from pathlib import Path @@ -173,7 +174,7 @@ def test_extract_file_from_mpq_output_directory_specified(binary_path, generate_ } result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), "-f", "".join(expected_output), str(test_file)], + [str(binary_path), "extract", "-o", str(output_dir), str(test_file), "".join(expected_output)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -222,7 +223,7 @@ def test_extract_file_from_mpq_with_locale(binary_path, generate_locales_mpq_tes shutil.rmtree(output_dir) result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), "-f", file_to_extract, str(test_file), "--locale", locale], + [str(binary_path), "extract", "-o", str(output_dir), str(test_file), file_to_extract, "--locale", locale], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -259,7 +260,7 @@ def test_extract_file_from_mpq_with_default_locale(binary_path, generate_locales result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), "-f", file_to_extract, str(test_file)], + [str(binary_path), "extract", "-o", str(output_dir), str(test_file), file_to_extract], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -295,7 +296,7 @@ def test_extract_file_from_mpq_with_illegal_locale(binary_path, generate_locales shutil.rmtree(output_dir) result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), "-f", file_to_extract, str(test_file), "--locale", locale], + [str(binary_path), "extract", "-o", str(output_dir), str(test_file), file_to_extract, "--locale", locale], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -323,7 +324,7 @@ def test_extract_file_from_mpq_with_locale_not_in_file(binary_path, generate_loc shutil.rmtree(output_dir) result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), "-f", file_to_extract, str(test_file), "--locale", locale], + [str(binary_path), "extract", "-o", str(output_dir), str(test_file), file_to_extract, "--locale", locale], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -360,7 +361,7 @@ def test_extract_file_from_mpq_with_no_locale_argument_and_no_default_locale(bin result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), "-f", file_to_extract, str(test_file)], + [str(binary_path), "extract", "-o", str(output_dir), str(test_file), file_to_extract], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -403,7 +404,7 @@ def test_extract_file_from_mpq_with_wrong_locale_argument_and_no_default_locale( result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), "-f", file_to_extract, str(test_file), "--locale", locale], + [str(binary_path), "extract", "-o", str(output_dir), str(test_file), file_to_extract, "--locale", locale], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -539,7 +540,7 @@ def test_extract_single_file_from_mpq_without_providing_listfile(binary_path, ge } result = subprocess.run( - [str(binary_path), "extract", "-o", str(output_dir), "-f", file_to_extract, str(test_file)], + [str(binary_path), "extract", "-o", str(output_dir), str(test_file), file_to_extract], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -842,21 +843,175 @@ def test_extract_nested_file_from_mpq(binary_path, generate_test_files): ) assert result.returncode == 0, f"Failed to add nested file: {result.stderr}" - # Extract the nested file + # Extract the nested file, which keeps its folder result = subprocess.run( - [str(binary_path), "extract", "-f", nested_path, "-o", str(output_dir), str(archive_file)], + [str(binary_path), "extract", "-o", str(output_dir), str(archive_file), nested_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) - expected_stderr = {"[*] Extracted: cats.txt"} + separator = "\\" if platform.system() == "Windows" else "/" + expected_stderr = {f"[*] Extracted: texts{separator}cats.txt"} output_lines = set(result.stderr.splitlines()) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" - extracted_file = output_dir / "cats.txt" + extracted_file = output_dir / "texts" / "cats.txt" assert extracted_file.exists(), "Extracted nested file does not exist" assert extracted_file.read_text(encoding="utf-8") == "This is a file about cats.\n", \ "Unexpected content in extracted nested file" + + # Extract it again with --flat, which drops the folder + flat_dir = script_dir / "data" / "extracted_nested_flat" + if flat_dir.exists(): + shutil.rmtree(flat_dir) + result = subprocess.run( + [str(binary_path), "extract", "--flat", "-o", str(flat_dir), str(archive_file), nested_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert set(result.stderr.splitlines()) == {"[*] Extracted: cats.txt"} + assert set(fi.name for fi in flat_dir.iterdir()) == {"cats.txt"} + + +def test_extract_multiple_files(binary_path, generate_test_files, tmp_path): + _ = generate_test_files + script_dir = Path(__file__).parent + test_file = script_dir / "data" / "mpq_with_output_v1.mpq" + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "extract", "-o", str(output_dir), str(test_file), "cats.txt", "dogs.txt"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert set(result.stderr.splitlines()) == {"[*] Extracted: cats.txt", "[*] Extracted: dogs.txt"} + assert set(fi.name for fi in output_dir.iterdir()) == {"cats.txt", "dogs.txt"} + + +def test_extract_files_via_stdin(binary_path, generate_test_files, tmp_path): + _ = generate_test_files + script_dir = Path(__file__).parent + test_file = script_dir / "data" / "mpq_with_output_v1.mpq" + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "extract", "-o", str(output_dir), str(test_file), "-"], + input="cats.txt\ndogs.txt\n", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert set(fi.name for fi in output_dir.iterdir()) == {"cats.txt", "dogs.txt"} + + +def test_extract_empty_stdin_extracts_nothing(binary_path, generate_test_files, tmp_path): + _ = generate_test_files + script_dir = Path(__file__).parent + test_file = script_dir / "data" / "mpq_with_output_v1.mpq" + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "extract", "-o", str(output_dir), str(test_file), "-"], + input="", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert "[*] No files to extract" in result.stderr + assert not output_dir.exists(), "Output directory was created" + + +def test_extract_flat_name_collision_extracts_nothing(binary_path, tmp_path): + input_dir = tmp_path / "files" + (input_dir / "a").mkdir(parents=True) + (input_dir / "b").mkdir() + (input_dir / "a" / "x.txt").write_text("A") + (input_dir / "b" / "x.txt").write_text("B") + archive_file = tmp_path / "collision.mpq" + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "create", str(input_dir), "-o", str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"Failed to create archive: {result.stderr}" + + result = subprocess.run( + [str(binary_path), "extract", "--flat", "-o", str(output_dir), str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" + assert "to the same file" in result.stderr + assert not output_dir.exists(), "Output directory was created" + + +def test_extract_listfile_with_named_files_is_rejected(binary_path, generate_test_files, tmp_path): + _ = generate_test_files + script_dir = Path(__file__).parent + test_file = script_dir / "data" / "mpq_with_output_v1.mpq" + listfile = tmp_path / "listfile.txt" + listfile.write_text("cats.txt\n") + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "extract", "-o", str(output_dir), "-l", str(listfile), str(test_file), "cats.txt"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 108, f"mpqcli failed with error: {result.stderr}" + assert not output_dir.exists(), "Output directory was created" + + +def test_extract_old_file_option_is_rejected(binary_path, generate_test_files, tmp_path): + _ = generate_test_files + script_dir = Path(__file__).parent + test_file = script_dir / "data" / "mpq_with_output_v1.mpq" + + result = subprocess.run( + [str(binary_path), "extract", "-f", "cats.txt", str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=tmp_path, + ) + + # -f is not an extract option, so the file name is taken as the archive + assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + assert list(tmp_path.iterdir()) == [] + + +def test_extract_old_keep_flag_is_rejected(binary_path, generate_test_files, tmp_path): + _ = generate_test_files + script_dir = Path(__file__).parent + test_file = script_dir / "data" / "mpq_with_output_v1.mpq" + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "extract", "-k", "-o", str(output_dir), str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 109, f"mpqcli failed with error: {result.stderr}" + assert not output_dir.exists(), "Output directory was created" From dcd1392ebd29eeca9fec315c210d4c057e45d585 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 00:27:06 +1300 Subject: [PATCH 09/20] Made create take the archive first and its files as positionals or stdin --- app/create.cpp | 18 +++- completion/mpqcli.bash | 10 +- completion/mpqcli.fish | 10 +- completion/mpqcli.ps1 | 4 +- completion/mpqcli.zsh | 6 +- docs/commands/create.md | 44 +++++--- docs/commands_list.md | 2 +- docs/installation.md | 2 +- src/commands/create.cpp | 73 +++++++------ src/commands/create.h | 7 +- test/conftest.py | 10 +- test/test_add.py | 8 +- test/test_compact.py | 2 +- test/test_create.py | 234 ++++++++++++++++++++++++++++------------ test/test_extract.py | 4 +- 15 files changed, 279 insertions(+), 155 deletions(-) diff --git a/app/create.cpp b/app/create.cpp index 38c2182..2372385 100644 --- a/app/create.cpp +++ b/app/create.cpp @@ -3,30 +3,38 @@ #include #include #include +#include +#include #include #include #include "commands.h" #include "gamerules/rules.h" +#include "util/path.h" #include "validators.h" void RegisterCreate(CLI::App &app, Context &context) { struct Options { mpqcli::CreateOptions create; + // Local paths, or - for the lines on stdin, settled into create.files by the callback + std::vector files; // The documented numbering is 1 to 4; StormLib counts the same formats from 0 std::optional format_version; }; auto options = std::make_shared(); - auto *sub = app.add_subcommand("create", "Create an MPQ archive from target file or directory"); + auto *sub = app.add_subcommand("create", "Create an MPQ archive from files and directories"); - sub->add_option("target", options->create.target, "Directory or file to put in MPQ archive") + sub->add_option("archive", options->create.archive, "MPQ archive to create") ->required() - ->check(CLI::ExistingPath); + ->check(CLI::NonexistentPath); + sub->add_option("files", options->files, + "Files or directories to add; pass - to read paths from stdin") + ->required() + ->expected(-1); sub->add_option("-p,--path", options->create.path, "Archive path for a single file, or prefix for a directory"); - sub->add_option("-o,--output", options->create.output, "Output MPQ archive"); sub->add_flag("-s,--sign", options->create.sign, "Sign the MPQ archive (default false)"); sub->add_option("--locale", options->create.locale, "Locale to use for added files") ->check(locale_valid); @@ -67,6 +75,8 @@ void RegisterCreate(CLI::App &app, Context &context) { ->group("Game setting overrides"); sub->callback([options, &context]() { + const auto files = mpqcli::ExpandStdinMarker(options->files, context.in); + options->create.files.assign(files.begin(), files.end()); if (options->format_version.has_value()) { options->create.create_overrides.mpq_version = static_cast(options->format_version.value() - 1); diff --git a/completion/mpqcli.bash b/completion/mpqcli.bash index f7ca3af..37f7df5 100644 --- a/completion/mpqcli.bash +++ b/completion/mpqcli.bash @@ -133,21 +133,19 @@ _mpqcli() { --locale) _mpqcli_words "${locales[*]}"; return ;; -g|--game) _mpqcli_words "${games[*]}"; return ;; --format-version) _mpqcli_words "1 2 3 4"; return ;; - -o|--output) _mpqcli_filedir; return ;; -p|--path|--stream-flags|--sector-size|--raw-chunk-size|\ --file-flags1|--file-flags2|--file-flags3|--attr-flags|\ --flags|--compression|--compression-next) return ;; esac if [[ "$cur" == -* ]]; then - _mpqcli_words "-p --path -o --output -s --sign --locale -g --game --format-version \ + _mpqcli_words "-p --path -s --sign --locale -g --game --format-version \ --stream-flags --sector-size --raw-chunk-size --file-flags1 --file-flags2 --file-flags3 \ --attr-flags --flags --compression --compression-next" else - _mpqcli_positional_index -s --sign - if [[ $pos -eq 0 ]]; then - _mpqcli_filedir - fi + # Positional 1 is the new archive; the rest are files or directories + # to add, so every positional takes a path + _mpqcli_filedir fi ;; diff --git a/completion/mpqcli.fish b/completion/mpqcli.fish index 40bfa52..1156c4a 100644 --- a/completion/mpqcli.fish +++ b/completion/mpqcli.fish @@ -76,7 +76,7 @@ complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ -a info -d 'Prints info about an MPQ archive' complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ - -a create -d 'Create an MPQ archive from target file or directory' + -a create -d 'Create an MPQ archive from files and directories' complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ -a add -d 'Add files to an existing MPQ archive' complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ @@ -105,17 +105,17 @@ complete -c mpqcli -n '__fish_seen_subcommand_from info' \ -r -f -a "$__mpqcli_info_properties" # create -# create [-p/--path] [-o/--output] [-s/--sign] +# create [-p/--path] [-s/--sign] # [--locale] [-g/--game] # [--format-version] [--stream-flags] [--sector-size] [--raw-chunk-size] # [--file-flags1/2/3] [--attr-flags] # [--flags] [--compression] [--compression-next] -complete -c mpqcli -n '__fish_seen_subcommand_from create; and test (__mpqcli_positional_index -s --sign) -eq 1' -F +# Positional 1 is the new archive and the rest are files or directories to add, +# so every positional takes a path +complete -c mpqcli -n '__fish_seen_subcommand_from create' -F complete -c mpqcli -n '__fish_seen_subcommand_from create' \ -s p -l path -d 'Archive path for a single file, or prefix for a directory' -r -f -complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -s o -l output -d 'Output MPQ archive path' -r -F complete -c mpqcli -n '__fish_seen_subcommand_from create' \ -s s -l sign -d 'Sign the MPQ archive' complete -c mpqcli -n '__fish_seen_subcommand_from create' \ diff --git a/completion/mpqcli.ps1 b/completion/mpqcli.ps1 index 7834fc1..901c137 100644 --- a/completion/mpqcli.ps1 +++ b/completion/mpqcli.ps1 @@ -24,7 +24,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl 'version' = 'Prints program version' 'about' = 'Prints program information' 'info' = 'Prints info about an MPQ archive' - 'create' = 'Create an MPQ archive from target file or directory' + 'create' = 'Create an MPQ archive from files and directories' 'add' = 'Add files to an existing MPQ archive' 'remove' = 'Remove files from an existing MPQ archive' 'rename' = 'Rename a file in an existing MPQ archive' @@ -80,8 +80,6 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl 'create' = [ordered]@{ '-p' = 'Archive path for a single file, or prefix for a directory' '--path' = 'Archive path for a single file, or prefix for a directory' - '-o' = 'Output MPQ archive' - '--output' = 'Output MPQ archive' '-s' = 'Sign the MPQ archive' '--sign' = 'Sign the MPQ archive' '--locale' = 'Locale to use for added files' diff --git a/completion/mpqcli.zsh b/completion/mpqcli.zsh index 4e998cb..34b99aa 100644 --- a/completion/mpqcli.zsh +++ b/completion/mpqcli.zsh @@ -66,7 +66,7 @@ _mpqcli_cmds() { 'version:Prints program version' 'about:Prints program information' 'info:Prints info about an MPQ archive' - 'create:Create an MPQ archive from target file or directory' + 'create:Create an MPQ archive from files and directories' 'add:Add files to an existing MPQ archive' 'remove:Remove files from an existing MPQ archive' 'rename:Rename a file in an existing MPQ archive' @@ -96,9 +96,9 @@ _mpqcli_info() { _mpqcli_create() { local locales="${_mpqcli_locales[*]}" games="${_mpqcli_games[*]}" _arguments \ - '1:target:_files' \ + '1:archive:_files' \ + '*:files:_files' \ '(-p --path)'{-p,--path}'[archive path for a single file, or prefix for a directory]:path' \ - '(-o --output)'{-o,--output}'[output archive]:file:_files' \ '(-s --sign)'{-s,--sign}'[sign the archive]' \ '--locale[locale for added files]:locale:('"$locales"')' \ '(-g --game)'{-g,--game}'[game profile]:profile:('"$games"')' \ diff --git a/docs/commands/create.md b/docs/commands/create.md index 620cb1b..0887e6a 100644 --- a/docs/commands/create.md +++ b/docs/commands/create.md @@ -1,26 +1,42 @@ # create -Create an MPQ archive from a target directory or a single file. +Create an MPQ archive from files and directories. -## Create an MPQ archive from a target directory +## Create an MPQ archive from a directory -Create an MPQ file from a target directory. Automatically adds `(listfile)` to the archive, and will skip this file if it exists in the target directory. +Name the new archive first, then the directory to put in it. Automatically adds `(listfile)` to the archive, and will skip this file if it exists in the directory. ```bash -$ mpqcli create +$ mpqcli create archive.mpq ``` -The default mode of operation for the `create` subcommand is to take everything from the "target" directory (and below) and recursively add it to the archive. The directory structure is retained. Windows-style backslash path separators are used (`\`), as per the observed behaviour in most MPQ archives. +The default mode of operation for the `create` subcommand is to take everything from the directory (and below) and recursively add it to the archive. The directory's contents go at the root of the archive, and the structure below it is retained. Windows-style backslash path separators are used (`\`), as per the observed behaviour in most MPQ archives. + +The archive must not already exist; `create` never overwrites a file. + +## Create an MPQ archive from several files and directories + +Name as many files and directories as needed after the archive. Each file is stored under its own name, and each directory's contents go at the root of the archive. If two inputs hold a file with the same name, the first one is kept. + +```bash +$ mpqcli create archive.mpq readme.txt textures/ sounds/ +``` + +Pass `-` in place of the files to read their paths from stdin, one per line: + +```bash +$ find art -name '*.blp' | mpqcli create art.mpq - +``` ## Create an MPQ archive for a specific game Target a specific game version by using the `-g` or `--game` argument. This will automatically set the correct archive format version and settings, although they can be overridden. ```bash -$ mpqcli create -g starcraft +$ mpqcli create -g starcraft archive.mpq $ mpqcli create --game wow-wotlk \ --sector-size 16384 \ - --format-version 3 + --format-version 3 archive.mpq ``` ## Create an MPQ archive from a single file @@ -28,22 +44,22 @@ $ mpqcli create --game wow-wotlk \ Create an MPQ file from a single file. ```bash -$ mpqcli create --game diablo2 +$ mpqcli create --game diablo2 archive.mpq ``` ## Control where files are stored -For single files, one can specify both directory and filename in one step using `-p` or `--path`: +For a single file, one can specify both directory and filename in one step using `-p` or `--path`: ```bash -$ mpqcli create fts.txt --output archive.mpq --path "texts\swarm.txt" +$ mpqcli create archive.mpq fts.txt --path "texts\swarm.txt" [+] Adding file: texts\swarm.txt ``` -For directories, one can specify the base directory using `-p` or `--path`: +For directories, or several inputs, `-p` or `--path` is a prefix that every file is stored under: ```bash -$ mpqcli create textures/ --output archive.mpq --path textures +$ mpqcli create archive.mpq textures/ --path textures [+] Adding file: textures\Creature\Bear\Bear.blp [+] Adding file: textures\Creature\Wolf\Wolf.blp ``` @@ -53,7 +69,7 @@ $ mpqcli create textures/ --output archive.mpq --path textures Use the `-s` or `--sign` argument to cryptographically sign an MPQ archive with the Blizzard weak signature. ```bash -$ mpqcli create --format-version 1 --sign +$ mpqcli create --format-version 1 --sign archive.mpq ``` ## Create an MPQ archive with a given locale @@ -61,5 +77,5 @@ $ mpqcli create --format-version 1 --sign Use the `--locale` argument to specify the locale that all added files will have in the archive. Note that subsequent added files will have the default locale unless the `--locale` argument is specified again. ```bash -$ mpqcli create --locale koKR +$ mpqcli create archive.mpq --locale koKR ``` diff --git a/docs/commands_list.md b/docs/commands_list.md index a40e8a5..b02d2ca 100644 --- a/docs/commands_list.md +++ b/docs/commands_list.md @@ -11,7 +11,7 @@ The `mpqcli` program has the following subcommands: | [`about`](./commands/about.md) | Print information about the tool | | [`completion`](./commands/completion.md) | Print shell completion script to stdout | | [`info`](./commands/info.md) | Print information about MPQ archive properties | -| [`create`](./commands/create.md) | Create an MPQ archive from a target directory or a single file | +| [`create`](./commands/create.md) | Create an MPQ archive from files and directories | | [`add`](./commands/add.md) | Add a file to an existing MPQ archive | | [`remove`](./commands/remove.md) | Remove a file from an existing MPQ archive | | [`rename`](./commands/rename.md) | Rename a file in an existing MPQ archive | diff --git a/docs/installation.md b/docs/installation.md index 73f2b56..54a7d8a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -46,7 +46,7 @@ $ docker run --rm -v "$PWD":/data ghcr.io/thegraydot/mpqcli:latest list example. The container runs as root, so a file it creates or extracts is owned by root on the host. To have the output owned by you, run the container as your own user: ```bash -$ docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/data ghcr.io/thegraydot/mpqcli:latest create -o example.mpq files/ +$ docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/data ghcr.io/thegraydot/mpqcli:latest create example.mpq files/ ``` A shell function makes that the default for everyday use: diff --git a/src/commands/create.cpp b/src/commands/create.cpp index 75f654c..4fa4366 100644 --- a/src/commands/create.cpp +++ b/src/commands/create.cpp @@ -40,26 +40,40 @@ struct PartialArchive { } // namespace bool Create(const CreateOptions &options, std::ostream &err, const std::atomic &cancelled) { + if (options.files.empty()) { + throw Error("No files or directories to add"); + } + std::error_code ec; - fs::path output_file_path; - if (options.output.has_value()) { - output_file_path = fs::absolute(options.output.value(), ec); - if (ec) { - throw FileError("Failed to resolve output path", options.output.value(), ec); - } - } else { - output_file_path = options.target; - // If the path ends with a separator (e.g. "dir/"), strip the - // trailing separator first so we get "dir.mpq" - if (output_file_path.filename().empty()) { - output_file_path = output_file_path.parent_path(); - } - output_file_path.replace_extension(".mpq"); + const fs::path output_file_path = fs::absolute(options.archive, ec); + if (ec) { + throw FileError("Failed to resolve archive path", options.archive, ec); } if (fs::exists(output_file_path, ec)) { throw ArchiveError("File already exists: " + output_file_path.string()); } + // Count every input up front: the archive's max file count is fixed at creation, + // and an input that cannot be read fails here, before the archive exists + uint32_t input_file_count = 0; + for (const auto &f : options.files) { + if (!fs::exists(f, ec)) { + throw FileError("Path does not exist", f); + } + if (fs::is_directory(f, ec)) { + const std::vector directory_files = ListFilesRecursive(f, ec); + if (ec) { + throw FileError("Failed to list directory", f, ec); + } + input_file_count += static_cast(directory_files.size()); + } else if (fs::is_regular_file(f, ec)) { + input_file_count++; + } else { + throw FileError("Not a file or directory", f); + } + } + const uint32_t file_count = CalculateMpqMaxFileValue(input_file_count); + GameProfile profile; if (options.game_profile.has_value()) { profile = GameRules::StringToProfile(options.game_profile.value()); @@ -73,20 +87,6 @@ bool Create(const CreateOptions &options, std::ostream &err, const std::atomic files; - const bool is_directory = fs::is_directory(options.target, ec); - if (is_directory) { - files = ListFilesRecursive(options.target, ec); - if (ec) { - throw FileError("Failed to list directory", options.target, ec); - } - } else if (!fs::is_regular_file(options.target, ec)) { - throw FileError("Not a file or directory", options.target); - } - const uint32_t file_count = - CalculateMpqMaxFileValue(is_directory ? static_cast(files.size()) : 1); - // The archive is invalid until it is closed, so it is written beside the output // and renamed into place once complete; an interrupt or error before that leaves // nothing at the output path. A partial left by an earlier interrupted run would @@ -99,15 +99,14 @@ bool Create(const CreateOptions &options, std::ostream &err, const std::atomic 0) { + err << "[*] " << files_skipped + << " file(s) were skipped because an earlier input added a file by the same name" + << std::endl; } if (options.sign) { diff --git a/src/commands/create.h b/src/commands/create.h index 692b606..c27a850 100644 --- a/src/commands/create.h +++ b/src/commands/create.h @@ -6,6 +6,7 @@ #include #include #include +#include #include "gamerules/settings.h" @@ -13,9 +14,9 @@ namespace mpqcli { /// Settled arguments for the create subcommand struct CreateOptions { - std::filesystem::path target; + std::filesystem::path archive; + std::vector files; std::optional path; - std::optional output; bool sign = false; std::optional locale; std::optional game_profile; @@ -23,7 +24,7 @@ struct CreateOptions { CompressionSettingsOverrides compression_overrides; }; -/// Creates an archive from a file or directory, returning false if any file failed to add +/// Creates an archive from files and directories, returning false if any file failed to add bool Create(const CreateOptions &options, std::ostream &err, const std::atomic &cancelled); } // namespace mpqcli diff --git a/test/conftest.py b/test/conftest.py index 870cde9..4df9d2d 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -96,7 +96,7 @@ def generate_locales_mpq_test_files(binary_path): if locale == "": # Default locale - create a new MPQ file result = subprocess.run( - [str(binary_path), "create", "--format-version", "1", "-o", str(mpq_many_locales_file_name), str(locales_files_dir)], + [str(binary_path), "create", "--format-version", "1", str(mpq_many_locales_file_name), str(locales_files_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -118,7 +118,7 @@ def generate_locales_mpq_test_files(binary_path): file_path.write_text(content, newline="\n") result = subprocess.run( - [str(binary_path), "create", "--format-version", "1", "-o", str(mpq_one_locale_file_name), str(locales_files_dir), "--locale", locale], + [str(binary_path), "create", "--format-version", "1", str(mpq_one_locale_file_name), str(locales_files_dir), "--locale", locale], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -153,7 +153,7 @@ def generate_mpq_without_internal_listfile(binary_path): if locale == "": # Default locale - create a new MPQ file result = subprocess.run( - [str(binary_path), "create", "-o", str(mpq_file_name), str(locales_files_dir), "--file-flags1", "0"], + [str(binary_path), "create", str(mpq_file_name), str(locales_files_dir), "--file-flags1", "0"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -189,7 +189,7 @@ def generate_path_traversal_mpq(binary_path): # Create a base MPQ containing the safe file result = subprocess.run( - [str(binary_path), "create", "-o", str(mpq_file), str(traversal_files_dir)], + [str(binary_path), "create", str(mpq_file), str(traversal_files_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -239,7 +239,7 @@ def generate_tampered_signature_mpq(binary_path): ) result = subprocess.run( - [str(binary_path), "create", "-s", "-o", str(mpq_file), str(tamper_files_dir)], + [str(binary_path), "create", "-s", str(mpq_file), str(tamper_files_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True diff --git a/test/test_add.py b/test/test_add.py index a0b44ac..f462885 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -1106,7 +1106,7 @@ def create_mpq_archive_for_test(binary_path, script_dir): target_file = target_dir.with_suffix(".mpq") target_file.unlink(missing_ok=True) result = subprocess.run( - [str(binary_path), "create", "--format-version", "1", str(target_dir)], + [str(binary_path), "create", "--format-version", "1", str(target_file), str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -1125,7 +1125,7 @@ def create_mpq_archive_with_attrs_for_test(binary_path, script_dir): target_file = target_dir.with_suffix(".mpq") target_file.unlink(missing_ok=True) result = subprocess.run( - [str(binary_path), "create", "--game", "wow1", str(target_dir)], + [str(binary_path), "create", "--game", "wow1", str(target_file), str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -1144,7 +1144,7 @@ def create_mpq_archive_with_crc32_for_test(binary_path, script_dir): target_file = target_dir.with_suffix(".mpq") target_file.unlink(missing_ok=True) result = subprocess.run( - [str(binary_path), "create", "--game", "wc3", str(target_dir)], + [str(binary_path), "create", "--game", "wc3", str(target_file), str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -1163,7 +1163,7 @@ def create_mpq_archive_with_filetime_for_test(binary_path, script_dir): target_file = target_dir.with_suffix(".mpq") target_file.unlink(missing_ok=True) result = subprocess.run( - [str(binary_path), "create", "--game", "wc3", "--attr-flags", "2", str(target_dir)], + [str(binary_path), "create", "--game", "wc3", "--attr-flags", "2", str(target_file), str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True diff --git a/test/test_compact.py b/test/test_compact.py index 1d53fa7..46ffcd9 100644 --- a/test/test_compact.py +++ b/test/test_compact.py @@ -136,7 +136,7 @@ def test_compact_file(binary_path): large_file.write_bytes(random.Random(0).randbytes(large_file_size)) result = subprocess.run( - [str(binary_path), "create", "-o", str(mpq_file), str(compaction_files_dir)], + [str(binary_path), "create", str(mpq_file), str(compaction_files_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True diff --git a/test/test_create.py b/test/test_create.py index b048192..32bcbf3 100644 --- a/test/test_create.py +++ b/test/test_create.py @@ -18,13 +18,14 @@ def test_create_mpq_target_does_not_exist(binary_path, generate_test_files): target_dir = script_dir / "does" / "not" / "exist" result = subprocess.run( - [str(binary_path), "create", "--format-version", "1", str(target_dir)], + [str(binary_path), "create", "--format-version", "1", str(target_dir.with_suffix(".mpq")), str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" + assert not target_dir.with_suffix(".mpq").exists(), "MPQ file was created" def test_create_mpq_versions(binary_path, generate_test_files): @@ -47,7 +48,7 @@ def test_create_mpq_versions(binary_path, generate_test_files): # test_create_mpq_already_exists target_file.unlink(missing_ok=True) result = subprocess.run( - [str(binary_path), "create", "--format-version", str(version), str(target_dir)], + [str(binary_path), "create", "--format-version", str(version), str(target_file), str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -100,7 +101,7 @@ def test_create_mpq_with_output(binary_path, generate_test_files): "--flags", "66048", "--compression", "2", "--compression-next", "4294967295", - "-o", str(output_file), + str(output_file), str(target_dir), ], stdout=subprocess.PIPE, @@ -143,7 +144,7 @@ def test_create_mpq_with_weak_signature(binary_path, generate_test_files): "--flags", "512", "--compression", "2", "--compression-next", "4294967295", - "-o", str(output_file), + str(output_file), str(target_dir), ], stdout=subprocess.PIPE, @@ -181,13 +182,13 @@ def test_create_mpq_already_exists(binary_path, generate_test_files): output_file = script_dir / "data" / "mpq_with_output_v1.mpq" result = subprocess.run( - [str(binary_path), "create", "--format-version", "1", "-o", str(output_file), str(target_dir)], + [str(binary_path), "create", "--format-version", "1", str(output_file), str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) - assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" + assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" def test_create_mpq_from_file(binary_path, generate_test_files): @@ -210,7 +211,7 @@ def test_create_mpq_from_file(binary_path, generate_test_files): # test_create_mpq_already_exists target_file.unlink(missing_ok=True) result = subprocess.run( - [str(binary_path), "create", str(test_file), "--output", str(target_file)], + [str(binary_path), "create", str(target_file), str(test_file)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -243,7 +244,7 @@ def test_create_mpq_from_file_with_path_parameter(binary_path, generate_test_fil # test_create_mpq_already_exists target_file.unlink(missing_ok=True) result = subprocess.run( - [str(binary_path), "create", str(test_file), "--output", str(target_file), "--path", "messages\\important.txt"], + [str(binary_path), "create", str(target_file), str(test_file), "--path", "messages\\important.txt"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -279,7 +280,7 @@ def test_create_mpq_from_directory_with_path_parameter(binary_path, generate_tes # test_create_mpq_already_exists target_file.unlink(missing_ok=True) result = subprocess.run( - [str(binary_path), "create", str(target_dir), "--output", str(target_file), "--path", "messages"], + [str(binary_path), "create", str(target_file), str(target_dir), "--path", "messages"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -310,7 +311,7 @@ def test_create_mpq_with_illegal_locale(binary_path, generate_test_files): output_file = script_dir / "data" / "new_mpq.mpq" result = subprocess.run( - [str(binary_path), "create", "--format-version", "1", "-o", str(output_file), str(target_dir), "--locale", "illegal_locale"], + [str(binary_path), "create", "--format-version", "1", str(output_file), str(target_dir), "--locale", "illegal_locale"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -368,7 +369,7 @@ def test_create_mpq_with_game_profile(binary_path, generate_test_files): output_file.unlink(missing_ok=True) result = subprocess.run( - [str(binary_path), "create", "-g", profile, "-o", str(output_file), str(target_dir)], + [str(binary_path), "create", "-g", profile, str(output_file), str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -425,7 +426,7 @@ def test_create_mpq_with_invalid_game_profile(binary_path, generate_test_files): output_file = script_dir / "data" / "mpq_with_invalid_profile.mpq" result = subprocess.run( - [str(binary_path), "create", "-g", "invalid_profile", "-o", str(output_file), str(target_dir)], + [str(binary_path), "create", "-g", "invalid_profile", str(output_file), str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -472,7 +473,7 @@ def test_create_mpq_with_all_game_profiles(binary_path, generate_test_files): output_file.unlink(missing_ok=True) result = subprocess.run( - [str(binary_path), "create", "-g", profile, "-o", str(output_file), str(target_dir)], + [str(binary_path), "create", "-g", profile, str(output_file), str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -526,7 +527,7 @@ def test_deletion_marker_only_for_zero_size_files(binary_path): try: # Create MPQ with StarCraft2 profile (which has deletion marker rule for size 0) result = subprocess.run( - [str(binary_path), "create", "-g", "starcraft2", "-o", str(output_file), str(test_dir)], + [str(binary_path), "create", "-g", "starcraft2", str(output_file), str(test_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -591,7 +592,7 @@ def test_create_mpq_no_sign_flag_has_no_signature(binary_path, generate_test_fil output_file.unlink(missing_ok=True) result = subprocess.run( - [str(binary_path), "create", str(target_dir), "-o", str(output_file)], + [str(binary_path), "create", str(output_file), str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -613,48 +614,6 @@ def test_create_mpq_no_sign_flag_has_no_signature(binary_path, generate_test_fil output_file.unlink(missing_ok=True) -def test_create_mpq_directory_with_trailing_slash(binary_path, generate_test_files): - """ - Test MPQ archive creation when the target directory has a trailing slash. - - Regression test for: mpqcli create dir/ producing "dir/.mpq" instead of "dir.mpq". - - This test checks: - - The archive is named after the directory, not "/.mpq". - - The archive is created successfully and is non-empty. - """ - _ = generate_test_files - script_dir = Path(__file__).parent - # Construct the path string with an explicit trailing slash - target_dir_str = str(script_dir / "data" / "files") + "/" - - expected_output = script_dir / "data" / "files.mpq" - malformed_output = script_dir / "data" / "files" / ".mpq" - - # Clean up from any previous run - expected_output.unlink(missing_ok=True) - malformed_output.unlink(missing_ok=True) - - result = subprocess.run( - [str(binary_path), "create", target_dir_str], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert expected_output.exists(), ( - f"Expected archive '{expected_output}' was not created. " - f"Malformed path exists: {malformed_output.exists()}" - ) - 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" - ) - - expected_output.unlink(missing_ok=True) - - def test_create_mpq_skips_special_files(binary_path, tmp_path): """ Test that special MPQ files in the source directory are skipped during archive creation. @@ -675,7 +634,7 @@ def test_create_mpq_skips_special_files(binary_path, tmp_path): output_file = tmp_path / "output.mpq" result = subprocess.run( - [str(binary_path), "create", str(source_dir), "-o", str(output_file)], + [str(binary_path), "create", str(output_file), str(source_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -726,7 +685,7 @@ def test_create_mpq_folder_structure_with_trailing_slash(binary_path, tmp_path): output_file = tmp_path / "output.mpq" result = subprocess.run( - [str(binary_path), "create", str(source_dir) + "/", "-o", str(output_file)], + [str(binary_path), "create", str(output_file), str(source_dir) + "/"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -758,7 +717,7 @@ def test_create_mpq_folder_structure_with_dot_relative_path(binary_path, tmp_pat output_file = tmp_path / "output.mpq" result = subprocess.run( - [str(binary_path), "create", "./src", "-o", str(output_file)], + [str(binary_path), "create", str(output_file), "./src"], cwd=str(tmp_path), stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -788,7 +747,7 @@ def test_create_mpq_skips_dangling_symlink(binary_path, tmp_path): output_file = tmp_path / "output.mpq" result = subprocess.run( - [str(binary_path), "create", str(source_dir), "-o", str(output_file)], + [str(binary_path), "create", str(output_file), str(source_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -820,7 +779,7 @@ def test_create_mpq_from_empty_directory(binary_path, tmp_path): output_file = tmp_path / "empty.mpq" result = subprocess.run( - [str(binary_path), "create", str(empty_dir), "-o", str(output_file)], + [str(binary_path), "create", str(output_file), str(empty_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -866,6 +825,149 @@ def test_create_old_version_option_is_rejected(binary_path, tmp_path): cwd=tmp_path, ) - # --version is not a create option, so the version number is taken as the input - assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" - assert list(tmp_path.glob("*.mpq")) == [] + assert result.returncode == 109, f"mpqcli failed with error: {result.stderr}" + assert sorted(p.name for p in tmp_path.iterdir()) == ["files"] + + +def test_create_old_directory_form_is_rejected(binary_path, tmp_path): + input_dir = tmp_path / "files" + input_dir.mkdir() + (input_dir / "a.txt").write_text("A file.") + + result = subprocess.run( + [str(binary_path), "create", str(input_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=tmp_path, + ) + + # The directory is taken as the archive, which leaves no files to add + assert result.returncode == 106, f"mpqcli failed with error: {result.stderr}" + assert sorted(p.name for p in tmp_path.iterdir()) == ["files"] + + +def test_create_old_output_option_is_rejected(binary_path, tmp_path): + input_dir = tmp_path / "files" + input_dir.mkdir() + (input_dir / "a.txt").write_text("A file.") + + result = subprocess.run( + [str(binary_path), "create", "-o", str(tmp_path / "out.mpq"), str(input_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=tmp_path, + ) + + assert result.returncode == 109, f"mpqcli failed with error: {result.stderr}" + assert sorted(p.name for p in tmp_path.iterdir()) == ["files"] + + +def test_create_from_several_inputs(binary_path, tmp_path): + input_dir = tmp_path / "textures" + (input_dir / "bear").mkdir(parents=True) + (input_dir / "bear" / "bear.blp").write_text("Bear.") + input_file = tmp_path / "readme.txt" + input_file.write_text("Read me.") + archive_file = tmp_path / "several.mpq" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(input_file), str(input_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + + list_result = subprocess.run( + [str(binary_path), "list", str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert set(list_result.stdout.splitlines()) == {"readme.txt", "bear\\bear.blp"} + + +def test_create_keeps_the_first_of_two_inputs_with_the_same_name(binary_path, tmp_path): + first_dir = tmp_path / "first" + second_dir = tmp_path / "second" + first_dir.mkdir() + second_dir.mkdir() + (first_dir / "a.txt").write_text("First.") + (second_dir / "a.txt").write_text("Second.") + archive_file = tmp_path / "duplicate.mpq" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(first_dir), str(second_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert "1 file(s) were skipped" in result.stderr + + read_result = subprocess.run( + [str(binary_path), "read", str(archive_file), "a.txt"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert read_result.stdout == "First." + + +def test_create_files_via_stdin(binary_path, tmp_path): + file_a = tmp_path / "a.txt" + file_b = tmp_path / "b.txt" + file_a.write_text("A.") + file_b.write_text("B.") + archive_file = tmp_path / "stdin.mpq" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), "-"], + input=f"{file_a}\n{file_b}\n", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + + list_result = subprocess.run( + [str(binary_path), "list", str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert set(list_result.stdout.splitlines()) == {"a.txt", "b.txt"} + + +def test_create_empty_stdin_creates_nothing(binary_path, tmp_path): + archive_file = tmp_path / "empty.mpq" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), "-"], + input="", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" + assert list(tmp_path.iterdir()) == [] + + +def test_create_missing_input_creates_nothing(binary_path, tmp_path): + input_file = tmp_path / "a.txt" + input_file.write_text("A file.") + archive_file = tmp_path / "missing.mpq" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(input_file), str(tmp_path / "typo")], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" + assert "Path does not exist" in result.stderr + assert sorted(p.name for p in tmp_path.iterdir()) == ["a.txt"] diff --git a/test/test_extract.py b/test/test_extract.py index c918709..38d2190 100644 --- a/test/test_extract.py +++ b/test/test_extract.py @@ -825,7 +825,7 @@ def test_extract_nested_file_from_mpq(binary_path, generate_test_files): target_dir = script_dir / "data" / "files" archive_file.unlink(missing_ok=True) result = subprocess.run( - [str(binary_path), "create", "--format-version", "1", str(target_dir), "-o", str(archive_file)], + [str(binary_path), "create", "--format-version", "1", str(archive_file), str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -944,7 +944,7 @@ def test_extract_flat_name_collision_extracts_nothing(binary_path, tmp_path): output_dir = tmp_path / "extracted" result = subprocess.run( - [str(binary_path), "create", str(input_dir), "-o", str(archive_file)], + [str(binary_path), "create", str(archive_file), str(input_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True From 788fe4864c303b017977e53a52be870be0eb291a Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 00:28:57 +1300 Subject: [PATCH 10/20] Bumped the version to 0.13.0 and drafted its changelog entry --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ CMakeLists.txt | 2 +- docs/changelog.md | 28 ++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bef9b8..b4fcfcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.13.0 - 2026-10-16 + +### Added + +- Accept several files, or a list read from stdin, in create and extract +- Add a flat option to extract, which fails rather than overwrites when two names clash + +### Changed + +- Take the archive as the first argument in every subcommand, including create and read +- Keep folders when extracting named files +- Rename the create version option to format-version +- Reject an unknown locale in extract and read instead of using the default locale + +### Fixed + +- Show the encryption keys of encrypted files listed without their names +- Accept Windows line endings in paths read from stdin + +### Removed + +- Remove create -o, extract -f and -k, and the -p flag from info, list and verify + +### Thanks + +- Thanks to @sjoblomj for the contributions in this release +- Thanks to @MikaMika for reporting the inconsistent subcommand arguments + ## 0.12.0 - 2026-10-04 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index 4d1091d..d3260c2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.25) -project(mpqcli VERSION 0.12.0) +project(mpqcli VERSION 0.13.0) # An option() name is global to the CMake cache, including a consumer's, so these # carry the project prefix; a bare BUILD_STATIC claims a name anyone might want. diff --git a/docs/changelog.md b/docs/changelog.md index 6bef9b8..b4fcfcf 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,33 @@ # Changelog +## 0.13.0 - 2026-10-16 + +### Added + +- Accept several files, or a list read from stdin, in create and extract +- Add a flat option to extract, which fails rather than overwrites when two names clash + +### Changed + +- Take the archive as the first argument in every subcommand, including create and read +- Keep folders when extracting named files +- Rename the create version option to format-version +- Reject an unknown locale in extract and read instead of using the default locale + +### Fixed + +- Show the encryption keys of encrypted files listed without their names +- Accept Windows line endings in paths read from stdin + +### Removed + +- Remove create -o, extract -f and -k, and the -p flag from info, list and verify + +### Thanks + +- Thanks to @sjoblomj for the contributions in this release +- Thanks to @MikaMika for reporting the inconsistent subcommand arguments + ## 0.12.0 - 2026-10-04 ### Added From 3ec62d3bac54d14e8918468ddde05e9a7dd513ab Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 02:02:54 +1300 Subject: [PATCH 11/20] Stopped add and create from storing an archive inside itself --- CHANGELOG.md | 1 + docs/changelog.md | 1 + src/commands/add.cpp | 6 ++-- src/commands/create.cpp | 4 +-- src/mpq/add.cpp | 28 +++++++++++++++++- src/mpq/add.h | 5 +++- test/test_add.py | 65 +++++++++++++++++++++++++++++++++++++++++ test/test_create.py | 25 ++++++++++++++++ 8 files changed, 128 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4fcfcf..7947406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Show the encryption keys of encrypted files listed without their names - Accept Windows line endings in paths read from stdin +- Stop add and create from storing an archive inside itself ### Removed diff --git a/docs/changelog.md b/docs/changelog.md index b4fcfcf..7947406 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -18,6 +18,7 @@ - Show the encryption keys of encrypted files listed without their names - Accept Windows line endings in paths read from stdin +- Stop add and create from storing an archive inside itself ### Removed diff --git a/src/commands/add.cpp b/src/commands/add.cpp index 97a0665..b3e896c 100644 --- a/src/commands/add.cpp +++ b/src/commands/add.cpp @@ -28,9 +28,9 @@ bool Add(const AddOptions &options, std::ostream &err, const std::atomic & GameRules game_rules(profile); int files_skipped = 0; - const int result = - AddInputs(archive.Handle(), options.files, options.path, lcid, game_rules, err, cancelled, - options.compression_overrides, options.overwrite, options.update, &files_skipped); + const int result = AddInputs(archive.Handle(), options.archive, options.files, options.path, + lcid, game_rules, err, cancelled, options.compression_overrides, + options.overwrite, options.update, &files_skipped); // Skipping pre-existing files is the default, so point at the flags that change it // rather than letting the run look like it silently did nothing. diff --git a/src/commands/create.cpp b/src/commands/create.cpp index 4fa4366..5c38c22 100644 --- a/src/commands/create.cpp +++ b/src/commands/create.cpp @@ -101,8 +101,8 @@ bool Create(const CreateOptions &options, std::ostream &err, const std::atomic 0) { err << "[*] " << files_skipped << " file(s) were skipped because an earlier input added a file by the same name" diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp index 267b54d..5c22554 100644 --- a/src/mpq/add.cpp +++ b/src/mpq/add.cpp @@ -25,6 +25,22 @@ namespace fs = std::filesystem; namespace mpqcli { +namespace { + +/// Whether file is the archive's own file, reporting the skip when it is +bool SkipIfArchiveFile(const fs::path &file, const fs::path &archive_file, std::ostream &err) { + // Compared by identity rather than by path, so a symlink to the archive or another + // spelling of its path is caught too + std::error_code ec; + if (!fs::equivalent(file, archive_file, ec)) { + return false; + } + err << "[*] Skipping the archive itself: " << file.string() << std::endl; + return true; +} + +} // namespace + int AddFiles(HANDLE archive, const std::vector &files, const fs::path &base_path, const std::string &path_prefix, LCID locale, const GameRules &game_rules, std::ostream &err, const std::atomic &cancelled, @@ -186,7 +202,7 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi return 0; } -int AddInputs(HANDLE archive, const std::vector &inputs, +int AddInputs(HANDLE archive, const fs::path &archive_file, const std::vector &inputs, const std::optional &path, LCID locale, const GameRules &game_rules, std::ostream &err, const std::atomic &cancelled, const CompressionSettingsOverrides &overrides, bool overwrite, bool update, @@ -218,11 +234,21 @@ int AddInputs(HANDLE archive, const std::vector &inputs, result |= 1; continue; } + // A directory can hold the archive being written, as "create out.mpq ." does + const auto skip_archive = [&](const fs::path &file) { + return SkipIfArchiveFile(file, archive_file, err); + }; + directory_files.erase( + std::remove_if(directory_files.begin(), directory_files.end(), skip_archive), + directory_files.end()); std::string prefix = path.value_or(""); result |= AddFiles(archive, directory_files, f, prefix, locale, game_rules, err, cancelled, overrides, overwrite, update, skipped); } else if (fs::is_regular_file(f, ec)) { + if (SkipIfArchiveFile(f, archive_file, err)) { + continue; + } const bool treat_as_directory = has_directory || inputs.size() > 1; std::string archive_path = ResolveArchiveName(f, path, treat_as_directory); result |= AddFile(archive, f, archive_path, locale, game_rules, err, overrides, diff --git a/src/mpq/add.h b/src/mpq/add.h index ea959a6..50d17b1 100644 --- a/src/mpq/add.h +++ b/src/mpq/add.h @@ -30,7 +30,10 @@ int AddFile(HANDLE archive, const std::filesystem::path &local_file, /// /// A directory's contents go under path, or at the archive root without it. A lone file is /// stored at path when given; beside other inputs or any directory, path is a prefix. -int AddInputs(HANDLE archive, const std::vector &inputs, +/// archive_file is the archive's own file on disk, which is skipped wherever it turns up +/// among the inputs, so an archive is never added to itself. +int AddInputs(HANDLE archive, const std::filesystem::path &archive_file, + const std::vector &inputs, const std::optional &path, LCID locale, const GameRules &game_rules, std::ostream &err, const std::atomic &cancelled, const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), diff --git a/test/test_add.py b/test/test_add.py index f462885..2ffd36f 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -1101,6 +1101,71 @@ def test_add_files_via_stdin_with_windows_line_endings(binary_path, generate_tes assert "[+] Adding file: stdin_b.txt" in result.stderr +def test_add_skips_the_archive_inside_an_input_directory(binary_path, tmp_path): + input_dir = tmp_path / "mod" + input_dir.mkdir() + (input_dir / "a.txt").write_text("A file.") + other_file = tmp_path / "b.txt" + other_file.write_text("B file.") + archive_file = input_dir / "mod.mpq" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(other_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"Failed to create archive: {result.stderr}" + + result = subprocess.run( + [str(binary_path), "add", str(archive_file), str(input_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert "Skipping the archive itself" in result.stderr + + list_result = subprocess.run( + [str(binary_path), "list", str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert set(list_result.stdout.splitlines()) == {"a.txt", "b.txt"} + + +def test_add_skips_the_archive_named_as_an_input(binary_path, tmp_path): + input_file = tmp_path / "a.txt" + input_file.write_text("A file.") + archive_file = tmp_path / "a.mpq" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(input_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"Failed to create archive: {result.stderr}" + + result = subprocess.run( + [str(binary_path), "add", str(archive_file), str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert "Skipping the archive itself" in result.stderr + + list_result = subprocess.run( + [str(binary_path), "list", str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert list_result.stdout.splitlines() == ["a.txt"] + + def create_mpq_archive_for_test(binary_path, script_dir): target_dir = script_dir / "data" / "files" target_file = target_dir.with_suffix(".mpq") diff --git a/test/test_create.py b/test/test_create.py index 32bcbf3..787c003 100644 --- a/test/test_create.py +++ b/test/test_create.py @@ -971,3 +971,28 @@ def test_create_missing_input_creates_nothing(binary_path, tmp_path): assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" assert "Path does not exist" in result.stderr assert sorted(p.name for p in tmp_path.iterdir()) == ["a.txt"] + + +def test_create_skips_the_archive_inside_its_own_input_directory(binary_path, tmp_path): + input_dir = tmp_path / "mod" + input_dir.mkdir() + (input_dir / "a.txt").write_text("A file.") + + result = subprocess.run( + [str(binary_path), "create", "out.mpq", "."], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=input_dir, + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert "Skipping the archive itself" in result.stderr + assert sorted(p.name for p in input_dir.iterdir()) == ["a.txt", "out.mpq"] + + list_result = subprocess.run( + [str(binary_path), "list", str(input_dir / "out.mpq")], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert list_result.stdout.splitlines() == ["a.txt"] From 5a71582f43afa6e4f115ea8daee67632f8a9f5f5 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 02:07:19 +1300 Subject: [PATCH 12/20] Made create leave no archive behind when any file fails to add --- CHANGELOG.md | 1 + docs/changelog.md | 1 + docs/commands/create.md | 2 +- src/commands/create.cpp | 13 +++++++++++-- src/commands/create.h | 2 +- test/test_create.py | 32 ++++++++++++++++++++++++++++++++ 6 files changed, 47 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7947406..98e3132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Show the encryption keys of encrypted files listed without their names - Accept Windows line endings in paths read from stdin - Stop add and create from storing an archive inside itself +- Leave no archive behind when create cannot add every file ### Removed diff --git a/docs/changelog.md b/docs/changelog.md index 7947406..98e3132 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -19,6 +19,7 @@ - Show the encryption keys of encrypted files listed without their names - Accept Windows line endings in paths read from stdin - Stop add and create from storing an archive inside itself +- Leave no archive behind when create cannot add every file ### Removed diff --git a/docs/commands/create.md b/docs/commands/create.md index 0887e6a..adee6a9 100644 --- a/docs/commands/create.md +++ b/docs/commands/create.md @@ -12,7 +12,7 @@ $ mpqcli create archive.mpq The default mode of operation for the `create` subcommand is to take everything from the directory (and below) and recursively add it to the archive. The directory's contents go at the root of the archive, and the structure below it is retained. Windows-style backslash path separators are used (`\`), as per the observed behaviour in most MPQ archives. -The archive must not already exist; `create` never overwrites a file. +The archive must not already exist; `create` never overwrites a file. If an input is missing or any file cannot be added, no archive is created. ## Create an MPQ archive from several files and directories diff --git a/src/commands/create.cpp b/src/commands/create.cpp index 5c38c22..ac183e3 100644 --- a/src/commands/create.cpp +++ b/src/commands/create.cpp @@ -54,7 +54,8 @@ bool Create(const CreateOptions &options, std::ostream &err, const std::atomic &cancelled); } // namespace mpqcli diff --git a/test/test_create.py b/test/test_create.py index 787c003..29ce897 100644 --- a/test/test_create.py +++ b/test/test_create.py @@ -1,3 +1,4 @@ +import os import platform import subprocess import shutil @@ -973,6 +974,37 @@ def test_create_missing_input_creates_nothing(binary_path, tmp_path): assert sorted(p.name for p in tmp_path.iterdir()) == ["a.txt"] +@pytest.mark.skipif( + platform.system() == "Windows" or os.geteuid() == 0, + reason="Needs a file permission that stops the user running the tests from reading it", +) +def test_create_unreadable_file_creates_nothing(binary_path, tmp_path): + input_dir = tmp_path / "mod" + input_dir.mkdir() + (input_dir / "a.txt").write_text("A file.") + locked_file = input_dir / "locked.txt" + locked_file.write_text("Locked.") + other_file = tmp_path / "b.txt" + other_file.write_text("B file.") + archive_file = tmp_path / "unreadable.mpq" + + locked_file.chmod(0) + try: + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(other_file), str(input_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + finally: + locked_file.chmod(0o644) + + assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" + assert "Failed to add: locked.txt" in result.stderr + assert "no archive was created" in result.stderr + assert sorted(p.name for p in tmp_path.iterdir()) == ["b.txt", "mod"] + + def test_create_skips_the_archive_inside_its_own_input_directory(binary_path, tmp_path): input_dir = tmp_path / "mod" input_dir.mkdir() From af2d73c87113399908eb9a5c7497e749e5f75424 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 02:09:45 +1300 Subject: [PATCH 13/20] Gave the new tests an archive of their own instead of one another test creates --- test/conftest.py | 22 ++++++++++++++++++++++ test/test_create.py | 7 +++++-- test/test_extract.py | 36 ++++++++++++------------------------ test/test_info.py | 5 ++--- test/test_list.py | 5 ++--- test/test_verify.py | 5 ++--- 6 files changed, 45 insertions(+), 35 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 4df9d2d..1f6b88a 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -208,6 +208,28 @@ def generate_path_traversal_mpq(binary_path): yield mpq_file +@pytest.fixture(scope="function") +def generate_cats_and_dogs_mpq(binary_path, tmp_path_factory): + # A directory of its own rather than test/data, so the test depends on no other test, + # and rather than the test's tmp_path, which some tests assert is left empty + archive_dir = tmp_path_factory.mktemp("cats_and_dogs") + files_dir = archive_dir / "files" + files_dir.mkdir() + (files_dir / "cats.txt").write_text("This is a file about cats.\n", newline="\n") + (files_dir / "dogs.txt").write_text("This is a file about dogs.\n", newline="\n") + + mpq_file = archive_dir / "cats_and_dogs.mpq" + result = subprocess.run( + [str(binary_path), "create", str(mpq_file), str(files_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + + yield mpq_file + + @pytest.fixture(scope="function") def generate_tampered_signature_mpq(binary_path): """ diff --git a/test/test_create.py b/test/test_create.py index 29ce897..afeb3bb 100644 --- a/test/test_create.py +++ b/test/test_create.py @@ -169,18 +169,20 @@ def test_create_mpq_with_weak_signature(binary_path, generate_test_files): f"Unexpected verify output: {verify_result.stderr}" -def test_create_mpq_already_exists(binary_path, generate_test_files): +def test_create_mpq_already_exists(binary_path, generate_test_files, tmp_path): """ Test MPQ file creation with an existing output file. This test checks: - If the MPQ archive is not created (as archive exists already). + - That the existing file is left as it was. """ _ = generate_test_files script_dir = Path(__file__).parent target_dir = script_dir / "data" / "files" - output_file = script_dir / "data" / "mpq_with_output_v1.mpq" + output_file = tmp_path / "existing.mpq" + output_file.write_bytes(b"Not an archive") result = subprocess.run( [str(binary_path), "create", "--format-version", "1", str(output_file), str(target_dir)], @@ -190,6 +192,7 @@ def test_create_mpq_already_exists(binary_path, generate_test_files): ) assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + assert output_file.read_bytes() == b"Not an archive" def test_create_mpq_from_file(binary_path, generate_test_files): diff --git a/test/test_extract.py b/test/test_extract.py index 38d2190..1f0703a 100644 --- a/test/test_extract.py +++ b/test/test_extract.py @@ -879,10 +879,8 @@ def test_extract_nested_file_from_mpq(binary_path, generate_test_files): assert set(fi.name for fi in flat_dir.iterdir()) == {"cats.txt"} -def test_extract_multiple_files(binary_path, generate_test_files, tmp_path): - _ = generate_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" +def test_extract_multiple_files(binary_path, generate_cats_and_dogs_mpq, tmp_path): + test_file = generate_cats_and_dogs_mpq output_dir = tmp_path / "extracted" result = subprocess.run( @@ -897,10 +895,8 @@ def test_extract_multiple_files(binary_path, generate_test_files, tmp_path): assert set(fi.name for fi in output_dir.iterdir()) == {"cats.txt", "dogs.txt"} -def test_extract_files_via_stdin(binary_path, generate_test_files, tmp_path): - _ = generate_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" +def test_extract_files_via_stdin(binary_path, generate_cats_and_dogs_mpq, tmp_path): + test_file = generate_cats_and_dogs_mpq output_dir = tmp_path / "extracted" result = subprocess.run( @@ -915,10 +911,8 @@ def test_extract_files_via_stdin(binary_path, generate_test_files, tmp_path): assert set(fi.name for fi in output_dir.iterdir()) == {"cats.txt", "dogs.txt"} -def test_extract_empty_stdin_extracts_nothing(binary_path, generate_test_files, tmp_path): - _ = generate_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" +def test_extract_empty_stdin_extracts_nothing(binary_path, generate_cats_and_dogs_mpq, tmp_path): + test_file = generate_cats_and_dogs_mpq output_dir = tmp_path / "extracted" result = subprocess.run( @@ -963,10 +957,8 @@ def test_extract_flat_name_collision_extracts_nothing(binary_path, tmp_path): assert not output_dir.exists(), "Output directory was created" -def test_extract_listfile_with_named_files_is_rejected(binary_path, generate_test_files, tmp_path): - _ = generate_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" +def test_extract_listfile_with_named_files_is_rejected(binary_path, generate_cats_and_dogs_mpq, tmp_path): + test_file = generate_cats_and_dogs_mpq listfile = tmp_path / "listfile.txt" listfile.write_text("cats.txt\n") output_dir = tmp_path / "extracted" @@ -982,10 +974,8 @@ def test_extract_listfile_with_named_files_is_rejected(binary_path, generate_tes assert not output_dir.exists(), "Output directory was created" -def test_extract_old_file_option_is_rejected(binary_path, generate_test_files, tmp_path): - _ = generate_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" +def test_extract_old_file_option_is_rejected(binary_path, generate_cats_and_dogs_mpq, tmp_path): + test_file = generate_cats_and_dogs_mpq result = subprocess.run( [str(binary_path), "extract", "-f", "cats.txt", str(test_file)], @@ -1000,10 +990,8 @@ def test_extract_old_file_option_is_rejected(binary_path, generate_test_files, t assert list(tmp_path.iterdir()) == [] -def test_extract_old_keep_flag_is_rejected(binary_path, generate_test_files, tmp_path): - _ = generate_test_files - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" +def test_extract_old_keep_flag_is_rejected(binary_path, generate_cats_and_dogs_mpq, tmp_path): + test_file = generate_cats_and_dogs_mpq output_dir = tmp_path / "extracted" result = subprocess.run( diff --git a/test/test_info.py b/test/test_info.py index 2c60257..6387573 100644 --- a/test/test_info.py +++ b/test/test_info.py @@ -163,9 +163,8 @@ def test_info_mpq_target_does_not_exist(binary_path): assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" -def test_info_short_property_flag_is_rejected(binary_path): - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" +def test_info_short_property_flag_is_rejected(binary_path, generate_cats_and_dogs_mpq): + test_file = generate_cats_and_dogs_mpq result = subprocess.run( [str(binary_path), "info", "-p", "file-count", str(test_file)], diff --git a/test/test_list.py b/test/test_list.py index 3bb87bb..6000237 100644 --- a/test/test_list.py +++ b/test/test_list.py @@ -563,9 +563,8 @@ def keys_by_file_index(*extra_args): f"Keys of file {file_index} differ: {(key, key_raw)} vs {with_names[file_index][1:3]}" -def test_list_short_property_flag_is_rejected(binary_path): - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" +def test_list_short_property_flag_is_rejected(binary_path, generate_cats_and_dogs_mpq): + test_file = generate_cats_and_dogs_mpq result = subprocess.run( [str(binary_path), "list", "-p", "locale", str(test_file)], diff --git a/test/test_verify.py b/test/test_verify.py index 90b2520..c8c675b 100644 --- a/test/test_verify.py +++ b/test/test_verify.py @@ -171,9 +171,8 @@ def test_verify_mpq_target_does_not_exist(binary_path): assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" -def test_verify_short_print_flag_is_rejected(binary_path): - script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_with_output_v1.mpq" +def test_verify_short_print_flag_is_rejected(binary_path, generate_cats_and_dogs_mpq): + test_file = generate_cats_and_dogs_mpq result = subprocess.run( [str(binary_path), "verify", "-p", str(test_file)], From 3f04dc635e06e2d645321fb6e1cdd088a0107f09 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 02:16:13 +1300 Subject: [PATCH 14/20] Checked the old-form error messages and added tests for path prefixes, stdin sizing, flat locales and listfile with stdin --- test/test_create.py | 66 ++++++++++++++++++++++++++++++++++++++++++++ test/test_extract.py | 57 ++++++++++++++++++++++++++++++++++++++ test/test_info.py | 1 + test/test_list.py | 1 + test/test_read.py | 1 + 5 files changed, 126 insertions(+) diff --git a/test/test_create.py b/test/test_create.py index afeb3bb..b2555c6 100644 --- a/test/test_create.py +++ b/test/test_create.py @@ -1031,3 +1031,69 @@ def test_create_skips_the_archive_inside_its_own_input_directory(binary_path, tm text=True ) assert list_result.stdout.splitlines() == ["a.txt"] + + +def test_create_path_is_a_prefix_for_several_inputs(binary_path, tmp_path): + file_a = tmp_path / "a.txt" + file_b = tmp_path / "b.txt" + file_a.write_text("A.") + file_b.write_text("B.") + input_dir = tmp_path / "textures" + (input_dir / "bear").mkdir(parents=True) + (input_dir / "bear" / "bear.blp").write_text("Bear.") + archive_file = tmp_path / "prefixed.mpq" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(file_a), str(file_b), str(input_dir), + "--path", "data"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + + list_result = subprocess.run( + [str(binary_path), "list", str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert set(list_result.stdout.splitlines()) == { + "data\\a.txt", "data\\b.txt", "data\\bear\\bear.blp" + } + + +def test_create_sizes_the_archive_for_paths_from_stdin(binary_path, tmp_path): + # Enough files that the count matters: an archive sized for none of them, and grown + # as they are added, reports the same max-files as one made from a single file + input_dir = tmp_path / "files" + input_dir.mkdir() + for i in range(30): + (input_dir / f"file{i}.txt").write_text(f"File {i}.") + paths = "".join(f"{p}\n" for p in sorted(input_dir.iterdir())) + + def create_and_read_max_files(archive_file, inputs, stdin=None): + result = subprocess.run( + [str(binary_path), "create", str(archive_file), *inputs], + input=stdin, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + info_result = subprocess.run( + [str(binary_path), "info", "--property", "max-files", str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert info_result.returncode == 0, f"mpqcli failed with error: {info_result.stderr}" + return int(info_result.stdout.strip()) + + from_stdin = create_and_read_max_files(tmp_path / "stdin.mpq", ["-"], paths) + from_directory = create_and_read_max_files(tmp_path / "directory.mpq", [str(input_dir)]) + from_one_file = create_and_read_max_files( + tmp_path / "one.mpq", [str(input_dir / "file0.txt")]) + + assert from_stdin == from_directory + assert from_stdin > from_one_file diff --git a/test/test_extract.py b/test/test_extract.py index 1f0703a..bb6835e 100644 --- a/test/test_extract.py +++ b/test/test_extract.py @@ -987,6 +987,7 @@ def test_extract_old_file_option_is_rejected(binary_path, generate_cats_and_dogs # -f is not an extract option, so the file name is taken as the archive assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + assert "archive: File does not exist: cats.txt" in result.stderr assert list(tmp_path.iterdir()) == [] @@ -1003,3 +1004,59 @@ def test_extract_old_keep_flag_is_rejected(binary_path, generate_cats_and_dogs_m assert result.returncode == 109, f"mpqcli failed with error: {result.stderr}" assert not output_dir.exists(), "Output directory was created" + + +def test_extract_listfile_with_stdin_is_rejected(binary_path, generate_cats_and_dogs_mpq, tmp_path): + test_file = generate_cats_and_dogs_mpq + listfile = tmp_path / "listfile.txt" + listfile.write_text("cats.txt\n") + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "extract", "-o", str(output_dir), "-l", str(listfile), str(test_file), "-"], + input="cats.txt\n", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 108, f"mpqcli failed with error: {result.stderr}" + assert not output_dir.exists(), "Output directory was created" + + +def test_extract_flat_allows_one_name_stored_under_several_locales(binary_path, tmp_path): + input_dir = tmp_path / "files" + input_dir.mkdir() + (input_dir / "cats.txt").write_text("Cats in the default locale.") + german_file = tmp_path / "cats.txt" + german_file.write_text("Cats in German.") + archive_file = tmp_path / "locales.mpq" + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(input_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"Failed to create archive: {result.stderr}" + result = subprocess.run( + [str(binary_path), "add", str(archive_file), str(german_file), "--locale", "deDE"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"Failed to add the second locale: {result.stderr}" + + result = subprocess.run( + [str(binary_path), "extract", "--flat", "-o", str(output_dir), str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + # cats.txt is listed once per locale, and both land on the same file without + # counting as a collision + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert result.stderr.splitlines().count("[*] Extracted: cats.txt") == 2 + assert (output_dir / "cats.txt").exists() diff --git a/test/test_info.py b/test/test_info.py index 6387573..5735671 100644 --- a/test/test_info.py +++ b/test/test_info.py @@ -175,4 +175,5 @@ def test_info_short_property_flag_is_rejected(binary_path, generate_cats_and_dog # -p is not an info option, so the property name is taken as the archive assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + assert "archive: File does not exist: file-count" in result.stderr assert result.stdout == "" diff --git a/test/test_list.py b/test/test_list.py index 6000237..cd93d80 100644 --- a/test/test_list.py +++ b/test/test_list.py @@ -575,4 +575,5 @@ def test_list_short_property_flag_is_rejected(binary_path, generate_cats_and_dog # -p is not a list option, so the property name is taken as the archive assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + assert "archive: File does not exist: locale" in result.stderr assert result.stdout == "" diff --git a/test/test_read.py b/test/test_read.py index c201e5a..62ebec1 100644 --- a/test/test_read.py +++ b/test/test_read.py @@ -273,4 +273,5 @@ def test_read_old_argument_order_is_rejected(binary_path, generate_locales_mpq_t # The file name is taken as the archive, and no local file has that name assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + assert "archive: File does not exist: cats.txt" in result.stderr assert result.stdout == "" From e5e25b4b1c318470b7de11c4d01d7857d3a58b7c Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 02:16:19 +1300 Subject: [PATCH 15/20] Replaced target in the command descriptions and updated the create stdin and PowerShell examples --- docs/advanced.md | 4 ++-- docs/commands/create.md | 4 +++- docs/commands/list.md | 2 +- docs/commands/verify.md | 2 +- docs/commands_list.md | 6 +++--- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/advanced.md b/docs/advanced.md index 7552929..68c51e6 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -35,11 +35,11 @@ $ mpqcli list -l scbw.txt StarDat.mpq | grep -i 'arr\\.*dat$' | sort | mpqcli ex ## Search and extract files on Windows -The following command lists all files in an MPQ archive, and each filename is filtered using `Select-String` - selecting files ending in `.exe` (note: `.` is a regex wildcard matching any character; use `\.exe` for a strictly literal match) - which is then passed back to `mpqcli extract`. The result: search and extract all `exe` files. +The following command lists all files in an MPQ archive, and each filename is filtered using `Select-String` - selecting files ending in `.exe` (note: `.` is a regex wildcard matching any character; use `\.exe` for a strictly literal match) - which is then passed back to `mpqcli extract` on stdin. `ForEach-Object Line` passes on the text of each match rather than the match object. The result: search and extract all `exe` files. ```powershell PS> mpqcli.exe list wow-patch.mpq | Select-String -Pattern \.exe | ` - ForEach-Object { mpqcli.exe extract wow-patch.mpq $_ } + ForEach-Object Line | mpqcli.exe extract wow-patch.mpq - [*] Extracted: Launcher.exe [*] Extracted: BackgroundDownloader.exe [*] Extracted: WoW.exe diff --git a/docs/commands/create.md b/docs/commands/create.md index adee6a9..e284816 100644 --- a/docs/commands/create.md +++ b/docs/commands/create.md @@ -25,9 +25,11 @@ $ mpqcli create archive.mpq readme.txt textures/ sounds/ Pass `-` in place of the files to read their paths from stdin, one per line: ```bash -$ find art -name '*.blp' | mpqcli create art.mpq - +$ find . -maxdepth 1 -name '*.txt' | mpqcli create texts.mpq - ``` +As with files named on the command line, each file is stored under its own name, without the folders in its path. To keep the folder structure, name the directory instead. + ## Create an MPQ archive for a specific game Target a specific game version by using the `-g` or `--game` argument. This will automatically set the correct archive format version and settings, although they can be overridden. diff --git a/docs/commands/list.md b/docs/commands/list.md index 39c7a10..8fbbe22 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -1,6 +1,6 @@ # list -List files in a target MPQ archive. +List files in an MPQ archive. ## List all files in an MPQ archive diff --git a/docs/commands/verify.md b/docs/commands/verify.md index 765eeba..cda527c 100644 --- a/docs/commands/verify.md +++ b/docs/commands/verify.md @@ -1,6 +1,6 @@ # verify -Verify a target MPQ archive signature. +Verify an MPQ archive signature. ## Verify an MPQ archive diff --git a/docs/commands_list.md b/docs/commands_list.md index b02d2ca..a7e6dce 100644 --- a/docs/commands_list.md +++ b/docs/commands_list.md @@ -15,8 +15,8 @@ The `mpqcli` program has the following subcommands: | [`add`](./commands/add.md) | Add a file to an existing MPQ archive | | [`remove`](./commands/remove.md) | Remove a file from an existing MPQ archive | | [`rename`](./commands/rename.md) | Rename a file in an existing MPQ archive | -| [`list`](./commands/list.md) | List files in a target MPQ archive | -| [`extract`](./commands/extract.md) | Extract one or all files from a target MPQ archive | +| [`list`](./commands/list.md) | List files in an MPQ archive | +| [`extract`](./commands/extract.md) | Extract files from an MPQ archive | | [`read`](./commands/read.md) | Read a specific file to stdout | -| [`verify`](./commands/verify.md) | Verify a target MPQ archive signature | +| [`verify`](./commands/verify.md) | Verify an MPQ archive signature | | [`compact`](./commands/compact.md) | Compact (defragment) an MPQ archive | From 2c721062cf7f387e778fe5064a923a6218278a30 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 02:18:55 +1300 Subject: [PATCH 16/20] Shortened the flat changelog entry and updated the path and locale help text --- CHANGELOG.md | 2 +- app/add.cpp | 2 +- app/create.cpp | 2 +- app/extract.cpp | 2 +- completion/mpqcli.fish | 6 +++--- completion/mpqcli.ps1 | 10 +++++----- completion/mpqcli.zsh | 6 +++--- docs/changelog.md | 2 +- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98e3132..0a87493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added - Accept several files, or a list read from stdin, in create and extract -- Add a flat option to extract, which fails rather than overwrites when two names clash +- Add a flat option to extract ### Changed diff --git a/app/add.cpp b/app/add.cpp index 1ff8569..c2bff04 100644 --- a/app/add.cpp +++ b/app/add.cpp @@ -29,7 +29,7 @@ void RegisterAdd(CLI::App &app, Context &context) { ->required() ->expected(-1); sub->add_option("-p,--path", options->add.path, - "Archive path for a single file, or prefix for a directory"); + "Archive path for a single file, or prefix for a directory or several inputs"); CLI::Option *overwrite = sub->add_flag("-w,--overwrite", options->add.overwrite, "Replace every file that already exists in the archive"); sub->add_flag("-u,--update", options->add.update, diff --git a/app/create.cpp b/app/create.cpp index 2372385..6be8bdd 100644 --- a/app/create.cpp +++ b/app/create.cpp @@ -34,7 +34,7 @@ void RegisterCreate(CLI::App &app, Context &context) { ->required() ->expected(-1); sub->add_option("-p,--path", options->create.path, - "Archive path for a single file, or prefix for a directory"); + "Archive path for a single file, or prefix for a directory or several inputs"); sub->add_flag("-s,--sign", options->create.sign, "Sign the MPQ archive (default false)"); sub->add_option("--locale", options->create.locale, "Locale to use for added files") ->check(locale_valid); diff --git a/app/extract.cpp b/app/extract.cpp index ea714b1..a67bd02 100644 --- a/app/extract.cpp +++ b/app/extract.cpp @@ -35,7 +35,7 @@ void RegisterExtract(CLI::App &app, Context &context) { "File listing content of an MPQ archive, when extracting every file") ->check(CLI::ExistingFile) ->excludes(files); - sub->add_option("--locale", options->extract.locale, "Preferred locale for extracted file") + sub->add_option("--locale", options->extract.locale, "Preferred locale for extracted files") ->check(locale_valid); sub->callback([options, &context]() { diff --git a/completion/mpqcli.fish b/completion/mpqcli.fish index 1156c4a..4217648 100644 --- a/completion/mpqcli.fish +++ b/completion/mpqcli.fish @@ -115,7 +115,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from info' \ complete -c mpqcli -n '__fish_seen_subcommand_from create' -F complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -s p -l path -d 'Archive path for a single file, or prefix for a directory' -r -f + -s p -l path -d 'Archive path for a single file, or prefix for a directory or several inputs' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ -s s -l sign -d 'Sign the MPQ archive' complete -c mpqcli -n '__fish_seen_subcommand_from create' \ @@ -157,7 +157,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from create' \ complete -c mpqcli -n '__fish_seen_subcommand_from add' -F complete -c mpqcli -n '__fish_seen_subcommand_from add' \ - -s p -l path -d 'Archive path for a single file, or prefix for a directory' -r -f + -s p -l path -d 'Archive path for a single file, or prefix for a directory or several inputs' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from add' \ -s w -l overwrite -d 'Replace every file that already exists in the archive' complete -c mpqcli -n '__fish_seen_subcommand_from add' \ @@ -224,7 +224,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ -s l -l listfile -d 'External file listing content of the archive' -r -F complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ - -l locale -d 'Preferred locale for extracted file' \ + -l locale -d 'Preferred locale for extracted files' \ -r -f -a "$__mpqcli_locales" # read diff --git a/completion/mpqcli.ps1 b/completion/mpqcli.ps1 index 901c137..c6ac037 100644 --- a/completion/mpqcli.ps1 +++ b/completion/mpqcli.ps1 @@ -78,8 +78,8 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--property' = 'Print only a specific property value' } 'create' = [ordered]@{ - '-p' = 'Archive path for a single file, or prefix for a directory' - '--path' = 'Archive path for a single file, or prefix for a directory' + '-p' = 'Archive path for a single file, or prefix for a directory or several inputs' + '--path' = 'Archive path for a single file, or prefix for a directory or several inputs' '-s' = 'Sign the MPQ archive' '--sign' = 'Sign the MPQ archive' '--locale' = 'Locale to use for added files' @@ -98,8 +98,8 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--compression-next' = 'Override compression for subsequent sectors' } 'add' = [ordered]@{ - '-p' = 'Archive path for a single file, or prefix for a directory' - '--path' = 'Archive path for a single file, or prefix for a directory' + '-p' = 'Archive path for a single file, or prefix for a directory or several inputs' + '--path' = 'Archive path for a single file, or prefix for a directory or several inputs' '-w' = 'Replace every file that already exists in the archive' '--overwrite' = 'Replace every file that already exists in the archive' '-u' = 'Replace only files that changed' @@ -132,7 +132,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--flat' = 'Write files into the output directory without their folders' '-l' = 'File listing content of an MPQ archive' '--listfile' = 'File listing content of an MPQ archive' - '--locale' = 'Preferred locale for extracted file' + '--locale' = 'Preferred locale for extracted files' } 'read' = [ordered]@{ '--locale' = 'Preferred locale for read file' diff --git a/completion/mpqcli.zsh b/completion/mpqcli.zsh index 34b99aa..3aefe0e 100644 --- a/completion/mpqcli.zsh +++ b/completion/mpqcli.zsh @@ -98,7 +98,7 @@ _mpqcli_create() { _arguments \ '1:archive:_files' \ '*:files:_files' \ - '(-p --path)'{-p,--path}'[archive path for a single file, or prefix for a directory]:path' \ + '(-p --path)'{-p,--path}'[archive path for a single file, or prefix for a directory or several inputs]:path' \ '(-s --sign)'{-s,--sign}'[sign the archive]' \ '--locale[locale for added files]:locale:('"$locales"')' \ '(-g --game)'{-g,--game}'[game profile]:profile:('"$games"')' \ @@ -120,7 +120,7 @@ _mpqcli_add() { _arguments \ '1:archive:_mpqcli_archives' \ '*:files:_files' \ - '(-p --path)'{-p,--path}'[archive path or prefix for directory add]:path' \ + '(-p --path)'{-p,--path}'[archive path for a single file, or prefix for a directory or several inputs]:path' \ '(-w --overwrite -u --update)'{-w,--overwrite}'[replace every existing file]' \ '(-u --update -w --overwrite)'{-u,--update}'[replace only files that changed]' \ '--locale[locale for added file]:locale:('"$locales"')' \ @@ -165,7 +165,7 @@ _mpqcli_extract() { '(-o --output)'{-o,--output}'[output directory]:dir:_files -/' \ '--flat[write files without their folders]' \ '(-l --listfile)'{-l,--listfile}'[listfile path]:file:_files' \ - '--locale[preferred locale for extracted file]:locale:('"$locales"')' + '--locale[preferred locale for extracted files]:locale:('"$locales"')' } _mpqcli_read() { diff --git a/docs/changelog.md b/docs/changelog.md index 98e3132..0a87493 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,7 +5,7 @@ ### Added - Accept several files, or a list read from stdin, in create and extract -- Add a flat option to extract, which fails rather than overwrites when two names clash +- Add a flat option to extract ### Changed From 8bebb9b71e9d72f4a658382c8c3c1b415b3ecc47 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 02:29:36 +1300 Subject: [PATCH 17/20] Added -e as the short form of --locale on every subcommand that takes one --- CHANGELOG.md | 1 + app/add.cpp | 2 +- app/create.cpp | 2 +- app/extract.cpp | 2 +- app/read.cpp | 2 +- app/remove.cpp | 3 ++- app/rename.cpp | 3 ++- completion/mpqcli.bash | 24 ++++++++++++------------ completion/mpqcli.fish | 24 ++++++++++++------------ completion/mpqcli.ps1 | 7 +++++++ completion/mpqcli.zsh | 12 ++++++------ docs/changelog.md | 1 + docs/commands/add.md | 2 +- docs/commands/create.md | 2 +- docs/commands/extract.md | 2 +- docs/commands/read.md | 2 +- docs/commands/remove.md | 2 +- docs/commands/rename.md | 2 +- test/test_read.py | 34 ++++++++++++++++++++++++++++++++++ 19 files changed, 87 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a87493..3ea2c5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Accept several files, or a list read from stdin, in create and extract - Add a flat option to extract +- Add -e as the short form of the locale option ### Changed diff --git a/app/add.cpp b/app/add.cpp index c2bff04..0b8b5d4 100644 --- a/app/add.cpp +++ b/app/add.cpp @@ -35,7 +35,7 @@ void RegisterAdd(CLI::App &app, Context &context) { sub->add_flag("-u,--update", options->add.update, "Replace only files that changed. Compares size, then timestamp/MD5/CRC32") ->excludes(overwrite); - sub->add_option("--locale", options->add.locale, "Locale to use for added file") + sub->add_option("-e,--locale", options->add.locale, "Locale to use for added file") ->check(locale_valid); sub->add_option("-g,--game", options->add.game_profile, "Game profile for compression rules. Valid options:\n" + diff --git a/app/create.cpp b/app/create.cpp index 6be8bdd..05518d5 100644 --- a/app/create.cpp +++ b/app/create.cpp @@ -36,7 +36,7 @@ void RegisterCreate(CLI::App &app, Context &context) { sub->add_option("-p,--path", options->create.path, "Archive path for a single file, or prefix for a directory or several inputs"); sub->add_flag("-s,--sign", options->create.sign, "Sign the MPQ archive (default false)"); - sub->add_option("--locale", options->create.locale, "Locale to use for added files") + sub->add_option("-e,--locale", options->create.locale, "Locale to use for added files") ->check(locale_valid); sub->add_option("-g,--game", options->create.game_profile, "Game profile for MPQ creation. Valid options:\n" + diff --git a/app/extract.cpp b/app/extract.cpp index a67bd02..b7c374e 100644 --- a/app/extract.cpp +++ b/app/extract.cpp @@ -35,7 +35,7 @@ void RegisterExtract(CLI::App &app, Context &context) { "File listing content of an MPQ archive, when extracting every file") ->check(CLI::ExistingFile) ->excludes(files); - sub->add_option("--locale", options->extract.locale, "Preferred locale for extracted files") + sub->add_option("-e,--locale", options->extract.locale, "Preferred locale for extracted files") ->check(locale_valid); sub->callback([options, &context]() { diff --git a/app/read.cpp b/app/read.cpp index c37ee0f..e3448fd 100644 --- a/app/read.cpp +++ b/app/read.cpp @@ -15,7 +15,7 @@ void RegisterRead(CLI::App &app, Context &context) { ->required() ->check(CLI::ExistingFile); sub->add_option("file", options->file, "File to read")->required(); - sub->add_option("--locale", options->locale, "Preferred locale for read file") + sub->add_option("-e,--locale", options->locale, "Preferred locale for read file") ->check(locale_valid); sub->callback([options, &context]() { diff --git a/app/remove.cpp b/app/remove.cpp index 0583af6..bf5a794 100644 --- a/app/remove.cpp +++ b/app/remove.cpp @@ -19,7 +19,8 @@ void RegisterRemove(CLI::App &app, Context &context) { "Archive paths of files to remove; pass - to read paths from stdin") ->required() ->expected(-1); - sub->add_option("--locale", options->locale, "Locale of file to remove")->check(locale_valid); + sub->add_option("-e,--locale", options->locale, "Locale of file to remove") + ->check(locale_valid); sub->callback([options, &context]() { options->files = mpqcli::ExpandStdinMarker(options->files, context.in); diff --git a/app/rename.cpp b/app/rename.cpp index 17a7d5a..c3f51c7 100644 --- a/app/rename.cpp +++ b/app/rename.cpp @@ -16,7 +16,8 @@ void RegisterRename(CLI::App &app, Context &context) { ->check(CLI::ExistingFile); sub->add_option("old-file", options->old_file, "Current archive path of the file")->required(); sub->add_option("new-file", options->new_file, "New archive path of the file")->required(); - sub->add_option("--locale", options->locale, "Locale of file to rename")->check(locale_valid); + sub->add_option("-e,--locale", options->locale, "Locale of file to rename") + ->check(locale_valid); sub->callback([options, &context]() { context.exit_code = mpqcli::Rename(*options, context.err) ? 0 : 1; diff --git a/completion/mpqcli.bash b/completion/mpqcli.bash index 37f7df5..3479212 100644 --- a/completion/mpqcli.bash +++ b/completion/mpqcli.bash @@ -130,7 +130,7 @@ _mpqcli() { create) case "$prev" in - --locale) _mpqcli_words "${locales[*]}"; return ;; + -e|--locale) _mpqcli_words "${locales[*]}"; return ;; -g|--game) _mpqcli_words "${games[*]}"; return ;; --format-version) _mpqcli_words "1 2 3 4"; return ;; -p|--path|--stream-flags|--sector-size|--raw-chunk-size|\ @@ -139,7 +139,7 @@ _mpqcli() { return ;; esac if [[ "$cur" == -* ]]; then - _mpqcli_words "-p --path -s --sign --locale -g --game --format-version \ + _mpqcli_words "-p --path -s --sign -e --locale -g --game --format-version \ --stream-flags --sector-size --raw-chunk-size --file-flags1 --file-flags2 --file-flags3 \ --attr-flags --flags --compression --compression-next" else @@ -151,12 +151,12 @@ _mpqcli() { add) case "$prev" in - --locale) _mpqcli_words "${locales[*]}"; return ;; + -e|--locale) _mpqcli_words "${locales[*]}"; return ;; -g|--game) _mpqcli_words "${games[*]}"; return ;; -p|--path|--flags|--compression|--compression-next) return ;; esac if [[ "$cur" == -* ]]; then - _mpqcli_words "-p --path -w --overwrite -u --update --locale -g --game \ + _mpqcli_words "-p --path -w --overwrite -u --update -e --locale -g --game \ --flags --compression --compression-next" else # Positional 1 is the archive; the rest are files or directories to add @@ -171,10 +171,10 @@ _mpqcli() { remove) case "$prev" in - --locale) _mpqcli_words "${locales[*]}"; return ;; + -e|--locale) _mpqcli_words "${locales[*]}"; return ;; esac if [[ "$cur" == -* ]]; then - _mpqcli_words "--locale" + _mpqcli_words "-e --locale" else # Positional 1 is the archive; the rest are in-archive paths, so # they get no filesystem completion @@ -187,10 +187,10 @@ _mpqcli() { rename) case "$prev" in - --locale) _mpqcli_words "${locales[*]}"; return ;; + -e|--locale) _mpqcli_words "${locales[*]}"; return ;; esac if [[ "$cur" == -* ]]; then - _mpqcli_words "--locale" + _mpqcli_words "-e --locale" else # Positional 1 is the archive; positionals 2 and 3 are in-archive # paths, so they get no filesystem completion @@ -220,10 +220,10 @@ _mpqcli() { case "$prev" in -o|--output) _mpqcli_filedir -d; return ;; -l|--listfile) _mpqcli_filedir; return ;; - --locale) _mpqcli_words "${locales[*]}"; return ;; + -e|--locale) _mpqcli_words "${locales[*]}"; return ;; esac if [[ "$cur" == -* ]]; then - _mpqcli_words "-o --output --flat -l --listfile --locale" + _mpqcli_words "-o --output --flat -l --listfile -e --locale" else # Positional 1 is the archive; the rest are in-archive paths, so # they get no filesystem completion @@ -236,10 +236,10 @@ _mpqcli() { read) case "$prev" in - --locale) _mpqcli_words "${locales[*]}"; return ;; + -e|--locale) _mpqcli_words "${locales[*]}"; return ;; esac if [[ "$cur" == -* ]]; then - _mpqcli_words "--locale" + _mpqcli_words "-e --locale" else # Positional 1 is the archive; positional 2 is an in-archive path, # so it gets no filesystem completion diff --git a/completion/mpqcli.fish b/completion/mpqcli.fish index 4217648..49e84af 100644 --- a/completion/mpqcli.fish +++ b/completion/mpqcli.fish @@ -106,7 +106,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from info' \ # create # create [-p/--path] [-s/--sign] -# [--locale] [-g/--game] +# [-e/--locale] [-g/--game] # [--format-version] [--stream-flags] [--sector-size] [--raw-chunk-size] # [--file-flags1/2/3] [--attr-flags] # [--flags] [--compression] [--compression-next] @@ -119,7 +119,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from create' \ complete -c mpqcli -n '__fish_seen_subcommand_from create' \ -s s -l sign -d 'Sign the MPQ archive' complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l locale -d 'Locale to use for added files' \ + -s e -l locale -d 'Locale to use for added files' \ -r -f -a "$__mpqcli_locales" complete -c mpqcli -n '__fish_seen_subcommand_from create' \ -s g -l game -d 'Game profile for MPQ creation' \ @@ -150,7 +150,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from create' \ # add # add [-p/--path] [-w/--overwrite | -u/--update] -# [--locale] [-g/--game] +# [-e/--locale] [-g/--game] # [--flags] [--compression] [--compression-next] # Positional 1 is the archive and the rest are files or directories to add, so # every positional takes a path @@ -163,7 +163,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from add' \ complete -c mpqcli -n '__fish_seen_subcommand_from add' \ -s u -l update -d 'Replace only files that changed' complete -c mpqcli -n '__fish_seen_subcommand_from add' \ - -l locale -d 'Locale to use for added file' \ + -s e -l locale -d 'Locale to use for added file' \ -r -f -a "$__mpqcli_locales" complete -c mpqcli -n '__fish_seen_subcommand_from add' \ -s g -l game -d 'Game profile for compression rules' \ @@ -176,23 +176,23 @@ complete -c mpqcli -n '__fish_seen_subcommand_from add' \ -l compression-next -d 'Override compression for subsequent sectors' -r -f # remove -# remove [--locale] +# remove [-e/--locale] # Positional 1 is the archive; the rest are in-archive paths, so they get no # filesystem completion complete -c mpqcli -n '__fish_seen_subcommand_from remove; and test (__mpqcli_positional_index) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from remove' \ - -l locale -d 'Locale of the file to remove' \ + -s e -l locale -d 'Locale of the file to remove' \ -r -f -a "$__mpqcli_locales" # rename -# rename [--locale] +# rename [-e/--locale] # Positional 1 is the archive; positionals 2 and 3 are in-archive paths, so # they get no filesystem completion complete -c mpqcli -n '__fish_seen_subcommand_from rename; and test (__mpqcli_positional_index) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from rename' \ - -l locale -d 'Locale of the file to rename' \ + -s e -l locale -d 'Locale of the file to rename' \ -r -f -a "$__mpqcli_locales" # list @@ -212,7 +212,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from list' \ # extract # extract [files...] [-o/--output] [--flat] -# [-l/--listfile] [--locale] +# [-l/--listfile] [-e/--locale] # Positional 1 is the archive; the rest are in-archive paths, so they get no # filesystem completion complete -c mpqcli -n '__fish_seen_subcommand_from extract; and test (__mpqcli_positional_index --flat) -eq 1' -F @@ -224,17 +224,17 @@ complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ -s l -l listfile -d 'External file listing content of the archive' -r -F complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ - -l locale -d 'Preferred locale for extracted files' \ + -s e -l locale -d 'Preferred locale for extracted files' \ -r -f -a "$__mpqcli_locales" # read -# read [--locale] +# read [-e/--locale] # Positional 1 is the archive; positional 2 is an in-archive path, so it gets # no filesystem completion complete -c mpqcli -n '__fish_seen_subcommand_from read; and test (__mpqcli_positional_index) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from read' \ - -l locale -d 'Preferred locale for read file' \ + -s e -l locale -d 'Preferred locale for read file' \ -r -f -a "$__mpqcli_locales" # verify diff --git a/completion/mpqcli.ps1 b/completion/mpqcli.ps1 index c6ac037..8fb4560 100644 --- a/completion/mpqcli.ps1 +++ b/completion/mpqcli.ps1 @@ -82,6 +82,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--path' = 'Archive path for a single file, or prefix for a directory or several inputs' '-s' = 'Sign the MPQ archive' '--sign' = 'Sign the MPQ archive' + '-e' = 'Locale to use for added files' '--locale' = 'Locale to use for added files' '-g' = 'Game profile for MPQ creation' '--game' = 'Game profile for MPQ creation' @@ -104,6 +105,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--overwrite' = 'Replace every file that already exists in the archive' '-u' = 'Replace only files that changed' '--update' = 'Replace only files that changed' + '-e' = 'Locale to use for added file' '--locale' = 'Locale to use for added file' '-g' = 'Game profile for compression rules' '--game' = 'Game profile for compression rules' @@ -112,9 +114,11 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--compression-next' = 'Override compression for subsequent sectors' } 'remove' = [ordered]@{ + '-e' = 'Locale of file to remove' '--locale' = 'Locale of file to remove' } 'rename' = [ordered]@{ + '-e' = 'Locale of file to rename' '--locale' = 'Locale of file to rename' } 'list' = [ordered]@{ @@ -132,9 +136,11 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--flat' = 'Write files into the output directory without their folders' '-l' = 'File listing content of an MPQ archive' '--listfile' = 'File listing content of an MPQ archive' + '-e' = 'Preferred locale for extracted files' '--locale' = 'Preferred locale for extracted files' } 'read' = [ordered]@{ + '-e' = 'Preferred locale for read file' '--locale' = 'Preferred locale for read file' } 'verify' = [ordered]@{ @@ -151,6 +157,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl # Options whose *argument* should complete from a static value set $valueOptions = @{ + '-e' = $locales '--locale' = $locales '-g' = $gameProfiles '--game' = $gameProfiles diff --git a/completion/mpqcli.zsh b/completion/mpqcli.zsh index 3aefe0e..5137215 100644 --- a/completion/mpqcli.zsh +++ b/completion/mpqcli.zsh @@ -100,7 +100,7 @@ _mpqcli_create() { '*:files:_files' \ '(-p --path)'{-p,--path}'[archive path for a single file, or prefix for a directory or several inputs]:path' \ '(-s --sign)'{-s,--sign}'[sign the archive]' \ - '--locale[locale for added files]:locale:('"$locales"')' \ + '(-e --locale)'{-e,--locale}'[locale for added files]:locale:('"$locales"')' \ '(-g --game)'{-g,--game}'[game profile]:profile:('"$games"')' \ '--format-version[MPQ archive format version (1-4)]:version:(1 2 3 4)' \ '--stream-flags[override stream flags]:flags' \ @@ -123,7 +123,7 @@ _mpqcli_add() { '(-p --path)'{-p,--path}'[archive path for a single file, or prefix for a directory or several inputs]:path' \ '(-w --overwrite -u --update)'{-w,--overwrite}'[replace every existing file]' \ '(-u --update -w --overwrite)'{-u,--update}'[replace only files that changed]' \ - '--locale[locale for added file]:locale:('"$locales"')' \ + '(-e --locale)'{-e,--locale}'[locale for added file]:locale:('"$locales"')' \ '(-g --game)'{-g,--game}'[game profile]:profile:('"$games"')' \ '--flags[override MPQ file flags]:flags' \ '--compression[override compression for first sector]:compression' \ @@ -135,7 +135,7 @@ _mpqcli_remove() { _arguments \ '1:archive:_mpqcli_archives' \ '*:archive paths' \ - '--locale[locale of file to remove]:locale:('"$locales"')' + '(-e --locale)'{-e,--locale}'[locale of file to remove]:locale:('"$locales"')' } _mpqcli_rename() { @@ -144,7 +144,7 @@ _mpqcli_rename() { '1:archive:_mpqcli_archives' \ '2:old archive path' \ '3:new archive path' \ - '--locale[locale of file to rename]:locale:('"$locales"')' + '(-e --locale)'{-e,--locale}'[locale of file to rename]:locale:('"$locales"')' } _mpqcli_list() { @@ -165,7 +165,7 @@ _mpqcli_extract() { '(-o --output)'{-o,--output}'[output directory]:dir:_files -/' \ '--flat[write files without their folders]' \ '(-l --listfile)'{-l,--listfile}'[listfile path]:file:_files' \ - '--locale[preferred locale for extracted files]:locale:('"$locales"')' + '(-e --locale)'{-e,--locale}'[preferred locale for extracted files]:locale:('"$locales"')' } _mpqcli_read() { @@ -173,7 +173,7 @@ _mpqcli_read() { _arguments \ '1:archive:_mpqcli_archives' \ '2:file-in-archive' \ - '--locale[preferred locale for read file]:locale:('"$locales"')' + '(-e --locale)'{-e,--locale}'[preferred locale for read file]:locale:('"$locales"')' } _mpqcli_verify() { diff --git a/docs/changelog.md b/docs/changelog.md index 0a87493..3ea2c5b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,6 +6,7 @@ - Accept several files, or a list read from stdin, in create and extract - Add a flat option to extract +- Add -e as the short form of the locale option ### Changed diff --git a/docs/commands/add.md b/docs/commands/add.md index 570cd34..9bb1818 100644 --- a/docs/commands/add.md +++ b/docs/commands/add.md @@ -100,7 +100,7 @@ $ mpqcli add wow-patch.mpq allegiance.txt --overwrite ## Add with a locale -Use `--locale` to store the file under a specific locale. Files added without `--locale` use the default locale. +Use `-e` or `--locale` to store the file under a specific locale. Files added without `--locale` use the default locale. ```bash $ mpqcli add wow-patch.mpq allianz.txt --locale deDE diff --git a/docs/commands/create.md b/docs/commands/create.md index e284816..5f85ebe 100644 --- a/docs/commands/create.md +++ b/docs/commands/create.md @@ -76,7 +76,7 @@ $ mpqcli create --format-version 1 --sign archive.mpq ## Create an MPQ archive with a given locale -Use the `--locale` argument to specify the locale that all added files will have in the archive. Note that subsequent added files will have the default locale unless the `--locale` argument is specified again. +Use the `-e` or `--locale` argument to specify the locale that all added files will have in the archive. Note that subsequent added files will have the default locale unless the `--locale` argument is specified again. ```bash $ mpqcli create archive.mpq --locale koKR diff --git a/docs/commands/extract.md b/docs/commands/extract.md index 7b17ad2..7ee9e41 100644 --- a/docs/commands/extract.md +++ b/docs/commands/extract.md @@ -65,7 +65,7 @@ $ mpqcli list wow-patch.mpq | grep -i '\.exe$' | mpqcli extract wow-patch.mpq - ## Extract one specific file with locale -Use the `--locale` argument to specify the locale of the file to extract. If there is no file with the requested name and locale, the default locale will be used instead. +Use the `-e` or `--locale` argument to specify the locale of the file to extract. If there is no file with the requested name and locale, the default locale will be used instead. ```bash $ mpqcli extract Patch_rt.mpq "rez\gluBNRes.res" --locale deDE diff --git a/docs/commands/read.md b/docs/commands/read.md index b577888..337a108 100644 --- a/docs/commands/read.md +++ b/docs/commands/read.md @@ -39,7 +39,7 @@ $ mpqcli read wow-patch.mpq "WoW.exe" | xxd ## Read one specific file with locale -Use the `--locale` argument to specify the locale of the file to read. If there is no file with the requested name and locale, the default locale will be used instead. +Use the `-e` or `--locale` argument to specify the locale of the file to read. If there is no file with the requested name and locale, the default locale will be used instead. ```bash $ mpqcli read Patch_rt.mpq "rez\stat_txt.tbl" --locale ptPT diff --git a/docs/commands/remove.md b/docs/commands/remove.md index d80f4f8..3c67151 100644 --- a/docs/commands/remove.md +++ b/docs/commands/remove.md @@ -35,7 +35,7 @@ $ echo -e "fth.txt\nfta.txt" | mpqcli remove wow-patch.mpq - ## Remove a file with a given locale -Use `--locale` to remove only the copy stored under a specific locale. Without `--locale`, +Use `-e` or `--locale` to remove only the copy stored under a specific locale. Without `--locale`, the file stored under the default locale is removed. ```bash diff --git a/docs/commands/rename.md b/docs/commands/rename.md index 81975cf..b8732a9 100644 --- a/docs/commands/rename.md +++ b/docs/commands/rename.md @@ -10,7 +10,7 @@ $ mpqcli rename wow-patch.mpq old-name.txt new-name.txt [~] Renaming file: old-name.txt -> new-name.txt ``` -Use `--locale` to rename only the copy stored under a specific locale. Without `--locale`, +Use `-e` or `--locale` to rename only the copy stored under a specific locale. Without `--locale`, the file stored under the default locale is renamed. ```bash diff --git a/test/test_read.py b/test/test_read.py index 62ebec1..b9566ea 100644 --- a/test/test_read.py +++ b/test/test_read.py @@ -1,6 +1,9 @@ +import re import subprocess from pathlib import Path +import pytest + def test_read_mpq_v1_plain(binary_path): script_dir = Path(__file__).parent @@ -75,6 +78,37 @@ def test_read_file_from_mpq_with_locale(binary_path, generate_locales_mpq_test_f assert output_lines == expected_content, f"Unexpected output: {output_lines}" +def test_read_file_from_mpq_with_short_locale_flag(binary_path, generate_locales_mpq_test_files): + _ = generate_locales_mpq_test_files + script_dir = Path(__file__).parent + test_file = script_dir / "data" / "mpq_with_many_locales.mpq" + + result = subprocess.run( + [str(binary_path), "read", str(test_file), "cats.txt", "-e", "deDE"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert result.stdout.splitlines() == ["Dies ist eine Datei über Katzen."] + + +@pytest.mark.parametrize("subcommand", ["add", "create", "extract", "read", "remove", "rename"]) +def test_locale_short_flag_is_e_on_every_subcommand(binary_path, subcommand): + # A short flag means one thing across the binary, so every subcommand with a + # locale offers it as -e + result = subprocess.run( + [str(binary_path), subcommand, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert re.search(r"^\s+-e,\s+--locale\b", result.stdout, re.MULTILINE), result.stdout + + def test_read_file_from_mpq_with_default_locale(binary_path, generate_locales_mpq_test_files): """ Test MPQ archive file reading with no specified locale but many matching file names. From ed51d43146790a2368d8f78d7cff6576ecd579f1 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 02:35:22 +1300 Subject: [PATCH 18/20] Added -j, -q and -x as short forms of --flat, --property and --print --- CHANGELOG.md | 2 +- app/extract.cpp | 2 +- app/info.cpp | 2 +- app/list.cpp | 2 +- app/verify.cpp | 2 +- completion/mpqcli.bash | 16 ++++++++-------- completion/mpqcli.fish | 20 ++++++++++---------- completion/mpqcli.ps1 | 6 +++++- completion/mpqcli.zsh | 8 ++++---- docs/changelog.md | 2 +- docs/commands/extract.md | 2 +- docs/commands/info.md | 2 +- docs/commands/list.md | 2 +- docs/commands/verify.md | 2 +- test/test_extract.py | 26 ++++++++++++++++++++++++++ test/test_info.py | 21 +++++++++++++++++++++ test/test_list.py | 21 +++++++++++++++++++++ test/test_verify.py | 31 +++++++++++++++++++++++++++++++ 18 files changed, 136 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea2c5b..4844132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Accept several files, or a list read from stdin, in create and extract - Add a flat option to extract -- Add -e as the short form of the locale option +- Add short flags -e, -j, -q and -x for the locale, flat, property and print options ### Changed diff --git a/app/extract.cpp b/app/extract.cpp index b7c374e..4b3d501 100644 --- a/app/extract.cpp +++ b/app/extract.cpp @@ -29,7 +29,7 @@ void RegisterExtract(CLI::App &app, Context &context) { "Files to extract, or every file when omitted; pass - to read " "names from stdin"); sub->add_option("-o,--output", options->extract.output, "Output directory"); - sub->add_flag("--flat", options->extract.flat, + sub->add_flag("-j,--flat", options->extract.flat, "Write files into the output directory without their folders"); sub->add_option("-l,--listfile", options->extract.listfile, "File listing content of an MPQ archive, when extracting every file") diff --git a/app/info.cpp b/app/info.cpp index 1b776b3..cc34710 100644 --- a/app/info.cpp +++ b/app/info.cpp @@ -24,7 +24,7 @@ void RegisterInfo(CLI::App &app, Context &context) { sub->add_option("archive", options->archive, "MPQ archive") ->required() ->check(CLI::ExistingFile); - sub->add_option("--property", options->property, "Prints only a specific property value") + sub->add_option("-q,--property", options->property, "Prints only a specific property value") ->check(CLI::IsMember(valid_properties)); sub->callback([options, &context]() { mpqcli::Info(*options, context.out); }); diff --git a/app/list.cpp b/app/list.cpp index ae7d595..77ccb76 100644 --- a/app/list.cpp +++ b/app/list.cpp @@ -30,7 +30,7 @@ void RegisterList(CLI::App &app, Context &context) { sub->add_flag("-d,--detailed", options->detailed, "File listing with additional columns (default false)"); sub->add_flag("-a,--all", options->all, "File listing including hidden files (default false)"); - sub->add_option("--property", options->properties, "Prints only specific property values") + sub->add_option("-q,--property", options->properties, "Prints only specific property values") ->check(CLI::IsMember(valid_properties)); sub->callback([options, &context]() { mpqcli::List(*options, context.out, context.err); }); diff --git a/app/verify.cpp b/app/verify.cpp index 407924f..3fbafc9 100644 --- a/app/verify.cpp +++ b/app/verify.cpp @@ -13,7 +13,7 @@ void RegisterVerify(CLI::App &app, Context &context) { sub->add_option("archive", options->archive, "MPQ archive") ->required() ->check(CLI::ExistingFile); - sub->add_flag("--print", options->print_signature, "Print the digital signature (in hex)"); + sub->add_flag("-x,--print", options->print_signature, "Print the digital signature (in hex)"); sub->callback([options, &context]() { context.exit_code = mpqcli::Verify(*options, context.out, context.err) ? 0 : 1; diff --git a/completion/mpqcli.bash b/completion/mpqcli.bash index 3479212..4f3c77d 100644 --- a/completion/mpqcli.bash +++ b/completion/mpqcli.bash @@ -116,10 +116,10 @@ _mpqcli() { case "${words[1]}" in info) case "$prev" in - --property) _mpqcli_words "${info_properties[*]}"; return ;; + -q|--property) _mpqcli_words "${info_properties[*]}"; return ;; esac if [[ "$cur" == -* ]]; then - _mpqcli_words "--property" + _mpqcli_words "-q --property" else _mpqcli_positional_index if [[ $pos -eq 0 ]]; then @@ -204,10 +204,10 @@ _mpqcli() { list) case "$prev" in -l|--listfile) _mpqcli_filedir; return ;; - --property) _mpqcli_words "${list_properties[*]}"; return ;; + -q|--property) _mpqcli_words "${list_properties[*]}"; return ;; esac if [[ "$cur" == -* ]]; then - _mpqcli_words "-l --listfile -d --detailed -a --all --property" + _mpqcli_words "-l --listfile -d --detailed -a --all -q --property" else _mpqcli_positional_index -d --detailed -a --all if [[ $pos -eq 0 ]]; then @@ -223,11 +223,11 @@ _mpqcli() { -e|--locale) _mpqcli_words "${locales[*]}"; return ;; esac if [[ "$cur" == -* ]]; then - _mpqcli_words "-o --output --flat -l --listfile -e --locale" + _mpqcli_words "-o --output -j --flat -l --listfile -e --locale" else # Positional 1 is the archive; the rest are in-archive paths, so # they get no filesystem completion - _mpqcli_positional_index --flat + _mpqcli_positional_index -j --flat if [[ $pos -eq 0 ]]; then _mpqcli_filedir mpq fi @@ -252,9 +252,9 @@ _mpqcli() { verify) if [[ "$cur" == -* ]]; then - _mpqcli_words "--print" + _mpqcli_words "-x --print" else - _mpqcli_positional_index --print + _mpqcli_positional_index -x --print if [[ $pos -eq 0 ]]; then _mpqcli_filedir mpq fi diff --git a/completion/mpqcli.fish b/completion/mpqcli.fish index 49e84af..33d4c63 100644 --- a/completion/mpqcli.fish +++ b/completion/mpqcli.fish @@ -97,11 +97,11 @@ complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ -a completion -d 'Generate shell completion script' # info -# info [--property ] +# info [-q/--property ] complete -c mpqcli -n '__fish_seen_subcommand_from info; and test (__mpqcli_positional_index) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from info' \ - -l property -d 'Print only a specific property value' \ + -s q -l property -d 'Print only a specific property value' \ -r -f -a "$__mpqcli_info_properties" # create @@ -197,7 +197,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from rename' \ # list # list [-l/--listfile] [-d/--detailed] [-a/--all] -# [--property ] +# [-q/--property ] complete -c mpqcli -n '__fish_seen_subcommand_from list; and test (__mpqcli_positional_index -d --detailed -a --all) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from list' \ @@ -207,20 +207,20 @@ complete -c mpqcli -n '__fish_seen_subcommand_from list' \ complete -c mpqcli -n '__fish_seen_subcommand_from list' \ -s a -l all -d 'Include hidden files' complete -c mpqcli -n '__fish_seen_subcommand_from list' \ - -l property -d 'Print only specific property values' \ + -s q -l property -d 'Print only specific property values' \ -r -f -a "$__mpqcli_list_properties" # extract -# extract [files...] [-o/--output] [--flat] +# extract [files...] [-o/--output] [-j/--flat] # [-l/--listfile] [-e/--locale] # Positional 1 is the archive; the rest are in-archive paths, so they get no # filesystem completion -complete -c mpqcli -n '__fish_seen_subcommand_from extract; and test (__mpqcli_positional_index --flat) -eq 1' -F +complete -c mpqcli -n '__fish_seen_subcommand_from extract; and test (__mpqcli_positional_index -j --flat) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ -s o -l output -d 'Output directory' -r -f -a '(__fish_complete_directories)' complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ - -l flat -d 'Write files without their folders' + -s j -l flat -d 'Write files without their folders' complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ -s l -l listfile -d 'External file listing content of the archive' -r -F complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ @@ -238,11 +238,11 @@ complete -c mpqcli -n '__fish_seen_subcommand_from read' \ -r -f -a "$__mpqcli_locales" # verify -# verify [--print] -complete -c mpqcli -n '__fish_seen_subcommand_from verify; and test (__mpqcli_positional_index --print) -eq 1' -F +# verify [-x/--print] +complete -c mpqcli -n '__fish_seen_subcommand_from verify; and test (__mpqcli_positional_index -x --print) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from verify' \ - -l print -d 'Print the digital signature (in hex)' + -s x -l print -d 'Print the digital signature (in hex)' # compact # compact [-l/--listfile] diff --git a/completion/mpqcli.ps1 b/completion/mpqcli.ps1 index 8fb4560..16b0233 100644 --- a/completion/mpqcli.ps1 +++ b/completion/mpqcli.ps1 @@ -75,6 +75,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl # short help string, in the order the help text lists them $optionSpec = @{ 'info' = [ordered]@{ + '-q' = 'Print only a specific property value' '--property' = 'Print only a specific property value' } 'create' = [ordered]@{ @@ -128,11 +129,13 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--detailed' = 'File listing with additional columns' '-a' = 'File listing including hidden files' '--all' = 'File listing including hidden files' + '-q' = 'Print only specific property values' '--property' = 'Print only specific property values' } 'extract' = [ordered]@{ '-o' = 'Output directory' '--output' = 'Output directory' + '-j' = 'Write files into the output directory without their folders' '--flat' = 'Write files into the output directory without their folders' '-l' = 'File listing content of an MPQ archive' '--listfile' = 'File listing content of an MPQ archive' @@ -144,6 +147,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--locale' = 'Preferred locale for read file' } 'verify' = [ordered]@{ + '-x' = 'Print the digital signature (in hex)' '--print' = 'Print the digital signature (in hex)' } 'compact' = [ordered]@{ @@ -235,7 +239,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl return & $emit $items 'ParameterValue' } # Property options: meaning depends on the subcommand - if ($prevToken -eq '--property') { + if ($prevToken -in @('-q', '--property')) { if ($subcommand -eq 'info') { $items = foreach ($v in $infoProperties) { [pscustomobject]@{ Value = $v; Tip = 'info property' } } diff --git a/completion/mpqcli.zsh b/completion/mpqcli.zsh index 5137215..9fa9d6f 100644 --- a/completion/mpqcli.zsh +++ b/completion/mpqcli.zsh @@ -90,7 +90,7 @@ _mpqcli_info() { local props="${_mpqcli_info_properties[*]}" _arguments \ '1:archive:_mpqcli_archives' \ - '--property[print only a specific property]:property:('"$props"')' + '(-q --property)'{-q,--property}'[print only a specific property]:property:('"$props"')' } _mpqcli_create() { @@ -154,7 +154,7 @@ _mpqcli_list() { '(-l --listfile)'{-l,--listfile}'[listfile path]:file:_files' \ '(-d --detailed)'{-d,--detailed}'[detailed listing with extra columns]' \ '(-a --all)'{-a,--all}'[include hidden files]' \ - '--property[print specific property values]:property:('"$props"')' + '*'{-q,--property}'[print specific property values]:property:('"$props"')' } _mpqcli_extract() { @@ -163,7 +163,7 @@ _mpqcli_extract() { '1:archive:_mpqcli_archives' \ '*:archive paths' \ '(-o --output)'{-o,--output}'[output directory]:dir:_files -/' \ - '--flat[write files without their folders]' \ + '(-j --flat)'{-j,--flat}'[write files without their folders]' \ '(-l --listfile)'{-l,--listfile}'[listfile path]:file:_files' \ '(-e --locale)'{-e,--locale}'[preferred locale for extracted files]:locale:('"$locales"')' } @@ -179,7 +179,7 @@ _mpqcli_read() { _mpqcli_verify() { _arguments \ '1:archive:_mpqcli_archives' \ - '--print[print the digital signature in hex]' + '(-x --print)'{-x,--print}'[print the digital signature in hex]' } _mpqcli_compact() { diff --git a/docs/changelog.md b/docs/changelog.md index 3ea2c5b..4844132 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,7 +6,7 @@ - Accept several files, or a list read from stdin, in create and extract - Add a flat option to extract -- Add -e as the short form of the locale option +- Add short flags -e, -j, -q and -x for the locale, flat, property and print options ### Changed diff --git a/docs/commands/extract.md b/docs/commands/extract.md index 7ee9e41..9317ad1 100644 --- a/docs/commands/extract.md +++ b/docs/commands/extract.md @@ -27,7 +27,7 @@ $ mpqcli extract -o patch-1.10 wow-patch.mpq ## Extract all files without the folder structure -Use the `--flat` flag to write every file straight into the output directory, keeping only the filename. If two files would end up with the same name, nothing is extracted. +Use the `-j` or `--flat` flag to write every file straight into the output directory, keeping only the filename. If two files would end up with the same name, nothing is extracted. ```bash $ mpqcli extract --flat wow-patch.mpq diff --git a/docs/commands/info.md b/docs/commands/info.md index eb25b78..1799902 100644 --- a/docs/commands/info.md +++ b/docs/commands/info.md @@ -29,7 +29,7 @@ The `info` subcommand supports the following properties: - `max-files` - `signature-type` -You can use the `--property` argument with the `info` subcommand to print just the value of a specific property. This can be useful for automation, for example, to determine the signature type of a directory of MPQ archives. +You can use the `-q` or `--property` argument with the `info` subcommand to print just the value of a specific property. This can be useful for automation, for example, to determine the signature type of a directory of MPQ archives. ```bash $ mpqcli info --property file-count wow-patch.mpq diff --git a/docs/commands/list.md b/docs/commands/list.md index 8fbbe22..fcd8d52 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -63,7 +63,7 @@ The `list` subcommand supports listing the following properties: * `n`: Use default flags for internal files. * `f`: Fix key; This is obsolete. -You can use the `--property` argument with the `list` subcommand to print the given properties. Many properties can be given, and they will be printed in the order given. +You can use the `-q` or `--property` argument with the `list` subcommand to print the given properties. Many properties can be given, and they will be printed in the order given. ```bash $ mpqcli list -d -a Patch_rt.mpq --property hash-index --property locale --property flags diff --git a/docs/commands/verify.md b/docs/commands/verify.md index cda527c..1638b62 100644 --- a/docs/commands/verify.md +++ b/docs/commands/verify.md @@ -20,7 +20,7 @@ $ echo $? ## Verify an MPQ archive and print the digital signature -Check the digital signature of an MPQ archive, by verifying the signature in the archive and also printing the digital signature value in hexadecimal using the `--print` argument. +Check the digital signature of an MPQ archive, by verifying the signature in the archive and also printing the digital signature value in hexadecimal using the `-x` or `--print` argument. ```bash $ mpqcli verify --print wow-patch.mpq > signature diff --git a/test/test_extract.py b/test/test_extract.py index bb6835e..627cf82 100644 --- a/test/test_extract.py +++ b/test/test_extract.py @@ -1060,3 +1060,29 @@ def test_extract_flat_allows_one_name_stored_under_several_locales(binary_path, assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" assert result.stderr.splitlines().count("[*] Extracted: cats.txt") == 2 assert (output_dir / "cats.txt").exists() + + +def test_extract_short_flat_flag(binary_path, tmp_path): + input_dir = tmp_path / "files" + (input_dir / "texts").mkdir(parents=True) + (input_dir / "texts" / "cats.txt").write_text("Cats.") + archive_file = tmp_path / "nested.mpq" + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(input_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"Failed to create archive: {result.stderr}" + + result = subprocess.run( + [str(binary_path), "extract", "-j", "-o", str(output_dir), str(archive_file), "texts\\cats.txt"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert set(fi.name for fi in output_dir.iterdir()) == {"cats.txt"} diff --git a/test/test_info.py b/test/test_info.py index 5735671..09c71b0 100644 --- a/test/test_info.py +++ b/test/test_info.py @@ -177,3 +177,24 @@ def test_info_short_property_flag_is_rejected(binary_path, generate_cats_and_dog assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" assert "archive: File does not exist: file-count" in result.stderr assert result.stdout == "" + + +def test_info_property_short_flag(binary_path, generate_cats_and_dogs_mpq): + test_file = generate_cats_and_dogs_mpq + + short_form = subprocess.run( + [str(binary_path), "info", "-q", "file-count", str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + long_form = subprocess.run( + [str(binary_path), "info", "--property", "file-count", str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + assert short_form.returncode == 0, f"mpqcli failed with error: {short_form.stderr}" + assert short_form.stdout.strip() != "" + assert short_form.stdout == long_form.stdout diff --git a/test/test_list.py b/test/test_list.py index cd93d80..360651f 100644 --- a/test/test_list.py +++ b/test/test_list.py @@ -577,3 +577,24 @@ def test_list_short_property_flag_is_rejected(binary_path, generate_cats_and_dog assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" assert "archive: File does not exist: locale" in result.stderr assert result.stdout == "" + + +def test_list_property_short_flag(binary_path, generate_cats_and_dogs_mpq): + test_file = generate_cats_and_dogs_mpq + + short_form = subprocess.run( + [str(binary_path), "list", "-q", "locale", str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + long_form = subprocess.run( + [str(binary_path), "list", "--property", "locale", str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert short_form.returncode == 0, f"mpqcli failed with error: {short_form.stderr}" + assert short_form.stdout.strip() != "" + assert short_form.stdout == long_form.stdout diff --git a/test/test_verify.py b/test/test_verify.py index c8c675b..b894ccf 100644 --- a/test/test_verify.py +++ b/test/test_verify.py @@ -183,3 +183,34 @@ def test_verify_short_print_flag_is_rejected(binary_path, generate_cats_and_dogs assert result.returncode == 109, f"mpqcli failed with error: {result.stderr}" assert result.stdout == "" + + +def test_verify_print_short_flag(binary_path, tmp_path): + input_file = tmp_path / "a.txt" + input_file.write_text("A file.") + archive_file = tmp_path / "signed.mpq" + + result = subprocess.run( + [str(binary_path), "create", "-s", str(archive_file), str(input_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"Failed to create archive: {result.stderr}" + + short_form = subprocess.run( + [str(binary_path), "verify", "-x", str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=False # The signature is raw bytes + ) + long_form = subprocess.run( + [str(binary_path), "verify", "--print", str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=False + ) + + assert short_form.returncode == 0, f"mpqcli failed with error: {short_form.stderr}" + assert short_form.stdout != b"" + assert short_form.stdout == long_form.stdout From 4ee23320510de6eba29453b6766b2a7585a0669d Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 02:49:13 +1300 Subject: [PATCH 19/20] Made create fail without an archive when two inputs hold a file with the same name --- docs/commands/create.md | 2 +- src/commands/create.cpp | 7 ++++--- test/test_create.py | 16 ++++++---------- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/docs/commands/create.md b/docs/commands/create.md index 5f85ebe..7111214 100644 --- a/docs/commands/create.md +++ b/docs/commands/create.md @@ -16,7 +16,7 @@ The archive must not already exist; `create` never overwrites a file. If an inpu ## Create an MPQ archive from several files and directories -Name as many files and directories as needed after the archive. Each file is stored under its own name, and each directory's contents go at the root of the archive. If two inputs hold a file with the same name, the first one is kept. +Name as many files and directories as needed after the archive. Each file is stored under its own name, and each directory's contents go at the root of the archive. If two inputs hold a file with the same name, no archive is created. ```bash $ mpqcli create archive.mpq readme.txt textures/ sounds/ diff --git a/src/commands/create.cpp b/src/commands/create.cpp index ac183e3..66ca6b4 100644 --- a/src/commands/create.cpp +++ b/src/commands/create.cpp @@ -104,15 +104,16 @@ bool Create(const CreateOptions &options, std::ostream &err, const std::atomic 0) { - err << "[*] " << files_skipped - << " file(s) were skipped because an earlier input added a file by the same name" + err << "[!] " << files_skipped + << " file(s) were not added because an earlier input added a file by the same name" << std::endl; } // An archive missing some of its files is not the one that was asked for, so it is // discarded with the partial and the output path stays empty, as for a missing input - if (result != 0) { + if (result != 0 || files_skipped > 0) { err << "[!] Failed to add every file, so no archive was created: " << output_file_path.string() << std::endl; return false; diff --git a/test/test_create.py b/test/test_create.py index b2555c6..9b6636f 100644 --- a/test/test_create.py +++ b/test/test_create.py @@ -893,7 +893,7 @@ def test_create_from_several_inputs(binary_path, tmp_path): assert set(list_result.stdout.splitlines()) == {"readme.txt", "bear\\bear.blp"} -def test_create_keeps_the_first_of_two_inputs_with_the_same_name(binary_path, tmp_path): +def test_create_two_inputs_with_the_same_name_creates_nothing(binary_path, tmp_path): first_dir = tmp_path / "first" second_dir = tmp_path / "second" first_dir.mkdir() @@ -908,16 +908,12 @@ def test_create_keeps_the_first_of_two_inputs_with_the_same_name(binary_path, tm stderr=subprocess.PIPE, text=True ) - assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "1 file(s) were skipped" in result.stderr - read_result = subprocess.run( - [str(binary_path), "read", str(archive_file), "a.txt"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - assert read_result.stdout == "First." + assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" + assert "1 file(s) were not added because an earlier input added a file by the same name" \ + in result.stderr + assert "no archive was created" in result.stderr + assert sorted(p.name for p in tmp_path.iterdir()) == ["first", "second"] def test_create_files_via_stdin(binary_path, tmp_path): From 8908e6ea7276c83a5e5bd394ba698712290b6a36 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 16 Oct 2026 03:03:40 +1300 Subject: [PATCH 20/20] Updated README container download stats --- Makefile | 17 +++++++++++------ README.md | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index 5336ff3..dced9a2 100644 --- a/Makefile +++ b/Makefile @@ -162,16 +162,21 @@ gen_docs_changelog: ## Copy CHANGELOG.md into the docs site @cp CHANGELOG.md docs/changelog.md @echo "[*] Updated docs/changelog.md" +# The exact count is read from the title attribute (

1.22K

) +# and abbreviated to match the shields.io release downloads badge (1.2k) .PHONY: gen_downloads gen_downloads: ## Update the package downloads badge in README.md from GitHub - @DOWNLOADS=$$(curl -s "$(PACKAGE_URL)" \ + @DOWNLOADS=$$(curl -fs "$(PACKAGE_URL)" \ | grep -A2 "Total downloads" \ - | grep -o '

[0-9]*

' \ - | grep -o 'title="[0-9]*"' \ - | grep -o '[0-9]*' \ + | sed -n 's/.*

&2; \ + exit 1; \ + fi; \ + BADGE=$$(awk -v n="$$DOWNLOADS" 'BEGIN { if (n < 1000) print n; else printf "%.1fk\n", n / 1000 }'); \ + sed -i "s/package_downloads-[^-]*-green/package_downloads-$$BADGE-green/" $(README); \ + echo "[*] Updated package downloads badge: $$BADGE ($$DOWNLOADS)" ##@ GET diff --git a/README.md b/README.md index 050fbf1..9946845 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Release Build](https://img.shields.io/github/actions/workflow/status/thegraydot/mpqcli/tag.yml?style=flat&label=release&logo=github) ![Main Build](https://img.shields.io/github/actions/workflow/status/thegraydot/mpqcli/main.yml?style=flat&label=main&logo=github) -![Release Version](https://img.shields.io/github/v/release/thegraydot/mpqcli?style=flat&logo=github) ![Release downloads](https://img.shields.io/github/downloads/thegraydot/mpqcli/total?style=flat&label=downloads&logo=github) ![Package downloads](https://img.shields.io/badge/package_downloads-996-green?style=flat&logo=github) +![Release Version](https://img.shields.io/github/v/release/thegraydot/mpqcli?style=flat&logo=github) ![Release downloads](https://img.shields.io/github/downloads/thegraydot/mpqcli/total?style=flat&label=downloads&logo=github) ![Package downloads](https://img.shields.io/badge/package_downloads-1.2k-green?style=flat&logo=github) ![C++ Version](https://img.shields.io/badge/Version-17-blue?style=flat&logo=cplusplus)