diff --git a/share/docs/man/keepassxc-cli.1 b/share/docs/man/keepassxc-cli.1 index d7ab9cdd7..d1360cd65 100644 --- a/share/docs/man/keepassxc-cli.1 +++ b/share/docs/man/keepassxc-cli.1 @@ -23,7 +23,7 @@ The same password generation options as documented for the generate command can Analyzes passwords in a database for weaknesses. .IP "\fBclip\fP [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. +Copies an attribute or the current TOTP (if the \fI-t\fP option is specified) of a database entry to the clipboard. If no attribute name is specified using the \fI-a\fP 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. 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. .IP "\fBclose\fP" In interactive mode, closes the currently opened database (see \fIopen\fP). @@ -174,10 +174,14 @@ hour or so). .SS "Clip options" -.IP "\fB-t\fP, \fB--totp\fP" -Copies the current TOTP instead of current password to clipboard. Will report -an error if no TOTP is configured for the entry. +.IP "\fB-a\fP, \fB--attribute\fP" +Copies the specified attribute to the clipboard. If no attribute is specified, +the password attribute is the default. For example, "\fI-a\fP username" would +copy the username to the clipboard. [Default: password] +.IP "\fB-t\fP, \fB--totp\fP" +Copies the current TOTP instead of the specified attribute to the clipboard. +Will report an error if no TOTP is configured for the entry. .SS "Create options" diff --git a/src/cli/Clip.cpp b/src/cli/Clip.cpp index 482ad8a13..ccb7c0e53 100644 --- a/src/cli/Clip.cpp +++ b/src/cli/Clip.cpp @@ -17,7 +17,6 @@ #include #include -#include #include #include "Clip.h" @@ -28,14 +27,23 @@ #include "core/Entry.h" #include "core/Group.h" -const QCommandLineOption Clip::TotpOption = QCommandLineOption(QStringList() << "t" - << "totp", - QObject::tr("Copy the current TOTP to the clipboard.")); +const QCommandLineOption Clip::AttributeOption = QCommandLineOption( + QStringList() << "a" + << "attribute", + QObject::tr("Copy the given attribute to the clipboard. Defaults to \"password\" if not specified."), + "attr", + "password"); + +const QCommandLineOption Clip::TotpOption = + QCommandLineOption(QStringList() << "t" + << "totp", + QObject::tr("Copy the current TOTP to the clipboard (equivalent to \"-a totp\").")); Clip::Clip() { name = QString("clip"); - description = QObject::tr("Copy an entry's password to the clipboard."); + description = QObject::tr("Copy an entry's attribute to the clipboard."); + options.append(Clip::AttributeOption); options.append(Clip::TotpOption); positionalArguments.append( {QString("entry"), QObject::tr("Path of the entry to clip.", "clip = copy to clipboard"), QString("")}); @@ -51,7 +59,6 @@ int Clip::executeWithDatabase(QSharedPointer database, QSharedPointer< if (args.size() == 3) { timeout = args.at(2); } - bool clipTotp = parser->isSet(Clip::TotpOption); TextStream errorTextStream(Utils::STDERR); int timeoutSeconds = 0; @@ -70,16 +77,39 @@ int Clip::executeWithDatabase(QSharedPointer database, QSharedPointer< return EXIT_FAILURE; } + if (parser->isSet(AttributeOption) && parser->isSet(TotpOption)) { + errorTextStream << QObject::tr("ERROR: Please specify one of --attribute or --totp, not both.") << endl; + return EXIT_FAILURE; + } + + QString selectedAttribute = parser->value(AttributeOption); QString value; - if (clipTotp) { + bool found = false; + if (parser->isSet(TotpOption) || selectedAttribute == "totp") { if (!entry->hasTotp()) { errorTextStream << QObject::tr("Entry with path %1 has no TOTP set up.").arg(entryPath) << endl; return EXIT_FAILURE; } + found = true; value = entry->totp(); } else { - value = entry->password(); + QStringList attrs = Utils::findAttributes(*entry->attributes(), selectedAttribute); + if (attrs.size() > 1) { + errorTextStream << QObject::tr("ERROR: attribute %1 is ambiguous, it matches %2.") + .arg(selectedAttribute, QLocale().createSeparatedList(attrs)) + << endl; + return EXIT_FAILURE; + } else if (attrs.size() == 1) { + found = true; + selectedAttribute = attrs[0]; + value = entry->attributes()->value(selectedAttribute); + } + } + + if (!found) { + outputTextStream << QObject::tr("Attribute \"%1\" not found.").arg(selectedAttribute) << endl; + return EXIT_FAILURE; } int exitCode = Utils::clipText(value); @@ -87,11 +117,7 @@ int Clip::executeWithDatabase(QSharedPointer database, QSharedPointer< return exitCode; } - if (clipTotp) { - outputTextStream << QObject::tr("Entry's current TOTP copied to the clipboard!") << endl; - } else { - outputTextStream << QObject::tr("Entry's password copied to the clipboard!") << endl; - } + outputTextStream << QObject::tr("Entry's \"%1\" attribute copied to the clipboard!").arg(selectedAttribute) << endl; if (!timeoutSeconds) { return exitCode; diff --git a/src/cli/Clip.h b/src/cli/Clip.h index b171c8689..291e63295 100644 --- a/src/cli/Clip.h +++ b/src/cli/Clip.h @@ -27,6 +27,7 @@ public: int executeWithDatabase(QSharedPointer db, QSharedPointer parser) override; + static const QCommandLineOption AttributeOption; static const QCommandLineOption TotpOption; }; diff --git a/src/cli/Show.cpp b/src/cli/Show.cpp index f7bf8d54b..12b2a835f 100644 --- a/src/cli/Show.cpp +++ b/src/cli/Show.cpp @@ -27,6 +27,8 @@ #include "core/Global.h" #include "core/Group.h" +#include + const QCommandLineOption Show::TotpOption = QCommandLineOption(QStringList() << "t" << "totp", QObject::tr("Show the entry's current TOTP.")); @@ -79,25 +81,33 @@ int Show::executeWithDatabase(QSharedPointer database, QSharedPointer< // If no attributes specified, output the default attribute set. bool showDefaultAttributes = attributes.isEmpty() && !showTotp; - if (attributes.isEmpty() && !showTotp) { + if (showDefaultAttributes) { attributes = EntryAttributes::DefaultAttributes; } // Iterate over the attributes and output them line-by-line. - bool sawUnknownAttribute = false; + bool encounteredError = false; for (const QString& attributeName : asConst(attributes)) { - if (!entry->attributes()->contains(attributeName)) { - sawUnknownAttribute = true; + QStringList attrs = Utils::findAttributes(*entry->attributes(), attributeName); + if (attrs.isEmpty()) { + encounteredError = true; errorTextStream << QObject::tr("ERROR: unknown attribute %1.").arg(attributeName) << endl; continue; + } else if (attrs.size() > 1) { + encounteredError = true; + errorTextStream << QObject::tr("ERROR: attribute %1 is ambiguous, it matches %2.") + .arg(attributeName, QLocale().createSeparatedList(attrs)) + << endl; + continue; } + QString canonicalName = attrs[0]; if (showDefaultAttributes) { - outputTextStream << attributeName << ": "; + outputTextStream << canonicalName << ": "; } - if (entry->attributes()->isProtected(attributeName) && showDefaultAttributes && !showProtectedAttributes) { + if (entry->attributes()->isProtected(canonicalName) && showDefaultAttributes && !showProtectedAttributes) { outputTextStream << "PROTECTED" << endl; } else { - outputTextStream << entry->resolveMultiplePlaceholders(entry->attributes()->value(attributeName)) << endl; + outputTextStream << entry->resolveMultiplePlaceholders(entry->attributes()->value(canonicalName)) << endl; } } @@ -105,5 +115,5 @@ int Show::executeWithDatabase(QSharedPointer database, QSharedPointer< outputTextStream << entry->totp() << endl; } - return sawUnknownAttribute ? EXIT_FAILURE : EXIT_SUCCESS; + return encounteredError ? EXIT_FAILURE : EXIT_SUCCESS; } diff --git a/src/cli/Utils.cpp b/src/cli/Utils.cpp index 9988b60f9..88d70466b 100644 --- a/src/cli/Utils.cpp +++ b/src/cli/Utils.cpp @@ -331,4 +331,21 @@ namespace Utils return result; } + QStringList findAttributes(const EntryAttributes& attributes, const QString& name) + { + QStringList result; + if (attributes.hasKey(name)) { + result.append(name); + return result; + } + + for (const QString& key : attributes.keys()) { + if (key.compare(name, Qt::CaseSensitivity::CaseInsensitive) == 0) { + result.append(key); + } + } + + return result; + } + } // namespace Utils diff --git a/src/cli/Utils.h b/src/cli/Utils.h index b7fa63acf..d96e260c4 100644 --- a/src/cli/Utils.h +++ b/src/cli/Utils.h @@ -20,6 +20,7 @@ #include "cli/TextStream.h" #include "core/Database.h" +#include "core/EntryAttributes.h" #include "keys/CompositeKey.h" #include "keys/FileKey.h" #include "keys/PasswordKey.h" @@ -51,6 +52,14 @@ namespace Utils QStringList splitCommandString(const QString& command); + /** + * If `attributes` contains an attribute named `name` (case-sensitive), + * returns a list containing only `name`. Otherwise, returns the list of + * all attribute names in `attributes` matching the given name + * (case-insensitive). + */ + QStringList findAttributes(const EntryAttributes& attributes, const QString& name); + namespace Test { void setNextPassword(const QString& password); diff --git a/tests/TestCli.cpp b/tests/TestCli.cpp index bc96de974..efaff1831 100644 --- a/tests/TestCli.cpp +++ b/tests/TestCli.cpp @@ -480,7 +480,7 @@ void TestCli::testClip() QCOMPARE(clipboard->text(), QString("Password")); m_stdoutFile->readLine(); // skip prompt line - QCOMPARE(m_stdoutFile->readLine(), QByteArray("Entry's password copied to the clipboard!\n")); + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Entry's \"Password\" attribute copied to the clipboard!\n")); // Quiet option qint64 pos = m_stdoutFile->pos(); @@ -491,6 +491,11 @@ void TestCli::testClip() QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); QCOMPARE(clipboard->text(), QString("Password")); + // Username + Utils::Test::setNextPassword("a"); + clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry", "-a", "username"}); + QCOMPARE(clipboard->text(), QString("User Name")); + // TOTP Utils::Test::setNextPassword("a"); clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry", "--totp"}); @@ -538,6 +543,20 @@ void TestCli::testClip() 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")); + + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + clipCmd.execute({"clip", m_dbFile->fileName(), "-a", "TESTAttribute1", "/Sample Entry"}); + m_stderrFile->seek(posErr); + QCOMPARE( + m_stderrFile->readAll(), + QByteArray("ERROR: attribute TESTAttribute1 is ambiguous, it matches TestAttribute1 and testattribute1.\n")); + + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + clipCmd.execute({"clip", m_dbFile2->fileName(), "--attribute", "Username", "--totp", "/Sample Entry"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("ERROR: Please specify one of --attribute or --totp, not both.\n")); } void TestCli::testCreate() @@ -1913,6 +1932,16 @@ void TestCli::testShow() QByteArray("Sample Entry\n" "http://www.somesite.com/\n")); + // Test case insensitivity + 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"}); @@ -1946,6 +1975,19 @@ void TestCli::testShow() 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")); + + // Show with ambiguous attributes + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", m_dbFile->fileName(), "-a", "Testattribute1", "/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("ERROR: attribute Testattribute1 is ambiguous, it matches TestAttribute1 and testattribute1.\n")); } void TestCli::testInvalidDbFiles() diff --git a/tests/data/NewDatabase.kdbx b/tests/data/NewDatabase.kdbx index 3008cce7c..a6d6adb17 100644 Binary files a/tests/data/NewDatabase.kdbx and b/tests/data/NewDatabase.kdbx differ