diff --git a/CHANGELOG b/CHANGELOG index 8bbef93195..be8cf68b79 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -12,6 +12,7 @@ - Add 'Monospaced font' option to the Notes field [#3321] - Drop to background when copy feature [#3253] - Fix password generator issues with special characters [#3303] +- Add interactive shell mode to keepassxc-cli via 'keepassxc-cli open' [#3426] 2.4.3 (2019-06-12) ========================= diff --git a/cmake/FindReadline.cmake b/cmake/FindReadline.cmake new file mode 100644 index 0000000000..9d9691370c --- /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/cli/CMakeLists.txt b/src/cli/CMakeLists.txt index f75d6c6f2d..b6e00c8f7b 100644 --- a/src/cli/CMakeLists.txt +++ b/src/cli/CMakeLists.txt @@ -25,15 +25,24 @@ set(cli_SOURCES Estimate.cpp Extract.cpp Generate.cpp + Help.cpp List.cpp Locate.cpp Merge.cpp + Open.cpp Remove.cpp Show.cpp) 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 diff --git a/src/cli/Command.cpp b/src/cli/Command.cpp index b1d5881a05..625944a8f5 100644 --- a/src/cli/Command.cpp +++ b/src/cli/Command.cpp @@ -31,14 +31,23 @@ #include "Estimate.h" #include "Extract.h" #include "Generate.h" +#include "Help.h" #include "List.h" #include "Locate.h" #include "Merge.h" +#include "Open.h" #include "Remove.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", @@ -55,6 +64,7 @@ const QCommandLineOption Command::NoPasswordOption = QMap commands; Command::Command() + : currentDatabase(nullptr) { options.append(Command::QuietOption); } @@ -74,32 +84,55 @@ QString Command::getDescriptionLine() return response; } +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 + +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); - 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); + if (!parser->parse(arguments)) { + errorTextStream << parser->errorText() << "\n\n"; + errorTextStream << getHelpText(); + return {}; } - 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); + errorTextStream << getHelpText(); + return {}; } if (parser->positionalArguments().size() > (positionalArguments.size() + optionalArguments.size())) { - errorTextStream << parser->helpText().replace("[options]", name.append(" [options]")); - return QSharedPointer(nullptr); + errorTextStream << getHelpText(); + return {}; + } + if (parser->isSet(HelpOption)) { + errorTextStream << getHelpText(); + return {}; } return parser; } @@ -116,9 +149,11 @@ void populateCommands() commands.insert(QString("estimate"), new Estimate()); commands.insert(QString("extract"), new Extract()); commands.insert(QString("generate"), new Generate()); + commands.insert(QString("help"), new Help()); commands.insert(QString("locate"), new Locate()); commands.insert(QString("ls"), new List()); commands.insert(QString("merge"), new Merge()); + commands.insert(QString("open"), new Open()); commands.insert(QString("rm"), new Remove()); commands.insert(QString("show"), new Show()); } diff --git a/src/cli/Command.h b/src/cli/Command.h index 7f3494ba6e..4786fec841 100644 --- a/src/cli/Command.h +++ b/src/cli/Command.h @@ -44,15 +44,19 @@ class Command 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; diff --git a/src/cli/Create.cpp b/src/cli/Create.cpp index ee3ac6054b..55c3430390 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 65d4f15b2b..d065326651 100644 --- a/src/cli/DatabaseCommand.cpp +++ b/src/cli/DatabaseCommand.cpp @@ -28,19 +28,32 @@ DatabaseCommand::DatabaseCommand() int DatabaseCommand::execute(const QStringList& arguments) { - QSharedPointer parser = getCommandLineParser(arguments); + QStringList amendedArgs(arguments); + if (currentDatabase) { + amendedArgs.insert(1, currentDatabase->filePath()); + } + QSharedPointer parser = getCommandLineParser(amendedArgs); + 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->isSet(Command::QuietOption) ? Utils::DEVNULL : Utils::STDOUT, - Utils::STDERR); + QStringList args = parser->positionalArguments(); + auto db = currentDatabase; if (!db) { - return EXIT_FAILURE; + // 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->isSet(Command::QuietOption) ? Utils::DEVNULL : Utils::STDOUT, + Utils::STDERR); + if (!db) { + return EXIT_FAILURE; + } } return executeWithDatabase(db, parser); diff --git a/src/cli/Help.cpp b/src/cli/Help.cpp new file mode 100644 index 0000000000..35dfefd262 --- /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); + Command* command = arguments.size() > 1 ? Command::getCommand(arguments.at(1)) : nullptr; + if (command) { + out << command->getHelpText(); + } else { + out << "\n\n" << QObject::tr("Available commands:") << "\n"; + for (Command* c : Command::getCommands()) { + out << c->getDescriptionLine(); + } + } + return EXIT_SUCCESS; +} diff --git a/src/cli/Help.h b/src/cli/Help.h new file mode 100644 index 0000000000..162f8ba985 --- /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 0000000000..c4e9a79adb --- /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 0000000000..786c4d09f2 --- /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 a23f872fd3..7be02b86d4 100644 --- a/src/cli/Utils.cpp +++ b/src/cli/Utils.cpp @@ -226,4 +226,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 bd89a2a5ca..9bc4ea68f7 100644 --- a/src/cli/Utils.h +++ b/src/cli/Utils.h @@ -41,6 +41,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 18d249170d..2e94879650 100644 --- a/src/cli/keepassxc-cli.1 +++ b/src/cli/keepassxc-cli.1 @@ -18,7 +18,7 @@ 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. @@ -27,7 +27,7 @@ Copies the password or the current TOTP (\fI-t\fP option) of a database entry to 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). @@ -40,7 +40,10 @@ Estimates the entropy of a password. The password to estimate can be provided as Extracts and prints the contents of a database to standard output in XML format. .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. @@ -51,6 +54,9 @@ Lists the contents of a group in a database. If no group is specified, it will d .IP "merge [options] " Merges two databases together. The first database file is going to be replaced by the result of the merge, for that reason it is advisable to keep a backup of the two database files before attempting a merge. In the case that both databases make use of the same credentials, the \fI--same-credentials\fP or \fI-s\fP option can be used. +.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 "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. @@ -65,13 +71,13 @@ 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 "-q, --quiet " -Silence password prompt and other secondary outputs. +Silences password prompt and other secondary outputs. .IP "-h, --help" Displays help information. @@ -83,16 +89,16 @@ 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 "-s, --same-credentials" -Use the same credentials for unlocking both database. +Uses the same credentials for unlocking both databases. .SS "Add and edit options" @@ -100,34 +106,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 @@ -137,37 +143,37 @@ 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 "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. @@ -175,22 +181,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 1f76812b07..4580971076 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,134 @@ #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) +{ + + 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; + } + + Command* cmd = Command::getCommand(args[0]); + if (cmd == nullptr) { + errorTextStream << QObject::tr("Unknown command %1").arg(args[0]) << "\n"; + continue; + } + + cmd->currentDatabase = currentDatabase; + cmd->execute(args); + currentDatabase = cmd->currentDatabase; + } +} + int main(int argc, char** argv) { if (!Crypto::init()) { @@ -84,9 +218,13 @@ int main(int argc, char** argv) } QString commandName = parser.positionalArguments().at(0); + if (commandName == "open") { + enterInteractiveMode(arguments); + return EXIT_SUCCESS; + } Command* command = Command::getCommand(commandName); - if (command == nullptr) { + 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/tests/TestCli.cpp b/tests/TestCli.cpp index d51f90d035..ed0b22ef37 100644 --- a/tests/TestCli.cpp +++ b/tests/TestCli.cpp @@ -41,9 +41,11 @@ #include "cli/Estimate.h" #include "cli/Extract.h" #include "cli/Generate.h" +#include "cli/Help.h" #include "cli/List.h" #include "cli/Locate.h" #include "cli/Merge.h" +#include "cli/Open.h" #include "cli/Remove.h" #include "cli/Show.h" #include "cli/Utils.h" @@ -59,6 +61,8 @@ QTEST_MAIN(TestCli) +QSharedPointer<Database> globalCurrentDatabase; + void TestCli::initTestCase() { QVERIFY(Crypto::init()); @@ -162,7 +166,7 @@ QSharedPointer<Database> TestCli::readTestDatabase() const void TestCli::testCommand() { - QCOMPARE(Command::getCommands().size(), 14); + QCOMPARE(Command::getCommands().size(), 16); QVERIFY(Command::getCommand("add")); QVERIFY(Command::getCommand("analyze")); QVERIFY(Command::getCommand("clip")); @@ -172,9 +176,11 @@ void TestCli::testCommand() QVERIFY(Command::getCommand("estimate")); QVERIFY(Command::getCommand("extract")); QVERIFY(Command::getCommand("generate")); + QVERIFY(Command::getCommand("help")); QVERIFY(Command::getCommand("locate")); QVERIFY(Command::getCommand("ls")); QVERIFY(Command::getCommand("merge")); + QVERIFY(Command::getCommand("open")); QVERIFY(Command::getCommand("rm")); QVERIFY(Command::getCommand("show")); QVERIFY(!Command::getCommand("doesnotexist")); @@ -1409,3 +1415,87 @@ void TestCli::testShow() QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry with path /Sample Entry has no TOTP set up.\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; + + { + 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 c012fc8075..8ecf44074c 100644 --- a/tests/TestCli.h +++ b/tests/TestCli.h @@ -47,6 +47,8 @@ private slots: void testAdd(); void testAnalyze(); void testClip(); + void testCommandParsing_data(); + void testCommandParsing(); void testCreate(); void testDiceware(); void testEdit(); @@ -57,9 +59,11 @@ private slots: void testGenerate(); void testKeyFileOption(); void testNoPasswordOption(); + void testHelp(); void testList(); void testLocate(); void testMerge(); + void testOpen(); void testRemove(); void testRemoveQuiet(); void testShow();