diff --git a/docs/man/keepassxc-cli.1.adoc b/docs/man/keepassxc-cli.1.adoc index 1cee5ad68..b5eabd550 100644 --- a/docs/man/keepassxc-cli.1.adoc +++ b/docs/man/keepassxc-cli.1.adoc @@ -40,6 +40,17 @@ It provides the ability to query and modify the entries of a KeePass database, d *analyze* [_options_] <__database__>:: Analyzes passwords in a database for weaknesses using offline HIBP SHA-1 hash lookup. +*attachment-export* [_options_] <__database__> <__entry__> <__attachment_name__> <__export_file__>:: + Exports the content of an attachment to a specified file. + Use *--stdout* option to instead output the contents of the attachment to stdout. + +*attachment-import* [_options_] <__database__> <__entry__> <__attachment_name__> <__import_file__>:: + Imports the attachment into an entry. + An existing attachment with the same name may be overwritten if the *-f* option is specified. + +*attachment-rm* <__database__> <__entry__> <__attachment_name__>:: + Removes the named attachment from an entry. + *clip* [_options_] <__database__> <__entry__> [_timeout_]:: Copies an attribute or the current TOTP (if the *-t* option is specified) of a database entry to the clipboard. If no attribute name is specified using the *-a* option, the password is copied. @@ -244,6 +255,9 @@ The same password generation options as documented for the generate command can *-s*, *--show-protected*:: Shows the protected attributes in clear text. +*--show-attachments*:: + Shows the attachment names along with the size of the attachments. + *-t*, *--totp*:: Also shows the current TOTP, reporting an error if no TOTP is configured for the entry. diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index c3ffb2d9c..dd87e1920 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -7386,6 +7386,94 @@ Please consider generating a new key file. %1 (invalid executable path) + + Export an attachment of an entry. + + + + Path of the entry with the target attachment. + + + + Name of the attachment to be exported. + + + + Path to which the attachment should be exported. + + + + Could not find attachment with name %1. + + + + No export target given. Please use '--stdout' or specify an 'export-file'. + + + + Could not open output file %1. + + + + Successfully exported attachment %1 of entry %2 to %3. + + + + Overwrite existing attachments. + + + + Imports an attachment to an entry. + + + + Path of the entry. + + + + Name of the attachment to be added. + + + + Path of the attachment to be imported. + + + + Attachment %1 already exists for entry %2. + + + + Could not open attachment file %1. + + + + Successfully imported attachment %1 as %2 to entry %3. + + + + Remove an attachment of an entry. + + + + Name of the attachment to be removed. + + + + Successfully removed attachment %1 from entry %2. + + + + Show the attachments of the entry. + + + + No attachments present. + + + + Attachments: + + QtIOCompressor diff --git a/src/cli/AttachmentExport.cpp b/src/cli/AttachmentExport.cpp new file mode 100644 index 000000000..46dc5f4b6 --- /dev/null +++ b/src/cli/AttachmentExport.cpp @@ -0,0 +1,88 @@ +/* + * 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 "AttachmentExport.h" + +#include "Utils.h" +#include "core/Group.h" + +#include +#include + +const QCommandLineOption AttachmentExport::StdoutOption = + QCommandLineOption(QStringList() << "stdout", QObject::tr("")); + +AttachmentExport::AttachmentExport() +{ + name = QString("attachment-export"); + description = QObject::tr("Export an attachment of an entry."); + options.append(AttachmentExport::StdoutOption); + positionalArguments.append( + {QString("entry"), QObject::tr("Path of the entry with the target attachment."), QString("")}); + positionalArguments.append( + {QString("attachment-name"), QObject::tr("Name of the attachment to be exported."), QString("")}); + optionalArguments.append( + {QString("export-file"), QObject::tr("Path to which the attachment should be exported."), QString("")}); +} + +int AttachmentExport::executeWithDatabase(QSharedPointer database, QSharedPointer parser) +{ + auto& out = parser->isSet(Command::QuietOption) ? Utils::DEVNULL : Utils::STDOUT; + auto& err = Utils::STDERR; + + auto args = parser->positionalArguments(); + auto entryPath = args.at(1); + + auto entry = database->rootGroup()->findEntryByPath(entryPath); + if (!entry) { + err << QObject::tr("Could not find entry with path %1.").arg(entryPath) << endl; + return EXIT_FAILURE; + } + + auto attachmentName = args.at(2); + + auto attachments = entry->attachments(); + if (!attachments->hasKey(attachmentName)) { + err << QObject::tr("Could not find attachment with name %1.").arg(attachmentName) << endl; + return EXIT_FAILURE; + } + + if (parser->isSet(AttachmentExport::StdoutOption)) { + // Output to STDOUT even in quiet mode + Utils::STDOUT << attachments->value(attachmentName) << flush; + return EXIT_SUCCESS; + } + + if (args.size() < 4) { + err << QObject::tr("No export target given. Please use '--stdout' or specify an 'export-file'.") << endl; + return EXIT_FAILURE; + } + + auto exportFileName = args.at(3); + QFile exportFile(exportFileName); + if (!exportFile.open(QIODevice::WriteOnly)) { + err << QObject::tr("Could not open output file %1.").arg(exportFileName) << endl; + return EXIT_FAILURE; + } + exportFile.write(attachments->value(attachmentName)); + + out << QObject::tr("Successfully exported attachment %1 of entry %2 to %3.") + .arg(attachmentName, entryPath, exportFileName) + << endl; + + return EXIT_SUCCESS; +} diff --git a/src/cli/AttachmentExport.h b/src/cli/AttachmentExport.h new file mode 100644 index 000000000..9fd8c15ec --- /dev/null +++ b/src/cli/AttachmentExport.h @@ -0,0 +1,33 @@ +/* + * 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_ATTACHMENTEXPORT_H +#define KEEPASSXC_ATTACHMENTEXPORT_H + +#include "DatabaseCommand.h" + +class AttachmentExport : public DatabaseCommand +{ +public: + AttachmentExport(); + + int executeWithDatabase(QSharedPointer db, QSharedPointer parser) override; + + static const QCommandLineOption StdoutOption; +}; + +#endif // KEEPASSXC_ATTACHMENTEXPORT_H diff --git a/src/cli/AttachmentImport.cpp b/src/cli/AttachmentImport.cpp new file mode 100644 index 000000000..080f81af0 --- /dev/null +++ b/src/cli/AttachmentImport.cpp @@ -0,0 +1,87 @@ +/* + * 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 "AttachmentImport.h" + +#include "Utils.h" +#include "core/Group.h" + +#include +#include + +const QCommandLineOption AttachmentImport::ForceOption = + QCommandLineOption(QStringList() << "f" + << "force", + QObject::tr("Overwrite existing attachments.")); + +AttachmentImport::AttachmentImport() +{ + name = QString("attachment-import"); + description = QObject::tr("Imports an attachment to an entry."); + options.append(AttachmentImport::ForceOption); + positionalArguments.append({QString("entry"), QObject::tr("Path of the entry."), QString("")}); + positionalArguments.append( + {QString("attachment-name"), QObject::tr("Name of the attachment to be added."), QString("")}); + positionalArguments.append( + {QString("import-file"), QObject::tr("Path of the attachment to be imported."), QString("")}); +} + +int AttachmentImport::executeWithDatabase(QSharedPointer database, QSharedPointer parser) +{ + auto& out = parser->isSet(Command::QuietOption) ? Utils::DEVNULL : Utils::STDOUT; + auto& err = Utils::STDERR; + + auto args = parser->positionalArguments(); + auto entryPath = args.at(1); + + auto entry = database->rootGroup()->findEntryByPath(entryPath); + if (!entry) { + err << QObject::tr("Could not find entry with path %1.").arg(entryPath) << endl; + return EXIT_FAILURE; + } + + auto attachmentName = args.at(2); + + auto attachments = entry->attachments(); + if (attachments->hasKey(attachmentName) && !parser->isSet(AttachmentImport::ForceOption)) { + err << QObject::tr("Attachment %1 already exists for entry %2.").arg(attachmentName, entryPath) << endl; + return EXIT_FAILURE; + } + + auto importFileName = args.at(3); + + QFile importFile(importFileName); + if (!importFile.open(QIODevice::ReadOnly)) { + err << QObject::tr("Could not open attachment file %1.").arg(importFileName) << endl; + return EXIT_FAILURE; + } + + entry->beginUpdate(); + attachments->set(attachmentName, importFile.readAll()); + entry->endUpdate(); + + QString errorMessage; + if (!database->save(Database::Atomic, false, &errorMessage)) { + err << QObject::tr("Writing the database failed %1.").arg(errorMessage) << endl; + return EXIT_FAILURE; + } + + out << QObject::tr("Successfully imported attachment %1 as %2 to entry %3.") + .arg(importFileName, attachmentName, entryPath) + << endl; + return EXIT_SUCCESS; +} diff --git a/src/cli/AttachmentImport.h b/src/cli/AttachmentImport.h new file mode 100644 index 000000000..ff6686af9 --- /dev/null +++ b/src/cli/AttachmentImport.h @@ -0,0 +1,33 @@ +/* + * 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_ATTACHMENTIMPORT_H +#define KEEPASSXC_ATTACHMENTIMPORT_H + +#include "DatabaseCommand.h" + +class AttachmentImport : public DatabaseCommand +{ +public: + AttachmentImport(); + + int executeWithDatabase(QSharedPointer db, QSharedPointer parser) override; + + static const QCommandLineOption ForceOption; +}; + +#endif // KEEPASSXC_ATTACHMENTIMPORT_H diff --git a/src/cli/AttachmentRemove.cpp b/src/cli/AttachmentRemove.cpp new file mode 100644 index 000000000..6561948b0 --- /dev/null +++ b/src/cli/AttachmentRemove.cpp @@ -0,0 +1,68 @@ +/* + * 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 "AttachmentRemove.h" + +#include "Utils.h" +#include "core/Group.h" + +#include + +AttachmentRemove::AttachmentRemove() +{ + name = QString("attachment-rm"); + description = QObject::tr("Remove an attachment of an entry."); + positionalArguments.append( + {QString("entry"), QObject::tr("Path of the entry with the target attachment."), QString("")}); + positionalArguments.append({QString("name"), QObject::tr("Name of the attachment to be removed."), QString("")}); +} + +int AttachmentRemove::executeWithDatabase(QSharedPointer database, QSharedPointer parser) +{ + auto& out = parser->isSet(Command::QuietOption) ? Utils::DEVNULL : Utils::STDOUT; + auto& err = Utils::STDERR; + + auto args = parser->positionalArguments(); + auto entryPath = args.at(1); + + auto entry = database->rootGroup()->findEntryByPath(entryPath); + if (!entry) { + err << QObject::tr("Could not find entry with path %1.").arg(entryPath) << endl; + return EXIT_FAILURE; + } + + auto attachmentName = args.at(2); + + auto attachments = entry->attachments(); + if (!attachments->hasKey(attachmentName)) { + err << QObject::tr("Could not find attachment with name %1.").arg(attachmentName) << endl; + return EXIT_FAILURE; + } + + entry->beginUpdate(); + attachments->remove(attachmentName); + entry->endUpdate(); + + QString errorMessage; + if (!database->save(Database::Atomic, false, &errorMessage)) { + err << QObject::tr("Writing the database failed %1.").arg(errorMessage) << endl; + return EXIT_FAILURE; + } + + out << QObject::tr("Successfully removed attachment %1 from entry %2.").arg(attachmentName, entryPath) << endl; + return EXIT_SUCCESS; +} diff --git a/src/cli/AttachmentRemove.h b/src/cli/AttachmentRemove.h new file mode 100644 index 000000000..c6a494a3b --- /dev/null +++ b/src/cli/AttachmentRemove.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_ATTACHMENTREMOVE_H +#define KEEPASSXC_ATTACHMENTREMOVE_H + +#include "DatabaseCommand.h" + +class AttachmentRemove : public DatabaseCommand +{ +public: + AttachmentRemove(); + + int executeWithDatabase(QSharedPointer db, QSharedPointer parser) override; +}; + +#endif // KEEPASSXC_ATTACHMENTMOVE_H diff --git a/src/cli/CMakeLists.txt b/src/cli/CMakeLists.txt index 0b348d3c9..470af6283 100644 --- a/src/cli/CMakeLists.txt +++ b/src/cli/CMakeLists.txt @@ -17,6 +17,9 @@ set(cli_SOURCES Add.cpp AddGroup.cpp Analyze.cpp + AttachmentExport.cpp + AttachmentImport.cpp + AttachmentRemove.cpp Clip.cpp Close.cpp Create.cpp diff --git a/src/cli/Command.cpp b/src/cli/Command.cpp index 0699921ac..2eab320a3 100644 --- a/src/cli/Command.cpp +++ b/src/cli/Command.cpp @@ -18,6 +18,9 @@ #include "Add.h" #include "AddGroup.h" #include "Analyze.h" +#include "AttachmentExport.h" +#include "AttachmentImport.h" +#include "AttachmentRemove.h" #include "Clip.h" #include "Close.h" #include "Create.h" @@ -107,7 +110,7 @@ QString Command::getDescriptionLine() { QString response = name; QString space(" "); - QString spaces = space.repeated(15 - name.length()); + QString spaces = space.repeated(20 - name.length()); response = response.append(spaces); response = response.append(description); response = response.append("\n"); @@ -164,6 +167,9 @@ namespace Commands s_commands.insert(QStringLiteral("add"), QSharedPointer(new Add())); s_commands.insert(QStringLiteral("analyze"), QSharedPointer(new Analyze())); + s_commands.insert(QStringLiteral("attachment-export"), QSharedPointer(new AttachmentExport())); + s_commands.insert(QStringLiteral("attachment-import"), QSharedPointer(new AttachmentImport())); + s_commands.insert(QStringLiteral("attachment-rm"), QSharedPointer(new AttachmentRemove())); s_commands.insert(QStringLiteral("clip"), QSharedPointer(new Clip())); s_commands.insert(QStringLiteral("close"), QSharedPointer(new Close())); s_commands.insert(QStringLiteral("db-create"), QSharedPointer(new Create())); diff --git a/src/cli/Show.cpp b/src/cli/Show.cpp index e378c8e4a..f4d8097f6 100644 --- a/src/cli/Show.cpp +++ b/src/cli/Show.cpp @@ -19,6 +19,7 @@ #include "Utils.h" #include "core/Group.h" +#include "core/Tools.h" #include @@ -31,6 +32,9 @@ const QCommandLineOption Show::ProtectedAttributesOption = << "show-protected", QObject::tr("Show the protected attributes in clear text.")); +const QCommandLineOption Show::AttachmentsOption = + QCommandLineOption(QStringList() << "show-attachments", QObject::tr("Show the attachments of the entry.")); + const QCommandLineOption Show::AttributesOption = QCommandLineOption( QStringList() << "a" << "attributes", @@ -47,6 +51,7 @@ Show::Show() options.append(Show::TotpOption); options.append(Show::AttributesOption); options.append(Show::ProtectedAttributesOption); + options.append(Show::AttachmentsOption); positionalArguments.append({QString("entry"), QObject::tr("Name of the entry to show."), QString("")}); } @@ -104,6 +109,25 @@ int Show::executeWithDatabase(QSharedPointer database, QSharedPointer< } } + if (parser->isSet(Show::AttachmentsOption)) { + // Separate attachment output from attributes output via a newline. + out << endl; + + EntryAttachments* attachments = entry->attachments(); + if (attachments->isEmpty()) { + out << QObject::tr("No attachments present.") << endl; + } else { + out << QObject::tr("Attachments:") << endl; + + // Iterate over the attachments and output their names and size line-by-line, indented. + for (const QString& attachmentName : attachments->keys()) { + // TODO: use QLocale::formattedDataSize when >= Qt 5.10 + QString attachmentSize = Tools::humanReadableFileSize(attachments->value(attachmentName).size(), 1); + out << " " << attachmentName << " (" << attachmentSize << ")" << endl; + } + } + } + if (showTotp) { out << entry->totp() << endl; } diff --git a/src/cli/Show.h b/src/cli/Show.h index bf76c6973..93713dbd8 100644 --- a/src/cli/Show.h +++ b/src/cli/Show.h @@ -30,6 +30,7 @@ public: static const QCommandLineOption TotpOption; static const QCommandLineOption AttributesOption; static const QCommandLineOption ProtectedAttributesOption; + static const QCommandLineOption AttachmentsOption; }; #endif // KEEPASSXC_SHOW_H diff --git a/tests/TestCli.cpp b/tests/TestCli.cpp index 03e6f2439..a6f1a0d54 100644 --- a/tests/TestCli.cpp +++ b/tests/TestCli.cpp @@ -30,6 +30,9 @@ #include "cli/Add.h" #include "cli/AddGroup.h" #include "cli/Analyze.h" +#include "cli/AttachmentExport.h" +#include "cli/AttachmentImport.h" +#include "cli/AttachmentRemove.h" #include "cli/Clip.h" #include "cli/Create.h" #include "cli/Diceware.h" @@ -215,6 +218,9 @@ void TestCli::testBatchCommands() Commands::setupCommands(false); QVERIFY(Commands::getCommand("add")); QVERIFY(Commands::getCommand("analyze")); + QVERIFY(Commands::getCommand("attachment-export")); + QVERIFY(Commands::getCommand("attachment-import")); + QVERIFY(Commands::getCommand("attachment-rm")); QVERIFY(Commands::getCommand("clip")); QVERIFY(Commands::getCommand("close")); QVERIFY(Commands::getCommand("db-create")); @@ -236,7 +242,7 @@ void TestCli::testBatchCommands() QVERIFY(Commands::getCommand("show")); QVERIFY(Commands::getCommand("search")); QVERIFY(!Commands::getCommand("doesnotexist")); - QCOMPARE(Commands::getCommands().size(), 22); + QCOMPARE(Commands::getCommands().size(), 25); } void TestCli::testInteractiveCommands() @@ -244,6 +250,9 @@ void TestCli::testInteractiveCommands() Commands::setupCommands(true); QVERIFY(Commands::getCommand("add")); QVERIFY(Commands::getCommand("analyze")); + QVERIFY(Commands::getCommand("attachment-export")); + QVERIFY(Commands::getCommand("attachment-import")); + QVERIFY(Commands::getCommand("attachment-rm")); QVERIFY(Commands::getCommand("clip")); QVERIFY(Commands::getCommand("close")); QVERIFY(Commands::getCommand("db-create")); @@ -265,7 +274,7 @@ void TestCli::testInteractiveCommands() QVERIFY(Commands::getCommand("show")); QVERIFY(Commands::getCommand("search")); QVERIFY(!Commands::getCommand("doesnotexist")); - QCOMPARE(Commands::getCommands().size(), 22); + QCOMPARE(Commands::getCommands().size(), 25); } void TestCli::testAdd() @@ -435,6 +444,184 @@ void TestCli::testAnalyze() QCOMPARE(m_stderr->readAll(), QByteArray()); } +void TestCli::testAttachmentExport() +{ + AttachmentExport attachmentExportCmd; + QVERIFY(!attachmentExportCmd.name.isEmpty()); + QVERIFY(attachmentExportCmd.getDescriptionLine().contains(attachmentExportCmd.name)); + + TemporaryFile exportOutput; + exportOutput.open(QIODevice::WriteOnly); + exportOutput.close(); + + // Try exporting an attachment of a non-existent entry + setInput("a"); + execCmd(attachmentExportCmd, + {"attachment-export", + m_dbFile->fileName(), + "invalid_entry_path", + "invalid_attachment_name", + exportOutput.fileName()}); + m_stderr->readLine(); // skip password prompt + QCOMPARE(m_stderr->readAll(), QByteArray("Could not find entry with path invalid_entry_path.\n")); + QCOMPARE(m_stdout->readAll(), QByteArray()); + + // Try exporting a non-existent attachment + setInput("a"); + execCmd(attachmentExportCmd, + {"attachment-export", + m_dbFile->fileName(), + "/Sample Entry", + "invalid_attachment_name", + exportOutput.fileName()}); + m_stderr->readLine(); // skip password prompt + QCOMPARE(m_stderr->readAll(), QByteArray("Could not find attachment with name invalid_attachment_name.\n")); + QCOMPARE(m_stdout->readAll(), QByteArray()); + + // Export an existing attachment to a file + setInput("a"); + execCmd( + attachmentExportCmd, + {"attachment-export", m_dbFile->fileName(), "/Sample Entry", "Sample attachment.txt", exportOutput.fileName()}); + m_stderr->readLine(); // skip password prompt + QCOMPARE(m_stderr->readAll(), QByteArray()); + QCOMPARE(m_stdout->readAll(), + QByteArray(qPrintable(QString("Successfully exported attachment %1 of entry %2 to %3.\n") + .arg("Sample attachment.txt", "/Sample Entry", exportOutput.fileName())))); + + exportOutput.open(QIODevice::ReadOnly); + QCOMPARE(exportOutput.readAll(), QByteArray("Sample content\n")); + + // Export an existing attachment to stdout + setInput("a"); + execCmd(attachmentExportCmd, + {"attachment-export", "--stdout", m_dbFile->fileName(), "/Sample Entry", "Sample attachment.txt"}); + m_stderr->readLine(); // skip password prompt + QCOMPARE(m_stderr->readAll(), QByteArray()); + QCOMPARE(m_stdout->readAll(), QByteArray("Sample content\n")); + + // Ensure --stdout works even in quiet mode + setInput("a"); + execCmd( + attachmentExportCmd, + {"attachment-export", "--quiet", "--stdout", m_dbFile->fileName(), "/Sample Entry", "Sample attachment.txt"}); + m_stderr->readLine(); // skip password prompt + QCOMPARE(m_stderr->readAll(), QByteArray()); + QCOMPARE(m_stdout->readAll(), QByteArray("Sample content\n")); +} + +void TestCli::testAttachmentImport() +{ + AttachmentImport attachmentImportCmd; + QVERIFY(!attachmentImportCmd.name.isEmpty()); + QVERIFY(attachmentImportCmd.getDescriptionLine().contains(attachmentImportCmd.name)); + + const QString attachmentPath = QString(KEEPASSX_TEST_DATA_DIR).append("/Attachment.txt"); + QVERIFY(QFile::exists(attachmentPath)); + + // Try importing an attachment to a non-existent entry + setInput("a"); + execCmd(attachmentImportCmd, + {"attachment-import", + m_dbFile->fileName(), + "invalid_entry_path", + "invalid_attachment_name", + "invalid_attachment_path"}); + m_stderr->readLine(); // skip password prompt + QCOMPARE(m_stderr->readAll(), QByteArray("Could not find entry with path invalid_entry_path.\n")); + QCOMPARE(m_stdout->readAll(), QByteArray()); + + // Try importing an attachment with an occupied name without -f option + setInput("a"); + execCmd(attachmentImportCmd, + {"attachment-import", + m_dbFile->fileName(), + "/Sample Entry", + "Sample attachment.txt", + "invalid_attachment_path"}); + m_stderr->readLine(); // skip password prompt + QCOMPARE(m_stderr->readAll(), + QByteArray("Attachment Sample attachment.txt already exists for entry /Sample Entry.\n")); + QCOMPARE(m_stdout->readAll(), QByteArray()); + + // Try importing a non-existent attachment + setInput("a"); + execCmd(attachmentImportCmd, + {"attachment-import", + m_dbFile->fileName(), + "/Sample Entry", + "Sample attachment 2.txt", + "invalid_attachment_path"}); + m_stderr->readLine(); // skip password prompt + QCOMPARE(m_stderr->readAll(), QByteArray("Could not open attachment file invalid_attachment_path.\n")); + QCOMPARE(m_stdout->readAll(), QByteArray()); + + // Try importing an attachment with an occupied name with -f option + setInput("a"); + execCmd( + attachmentImportCmd, + {"attachment-import", "-f", m_dbFile->fileName(), "/Sample Entry", "Sample attachment.txt", attachmentPath}); + m_stderr->readLine(); // skip password prompt + QCOMPARE(m_stderr->readAll(), QByteArray()); + QCOMPARE(m_stdout->readAll(), + QByteArray(qPrintable( + QString("Successfully imported attachment %1 as Sample attachment.txt to entry /Sample Entry.\n") + .arg(attachmentPath)))); + + // Try importing an attachment with an unoccupied name + setInput("a"); + execCmd(attachmentImportCmd, + {"attachment-import", m_dbFile->fileName(), "/Sample Entry", "Attachment.txt", attachmentPath}); + m_stderr->readLine(); // skip password prompt + QCOMPARE(m_stderr->readAll(), QByteArray()); + QCOMPARE( + m_stdout->readAll(), + QByteArray(qPrintable(QString("Successfully imported attachment %1 as Attachment.txt to entry /Sample Entry.\n") + .arg(attachmentPath)))); +} + +void TestCli::testAttachmentRemove() +{ + AttachmentRemove attachmentRemoveCmd; + QVERIFY(!attachmentRemoveCmd.name.isEmpty()); + QVERIFY(attachmentRemoveCmd.getDescriptionLine().contains(attachmentRemoveCmd.name)); + + // Try deleting an attachment belonging to an non-existent entry + setInput("a"); + execCmd(attachmentRemoveCmd, + {"attachment-rm", m_dbFile->fileName(), "invalid_entry_path", "invalid_attachment_name"}); + m_stderr->readLine(); // skip password prompt + QCOMPARE(m_stderr->readAll(), QByteArray("Could not find entry with path invalid_entry_path.\n")); + QCOMPARE(m_stdout->readAll(), QByteArray()); + + // Try deleting a non-existent attachment from an entry + setInput("a"); + execCmd(attachmentRemoveCmd, {"attachment-rm", m_dbFile->fileName(), "/Sample Entry", "invalid_attachment_name"}); + m_stderr->readLine(); // skip password prompt + QCOMPARE(m_stderr->readAll(), QByteArray("Could not find attachment with name invalid_attachment_name.\n")); + QCOMPARE(m_stdout->readAll(), QByteArray()); + + // Finally delete an existing attachment from an existing entry + auto db = readDatabase(); + QVERIFY(db); + + const Entry* entry = db->rootGroup()->findEntryByPath("/Sample Entry"); + QVERIFY(entry); + + QVERIFY(entry->attachments()->hasKey("Sample attachment.txt")); + + setInput("a"); + execCmd(attachmentRemoveCmd, {"attachment-rm", m_dbFile->fileName(), "/Sample Entry", "Sample attachment.txt"}); + m_stderr->readLine(); // skip password prompt + QCOMPARE(m_stderr->readAll(), QByteArray()); + QCOMPARE(m_stdout->readAll(), + QByteArray("Successfully removed attachment Sample attachment.txt from entry /Sample Entry.\n")); + + db = readDatabase(); + QVERIFY(db); + QVERIFY(!db->rootGroup()->findEntryByPath("/Sample Entry")->attachments()->hasKey("Sample attachment.txt")); +} + void TestCli::testClip() { if (QProcessEnvironment::systemEnvironment().contains("WAYLAND_DISPLAY")) { @@ -1751,6 +1938,33 @@ void TestCli::testShow() "URL: http://www.somesite.com/\n" "Notes: Notes\n")); + setInput("a"); + execCmd(showCmd, {"show", m_dbFile->fileName(), "--show-attachments", "/Sample Entry"}); + m_stderr->readLine(); // Skip password prompt + QCOMPARE(m_stderr->readAll(), QByteArray()); + QCOMPARE(m_stdout->readAll(), + QByteArray("Title: Sample Entry\n" + "UserName: User Name\n" + "Password: PROTECTED\n" + "URL: http://www.somesite.com/\n" + "Notes: Notes\n" + "\n" + "Attachments:\n" + " Sample attachment.txt (15.0 B)\n")); + + setInput("a"); + execCmd(showCmd, {"show", m_dbFile->fileName(), "--show-attachments", "/Homebanking/Subgroup/Subgroup Entry"}); + m_stderr->readLine(); // Skip password prompt + QCOMPARE(m_stderr->readAll(), QByteArray()); + QCOMPARE(m_stdout->readAll(), + QByteArray("Title: Subgroup Entry\n" + "UserName: Bank User Name\n" + "Password: PROTECTED\n" + "URL: https://www.bank.com\n" + "Notes: Important note\n" + "\n" + "No attachments present.\n")); + setInput("a"); execCmd(showCmd, {"show", "-a", "Title", m_dbFile->fileName(), "/Sample Entry"}); QCOMPARE(m_stdout->readAll(), QByteArray("Sample Entry\n")); diff --git a/tests/TestCli.h b/tests/TestCli.h index 299264dd7..bbe80a4b3 100644 --- a/tests/TestCli.h +++ b/tests/TestCli.h @@ -47,6 +47,9 @@ private slots: void testAdd(); void testAddGroup(); void testAnalyze(); + void testAttachmentExport(); + void testAttachmentImport(); + void testAttachmentRemove(); void testClip(); void testCommandParsing_data(); void testCommandParsing(); diff --git a/tests/data/Attachment.txt b/tests/data/Attachment.txt new file mode 100644 index 000000000..39c9f3681 --- /dev/null +++ b/tests/data/Attachment.txt @@ -0,0 +1 @@ +Content diff --git a/tests/data/NewDatabase.kdbx b/tests/data/NewDatabase.kdbx index dfffcc1e4..cf7b2ffaa 100644 Binary files a/tests/data/NewDatabase.kdbx and b/tests/data/NewDatabase.kdbx differ