From d5153162f6f063e8043c77641fa5a0c74a28bf00 Mon Sep 17 00:00:00 2001 From: James Ring Date: Mon, 5 Aug 2019 17:04:15 -0700 Subject: [PATCH] Add interactive session mode to keepassxc-cli. 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 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. Fixes #3224. --- CHANGELOG | 1 + cmake/FindReadline.cmake | 50 ++++++++++++++ src/cli/CMakeLists.txt | 8 +++ src/cli/Command.cpp | 8 ++- src/cli/Command.h | 1 + src/cli/DatabaseCommand.cpp | 30 +++++--- src/cli/Open.cpp | 37 ++++++++++ src/cli/Open.h | 30 ++++++++ src/cli/Utils.cpp | 37 ++++++++++ src/cli/Utils.h | 2 + src/cli/keepassxc-cli.1 | 3 + src/cli/keepassxc-cli.cpp | 132 ++++++++++++++++++++++++++++++++++++ tests/TestCli.cpp | 70 ++++++++++++++++++- tests/TestCli.h | 3 + 14 files changed, 400 insertions(+), 12 deletions(-) create mode 100644 cmake/FindReadline.cmake create mode 100644 src/cli/Open.cpp create mode 100644 src/cli/Open.h diff --git a/CHANGELOG b/CHANGELOG index 91c7604212..834a57537d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -7,6 +7,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..73eb5af05c --- /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..b65e43d92d 100644 --- a/src/cli/CMakeLists.txt +++ b/src/cli/CMakeLists.txt @@ -28,12 +28,20 @@ set(cli_SOURCES 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 1ed18bb5ae..ab0091214e 100644 --- a/src/cli/Command.cpp +++ b/src/cli/Command.cpp @@ -34,6 +34,7 @@ #include "List.h" #include "Locate.h" #include "Merge.h" +#include "Open.h" #include "Remove.h" #include "Show.h" #include "TextStream.h" @@ -54,7 +55,7 @@ const QCommandLineOption Command::NoPasswordOption = QMap commands; -Command::Command() +Command::Command() : currentDatabase(nullptr) { options.append(Command::QuietOption); } @@ -94,11 +95,11 @@ QSharedPointer Command::getCommandLineParser(const QStringLi parser->process(arguments); if (parser->positionalArguments().size() < positionalArguments.size()) { - errorTextStream << parser->helpText().replace("[options]", name.append(" [options]")); + errorTextStream << parser->helpText().replace("[options]", name + " [options]"); return QSharedPointer(nullptr); } if (parser->positionalArguments().size() > (positionalArguments.size() + optionalArguments.size())) { - errorTextStream << parser->helpText().replace("[options]", name.append(" [options]")); + errorTextStream << parser->helpText().replace("[options]", name + " [options]"); return QSharedPointer(nullptr); } return parser; @@ -119,6 +120,7 @@ void populateCommands() 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..f39d65a308 100644 --- a/src/cli/Command.h +++ b/src/cli/Command.h @@ -44,6 +44,7 @@ class Command virtual int execute(const QStringList& arguments) = 0; QString name; QString description; + QSharedPointer currentDatabase; QList positionalArguments; QList optionalArguments; QList options; diff --git a/src/cli/DatabaseCommand.cpp b/src/cli/DatabaseCommand.cpp index 65d4f15b2b..6d8152a942 100644 --- a/src/cli/DatabaseCommand.cpp +++ b/src/cli/DatabaseCommand.cpp @@ -28,19 +28,33 @@ DatabaseCommand::DatabaseCommand() int DatabaseCommand::execute(const QStringList& arguments) { - QSharedPointer parser = getCommandLineParser(arguments); + QStringList amendedArgs(arguments); + if (currentDatabase) { + amendedArgs.prepend(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/Open.cpp b/src/cli/Open.cpp new file mode 100644 index 0000000000..c0c4c365e8 --- /dev/null +++ b/src/cli/Open.cpp @@ -0,0 +1,37 @@ +/* + * 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::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..9460252aae --- /dev/null +++ b/src/cli/Open.h @@ -0,0 +1,30 @@ +/* + * 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 executeWithDatabase(QSharedPointer db, QSharedPointer parser) override; +}; + +#endif // KEEPASSXC_OPEN_H diff --git a/src/cli/Utils.cpp b/src/cli/Utils.cpp index 749f7ff200..a78850cdf9 100644 --- a/src/cli/Utils.cpp +++ b/src/cli/Utils.cpp @@ -225,4 +225,41 @@ 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 inside_quotes = 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 (!inside_quotes && (c == ' ' || c == '\t')) { + if (!cur.isEmpty()) { + result.append(cur); + cur.clear(); + } + } else if (c == '"' && (inside_quotes || i == 0 || command[i - 1].isSpace())) { + inside_quotes = !inside_quotes; + } 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 873a973ef5..1dc43f714a 100644 --- a/src/cli/keepassxc-cli.1 +++ b/src/cli/keepassxc-cli.1 @@ -49,6 +49,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. diff --git a/src/cli/keepassxc-cli.cpp b/src/cli/keepassxc-cli.cpp index 1f76812b07..1a187a47a5 100644 --- a/src/cli/keepassxc-cli.cpp +++ b/src/cli/keepassxc-cli.cpp @@ -16,9 +16,11 @@ */ #include +#include #include #include +#include #include #include "cli/TextStream.h" @@ -28,11 +30,137 @@ #include "core/Bootstrap.h" #include "core/Tools.h" #include "crypto/Crypto.h" +#include "Open.h" +#include "Utils.h" +#include "DatabaseCommand.h" #if defined(WITH_ASAN) && defined(WITH_LSAN) #include #endif +#if defined(USE_READLINE) +#include +#include +#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(QStringList arguments) { + + Open o; + arguments.removeFirst(); + o.execute(arguments); + + std::unique_ptr r; +#if defined(USE_READLINE) + r.reset(new ReadlineLineReader); +#else + r.reset(new SimpleLineReader); +#endif + + QSharedPointer currentDatabase(o.currentDatabase); + + QString command; + do { + TextStream errorTextStream(Utils::STDERR, QIODevice::WriteOnly); + + QString prompt; + if (currentDatabase) { + prompt += QDir::current().relativeFilePath(currentDatabase->filePath()); + } + prompt += "> "; + command = r->readLine(prompt); + if (r->isFinished()) { + return; + } + + QStringList args = Utils::splitCommandString(command); + if (args.empty()) { + continue; + } + + Command* cmd = Command::getCommand(args[0]); + if (cmd == nullptr) { + errorTextStream << "Unknown command " << args[0] << "\n"; + continue; + } + + cmd->currentDatabase = currentDatabase; + cmd->execute(args); + currentDatabase = cmd->currentDatabase; + } while (true); +} + int main(int argc, char** argv) { if (!Crypto::init()) { @@ -84,6 +212,10 @@ 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) { diff --git a/tests/TestCli.cpp b/tests/TestCli.cpp index d65a7af6cb..bfb6676964 100644 --- a/tests/TestCli.cpp +++ b/tests/TestCli.cpp @@ -44,6 +44,7 @@ #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 +60,8 @@ QTEST_MAIN(TestCli) +QSharedPointer globalCurrentDatabase; + void TestCli::initTestCase() { QVERIFY(Crypto::init()); @@ -162,7 +165,7 @@ QSharedPointer TestCli::readTestDatabase() const void TestCli::testCommand() { - QCOMPARE(Command::getCommands().size(), 14); + QCOMPARE(Command::getCommands().size(), 15); QVERIFY(Command::getCommand("add")); QVERIFY(Command::getCommand("analyze")); QVERIFY(Command::getCommand("clip")); @@ -175,6 +178,7 @@ void TestCli::testCommand() 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")); @@ -1322,3 +1326,67 @@ 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("input"); + QTest::addColumn("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({"\\"}); +} + +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 != nullptr); + + 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); +} diff --git a/tests/TestCli.h b/tests/TestCli.h index dcc84c2e36..a2605bce80 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(); @@ -60,6 +62,7 @@ private slots: void testList(); void testLocate(); void testMerge(); + void testOpen(); void testRemove(); void testRemoveQuiet(); void testShow();