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();