From b1eda37cca4b495ffb55ce1820ae7444c496ad28 Mon Sep 17 00:00:00 2001 From: James Ring Date: Fri, 13 Sep 2019 09:49:03 -0400 Subject: [PATCH] CLI: Add interactive session mode command `open` This change adds a GNU Readline-based interactive mode to keepassxc-cli. If GNU Readline is not available, commands are just read from stdin with no editing or auto-complete support. DatabaseCommand is modified to add the path to the current database to the arguments passed to executeWithDatabase. In this way, instances of DatabaseCommand do not have to prompt to re-open the database after each invocation, and existing command implementations do not have to be changed to support interactive mode. This change also introduces a new way of handling commands between interactive and batch modes. * Fixes #3224. * Ran make format --- CHANGELOG.md | 1 + cmake/FindReadline.cmake | 50 + src/browser/BrowserAction.cpp | 5 +- src/cli/Add.cpp | 1 - src/cli/CMakeLists.txt | 17 + src/cli/Close.cpp | 38 + src/cli/Close.h | 32 + src/cli/Command.cpp | 347 +-- src/cli/Command.h | 14 +- src/cli/Create.cpp | 7 +- src/cli/DatabaseCommand.cpp | 115 +- src/cli/Exit.cpp | 35 + src/cli/Exit.h | 33 + src/cli/Export.cpp | 1 - src/cli/Help.cpp | 43 + src/cli/Help.h | 31 + src/cli/Open.cpp | 43 + src/cli/Open.h | 31 + src/cli/Utils.cpp | 38 + src/cli/Utils.h | 2 + src/cli/keepassxc-cli.1 | 81 +- src/cli/keepassxc-cli.cpp | 149 +- src/keys/YkChallengeResponseKeyCLI.h | 5 +- tests/TestCli.cpp | 3628 +++++++++++++------------- tests/TestCli.h | 7 +- tests/TestMerge.cpp | 2 - tests/gui/TestGuiBrowser.cpp | 10 +- 27 files changed, 2751 insertions(+), 2015 deletions(-) create mode 100644 cmake/FindReadline.cmake create mode 100644 src/cli/Close.cpp create mode 100644 src/cli/Close.h create mode 100644 src/cli/Exit.cpp create mode 100644 src/cli/Exit.h create mode 100644 src/cli/Help.cpp create mode 100644 src/cli/Help.h create mode 100644 src/cli/Open.cpp create mode 100644 src/cli/Open.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fc10c300..7d1e89ffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - CLI: Add `-y --yubikey` option for YubiKey [#3416](https://github.com/keepassxreboot/keepassxc/issues/3416) - Add 'Monospaced font' option to the Notes field [#3321](https://github.com/keepassxreboot/keepassxc/issues/3321) - CLI: Add group commands (mv, mkdir and rmdir) [#3313]. +- CLI: Add interactive shell mode command `open` [#3224](https://github.com/keepassxreboot/keepassxc/issues/3224) - Add "Paper Backup" aka "Export to HTML file" to the "Database" menu [#3277](https://github.com/keepassxreboot/keepassxc/pull/3277) ### Changed diff --git a/cmake/FindReadline.cmake b/cmake/FindReadline.cmake new file mode 100644 index 000000000..9d9691370 --- /dev/null +++ b/cmake/FindReadline.cmake @@ -0,0 +1,50 @@ +# Code copied from sethhall@github +# +# - Try to find readline include dirs and libraries +# +# Usage of this module as follows: +# +# find_package(Readline) +# +# Variables used by this module, they can change the default behaviour and need +# to be set before calling find_package: +# +# Readline_ROOT_DIR Set this variable to the root installation of +# readline if the module has problems finding the +# proper installation path. +# +# Variables defined by this module: +# +# READLINE_FOUND System has readline, include and lib dirs found +# Readline_INCLUDE_DIR The readline include directories. +# Readline_LIBRARY The readline library. + +find_path(Readline_ROOT_DIR + NAMES include/readline/readline.h +) + +find_path(Readline_INCLUDE_DIR + NAMES readline/readline.h + HINTS ${Readline_ROOT_DIR}/include +) + +find_library(Readline_LIBRARY + NAMES readline + HINTS ${Readline_ROOT_DIR}/lib +) + +if(Readline_INCLUDE_DIR AND Readline_LIBRARY AND Ncurses_LIBRARY) + set(READLINE_FOUND TRUE) +else(Readline_INCLUDE_DIR AND Readline_LIBRARY AND Ncurses_LIBRARY) + find_library(Readline_LIBRARY NAMES readline) + include(FindPackageHandleStandardArgs) + find_package_handle_standard_args(Readline DEFAULT_MSG Readline_INCLUDE_DIR Readline_LIBRARY ) + mark_as_advanced(Readline_INCLUDE_DIR Readline_LIBRARY) +endif(Readline_INCLUDE_DIR AND Readline_LIBRARY AND Ncurses_LIBRARY) + +mark_as_advanced( + Readline_ROOT_DIR + Readline_INCLUDE_DIR + Readline_LIBRARY +) + diff --git a/src/browser/BrowserAction.cpp b/src/browser/BrowserAction.cpp index 70b301fda..20b2fc975 100644 --- a/src/browser/BrowserAction.cpp +++ b/src/browser/BrowserAction.cpp @@ -541,10 +541,7 @@ QString BrowserAction::getDatabaseHash() { QMutexLocker locker(&m_mutex); QByteArray hash = - QCryptographicHash::hash( - m_browserService.getDatabaseRootUuid().toUtf8(), - QCryptographicHash::Sha256) - .toHex(); + QCryptographicHash::hash(m_browserService.getDatabaseRootUuid().toUtf8(), QCryptographicHash::Sha256).toHex(); return QString(hash); } diff --git a/src/cli/Add.cpp b/src/cli/Add.cpp index bd29a169a..c8a0189e4 100644 --- a/src/cli/Add.cpp +++ b/src/cli/Add.cpp @@ -45,7 +45,6 @@ const QCommandLineOption Add::GenerateOption = QCommandLineOption(QStringList() << "generate", QObject::tr("Generate a password for the entry.")); - Add::Add() { name = QString("add"); diff --git a/src/cli/CMakeLists.txt b/src/cli/CMakeLists.txt index 8d3c0d69f..cb165f461 100644 --- a/src/cli/CMakeLists.txt +++ b/src/cli/CMakeLists.txt @@ -18,18 +18,22 @@ set(cli_SOURCES AddGroup.cpp Analyze.cpp Clip.cpp + Close.cpp Create.cpp Command.cpp DatabaseCommand.cpp Diceware.cpp Edit.cpp Estimate.cpp + Exit.cpp Export.cpp Generate.cpp + Help.cpp List.cpp Locate.cpp Merge.cpp Move.cpp + Open.cpp Remove.cpp RemoveGroup.cpp Show.cpp) @@ -37,6 +41,13 @@ set(cli_SOURCES add_library(cli STATIC ${cli_SOURCES}) target_link_libraries(cli Qt5::Core Qt5::Widgets) +find_package(Readline) + +if (READLINE_FOUND) + target_compile_definitions(cli PUBLIC USE_READLINE) + target_link_libraries(cli readline) +endif() + add_executable(keepassxc-cli keepassxc-cli.cpp) target_link_libraries(keepassxc-cli cli @@ -53,6 +64,12 @@ install(TARGETS keepassxc-cli BUNDLE DESTINATION . COMPONENT Runtime RUNTIME DESTINATION ${CLI_INSTALL_DIR} COMPONENT Runtime) +if(MINGW) + install(CODE "include(BundleUtilities) + fixup_bundle(\"\${CMAKE_INSTALL_PREFIX}/keepassxc-cli.exe\" \"\" \"\")" + COMPONENT Runtime) +endif() + if(APPLE AND WITH_APP_BUNDLE) add_custom_command(TARGET keepassxc-cli POST_BUILD diff --git a/src/cli/Close.cpp b/src/cli/Close.cpp new file mode 100644 index 000000000..4ff3bcdad --- /dev/null +++ b/src/cli/Close.cpp @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "Close.h" + +#include +#include + +#include "DatabaseCommand.h" +#include "TextStream.h" +#include "Utils.h" + +Close::Close() +{ + name = QString("close"); + description = QObject::tr("Close the currently opened database."); +} + +int Close::execute(const QStringList& arguments) +{ + Q_UNUSED(arguments) + currentDatabase.reset(nullptr); + return EXIT_SUCCESS; +} diff --git a/src/cli/Close.h b/src/cli/Close.h new file mode 100644 index 000000000..afc91f9c6 --- /dev/null +++ b/src/cli/Close.h @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_CLOSE_H +#define KEEPASSXC_CLOSE_H + +#include + +#include "Command.h" + +class Close : public Command +{ +public: + Close(); + int execute(const QStringList& arguments) override; +}; + +#endif // KEEPASSXC_CLOSE_H diff --git a/src/cli/Command.cpp b/src/cli/Command.cpp index 891810a6f..c879a09f2 100644 --- a/src/cli/Command.cpp +++ b/src/cli/Command.cpp @@ -1,152 +1,195 @@ -/* - * Copyright (C) 2019 KeePassXC Team - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 or (at your option) - * version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include -#include - -#include - -#include "Command.h" - -#include "Add.h" -#include "AddGroup.h" -#include "Analyze.h" -#include "Clip.h" -#include "Create.h" -#include "Diceware.h" -#include "Edit.h" -#include "Estimate.h" -#include "Export.h" -#include "Generate.h" -#include "List.h" -#include "Locate.h" -#include "Merge.h" -#include "Move.h" -#include "Remove.h" -#include "RemoveGroup.h" -#include "Show.h" -#include "TextStream.h" -#include "Utils.h" - -const QCommandLineOption Command::QuietOption = - QCommandLineOption(QStringList() << "q" - << "quiet", - QObject::tr("Silence password prompt and other secondary outputs.")); - -const QCommandLineOption Command::KeyFileOption = QCommandLineOption(QStringList() << "k" - << "key-file", - QObject::tr("Key file of the database."), - QObject::tr("path")); - -const QCommandLineOption Command::NoPasswordOption = - QCommandLineOption(QStringList() << "no-password", QObject::tr("Deactivate password key for the database.")); - -const QCommandLineOption Command::YubiKeyOption = - QCommandLineOption(QStringList() << "y" - << "yubikey", - QObject::tr("Yubikey slot used to encrypt the database."), - QObject::tr("slot")); - -QMap commands; - -Command::Command() -{ - options.append(Command::QuietOption); -} - -Command::~Command() -{ -} - -QString Command::getDescriptionLine() -{ - QString response = name; - QString space(" "); - QString spaces = space.repeated(15 - name.length()); - response = response.append(spaces); - response = response.append(description); - response = response.append("\n"); - return response; -} - -QSharedPointer Command::getCommandLineParser(const QStringList& arguments) -{ - TextStream errorTextStream(Utils::STDERR, QIODevice::WriteOnly); - - QSharedPointer parser = QSharedPointer(new QCommandLineParser()); - parser->setApplicationDescription(description); - for (const CommandLineArgument& positionalArgument : positionalArguments) { - parser->addPositionalArgument( - positionalArgument.name, positionalArgument.description, positionalArgument.syntax); - } - for (const CommandLineArgument& optionalArgument : optionalArguments) { - parser->addPositionalArgument(optionalArgument.name, optionalArgument.description, optionalArgument.syntax); - } - for (const QCommandLineOption& option : options) { - parser->addOption(option); - } - parser->addHelpOption(); - parser->process(arguments); - - if (parser->positionalArguments().size() < positionalArguments.size()) { - errorTextStream << parser->helpText().replace("[options]", name.append(" [options]")); - return QSharedPointer(nullptr); - } - if (parser->positionalArguments().size() > (positionalArguments.size() + optionalArguments.size())) { - errorTextStream << parser->helpText().replace("[options]", name.append(" [options]")); - return QSharedPointer(nullptr); - } - return parser; -} - -void populateCommands() -{ - if (commands.isEmpty()) { - commands.insert(QString("add"), new Add()); - commands.insert(QString("analyze"), new Analyze()); - commands.insert(QString("clip"), new Clip()); - commands.insert(QString("create"), new Create()); - commands.insert(QString("diceware"), new Diceware()); - commands.insert(QString("edit"), new Edit()); - commands.insert(QString("estimate"), new Estimate()); - commands.insert(QString("export"), new Export()); - commands.insert(QString("generate"), new Generate()); - commands.insert(QString("locate"), new Locate()); - commands.insert(QString("ls"), new List()); - commands.insert(QString("merge"), new Merge()); - commands.insert(QString("mkdir"), new AddGroup()); - commands.insert(QString("mv"), new Move()); - commands.insert(QString("rm"), new Remove()); - commands.insert(QString("rmdir"), new RemoveGroup()); - commands.insert(QString("show"), new Show()); - } -} - -Command* Command::getCommand(const QString& commandName) -{ - populateCommands(); - if (commands.contains(commandName)) { - return commands[commandName]; - } - return nullptr; -} - -QList Command::getCommands() -{ - populateCommands(); - return commands.values(); -} +/* + * Copyright (C) 2019 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include +#include + +#include + +#include "Command.h" + +#include "Add.h" +#include "AddGroup.h" +#include "Analyze.h" +#include "Clip.h" +#include "Close.h" +#include "Create.h" +#include "Diceware.h" +#include "Edit.h" +#include "Estimate.h" +#include "Exit.h" +#include "Export.h" +#include "Generate.h" +#include "Help.h" +#include "List.h" +#include "Locate.h" +#include "Merge.h" +#include "Move.h" +#include "Open.h" +#include "Remove.h" +#include "RemoveGroup.h" +#include "Show.h" +#include "TextStream.h" +#include "Utils.h" + +const QCommandLineOption Command::HelpOption = QCommandLineOption(QStringList() +#ifdef Q_OS_WIN + << QStringLiteral("?") +#endif + << QStringLiteral("h") << QStringLiteral("help"), + QObject::tr("Display this help.")); + +const QCommandLineOption Command::QuietOption = + QCommandLineOption(QStringList() << "q" + << "quiet", + QObject::tr("Silence password prompt and other secondary outputs.")); + +const QCommandLineOption Command::KeyFileOption = QCommandLineOption(QStringList() << "k" + << "key-file", + QObject::tr("Key file of the database."), + QObject::tr("path")); + +const QCommandLineOption Command::NoPasswordOption = + QCommandLineOption(QStringList() << "no-password", QObject::tr("Deactivate password key for the database.")); + +const QCommandLineOption Command::YubiKeyOption = + QCommandLineOption(QStringList() << "y" + << "yubikey", + QObject::tr("Yubikey slot used to encrypt the database."), + QObject::tr("slot")); + +namespace +{ + + QSharedPointer buildParser(Command* command) + { + auto parser = QSharedPointer(new QCommandLineParser()); + parser->setApplicationDescription(command->description); + for (const CommandLineArgument& positionalArgument : command->positionalArguments) { + parser->addPositionalArgument( + positionalArgument.name, positionalArgument.description, positionalArgument.syntax); + } + for (const CommandLineArgument& optionalArgument : command->optionalArguments) { + parser->addPositionalArgument(optionalArgument.name, optionalArgument.description, optionalArgument.syntax); + } + for (const QCommandLineOption& option : command->options) { + parser->addOption(option); + } + parser->addOption(Command::HelpOption); + return parser; + } + +} // namespace + +Command::Command() + : currentDatabase(nullptr) +{ + options.append(Command::QuietOption); +} + +Command::~Command() +{ +} + +QString Command::getDescriptionLine() +{ + QString response = name; + QString space(" "); + QString spaces = space.repeated(15 - name.length()); + response = response.append(spaces); + response = response.append(description); + response = response.append("\n"); + return response; +} + +QString Command::getHelpText() +{ + return buildParser(this)->helpText().replace("[options]", name + " [options]"); +} + +QSharedPointer Command::getCommandLineParser(const QStringList& arguments) +{ + TextStream errorTextStream(Utils::STDERR, QIODevice::WriteOnly); + QSharedPointer parser = buildParser(this); + + if (!parser->parse(arguments)) { + errorTextStream << parser->errorText() << "\n\n"; + errorTextStream << getHelpText(); + return {}; + } + if (parser->positionalArguments().size() < positionalArguments.size()) { + errorTextStream << getHelpText(); + return {}; + } + if (parser->positionalArguments().size() > (positionalArguments.size() + optionalArguments.size())) { + errorTextStream << getHelpText(); + return {}; + } + if (parser->isSet(HelpOption)) { + errorTextStream << getHelpText(); + return {}; + } + return parser; +} + +namespace Commands +{ + QMap> s_commands; + + void setupCommands(bool interactive) + { + s_commands.clear(); + + s_commands.insert(QStringLiteral("add"), QSharedPointer(new Add())); + s_commands.insert(QStringLiteral("analyze"), QSharedPointer(new Analyze())); + s_commands.insert(QStringLiteral("clip"), QSharedPointer(new Clip())); + s_commands.insert(QStringLiteral("close"), QSharedPointer(new Close())); + s_commands.insert(QStringLiteral("create"), QSharedPointer(new Create())); + s_commands.insert(QStringLiteral("diceware"), QSharedPointer(new Diceware())); + s_commands.insert(QStringLiteral("edit"), QSharedPointer(new Edit())); + s_commands.insert(QStringLiteral("estimate"), QSharedPointer(new Estimate())); + s_commands.insert(QStringLiteral("generate"), QSharedPointer(new Generate())); + s_commands.insert(QStringLiteral("help"), QSharedPointer(new Help())); + s_commands.insert(QStringLiteral("locate"), QSharedPointer(new Locate())); + s_commands.insert(QStringLiteral("ls"), QSharedPointer(new List())); + s_commands.insert(QStringLiteral("merge"), QSharedPointer(new Merge())); + s_commands.insert(QStringLiteral("mkdir"), QSharedPointer(new AddGroup())); + s_commands.insert(QStringLiteral("mv"), QSharedPointer(new Move())); + s_commands.insert(QStringLiteral("open"), QSharedPointer(new Open())); + s_commands.insert(QStringLiteral("rm"), QSharedPointer(new Remove())); + s_commands.insert(QStringLiteral("rmdir"), QSharedPointer(new RemoveGroup())); + s_commands.insert(QStringLiteral("show"), QSharedPointer(new Show())); + + if (interactive) { + s_commands.insert(QStringLiteral("exit"), QSharedPointer(new Exit("exit"))); + s_commands.insert(QStringLiteral("quit"), QSharedPointer(new Exit("quit"))); + } else { + s_commands.insert(QStringLiteral("export"), QSharedPointer(new Export())); + } + } + + QList> getCommands() + { + return s_commands.values(); + } + + QSharedPointer getCommand(const QString& commandName) + { + return s_commands.value(commandName); + } +} // namespace Commands diff --git a/src/cli/Command.h b/src/cli/Command.h index 1ec161a65..4381bf187 100644 --- a/src/cli/Command.h +++ b/src/cli/Command.h @@ -44,19 +44,27 @@ public: virtual int execute(const QStringList& arguments) = 0; QString name; QString description; + QSharedPointer currentDatabase; QList positionalArguments; QList optionalArguments; QList options; + QString getDescriptionLine(); QSharedPointer getCommandLineParser(const QStringList& arguments); + QString getHelpText(); - static QList getCommands(); - static Command* getCommand(const QString& commandName); - + static const QCommandLineOption HelpOption; static const QCommandLineOption QuietOption; static const QCommandLineOption KeyFileOption; static const QCommandLineOption NoPasswordOption; static const QCommandLineOption YubiKeyOption; }; +namespace Commands +{ + void setupCommands(bool interactive); + QList> getCommands(); + QSharedPointer getCommand(const QString& commandName); +} // namespace Commands + #endif // KEEPASSXC_COMMAND_H diff --git a/src/cli/Create.cpp b/src/cli/Create.cpp index ee3ac6054..55c343039 100644 --- a/src/cli/Create.cpp +++ b/src/cli/Create.cpp @@ -93,16 +93,17 @@ int Create::execute(const QStringList& arguments) return EXIT_FAILURE; } - Database db; - db.setKey(key); + QSharedPointer db(new Database); + db->setKey(key); QString errorMessage; - if (!db.save(databaseFilename, &errorMessage, true, false)) { + if (!db->save(databaseFilename, &errorMessage, true, false)) { err << QObject::tr("Failed to save the database: %1.").arg(errorMessage) << endl; return EXIT_FAILURE; } out << QObject::tr("Successfully created new database.") << endl; + currentDatabase = db; return EXIT_SUCCESS; } diff --git a/src/cli/DatabaseCommand.cpp b/src/cli/DatabaseCommand.cpp index 0fc1e6f06..6d37d7b07 100644 --- a/src/cli/DatabaseCommand.cpp +++ b/src/cli/DatabaseCommand.cpp @@ -1,51 +1,64 @@ -/* - * Copyright (C) 2019 KeePassXC Team - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 or (at your option) - * version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include "DatabaseCommand.h" - -#include "Utils.h" - -DatabaseCommand::DatabaseCommand() -{ - positionalArguments.append({QString("database"), QObject::tr("Path of the database."), QString("")}); - options.append(Command::KeyFileOption); - options.append(Command::NoPasswordOption); -#ifdef WITH_XC_YUBIKEY - options.append(Command::YubiKeyOption); -#endif -} - -int DatabaseCommand::execute(const QStringList& arguments) -{ - QSharedPointer parser = getCommandLineParser(arguments); - if (parser.isNull()) { - return EXIT_FAILURE; - } - - const QStringList args = parser->positionalArguments(); - auto db = Utils::unlockDatabase(args.at(0), - !parser->isSet(Command::NoPasswordOption), - parser->value(Command::KeyFileOption), - parser->value(Command::YubiKeyOption), - parser->isSet(Command::QuietOption) ? Utils::DEVNULL : Utils::STDOUT, - Utils::STDERR); - if (!db) { - return EXIT_FAILURE; - } - - return executeWithDatabase(db, parser); -} +/* + * Copyright (C) 2019 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "DatabaseCommand.h" + +#include "Utils.h" + +DatabaseCommand::DatabaseCommand() +{ + positionalArguments.append({QString("database"), QObject::tr("Path of the database."), QString("")}); + options.append(Command::KeyFileOption); + options.append(Command::NoPasswordOption); +#ifdef WITH_XC_YUBIKEY + options.append(Command::YubiKeyOption); +#endif +} + +int DatabaseCommand::execute(const QStringList& arguments) +{ + QStringList amendedArgs(arguments); + if (currentDatabase) { + amendedArgs.insert(1, currentDatabase->filePath()); + } + QSharedPointer parser = getCommandLineParser(amendedArgs); + + if (parser.isNull()) { + return EXIT_FAILURE; + } + + QStringList args = parser->positionalArguments(); + auto db = currentDatabase; + if (!db) { + // It would be nice to update currentDatabase here, but the CLI tests frequently + // re-use Command objects to exercise non-interactive behavior. Updating the current + // database confuses these tests. Because of this, we leave it up to the interactive + // mode implementation in the main command loop to update currentDatabase + // (see keepassxc-cli.cpp). + db = Utils::unlockDatabase(args.at(0), + !parser->isSet(Command::NoPasswordOption), + parser->value(Command::KeyFileOption), + parser->value(Command::YubiKeyOption), + parser->isSet(Command::QuietOption) ? Utils::DEVNULL : Utils::STDOUT, + Utils::STDERR); + if (!db) { + return EXIT_FAILURE; + } + } + + return executeWithDatabase(db, parser); +} diff --git a/src/cli/Exit.cpp b/src/cli/Exit.cpp new file mode 100644 index 000000000..768088e4d --- /dev/null +++ b/src/cli/Exit.cpp @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "Exit.h" + +#include +#include +#include + +Exit::Exit(const QString& name) +{ + this->name = name; + description = QObject::tr("Exit interactive mode."); +} + +int Exit::execute(const QStringList& arguments) +{ + Q_UNUSED(arguments) + // A placeholder only, behavior is implemented in keepassxc-cli.cpp. + return EXIT_SUCCESS; +} diff --git a/src/cli/Exit.h b/src/cli/Exit.h new file mode 100644 index 000000000..8b0ce41f7 --- /dev/null +++ b/src/cli/Exit.h @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_EXIT_H +#define KEEPASSXC_EXIT_H + +#include +#include + +#include "Command.h" + +class Exit : public Command +{ +public: + Exit(const QString& name); + int execute(const QStringList& arguments) override; +}; + +#endif // KEEPASSXC_EXIT_H diff --git a/src/cli/Export.cpp b/src/cli/Export.cpp index 77acaf806..f68826a23 100644 --- a/src/cli/Export.cpp +++ b/src/cli/Export.cpp @@ -38,7 +38,6 @@ Export::Export() description = QObject::tr("Exports the content of a database to standard output in the specified format."); } - int Export::executeWithDatabase(QSharedPointer database, QSharedPointer parser) { TextStream outputTextStream(Utils::STDOUT, QIODevice::WriteOnly); diff --git a/src/cli/Help.cpp b/src/cli/Help.cpp new file mode 100644 index 000000000..193f55dd7 --- /dev/null +++ b/src/cli/Help.cpp @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "Help.h" + +#include "Command.h" +#include "TextStream.h" +#include "Utils.h" + +Help::Help() +{ + name = QString("help"); + description = QObject::tr("Display command help."); +} + +int Help::execute(const QStringList& arguments) +{ + TextStream out(Utils::STDERR, QIODevice::WriteOnly); + QSharedPointer command; + if (arguments.size() > 1 && (command = Commands::getCommand(arguments.at(1)))) { + out << command->getHelpText(); + } else { + out << "\n\n" << QObject::tr("Available commands:") << "\n"; + for (auto& cmd : Commands::getCommands()) { + out << cmd->getDescriptionLine(); + } + } + return EXIT_SUCCESS; +} diff --git a/src/cli/Help.h b/src/cli/Help.h new file mode 100644 index 000000000..162f8ba98 --- /dev/null +++ b/src/cli/Help.h @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_HELP_H +#define KEEPASSXC_HELP_H + +#include "Command.h" + +class Help : public Command +{ +public: + Help(); + ~Help() override = default; + int execute(const QStringList& arguments) override; +}; + +#endif // KEEPASSXC_HELP_H diff --git a/src/cli/Open.cpp b/src/cli/Open.cpp new file mode 100644 index 000000000..c4e9a79ad --- /dev/null +++ b/src/cli/Open.cpp @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "Open.h" + +#include + +#include "DatabaseCommand.h" +#include "TextStream.h" +#include "Utils.h" + +Open::Open() +{ + name = QString("open"); + description = QObject::tr("Open a database."); +} + +int Open::execute(const QStringList& arguments) +{ + currentDatabase.reset(nullptr); + return this->DatabaseCommand::execute(arguments); +} + +int Open::executeWithDatabase(QSharedPointer db, QSharedPointer parser) +{ + Q_UNUSED(parser) + currentDatabase = db; + return EXIT_SUCCESS; +} diff --git a/src/cli/Open.h b/src/cli/Open.h new file mode 100644 index 000000000..786c4d09f --- /dev/null +++ b/src/cli/Open.h @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_OPEN_H +#define KEEPASSXC_OPEN_H + +#include "DatabaseCommand.h" + +class Open : public DatabaseCommand +{ +public: + Open(); + int execute(const QStringList& arguments) override; + int executeWithDatabase(QSharedPointer db, QSharedPointer parser) override; +}; + +#endif // KEEPASSXC_OPEN_H diff --git a/src/cli/Utils.cpp b/src/cli/Utils.cpp index 05197e4da..0ed05d79d 100644 --- a/src/cli/Utils.cpp +++ b/src/cli/Utils.cpp @@ -269,4 +269,42 @@ namespace Utils return clipProcess->exitCode(); } + /** + * Splits the given QString into a QString list. For example: + * + * "hello world" -> ["hello", "world"] + * "hello world" -> ["hello", "world"] + * "hello\\ world" -> ["hello world"] (i.e. backslash is an escape character + * "\"hello world\"" -> ["hello world"] + */ + QStringList splitCommandString(const QString& command) + { + QStringList result; + + bool insideQuotes = false; + QString cur; + for (int i = 0; i < command.size(); ++i) { + QChar c = command[i]; + if (c == '\\' && i < command.size() - 1) { + cur.append(command[i + 1]); + ++i; + } else if (!insideQuotes && (c == ' ' || c == '\t')) { + if (!cur.isEmpty()) { + result.append(cur); + cur.clear(); + } + } else if (c == '"' && (insideQuotes || i == 0 || command[i - 1].isSpace())) { + insideQuotes = !insideQuotes; + } else { + cur.append(c); + } + } + + if (!cur.isEmpty()) { + result.append(cur); + } + + return result; + } + } // namespace Utils diff --git a/src/cli/Utils.h b/src/cli/Utils.h index 3b252ad57..742bb543d 100644 --- a/src/cli/Utils.h +++ b/src/cli/Utils.h @@ -48,6 +48,8 @@ namespace Utils FILE* outputDescriptor = STDOUT, FILE* errorDescriptor = STDERR); + QStringList splitCommandString(const QString& command); + namespace Test { void setNextPassword(const QString& password); diff --git a/src/cli/keepassxc-cli.1 b/src/cli/keepassxc-cli.1 index efdb60131..141f980b8 100644 --- a/src/cli/keepassxc-cli.1 +++ b/src/cli/keepassxc-cli.1 @@ -18,16 +18,19 @@ Adds a new entry to a database. A password can be generated (\fI-g\fP option), o The same password generation options as documented for the generate command can be used when the \fI-g\fP option is set. .IP "analyze [options] " -Analyze passwords in a database for weaknesses. +Analyzes passwords in a database for weaknesses. .IP "clip [options] [timeout]" Copies the password or the current TOTP (\fI-t\fP option) of a database entry to the clipboard. If multiple entries with the same name exist in different groups, only the password for the first one is going to be copied. For copying the password of an entry in a specific group, the group path to the entry should be specified as well, instead of just the name. Optionally, a timeout in seconds can be specified to automatically clear the clipboard. +.IP "close" +In interactive mode, closes the currently opened database (see \fIopen\fP). + .IP "create [options] " Creates a new database with a key file and/or password. The key file will be created if the file that is referred to does not exist. If both the key file and password are empty, no database will be created. .IP "diceware [options]" -Generate a random diceware passphrase. +Generates a random diceware passphrase. .IP "edit [options] " Edits a database entry. A password can be generated (\fI-g\fP option), or a prompt can be displayed to input the password (\fI-p\fP option). @@ -36,11 +39,17 @@ The same password generation options as documented for the generate command can .IP "estimate [options] [password]" Estimates the entropy of a password. The password to estimate can be provided as a positional argument, or using the standard input. +.IP "exit" +Exits interactive mode. Synonymous with \fIquit\fP. + .IP "export [options] " Exports the content of a database to standard output in the specified format (defaults to XML). .IP "generate [options]" -Generate a random password. +Generates a random password. + +.IP "help [command]" +Displays a list of available commands, or detailed information about the specified command. .IP "locate [options] " Locates all the entries that match a specific search term in a database. @@ -57,6 +66,12 @@ Adds a new group to a database. .IP "mv [options] " Moves an entry to a new group. +.IP "open [options] " +Opens the given database in a shell-style interactive mode. This is useful for performing multiple operations on a single database (e.g. \fIls\fP followed by \fIshow\fP). + +.IP "quit" +Exits interactive mode. Synonymous with \fIexit\fP. + .IP "rm [options] " Removes an entry from a database. If the database has a recycle bin, the entry will be moved there. If the entry is already in the recycle bin, it will be removed permanently. @@ -74,16 +89,16 @@ Shows the title, username, password, URL and notes of a database entry. Can also Displays debugging information. .IP "-k, --key-file " -Specifies a path to a key file for unlocking the database. In a merge operation this option is used to specify the key file path for the first database. +Specifies a path to a key file for unlocking the database. In a merge operation this option, is used to specify the key file path for the first database. .IP "--no-password" -Deactivate password key for the database. +Deactivates the password key for the database. .IP "-y, --yubikey " Specifies a yubikey slot for unlocking the database. In a merge operation this option is used to specify the yubikey slot for the first database. .IP "-q, --quiet " -Silence password prompt and other secondary outputs. +Silences password prompt and other secondary outputs. .IP "-h, --help" Displays help information. @@ -95,19 +110,19 @@ Displays the program version. .SS "Merge options" .IP "-d, --dry-run " -Only print the changes detected by the merge operation. +Prints the changes detected by the merge operation without making any changes to the database. .IP "-f, --key-file-from " -Path of the key file for the second database. +Sets the path of the key file for the second database. .IP "--no-password-from" -Deactivate password key for the database to merge from. +Deactivates password key for the database to merge from. .IP "--yubikey-from " Yubikey slot for the second database. .IP "-s, --same-credentials" -Use the same credentials for unlocking both database. +Uses the same credentials for unlocking both databases. .SS "Add and edit options" @@ -115,34 +130,34 @@ The same password generation options as documented for the generate command can with those 2 commands when the -g option is set. .IP "-u, --username " -Specify the username of the entry. +Specifies the username of the entry. .IP "--url " -Specify the URL of the entry. +Specifies the URL of the entry. .IP "-p, --password-prompt" -Use a password prompt for the entry's password. +Uses a password prompt for the entry's password. .IP "-g, --generate" -Generate a new password for the entry. +Generates a new password for the entry. .SS "Edit options" .IP "-t, --title " -Specify the title of the entry. +Specifies the title of the entry. .SS "Estimate options" .IP "-a, --advanced" -Perform advanced analysis on the password. +Performs advanced analysis on the password. .SS "Analyze options" .IP "-H, --hibp <filename>" -Check if any passwords have been publicly leaked, by comparing against the given +Checks if any passwords have been publicly leaked, by comparing against the given list of password SHA-1 hashes, which must be in "Have I Been Pwned" format. Such files are available from https://haveibeenpwned.com/Passwords; note that they are large, and so this operation typically takes some time (minutes up to an @@ -152,31 +167,31 @@ hour or so). .SS "Clip options" .IP "-t, --totp" -Copy the current TOTP instead of current password to clipboard. Will report an error -if no TOTP is configured for the entry. +Copies the current TOTP instead of current password to clipboard. Will report +an error if no TOTP is configured for the entry. .SS "Show options" .IP "-a, --attributes <attribute>..." -Names of the attributes to show. This option can be specified more than once, +Shows the named attributes. This option can be specified more than once, with each attribute shown one-per-line in the given order. If no attributes are specified and \fI-t\fP is not specified, a summary of the default attributes is given. .IP "-t, --totp" -Also show the current TOTP. Will report an error if no TOTP is configured for the -entry. +Also shows the current TOTP, reporting an error if no TOTP is configured for +the entry. .SS "Diceware options" .IP "-W, --words <count>" -Desired number of words for the generated passphrase. [Default: 7] +Sets the desired number of words for the generated passphrase. [Default: 7] .IP "-w, --word-list <path>" -Path of the wordlist for the diceware generator. The wordlist must have > 1000 words, -otherwise the program will fail. If the wordlist has < 4000 words a warning will -be printed to STDERR. +Sets the Path of the wordlist for the diceware generator. The wordlist must +have > 1000 words, otherwise the program will fail. If the wordlist has < 4000 +words a warning will be printed to STDERR. .SS "Export options" @@ -188,7 +203,7 @@ Format to use when exporting. Available choices are xml or csv. Defaults to xml. .SS "List options" .IP "-R, --recursive" -Recursively list the elements of the group. +Recursively lists the elements of the group. .IP "-f, --flatten" Flattens the output to single lines. When this option is enabled, subgroups and subentries will be displayed with a relative group path instead of indentation. @@ -196,22 +211,22 @@ Flattens the output to single lines. When this option is enabled, subgroups and .SS "Generate options" .IP "-L, --length <length>" -Desired length for the generated password. [Default: 16] +Sets the desired length for the generated password. [Default: 16] .IP "-l --lower" -Use lowercase characters for the generated password. [Default: Enabled] +Uses lowercase characters for the generated password. [Default: Enabled] .IP "-U --upper" -Use uppercase characters for the generated password. [Default: Enabled] +Uses uppercase characters for the generated password. [Default: Enabled] .IP "-n --numeric" -Use numbers characters for the generated password. [Default: Enabled] +Uses numbers characters for the generated password. [Default: Enabled] .IP "-s --special" -Use special characters for the generated password. [Default: Disabled] +Uses special characters for the generated password. [Default: Disabled] .IP "-e --extended" -Use extended ASCII characters for the generated password. [Default: Disabled] +Uses extended ASCII characters for the generated password. [Default: Disabled] .IP "-x --exclude <chars>" Comma-separated list of characters to exclude from the generated password. None is excluded by default. diff --git a/src/cli/keepassxc-cli.cpp b/src/cli/keepassxc-cli.cpp index 1f76812b0..e16be9b29 100644 --- a/src/cli/keepassxc-cli.cpp +++ b/src/cli/keepassxc-cli.cpp @@ -16,14 +16,20 @@ */ #include <cstdlib> +#include <memory> #include <QCommandLineParser> #include <QCoreApplication> +#include <QDir> +#include <QScopedPointer> #include <QStringList> #include "cli/TextStream.h" #include <cli/Command.h> +#include "DatabaseCommand.h" +#include "Open.h" +#include "Utils.h" #include "config-keepassx.h" #include "core/Bootstrap.h" #include "core/Tools.h" @@ -33,6 +39,138 @@ #include <sanitizer/lsan_interface.h> #endif +#if defined(USE_READLINE) +#include <readline/history.h> +#include <readline/readline.h> +#endif + +class LineReader +{ +public: + virtual ~LineReader() = default; + virtual QString readLine(QString prompt) = 0; + virtual bool isFinished() = 0; +}; + +class SimpleLineReader : public LineReader +{ +public: + SimpleLineReader() + : inStream(stdin, QIODevice::ReadOnly) + , outStream(stdout, QIODevice::WriteOnly) + , finished(false) + { + } + + QString readLine(QString prompt) override + { + outStream << prompt; + outStream.flush(); + QString result = inStream.readLine(); + if (result.isNull()) { + finished = true; + } + return result; + } + + bool isFinished() override + { + return finished; + } + +private: + TextStream inStream; + TextStream outStream; + bool finished; +}; + +#if defined(USE_READLINE) +class ReadlineLineReader : public LineReader +{ +public: + ReadlineLineReader() + : finished(false) + { + } + + QString readLine(QString prompt) override + { + char* result = readline(prompt.toStdString().c_str()); + if (!result) { + finished = true; + return {}; + } + add_history(result); + QString qstr(result); + free(result); + return qstr; + } + + bool isFinished() override + { + return finished; + } + +private: + bool finished; +}; +#endif + +void enterInteractiveMode(const QStringList& arguments) +{ + // Replace command list with interactive version + Commands::setupCommands(true); + + Open o; + QStringList openArgs(arguments); + openArgs.removeFirst(); + o.execute(openArgs); + + QScopedPointer<LineReader> reader; +#if defined(USE_READLINE) + reader.reset(new ReadlineLineReader()); +#else + reader.reset(new SimpleLineReader()); +#endif + + QSharedPointer<Database> currentDatabase(o.currentDatabase); + + QString command; + while (true) { + TextStream errorTextStream(Utils::STDERR, QIODevice::WriteOnly); + + QString prompt; + if (currentDatabase) { + prompt += currentDatabase->metadata()->name(); + if (prompt.isEmpty()) { + prompt += QFileInfo(currentDatabase->filePath()).fileName(); + } + } + prompt += "> "; + command = reader->readLine(prompt); + if (reader->isFinished()) { + return; + } + + QStringList args = Utils::splitCommandString(command); + if (args.empty()) { + continue; + } + + auto cmd = Commands::getCommand(args[0]); + if (!cmd) { + errorTextStream << QObject::tr("Unknown command %1").arg(args[0]) << "\n"; + continue; + } else if (cmd->name == "quit" || cmd->name == "exit") { + return; + } + + cmd->currentDatabase = currentDatabase; + cmd->execute(args); + currentDatabase = cmd->currentDatabase; + } +} + int main(int argc, char** argv) { if (!Crypto::init()) { @@ -44,6 +182,7 @@ int main(int argc, char** argv) QCoreApplication::setApplicationVersion(KEEPASSXC_VERSION); Bootstrap::bootstrap(); + Commands::setupCommands(false); TextStream out(stdout); QStringList arguments; @@ -54,7 +193,7 @@ int main(int argc, char** argv) QString description("KeePassXC command line interface."); description = description.append(QObject::tr("\n\nAvailable commands:\n")); - for (Command* command : Command::getCommands()) { + for (auto& command : Commands::getCommands()) { description = description.append(command->getDescriptionLine()); } parser.setApplicationDescription(description); @@ -84,9 +223,13 @@ int main(int argc, char** argv) } QString commandName = parser.positionalArguments().at(0); - Command* command = Command::getCommand(commandName); + if (commandName == "open") { + enterInteractiveMode(arguments); + return EXIT_SUCCESS; + } - if (command == nullptr) { + auto command = Commands::getCommand(commandName); + if (!command) { qCritical("Invalid command %s.", qPrintable(commandName)); // showHelp exits the application immediately, so we need to set the // exit code here. diff --git a/src/keys/YkChallengeResponseKeyCLI.h b/src/keys/YkChallengeResponseKeyCLI.h index 93e73a56f..ed2d62b2a 100644 --- a/src/keys/YkChallengeResponseKeyCLI.h +++ b/src/keys/YkChallengeResponseKeyCLI.h @@ -32,10 +32,7 @@ class YkChallengeResponseKeyCLI : public QObject, public ChallengeResponseKey public: static QUuid UUID; - explicit YkChallengeResponseKeyCLI(int slot, - bool blocking, - QString messageInteraction, - FILE* outputDescriptor); + explicit YkChallengeResponseKeyCLI(int slot, bool blocking, QString messageInteraction, FILE* outputDescriptor); QByteArray rawKey() const override; bool challenge(const QByteArray& challenge) override; diff --git a/tests/TestCli.cpp b/tests/TestCli.cpp index 7e3bcf585..4e27170ea 100644 --- a/tests/TestCli.cpp +++ b/tests/TestCli.cpp @@ -1,1752 +1,1876 @@ -/* - * Copyright (C) 2019 KeePassXC Team <team@keepassxc.org> - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 or (at your option) - * version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -#include "TestCli.h" - -#include "config-keepassx-tests.h" -#include "core/Bootstrap.h" -#include "core/Config.h" -#include "core/Global.h" -#include "core/PasswordGenerator.h" -#include "core/Tools.h" -#include "crypto/Crypto.h" -#include "format/Kdbx3Reader.h" -#include "format/Kdbx3Writer.h" -#include "format/Kdbx4Reader.h" -#include "format/Kdbx4Writer.h" -#include "format/KdbxXmlReader.h" -#include "format/KeePass2.h" - -#include "cli/Add.h" -#include "cli/AddGroup.h" -#include "cli/Analyze.h" -#include "cli/Clip.h" -#include "cli/Command.h" -#include "cli/Create.h" -#include "cli/Diceware.h" -#include "cli/Edit.h" -#include "cli/Estimate.h" -#include "cli/Export.h" -#include "cli/Generate.h" -#include "cli/List.h" -#include "cli/Locate.h" -#include "cli/Merge.h" -#include "cli/Move.h" -#include "cli/Remove.h" -#include "cli/RemoveGroup.h" -#include "cli/Show.h" -#include "cli/Utils.h" - -#include <QClipboard> -#include <QFile> -#include <QFuture> -#include <QSet> -#include <QTextStream> -#include <QtConcurrent> - -#include <cstdio> - -QTEST_MAIN(TestCli) - -void TestCli::initTestCase() -{ - QVERIFY(Crypto::init()); - - Config::createTempFileInstance(); - Bootstrap::bootstrapApplication(); - - // Load the NewDatabase.kdbx file into temporary storage - QFile sourceDbFile(QString(KEEPASSX_TEST_DATA_DIR).append("/NewDatabase.kdbx")); - QVERIFY(sourceDbFile.open(QIODevice::ReadOnly)); - QVERIFY(Tools::readAllFromDevice(&sourceDbFile, m_dbData)); - sourceDbFile.close(); - - // Load the NewDatabase2.kdbx file into temporary storage - QFile sourceDbFile2(QString(KEEPASSX_TEST_DATA_DIR).append("/NewDatabase2.kdbx")); - QVERIFY(sourceDbFile2.open(QIODevice::ReadOnly)); - QVERIFY(Tools::readAllFromDevice(&sourceDbFile2, m_dbData2)); - sourceDbFile2.close(); - - // Load the KeyFileProtected.kdbx file into temporary storage - QFile sourceDbFile3(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtected.kdbx")); - QVERIFY(sourceDbFile3.open(QIODevice::ReadOnly)); - QVERIFY(Tools::readAllFromDevice(&sourceDbFile3, m_keyFileProtectedDbData)); - sourceDbFile3.close(); - - // Load the KeyFileProtectedNoPassword.kdbx file into temporary storage - QFile sourceDbFile4(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtectedNoPassword.kdbx")); - QVERIFY(sourceDbFile4.open(QIODevice::ReadOnly)); - QVERIFY(Tools::readAllFromDevice(&sourceDbFile4, m_keyFileProtectedNoPasswordDbData)); - sourceDbFile4.close(); - - QFile sourceDbFileYubiKeyProtected(QString(KEEPASSX_TEST_DATA_DIR).append("/YubiKeyProtectedPasswords.kdbx")); - QVERIFY(sourceDbFileYubiKeyProtected.open(QIODevice::ReadOnly)); - QVERIFY(Tools::readAllFromDevice(&sourceDbFileYubiKeyProtected, m_yubiKeyProtectedDbData)); - sourceDbFileYubiKeyProtected.close(); -} - -void TestCli::init() -{ - m_dbFile.reset(new TemporaryFile()); - m_dbFile->open(); - m_dbFile->write(m_dbData); - m_dbFile->close(); - - m_dbFile2.reset(new TemporaryFile()); - m_dbFile2->open(); - m_dbFile2->write(m_dbData2); - m_dbFile2->close(); - - m_keyFileProtectedDbFile.reset(new TemporaryFile()); - m_keyFileProtectedDbFile->open(); - m_keyFileProtectedDbFile->write(m_keyFileProtectedDbData); - m_keyFileProtectedDbFile->close(); - - m_keyFileProtectedNoPasswordDbFile.reset(new TemporaryFile()); - m_keyFileProtectedNoPasswordDbFile->open(); - m_keyFileProtectedNoPasswordDbFile->write(m_keyFileProtectedNoPasswordDbData); - m_keyFileProtectedNoPasswordDbFile->close(); - - m_yubiKeyProtectedDbFile.reset(new TemporaryFile()); - m_yubiKeyProtectedDbFile->open(); - m_yubiKeyProtectedDbFile->write(m_yubiKeyProtectedDbData); - m_yubiKeyProtectedDbFile->close(); - - m_stdinFile.reset(new TemporaryFile()); - m_stdinFile->open(); - m_stdinHandle = fdopen(m_stdinFile->handle(), "r+"); - Utils::STDIN = m_stdinHandle; - - m_stdoutFile.reset(new TemporaryFile()); - m_stdoutFile->open(); - m_stdoutHandle = fdopen(m_stdoutFile->handle(), "r+"); - Utils::STDOUT = m_stdoutHandle; - - m_stderrFile.reset(new TemporaryFile()); - m_stderrFile->open(); - m_stderrHandle = fdopen(m_stderrFile->handle(), "r+"); - Utils::STDERR = m_stderrHandle; -} - -void TestCli::cleanup() -{ - m_dbFile.reset(); - - m_dbFile2.reset(); - - m_stdinFile.reset(); - m_stdinHandle = stdin; - Utils::STDIN = stdin; - - m_stdoutFile.reset(); - Utils::STDOUT = stdout; - m_stdoutHandle = stdout; - - m_stderrFile.reset(); - m_stderrHandle = stderr; - Utils::STDERR = stderr; -} - -void TestCli::cleanupTestCase() -{ -} - -QSharedPointer<Database> TestCli::readTestDatabase() const -{ - Utils::Test::setNextPassword("a"); - auto db = QSharedPointer<Database>(Utils::unlockDatabase(m_dbFile->fileName(), true, "", "", m_stdoutHandle)); - m_stdoutFile->seek(ftell(m_stdoutHandle)); // re-synchronize handles - return db; -} - -void TestCli::testCommand() -{ - QCOMPARE(Command::getCommands().size(), 17); - QVERIFY(Command::getCommand("add")); - QVERIFY(Command::getCommand("analyze")); - QVERIFY(Command::getCommand("clip")); - QVERIFY(Command::getCommand("create")); - QVERIFY(Command::getCommand("diceware")); - QVERIFY(Command::getCommand("edit")); - QVERIFY(Command::getCommand("estimate")); - QVERIFY(Command::getCommand("export")); - QVERIFY(Command::getCommand("generate")); - QVERIFY(Command::getCommand("locate")); - QVERIFY(Command::getCommand("ls")); - QVERIFY(Command::getCommand("merge")); - QVERIFY(Command::getCommand("rm")); - QVERIFY(Command::getCommand("show")); - QVERIFY(!Command::getCommand("doesnotexist")); -} - -void TestCli::testAdd() -{ - Add addCmd; - QVERIFY(!addCmd.name.isEmpty()); - QVERIFY(addCmd.getDescriptionLine().contains(addCmd.name)); - - Utils::Test::setNextPassword("a"); - addCmd.execute({"add", - "-u", - "newuser", - "--url", - "https://example.com/", - "-g", - "-L", - "20", - m_dbFile->fileName(), - "/newuser-entry"}); - m_stderrFile->reset(); - m_stdoutFile->reset(); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added entry newuser-entry.\n")); - - auto db = readTestDatabase(); - auto* entry = db->rootGroup()->findEntryByPath("/newuser-entry"); - QVERIFY(entry); - QCOMPARE(entry->username(), QString("newuser")); - QCOMPARE(entry->url(), QString("https://example.com/")); - QCOMPARE(entry->password().size(), 20); - - // Quiet option - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - addCmd.execute({"add", "-q", "-u", "newuser", "-g", "-L", "20", m_dbFile->fileName(), "/newentry-quiet"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("/newentry-quiet"); - QVERIFY(entry); - QCOMPARE(entry->password().size(), 20); - - Utils::Test::setNextPassword("a"); - Utils::Test::setNextPassword("newpassword"); - addCmd.execute( - {"add", "-u", "newuser2", "--url", "https://example.net/", "-p", m_dbFile->fileName(), "/newuser-entry2"}); - - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("/newuser-entry2"); - QVERIFY(entry); - QCOMPARE(entry->username(), QString("newuser2")); - QCOMPARE(entry->url(), QString("https://example.net/")); - QCOMPARE(entry->password(), QString("newpassword")); - - // Password generation options - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - addCmd.execute({"add", "-u", "newuser3", "-g", "-L", "34", m_dbFile->fileName(), "/newuser-entry3"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added entry newuser-entry3.\n")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("/newuser-entry3"); - QVERIFY(entry); - QCOMPARE(entry->username(), QString("newuser3")); - QCOMPARE(entry->password().size(), 34); - QRegularExpression defaultPasswordClassesRegex("^[a-zA-Z0-9]+$"); - QVERIFY(defaultPasswordClassesRegex.match(entry->password()).hasMatch()); - - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - addCmd.execute({"add", - "-u", - "newuser4", - "-g", - "-L", - "20", - "--every-group", - "-s", - "-n", - "-U", - "-l", - m_dbFile->fileName(), - "/newuser-entry4"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added entry newuser-entry4.\n")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("/newuser-entry4"); - QVERIFY(entry); - QCOMPARE(entry->username(), QString("newuser4")); - QCOMPARE(entry->password().size(), 20); - QVERIFY(!defaultPasswordClassesRegex.match(entry->password()).hasMatch()); -} - -void TestCli::testAddGroup() -{ - AddGroup addGroupCmd; - QVERIFY(!addGroupCmd.name.isEmpty()); - QVERIFY(addGroupCmd.getDescriptionLine().contains(addGroupCmd.name)); - - Utils::Test::setNextPassword("a"); - addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/new_group"}); - m_stderrFile->reset(); - m_stdoutFile->reset(); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added group new_group.\n")); - - auto db = readTestDatabase(); - auto* group = db->rootGroup()->findGroupByPath("new_group"); - QVERIFY(group); - QCOMPARE(group->name(), QString("new_group")); - - // Trying to add the same group should fail. - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/new_group"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Group /new_group already exists!\n")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - - // Should be able to add groups down the tree. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/new_group/newer_group"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added group newer_group.\n")); - - db = readTestDatabase(); - group = db->rootGroup()->findGroupByPath("new_group/newer_group"); - QVERIFY(group); - QCOMPARE(group->name(), QString("newer_group")); - - // Should fail if the path is invalid. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/invalid_group/newer_group"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Group /invalid_group not found.\n")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - - // Should fail to add the root group. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Group / already exists!\n")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); -} - -bool isTOTP(const QString& value) -{ - QString val = value.trimmed(); - if (val.length() < 5 || val.length() > 6) { - return false; - } - for (int i = 0; i < val.length(); ++i) { - if (!value[i].isDigit()) { - return false; - } - } - return true; -} - -void TestCli::testAnalyze() -{ - Analyze analyzeCmd; - QVERIFY(!analyzeCmd.name.isEmpty()); - QVERIFY(analyzeCmd.getDescriptionLine().contains(analyzeCmd.name)); - - const QString hibpPath = QString(KEEPASSX_TEST_DATA_DIR).append("/hibp.txt"); - - Utils::Test::setNextPassword("a"); - analyzeCmd.execute({"analyze", "--hibp", hibpPath, m_dbFile->fileName()}); - m_stdoutFile->reset(); - m_stdoutFile->readLine(); // skip password prompt - auto output = m_stdoutFile->readAll(); - QVERIFY(output.contains("Sample Entry") && output.contains("123")); -} - -void TestCli::testClip() -{ - QClipboard* clipboard = QGuiApplication::clipboard(); - clipboard->clear(); - - Clip clipCmd; - QVERIFY(!clipCmd.name.isEmpty()); - QVERIFY(clipCmd.getDescriptionLine().contains(clipCmd.name)); - - // Password - Utils::Test::setNextPassword("a"); - clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry"}); - - m_stderrFile->reset(); - m_stdoutFile->reset(); - QString errorOutput(m_stderrFile->readAll()); - - if (errorOutput.contains("Unable to start program") - || errorOutput.contains("No program defined for clipboard manipulation")) { - QSKIP("Clip test skipped due to missing clipboard tool"); - } - - QCOMPARE(clipboard->text(), QString("Password")); - m_stdoutFile->readLine(); // skip prompt line - QCOMPARE(m_stdoutFile->readLine(), QByteArray("Entry's password copied to the clipboard!\n")); - - // Quiet option - qint64 pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry", "-q"}); - m_stdoutFile->seek(pos); - // Output should be empty when quiet option is set. - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(clipboard->text(), QString("Password")); - - // TOTP - Utils::Test::setNextPassword("a"); - clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry", "--totp"}); - - QVERIFY(isTOTP(clipboard->text())); - - // Password with timeout - Utils::Test::setNextPassword("a"); - // clang-format off - QFuture<void> future = QtConcurrent::run(&clipCmd, - static_cast<int(Clip::*)(const QStringList&)>(&DatabaseCommand::execute), - QStringList{"clip", m_dbFile->fileName(), "/Sample Entry", "1"}); - // clang-format on - - QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString("Password"), 500); - QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString(""), 1500); - - future.waitForFinished(); - - // TOTP with timeout - Utils::Test::setNextPassword("a"); - future = QtConcurrent::run(&clipCmd, - static_cast<int (Clip::*)(const QStringList&)>(&DatabaseCommand::execute), - QStringList{"clip", m_dbFile->fileName(), "/Sample Entry", "1", "-t"}); - - QTRY_VERIFY_WITH_TIMEOUT(isTOTP(clipboard->text()), 500); - QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString(""), 1500); - - future.waitForFinished(); - - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - clipCmd.execute({"clip", m_dbFile->fileName(), "--totp", "/Sample Entry", "0"}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Invalid timeout value 0.\n")); - - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - clipCmd.execute({"clip", m_dbFile->fileName(), "--totp", "/Sample Entry", "bleuh"}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Invalid timeout value bleuh.\n")); - - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - clipCmd.execute({"clip", m_dbFile2->fileName(), "--totp", "/Sample Entry"}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry with path /Sample Entry has no TOTP set up.\n")); -} - -void TestCli::testCreate() -{ - Create createCmd; - QVERIFY(!createCmd.name.isEmpty()); - QVERIFY(createCmd.getDescriptionLine().contains(createCmd.name)); - - QScopedPointer<QTemporaryDir> testDir(new QTemporaryDir()); - - QString databaseFilename = testDir->path() + "testCreate1.kdbx"; - // Password - Utils::Test::setNextPassword("a"); - createCmd.execute({"create", databaseFilename}); - - m_stderrFile->reset(); - m_stdoutFile->reset(); - - QCOMPARE(m_stdoutFile->readLine(), - QByteArray("Insert password to encrypt database (Press enter to leave blank): \n")); - QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully created new database.\n")); - - Utils::Test::setNextPassword("a"); - auto db = QSharedPointer<Database>(Utils::unlockDatabase(databaseFilename, true, "", "", Utils::DEVNULL)); - QVERIFY(db); - - // Should refuse to create the database if it already exists. - qint64 pos = m_stdoutFile->pos(); - qint64 errPos = m_stderrFile->pos(); - createCmd.execute({"create", databaseFilename}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(errPos); - // Output should be empty when there is an error. - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QString errorMessage = QString("File " + databaseFilename + " already exists.\n"); - QCOMPARE(m_stderrFile->readAll(), errorMessage.toUtf8()); - - // Testing with keyfile creation - QString databaseFilename2 = testDir->path() + "testCreate2.kdbx"; - QString keyfilePath = testDir->path() + "keyfile.txt"; - pos = m_stdoutFile->pos(); - errPos = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - createCmd.execute({"create", databaseFilename2, "-k", keyfilePath}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(errPos); - - QCOMPARE(m_stdoutFile->readLine(), - QByteArray("Insert password to encrypt database (Press enter to leave blank): \n")); - QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully created new database.\n")); - - Utils::Test::setNextPassword("a"); - auto db2 = - QSharedPointer<Database>(Utils::unlockDatabase(databaseFilename2, true, keyfilePath, "", Utils::DEVNULL)); - QVERIFY(db2); - - // Testing with existing keyfile - QString databaseFilename3 = testDir->path() + "testCreate3.kdbx"; - pos = m_stdoutFile->pos(); - errPos = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - createCmd.execute({"create", databaseFilename3, "-k", keyfilePath}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(errPos); - - QCOMPARE(m_stdoutFile->readLine(), - QByteArray("Insert password to encrypt database (Press enter to leave blank): \n")); - QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully created new database.\n")); - - Utils::Test::setNextPassword("a"); - auto db3 = - QSharedPointer<Database>(Utils::unlockDatabase(databaseFilename3, true, keyfilePath, "", Utils::DEVNULL)); - QVERIFY(db3); -} - -void TestCli::testDiceware() -{ - Diceware dicewareCmd; - QVERIFY(!dicewareCmd.name.isEmpty()); - QVERIFY(dicewareCmd.getDescriptionLine().contains(dicewareCmd.name)); - - dicewareCmd.execute({"diceware"}); - m_stdoutFile->reset(); - QString passphrase(m_stdoutFile->readLine()); - QVERIFY(!passphrase.isEmpty()); - - dicewareCmd.execute({"diceware", "-W", "2"}); - m_stdoutFile->seek(passphrase.toLatin1().size()); - passphrase = m_stdoutFile->readLine(); - QCOMPARE(passphrase.split(" ").size(), 2); - - auto pos = m_stdoutFile->pos(); - dicewareCmd.execute({"diceware", "-W", "10"}); - m_stdoutFile->seek(pos); - passphrase = m_stdoutFile->readLine(); - QCOMPARE(passphrase.split(" ").size(), 10); - - // Testing with invalid word count - auto posErr = m_stderrFile->pos(); - dicewareCmd.execute({"diceware", "-W", "-10"}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid word count -10\n")); - - // Testing with invalid word count format - posErr = m_stderrFile->pos(); - dicewareCmd.execute({"diceware", "-W", "bleuh"}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid word count bleuh\n")); - - TemporaryFile wordFile; - wordFile.open(); - for (int i = 0; i < 4500; ++i) { - wordFile.write(QString("word" + QString::number(i) + "\n").toLatin1()); - } - wordFile.close(); - - pos = m_stdoutFile->pos(); - dicewareCmd.execute({"diceware", "-W", "11", "-w", wordFile.fileName()}); - m_stdoutFile->seek(pos); - passphrase = m_stdoutFile->readLine(); - const auto words = passphrase.split(" "); - QCOMPARE(words.size(), 11); - QRegularExpression regex("^word\\d+$"); - for (const auto& word : words) { - QVERIFY2(regex.match(word).hasMatch(), qPrintable("Word " + word + " was not on the word list")); - } - - TemporaryFile smallWordFile; - smallWordFile.open(); - for (int i = 0; i < 50; ++i) { - smallWordFile.write(QString("word" + QString::number(i) + "\n").toLatin1()); - } - smallWordFile.close(); - - posErr = m_stderrFile->pos(); - dicewareCmd.execute({"diceware", "-W", "11", "-w", smallWordFile.fileName()}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readLine(), QByteArray("The word list is too small (< 1000 items)\n")); -} - -void TestCli::testEdit() -{ - Edit editCmd; - QVERIFY(!editCmd.name.isEmpty()); - QVERIFY(editCmd.getDescriptionLine().contains(editCmd.name)); - - Utils::Test::setNextPassword("a"); - // clang-format off - editCmd.execute({"edit", "-u", "newuser", "--url", "https://otherurl.example.com/", "-t", "newtitle", m_dbFile->fileName(), "/Sample Entry"}); - // clang-format on - m_stdoutFile->reset(); - m_stdoutFile->readLine(); // skip prompt line - QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully edited entry newtitle.\n")); - - auto db = readTestDatabase(); - auto* entry = db->rootGroup()->findEntryByPath("/newtitle"); - QVERIFY(entry); - QCOMPARE(entry->username(), QString("newuser")); - QCOMPARE(entry->url(), QString("https://otherurl.example.com/")); - QCOMPARE(entry->password(), QString("Password")); - - // Quiet option - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - editCmd.execute({"edit", m_dbFile->fileName(), "-q", "-t", "newertitle", "/newtitle"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - - Utils::Test::setNextPassword("a"); - editCmd.execute({"edit", "-g", m_dbFile->fileName(), "/newertitle"}); - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("/newertitle"); - QVERIFY(entry); - QCOMPARE(entry->username(), QString("newuser")); - QCOMPARE(entry->url(), QString("https://otherurl.example.com/")); - QVERIFY(!entry->password().isEmpty()); - QVERIFY(entry->password() != QString("Password")); - - Utils::Test::setNextPassword("a"); - editCmd.execute({"edit", "-g", "-L", "34", "-t", "evennewertitle", m_dbFile->fileName(), "/newertitle"}); - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("/evennewertitle"); - QVERIFY(entry); - QCOMPARE(entry->username(), QString("newuser")); - QCOMPARE(entry->url(), QString("https://otherurl.example.com/")); - QVERIFY(entry->password() != QString("Password")); - QCOMPARE(entry->password().size(), 34); - QRegularExpression defaultPasswordClassesRegex("^[a-zA-Z0-9]+$"); - QVERIFY(defaultPasswordClassesRegex.match(entry->password()).hasMatch()); - - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - editCmd.execute({"edit", - "-g", - "-L", - "20", - "--every-group", - "-s", - "-n", - "--upper", - "-l", - m_dbFile->fileName(), - "/evennewertitle"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully edited entry evennewertitle.\n")); - - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("/evennewertitle"); - QVERIFY(entry); - QCOMPARE(entry->password().size(), 20); - QVERIFY(!defaultPasswordClassesRegex.match(entry->password()).hasMatch()); - - Utils::Test::setNextPassword("a"); - Utils::Test::setNextPassword("newpassword"); - editCmd.execute({"edit", "-p", m_dbFile->fileName(), "/evennewertitle"}); - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("/evennewertitle"); - QVERIFY(entry); - QCOMPARE(entry->password(), QString("newpassword")); -} - -void TestCli::testEstimate_data() -{ - QTest::addColumn<QString>("input"); - QTest::addColumn<QString>("length"); - QTest::addColumn<QString>("entropy"); - QTest::addColumn<QString>("log10"); - QTest::addColumn<QStringList>("searchStrings"); - - QTest::newRow("Dictionary") << "password" - << "8" - << "1.0" - << "0.3" << QStringList{"Type: Dictionary", "\tpassword"}; - - QTest::newRow("Spatial") << "zxcv" - << "4" - << "10.3" - << "3.1" << QStringList{"Type: Spatial", "\tzxcv"}; - - QTest::newRow("Spatial(Rep)") << "sdfgsdfg" - << "8" - << "11.3" - << "3.4" << QStringList{"Type: Spatial(Rep)", "\tsdfgsdfg"}; - - QTest::newRow("Dictionary / Sequence") - << "password123" - << "11" - << "4.5" - << "1.3" << QStringList{"Type: Dictionary", "Type: Sequence", "\tpassword", "\t123"}; - - QTest::newRow("Dict+Leet") << "p455w0rd" - << "8" - << "2.5" - << "0.7" << QStringList{"Type: Dict+Leet", "\tp455w0rd"}; - - QTest::newRow("Dictionary(Rep)") << "hellohello" - << "10" - << "7.3" - << "2.2" << QStringList{"Type: Dictionary(Rep)", "\thellohello"}; - - QTest::newRow("Sequence(Rep) / Dictionary") - << "456456foobar" - << "12" - << "16.7" - << "5.0" << QStringList{"Type: Sequence(Rep)", "Type: Dictionary", "\t456456", "\tfoobar"}; - - QTest::newRow("Bruteforce(Rep) / Bruteforce") - << "xzxzy" - << "5" - << "16.1" - << "4.8" << QStringList{"Type: Bruteforce(Rep)", "Type: Bruteforce", "\txzxz", "\ty"}; - - QTest::newRow("Dictionary / Date(Rep)") - << "pass20182018" - << "12" - << "15.1" - << "4.56" << QStringList{"Type: Dictionary", "Type: Date(Rep)", "\tpass", "\t20182018"}; - - QTest::newRow("Dictionary / Date / Bruteforce") - << "mypass2018-2" - << "12" - << "32.9" - << "9.9" << QStringList{"Type: Dictionary", "Type: Date", "Type: Bruteforce", "\tmypass", "\t2018", "\t-2"}; - - QTest::newRow("Strong Password") << "E*!%.Qw{t.X,&bafw)\"Q!ah$%;U/" - << "28" - << "165.7" - << "49.8" << QStringList{"Type: Bruteforce", "\tE*"}; - - // TODO: detect passphrases and adjust entropy calculation accordingly (issue #2347) - QTest::newRow("Strong Passphrase") - << "squint wooing resupply dangle isolation axis headsman" - << "53" - << "151.2" - << "45.5" - << QStringList{ - "Type: Dictionary", "Type: Bruteforce", "Multi-word extra bits 22.0", "\tsquint", "\t ", "\twooing"}; -} - -void TestCli::testEstimate() -{ - QFETCH(QString, input); - QFETCH(QString, length); - QFETCH(QString, entropy); - QFETCH(QString, log10); - QFETCH(QStringList, searchStrings); - - Estimate estimateCmd; - QVERIFY(!estimateCmd.name.isEmpty()); - QVERIFY(estimateCmd.getDescriptionLine().contains(estimateCmd.name)); - - QTextStream in(m_stdinFile.data()); - QTextStream out(m_stdoutFile.data()); - - in << input << endl; - in.seek(0); - estimateCmd.execute({"estimate", "-a"}); - out.seek(0); - auto result = out.readAll(); - QVERIFY(result.contains("Length " + length)); - QVERIFY(result.contains("Entropy " + entropy)); - QVERIFY(result.contains("Log10 " + log10)); - for (const auto& string : asConst(searchStrings)) { - QVERIFY2(result.contains(string), qPrintable("String " + string + " missing")); - } -} - -void TestCli::testExport() -{ - Export exportCmd; - QVERIFY(!exportCmd.name.isEmpty()); - QVERIFY(exportCmd.getDescriptionLine().contains(exportCmd.name)); - - Utils::Test::setNextPassword("a"); - exportCmd.execute({"export", m_dbFile->fileName()}); - - m_stdoutFile->seek(0); - m_stdoutFile->readLine(); // skip prompt line - - KdbxXmlReader reader(KeePass2::FILE_VERSION_3_1); - QScopedPointer<Database> db(new Database()); - reader.readDatabase(m_stdoutFile.data(), db.data()); - QVERIFY(!reader.hasError()); - QVERIFY(db.data()); - auto* entry = db->rootGroup()->findEntryByPath("/Sample Entry"); - QVERIFY(entry); - QCOMPARE(entry->password(), QString("Password")); - - m_stdoutFile->reset(); - - // Quiet option - QScopedPointer<Database> dbQuiet(new Database()); - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - exportCmd.execute({"export", "-f", "xml", "-q", m_dbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - reader.readDatabase(m_stdoutFile.data(), dbQuiet.data()); - QVERIFY(!reader.hasError()); - QVERIFY(db.data()); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - - // CSV exporting - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - exportCmd.execute({"export", "-f", "csv", m_dbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip prompt line - m_stderrFile->seek(posErr); - QByteArray csvHeader = m_stdoutFile->readLine(); - QCOMPARE(csvHeader, QByteArray("\"Group\",\"Title\",\"Username\",\"Password\",\"URL\",\"Notes\"\n")); - QByteArray csvData = m_stdoutFile->readAll(); - QVERIFY(csvData.contains(QByteArray( - "\"NewDatabase\",\"Sample Entry\",\"User Name\",\"Password\",\"http://www.somesite.com/\",\"Notes\"\n"))); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - - // test invalid format - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - exportCmd.execute({"export", "-f", "yaml", m_dbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip prompt line - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readLine(), QByteArray("")); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Unsupported format yaml\n")); -} - -void TestCli::testGenerate_data() -{ - QTest::addColumn<QStringList>("parameters"); - QTest::addColumn<QString>("pattern"); - - QTest::newRow("default") << QStringList{"generate"} << "^[^\r\n]+$"; - QTest::newRow("length") << QStringList{"generate", "-L", "13"} << "^.{13}$"; - QTest::newRow("lowercase") << QStringList{"generate", "-L", "14", "-l"} << "^[a-z]{14}$"; - QTest::newRow("uppercase") << QStringList{"generate", "-L", "15", "--upper"} << "^[A-Z]{15}$"; - QTest::newRow("numbers") << QStringList{"generate", "-L", "16", "-n"} << "^[0-9]{16}$"; - QTest::newRow("special") << QStringList{"generate", "-L", "200", "-s"} - << R"(^[\(\)\[\]\{\}\.\-*|\\,:;"'\/\_!+-<=>?#$%&^`@~]{200}$)"; - QTest::newRow("special (exclude)") << QStringList{"generate", "-L", "200", "-s", "-x", "+.?@&"} - << R"(^[\(\)\[\]\{\}\.\-*|\\,:;"'\/\_!-<=>#$%^`~]{200}$)"; - QTest::newRow("extended") << QStringList{"generate", "-L", "50", "-e"} - << R"(^[^a-zA-Z0-9\(\)\[\]\{\}\.\-\*\|\\,:;"'\/\_!+-<=>?#$%&^`@~]{50}$)"; - QTest::newRow("numbers + lowercase + uppercase") - << QStringList{"generate", "-L", "16", "-n", "--upper", "-l"} << "^[0-9a-zA-Z]{16}$"; - QTest::newRow("numbers + lowercase + uppercase (exclude)") - << QStringList{"generate", "-L", "500", "-n", "-U", "-l", "-x", "abcdefg0123@"} << "^[^abcdefg0123@]{500}$"; - QTest::newRow("numbers + lowercase + uppercase (exclude similar)") - << QStringList{"generate", "-L", "200", "-n", "-U", "-l", "--exclude-similar"} << "^[^l1IO0]{200}$"; - QTest::newRow("uppercase + lowercase (every)") - << QStringList{"generate", "-L", "2", "--upper", "-l", "--every-group"} << "^[a-z][A-Z]|[A-Z][a-z]$"; - QTest::newRow("numbers + lowercase (every)") - << QStringList{"generate", "-L", "2", "-n", "-l", "--every-group"} << "^[a-z][0-9]|[0-9][a-z]$"; -} - -void TestCli::testGenerate() -{ - QFETCH(QStringList, parameters); - QFETCH(QString, pattern); - - Generate generateCmd; - QVERIFY(!generateCmd.name.isEmpty()); - QVERIFY(generateCmd.getDescriptionLine().contains(generateCmd.name)); - - qint64 pos = 0; - // run multiple times to make accidental passes unlikely - TextStream stream(m_stdoutFile.data()); - for (int i = 0; i < 10; ++i) { - generateCmd.execute(parameters); - stream.seek(pos); - QRegularExpression regex(pattern); - QString password = stream.readLine(); - pos = stream.pos(); - QVERIFY2(regex.match(password).hasMatch(), - qPrintable("Password " + password + " does not match pattern " + pattern)); - } - - // Testing with invalid password length - auto posErr = m_stderrFile->pos(); - generateCmd.execute({"generate", "-L", "-10"}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid password length -10\n")); - - posErr = m_stderrFile->pos(); - generateCmd.execute({"generate", "-L", "0"}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid password length 0\n")); - - // Testing with invalid word count format - posErr = m_stderrFile->pos(); - generateCmd.execute({"generate", "-L", "bleuh"}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid password length bleuh\n")); -} - -void TestCli::testKeyFileOption() -{ - List listCmd; - - QString keyFilePath(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtected.key")); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-k", keyFilePath, m_keyFileProtectedDbFile->fileName()}); - m_stdoutFile->reset(); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("entry1\n" - "entry2\n")); - - // Should raise an error with no key file. - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", m_keyFileProtectedDbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QVERIFY(m_stderrFile->readAll().contains("Invalid credentials were provided")); - - // Should raise an error if key file path is invalid. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-k", "invalidpath", m_keyFileProtectedDbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll().split(':').at(0), QByteArray("Failed to load key file invalidpath")); -} - -void TestCli::testNoPasswordOption() -{ - List listCmd; - - QString keyFilePath(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtectedNoPassword.key")); - listCmd.execute({"ls", "-k", keyFilePath, "--no-password", m_keyFileProtectedNoPasswordDbFile->fileName()}); - m_stdoutFile->reset(); - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("entry1\n" - "entry2\n")); - - // Should raise an error with no key file. - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - listCmd.execute({"ls", "--no-password", m_keyFileProtectedNoPasswordDbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QVERIFY(m_stderrFile->readAll().contains("Invalid credentials were provided")); -} - -void TestCli::testList() -{ - List listCmd; - QVERIFY(!listCmd.name.isEmpty()); - QVERIFY(listCmd.getDescriptionLine().contains(listCmd.name)); - - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", m_dbFile->fileName()}); - m_stdoutFile->reset(); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("Sample Entry\n" - "General/\n" - "Windows/\n" - "Network/\n" - "Internet/\n" - "eMail/\n" - "Homebanking/\n")); - - // Quiet option - qint64 pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-q", m_dbFile->fileName()}); - m_stdoutFile->seek(pos); - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("Sample Entry\n" - "General/\n" - "Windows/\n" - "Network/\n" - "Internet/\n" - "eMail/\n" - "Homebanking/\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-R", m_dbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("Sample Entry\n" - "General/\n" - " [empty]\n" - "Windows/\n" - " [empty]\n" - "Network/\n" - " [empty]\n" - "Internet/\n" - " [empty]\n" - "eMail/\n" - " [empty]\n" - "Homebanking/\n" - " Subgroup/\n" - " Subgroup Entry\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-R", "-f", m_dbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("Sample Entry\n" - "General/\n" - "General/[empty]\n" - "Windows/\n" - "Windows/[empty]\n" - "Network/\n" - "Network/[empty]\n" - "Internet/\n" - "Internet/[empty]\n" - "eMail/\n" - "eMail/[empty]\n" - "Homebanking/\n" - "Homebanking/Subgroup/\n" - "Homebanking/Subgroup/Subgroup Entry\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-R", "-f", m_dbFile->fileName(), "/Homebanking"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("Subgroup/\n" - "Subgroup/Subgroup Entry\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", m_dbFile->fileName(), "/General/"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("[empty]\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", m_dbFile->fileName(), "/DoesNotExist/"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->reset(); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Cannot find group /DoesNotExist/.\n")); -} - -void TestCli::testLocate() -{ - Locate locateCmd; - QVERIFY(!locateCmd.name.isEmpty()); - QVERIFY(locateCmd.getDescriptionLine().contains(locateCmd.name)); - - Utils::Test::setNextPassword("a"); - locateCmd.execute({"locate", m_dbFile->fileName(), "Sample"}); - m_stdoutFile->reset(); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), QByteArray("/Sample Entry\n")); - - // Quiet option - qint64 pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - locateCmd.execute({"locate", m_dbFile->fileName(), "-q", "Sample"}); - m_stdoutFile->seek(pos); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("/Sample Entry\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - locateCmd.execute({"locate", m_dbFile->fileName(), "Does Not Exist"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->reset(); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("No results for that search term.\n")); - - // write a modified database - auto db = readTestDatabase(); - QVERIFY(db); - auto* group = db->rootGroup()->findGroupByPath("/General/"); - QVERIFY(group); - auto* entry = new Entry(); - entry->setUuid(QUuid::createUuid()); - entry->setTitle("New Entry"); - group->addEntry(entry); - TemporaryFile tmpFile; - tmpFile.open(); - Kdbx4Writer writer; - writer.writeDatabase(&tmpFile, db.data()); - tmpFile.close(); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - locateCmd.execute({"locate", tmpFile.fileName(), "New"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), QByteArray("/General/New Entry\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - locateCmd.execute({"locate", tmpFile.fileName(), "Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("/Sample Entry\n/General/New Entry\n/Homebanking/Subgroup/Subgroup Entry\n")); -} - -void TestCli::testMerge() -{ - Merge mergeCmd; - QVERIFY(!mergeCmd.name.isEmpty()); - QVERIFY(mergeCmd.getDescriptionLine().contains(mergeCmd.name)); - - Kdbx4Writer writer; - Kdbx4Reader reader; - - // load test database and save copies - auto db = readTestDatabase(); - QVERIFY(db); - TemporaryFile targetFile1; - targetFile1.open(); - writer.writeDatabase(&targetFile1, db.data()); - targetFile1.close(); - TemporaryFile targetFile2; - targetFile2.open(); - writer.writeDatabase(&targetFile2, db.data()); - targetFile2.close(); - - // save another copy with a different password - TemporaryFile targetFile3; - targetFile3.open(); - auto oldKey = db->key(); - auto key = QSharedPointer<CompositeKey>::create(); - key->addKey(QSharedPointer<PasswordKey>::create("b")); - db->setKey(key); - writer.writeDatabase(&targetFile3, db.data()); - targetFile3.close(); - db->setKey(oldKey); - - // then add a new entry to the in-memory database and save another copy - auto* entry = new Entry(); - entry->setUuid(QUuid::createUuid()); - entry->setTitle("Some Website"); - entry->setPassword("secretsecretsecret"); - auto* group = db->rootGroup()->findGroupByPath("/Internet/"); - QVERIFY(group); - group->addEntry(entry); - TemporaryFile sourceFile; - sourceFile.open(); - writer.writeDatabase(&sourceFile, db.data()); - sourceFile.close(); - - qint64 pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - mergeCmd.execute({"merge", "-s", targetFile1.fileName(), sourceFile.fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); - m_stderrFile->reset(); - QList<QByteArray> outLines1 = m_stdoutFile->readAll().split('\n'); - QCOMPARE(outLines1.at(0).split('[').at(0), QByteArray("\tOverwriting Internet ")); - QCOMPARE(outLines1.at(1).split('[').at(0), QByteArray("\tCreating missing Some Website ")); - QCOMPARE(outLines1.at(2), - QString("Successfully merged %1 into %2.").arg(sourceFile.fileName(), targetFile1.fileName()).toUtf8()); - - QFile readBack(targetFile1.fileName()); - readBack.open(QIODevice::ReadOnly); - auto mergedDb = QSharedPointer<Database>::create(); - reader.readDatabase(&readBack, oldKey, mergedDb.data()); - readBack.close(); - QVERIFY(mergedDb); - auto* entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website"); - QVERIFY(entry1); - QCOMPARE(entry1->title(), QString("Some Website")); - QCOMPARE(entry1->password(), QString("secretsecretsecret")); - - // the dry run option should not modify the target database. - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - mergeCmd.execute({"merge", "--dry-run", "-s", targetFile2.fileName(), sourceFile.fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); - m_stderrFile->reset(); - QList<QByteArray> outLines2 = m_stdoutFile->readAll().split('\n'); - QCOMPARE(outLines2.at(0).split('[').at(0), QByteArray("\tOverwriting Internet ")); - QCOMPARE(outLines2.at(1).split('[').at(0), QByteArray("\tCreating missing Some Website ")); - QCOMPARE(outLines2.at(2), QByteArray("Database was not modified by merge operation.")); - - QFile readBack2(targetFile2.fileName()); - readBack2.open(QIODevice::ReadOnly); - mergedDb = QSharedPointer<Database>::create(); - reader.readDatabase(&readBack2, oldKey, mergedDb.data()); - readBack2.close(); - QVERIFY(mergedDb); - entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website"); - QVERIFY(!entry1); - - // the dry run option can be used with the quiet option - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - mergeCmd.execute({"merge", "--dry-run", "-s", "-q", targetFile2.fileName(), sourceFile.fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); - m_stderrFile->reset(); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - - readBack2.setFileName(targetFile2.fileName()); - readBack2.open(QIODevice::ReadOnly); - mergedDb = QSharedPointer<Database>::create(); - reader.readDatabase(&readBack2, oldKey, mergedDb.data()); - readBack2.close(); - QVERIFY(mergedDb); - entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website"); - QVERIFY(!entry1); - - // try again with different passwords for both files - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("b"); - Utils::Test::setNextPassword("a"); - mergeCmd.execute({"merge", targetFile3.fileName(), sourceFile.fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); - m_stdoutFile->readLine(); - QList<QByteArray> outLines3 = m_stdoutFile->readAll().split('\n'); - QCOMPARE(outLines3.at(2), - QString("Successfully merged %1 into %2.").arg(sourceFile.fileName(), targetFile3.fileName()).toUtf8()); - - readBack.setFileName(targetFile3.fileName()); - readBack.open(QIODevice::ReadOnly); - mergedDb = QSharedPointer<Database>::create(); - reader.readDatabase(&readBack, key, mergedDb.data()); - readBack.close(); - QVERIFY(mergedDb); - entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website"); - QVERIFY(entry1); - QCOMPARE(entry1->title(), QString("Some Website")); - QCOMPARE(entry1->password(), QString("secretsecretsecret")); - - // making sure that the message is different if the database was not - // modified by the merge operation. - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - mergeCmd.execute({"merge", "-s", sourceFile.fileName(), sourceFile.fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Database was not modified by merge operation.\n")); - - // Quiet option - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - mergeCmd.execute({"merge", "-q", "-s", sourceFile.fileName(), sourceFile.fileName()}); - m_stdoutFile->seek(pos); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - - // Quiet option without the -s option - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - Utils::Test::setNextPassword("a"); - mergeCmd.execute({"merge", "-q", sourceFile.fileName(), sourceFile.fileName()}); - m_stdoutFile->seek(pos); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); -} - -void TestCli::testMove() -{ - Move moveCmd; - QVERIFY(!moveCmd.name.isEmpty()); - QVERIFY(moveCmd.getDescriptionLine().contains(moveCmd.name)); - - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - moveCmd.execute({"mv", m_dbFile->fileName(), "invalid_entry_path", "invalid_group_path"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - m_stdoutFile->readLine(); // skip prompt line - QCOMPARE(m_stdoutFile->readLine(), QByteArray("")); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Could not find entry with path invalid_entry_path.\n")); - - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - moveCmd.execute({"mv", m_dbFile->fileName(), "Sample Entry", "invalid_group_path"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - m_stdoutFile->readLine(); // skip prompt line - QCOMPARE(m_stdoutFile->readLine(), QByteArray("")); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Could not find group with path invalid_group_path.\n")); - - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - moveCmd.execute({"mv", m_dbFile->fileName(), "Sample Entry", "General/"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - m_stdoutFile->readLine(); // skip prompt line - QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully moved entry Sample Entry to group General/.\n")); - QCOMPARE(m_stderrFile->readLine(), QByteArray("")); - - auto db = readTestDatabase(); - auto* entry = db->rootGroup()->findEntryByPath("General/Sample Entry"); - QVERIFY(entry); - - // Test that not modified if the same group is destination. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - moveCmd.execute({"mv", m_dbFile->fileName(), "General/Sample Entry", "General/"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - m_stdoutFile->readLine(); // skip prompt line - QCOMPARE(m_stdoutFile->readLine(), QByteArray("")); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Entry is already in group General/.\n")); - - // sanity check - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("General/Sample Entry"); - QVERIFY(entry); -} - -void TestCli::testRemove() -{ - Remove removeCmd; - QVERIFY(!removeCmd.name.isEmpty()); - QVERIFY(removeCmd.getDescriptionLine().contains(removeCmd.name)); - - Kdbx3Reader reader; - Kdbx3Writer writer; - - // load test database and save a copy with disabled recycle bin - auto db = readTestDatabase(); - QVERIFY(db); - TemporaryFile fileCopy; - fileCopy.open(); - db->metadata()->setRecycleBinEnabled(false); - writer.writeDatabase(&fileCopy, db.data()); - fileCopy.close(); - - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - - // delete entry and verify - Utils::Test::setNextPassword("a"); - removeCmd.execute({"rm", m_dbFile->fileName(), "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully recycled entry Sample Entry.\n")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - - auto key = QSharedPointer<CompositeKey>::create(); - key->addKey(QSharedPointer<PasswordKey>::create("a")); - QFile readBack(m_dbFile->fileName()); - readBack.open(QIODevice::ReadOnly); - auto readBackDb = QSharedPointer<Database>::create(); - reader.readDatabase(&readBack, key, readBackDb.data()); - readBack.close(); - QVERIFY(readBackDb); - QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry")); - QVERIFY(readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin")))); - - pos = m_stdoutFile->pos(); - pos = m_stdoutFile->pos(); - - // try again, this time without recycle bin - Utils::Test::setNextPassword("a"); - removeCmd.execute({"rm", fileCopy.fileName(), "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully deleted entry Sample Entry.\n")); - - readBack.setFileName(fileCopy.fileName()); - readBack.open(QIODevice::ReadOnly); - readBackDb = QSharedPointer<Database>::create(); - reader.readDatabase(&readBack, key, readBackDb.data()); - readBack.close(); - QVERIFY(readBackDb); - QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry")); - QVERIFY(!readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin")))); - - // finally, try deleting a non-existent entry - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - removeCmd.execute({"rm", fileCopy.fileName(), "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry /Sample Entry not found.\n")); - - // try deleting a directory, should fail - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - removeCmd.execute({"rm", fileCopy.fileName(), "/General"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry /General not found.\n")); -} - -void TestCli::testRemoveGroup() -{ - RemoveGroup removeGroupCmd; - QVERIFY(!removeGroupCmd.name.isEmpty()); - QVERIFY(removeGroupCmd.getDescriptionLine().contains(removeGroupCmd.name)); - - Kdbx3Reader reader; - Kdbx3Writer writer; - - // try deleting a directory, should recycle it first. - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - removeGroupCmd.execute({"rmdir", m_dbFile->fileName(), "/General"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully recycled group /General.\n")); - - auto db = readTestDatabase(); - auto* group = db->rootGroup()->findGroupByPath("General"); - QVERIFY(!group); - - // try deleting a directory again, should delete it permanently. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - removeGroupCmd.execute({"rmdir", m_dbFile->fileName(), "Recycle Bin/General"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully deleted group Recycle Bin/General.\n")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - - db = readTestDatabase(); - group = db->rootGroup()->findGroupByPath("Recycle Bin/General"); - QVERIFY(!group); - - // try deleting an invalid group, should fail. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - removeGroupCmd.execute({"rmdir", m_dbFile->fileName(), "invalid"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Group invalid not found.\n")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - - // Should fail to remove the root group. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - removeGroupCmd.execute({"rmdir", m_dbFile->fileName(), "/"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Cannot remove root group from database.\n")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); -} - -void TestCli::testRemoveQuiet() -{ - Remove removeCmd; - QVERIFY(!removeCmd.name.isEmpty()); - QVERIFY(removeCmd.getDescriptionLine().contains(removeCmd.name)); - - Kdbx3Reader reader; - Kdbx3Writer writer; - - qint64 pos = m_stdoutFile->pos(); - - // delete entry and verify - Utils::Test::setNextPassword("a"); - removeCmd.execute({"rm", "-q", m_dbFile->fileName(), "/Sample Entry"}); - m_stdoutFile->seek(pos); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - - auto key = QSharedPointer<CompositeKey>::create(); - key->addKey(QSharedPointer<PasswordKey>::create("a")); - QFile readBack(m_dbFile->fileName()); - readBack.open(QIODevice::ReadOnly); - auto readBackDb = QSharedPointer<Database>::create(); - reader.readDatabase(&readBack, key, readBackDb.data()); - readBack.close(); - QVERIFY(readBackDb); - QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry")); - QVERIFY(readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin")))); - - pos = m_stdoutFile->pos(); - - // remove the entry completely - Utils::Test::setNextPassword("a"); - removeCmd.execute({"rm", "-q", m_dbFile->fileName(), QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin"))}); - m_stdoutFile->seek(pos); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - - readBack.setFileName(m_dbFile->fileName()); - readBack.open(QIODevice::ReadOnly); - readBackDb = QSharedPointer<Database>::create(); - reader.readDatabase(&readBack, key, readBackDb.data()); - readBack.close(); - QVERIFY(readBackDb); - QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry")); - QVERIFY(!readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin")))); -} - -void TestCli::testShow() -{ - Show showCmd; - QVERIFY(!showCmd.name.isEmpty()); - QVERIFY(showCmd.getDescriptionLine().contains(showCmd.name)); - - Utils::Test::setNextPassword("a"); - showCmd.execute({"show", m_dbFile->fileName(), "/Sample Entry"}); - m_stdoutFile->reset(); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("Title: Sample Entry\n" - "UserName: User Name\n" - "Password: Password\n" - "URL: http://www.somesite.com/\n" - "Notes: Notes\n")); - - qint64 pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - showCmd.execute({"show", m_dbFile->fileName(), "-q", "/Sample Entry"}); - m_stdoutFile->seek(pos); - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("Title: Sample Entry\n" - "UserName: User Name\n" - "Password: Password\n" - "URL: http://www.somesite.com/\n" - "Notes: Notes\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - showCmd.execute({"show", "-a", "Title", m_dbFile->fileName(), "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Sample Entry\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - showCmd.execute({"show", "-a", "Title", "-a", "URL", m_dbFile->fileName(), "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("Sample Entry\n" - "http://www.somesite.com/\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - showCmd.execute({"show", "-a", "DoesNotExist", m_dbFile->fileName(), "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->reset(); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("ERROR: unknown attribute DoesNotExist.\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - showCmd.execute({"show", "-t", m_dbFile->fileName(), "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QVERIFY(isTOTP(m_stdoutFile->readAll())); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - showCmd.execute({"show", "-a", "Title", m_dbFile->fileName(), "--totp", "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readLine(), QByteArray("Sample Entry\n")); - QVERIFY(isTOTP(m_stdoutFile->readAll())); - - pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - showCmd.execute({"show", m_dbFile2->fileName(), "--totp", "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry with path /Sample Entry has no TOTP set up.\n")); -} - -void TestCli::testInvalidDbFiles() -{ - Show showCmd; - QString nonExistentDbPath("/foo/bar/baz"); - QString directoryName("/"); - - qint64 pos = m_stderrFile->pos(); - showCmd.execute({"show", nonExistentDbPath, "-q", "/Sample Entry"}); - m_stderrFile->seek(pos); - QCOMPARE(QString(m_stderrFile->readAll()), - QObject::tr("Failed to open database file %1: not found").arg(nonExistentDbPath) + "\n"); - - pos = m_stderrFile->pos(); - showCmd.execute({"show", directoryName, "-q", "whatever"}); - m_stderrFile->seek(pos); - QCOMPARE(QString(m_stderrFile->readAll()), - QObject::tr("Failed to open database file %1: not a plain file").arg(directoryName) + "\n"); - - // Create a write-only file and try to open it. - // QFileInfo.isReadable returns 'true' on Windows, even after the call to - // setPermissions(WriteOwner) and with NTFS permissions enabled, so this - // check doesn't work. -#if !defined(Q_OS_WIN) - QTemporaryFile tempFile; - QVERIFY(tempFile.open()); - QString path = QFileInfo(tempFile).absoluteFilePath(); - QVERIFY(tempFile.setPermissions(QFileDevice::WriteOwner)); - pos = m_stderrFile->pos(); - showCmd.execute({"show", path, "some entry"}); - m_stderrFile->seek(pos); - QCOMPARE(QString(m_stderrFile->readAll()), - QObject::tr("Failed to open database file %1: not readable").arg(path) + "\n"); -#endif // Q_OS_WIN -} - -/** - * Secret key for the YubiKey slot used by the unit test is - * 1c e3 0f d7 8d 20 dc fa 40 b5 0c 18 77 9a fb 0f 02 28 8d b7 - * This secret should be configured at slot 2, and the slot - * should be configured as passive. - */ -void TestCli::testYubiKeyOption() -{ - if (!YubiKey::instance()->init()) { - QSKIP("Unable to connect to YubiKey"); - } - - QString errorMessage; - bool isBlocking = YubiKey::instance()->checkSlotIsBlocking(2, errorMessage); - if (isBlocking && errorMessage.isEmpty()) { - QSKIP("Skipping YubiKey in press mode."); - } - - QByteArray challenge("CLITest"); - QByteArray response; - YubiKey::instance()->challenge(2, false, challenge, response); - QByteArray expected("\xA2\x3B\x94\x00\xBE\x47\x9A\x30\xA9\xEB\x50\x9B\x85\x56\x5B\x6B\x30\x25\xB4\x8E", 20); - QVERIFY2(response == expected, "YubiKey Slot 2 is not configured with correct secret key."); - - List listCmd; - Add addCmd; - - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-y", "2", m_yubiKeyProtectedDbFile->fileName()}); - m_stdoutFile->reset(); - m_stderrFile->reset(); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("entry1\n" - "entry2\n")); - - // Should raise an error with no yubikey slot. - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", m_yubiKeyProtectedDbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readLine(), - QByteArray("Error while reading the database: Invalid credentials were provided, please try again.\n")); - QCOMPARE(m_stderrFile->readLine(), - QByteArray("If this reoccurs, then your database file may be corrupt. (HMAC mismatch)\n")); - - // Should raise an error if yubikey slot is not a string - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-y", "invalidslot", m_yubiKeyProtectedDbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll().split(':').at(0), QByteArray("Invalid YubiKey slot invalidslot\n")); - - // Should raise an error if yubikey slot is invalid. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-y", "3", m_yubiKeyProtectedDbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll().split(':').at(0), QByteArray("Invalid YubiKey slot 3\n")); -} +/* + * Copyright (C) 2019 KeePassXC Team <team@keepassxc.org> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +#include "TestCli.h" + +#include "config-keepassx-tests.h" +#include "core/Bootstrap.h" +#include "core/Config.h" +#include "core/Global.h" +#include "core/PasswordGenerator.h" +#include "core/Tools.h" +#include "crypto/Crypto.h" +#include "format/Kdbx3Reader.h" +#include "format/Kdbx3Writer.h" +#include "format/Kdbx4Reader.h" +#include "format/Kdbx4Writer.h" +#include "format/KdbxXmlReader.h" +#include "format/KeePass2.h" + +#include "cli/Add.h" +#include "cli/AddGroup.h" +#include "cli/Analyze.h" +#include "cli/Clip.h" +#include "cli/Command.h" +#include "cli/Create.h" +#include "cli/Diceware.h" +#include "cli/Edit.h" +#include "cli/Estimate.h" +#include "cli/Export.h" +#include "cli/Generate.h" +#include "cli/Help.h" +#include "cli/List.h" +#include "cli/Locate.h" +#include "cli/Merge.h" +#include "cli/Move.h" +#include "cli/Open.h" +#include "cli/Remove.h" +#include "cli/RemoveGroup.h" +#include "cli/Show.h" +#include "cli/Utils.h" + +#include <QClipboard> +#include <QFile> +#include <QFuture> +#include <QSet> +#include <QTextStream> +#include <QtConcurrent> + +#include <cstdio> + +QTEST_MAIN(TestCli) + +QSharedPointer<Database> globalCurrentDatabase; + +void TestCli::initTestCase() +{ + QVERIFY(Crypto::init()); + + Config::createTempFileInstance(); + Bootstrap::bootstrapApplication(); + + // Load the NewDatabase.kdbx file into temporary storage + QFile sourceDbFile(QString(KEEPASSX_TEST_DATA_DIR).append("/NewDatabase.kdbx")); + QVERIFY(sourceDbFile.open(QIODevice::ReadOnly)); + QVERIFY(Tools::readAllFromDevice(&sourceDbFile, m_dbData)); + sourceDbFile.close(); + + // Load the NewDatabase2.kdbx file into temporary storage + QFile sourceDbFile2(QString(KEEPASSX_TEST_DATA_DIR).append("/NewDatabase2.kdbx")); + QVERIFY(sourceDbFile2.open(QIODevice::ReadOnly)); + QVERIFY(Tools::readAllFromDevice(&sourceDbFile2, m_dbData2)); + sourceDbFile2.close(); + + // Load the KeyFileProtected.kdbx file into temporary storage + QFile sourceDbFile3(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtected.kdbx")); + QVERIFY(sourceDbFile3.open(QIODevice::ReadOnly)); + QVERIFY(Tools::readAllFromDevice(&sourceDbFile3, m_keyFileProtectedDbData)); + sourceDbFile3.close(); + + // Load the KeyFileProtectedNoPassword.kdbx file into temporary storage + QFile sourceDbFile4(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtectedNoPassword.kdbx")); + QVERIFY(sourceDbFile4.open(QIODevice::ReadOnly)); + QVERIFY(Tools::readAllFromDevice(&sourceDbFile4, m_keyFileProtectedNoPasswordDbData)); + sourceDbFile4.close(); + + QFile sourceDbFileYubiKeyProtected(QString(KEEPASSX_TEST_DATA_DIR).append("/YubiKeyProtectedPasswords.kdbx")); + QVERIFY(sourceDbFileYubiKeyProtected.open(QIODevice::ReadOnly)); + QVERIFY(Tools::readAllFromDevice(&sourceDbFileYubiKeyProtected, m_yubiKeyProtectedDbData)); + sourceDbFileYubiKeyProtected.close(); +} + +void TestCli::init() +{ + m_dbFile.reset(new TemporaryFile()); + m_dbFile->open(); + m_dbFile->write(m_dbData); + m_dbFile->close(); + + m_dbFile2.reset(new TemporaryFile()); + m_dbFile2->open(); + m_dbFile2->write(m_dbData2); + m_dbFile2->close(); + + m_keyFileProtectedDbFile.reset(new TemporaryFile()); + m_keyFileProtectedDbFile->open(); + m_keyFileProtectedDbFile->write(m_keyFileProtectedDbData); + m_keyFileProtectedDbFile->close(); + + m_keyFileProtectedNoPasswordDbFile.reset(new TemporaryFile()); + m_keyFileProtectedNoPasswordDbFile->open(); + m_keyFileProtectedNoPasswordDbFile->write(m_keyFileProtectedNoPasswordDbData); + m_keyFileProtectedNoPasswordDbFile->close(); + + m_yubiKeyProtectedDbFile.reset(new TemporaryFile()); + m_yubiKeyProtectedDbFile->open(); + m_yubiKeyProtectedDbFile->write(m_yubiKeyProtectedDbData); + m_yubiKeyProtectedDbFile->close(); + + m_stdinFile.reset(new TemporaryFile()); + m_stdinFile->open(); + m_stdinHandle = fdopen(m_stdinFile->handle(), "r+"); + Utils::STDIN = m_stdinHandle; + + m_stdoutFile.reset(new TemporaryFile()); + m_stdoutFile->open(); + m_stdoutHandle = fdopen(m_stdoutFile->handle(), "r+"); + Utils::STDOUT = m_stdoutHandle; + + m_stderrFile.reset(new TemporaryFile()); + m_stderrFile->open(); + m_stderrHandle = fdopen(m_stderrFile->handle(), "r+"); + Utils::STDERR = m_stderrHandle; +} + +void TestCli::cleanup() +{ + m_dbFile.reset(); + + m_dbFile2.reset(); + + m_stdinFile.reset(); + m_stdinHandle = stdin; + Utils::STDIN = stdin; + + m_stdoutFile.reset(); + Utils::STDOUT = stdout; + m_stdoutHandle = stdout; + + m_stderrFile.reset(); + m_stderrHandle = stderr; + Utils::STDERR = stderr; +} + +void TestCli::cleanupTestCase() +{ +} + +QSharedPointer<Database> TestCli::readTestDatabase() const +{ + Utils::Test::setNextPassword("a"); + auto db = QSharedPointer<Database>(Utils::unlockDatabase(m_dbFile->fileName(), true, "", "", m_stdoutHandle)); + m_stdoutFile->seek(ftell(m_stdoutHandle)); // re-synchronize handles + return db; +} + +void TestCli::testBatchCommands() +{ + Commands::setupCommands(false); + QVERIFY(Commands::getCommand("add")); + QVERIFY(Commands::getCommand("analyze")); + QVERIFY(Commands::getCommand("clip")); + QVERIFY(Commands::getCommand("close")); + QVERIFY(Commands::getCommand("create")); + QVERIFY(Commands::getCommand("diceware")); + QVERIFY(Commands::getCommand("edit")); + QVERIFY(Commands::getCommand("estimate")); + QVERIFY(Commands::getCommand("export")); + QVERIFY(Commands::getCommand("generate")); + QVERIFY(Commands::getCommand("help")); + QVERIFY(Commands::getCommand("locate")); + QVERIFY(Commands::getCommand("ls")); + QVERIFY(Commands::getCommand("merge")); + QVERIFY(Commands::getCommand("mkdir")); + QVERIFY(Commands::getCommand("mv")); + QVERIFY(Commands::getCommand("open")); + QVERIFY(Commands::getCommand("rm")); + QVERIFY(Commands::getCommand("rmdir")); + QVERIFY(Commands::getCommand("show")); + QVERIFY(!Commands::getCommand("doesnotexist")); + QCOMPARE(Commands::getCommands().size(), 20); +} + +void TestCli::testInteractiveCommands() +{ + Commands::setupCommands(true); + QVERIFY(Commands::getCommand("add")); + QVERIFY(Commands::getCommand("analyze")); + QVERIFY(Commands::getCommand("clip")); + QVERIFY(Commands::getCommand("close")); + QVERIFY(Commands::getCommand("create")); + QVERIFY(Commands::getCommand("diceware")); + QVERIFY(Commands::getCommand("edit")); + QVERIFY(Commands::getCommand("estimate")); + QVERIFY(Commands::getCommand("exit")); + QVERIFY(Commands::getCommand("generate")); + QVERIFY(Commands::getCommand("help")); + QVERIFY(Commands::getCommand("locate")); + QVERIFY(Commands::getCommand("ls")); + QVERIFY(Commands::getCommand("merge")); + QVERIFY(Commands::getCommand("mkdir")); + QVERIFY(Commands::getCommand("mv")); + QVERIFY(Commands::getCommand("open")); + QVERIFY(Commands::getCommand("quit")); + QVERIFY(Commands::getCommand("rm")); + QVERIFY(Commands::getCommand("rmdir")); + QVERIFY(Commands::getCommand("show")); + QVERIFY(!Commands::getCommand("doesnotexist")); + QCOMPARE(Commands::getCommands().size(), 21); +} + +void TestCli::testAdd() +{ + Add addCmd; + QVERIFY(!addCmd.name.isEmpty()); + QVERIFY(addCmd.getDescriptionLine().contains(addCmd.name)); + + Utils::Test::setNextPassword("a"); + addCmd.execute({"add", + "-u", + "newuser", + "--url", + "https://example.com/", + "-g", + "-L", + "20", + m_dbFile->fileName(), + "/newuser-entry"}); + m_stderrFile->reset(); + m_stdoutFile->reset(); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added entry newuser-entry.\n")); + + auto db = readTestDatabase(); + auto* entry = db->rootGroup()->findEntryByPath("/newuser-entry"); + QVERIFY(entry); + QCOMPARE(entry->username(), QString("newuser")); + QCOMPARE(entry->url(), QString("https://example.com/")); + QCOMPARE(entry->password().size(), 20); + + // Quiet option + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + addCmd.execute({"add", "-q", "-u", "newuser", "-g", "-L", "20", m_dbFile->fileName(), "/newentry-quiet"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("/newentry-quiet"); + QVERIFY(entry); + QCOMPARE(entry->password().size(), 20); + + Utils::Test::setNextPassword("a"); + Utils::Test::setNextPassword("newpassword"); + addCmd.execute( + {"add", "-u", "newuser2", "--url", "https://example.net/", "-p", m_dbFile->fileName(), "/newuser-entry2"}); + + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("/newuser-entry2"); + QVERIFY(entry); + QCOMPARE(entry->username(), QString("newuser2")); + QCOMPARE(entry->url(), QString("https://example.net/")); + QCOMPARE(entry->password(), QString("newpassword")); + + // Password generation options + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + addCmd.execute({"add", "-u", "newuser3", "-g", "-L", "34", m_dbFile->fileName(), "/newuser-entry3"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added entry newuser-entry3.\n")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("/newuser-entry3"); + QVERIFY(entry); + QCOMPARE(entry->username(), QString("newuser3")); + QCOMPARE(entry->password().size(), 34); + QRegularExpression defaultPasswordClassesRegex("^[a-zA-Z0-9]+$"); + QVERIFY(defaultPasswordClassesRegex.match(entry->password()).hasMatch()); + + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + addCmd.execute({"add", + "-u", + "newuser4", + "-g", + "-L", + "20", + "--every-group", + "-s", + "-n", + "-U", + "-l", + m_dbFile->fileName(), + "/newuser-entry4"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added entry newuser-entry4.\n")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("/newuser-entry4"); + QVERIFY(entry); + QCOMPARE(entry->username(), QString("newuser4")); + QCOMPARE(entry->password().size(), 20); + QVERIFY(!defaultPasswordClassesRegex.match(entry->password()).hasMatch()); +} + +void TestCli::testAddGroup() +{ + AddGroup addGroupCmd; + QVERIFY(!addGroupCmd.name.isEmpty()); + QVERIFY(addGroupCmd.getDescriptionLine().contains(addGroupCmd.name)); + + Utils::Test::setNextPassword("a"); + addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/new_group"}); + m_stderrFile->reset(); + m_stdoutFile->reset(); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added group new_group.\n")); + + auto db = readTestDatabase(); + auto* group = db->rootGroup()->findGroupByPath("new_group"); + QVERIFY(group); + QCOMPARE(group->name(), QString("new_group")); + + // Trying to add the same group should fail. + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/new_group"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Group /new_group already exists!\n")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + + // Should be able to add groups down the tree. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/new_group/newer_group"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added group newer_group.\n")); + + db = readTestDatabase(); + group = db->rootGroup()->findGroupByPath("new_group/newer_group"); + QVERIFY(group); + QCOMPARE(group->name(), QString("newer_group")); + + // Should fail if the path is invalid. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/invalid_group/newer_group"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Group /invalid_group not found.\n")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + + // Should fail to add the root group. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Group / already exists!\n")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); +} + +bool isTOTP(const QString& value) +{ + QString val = value.trimmed(); + if (val.length() < 5 || val.length() > 6) { + return false; + } + for (int i = 0; i < val.length(); ++i) { + if (!value[i].isDigit()) { + return false; + } + } + return true; +} + +void TestCli::testAnalyze() +{ + Analyze analyzeCmd; + QVERIFY(!analyzeCmd.name.isEmpty()); + QVERIFY(analyzeCmd.getDescriptionLine().contains(analyzeCmd.name)); + + const QString hibpPath = QString(KEEPASSX_TEST_DATA_DIR).append("/hibp.txt"); + + Utils::Test::setNextPassword("a"); + analyzeCmd.execute({"analyze", "--hibp", hibpPath, m_dbFile->fileName()}); + m_stdoutFile->reset(); + m_stdoutFile->readLine(); // skip password prompt + auto output = m_stdoutFile->readAll(); + QVERIFY(output.contains("Sample Entry") && output.contains("123")); +} + +void TestCli::testClip() +{ + QClipboard* clipboard = QGuiApplication::clipboard(); + clipboard->clear(); + + Clip clipCmd; + QVERIFY(!clipCmd.name.isEmpty()); + QVERIFY(clipCmd.getDescriptionLine().contains(clipCmd.name)); + + // Password + Utils::Test::setNextPassword("a"); + clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry"}); + + m_stderrFile->reset(); + m_stdoutFile->reset(); + QString errorOutput(m_stderrFile->readAll()); + + if (errorOutput.contains("Unable to start program") + || errorOutput.contains("No program defined for clipboard manipulation")) { + QSKIP("Clip test skipped due to missing clipboard tool"); + } + + QCOMPARE(clipboard->text(), QString("Password")); + m_stdoutFile->readLine(); // skip prompt line + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Entry's password copied to the clipboard!\n")); + + // Quiet option + qint64 pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry", "-q"}); + m_stdoutFile->seek(pos); + // Output should be empty when quiet option is set. + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(clipboard->text(), QString("Password")); + + // TOTP + Utils::Test::setNextPassword("a"); + clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry", "--totp"}); + + QVERIFY(isTOTP(clipboard->text())); + + // Password with timeout + Utils::Test::setNextPassword("a"); + // clang-format off + QFuture<void> future = QtConcurrent::run(&clipCmd, + static_cast<int(Clip::*)(const QStringList&)>(&DatabaseCommand::execute), + QStringList{"clip", m_dbFile->fileName(), "/Sample Entry", "1"}); + // clang-format on + + QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString("Password"), 500); + QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString(""), 1500); + + future.waitForFinished(); + + // TOTP with timeout + Utils::Test::setNextPassword("a"); + future = QtConcurrent::run(&clipCmd, + static_cast<int (Clip::*)(const QStringList&)>(&DatabaseCommand::execute), + QStringList{"clip", m_dbFile->fileName(), "/Sample Entry", "1", "-t"}); + + QTRY_VERIFY_WITH_TIMEOUT(isTOTP(clipboard->text()), 500); + QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString(""), 1500); + + future.waitForFinished(); + + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + clipCmd.execute({"clip", m_dbFile->fileName(), "--totp", "/Sample Entry", "0"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Invalid timeout value 0.\n")); + + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + clipCmd.execute({"clip", m_dbFile->fileName(), "--totp", "/Sample Entry", "bleuh"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Invalid timeout value bleuh.\n")); + + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + clipCmd.execute({"clip", m_dbFile2->fileName(), "--totp", "/Sample Entry"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry with path /Sample Entry has no TOTP set up.\n")); +} + +void TestCli::testCreate() +{ + Create createCmd; + QVERIFY(!createCmd.name.isEmpty()); + QVERIFY(createCmd.getDescriptionLine().contains(createCmd.name)); + + QScopedPointer<QTemporaryDir> testDir(new QTemporaryDir()); + + QString databaseFilename = testDir->path() + "testCreate1.kdbx"; + // Password + Utils::Test::setNextPassword("a"); + createCmd.execute({"create", databaseFilename}); + + m_stderrFile->reset(); + m_stdoutFile->reset(); + + QCOMPARE(m_stdoutFile->readLine(), + QByteArray("Insert password to encrypt database (Press enter to leave blank): \n")); + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully created new database.\n")); + + Utils::Test::setNextPassword("a"); + auto db = QSharedPointer<Database>(Utils::unlockDatabase(databaseFilename, true, "", "", Utils::DEVNULL)); + QVERIFY(db); + + // Should refuse to create the database if it already exists. + qint64 pos = m_stdoutFile->pos(); + qint64 errPos = m_stderrFile->pos(); + createCmd.execute({"create", databaseFilename}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(errPos); + // Output should be empty when there is an error. + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QString errorMessage = QString("File " + databaseFilename + " already exists.\n"); + QCOMPARE(m_stderrFile->readAll(), errorMessage.toUtf8()); + + // Testing with keyfile creation + QString databaseFilename2 = testDir->path() + "testCreate2.kdbx"; + QString keyfilePath = testDir->path() + "keyfile.txt"; + pos = m_stdoutFile->pos(); + errPos = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + createCmd.execute({"create", databaseFilename2, "-k", keyfilePath}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(errPos); + + QCOMPARE(m_stdoutFile->readLine(), + QByteArray("Insert password to encrypt database (Press enter to leave blank): \n")); + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully created new database.\n")); + + Utils::Test::setNextPassword("a"); + auto db2 = + QSharedPointer<Database>(Utils::unlockDatabase(databaseFilename2, true, keyfilePath, "", Utils::DEVNULL)); + QVERIFY(db2); + + // Testing with existing keyfile + QString databaseFilename3 = testDir->path() + "testCreate3.kdbx"; + pos = m_stdoutFile->pos(); + errPos = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + createCmd.execute({"create", databaseFilename3, "-k", keyfilePath}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(errPos); + + QCOMPARE(m_stdoutFile->readLine(), + QByteArray("Insert password to encrypt database (Press enter to leave blank): \n")); + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully created new database.\n")); + + Utils::Test::setNextPassword("a"); + auto db3 = + QSharedPointer<Database>(Utils::unlockDatabase(databaseFilename3, true, keyfilePath, "", Utils::DEVNULL)); + QVERIFY(db3); +} + +void TestCli::testDiceware() +{ + Diceware dicewareCmd; + QVERIFY(!dicewareCmd.name.isEmpty()); + QVERIFY(dicewareCmd.getDescriptionLine().contains(dicewareCmd.name)); + + dicewareCmd.execute({"diceware"}); + m_stdoutFile->reset(); + QString passphrase(m_stdoutFile->readLine()); + QVERIFY(!passphrase.isEmpty()); + + dicewareCmd.execute({"diceware", "-W", "2"}); + m_stdoutFile->seek(passphrase.toLatin1().size()); + passphrase = m_stdoutFile->readLine(); + QCOMPARE(passphrase.split(" ").size(), 2); + + auto pos = m_stdoutFile->pos(); + dicewareCmd.execute({"diceware", "-W", "10"}); + m_stdoutFile->seek(pos); + passphrase = m_stdoutFile->readLine(); + QCOMPARE(passphrase.split(" ").size(), 10); + + // Testing with invalid word count + auto posErr = m_stderrFile->pos(); + dicewareCmd.execute({"diceware", "-W", "-10"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid word count -10\n")); + + // Testing with invalid word count format + posErr = m_stderrFile->pos(); + dicewareCmd.execute({"diceware", "-W", "bleuh"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid word count bleuh\n")); + + TemporaryFile wordFile; + wordFile.open(); + for (int i = 0; i < 4500; ++i) { + wordFile.write(QString("word" + QString::number(i) + "\n").toLatin1()); + } + wordFile.close(); + + pos = m_stdoutFile->pos(); + dicewareCmd.execute({"diceware", "-W", "11", "-w", wordFile.fileName()}); + m_stdoutFile->seek(pos); + passphrase = m_stdoutFile->readLine(); + const auto words = passphrase.split(" "); + QCOMPARE(words.size(), 11); + QRegularExpression regex("^word\\d+$"); + for (const auto& word : words) { + QVERIFY2(regex.match(word).hasMatch(), qPrintable("Word " + word + " was not on the word list")); + } + + TemporaryFile smallWordFile; + smallWordFile.open(); + for (int i = 0; i < 50; ++i) { + smallWordFile.write(QString("word" + QString::number(i) + "\n").toLatin1()); + } + smallWordFile.close(); + + posErr = m_stderrFile->pos(); + dicewareCmd.execute({"diceware", "-W", "11", "-w", smallWordFile.fileName()}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readLine(), QByteArray("The word list is too small (< 1000 items)\n")); +} + +void TestCli::testEdit() +{ + Edit editCmd; + QVERIFY(!editCmd.name.isEmpty()); + QVERIFY(editCmd.getDescriptionLine().contains(editCmd.name)); + + Utils::Test::setNextPassword("a"); + // clang-format off + editCmd.execute({"edit", "-u", "newuser", "--url", "https://otherurl.example.com/", "-t", "newtitle", m_dbFile->fileName(), "/Sample Entry"}); + // clang-format on + m_stdoutFile->reset(); + m_stdoutFile->readLine(); // skip prompt line + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully edited entry newtitle.\n")); + + auto db = readTestDatabase(); + auto* entry = db->rootGroup()->findEntryByPath("/newtitle"); + QVERIFY(entry); + QCOMPARE(entry->username(), QString("newuser")); + QCOMPARE(entry->url(), QString("https://otherurl.example.com/")); + QCOMPARE(entry->password(), QString("Password")); + + // Quiet option + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + editCmd.execute({"edit", m_dbFile->fileName(), "-q", "-t", "newertitle", "/newtitle"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + + Utils::Test::setNextPassword("a"); + editCmd.execute({"edit", "-g", m_dbFile->fileName(), "/newertitle"}); + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("/newertitle"); + QVERIFY(entry); + QCOMPARE(entry->username(), QString("newuser")); + QCOMPARE(entry->url(), QString("https://otherurl.example.com/")); + QVERIFY(!entry->password().isEmpty()); + QVERIFY(entry->password() != QString("Password")); + + Utils::Test::setNextPassword("a"); + editCmd.execute({"edit", "-g", "-L", "34", "-t", "evennewertitle", m_dbFile->fileName(), "/newertitle"}); + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("/evennewertitle"); + QVERIFY(entry); + QCOMPARE(entry->username(), QString("newuser")); + QCOMPARE(entry->url(), QString("https://otherurl.example.com/")); + QVERIFY(entry->password() != QString("Password")); + QCOMPARE(entry->password().size(), 34); + QRegularExpression defaultPasswordClassesRegex("^[a-zA-Z0-9]+$"); + QVERIFY(defaultPasswordClassesRegex.match(entry->password()).hasMatch()); + + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + editCmd.execute({"edit", + "-g", + "-L", + "20", + "--every-group", + "-s", + "-n", + "--upper", + "-l", + m_dbFile->fileName(), + "/evennewertitle"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully edited entry evennewertitle.\n")); + + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("/evennewertitle"); + QVERIFY(entry); + QCOMPARE(entry->password().size(), 20); + QVERIFY(!defaultPasswordClassesRegex.match(entry->password()).hasMatch()); + + Utils::Test::setNextPassword("a"); + Utils::Test::setNextPassword("newpassword"); + editCmd.execute({"edit", "-p", m_dbFile->fileName(), "/evennewertitle"}); + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("/evennewertitle"); + QVERIFY(entry); + QCOMPARE(entry->password(), QString("newpassword")); +} + +void TestCli::testEstimate_data() +{ + QTest::addColumn<QString>("input"); + QTest::addColumn<QString>("length"); + QTest::addColumn<QString>("entropy"); + QTest::addColumn<QString>("log10"); + QTest::addColumn<QStringList>("searchStrings"); + + QTest::newRow("Dictionary") << "password" + << "8" + << "1.0" + << "0.3" << QStringList{"Type: Dictionary", "\tpassword"}; + + QTest::newRow("Spatial") << "zxcv" + << "4" + << "10.3" + << "3.1" << QStringList{"Type: Spatial", "\tzxcv"}; + + QTest::newRow("Spatial(Rep)") << "sdfgsdfg" + << "8" + << "11.3" + << "3.4" << QStringList{"Type: Spatial(Rep)", "\tsdfgsdfg"}; + + QTest::newRow("Dictionary / Sequence") + << "password123" + << "11" + << "4.5" + << "1.3" << QStringList{"Type: Dictionary", "Type: Sequence", "\tpassword", "\t123"}; + + QTest::newRow("Dict+Leet") << "p455w0rd" + << "8" + << "2.5" + << "0.7" << QStringList{"Type: Dict+Leet", "\tp455w0rd"}; + + QTest::newRow("Dictionary(Rep)") << "hellohello" + << "10" + << "7.3" + << "2.2" << QStringList{"Type: Dictionary(Rep)", "\thellohello"}; + + QTest::newRow("Sequence(Rep) / Dictionary") + << "456456foobar" + << "12" + << "16.7" + << "5.0" << QStringList{"Type: Sequence(Rep)", "Type: Dictionary", "\t456456", "\tfoobar"}; + + QTest::newRow("Bruteforce(Rep) / Bruteforce") + << "xzxzy" + << "5" + << "16.1" + << "4.8" << QStringList{"Type: Bruteforce(Rep)", "Type: Bruteforce", "\txzxz", "\ty"}; + + QTest::newRow("Dictionary / Date(Rep)") + << "pass20182018" + << "12" + << "15.1" + << "4.56" << QStringList{"Type: Dictionary", "Type: Date(Rep)", "\tpass", "\t20182018"}; + + QTest::newRow("Dictionary / Date / Bruteforce") + << "mypass2018-2" + << "12" + << "32.9" + << "9.9" << QStringList{"Type: Dictionary", "Type: Date", "Type: Bruteforce", "\tmypass", "\t2018", "\t-2"}; + + QTest::newRow("Strong Password") << "E*!%.Qw{t.X,&bafw)\"Q!ah$%;U/" + << "28" + << "165.7" + << "49.8" << QStringList{"Type: Bruteforce", "\tE*"}; + + // TODO: detect passphrases and adjust entropy calculation accordingly (issue #2347) + QTest::newRow("Strong Passphrase") + << "squint wooing resupply dangle isolation axis headsman" + << "53" + << "151.2" + << "45.5" + << QStringList{ + "Type: Dictionary", "Type: Bruteforce", "Multi-word extra bits 22.0", "\tsquint", "\t ", "\twooing"}; +} + +void TestCli::testEstimate() +{ + QFETCH(QString, input); + QFETCH(QString, length); + QFETCH(QString, entropy); + QFETCH(QString, log10); + QFETCH(QStringList, searchStrings); + + Estimate estimateCmd; + QVERIFY(!estimateCmd.name.isEmpty()); + QVERIFY(estimateCmd.getDescriptionLine().contains(estimateCmd.name)); + + QTextStream in(m_stdinFile.data()); + QTextStream out(m_stdoutFile.data()); + + in << input << endl; + in.seek(0); + estimateCmd.execute({"estimate", "-a"}); + out.seek(0); + auto result = out.readAll(); + QVERIFY(result.contains("Length " + length)); + QVERIFY(result.contains("Entropy " + entropy)); + QVERIFY(result.contains("Log10 " + log10)); + for (const auto& string : asConst(searchStrings)) { + QVERIFY2(result.contains(string), qPrintable("String " + string + " missing")); + } +} + +void TestCli::testExport() +{ + Export exportCmd; + QVERIFY(!exportCmd.name.isEmpty()); + QVERIFY(exportCmd.getDescriptionLine().contains(exportCmd.name)); + + Utils::Test::setNextPassword("a"); + exportCmd.execute({"export", m_dbFile->fileName()}); + + m_stdoutFile->seek(0); + m_stdoutFile->readLine(); // skip prompt line + + KdbxXmlReader reader(KeePass2::FILE_VERSION_3_1); + QScopedPointer<Database> db(new Database()); + reader.readDatabase(m_stdoutFile.data(), db.data()); + QVERIFY(!reader.hasError()); + QVERIFY(db.data()); + auto* entry = db->rootGroup()->findEntryByPath("/Sample Entry"); + QVERIFY(entry); + QCOMPARE(entry->password(), QString("Password")); + + m_stdoutFile->reset(); + + // Quiet option + QScopedPointer<Database> dbQuiet(new Database()); + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + exportCmd.execute({"export", "-f", "xml", "-q", m_dbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + reader.readDatabase(m_stdoutFile.data(), dbQuiet.data()); + QVERIFY(!reader.hasError()); + QVERIFY(db.data()); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + + // CSV exporting + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + exportCmd.execute({"export", "-f", "csv", m_dbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip prompt line + m_stderrFile->seek(posErr); + QByteArray csvHeader = m_stdoutFile->readLine(); + QCOMPARE(csvHeader, QByteArray("\"Group\",\"Title\",\"Username\",\"Password\",\"URL\",\"Notes\"\n")); + QByteArray csvData = m_stdoutFile->readAll(); + QVERIFY(csvData.contains(QByteArray( + "\"NewDatabase\",\"Sample Entry\",\"User Name\",\"Password\",\"http://www.somesite.com/\",\"Notes\"\n"))); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + + // test invalid format + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + exportCmd.execute({"export", "-f", "yaml", m_dbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip prompt line + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readLine(), QByteArray("")); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Unsupported format yaml\n")); +} + +void TestCli::testGenerate_data() +{ + QTest::addColumn<QStringList>("parameters"); + QTest::addColumn<QString>("pattern"); + + QTest::newRow("default") << QStringList{"generate"} << "^[^\r\n]+$"; + QTest::newRow("length") << QStringList{"generate", "-L", "13"} << "^.{13}$"; + QTest::newRow("lowercase") << QStringList{"generate", "-L", "14", "-l"} << "^[a-z]{14}$"; + QTest::newRow("uppercase") << QStringList{"generate", "-L", "15", "--upper"} << "^[A-Z]{15}$"; + QTest::newRow("numbers") << QStringList{"generate", "-L", "16", "-n"} << "^[0-9]{16}$"; + QTest::newRow("special") << QStringList{"generate", "-L", "200", "-s"} + << R"(^[\(\)\[\]\{\}\.\-*|\\,:;"'\/\_!+-<=>?#$%&^`@~]{200}$)"; + QTest::newRow("special (exclude)") << QStringList{"generate", "-L", "200", "-s", "-x", "+.?@&"} + << R"(^[\(\)\[\]\{\}\.\-*|\\,:;"'\/\_!-<=>#$%^`~]{200}$)"; + QTest::newRow("extended") << QStringList{"generate", "-L", "50", "-e"} + << R"(^[^a-zA-Z0-9\(\)\[\]\{\}\.\-\*\|\\,:;"'\/\_!+-<=>?#$%&^`@~]{50}$)"; + QTest::newRow("numbers + lowercase + uppercase") + << QStringList{"generate", "-L", "16", "-n", "--upper", "-l"} << "^[0-9a-zA-Z]{16}$"; + QTest::newRow("numbers + lowercase + uppercase (exclude)") + << QStringList{"generate", "-L", "500", "-n", "-U", "-l", "-x", "abcdefg0123@"} << "^[^abcdefg0123@]{500}$"; + QTest::newRow("numbers + lowercase + uppercase (exclude similar)") + << QStringList{"generate", "-L", "200", "-n", "-U", "-l", "--exclude-similar"} << "^[^l1IO0]{200}$"; + QTest::newRow("uppercase + lowercase (every)") + << QStringList{"generate", "-L", "2", "--upper", "-l", "--every-group"} << "^[a-z][A-Z]|[A-Z][a-z]$"; + QTest::newRow("numbers + lowercase (every)") + << QStringList{"generate", "-L", "2", "-n", "-l", "--every-group"} << "^[a-z][0-9]|[0-9][a-z]$"; +} + +void TestCli::testGenerate() +{ + QFETCH(QStringList, parameters); + QFETCH(QString, pattern); + + Generate generateCmd; + QVERIFY(!generateCmd.name.isEmpty()); + QVERIFY(generateCmd.getDescriptionLine().contains(generateCmd.name)); + + qint64 pos = 0; + // run multiple times to make accidental passes unlikely + TextStream stream(m_stdoutFile.data()); + for (int i = 0; i < 10; ++i) { + generateCmd.execute(parameters); + stream.seek(pos); + QRegularExpression regex(pattern); + QString password = stream.readLine(); + pos = stream.pos(); + QVERIFY2(regex.match(password).hasMatch(), + qPrintable("Password " + password + " does not match pattern " + pattern)); + } + + // Testing with invalid password length + auto posErr = m_stderrFile->pos(); + generateCmd.execute({"generate", "-L", "-10"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid password length -10\n")); + + posErr = m_stderrFile->pos(); + generateCmd.execute({"generate", "-L", "0"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid password length 0\n")); + + // Testing with invalid word count format + posErr = m_stderrFile->pos(); + generateCmd.execute({"generate", "-L", "bleuh"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid password length bleuh\n")); +} + +void TestCli::testKeyFileOption() +{ + List listCmd; + + QString keyFilePath(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtected.key")); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-k", keyFilePath, m_keyFileProtectedDbFile->fileName()}); + m_stdoutFile->reset(); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("entry1\n" + "entry2\n")); + + // Should raise an error with no key file. + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", m_keyFileProtectedDbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QVERIFY(m_stderrFile->readAll().contains("Invalid credentials were provided")); + + // Should raise an error if key file path is invalid. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-k", "invalidpath", m_keyFileProtectedDbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll().split(':').at(0), QByteArray("Failed to load key file invalidpath")); +} + +void TestCli::testNoPasswordOption() +{ + List listCmd; + + QString keyFilePath(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtectedNoPassword.key")); + listCmd.execute({"ls", "-k", keyFilePath, "--no-password", m_keyFileProtectedNoPasswordDbFile->fileName()}); + m_stdoutFile->reset(); + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("entry1\n" + "entry2\n")); + + // Should raise an error with no key file. + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + listCmd.execute({"ls", "--no-password", m_keyFileProtectedNoPasswordDbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QVERIFY(m_stderrFile->readAll().contains("Invalid credentials were provided")); +} + +void TestCli::testList() +{ + List listCmd; + QVERIFY(!listCmd.name.isEmpty()); + QVERIFY(listCmd.getDescriptionLine().contains(listCmd.name)); + + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", m_dbFile->fileName()}); + m_stdoutFile->reset(); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("Sample Entry\n" + "General/\n" + "Windows/\n" + "Network/\n" + "Internet/\n" + "eMail/\n" + "Homebanking/\n")); + + // Quiet option + qint64 pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-q", m_dbFile->fileName()}); + m_stdoutFile->seek(pos); + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("Sample Entry\n" + "General/\n" + "Windows/\n" + "Network/\n" + "Internet/\n" + "eMail/\n" + "Homebanking/\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-R", m_dbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("Sample Entry\n" + "General/\n" + " [empty]\n" + "Windows/\n" + " [empty]\n" + "Network/\n" + " [empty]\n" + "Internet/\n" + " [empty]\n" + "eMail/\n" + " [empty]\n" + "Homebanking/\n" + " Subgroup/\n" + " Subgroup Entry\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-R", "-f", m_dbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("Sample Entry\n" + "General/\n" + "General/[empty]\n" + "Windows/\n" + "Windows/[empty]\n" + "Network/\n" + "Network/[empty]\n" + "Internet/\n" + "Internet/[empty]\n" + "eMail/\n" + "eMail/[empty]\n" + "Homebanking/\n" + "Homebanking/Subgroup/\n" + "Homebanking/Subgroup/Subgroup Entry\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-R", "-f", m_dbFile->fileName(), "/Homebanking"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("Subgroup/\n" + "Subgroup/Subgroup Entry\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", m_dbFile->fileName(), "/General/"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("[empty]\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", m_dbFile->fileName(), "/DoesNotExist/"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->reset(); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Cannot find group /DoesNotExist/.\n")); +} + +void TestCli::testLocate() +{ + Locate locateCmd; + QVERIFY(!locateCmd.name.isEmpty()); + QVERIFY(locateCmd.getDescriptionLine().contains(locateCmd.name)); + + Utils::Test::setNextPassword("a"); + locateCmd.execute({"locate", m_dbFile->fileName(), "Sample"}); + m_stdoutFile->reset(); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), QByteArray("/Sample Entry\n")); + + // Quiet option + qint64 pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + locateCmd.execute({"locate", m_dbFile->fileName(), "-q", "Sample"}); + m_stdoutFile->seek(pos); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("/Sample Entry\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + locateCmd.execute({"locate", m_dbFile->fileName(), "Does Not Exist"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->reset(); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("No results for that search term.\n")); + + // write a modified database + auto db = readTestDatabase(); + QVERIFY(db); + auto* group = db->rootGroup()->findGroupByPath("/General/"); + QVERIFY(group); + auto* entry = new Entry(); + entry->setUuid(QUuid::createUuid()); + entry->setTitle("New Entry"); + group->addEntry(entry); + TemporaryFile tmpFile; + tmpFile.open(); + Kdbx4Writer writer; + writer.writeDatabase(&tmpFile, db.data()); + tmpFile.close(); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + locateCmd.execute({"locate", tmpFile.fileName(), "New"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), QByteArray("/General/New Entry\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + locateCmd.execute({"locate", tmpFile.fileName(), "Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("/Sample Entry\n/General/New Entry\n/Homebanking/Subgroup/Subgroup Entry\n")); +} + +void TestCli::testMerge() +{ + Merge mergeCmd; + QVERIFY(!mergeCmd.name.isEmpty()); + QVERIFY(mergeCmd.getDescriptionLine().contains(mergeCmd.name)); + + Kdbx4Writer writer; + Kdbx4Reader reader; + + // load test database and save copies + auto db = readTestDatabase(); + QVERIFY(db); + TemporaryFile targetFile1; + targetFile1.open(); + writer.writeDatabase(&targetFile1, db.data()); + targetFile1.close(); + TemporaryFile targetFile2; + targetFile2.open(); + writer.writeDatabase(&targetFile2, db.data()); + targetFile2.close(); + + // save another copy with a different password + TemporaryFile targetFile3; + targetFile3.open(); + auto oldKey = db->key(); + auto key = QSharedPointer<CompositeKey>::create(); + key->addKey(QSharedPointer<PasswordKey>::create("b")); + db->setKey(key); + writer.writeDatabase(&targetFile3, db.data()); + targetFile3.close(); + db->setKey(oldKey); + + // then add a new entry to the in-memory database and save another copy + auto* entry = new Entry(); + entry->setUuid(QUuid::createUuid()); + entry->setTitle("Some Website"); + entry->setPassword("secretsecretsecret"); + auto* group = db->rootGroup()->findGroupByPath("/Internet/"); + QVERIFY(group); + group->addEntry(entry); + TemporaryFile sourceFile; + sourceFile.open(); + writer.writeDatabase(&sourceFile, db.data()); + sourceFile.close(); + + qint64 pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + mergeCmd.execute({"merge", "-s", targetFile1.fileName(), sourceFile.fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); + m_stderrFile->reset(); + QList<QByteArray> outLines1 = m_stdoutFile->readAll().split('\n'); + QCOMPARE(outLines1.at(0).split('[').at(0), QByteArray("\tOverwriting Internet ")); + QCOMPARE(outLines1.at(1).split('[').at(0), QByteArray("\tCreating missing Some Website ")); + QCOMPARE(outLines1.at(2), + QString("Successfully merged %1 into %2.").arg(sourceFile.fileName(), targetFile1.fileName()).toUtf8()); + + QFile readBack(targetFile1.fileName()); + readBack.open(QIODevice::ReadOnly); + auto mergedDb = QSharedPointer<Database>::create(); + reader.readDatabase(&readBack, oldKey, mergedDb.data()); + readBack.close(); + QVERIFY(mergedDb); + auto* entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website"); + QVERIFY(entry1); + QCOMPARE(entry1->title(), QString("Some Website")); + QCOMPARE(entry1->password(), QString("secretsecretsecret")); + + // the dry run option should not modify the target database. + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + mergeCmd.execute({"merge", "--dry-run", "-s", targetFile2.fileName(), sourceFile.fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); + m_stderrFile->reset(); + QList<QByteArray> outLines2 = m_stdoutFile->readAll().split('\n'); + QCOMPARE(outLines2.at(0).split('[').at(0), QByteArray("\tOverwriting Internet ")); + QCOMPARE(outLines2.at(1).split('[').at(0), QByteArray("\tCreating missing Some Website ")); + QCOMPARE(outLines2.at(2), QByteArray("Database was not modified by merge operation.")); + + QFile readBack2(targetFile2.fileName()); + readBack2.open(QIODevice::ReadOnly); + mergedDb = QSharedPointer<Database>::create(); + reader.readDatabase(&readBack2, oldKey, mergedDb.data()); + readBack2.close(); + QVERIFY(mergedDb); + entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website"); + QVERIFY(!entry1); + + // the dry run option can be used with the quiet option + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + mergeCmd.execute({"merge", "--dry-run", "-s", "-q", targetFile2.fileName(), sourceFile.fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); + m_stderrFile->reset(); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + + readBack2.setFileName(targetFile2.fileName()); + readBack2.open(QIODevice::ReadOnly); + mergedDb = QSharedPointer<Database>::create(); + reader.readDatabase(&readBack2, oldKey, mergedDb.data()); + readBack2.close(); + QVERIFY(mergedDb); + entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website"); + QVERIFY(!entry1); + + // try again with different passwords for both files + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("b"); + Utils::Test::setNextPassword("a"); + mergeCmd.execute({"merge", targetFile3.fileName(), sourceFile.fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); + m_stdoutFile->readLine(); + QList<QByteArray> outLines3 = m_stdoutFile->readAll().split('\n'); + QCOMPARE(outLines3.at(2), + QString("Successfully merged %1 into %2.").arg(sourceFile.fileName(), targetFile3.fileName()).toUtf8()); + + readBack.setFileName(targetFile3.fileName()); + readBack.open(QIODevice::ReadOnly); + mergedDb = QSharedPointer<Database>::create(); + reader.readDatabase(&readBack, key, mergedDb.data()); + readBack.close(); + QVERIFY(mergedDb); + entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website"); + QVERIFY(entry1); + QCOMPARE(entry1->title(), QString("Some Website")); + QCOMPARE(entry1->password(), QString("secretsecretsecret")); + + // making sure that the message is different if the database was not + // modified by the merge operation. + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + mergeCmd.execute({"merge", "-s", sourceFile.fileName(), sourceFile.fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Database was not modified by merge operation.\n")); + + // Quiet option + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + mergeCmd.execute({"merge", "-q", "-s", sourceFile.fileName(), sourceFile.fileName()}); + m_stdoutFile->seek(pos); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + + // Quiet option without the -s option + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + Utils::Test::setNextPassword("a"); + mergeCmd.execute({"merge", "-q", sourceFile.fileName(), sourceFile.fileName()}); + m_stdoutFile->seek(pos); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); +} + +void TestCli::testMove() +{ + Move moveCmd; + QVERIFY(!moveCmd.name.isEmpty()); + QVERIFY(moveCmd.getDescriptionLine().contains(moveCmd.name)); + + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + moveCmd.execute({"mv", m_dbFile->fileName(), "invalid_entry_path", "invalid_group_path"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + m_stdoutFile->readLine(); // skip prompt line + QCOMPARE(m_stdoutFile->readLine(), QByteArray("")); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Could not find entry with path invalid_entry_path.\n")); + + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + moveCmd.execute({"mv", m_dbFile->fileName(), "Sample Entry", "invalid_group_path"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + m_stdoutFile->readLine(); // skip prompt line + QCOMPARE(m_stdoutFile->readLine(), QByteArray("")); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Could not find group with path invalid_group_path.\n")); + + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + moveCmd.execute({"mv", m_dbFile->fileName(), "Sample Entry", "General/"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + m_stdoutFile->readLine(); // skip prompt line + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully moved entry Sample Entry to group General/.\n")); + QCOMPARE(m_stderrFile->readLine(), QByteArray("")); + + auto db = readTestDatabase(); + auto* entry = db->rootGroup()->findEntryByPath("General/Sample Entry"); + QVERIFY(entry); + + // Test that not modified if the same group is destination. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + moveCmd.execute({"mv", m_dbFile->fileName(), "General/Sample Entry", "General/"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + m_stdoutFile->readLine(); // skip prompt line + QCOMPARE(m_stdoutFile->readLine(), QByteArray("")); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Entry is already in group General/.\n")); + + // sanity check + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("General/Sample Entry"); + QVERIFY(entry); +} + +void TestCli::testRemove() +{ + Remove removeCmd; + QVERIFY(!removeCmd.name.isEmpty()); + QVERIFY(removeCmd.getDescriptionLine().contains(removeCmd.name)); + + Kdbx3Reader reader; + Kdbx3Writer writer; + + // load test database and save a copy with disabled recycle bin + auto db = readTestDatabase(); + QVERIFY(db); + TemporaryFile fileCopy; + fileCopy.open(); + db->metadata()->setRecycleBinEnabled(false); + writer.writeDatabase(&fileCopy, db.data()); + fileCopy.close(); + + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + + // delete entry and verify + Utils::Test::setNextPassword("a"); + removeCmd.execute({"rm", m_dbFile->fileName(), "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully recycled entry Sample Entry.\n")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + + auto key = QSharedPointer<CompositeKey>::create(); + key->addKey(QSharedPointer<PasswordKey>::create("a")); + QFile readBack(m_dbFile->fileName()); + readBack.open(QIODevice::ReadOnly); + auto readBackDb = QSharedPointer<Database>::create(); + reader.readDatabase(&readBack, key, readBackDb.data()); + readBack.close(); + QVERIFY(readBackDb); + QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry")); + QVERIFY(readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin")))); + + pos = m_stdoutFile->pos(); + pos = m_stdoutFile->pos(); + + // try again, this time without recycle bin + Utils::Test::setNextPassword("a"); + removeCmd.execute({"rm", fileCopy.fileName(), "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully deleted entry Sample Entry.\n")); + + readBack.setFileName(fileCopy.fileName()); + readBack.open(QIODevice::ReadOnly); + readBackDb = QSharedPointer<Database>::create(); + reader.readDatabase(&readBack, key, readBackDb.data()); + readBack.close(); + QVERIFY(readBackDb); + QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry")); + QVERIFY(!readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin")))); + + // finally, try deleting a non-existent entry + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + removeCmd.execute({"rm", fileCopy.fileName(), "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry /Sample Entry not found.\n")); + + // try deleting a directory, should fail + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + removeCmd.execute({"rm", fileCopy.fileName(), "/General"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry /General not found.\n")); +} + +void TestCli::testRemoveGroup() +{ + RemoveGroup removeGroupCmd; + QVERIFY(!removeGroupCmd.name.isEmpty()); + QVERIFY(removeGroupCmd.getDescriptionLine().contains(removeGroupCmd.name)); + + Kdbx3Reader reader; + Kdbx3Writer writer; + + // try deleting a directory, should recycle it first. + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + removeGroupCmd.execute({"rmdir", m_dbFile->fileName(), "/General"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully recycled group /General.\n")); + + auto db = readTestDatabase(); + auto* group = db->rootGroup()->findGroupByPath("General"); + QVERIFY(!group); + + // try deleting a directory again, should delete it permanently. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + removeGroupCmd.execute({"rmdir", m_dbFile->fileName(), "Recycle Bin/General"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully deleted group Recycle Bin/General.\n")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + + db = readTestDatabase(); + group = db->rootGroup()->findGroupByPath("Recycle Bin/General"); + QVERIFY(!group); + + // try deleting an invalid group, should fail. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + removeGroupCmd.execute({"rmdir", m_dbFile->fileName(), "invalid"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Group invalid not found.\n")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + + // Should fail to remove the root group. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + removeGroupCmd.execute({"rmdir", m_dbFile->fileName(), "/"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Cannot remove root group from database.\n")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); +} + +void TestCli::testRemoveQuiet() +{ + Remove removeCmd; + QVERIFY(!removeCmd.name.isEmpty()); + QVERIFY(removeCmd.getDescriptionLine().contains(removeCmd.name)); + + Kdbx3Reader reader; + Kdbx3Writer writer; + + qint64 pos = m_stdoutFile->pos(); + + // delete entry and verify + Utils::Test::setNextPassword("a"); + removeCmd.execute({"rm", "-q", m_dbFile->fileName(), "/Sample Entry"}); + m_stdoutFile->seek(pos); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + + auto key = QSharedPointer<CompositeKey>::create(); + key->addKey(QSharedPointer<PasswordKey>::create("a")); + QFile readBack(m_dbFile->fileName()); + readBack.open(QIODevice::ReadOnly); + auto readBackDb = QSharedPointer<Database>::create(); + reader.readDatabase(&readBack, key, readBackDb.data()); + readBack.close(); + QVERIFY(readBackDb); + QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry")); + QVERIFY(readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin")))); + + pos = m_stdoutFile->pos(); + + // remove the entry completely + Utils::Test::setNextPassword("a"); + removeCmd.execute({"rm", "-q", m_dbFile->fileName(), QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin"))}); + m_stdoutFile->seek(pos); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + + readBack.setFileName(m_dbFile->fileName()); + readBack.open(QIODevice::ReadOnly); + readBackDb = QSharedPointer<Database>::create(); + reader.readDatabase(&readBack, key, readBackDb.data()); + readBack.close(); + QVERIFY(readBackDb); + QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry")); + QVERIFY(!readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin")))); +} + +void TestCli::testShow() +{ + Show showCmd; + QVERIFY(!showCmd.name.isEmpty()); + QVERIFY(showCmd.getDescriptionLine().contains(showCmd.name)); + + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", m_dbFile->fileName(), "/Sample Entry"}); + m_stdoutFile->reset(); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("Title: Sample Entry\n" + "UserName: User Name\n" + "Password: Password\n" + "URL: http://www.somesite.com/\n" + "Notes: Notes\n")); + + qint64 pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", m_dbFile->fileName(), "-q", "/Sample Entry"}); + m_stdoutFile->seek(pos); + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("Title: Sample Entry\n" + "UserName: User Name\n" + "Password: Password\n" + "URL: http://www.somesite.com/\n" + "Notes: Notes\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", "-a", "Title", m_dbFile->fileName(), "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Sample Entry\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", "-a", "Title", "-a", "URL", m_dbFile->fileName(), "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("Sample Entry\n" + "http://www.somesite.com/\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", "-a", "DoesNotExist", m_dbFile->fileName(), "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->reset(); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("ERROR: unknown attribute DoesNotExist.\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", "-t", m_dbFile->fileName(), "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QVERIFY(isTOTP(m_stdoutFile->readAll())); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", "-a", "Title", m_dbFile->fileName(), "--totp", "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Sample Entry\n")); + QVERIFY(isTOTP(m_stdoutFile->readAll())); + + pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", m_dbFile2->fileName(), "--totp", "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry with path /Sample Entry has no TOTP set up.\n")); +} + +void TestCli::testInvalidDbFiles() +{ + Show showCmd; + QString nonExistentDbPath("/foo/bar/baz"); + QString directoryName("/"); + + qint64 pos = m_stderrFile->pos(); + showCmd.execute({"show", nonExistentDbPath, "-q", "/Sample Entry"}); + m_stderrFile->seek(pos); + QCOMPARE(QString(m_stderrFile->readAll()), + QObject::tr("Failed to open database file %1: not found").arg(nonExistentDbPath) + "\n"); + + pos = m_stderrFile->pos(); + showCmd.execute({"show", directoryName, "-q", "whatever"}); + m_stderrFile->seek(pos); + QCOMPARE(QString(m_stderrFile->readAll()), + QObject::tr("Failed to open database file %1: not a plain file").arg(directoryName) + "\n"); + + // Create a write-only file and try to open it. + // QFileInfo.isReadable returns 'true' on Windows, even after the call to + // setPermissions(WriteOwner) and with NTFS permissions enabled, so this + // check doesn't work. +#if !defined(Q_OS_WIN) + QTemporaryFile tempFile; + QVERIFY(tempFile.open()); + QString path = QFileInfo(tempFile).absoluteFilePath(); + QVERIFY(tempFile.setPermissions(QFileDevice::WriteOwner)); + pos = m_stderrFile->pos(); + showCmd.execute({"show", path, "some entry"}); + m_stderrFile->seek(pos); + QCOMPARE(QString(m_stderrFile->readAll()), + QObject::tr("Failed to open database file %1: not readable").arg(path) + "\n"); +#endif // Q_OS_WIN +} + +/** + * Secret key for the YubiKey slot used by the unit test is + * 1c e3 0f d7 8d 20 dc fa 40 b5 0c 18 77 9a fb 0f 02 28 8d b7 + * This secret should be configured at slot 2, and the slot + * should be configured as passive. + */ +void TestCli::testYubiKeyOption() +{ + if (!YubiKey::instance()->init()) { + QSKIP("Unable to connect to YubiKey"); + } + + QString errorMessage; + bool isBlocking = YubiKey::instance()->checkSlotIsBlocking(2, errorMessage); + if (isBlocking && errorMessage.isEmpty()) { + QSKIP("Skipping YubiKey in press mode."); + } + + QByteArray challenge("CLITest"); + QByteArray response; + YubiKey::instance()->challenge(2, false, challenge, response); + QByteArray expected("\xA2\x3B\x94\x00\xBE\x47\x9A\x30\xA9\xEB\x50\x9B\x85\x56\x5B\x6B\x30\x25\xB4\x8E", 20); + QVERIFY2(response == expected, "YubiKey Slot 2 is not configured with correct secret key."); + + List listCmd; + Add addCmd; + + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-y", "2", m_yubiKeyProtectedDbFile->fileName()}); + m_stdoutFile->reset(); + m_stderrFile->reset(); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("entry1\n" + "entry2\n")); + + // Should raise an error with no yubikey slot. + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", m_yubiKeyProtectedDbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readLine(), + QByteArray("Error while reading the database: Invalid credentials were provided, please try again.\n")); + QCOMPARE(m_stderrFile->readLine(), + QByteArray("If this reoccurs, then your database file may be corrupt. (HMAC mismatch)\n")); + + // Should raise an error if yubikey slot is not a string + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-y", "invalidslot", m_yubiKeyProtectedDbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll().split(':').at(0), QByteArray("Invalid YubiKey slot invalidslot\n")); + + // Should raise an error if yubikey slot is invalid. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-y", "3", m_yubiKeyProtectedDbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll().split(':').at(0), QByteArray("Invalid YubiKey slot 3\n")); +} + +namespace +{ + + void expectParseResult(const QString& input, const QStringList& expectedOutput) + { + QStringList result = Utils::splitCommandString(input); + QCOMPARE(result.size(), expectedOutput.size()); + for (int i = 0; i < expectedOutput.size(); ++i) { + QCOMPARE(result[i], expectedOutput[i]); + } + } + +} // namespace + +void TestCli::testCommandParsing_data() +{ + QTest::addColumn<QString>("input"); + QTest::addColumn<QStringList>("expectedOutput"); + + QTest::newRow("basic") << "hello world" << QStringList({"hello", "world"}); + QTest::newRow("basic escaping") << "hello\\ world" << QStringList({"hello world"}); + QTest::newRow("quoted string") << "\"hello world\"" << QStringList({"hello world"}); + QTest::newRow("multiple params") << "show Passwords/Internet" << QStringList({"show", "Passwords/Internet"}); + QTest::newRow("quoted string inside param") + << R"(ls foo\ bar\ baz"quoted")" << QStringList({"ls", "foo bar baz\"quoted\""}); + QTest::newRow("multiple whitespace") << "hello world" << QStringList({"hello", "world"}); + QTest::newRow("single slash char") << "\\" << QStringList({"\\"}); + QTest::newRow("double backslash entry name") << "show foo\\\\\\\\bar" << QStringList({"show", "foo\\\\bar"}); +} + +void TestCli::testCommandParsing() +{ + QFETCH(QString, input); + QFETCH(QStringList, expectedOutput); + + expectParseResult(input, expectedOutput); +} + +void TestCli::testOpen() +{ + Open o; + + Utils::Test::setNextPassword("a"); + o.execute({"open", m_dbFile->fileName()}); + m_stdoutFile->reset(); + QVERIFY(o.currentDatabase); + + List l; + // Set a current database, simulating interactive mode. + l.currentDatabase = o.currentDatabase; + l.execute({"ls"}); + m_stdoutFile->reset(); + QByteArray expectedOutput("Sample Entry\n" + "General/\n" + "Windows/\n" + "Network/\n" + "Internet/\n" + "eMail/\n" + "Homebanking/\n"); + QByteArray actualOutput = m_stdoutFile->readAll(); + actualOutput.truncate(expectedOutput.length()); + QCOMPARE(actualOutput, expectedOutput); +} + +void TestCli::testHelp() +{ + Help h; + Commands::setupCommands(false); + + { + h.execute({"help"}); + m_stderrFile->reset(); + QString output(m_stderrFile->readAll()); + QVERIFY(output.contains(QObject::tr("Available commands"))); + } + + { + List l; + h.execute({"help", "ls"}); + m_stderrFile->reset(); + QString output(m_stderrFile->readAll()); + QVERIFY(output.contains(l.description)); + } +} diff --git a/tests/TestCli.h b/tests/TestCli.h index aa7dda8ff..d1969943c 100644 --- a/tests/TestCli.h +++ b/tests/TestCli.h @@ -43,11 +43,13 @@ private slots: void cleanup(); void cleanupTestCase(); - void testCommand(); + void testBatchCommands(); void testAdd(); void testAddGroup(); void testAnalyze(); void testClip(); + void testCommandParsing_data(); + void testCommandParsing(); void testCreate(); void testDiceware(); void testEdit(); @@ -58,10 +60,13 @@ private slots: void testGenerate(); void testKeyFileOption(); void testNoPasswordOption(); + void testHelp(); + void testInteractiveCommands(); void testList(); void testLocate(); void testMerge(); void testMove(); + void testOpen(); void testRemove(); void testRemoveGroup(); void testRemoveQuiet(); diff --git a/tests/TestMerge.cpp b/tests/TestMerge.cpp index a682e8681..4f96d3e6d 100644 --- a/tests/TestMerge.cpp +++ b/tests/TestMerge.cpp @@ -1214,7 +1214,6 @@ void TestMerge::testCustomData() QCOMPARE(dbDestination->metadata()->customData()->value("key3"), QString("newValue")); // Old value should be replaced - // Merging again should not do anything if the values are the same. m_clock->advanceSecond(1); dbSource->metadata()->customData()->set("key3", "oldValue"); @@ -1223,7 +1222,6 @@ void TestMerge::testCustomData() QStringList changes2 = merger2.merge(); QVERIFY(changes2.isEmpty()); - Merger merger3(dbSource2.data(), dbDestination2.data()); merger3.merge(); diff --git a/tests/gui/TestGuiBrowser.cpp b/tests/gui/TestGuiBrowser.cpp index 7f2c54805..1b578df02 100644 --- a/tests/gui/TestGuiBrowser.cpp +++ b/tests/gui/TestGuiBrowser.cpp @@ -26,8 +26,8 @@ #include <QDialogButtonBox> #include <QLineEdit> #include <QPushButton> -#include <QToolBar> #include <QTableView> +#include <QToolBar> #include "config-keepassx-tests.h" #include "core/Bootstrap.h" @@ -144,7 +144,7 @@ void TestGuiBrowser::testEntrySettings() QTest::mouseClick(entryEditWidget, Qt::LeftButton); QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode); auto* editEntryWidget = m_dbWidget->findChild<EditEntryWidget*>("editEntryWidget"); - + // Switch to Properties page and select all rows from the custom data table editEntryWidget->setCurrentPage(4); auto customDataTableView = editEntryWidget->findChild<QTableView*>("customDataTable"); @@ -181,9 +181,9 @@ void TestGuiBrowser::triggerAction(const QString& name) } void TestGuiBrowser::clickIndex(const QModelIndex& index, - QAbstractItemView* view, - Qt::MouseButton button, - Qt::KeyboardModifiers stateKey) + QAbstractItemView* view, + Qt::MouseButton button, + Qt::KeyboardModifiers stateKey) { QTest::mouseClick(view->viewport(), button, stateKey, view->visualRect(index).center()); }