CLI Improvements

* Fix #6001 - only use `--notes` in Add/Edit commands to prevent clash with password generator option `-n`.

* Fix #6119 - Send Unicode to clip command; Windows only understands UTF-16 encoding.

* Fix #6128 - `clip` command will default to clearing the clipboard after 10 seconds. To disable clearing set timeout to 0.
This commit is contained in:
Jonathan White 2021-04-18 16:11:57 -04:00
parent be3e77d721
commit 8a7be101e4
6 changed files with 52 additions and 47 deletions

View File

@ -45,7 +45,7 @@ It provides the ability to query and modify the entries of a KeePass database, d
If no attribute name is specified using the *-a* option, the password is copied. If no attribute name is specified using the *-a* option, the password is copied.
If multiple entries with the same name exist in different groups, only the attribute for the first one is copied. If multiple entries with the same name exist in different groups, only the attribute for the first one is copied.
For copying the attribute of an entry in a specific group, the group path to the entry should be specified as well, instead of just the name. For copying the attribute 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. Optionally, a timeout in seconds can be specified to automatically clear the clipboard, the default timeout is 10 seconds, set to 0 to disable.
*close*:: *close*::
In interactive mode, closes the currently opened database (see *open*). In interactive mode, closes the currently opened database (see *open*).
@ -182,7 +182,7 @@ The same password generation options as documented for the generate command can
*--url* <__url__>:: *--url* <__url__>::
Specifies the URL of the entry. Specifies the URL of the entry.
*-n*, *--notes* <__notes__>:: *--notes* <__notes__>::
Specifies the notes of the entry. Specifies the notes of the entry.
*-p*, *--password-prompt*:: *-p*, *--password-prompt*::

View File

