diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bef9b8..4844132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # 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 +- Add short flags -e, -j, -q and -x for the locale, flat, property and print options + +### 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 +- Stop add and create from storing an archive inside itself +- Leave no archive behind when create cannot add every file + +### 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/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) diff --git a/app/add.cpp b/app/add.cpp index 54dabde..0b8b5d4 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, @@ -29,13 +29,13 @@ 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, "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/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/create.cpp b/app/create.cpp index cb8f4ce..05518d5 100644 --- a/app/create.cpp +++ b/app/create.cpp @@ -3,32 +3,40 @@ #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 version; + 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"); + "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" + @@ -36,7 +44,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 +75,11 @@ void RegisterCreate(CLI::App &app, Context &context) { ->group("Game setting overrides"); sub->callback([options, &context]() { - if (options->version.has_value()) { + 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->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/app/extract.cpp b/app/extract.cpp index ad954cb..4b3d501 100644 --- a/app/extract.cpp +++ b/app/extract.cpp @@ -1,27 +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("target", options->target, "Target 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("-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") + ->check(CLI::ExistingFile) + ->excludes(files); + sub->add_option("-e,--locale", options->extract.locale, "Preferred locale for extracted files") + ->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/app/info.cpp b/app/info.cpp index 9b014c1..cc34710 100644 --- a/app/info.cpp +++ b/app/info.cpp @@ -21,10 +21,10 @@ 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") + 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 ad2e2f1..77ccb76 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") @@ -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("-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/read.cpp b/app/read.cpp index 5d5997b..e3448fd 100644 --- a/app/read.cpp +++ b/app/read.cpp @@ -5,16 +5,18 @@ #include #include "commands.h" +#include "validators.h" 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("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"); + sub->add_option("file", options->file, "File to read")->required(); + sub->add_option("-e,--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/app/remove.cpp b/app/remove.cpp index 7e3a40a..bf5a794 100644 --- a/app/remove.cpp +++ b/app/remove.cpp @@ -12,14 +12,15 @@ 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, "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 7dd2d95..c3f51c7 100644 --- a/app/rename.cpp +++ b/app/rename.cpp @@ -11,12 +11,13 @@ 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(); 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/app/verify.cpp b/app/verify.cpp index 2f6bf57..3fbafc9 100644 --- a/app/verify.cpp +++ b/app/verify.cpp @@ -10,10 +10,10 @@ 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)"); + 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 5990f53..4f3c77d 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 ;; + -q|--property) _mpqcli_words "${info_properties[*]}"; return ;; esac if [[ "$cur" == -* ]]; then - _mpqcli_words "-p --property" + _mpqcli_words "-q --property" else _mpqcli_positional_index if [[ $pos -eq 0 ]]; then @@ -130,35 +130,33 @@ _mpqcli() { create) case "$prev" in - --locale) _mpqcli_words "${locales[*]}"; return ;; + -e|--locale) _mpqcli_words "${locales[*]}"; return ;; -g|--game) _mpqcli_words "${games[*]}"; return ;; - --version) _mpqcli_words "1 2 3 4"; return ;; - -o|--output) _mpqcli_filedir; return ;; + --format-version) _mpqcli_words "1 2 3 4"; 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 --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 - _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 ;; 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 @@ -173,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 @@ -189,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 @@ -206,10 +204,10 @@ _mpqcli() { list) case "$prev" in -l|--listfile) _mpqcli_filedir; return ;; - -p|--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 -p --property" + _mpqcli_words "-l --listfile -d --detailed -a --all -q --property" else _mpqcli_positional_index -d --detailed -a --all if [[ $pos -eq 0 ]]; then @@ -222,13 +220,14 @@ _mpqcli() { case "$prev" in -o|--output) _mpqcli_filedir -d; return ;; -l|--listfile) _mpqcli_filedir; return ;; - --locale) _mpqcli_words "${locales[*]}"; return ;; - -f|--file) return ;; + -e|--locale) _mpqcli_words "${locales[*]}"; return ;; esac if [[ "$cur" == -* ]]; then - _mpqcli_words "-o --output -f --file -k --keep -l --listfile --locale" + _mpqcli_words "-o --output -j --flat -l --listfile -e --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 -j --flat if [[ $pos -eq 0 ]]; then _mpqcli_filedir mpq fi @@ -237,15 +236,15 @@ _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 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 @@ -253,9 +252,9 @@ _mpqcli() { verify) if [[ "$cur" == -* ]]; then - _mpqcli_words "-p --print" + _mpqcli_words "-x --print" else - _mpqcli_positional_index -p --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 48f3fc0..33d4c63 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" \ @@ -97,36 +97,36 @@ complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ -a completion -d 'Generate shell completion script' # info -# info [-p/--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' \ - -s p -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 -# create [-p/--path] [-o/--output] [-s/--sign] -# [--locale] [-g/--game] -# [--version] [--stream-flags] [--sector-size] [--raw-chunk-size] +# create [-p/--path] [-s/--sign] +# [-e/--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 + -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' \ - -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' \ -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' \ @@ -150,20 +150,20 @@ 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 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' \ -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,28 +176,28 @@ 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 -# list [-l/--listfile] [-d/--detailed] [-a/--all] -# [-p/--property ] +# list [-l/--listfile] [-d/--detailed] [-a/--all] +# [-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,45 +207,45 @@ 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' \ + -s q -l property -d 'Print only specific property values' \ -r -f -a "$__mpqcli_list_properties" # extract -# 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 +# 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 -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' \ - -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' + -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' \ - -l locale -d 'Preferred locale for extracted file' \ + -s e -l locale -d 'Preferred locale for extracted files' \ -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 [-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 -# verify [-p/--print] -complete -c mpqcli -n '__fish_seen_subcommand_from verify; and test (__mpqcli_positional_index -p --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' \ - -s p -l print -d 'Print the digital signature (in hex)' + -s x -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/completion/mpqcli.ps1 b/completion/mpqcli.ps1 index 30ae3ac..16b0233 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' @@ -75,20 +75,19 @@ 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' + '-q' = 'Print only a specific property value' '--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' - '-o' = 'Output MPQ archive' - '--output' = 'Output MPQ archive' + '-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' + '-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' - '--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' @@ -101,12 +100,13 @@ 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' '--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' @@ -115,9 +115,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]@{ @@ -127,25 +129,25 @@ 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' + '-q' = 'Print only specific property values' '--property' = 'Print only specific property values' } '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' + '-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' - '--locale' = 'Preferred locale for extracted file' + '-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]@{ - '-p' = 'Print the digital signature (in hex)' + '-x' = 'Print the digital signature (in hex)' '--print' = 'Print the digital signature (in hex)' } 'compact' = [ordered]@{ @@ -159,10 +161,11 @@ 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') + '-e' = $locales + '--locale' = $locales + '-g' = $gameProfiles + '--game' = $gameProfiles + '--format-version' = @('1', '2', '3', '4') } # Property options depend on the subcommand (info vs list), handled below @@ -236,7 +239,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 -in @('-q', '--property')) { if ($subcommand -eq 'info') { $items = foreach ($v in $infoProperties) { [pscustomobject]@{ Value = $v; Tip = 'info property' } } @@ -247,13 +250,10 @@ 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'))) { + $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 b1f463e..9fa9d6f 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' @@ -90,19 +90,19 @@ _mpqcli_info() { local props="${_mpqcli_info_properties[*]}" _arguments \ '1:archive:_mpqcli_archives' \ - '(-p --property)'{-p,--property}'[print only a specific property]:property:('"$props"')' + '(-q --property)'{-q,--property}'[print only a specific property]:property:('"$props"')' } _mpqcli_create() { local locales="${_mpqcli_locales[*]}" games="${_mpqcli_games[*]}" _arguments \ - '1:target:_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' \ + '1:archive:_files' \ + '*: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"')' \ - '--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' \ @@ -120,10 +120,10 @@ _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"')' \ + '(-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() { @@ -154,32 +154,32 @@ _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"')' + '*'{-q,--property}'[print specific property values]:property:('"$props"')' } _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]' \ + '(-j --flat)'{-j,--flat}'[write files without their folders]' \ '(-l --listfile)'{-l,--listfile}'[listfile path]:file:_files' \ - '--locale[preferred locale for extracted file]:locale:('"$locales"')' + '(-e --locale)'{-e,--locale}'[preferred locale for extracted files]:locale:('"$locales"')' } _mpqcli_read() { local locales="${_mpqcli_locales[*]}" _arguments \ - '1:file-in-archive' \ - '2:archive:_mpqcli_archives' \ - '--locale[preferred locale for read file]:locale:('"$locales"')' + '1:archive:_mpqcli_archives' \ + '2:file-in-archive' \ + '(-e --locale)'{-e,--locale}'[preferred locale for read file]:locale:('"$locales"')' } _mpqcli_verify() { _arguments \ '1:archive:_mpqcli_archives' \ - '(-p --print)'{-p,--print}'[print the digital signature in hex]' + '(-x --print)'{-x,--print}'[print the digital signature in hex]' } _mpqcli_compact() { diff --git a/docs/advanced.md b/docs/advanced.md index d334761..68c51e6 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 @@ -36,11 +35,11 @@ $ mpqcli list -l scbw.txt StarDat.mpq | grep -i "arr\\\.*dat$" | sort | sed 's|\ ## 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 -f $_ 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/changelog.md b/docs/changelog.md index 6bef9b8..4844132 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,36 @@ # 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 +- Add short flags -e, -j, -q and -x for the locale, flat, property and print options + +### 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 +- Stop add and create from storing an archive inside itself +- Leave no archive behind when create cannot add every file + +### 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/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 4ec1f68..7111214 100644 --- a/docs/commands/create.md +++ b/docs/commands/create.md @@ -1,26 +1,44 @@ # 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. If an input is missing or any file cannot be added, no archive is created. + +## 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, no archive is created. + +```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 . -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. ```bash -$ mpqcli create -g starcraft +$ mpqcli create -g starcraft archive.mpq $ mpqcli create --game wow-wotlk \ --sector-size 16384 \ - --version 3 + --format-version 3 archive.mpq ``` ## Create an MPQ archive from a single file @@ -28,22 +46,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,13 +71,13 @@ $ 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 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 --locale koKR +$ mpqcli create archive.mpq --locale koKR ``` diff --git a/docs/commands/extract.md b/docs/commands/extract.md index d8c0ee7..9317ad1 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 `-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 -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,19 +43,30 @@ 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 -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 -f "rez\gluBNRes.res" Patch_rt.mpq --locale deDE +$ mpqcli extract Patch_rt.mpq "rez\gluBNRes.res" --locale deDE ``` diff --git a/docs/commands/info.md b/docs/commands/info.md index 5d726bd..1799902 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 `-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 -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..fcd8d52 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 @@ -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 `-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 -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/read.md b/docs/commands/read.md index 0d6952e..337a108 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 @@ -39,8 +39,8 @@ $ mpqcli read "WoW.exe" wow-patch.mpq | 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 "rez\stat_txt.tbl" Patch_rt.mpq --locale ptPT +$ 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/docs/commands/verify.md b/docs/commands/verify.md index c5120a0..1638b62 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 @@ -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 `-x` or `--print` argument. ```bash -$ mpqcli verify -p wow-patch.mpq > signature +$ mpqcli verify --print wow-patch.mpq > signature ``` diff --git a/docs/commands_list.md b/docs/commands_list.md index a40e8a5..a7e6dce 100644 --- a/docs/commands_list.md +++ b/docs/commands_list.md @@ -11,12 +11,12 @@ 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 | -| [`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 | 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/add.cpp b/src/commands/add.cpp index 3a89e98..b3e896c 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.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/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/create.cpp b/src/commands/create.cpp index 75f654c..66ca6b4 100644 --- a/src/commands/create.cpp +++ b/src/commands/create.cpp @@ -40,26 +40,41 @@ 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 a missing input or a directory that cannot be listed 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 +88,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 +100,23 @@ bool Create(const CreateOptions &options, std::ostream &err, const std::atomic 0) { + 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 || files_skipped > 0) { + err << "[!] Failed to add every file, so no archive was created: " + << output_file_path.string() << std::endl; + return false; } if (options.sign) { @@ -123,7 +132,7 @@ bool Create(const CreateOptions &options, std::ostream &err, const std::atomic #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, creating nothing if any file failed to add bool Create(const CreateOptions &options, std::ostream &err, const std::atomic &cancelled); } // namespace mpqcli diff --git a/src/commands/extract.cpp b/src/commands/extract.cpp index fde7bb9..3c7ec3a 100644 --- a/src/commands/extract.cpp +++ b/src/commands/extract.cpp @@ -3,7 +3,9 @@ #include #include #include +#include #include +#include #include @@ -20,14 +22,40 @@ 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(); } + + 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,22 +73,8 @@ 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); - - 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()) { - 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 bfe997c..c3468e4 100644 --- a/src/commands/extract.h +++ b/src/commands/extract.h @@ -6,20 +6,22 @@ #include #include #include +#include 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; + /// 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/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..069fbcd 100644 --- a/src/commands/read.cpp +++ b/src/commands/read.cpp @@ -13,13 +13,9 @@ 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) { - 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/src/commands/read.h b/src/commands/read.h index 1a02663..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::filesystem::path archive; std::string file; - std::filesystem::path target; 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/add.cpp b/src/mpq/add.cpp index 80631c5..5c22554 100644 --- a/src/mpq/add.cpp +++ b/src/mpq/add.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -24,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, @@ -185,4 +202,65 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi return 0; } +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, + 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; + } + // 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, + 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..50d17b1 100644 --- a/src/mpq/add.h +++ b/src/mpq/add.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -25,6 +26,19 @@ 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. +/// 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(), + bool overwrite = false, bool update = false, int *skipped = nullptr); + } // namespace mpqcli #endif 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/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 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/conftest.py b/test/conftest.py index d4efca6..1f6b88a 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", 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", 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 @@ -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): """ @@ -239,7 +261,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 @@ -247,7 +269,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 +278,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 0b48fb4..2ffd36f 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 @@ -1074,12 +1074,104 @@ 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 test_add_skips_the_archive_inside_an_input_directory(binary_path, tmp_path): + input_dir = tmp_path / "mod" + input_dir.mkdir() + (input_dir / "a.txt").write_text("A file.") + other_file = tmp_path / "b.txt" + other_file.write_text("B file.") + archive_file = input_dir / "mod.mpq" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(other_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"Failed to create archive: {result.stderr}" + + result = subprocess.run( + [str(binary_path), "add", str(archive_file), str(input_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert "Skipping the archive itself" in result.stderr + + list_result = subprocess.run( + [str(binary_path), "list", str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert set(list_result.stdout.splitlines()) == {"a.txt", "b.txt"} + + +def test_add_skips_the_archive_named_as_an_input(binary_path, tmp_path): + input_file = tmp_path / "a.txt" + input_file.write_text("A file.") + archive_file = tmp_path / "a.mpq" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(input_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"Failed to create archive: {result.stderr}" + + result = subprocess.run( + [str(binary_path), "add", str(archive_file), str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert "Skipping the archive itself" in result.stderr + + list_result = subprocess.run( + [str(binary_path), "list", str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert list_result.stdout.splitlines() == ["a.txt"] + + def create_mpq_archive_for_test(binary_path, script_dir): target_dir = script_dir / "data" / "files" target_file = target_dir.with_suffix(".mpq") target_file.unlink(missing_ok=True) result = subprocess.run( - [str(binary_path), "create", "--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 @@ -1098,7 +1190,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 @@ -1117,7 +1209,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 @@ -1136,7 +1228,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 @@ -1149,7 +1241,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 @@ -1161,7 +1253,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_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 235e9c0..9b6636f 100644 --- a/test/test_create.py +++ b/test/test_create.py @@ -1,3 +1,4 @@ +import os import platform import subprocess import shutil @@ -18,13 +19,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", "--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 +49,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_file), str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -59,7 +61,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 @@ -92,7 +94,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", @@ -100,7 +102,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 +145,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, @@ -167,27 +169,30 @@ 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", "--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}" + assert output_file.read_bytes() == b"Not an archive" def test_create_mpq_from_file(binary_path, generate_test_files): @@ -210,7 +215,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 +248,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 +284,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 +315,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", str(output_file), str(target_dir), "--locale", "illegal_locale"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -368,7 +373,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 @@ -381,7 +386,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 +397,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 @@ -425,7 +430,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 +477,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 @@ -484,7 +489,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 @@ -526,7 +531,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 @@ -536,7 +541,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 @@ -591,7 +596,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 @@ -601,7 +606,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 @@ -613,48 +618,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 +638,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 @@ -686,7 +649,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 @@ -726,7 +689,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 +721,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 +751,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 @@ -802,7 +765,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 @@ -820,7 +783,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 @@ -843,7 +806,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 @@ -851,3 +814,282 @@ 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, + ) + + assert result.returncode == 109, f"mpqcli failed with error: {result.stderr}" + assert sorted(p.name for p in tmp_path.iterdir()) == ["files"] + + +def test_create_old_directory_form_is_rejected(binary_path, tmp_path): + input_dir = tmp_path / "files" + input_dir.mkdir() + (input_dir / "a.txt").write_text("A file.") + + result = subprocess.run( + [str(binary_path), "create", str(input_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=tmp_path, + ) + + # The directory is taken as the archive, which leaves no files to add + assert result.returncode == 106, f"mpqcli failed with error: {result.stderr}" + assert sorted(p.name for p in tmp_path.iterdir()) == ["files"] + + +def test_create_old_output_option_is_rejected(binary_path, tmp_path): + input_dir = tmp_path / "files" + input_dir.mkdir() + (input_dir / "a.txt").write_text("A file.") + + result = subprocess.run( + [str(binary_path), "create", "-o", str(tmp_path / "out.mpq"), str(input_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=tmp_path, + ) + + assert result.returncode == 109, f"mpqcli failed with error: {result.stderr}" + assert sorted(p.name for p in tmp_path.iterdir()) == ["files"] + + +def test_create_from_several_inputs(binary_path, tmp_path): + input_dir = tmp_path / "textures" + (input_dir / "bear").mkdir(parents=True) + (input_dir / "bear" / "bear.blp").write_text("Bear.") + input_file = tmp_path / "readme.txt" + input_file.write_text("Read me.") + archive_file = tmp_path / "several.mpq" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(input_file), str(input_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + + list_result = subprocess.run( + [str(binary_path), "list", str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert set(list_result.stdout.splitlines()) == {"readme.txt", "bear\\bear.blp"} + + +def test_create_two_inputs_with_the_same_name_creates_nothing(binary_path, tmp_path): + first_dir = tmp_path / "first" + second_dir = tmp_path / "second" + first_dir.mkdir() + second_dir.mkdir() + (first_dir / "a.txt").write_text("First.") + (second_dir / "a.txt").write_text("Second.") + archive_file = tmp_path / "duplicate.mpq" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(first_dir), str(second_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" + assert "1 file(s) were not added because an earlier input added a file by the same name" \ + in result.stderr + assert "no archive was created" in result.stderr + assert sorted(p.name for p in tmp_path.iterdir()) == ["first", "second"] + + +def test_create_files_via_stdin(binary_path, tmp_path): + file_a = tmp_path / "a.txt" + file_b = tmp_path / "b.txt" + file_a.write_text("A.") + file_b.write_text("B.") + archive_file = tmp_path / "stdin.mpq" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), "-"], + input=f"{file_a}\n{file_b}\n", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + + list_result = subprocess.run( + [str(binary_path), "list", str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert set(list_result.stdout.splitlines()) == {"a.txt", "b.txt"} + + +def test_create_empty_stdin_creates_nothing(binary_path, tmp_path): + archive_file = tmp_path / "empty.mpq" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), "-"], + input="", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" + assert list(tmp_path.iterdir()) == [] + + +def test_create_missing_input_creates_nothing(binary_path, tmp_path): + input_file = tmp_path / "a.txt" + input_file.write_text("A file.") + archive_file = tmp_path / "missing.mpq" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(input_file), str(tmp_path / "typo")], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" + assert "Path does not exist" in result.stderr + assert sorted(p.name for p in tmp_path.iterdir()) == ["a.txt"] + + +@pytest.mark.skipif( + platform.system() == "Windows" or os.geteuid() == 0, + reason="Needs a file permission that stops the user running the tests from reading it", +) +def test_create_unreadable_file_creates_nothing(binary_path, tmp_path): + input_dir = tmp_path / "mod" + input_dir.mkdir() + (input_dir / "a.txt").write_text("A file.") + locked_file = input_dir / "locked.txt" + locked_file.write_text("Locked.") + other_file = tmp_path / "b.txt" + other_file.write_text("B file.") + archive_file = tmp_path / "unreadable.mpq" + + locked_file.chmod(0) + try: + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(other_file), str(input_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + finally: + locked_file.chmod(0o644) + + assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" + assert "Failed to add: locked.txt" in result.stderr + assert "no archive was created" in result.stderr + assert sorted(p.name for p in tmp_path.iterdir()) == ["b.txt", "mod"] + + +def test_create_skips_the_archive_inside_its_own_input_directory(binary_path, tmp_path): + input_dir = tmp_path / "mod" + input_dir.mkdir() + (input_dir / "a.txt").write_text("A file.") + + result = subprocess.run( + [str(binary_path), "create", "out.mpq", "."], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=input_dir, + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert "Skipping the archive itself" in result.stderr + assert sorted(p.name for p in input_dir.iterdir()) == ["a.txt", "out.mpq"] + + list_result = subprocess.run( + [str(binary_path), "list", str(input_dir / "out.mpq")], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert list_result.stdout.splitlines() == ["a.txt"] + + +def test_create_path_is_a_prefix_for_several_inputs(binary_path, tmp_path): + file_a = tmp_path / "a.txt" + file_b = tmp_path / "b.txt" + file_a.write_text("A.") + file_b.write_text("B.") + input_dir = tmp_path / "textures" + (input_dir / "bear").mkdir(parents=True) + (input_dir / "bear" / "bear.blp").write_text("Bear.") + archive_file = tmp_path / "prefixed.mpq" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(file_a), str(file_b), str(input_dir), + "--path", "data"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + + list_result = subprocess.run( + [str(binary_path), "list", str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert set(list_result.stdout.splitlines()) == { + "data\\a.txt", "data\\b.txt", "data\\bear\\bear.blp" + } + + +def test_create_sizes_the_archive_for_paths_from_stdin(binary_path, tmp_path): + # Enough files that the count matters: an archive sized for none of them, and grown + # as they are added, reports the same max-files as one made from a single file + input_dir = tmp_path / "files" + input_dir.mkdir() + for i in range(30): + (input_dir / f"file{i}.txt").write_text(f"File {i}.") + paths = "".join(f"{p}\n" for p in sorted(input_dir.iterdir())) + + def create_and_read_max_files(archive_file, inputs, stdin=None): + result = subprocess.run( + [str(binary_path), "create", str(archive_file), *inputs], + input=stdin, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + info_result = subprocess.run( + [str(binary_path), "info", "--property", "max-files", str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert info_result.returncode == 0, f"mpqcli failed with error: {info_result.stderr}" + return int(info_result.stdout.strip()) + + from_stdin = create_and_read_max_files(tmp_path / "stdin.mpq", ["-"], paths) + from_directory = create_and_read_max_files(tmp_path / "directory.mpq", [str(input_dir)]) + from_one_file = create_and_read_max_files( + tmp_path / "one.mpq", [str(input_dir / "file0.txt")]) + + assert from_stdin == from_directory + assert from_stdin > from_one_file diff --git a/test/test_extract.py b/test/test_extract.py index 3fa6400..627cf82 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 @@ -283,8 +284,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,26 +295,15 @@ 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], + [str(binary_path), "extract", "-o", str(output_dir), str(test_file), file_to_extract, "--locale", locale], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) - expected_stderr = { - "[!] 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): @@ -335,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 @@ -372,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 @@ -415,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 @@ -551,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 @@ -836,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", "--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 @@ -854,21 +843,246 @@ 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_cats_and_dogs_mpq, tmp_path): + test_file = generate_cats_and_dogs_mpq + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "extract", "-o", str(output_dir), str(test_file), "cats.txt", "dogs.txt"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert set(result.stderr.splitlines()) == {"[*] Extracted: cats.txt", "[*] Extracted: dogs.txt"} + assert set(fi.name for fi in output_dir.iterdir()) == {"cats.txt", "dogs.txt"} + + +def test_extract_files_via_stdin(binary_path, generate_cats_and_dogs_mpq, tmp_path): + test_file = generate_cats_and_dogs_mpq + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "extract", "-o", str(output_dir), str(test_file), "-"], + input="cats.txt\ndogs.txt\n", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert set(fi.name for fi in output_dir.iterdir()) == {"cats.txt", "dogs.txt"} + + +def test_extract_empty_stdin_extracts_nothing(binary_path, generate_cats_and_dogs_mpq, tmp_path): + test_file = generate_cats_and_dogs_mpq + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "extract", "-o", str(output_dir), str(test_file), "-"], + input="", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert "[*] No files to extract" in result.stderr + assert not output_dir.exists(), "Output directory was created" + + +def test_extract_flat_name_collision_extracts_nothing(binary_path, tmp_path): + input_dir = tmp_path / "files" + (input_dir / "a").mkdir(parents=True) + (input_dir / "b").mkdir() + (input_dir / "a" / "x.txt").write_text("A") + (input_dir / "b" / "x.txt").write_text("B") + archive_file = tmp_path / "collision.mpq" + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(input_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"Failed to create archive: {result.stderr}" + + result = subprocess.run( + [str(binary_path), "extract", "--flat", "-o", str(output_dir), str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" + assert "to the same file" in result.stderr + assert not output_dir.exists(), "Output directory was created" + + +def test_extract_listfile_with_named_files_is_rejected(binary_path, generate_cats_and_dogs_mpq, tmp_path): + test_file = generate_cats_and_dogs_mpq + listfile = tmp_path / "listfile.txt" + listfile.write_text("cats.txt\n") + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "extract", "-o", str(output_dir), "-l", str(listfile), str(test_file), "cats.txt"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 108, f"mpqcli failed with error: {result.stderr}" + assert not output_dir.exists(), "Output directory was created" + + +def test_extract_old_file_option_is_rejected(binary_path, generate_cats_and_dogs_mpq, tmp_path): + test_file = generate_cats_and_dogs_mpq + + result = subprocess.run( + [str(binary_path), "extract", "-f", "cats.txt", str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=tmp_path, + ) + + # -f is not an extract option, so the file name is taken as the archive + assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + assert "archive: File does not exist: cats.txt" in result.stderr + assert list(tmp_path.iterdir()) == [] + + +def test_extract_old_keep_flag_is_rejected(binary_path, generate_cats_and_dogs_mpq, tmp_path): + test_file = generate_cats_and_dogs_mpq + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "extract", "-k", "-o", str(output_dir), str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 109, f"mpqcli failed with error: {result.stderr}" + assert not output_dir.exists(), "Output directory was created" + + +def test_extract_listfile_with_stdin_is_rejected(binary_path, generate_cats_and_dogs_mpq, tmp_path): + test_file = generate_cats_and_dogs_mpq + listfile = tmp_path / "listfile.txt" + listfile.write_text("cats.txt\n") + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "extract", "-o", str(output_dir), "-l", str(listfile), str(test_file), "-"], + input="cats.txt\n", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 108, f"mpqcli failed with error: {result.stderr}" + assert not output_dir.exists(), "Output directory was created" + + +def test_extract_flat_allows_one_name_stored_under_several_locales(binary_path, tmp_path): + input_dir = tmp_path / "files" + input_dir.mkdir() + (input_dir / "cats.txt").write_text("Cats in the default locale.") + german_file = tmp_path / "cats.txt" + german_file.write_text("Cats in German.") + archive_file = tmp_path / "locales.mpq" + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(input_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"Failed to create archive: {result.stderr}" + result = subprocess.run( + [str(binary_path), "add", str(archive_file), str(german_file), "--locale", "deDE"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"Failed to add the second locale: {result.stderr}" + + result = subprocess.run( + [str(binary_path), "extract", "--flat", "-o", str(output_dir), str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + # cats.txt is listed once per locale, and both land on the same file without + # counting as a collision + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert result.stderr.splitlines().count("[*] Extracted: cats.txt") == 2 + assert (output_dir / "cats.txt").exists() + + +def test_extract_short_flat_flag(binary_path, tmp_path): + input_dir = tmp_path / "files" + (input_dir / "texts").mkdir(parents=True) + (input_dir / "texts" / "cats.txt").write_text("Cats.") + archive_file = tmp_path / "nested.mpq" + output_dir = tmp_path / "extracted" + + result = subprocess.run( + [str(binary_path), "create", str(archive_file), str(input_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"Failed to create archive: {result.stderr}" + + result = subprocess.run( + [str(binary_path), "extract", "-j", "-o", str(output_dir), str(archive_file), "texts\\cats.txt"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert set(fi.name for fi in output_dir.iterdir()) == {"cats.txt"} diff --git a/test/test_info.py b/test/test_info.py index 07476dc..09c71b0 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,40 @@ 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, generate_cats_and_dogs_mpq): + test_file = generate_cats_and_dogs_mpq + + result = subprocess.run( + [str(binary_path), "info", "-p", "file-count", str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + # -p is not an info option, so the property name is taken as the archive + assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + assert "archive: File does not exist: file-count" in result.stderr + assert result.stdout == "" + + +def test_info_property_short_flag(binary_path, generate_cats_and_dogs_mpq): + test_file = generate_cats_and_dogs_mpq + + short_form = subprocess.run( + [str(binary_path), "info", "-q", "file-count", str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + long_form = subprocess.run( + [str(binary_path), "info", "--property", "file-count", str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + assert short_form.returncode == 0, f"mpqcli failed with error: {short_form.stderr}" + assert short_form.stdout.strip() != "" + assert short_form.stdout == long_form.stdout diff --git a/test/test_list.py b/test/test_list.py index ed4069b..360651f 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,40 @@ 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, generate_cats_and_dogs_mpq): + test_file = generate_cats_and_dogs_mpq + + result = subprocess.run( + [str(binary_path), "list", "-p", "locale", str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + # -p is not a list option, so the property name is taken as the archive + assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + assert "archive: File does not exist: locale" in result.stderr + assert result.stdout == "" + + +def test_list_property_short_flag(binary_path, generate_cats_and_dogs_mpq): + test_file = generate_cats_and_dogs_mpq + + short_form = subprocess.run( + [str(binary_path), "list", "-q", "locale", str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + long_form = subprocess.run( + [str(binary_path), "list", "--property", "locale", str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert short_form.returncode == 0, f"mpqcli failed with error: {short_form.stderr}" + assert short_form.stdout.strip() != "" + assert short_form.stdout == long_form.stdout diff --git a/test/test_read.py b/test/test_read.py index 8fbf02a..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 @@ -11,7 +14,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 +36,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 +63,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 @@ -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. @@ -90,7 +124,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 @@ -110,8 +144,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,25 +152,15 @@ 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], + [str(binary_path), "read", str(test_file), file_to_read, "--locale", locale], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) - expected_content = { - "This is a file about cats.", - } - 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): @@ -155,7 +178,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 @@ -185,7 +208,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 @@ -220,7 +243,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 @@ -244,7 +267,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 @@ -258,7 +281,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 @@ -267,3 +290,22 @@ 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 "archive: File does not exist: cats.txt" in result.stderr + assert result.stdout == "" diff --git a/test/test_remove.py b/test/test_remove.py 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..b894ccf 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,48 @@ 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, generate_cats_and_dogs_mpq): + test_file = generate_cats_and_dogs_mpq + + result = subprocess.run( + [str(binary_path), "verify", "-p", str(test_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 109, f"mpqcli failed with error: {result.stderr}" + assert result.stdout == "" + + +def test_verify_print_short_flag(binary_path, tmp_path): + input_file = tmp_path / "a.txt" + input_file.write_text("A file.") + archive_file = tmp_path / "signed.mpq" + + result = subprocess.run( + [str(binary_path), "create", "-s", str(archive_file), str(input_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"Failed to create archive: {result.stderr}" + + short_form = subprocess.run( + [str(binary_path), "verify", "-x", str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=False # The signature is raw bytes + ) + long_form = subprocess.run( + [str(binary_path), "verify", "--print", str(archive_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=False + ) + + assert short_form.returncode == 0, f"mpqcli failed with error: {short_form.stderr}" + assert short_form.stdout != b"" + assert short_form.stdout == long_form.stdout