aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorsaturneric <[email protected]>2026-09-24 22:31:28 +0200
committersaturneric <[email protected]>2026-09-24 22:31:28 +0200
commite1a60bb1405457da13b4050f912831805403419e (patch)
treec75a6d0298d326aa065afbe855bceb16506a0764 /src
parentfix(email): persist IMAP state on close (diff)
downloadmodules-e1a60bb1405457da13b4050f912831805403419e.tar.gz
modules-e1a60bb1405457da13b4050f912831805403419e.zip
feat(key-server): add batch key operations
- support batch refresh and publish actions for key selections - add publication status checking through the key server - manage request ownership, spacing, cancellation, and summaries - normalize duplicate keys and combine fetched blocks for import - add UI actions for selected keys and key detail views - add unit tests for batch logic and optional test integration
Diffstat (limited to 'src')
-rw-r--r--src/m_key_server_sync/CMakeLists.txt4
-rw-r--r--src/m_key_server_sync/KeyServerBatchLogic.cpp99
-rw-r--r--src/m_key_server_sync/KeyServerBatchLogic.h79
-rw-r--r--src/m_key_server_sync/KeyServerSyncModule.cpp280
-rw-r--r--src/m_key_server_sync/module.json1
-rw-r--r--src/m_key_server_sync/test/CMakeLists.txt52
-rw-r--r--src/m_key_server_sync/test/KeyServerBatchLogicTest.cpp97
-rw-r--r--src/m_key_server_sync/ui/main.lua16
8 files changed, 610 insertions, 18 deletions
diff --git a/src/m_key_server_sync/CMakeLists.txt b/src/m_key_server_sync/CMakeLists.txt
index df12fc1..5a1e767 100644
--- a/src/m_key_server_sync/CMakeLists.txt
+++ b/src/m_key_server_sync/CMakeLists.txt
@@ -29,3 +29,7 @@ gf_add_module(
NAME key_server_sync
QT Core Widgets Network
LUA_SCRIPTS ui/main.lua)
+
+if(GPGFRONTEND_MODULES_BUILD_TESTS)
+ add_subdirectory(test)
+endif()
diff --git a/src/m_key_server_sync/KeyServerBatchLogic.cpp b/src/m_key_server_sync/KeyServerBatchLogic.cpp
new file mode 100644
index 0000000..c67f65e
--- /dev/null
+++ b/src/m_key_server_sync/KeyServerBatchLogic.cpp
@@ -0,0 +1,99 @@
+/**
+ * Copyright (C) 2021-2024 Saturneric <[email protected]>
+ *
+ * This file is part of GpgFrontend.
+ *
+ * GpgFrontend 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 3 of the License, or
+ * (at your option) any later version.
+ *
+ * GpgFrontend 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 GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * The initial version of the source code is inherited from
+ * the gpg4usb project, which is under GPL-3.0-or-later.
+ *
+ * All the source code of GpgFrontend was modified and released by
+ * Saturneric <[email protected]> starting on May 12, 2021.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+
+#include "KeyServerBatchLogic.h"
+
+#include <QCoreApplication>
+#include <QSet>
+
+namespace KeyServerBatchLogic {
+
+namespace {
+
+/// The failures, one per line, under the headline.
+auto WithFailures(QString headline, const QStringList& failures) -> QString {
+ if (failures.isEmpty()) return headline;
+ headline += QStringLiteral("\n\n");
+ headline += QCoreApplication::translate("GTrC", "Failed:");
+ for (const auto& f : failures) headline += QStringLiteral("\n") + f;
+ return headline;
+}
+
+} // namespace
+
+auto Normalise(const std::optional<BatchKey>& key,
+ const std::optional<QList<BatchKey>>& keys) -> QList<BatchKey> {
+ QList<BatchKey> all;
+ if (key.has_value()) all.append(*key);
+ if (keys.has_value()) all.append(*keys);
+
+ QList<BatchKey> out;
+ QSet<QString> seen;
+ for (const auto& k : all) {
+ const auto fpr = k.fingerprint.trimmed().toUpper();
+ if (fpr.isEmpty() || seen.contains(fpr)) continue;
+ seen.insert(fpr);
+ out.append(k);
+ }
+ return out;
+}
+
+auto JoinForImport(const QList<QByteArray>& blocks) -> QByteArray {
+ QByteArray out;
+ for (const auto& b : blocks) {
+ if (b.trimmed().isEmpty()) continue;
+ out.append(b);
+ if (!b.endsWith('\n')) out.append('\n');
+ }
+ return out;
+}
+
+auto RefreshSummary(int fetched, int total, const QStringList& failures)
+ -> QString {
+ return WithFailures(
+ QCoreApplication::translate("GTrC",
+ "%1 of %2 keys were fetched from the key "
+ "server.")
+ .arg(fetched)
+ .arg(total),
+ failures);
+}
+
+auto PublishSummary(int published, int total, const QString& host,
+ const QStringList& failures) -> QString {
+ return WithFailures(
+ QCoreApplication::translate("GTrC",
+ "%1 of %2 keys were published to the key "
+ "server %3.")
+ .arg(published)
+ .arg(total)
+ .arg(host),
+ failures);
+}
+
+} // namespace KeyServerBatchLogic
diff --git a/src/m_key_server_sync/KeyServerBatchLogic.h b/src/m_key_server_sync/KeyServerBatchLogic.h
new file mode 100644
index 0000000..439ef87
--- /dev/null
+++ b/src/m_key_server_sync/KeyServerBatchLogic.h
@@ -0,0 +1,79 @@
+/**
+ * Copyright (C) 2021-2024 Saturneric <[email protected]>
+ *
+ * This file is part of GpgFrontend.
+ *
+ * GpgFrontend 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 3 of the License, or
+ * (at your option) any later version.
+ *
+ * GpgFrontend 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 GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * The initial version of the source code is inherited from
+ * the gpg4usb project, which is under GPL-3.0-or-later.
+ *
+ * All the source code of GpgFrontend was modified and released by
+ * Saturneric <[email protected]> starting on May 12, 2021.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+
+#pragma once
+
+#include <QByteArray>
+#include <QList>
+#include <QString>
+#include <QStringList>
+#include <optional>
+
+/**
+ * @file KeyServerBatchLogic.h
+ * @brief The decisions a batch of key-server operations makes, without the
+ * network or the SDK, so they can be tested on their own.
+ */
+namespace KeyServerBatchLogic {
+
+/// One key a batch acts on: the part of the Host's key reference it needs.
+struct BatchKey {
+ qint64 channel = 0;
+ QString key_id;
+ QString fingerprint;
+};
+
+/**
+ * @brief The keys a command was given, as one list.
+ *
+ * A command is given either one key (the key details dialog) or a selection
+ * (a key list). Both together are merged; a key named twice, by fingerprint,
+ * is acted on once, in the order it was first named. Keys without a
+ * fingerprint are dropped: there is nothing to fetch or publish them by.
+ */
+auto Normalise(const std::optional<BatchKey>& key,
+ const std::optional<QList<BatchKey>>& keys) -> QList<BatchKey>;
+
+/**
+ * @brief Several fetched key blocks as one import payload.
+ *
+ * One import means one dialog for the whole batch. Each block ends in a
+ * newline so the next one's armor header starts on a line of its own; empty
+ * blocks are skipped.
+ */
+auto JoinForImport(const QList<QByteArray>& blocks) -> QByteArray;
+
+/// What a finished refresh says, e.g. "3 of 4 keys fetched" plus failures.
+auto RefreshSummary(int fetched, int total, const QStringList& failures)
+ -> QString;
+
+/// What a finished publish says, e.g. "2 of 2 keys published" plus failures.
+auto PublishSummary(int published, int total, const QString& host,
+ const QStringList& failures) -> QString;
+
+} // namespace KeyServerBatchLogic
diff --git a/src/m_key_server_sync/KeyServerSyncModule.cpp b/src/m_key_server_sync/KeyServerSyncModule.cpp
index a8f0e21..0c4122b 100644
--- a/src/m_key_server_sync/KeyServerSyncModule.cpp
+++ b/src/m_key_server_sync/KeyServerSyncModule.cpp
@@ -38,6 +38,7 @@
#include "GFModuleIdentity.h"
#include "GFSDKHostCommands.hpp"
#include "GFSDKUI.h"
+#include "KeyServerBatchLogic.h"
#include "KeyServerList.h"
#include "KeyServerSettingsPage.h"
#include "PKSInterface.h"
@@ -83,17 +84,19 @@ using ErrorCallback = std::function<void(const QString&, const QString&)>;
*
* @param by_fingerprint VKS has separate endpoints for the two handle kinds;
* HKP does not care.
+ * @param owner when given, owns the request: deleting it aborts the request
+ * and guarantees neither callback runs.
*/
void FetchKey(const KeyServerList::Route& route, const QString& handle,
bool by_fingerprint, const KeyCallback& on_key,
- const ErrorCallback& on_error) {
+ const ErrorCallback& on_error, QObject* owner = nullptr) {
+ QObject* context =
+ owner != nullptr ? owner : static_cast<QObject*>(QThread::currentThread());
if (route.vks) {
- auto* vks = new VKSInterface(route.url);
- QObject::connect(vks, &VKSInterface::SignalKeyRetrieved,
- QThread::currentThread(),
+ auto* vks = new VKSInterface(route.url, owner);
+ QObject::connect(vks, &VKSInterface::SignalKeyRetrieved, context,
[on_key](const QString& key) { on_key(key); });
- QObject::connect(vks, &VKSInterface::SignalErrorOccurred,
- QThread::currentThread(),
+ QObject::connect(vks, &VKSInterface::SignalErrorOccurred, context,
[on_error](const QString& error, const QString& data) {
on_error(error, data);
});
@@ -110,10 +113,9 @@ void FetchKey(const KeyServerList::Route& route, const QString& handle,
return;
}
- auto* pks = new PKSInterface();
+ auto* pks = new PKSInterface(owner);
QObject::connect(
- pks, &PKSInterface::SignalKeyServerKeyLookupResult,
- QThread::currentThread(),
+ pks, &PKSInterface::SignalKeyServerKeyLookupResult, context,
[on_key, on_error](QNetworkReply::NetworkError error,
const QString& error_string, const QByteArray& data) {
if (error != QNetworkReply::NoError) {
@@ -163,10 +165,175 @@ auto ConfirmHkpPublish(const QString& url) -> bool {
QMessageBox::Ok | QMessageBox::Cancel,
QMessageBox::Cancel) == QMessageBox::Ok;
}
+
+using KeyServerBatchLogic::BatchKey;
+
+/// Set when the module deactivates; every batch step checks it first.
+std::atomic<bool> g_stopped{false};
+
+/**
+ * @brief Refreshes or publishes several keys, one request at a time.
+ *
+ * Owns its requests: deleting it aborts them, and no callback runs after.
+ * Spaced out so a large selection does not hammer the server, and finished
+ * with one import (one dialog) and one summary rather than one per key.
+ */
+class KeyServerBatch : public QObject {
+ public:
+ enum class Kind { kRefresh, kPublish };
+
+ /// Between two requests of one batch.
+ static constexpr int kSpacingMs = 200;
+
+ KeyServerBatch(Kind kind, QList<BatchKey> keys)
+ : kind_(kind), keys_(std::move(keys)),
+ route_(KeyServerList::SyncRoute()) {}
+
+ /// @return false when the user declined, and the batch is already gone
+ auto Start() -> bool {
+ if (kind_ == Kind::kPublish && !route_.vks &&
+ !ConfirmHkpPublish(route_.url)) {
+ deleteLater();
+ return false;
+ }
+ Next();
+ return true;
+ }
+
+ private:
+ void Next() {
+ if (g_stopped.load()) return;
+ if (index_ >= keys_.size()) return Finish();
+ const auto key = keys_[index_++];
+ if (kind_ == Kind::kRefresh) {
+ FetchKey(
+ route_, key.fingerprint, true,
+ [this](const QString& data) {
+ blocks_.append(data.toUtf8());
+ Schedule();
+ },
+ [this, key](const QString& error, const QString&) {
+ failures_.append(QStringLiteral("%1: %2").arg(key.fingerprint,
+ error));
+ Schedule();
+ },
+ this);
+ return;
+ }
+ Publish(key);
+ }
+
+ void Publish(const BatchKey& key) {
+ const auto exported =
+ gf::sdk::ExportKey(GFModuleSdkContext(),
+ static_cast<int>(key.channel), key.key_id, true);
+ if (exported.isEmpty()) {
+ failures_.append(QCoreApplication::translate(
+ "GTrC", "%1: the public key could not be exported")
+ .arg(key.fingerprint));
+ return Schedule();
+ }
+ const auto ok = [this]() {
+ ++done_;
+ Schedule();
+ };
+ const auto failed = [this, key](const QString& error) {
+ failures_.append(QStringLiteral("%1: %2").arg(key.fingerprint, error));
+ Schedule();
+ };
+ if (route_.vks) {
+ auto* vks = new VKSInterface(route_.url, this);
+ connect(vks, &VKSInterface::SignalKeyUploaded, this,
+ [ok](const QString&, const QJsonObject&, const QString&) {
+ ok();
+ });
+ connect(vks, &VKSInterface::SignalErrorOccurred, this,
+ [failed](const QString& error, const QString&) {
+ failed(error);
+ });
+ vks->UploadKey(QString::fromUtf8(exported));
+ return;
+ }
+ auto* pks = new PKSInterface(this);
+ connect(pks, &PKSInterface::SignalKeyServerKeyUploadResult, this,
+ [ok, failed](QNetworkReply::NetworkError error,
+ const QString& error_string) {
+ if (error != QNetworkReply::NoError) return failed(error_string);
+ ok();
+ });
+ pks->UploadKey(route_.url, exported);
+ }
+
+ void Schedule() {
+ if (g_stopped.load()) return;
+ QTimer::singleShot(kSpacingMs, this, [this]() { Next(); });
+ }
+
+ void Finish() {
+ const auto total = static_cast<int>(keys_.size());
+ const auto severity =
+ failures_.isEmpty() ? Severity::kInfo : Severity::kWarning;
+ if (kind_ == Kind::kRefresh) {
+ if (!blocks_.isEmpty()) {
+ gf::sdk::ImportKeys(GFModuleSdkContext(),
+ static_cast<int>(keys_.front().channel),
+ KeyServerBatchLogic::JoinForImport(blocks_));
+ }
+ Tell(severity,
+ QCoreApplication::translate("GTrC", "Key Refresh Finished"),
+ KeyServerBatchLogic::RefreshSummary(
+ static_cast<int>(blocks_.size()), total, failures_));
+ } else {
+ Tell(severity,
+ QCoreApplication::translate("GTrC", "Key Publishing Finished"),
+ KeyServerBatchLogic::PublishSummary(done_, total,
+ QUrl(route_.url).host(),
+ failures_));
+ }
+ deleteLater();
+ }
+
+ Kind kind_;
+ QList<BatchKey> keys_;
+ KeyServerList::Route route_;
+ qsizetype index_ = 0;
+ int done_ = 0;
+ QList<QByteArray> blocks_;
+ QStringList failures_;
+};
+
+/// The one batch that may run; a second request waits for it to finish.
+QPointer<KeyServerBatch> g_batch;
+
+void StartBatch(KeyServerBatch::Kind kind, QList<BatchKey> keys) {
+ if (!g_batch.isNull()) {
+ Tell(Severity::kInfo, QCoreApplication::translate("GTrC", "Key Server"),
+ QCoreApplication::translate(
+ "GTrC", "A key server operation is already running. Try again "
+ "when it has finished."));
+ return;
+ }
+ auto* batch = new KeyServerBatch(kind, std::move(keys));
+ g_batch = batch;
+ batch->Start();
+}
+
} // namespace
+auto OnDeactivate() -> GFResult {
+ // No batch step runs from here on; the batch and its requests go with the
+ // event loop's next turn, on the thread that owns them.
+ g_stopped.store(true);
+ if (!g_batch.isNull()) {
+ QMetaObject::invokeMethod(g_batch.data(), &QObject::deleteLater,
+ Qt::QueuedConnection);
+ }
+ return GFResult::Ok();
+}
+
auto OnActivate() -> GFResult {
LOG_INFO("key server sync module registering");
+ g_stopped.store(false);
// Presentation is registered untranslated: the module translators are not
// installed yet, so anything translated here would be stuck at the source
@@ -340,14 +507,33 @@ auto UpdateKeyFromKeyServer(int channel, const QString& fpr) -> int {
namespace {
-/// Both key commands act on one key, named by the Host's context.
+/// The key commands act on one key (the key details dialog) or on a
+/// selection (a key list's context menu), named by the Host's context.
struct KeyArgs {
- gf::cmd::KeyRef key;
+ std::optional<gf::cmd::KeyRef> key;
+ std::optional<QList<gf::cmd::KeyRef>> keys;
static constexpr auto Fields() {
- return std::make_tuple(gf::cmd::F("key", &KeyArgs::key));
+ return std::make_tuple(gf::cmd::F("key", &KeyArgs::key),
+ gf::cmd::F("keys", &KeyArgs::keys));
}
};
+auto ToBatchKey(const gf::cmd::KeyRef& k) -> BatchKey {
+ return {k.channel, k.key_id, k.fingerprint};
+}
+
+/// Every key the arguments name, once each.
+auto KeysOf(const KeyArgs& a) -> QList<BatchKey> {
+ std::optional<BatchKey> one;
+ if (a.key.has_value()) one = ToBatchKey(*a.key);
+ std::optional<QList<BatchKey>> many;
+ if (a.keys.has_value()) {
+ many.emplace();
+ for (const auto& k : *a.keys) many->append(ToBatchKey(k));
+ }
+ return KeyServerBatchLogic::Normalise(one, many);
+}
+
struct PublishKey {
static constexpr gf::cmd::Meta kMeta{
GF_MODULE_ID ".publish_key", GC_TR("Publish Public Key to Key Server"),
@@ -366,6 +552,16 @@ struct RefreshKey {
using Result = gf::cmd::Unit;
};
+struct CheckPublication {
+ static constexpr gf::cmd::Meta kMeta{
+ GF_MODULE_ID ".check_publication",
+ GC_TR("Check Publication Status"),
+ GC_TR("Ask the key server whether it has this public key"),
+ GC_TR("Key Server Operations"), 0, gf::cmd::kNeedsGuiThread};
+ using Args = KeyArgs;
+ using Result = gf::cmd::Unit;
+};
+
struct SearchKey {
static constexpr gf::cmd::Meta kMeta{
GF_MODULE_ID ".search_key", GC_TR("Key Server"),
@@ -380,11 +576,23 @@ struct SearchKey {
using Result = gf::cmd::Unit;
};
+auto NoKey() -> gf::cmd::Outcome<gf::cmd::Unit> {
+ return gf::cmd::Outcome<gf::cmd::Unit>::Failure(GF_CMD_E_BAD_ARGS,
+ "no key was given");
+}
+
auto DoPublishKey(const gf::cmd::CommandContext& /*ctx*/, const KeyArgs& a)
-> gf::cmd::Outcome<gf::cmd::Unit> {
- // Any key with a public part can be published, mirroring the Key
- // Management "Publish Key to Keyserver" action.
- UploadKeyToServer(static_cast<int>(a.key.channel), a.key.key_id);
+ // Any key with a public part can be published. One key keeps the detailed
+ // per-key report; several go as one batch with one summary.
+ const auto keys = KeysOf(a);
+ if (keys.isEmpty()) return NoKey();
+ if (keys.size() == 1) {
+ UploadKeyToServer(static_cast<int>(keys.front().channel),
+ keys.front().key_id);
+ } else {
+ StartBatch(KeyServerBatch::Kind::kPublish, keys);
+ }
return gf::cmd::Outcome<gf::cmd::Unit>::Success({});
}
@@ -392,7 +600,42 @@ auto DoRefreshKey(const gf::cmd::CommandContext& /*ctx*/, const KeyArgs& a)
-> gf::cmd::Outcome<gf::cmd::Unit> {
// Refresh re-imports the latest public key from the server; it is valid
// for any key, including your own.
- UpdateKeyFromKeyServer(static_cast<int>(a.key.channel), a.key.fingerprint);
+ const auto keys = KeysOf(a);
+ if (keys.isEmpty()) return NoKey();
+ if (keys.size() == 1) {
+ UpdateKeyFromKeyServer(static_cast<int>(keys.front().channel),
+ keys.front().fingerprint);
+ } else {
+ StartBatch(KeyServerBatch::Kind::kRefresh, keys);
+ }
+ return gf::cmd::Outcome<gf::cmd::Unit>::Success({});
+}
+
+auto DoCheckPublication(const gf::cmd::CommandContext& /*ctx*/,
+ const KeyArgs& a) -> gf::cmd::Outcome<gf::cmd::Unit> {
+ // Asked for, never automatic: looking a key up tells the server which key
+ // the user is interested in.
+ const auto keys = KeysOf(a);
+ if (keys.isEmpty()) return NoKey();
+ const auto fpr = keys.front().fingerprint;
+ const auto route = KeyServerList::SyncRoute();
+ const auto host = QUrl(route.url).host();
+ const auto title =
+ QCoreApplication::translate("GTrC", "Publication Status");
+ FetchKey(
+ route, fpr, true,
+ [title, host](const QString&) {
+ Tell(Severity::kInfo, title,
+ QCoreApplication::translate(
+ "GTrC", "The public key has been published on %1.")
+ .arg(host));
+ },
+ [title, host](const QString& error, const QString&) {
+ Tell(Severity::kInfo, title,
+ QCoreApplication::translate(
+ "GTrC", "%1 did not return this public key.\n%2")
+ .arg(host, error));
+ });
return gf::cmd::Outcome<gf::cmd::Unit>::Success({});
}
@@ -539,9 +782,10 @@ constexpr std::array<GFEventBinding, 4> kEvents = {{
{"REQUEST_UPLOAD_PUBLIC_KEY", &OnRequestUploadPublicKey},
}};
-const std::array<gf::cmd::Binding, 3> kCommands = {
+const std::array<gf::cmd::Binding, 4> kCommands = {
gf::cmd::Bind<PublishKey, &DoPublishKey>(),
gf::cmd::Bind<RefreshKey, &DoRefreshKey>(),
+ gf::cmd::Bind<CheckPublication, &DoCheckPublication>(),
gf::cmd::Bind<SearchKey, &DoSearchKey>(),
};
@@ -551,7 +795,7 @@ const GFModuleHooks kHooks = {
GF_MODULE_VERSION,
GF_MODULE_TRANSLATION_CONTEXT,
&OnActivate,
- nullptr, // the Host withdraws the commands, widgets and script itself
+ &OnDeactivate, // stops a running batch; the Host withdraws the rest
&OnUnload,
kEvents.data(),
kEvents.size(),
diff --git a/src/m_key_server_sync/module.json b/src/m_key_server_sync/module.json
index c02aff7..02cbd6f 100644
--- a/src/m_key_server_sync/module.json
+++ b/src/m_key_server_sync/module.json
@@ -19,6 +19,7 @@
"REQUEST_UPLOAD_PUBLIC_KEY"
],
"commands": [
+ "com.bktus.gpgfrontend.module.key_server_sync.check_publication",
"com.bktus.gpgfrontend.module.key_server_sync.publish_key",
"com.bktus.gpgfrontend.module.key_server_sync.refresh_key",
"com.bktus.gpgfrontend.module.key_server_sync.search_key"
diff --git a/src/m_key_server_sync/test/CMakeLists.txt b/src/m_key_server_sync/test/CMakeLists.txt
new file mode 100644
index 0000000..898a1f9
--- /dev/null
+++ b/src/m_key_server_sync/test/CMakeLists.txt
@@ -0,0 +1,52 @@
+# Copyright (C) 2021-2024 Saturneric <[email protected]>
+#
+# This file is part of GpgFrontend.
+#
+# GpgFrontend 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 3 of the License, or
+# (at your option) any later version.
+#
+# GpgFrontend 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 GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
+#
+# The initial version of the source code is inherited from
+# the gpg4usb project, which is under GPL-3.0-or-later.
+#
+# All the source code of GpgFrontend was modified and released by
+# Saturneric <[email protected]> starting on May 12, 2021.
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Unit tests for the SDK-free half of the key server batch.
+#
+# KeyServerBatchLogic.cpp carries no GF SDK symbol, no widget and no network,
+# so it compiles straight into a plain gtest binary with nothing but Qt Core
+# behind it. Keep it that way, or this target stops linking.
+
+if(NOT TARGET GTest::gtest)
+ find_package(GTest QUIET)
+endif()
+
+if(NOT TARGET GTest::gtest)
+ message(STATUS "GTest not found; skipping key server sync module tests")
+ return()
+endif()
+
+add_executable(gf_mod_key_server_sync_test
+ KeyServerBatchLogicTest.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/../KeyServerBatchLogic.cpp)
+
+target_include_directories(gf_mod_key_server_sync_test PRIVATE
+ "${CMAKE_CURRENT_SOURCE_DIR}/..")
+
+target_link_libraries(gf_mod_key_server_sync_test PRIVATE
+ GTest::gtest GTest::gtest_main Qt::Core)
+
+set_target_properties(gf_mod_key_server_sync_test PROPERTIES
+ RUNTIME_OUTPUT_DIRECTORY "${GPGFRONTEND_MODULE_TEST_OUTPUT_DIR}")
diff --git a/src/m_key_server_sync/test/KeyServerBatchLogicTest.cpp b/src/m_key_server_sync/test/KeyServerBatchLogicTest.cpp
new file mode 100644
index 0000000..2404b14
--- /dev/null
+++ b/src/m_key_server_sync/test/KeyServerBatchLogicTest.cpp
@@ -0,0 +1,97 @@
+/**
+ * Copyright (C) 2021-2024 Saturneric <[email protected]>
+ *
+ * This file is part of GpgFrontend.
+ *
+ * GpgFrontend 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 3 of the License, or
+ * (at your option) any later version.
+ *
+ * GpgFrontend 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 GpgFrontend. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * The initial version of the source code is inherited from
+ * the gpg4usb project, which is under GPL-3.0-or-later.
+ *
+ * All the source code of GpgFrontend was modified and released by
+ * Saturneric <[email protected]> starting on May 12, 2021.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+
+#include <gtest/gtest.h>
+
+#include "KeyServerBatchLogic.h"
+
+using KeyServerBatchLogic::BatchKey;
+
+namespace {
+
+auto Key(const QString& fpr, qint64 channel = 0) -> BatchKey {
+ return {channel, fpr.right(16), fpr};
+}
+
+} // namespace
+
+TEST(KeyServerBatchLogicTest, OneKeyOrASelectionBecomeOneList) {
+ const auto one = KeyServerBatchLogic::Normalise(Key("AAAA"), std::nullopt);
+ ASSERT_EQ(one.size(), 1);
+ EXPECT_EQ(one.front().fingerprint, "AAAA");
+
+ const auto many = KeyServerBatchLogic::Normalise(
+ std::nullopt, QList<BatchKey>{Key("AAAA"), Key("BBBB")});
+ ASSERT_EQ(many.size(), 2);
+ EXPECT_EQ(many[1].fingerprint, "BBBB");
+
+ EXPECT_TRUE(
+ KeyServerBatchLogic::Normalise(std::nullopt, std::nullopt).isEmpty());
+}
+
+TEST(KeyServerBatchLogicTest, AKeyNamedTwiceIsActedOnOnceInFirstOrder) {
+ // Case and surrounding space do not make a fingerprint a different key.
+ const auto keys = KeyServerBatchLogic::Normalise(
+ Key("bbbb"), QList<BatchKey>{Key("AAAA"), Key(" BBBB "), Key("AAAA")});
+ ASSERT_EQ(keys.size(), 2);
+ EXPECT_EQ(keys[0].fingerprint, "bbbb");
+ EXPECT_EQ(keys[1].fingerprint, "AAAA");
+}
+
+TEST(KeyServerBatchLogicTest, AKeyWithoutAFingerprintIsDropped) {
+ const auto keys = KeyServerBatchLogic::Normalise(
+ std::nullopt, QList<BatchKey>{Key(""), Key(" "), Key("CCCC")});
+ ASSERT_EQ(keys.size(), 1);
+ EXPECT_EQ(keys.front().fingerprint, "CCCC");
+}
+
+TEST(KeyServerBatchLogicTest, BlocksJoinIntoOneImportOnSeparateLines) {
+ const QByteArray a = "-----BEGIN PGP PUBLIC KEY BLOCK-----\nA\n"
+ "-----END PGP PUBLIC KEY BLOCK-----";
+ const QByteArray b = "-----BEGIN PGP PUBLIC KEY BLOCK-----\nB\n"
+ "-----END PGP PUBLIC KEY BLOCK-----\n";
+ const auto joined = KeyServerBatchLogic::JoinForImport({a, "", " \n", b});
+ EXPECT_EQ(joined, a + "\n" + b);
+ EXPECT_TRUE(KeyServerBatchLogic::JoinForImport({}).isEmpty());
+}
+
+TEST(KeyServerBatchLogicTest, SummariesCountAndListFailures) {
+ const auto clean = KeyServerBatchLogic::RefreshSummary(3, 3, {});
+ EXPECT_TRUE(clean.contains("3 of 3"));
+ EXPECT_FALSE(clean.contains("Failed"));
+
+ const auto partial =
+ KeyServerBatchLogic::RefreshSummary(1, 2, {"BBBB: not found"});
+ EXPECT_TRUE(partial.contains("1 of 2"));
+ EXPECT_TRUE(partial.contains("BBBB: not found"));
+
+ const auto published = KeyServerBatchLogic::PublishSummary(
+ 2, 2, "keys.openpgp.org", {});
+ EXPECT_TRUE(published.contains("2 of 2"));
+ EXPECT_TRUE(published.contains("keys.openpgp.org"));
+}
diff --git a/src/m_key_server_sync/ui/main.lua b/src/m_key_server_sync/ui/main.lua
index 2ad2a01..5c9c1e9 100644
--- a/src/m_key_server_sync/ui/main.lua
+++ b/src/m_key_server_sync/ui/main.lua
@@ -6,6 +6,7 @@
local search = commands.get("com.bktus.gpgfrontend.module.key_server_sync.search_key")
local publish = commands.get("com.bktus.gpgfrontend.module.key_server_sync.publish_key")
local refresh = commands.get("com.bktus.gpgfrontend.module.key_server_sync.refresh_key")
+local check = commands.get("com.bktus.gpgfrontend.module.key_server_sync.check_publication")
ui.mount { id = "search", anchor = ui.anchor.dialog {}, widget = native.widget("search") }
@@ -33,3 +34,18 @@ ui.action { id = "publish", anchor = ui.anchor("key.details.actions"),
command = publish, update = for_this_key }
ui.action { id = "refresh", anchor = ui.anchor("key.details.actions"),
command = refresh, update = for_this_key }
+ui.action { id = "check", anchor = ui.anchor("key.details.actions"),
+ command = check, update = for_this_key }
+
+-- The same operations on whatever a key list has selected: one key or many,
+-- sent as one batch.
+local function for_the_selection(ctx)
+ local keys = ctx.keys
+ if not keys or #keys == 0 then return { visible = false } end
+ return { args = { keys = keys } }
+end
+
+ui.action { id = "publish_selected", anchor = ui.anchor("key.list.context"),
+ command = publish, update = for_the_selection }
+ui.action { id = "refresh_selected", anchor = ui.anchor("key.list.context"),
+ command = refresh, update = for_the_selection }