@ -30,10 +30,8 @@ const QCommandLineOption Add::UsernameOption = QCommandLineOption(QStringList()
const QCommandLineOption Add::UrlOption = const QCommandLineOption Add::UrlOption =
QCommandLineOption(QStringList() << "url", QObject::tr("URL for the entry."), QObject::tr("URL")); QCommandLineOption(QStringList() << "url", QObject::tr("URL for the entry."), QObject::tr("URL"));
const QCommandLineOption Add::NotesOption = QCommandLineOption(QStringList() << "n" const QCommandLineOption Add::NotesOption =
<< "notes", QCommandLineOption(QStringList() << "notes", QObject::tr("Notes for the entry."), QObject::tr("Notes"));
QObject::tr("Notes for the entry."),
QObject::tr("Notes"));
const QCommandLineOption Add::PasswordPromptOption = const QCommandLineOption Add::PasswordPromptOption =
QCommandLineOption(QStringList() << "p" QCommandLineOption(QStringList() << "p"

View File

@ -22,6 +22,8 @@
#include "core/Group.h" #include "core/Group.h"
#include "core/Tools.h" #include "core/Tools.h"
#define CLI_DEFAULT_CLIP_TIMEOUT 10
const QCommandLineOption Clip::AttributeOption = QCommandLineOption( const QCommandLineOption Clip::AttributeOption = QCommandLineOption(
QStringList() << "a" QStringList() << "a"
<< "attribute", << "attribute",
@ -50,7 +52,10 @@ Clip::Clip()
positionalArguments.append( positionalArguments.append(
{QString("entry"), QObject::tr("Path of the entry to clip.", "clip = copy to clipboard"), QString("")}); {QString("entry"), QObject::tr("Path of the entry to clip.", "clip = copy to clipboard"), QString("")});
optionalArguments.append( optionalArguments.append(
{QString("timeout"), QObject::tr("Timeout in seconds before clearing the clipboard."), QString("[timeout]")}); {QString("timeout"),
QObject::tr("Timeout before clearing the clipboard (default is %1 seconds, set to 0 for unlimited).")
.arg(CLI_DEFAULT_CLIP_TIMEOUT),
QString("[timeout]")});
} }
int Clip::executeWithDatabase(QSharedPointer<Database> database, QSharedPointer<QCommandLineParser> parser) int Clip::executeWithDatabase(QSharedPointer<Database> database, QSharedPointer<QCommandLineParser> parser)
@ -59,13 +64,18 @@ int Clip::executeWithDatabase(QSharedPointer<Database> database, QSharedPointer<
auto& err = Utils::STDERR; auto& err = Utils::STDERR;
const QStringList args = parser->positionalArguments(); const QStringList args = parser->positionalArguments();
QString bestEntryPath;
QString timeout; auto timeout = CLI_DEFAULT_CLIP_TIMEOUT;
if (args.size() == 3) { if (args.size() == 3) {
timeout = args.at(2); bool ok;
timeout = args.at(2).toInt(&ok);
if (!ok) {
err << QObject::tr("Invalid timeout value %1.").arg(args.at(2)) << endl;
return EXIT_FAILURE;
}
} }
QString entryPath;
if (parser->isSet(Clip::BestMatchOption)) { if (parser->isSet(Clip::BestMatchOption)) {
QStringList results = database->rootGroup()->locate(args.at(1)); QStringList results = database->rootGroup()->locate(args.at(1));
if (results.count() > 1) { if (results.count() > 1) {
@ -75,24 +85,14 @@ int Clip::executeWithDatabase(QSharedPointer<Database> database, QSharedPointer<
} }
return EXIT_FAILURE; return EXIT_FAILURE;
} else { } else {
bestEntryPath = (results.isEmpty()) ? args.at(1) : results[0]; entryPath = (results.isEmpty()) ? args.at(1) : results[0];
out << QObject::tr("Used matching entry: %1").arg(bestEntryPath) << endl; out << QObject::tr("Used matching entry: %1").arg(entryPath) << endl;
} }
} else { } else {
bestEntryPath = args.at(1); entryPath = args.at(1);
} }
const QString& entryPath = bestEntryPath; auto* entry = database->rootGroup()->findEntryByPath(entryPath);
int timeoutSeconds = 0;
if (!timeout.isEmpty() && timeout.toInt() <= 0) {
err << QObject::tr("Invalid timeout value %1.").arg(timeout) << endl;
return EXIT_FAILURE;
} else if (!timeout.isEmpty()) {
timeoutSeconds = timeout.toInt();
}
Entry* entry = database->rootGroup()->findEntryByPath(entryPath);
if (!entry) { if (!entry) {
err << QObject::tr("Entry %1 not found.").arg(entryPath) << endl; err << QObject::tr("Entry %1 not found.").arg(entryPath) << endl;
return EXIT_FAILURE; return EXIT_FAILURE;
@ -140,17 +140,17 @@ int Clip::executeWithDatabase(QSharedPointer<Database> database, QSharedPointer<
out << QObject::tr("Entry's \"%1\" attribute copied to the clipboard!").arg(selectedAttribute) << endl; out << QObject::tr("Entry's \"%1\" attribute copied to the clipboard!").arg(selectedAttribute) << endl;
if (!timeoutSeconds) { if (timeout <= 0) {
return exitCode; return exitCode;
} }
QString lastLine = ""; QString lastLine = "";
while (timeoutSeconds > 0) { while (timeout > 0) {
out << '\r' << QString(lastLine.size(), ' ') << '\r'; out << '\r' << QString(lastLine.size(), ' ') << '\r';
lastLine = QObject::tr("Clearing the clipboard in %1 second(s)", "", timeoutSeconds).arg(timeoutSeconds); lastLine = QObject::tr("Clearing the clipboard in %1 second(s)...", "", timeout).arg(timeout);
out << lastLine << flush; out << lastLine << flush;
Tools::sleep(1000); Tools::sleep(1000);
--timeoutSeconds; --timeout;
} }
Utils::clipText(""); Utils::clipText("");
out << '\r' << QString(lastLine.size(), ' ') << '\r'; out << '\r' << QString(lastLine.size(), ' ') << '\r';

View File

@ -308,7 +308,14 @@ namespace Utils
continue; continue;
} }
if (clipProcess->write(text.toLatin1()) == -1) { #ifdef Q_OS_WIN
// Windows clip command only understands Unicode written as UTF-16
auto data = QByteArray::fromRawData(reinterpret_cast<const char*>(text.utf16()), text.size() * 2);
if (clipProcess->write(data) == -1) {
#else
// Other platforms understand UTF-8
if (clipProcess->write(text.toUtf8()) == -1) {
#endif
qDebug("Unable to write to process : %s", qPrintable(clipProcess->errorString())); qDebug("Unable to write to process : %s", qPrintable(clipProcess->errorString()));
} }
clipProcess->waitForBytesWritten(); clipProcess->waitForBytesWritten();

View File

@ -284,7 +284,7 @@ void TestCli::testAdd()
"-g", "-g",
"-L", "-L",
"20", "20",
"-n", "--notes",
"some notes", "some notes",
m_dbFile->fileName(), m_dbFile->fileName(),
"/newuser-entry"}); "/newuser-entry"});
@ -360,7 +360,7 @@ void TestCli::testAdd()
QVERIFY(!defaultPasswordClassesRegex.match(entry->password()).hasMatch()); QVERIFY(!defaultPasswordClassesRegex.match(entry->password()).hasMatch());
setInput("a"); setInput("a");
execCmd(addCmd, {"add", "-u", "newuser5", "-n", "test\\nnew line", m_dbFile->fileName(), "/newuser-entry5"}); execCmd(addCmd, {"add", "-u", "newuser5", "--notes", "test\\nnew line", m_dbFile->fileName(), "/newuser-entry5"});
m_stderr->readLine(); // skip password prompt m_stderr->readLine(); // skip password prompt
QCOMPARE(m_stderr->readAll(), QByteArray("")); QCOMPARE(m_stderr->readAll(), QByteArray(""));
QCOMPARE(m_stdout->readAll(), QByteArray("Successfully added entry newuser-entry5.\n")); QCOMPARE(m_stdout->readAll(), QByteArray("Successfully added entry newuser-entry5.\n"));
@ -446,7 +446,7 @@ void TestCli::testClip()
// Password // Password
setInput("a"); setInput("a");
execCmd(clipCmd, {"clip", m_dbFile->fileName(), "/Sample Entry"}); execCmd(clipCmd, {"clip", m_dbFile->fileName(), "/Sample Entry", "0"});
QString errorOutput(m_stderr->readAll()); QString errorOutput(m_stderr->readAll());
if (QProcessEnvironment::systemEnvironment().contains("WAYLAND_DISPLAY")) { if (QProcessEnvironment::systemEnvironment().contains("WAYLAND_DISPLAY")) {
@ -465,22 +465,26 @@ void TestCli::testClip()
// Quiet option // Quiet option
setInput("a"); setInput("a");
execCmd(clipCmd, {"clip", m_dbFile->fileName(), "/Sample Entry", "-q"}); execCmd(clipCmd, {"clip", m_dbFile->fileName(), "/Sample Entry", "0", "-q"});
QCOMPARE(m_stderr->readAll(), QByteArray()); QCOMPARE(m_stderr->readAll(), QByteArray());
QCOMPARE(m_stdout->readAll(), QByteArray()); QCOMPARE(m_stdout->readAll(), QByteArray());
QTRY_COMPARE(clipboard->text(), QString("Password")); QTRY_COMPARE(clipboard->text(), QString("Password"));
// Username // Username
setInput("a"); setInput("a");
execCmd(clipCmd, {"clip", m_dbFile->fileName(), "/Sample Entry", "-a", "username"}); execCmd(clipCmd, {"clip", m_dbFile->fileName(), "/Sample Entry", "0", "-a", "username"});
QTRY_COMPARE(clipboard->text(), QString("User Name")); QTRY_COMPARE(clipboard->text(), QString("User Name"));
// TOTP // TOTP
setInput("a"); setInput("a");
execCmd(clipCmd, {"clip", m_dbFile->fileName(), "/Sample Entry", "--totp"}); execCmd(clipCmd, {"clip", m_dbFile->fileName(), "/Sample Entry", "0", "--totp"});
QTRY_VERIFY(isTotp(clipboard->text())); QTRY_VERIFY(isTotp(clipboard->text()));
// Test Unicode
setInput("a");
execCmd(clipCmd, {"clip", m_dbFile2->fileName(), "/General/Unicode", "0", "-a", "username"});
QTRY_COMPARE(clipboard->text(), QString(R"\_(ツ)_/¯)"));
// Password with timeout // Password with timeout
setInput("a"); setInput("a");
// clang-format off // clang-format off
@ -505,35 +509,31 @@ void TestCli::testClip()
future.waitForFinished(); future.waitForFinished();
setInput("a");
execCmd(clipCmd, {"clip", m_dbFile->fileName(), "--totp", "/Sample Entry", "0"});
QVERIFY(m_stderr->readAll().contains("Invalid timeout value 0.\n"));
setInput("a"); setInput("a");
execCmd(clipCmd, {"clip", m_dbFile->fileName(), "--totp", "/Sample Entry", "bleuh"}); execCmd(clipCmd, {"clip", m_dbFile->fileName(), "--totp", "/Sample Entry", "bleuh"});
QVERIFY(m_stderr->readAll().contains("Invalid timeout value bleuh.\n")); QVERIFY(m_stderr->readAll().contains("Invalid timeout value bleuh.\n"));
setInput("a"); setInput("a");
execCmd(clipCmd, {"clip", m_dbFile2->fileName(), "--totp", "/Sample Entry"}); execCmd(clipCmd, {"clip", m_dbFile2->fileName(), "--totp", "/Sample Entry", "0"});
QVERIFY(m_stderr->readAll().contains("Entry with path /Sample Entry has no TOTP set up.\n")); QVERIFY(m_stderr->readAll().contains("Entry with path /Sample Entry has no TOTP set up.\n"));
setInput("a"); setInput("a");
execCmd(clipCmd, {"clip", m_dbFile->fileName(), "-a", "TESTAttribute1", "/Sample Entry"}); execCmd(clipCmd, {"clip", m_dbFile->fileName(), "-a", "TESTAttribute1", "/Sample Entry", "0"});
QVERIFY(m_stderr->readAll().contains("ERROR: attribute TESTAttribute1 is ambiguous")); QVERIFY(m_stderr->readAll().contains("ERROR: attribute TESTAttribute1 is ambiguous"));
setInput("a"); setInput("a");
execCmd(clipCmd, {"clip", m_dbFile2->fileName(), "--attribute", "Username", "--totp", "/Sample Entry"}); execCmd(clipCmd, {"clip", m_dbFile2->fileName(), "--attribute", "Username", "--totp", "/Sample Entry", "0"});
QVERIFY(m_stderr->readAll().contains("ERROR: Please specify one of --attribute or --totp, not both.\n")); QVERIFY(m_stderr->readAll().contains("ERROR: Please specify one of --attribute or --totp, not both.\n"));
// Best option // Best option
setInput("a"); setInput("a");
execCmd(clipCmd, {"clip", m_dbFileMulti->fileName(), "Multi", "-b"}); execCmd(clipCmd, {"clip", m_dbFileMulti->fileName(), "Multi", "0", "-b"});
QByteArray errorChoices = m_stderr->readAll(); QByteArray errorChoices = m_stderr->readAll();
QVERIFY(errorChoices.contains("Multi Entry 1")); QVERIFY(errorChoices.contains("Multi Entry 1"));
QVERIFY(errorChoices.contains("Multi Entry 2")); QVERIFY(errorChoices.contains("Multi Entry 2"));
setInput("a"); setInput("a");
execCmd(clipCmd, {"clip", m_dbFileMulti->fileName(), "Entry 2", "-b"}); execCmd(clipCmd, {"clip", m_dbFileMulti->fileName(), "Entry 2", "0", "-b"});
QTRY_COMPARE(clipboard->text(), QString("Password2")); QTRY_COMPARE(clipboard->text(), QString("Password2"));
} }
@ -750,7 +750,7 @@ void TestCli::testEdit()
"newuser", "newuser",
"--url", "--url",
"https://otherurl.example.com/", "https://otherurl.example.com/",
"-n", "--notes",
"newnotes", "newnotes",
"-t", "-t",
"newtitle", "newtitle",
@ -825,7 +825,7 @@ void TestCli::testEdit()
// with line break in notes // with line break in notes
setInput("a"); setInput("a");
execCmd(editCmd, {"edit", m_dbFile->fileName(), "-n", "testing\\nline breaks", "/evennewertitle"}); execCmd(editCmd, {"edit", m_dbFile->fileName(), "--notes", "testing\\nline breaks", "/evennewertitle"});
db = readDatabase(); db = readDatabase();
entry = db->rootGroup()->findEntryByPath("/evennewertitle"); entry = db->rootGroup()->findEntryByPath("/evennewertitle");
QVERIFY(entry); QVERIFY(entry);

Binary file not shown.