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