diff --git a/src/core/Config.cpp b/src/core/Config.cpp index 2c2b0bc57b..f92818ad9e 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -202,6 +202,7 @@ static const QHash configStrings = { {Config::KeeShare_Own, {QS("KeeShare/Own"), Roaming, {}}}, {Config::KeeShare_Foreign, {QS("KeeShare/Foreign"), Roaming, {}}}, {Config::KeeShare_Active, {QS("KeeShare/Active"), Roaming, {}}}, + {Config::KeeShare_DeviceId, {QS("KeeShare/DeviceId"), Local, {}}}, // PasswordGenerator {Config::PasswordGenerator_LowerCase, {QS("PasswordGenerator/LowerCase"), Roaming, true}}, diff --git a/src/core/Config.h b/src/core/Config.h index 8f54f9c013..55f6a0dd84 100644 --- a/src/core/Config.h +++ b/src/core/Config.h @@ -179,6 +179,7 @@ class Config : public QObject KeeShare_Own, KeeShare_Foreign, KeeShare_Active, + KeeShare_DeviceId, PasswordGenerator_LowerCase, PasswordGenerator_UpperCase, diff --git a/src/keeshare/KeeShare.cpp b/src/keeshare/KeeShare.cpp index f14b0d5aea..9dab173dc6 100644 --- a/src/keeshare/KeeShare.cpp +++ b/src/keeshare/KeeShare.cpp @@ -23,6 +23,9 @@ #include "gui/DatabaseIcons.h" #include "keeshare/ShareObserver.h" +#include +#include + namespace { static const QString KeeShare_Reference("KeeShare/Reference"); @@ -51,6 +54,39 @@ void KeeShare::init(QObject* parent) m_instance = new KeeShare(parent); } +QString KeeShare::deviceId() +{ + auto id = config()->get(Config::KeeShare_DeviceId).toString(); + if (id.isEmpty()) { + // Generate fallback from machine unique ID, truncated to 7 chars + auto machineId = QSysInfo::machineUniqueId(); + if (!machineId.isEmpty()) { + // machineUniqueId() on Linux returns a hex string directly from /etc/machine-id + id = QString::fromLatin1(machineId).left(7).toUpper(); + } else { + // Last resort: use hostname + id = QSysInfo::machineHostName(); + } + setDeviceId(id); + // Re-read the sanitized value + id = config()->get(Config::KeeShare_DeviceId).toString(); + } + return id; +} + +void KeeShare::setDeviceId(const QString& id) +{ + // All sanitization consolidated here: strip non-alphanumeric, enforce max + // length, and fall back to DEFAULT if empty + QString sanitized = id; + sanitized.remove(QRegularExpression("[^A-Za-z0-9]")); + sanitized.truncate(32); + if (sanitized.isEmpty()) { + sanitized = QStringLiteral("DEFAULT"); + } + config()->set(Config::KeeShare_DeviceId, sanitized); +} + KeeShareSettings::Own KeeShare::own() { // Read existing own certificate or generate a new one if none available diff --git a/src/keeshare/KeeShare.h b/src/keeshare/KeeShare.h index 17052a9c62..0a5e91af47 100644 --- a/src/keeshare/KeeShare.h +++ b/src/keeshare/KeeShare.h @@ -54,6 +54,9 @@ class KeeShare : public QObject static const Group* resolveSharedGroup(const Group* group); static QString sharingLabel(const Group* group); + static QString deviceId(); + static void setDeviceId(const QString& id); + static KeeShareSettings::Own own(); static void setOwn(const KeeShareSettings::Own& own); diff --git a/src/keeshare/KeeShareSettings.cpp b/src/keeshare/KeeShareSettings.cpp index 61ab2bb8eb..558601ee58 100644 --- a/src/keeshare/KeeShareSettings.cpp +++ b/src/keeshare/KeeShareSettings.cpp @@ -25,6 +25,8 @@ #include "gui/DatabaseIcons.h" #include +#include +#include #include #include @@ -286,6 +288,15 @@ namespace KeeShareSettings return (type & ImportFrom) != 0 && !path.isEmpty(); } + bool Reference::isPerDeviceMode(const QDir& baseDir) const + { + if (path.isEmpty()) { + return false; + } + const QString resolvedPath = baseDir.absoluteFilePath(path); + return QFileInfo(resolvedPath).isDir(); + } + bool Reference::operator<(const Reference& other) const { if (type != other.type) { diff --git a/src/keeshare/KeeShareSettings.h b/src/keeshare/KeeShareSettings.h index 667cb8a74e..1a72118ec6 100644 --- a/src/keeshare/KeeShareSettings.h +++ b/src/keeshare/KeeShareSettings.h @@ -21,6 +21,8 @@ #include #include +class QDir; + namespace Botan { class Private_Key; @@ -133,6 +135,7 @@ namespace KeeShareSettings bool isValid() const; bool isExporting() const; bool isImporting() const; + bool isPerDeviceMode(const QDir& baseDir) const; bool operator<(const Reference& other) const; bool operator==(const Reference& other) const; diff --git a/src/keeshare/SettingsWidgetKeeShare.cpp b/src/keeshare/SettingsWidgetKeeShare.cpp index 7462aa5f3c..850864d766 100644 --- a/src/keeshare/SettingsWidgetKeeShare.cpp +++ b/src/keeshare/SettingsWidgetKeeShare.cpp @@ -23,6 +23,7 @@ #include "gui/MessageBox.h" #include "keeshare/KeeShare.h" +#include #include #include #include @@ -33,6 +34,9 @@ SettingsWidgetKeeShare::SettingsWidgetKeeShare(QWidget* parent) { m_ui->setupUi(this); + m_ui->deviceIdEdit->setValidator( + new QRegularExpressionValidator(QRegularExpression("[A-Za-z0-9]{0,32}"), this)); + connect(m_ui->ownCertificateSignerEdit, SIGNAL(textChanged(QString)), SLOT(setVerificationExporter(QString))); connect(m_ui->generateOwnCerticateButton, SIGNAL(clicked(bool)), SLOT(generateCertificate())); } @@ -47,6 +51,8 @@ void SettingsWidgetKeeShare::loadSettings() m_ui->enableExportCheckBox->setChecked(active.out); m_ui->enableImportCheckBox->setChecked(active.in); + m_ui->deviceIdEdit->setText(KeeShare::deviceId()); + m_own = KeeShare::own(); updateOwnCertificate(); } @@ -68,6 +74,14 @@ void SettingsWidgetKeeShare::saveSettings() KeeShare::setOwn(m_own); KeeShare::setActive(active); + auto deviceId = m_ui->deviceIdEdit->text().trimmed(); + if (!deviceId.isEmpty()) { + KeeShare::setDeviceId(deviceId); + } else { + // Clear stored ID so it will be auto-detected next time + config()->set(Config::KeeShare_DeviceId, QString()); + } + config()->set(Config::KeeShare_QuietSuccess, m_ui->quietSuccessCheckBox->isChecked()); } diff --git a/src/keeshare/SettingsWidgetKeeShare.ui b/src/keeshare/SettingsWidgetKeeShare.ui index 48a79d8d36..56211af1ae 100644 --- a/src/keeshare/SettingsWidgetKeeShare.ui +++ b/src/keeshare/SettingsWidgetKeeShare.ui @@ -68,6 +68,48 @@ + + + + Device Identity + + + + + + Device ID: + + + + + + + Device ID field + + + Unique identifier for this device in per-device sync mode (alphanumeric only) + + + Auto-detected from system + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + diff --git a/src/keeshare/ShareObserver.cpp b/src/keeshare/ShareObserver.cpp index 812dbc0f09..e80c3d813b 100644 --- a/src/keeshare/ShareObserver.cpp +++ b/src/keeshare/ShareObserver.cpp @@ -23,6 +23,7 @@ #include "keeshare/ShareImport.h" #include +#include namespace { @@ -67,6 +68,7 @@ void ShareObserver::deinitialize() m_groupToReference.clear(); m_shareToGroup.clear(); m_fileWatchers.clear(); + m_dirWatchers.clear(); } void ShareObserver::reinitialize() @@ -83,6 +85,7 @@ void ShareObserver::reinitialize() m_groupToReference.remove(group); m_shareToGroup.remove(oldResolvedPath); m_fileWatchers.remove(oldResolvedPath); + m_dirWatchers.remove(oldResolvedPath); if (newReference.isValid()) { m_groupToReference[group] = newReference; @@ -99,6 +102,8 @@ void ShareObserver::reinitialize() QMap imported; QMap exported; + const QDir baseDir = QFileInfo(m_db->filePath()).absoluteDir(); + for (const auto& share : shares) { auto group = share.first; auto& reference = share.second; @@ -109,10 +114,23 @@ void ShareObserver::reinitialize() if (!reference.path.isEmpty() && reference.type != KeeShareSettings::Inactive) { const auto newResolvedPath = resolvePath(reference.path, m_db); - auto fileWatcher = QSharedPointer::create(this); - connect(fileWatcher.data(), &FileWatcher::fileChanged, this, &ShareObserver::handleFileUpdated); - fileWatcher->start(newResolvedPath, FileWatchPeriod, FileWatchSize); - m_fileWatchers.insert(newResolvedPath, fileWatcher); + + if (reference.isPerDeviceMode(baseDir)) { + // Per-device mode: watch the directory for changes + auto dirWatcher = QSharedPointer::create(); + if (QDir(newResolvedPath).exists()) { + dirWatcher->addPath(newResolvedPath); + } + connect(dirWatcher.data(), &QFileSystemWatcher::directoryChanged, + this, &ShareObserver::handleDirectoryUpdated); + m_dirWatchers.insert(newResolvedPath, dirWatcher); + } else { + // Classic mode: watch the individual file + auto fileWatcher = QSharedPointer::create(this); + connect(fileWatcher.data(), &FileWatcher::fileChanged, this, &ShareObserver::handleFileUpdated); + fileWatcher->start(newResolvedPath, FileWatchPeriod, FileWatchSize); + m_fileWatchers.insert(newResolvedPath, fileWatcher); + } } if (reference.isExporting()) { exported[reference.path] << group->name(); @@ -121,21 +139,42 @@ void ShareObserver::reinitialize() if (reference.isImporting()) { imported[reference.path] << group->name(); - // import has to occur immediately - const auto result = this->importShare(reference.path); - if (!result.isValid()) { - // tolerable result - blocked import or missing source - continue; - } - - if (result.isError()) { - error << tr("Import from %1 failed (%2)").arg(result.path).arg(result.message); - } else if (result.isWarning()) { - warning << tr("Import from %1 failed (%2)").arg(result.path).arg(result.message); - } else if (result.isInfo()) { - success << tr("Import from %1 successful (%2)").arg(result.path).arg(result.message); + const auto resolvedDir = resolvePath(reference.path, m_db); + + if (reference.isPerDeviceMode(baseDir)) { + // Per-device mode: import from all device files in the directory + const auto results = importPerDeviceShares(resolvedDir, reference, group); + for (const auto& result : results) { + if (!result.isValid()) { + continue; + } + if (result.isError()) { + error << tr("Import from %1 failed (%2)").arg(result.path, result.message); + } else if (result.isWarning()) { + warning << tr("Import from %1 failed (%2)").arg(result.path, result.message); + } else if (result.isInfo()) { + success << tr("Import from %1 successful (%2)").arg(result.path, result.message); + } else { + success << tr("Imported from %1").arg(result.path); + } + } } else { - success << tr("Imported from %1").arg(result.path); + // Classic mode: import single file + const auto result = this->importShare(reference.path); + if (!result.isValid()) { + // tolerable result - blocked import or missing source + continue; + } + + if (result.isError()) { + error << tr("Import from %1 failed (%2)").arg(result.path).arg(result.message); + } else if (result.isWarning()) { + warning << tr("Import from %1 failed (%2)").arg(result.path).arg(result.message); + } else if (result.isInfo()) { + success << tr("Import from %1 successful (%2)").arg(result.path).arg(result.message); + } else { + success << tr("Imported from %1").arg(result.path); + } } } } @@ -216,6 +255,87 @@ void ShareObserver::handleFileUpdated(const QString& path) } } +void ShareObserver::handleDirectoryUpdated(const QString& dirPath) +{ + auto group = m_shareToGroup.value(dirPath); + if (!group) { + return; + } + auto reference = KeeShare::referenceOf(group); + const QDir handleBaseDir = QFileInfo(m_db->filePath()).absoluteDir(); + if (!reference.isImporting() || !reference.isPerDeviceMode(handleBaseDir)) { + return; + } + + // Re-add the directory to the watcher (Qt removes it after notification) + auto dirWatcher = m_dirWatchers.value(dirPath); + if (dirWatcher && dirWatcher->directories().isEmpty()) { + dirWatcher->addPath(dirPath); + } + + if (!m_inDirUpdate) { + QTimer::singleShot(100, this, [this, dirPath] { + auto shareGroup = m_shareToGroup.value(dirPath); + if (!shareGroup) { + m_inDirUpdate = false; + return; + } + auto shareRef = KeeShare::referenceOf(shareGroup); + auto results = importPerDeviceShares(dirPath, shareRef, shareGroup); + m_inDirUpdate = false; + + QStringList success; + QStringList warning; + QStringList error; + for (const auto& result : results) { + if (!result.isValid()) { + continue; + } + if (result.isError()) { + error << tr("Import from %1 failed (%2)").arg(result.path, result.message); + } else if (result.isWarning()) { + warning << tr("Import from %1 failed (%2)").arg(result.path, result.message); + } else if (result.isInfo()) { + success << tr("Import from %1 successful (%2)").arg(result.path, result.message); + } else { + success << tr("Imported from %1").arg(result.path); + } + } + notifyAbout(success, warning, error); + }); + m_inDirUpdate = true; + } +} + +QList ShareObserver::importPerDeviceShares( + const QString& resolvedDir, + const KeeShareSettings::Reference& reference, + Group* targetGroup) +{ + QList results; + if (!KeeShare::active().in) { + return results; + } + + const QString ownFile = KeeShare::deviceId() + ".kdbx"; + QDir dir(resolvedDir); + if (!dir.exists()) { + return results; + } + + const auto files = dir.entryList({"*.kdbx"}, QDir::Files, QDir::Name); + for (const auto& fileName : files) { + if (fileName.compare(ownFile, Qt::CaseInsensitive) == 0) { + continue; // Skip own device's file + } + const auto filePath = dir.absoluteFilePath(fileName); + auto result = ShareImport::containerInto(filePath, reference, targetGroup); + result.path = filePath; + results << result; + } + return results; +} + ShareObserver::Result ShareObserver::importShare(const QString& path) { if (!KeeShare::active().in) { @@ -283,19 +403,50 @@ QList ShareObserver::exportShares() return results; } + const QDir exportBaseDir = QFileInfo(m_db->filePath()).absoluteDir(); + for (auto it = references.cbegin(); it != references.cend(); ++it) { auto reference = it.value().first(); const QString resolvedPath = resolvePath(reference.config.path, m_db); - auto watcher = m_fileWatchers.value(resolvedPath); - if (watcher) { - watcher->stop(); - } - // TODO: save new path into group settings if not saving to signed container anymore - results << ShareExport::intoContainer(resolvedPath, reference.config, reference.group); + if (reference.config.isPerDeviceMode(exportBaseDir)) { + // Per-device mode: export to {directory}/{DEVICE_ID}.kdbx + QDir dir(resolvedPath); + if (!dir.exists()) { + if (!dir.mkpath(".")) { + results << Result{resolvedPath, + Result::Error, + tr("Could not create directory %1").arg(resolvedPath)}; + continue; + } + } + const auto deviceFile = dir.absoluteFilePath(KeeShare::deviceId() + ".kdbx"); + + // Pause directory watcher during export + auto dirWatcher = m_dirWatchers.value(resolvedPath); + if (dirWatcher) { + dirWatcher->removePath(resolvedPath); + } + + results << ShareExport::intoContainer(deviceFile, reference.config, reference.group); - if (watcher) { - watcher->start(resolvedPath, FileWatchPeriod, FileWatchSize); + // Resume directory watcher (also add path if it was newly created) + if (dirWatcher) { + dirWatcher->addPath(resolvedPath); + } + } else { + // Classic mode: export to the file directly + auto watcher = m_fileWatchers.value(resolvedPath); + if (watcher) { + watcher->stop(); + } + + // TODO: save new path into group settings if not saving to signed container anymore + results << ShareExport::intoContainer(resolvedPath, reference.config, reference.group); + + if (watcher) { + watcher->start(resolvedPath, FileWatchPeriod, FileWatchSize); + } } } return results; diff --git a/src/keeshare/ShareObserver.h b/src/keeshare/ShareObserver.h index 0694aee50c..ac1a2e6451 100644 --- a/src/keeshare/ShareObserver.h +++ b/src/keeshare/ShareObserver.h @@ -18,6 +18,7 @@ #ifndef KEEPASSXC_SHAREOBSERVER_H #define KEEPASSXC_SHAREOBSERVER_H +#include #include #include @@ -68,10 +69,14 @@ private slots: void handleDatabaseChanged(); void handleDatabaseSaved(); void handleFileUpdated(const QString& path); + void handleDirectoryUpdated(const QString& dirPath); private: Result importShare(const QString& path); QList exportShares(); + QList importPerDeviceShares(const QString& resolvedDir, + const KeeShareSettings::Reference& reference, + Group* targetGroup); void deinitialize(); void reinitialize(); @@ -82,7 +87,9 @@ private slots: QMap, KeeShareSettings::Reference> m_groupToReference; QMap> m_shareToGroup; QMap> m_fileWatchers; + QMap> m_dirWatchers; bool m_inFileUpdate = false; + bool m_inDirUpdate = false; bool m_enabled = true; }; diff --git a/src/keeshare/group/EditGroupWidgetKeeShare.cpp b/src/keeshare/group/EditGroupWidgetKeeShare.cpp index bea495b0aa..2a0dc7cda5 100644 --- a/src/keeshare/group/EditGroupWidgetKeeShare.cpp +++ b/src/keeshare/group/EditGroupWidgetKeeShare.cpp @@ -24,6 +24,7 @@ #include "keeshare/KeeShare.h" #include +#include #include EditGroupWidgetKeeShare::EditGroupWidgetKeeShare(QWidget* parent) @@ -110,20 +111,30 @@ void EditGroupWidgetKeeShare::updateSharingState() // Custom message for active KeeShare reference const auto reference = KeeShare::referenceOf(m_temporaryGroup); + const QDir uiBaseDir = QFileInfo(m_database->filePath()).absoluteDir(); if (!reference.path.isEmpty()) { - bool supported = false; - for (const auto& extension : supportedExtensions) { - if (reference.path.endsWith(extension, Qt::CaseInsensitive)) { - supported = true; - break; + if (reference.isPerDeviceMode(uiBaseDir)) { + // Per-device mode: path is a directory, show info message + m_ui->messageWidget->showMessage( + tr("Per-device sync mode: each device writes its own container in this directory.\n" + "Device ID: %1").arg(KeeShare::deviceId()), + MessageWidget::Information); + } else { + // Classic mode: validate file extension + bool supported = false; + for (const auto& extension : supportedExtensions) { + if (reference.path.endsWith(extension, Qt::CaseInsensitive)) { + supported = true; + break; + } + } + if (!supported) { + m_ui->messageWidget->showMessage(tr("Your KeePassXC version does not support sharing this container type.\n" + "Supported extensions are: %1.") + .arg(supportedExtensions.join(", ")), + MessageWidget::Warning); + return; } - } - if (!supported) { - m_ui->messageWidget->showMessage(tr("Your KeePassXC version does not support sharing this container type.\n" - "Supported extensions are: %1.") - .arg(supportedExtensions.join(", ")), - MessageWidget::Warning); - return; } const auto groups = m_database->rootGroup()->groupsRecursive(true); @@ -140,8 +151,12 @@ void EditGroupWidgetKeeShare::updateSharingState() } multipleImport |= other.isImporting() && reference.isImporting(); conflictExport |= other.isExporting() && reference.isExporting(); - cycleImportExport |= - (other.isImporting() && reference.isExporting()) || (other.isExporting() && reference.isImporting()); + // In per-device mode, import+export to the same directory is expected + // (export writes own device file, import reads other devices' files) + if (!reference.isPerDeviceMode(uiBaseDir)) { + cycleImportExport |= + (other.isImporting() && reference.isExporting()) || (other.isExporting() && reference.isImporting()); + } } if (conflictExport) { m_ui->messageWidget->showMessage(tr("%1 is already being exported by this database.").arg(reference.path), @@ -239,6 +254,23 @@ void EditGroupWidgetKeeShare::launchPathSelectionDialog() if (filename.isEmpty()) { filename = m_temporaryGroup->name(); } + + // For SynchronizeWith, offer both file and directory selection + if (reference.type == KeeShareSettings::SynchronizeWith) { + // Try directory selection first for per-device sync + auto dirPath = fileDialog()->getExistingDirectory( + this, tr("Select per-device sync directory"), defaultDirPath); + if (!dirPath.isEmpty()) { + // Directory selected: per-device mode + m_ui->pathEdit->setText(dirPath); + selectPath(); + FileDialog::saveLastDir("keeshare", dirPath); + updateSharingState(); + return; + } + // User cancelled directory dialog; fall through to file dialog + } + switch (reference.type) { case KeeShareSettings::ImportFrom: filename = fileDialog()->getOpenFileName(this, tr("Select import source"), defaultDirPath, filters); diff --git a/src/keeshare/group/EditGroupWidgetKeeShare.ui b/src/keeshare/group/EditGroupWidgetKeeShare.ui index 1e8e0e1a6d..582165efc4 100644 --- a/src/keeshare/group/EditGroupWidgetKeeShare.ui +++ b/src/keeshare/group/EditGroupWidgetKeeShare.ui @@ -124,6 +124,9 @@ Path to share file field + + File path for classic mode, or directory path for per-device sync + diff --git a/src/quickunlock/Polkit.cpp b/src/quickunlock/Polkit.cpp index d73a7c71b0..aad3535876 100644 --- a/src/quickunlock/Polkit.cpp +++ b/src/quickunlock/Polkit.cpp @@ -48,6 +48,7 @@ Polkit::Polkit() { PolkitSubject::registerMetaType(); PolkitAuthorizationResults::registerMetaType(); + qDBusRegisterMetaType>(); /* Note we explicitly use our own dbus path here, as the ::systemBus() method could be overridden through an environment variable to return an alternative bus path. This bus could have an application diff --git a/tests/TestSharing.cpp b/tests/TestSharing.cpp index 0b5414ea8e..27884703fc 100644 --- a/tests/TestSharing.cpp +++ b/tests/TestSharing.cpp @@ -17,6 +17,7 @@ #include "TestSharing.h" +#include #include #include @@ -35,6 +36,14 @@ Q_DECLARE_METATYPE(KeeShareSettings::Certificate) void TestSharing::initTestCase() { QVERIFY(Crypto::init()); + m_tempDir = new QTemporaryDir(); + QVERIFY(m_tempDir->isValid()); +} + +void TestSharing::cleanupTestCase() +{ + delete m_tempDir; + m_tempDir = nullptr; } void TestSharing::testNullObjects() @@ -175,6 +184,92 @@ void TestSharing::testSettingsSerialization_data() QTest::newRow("5") << false << false << certificate0 << key0; } +void TestSharing::testPerDeviceMode() +{ + QFETCH(QString, path); + QFETCH(QString, baseDir); + QFETCH(bool, expectedPerDevice); + + KeeShareSettings::Reference reference; + reference.path = path; + reference.type = KeeShareSettings::SynchronizeWith; + + QCOMPARE(reference.isPerDeviceMode(QDir(baseDir)), expectedPerDevice); +} + +void TestSharing::testPerDeviceMode_data() +{ + QTest::addColumn("path"); + QTest::addColumn("baseDir"); + QTest::addColumn("expectedPerDevice"); + + auto base = m_tempDir->path(); + + // Classic mode paths (file-based — don't need to exist on disk) + QTest::newRow("kdbx file") << "/some/path/share.kdbx" << base << false; + QTest::newRow("kdbx.share file") << "/some/path/share.kdbx.share" << base << false; + QTest::newRow("KDBX uppercase") << "/some/path/share.KDBX" << base << false; + QTest::newRow("KDBX.SHARE uppercase") << "/some/path/share.KDBX.SHARE" << base << false; + QTest::newRow("empty path") << "" << base << false; + QTest::newRow("nonexistent path") << "/nonexistent/path/nowhere" << base << false; + + // Per-device mode paths (real directories on disk — absolute) + QDir(base).mkpath("syncdir"); + QTest::newRow("directory path") << base + "/syncdir" << base << true; + + QDir(base).mkpath("syncdir_slash"); + QTest::newRow("directory trailing slash") << base + "/syncdir_slash/" << base << true; + + QDir(base).mkpath("sub/shared"); + QTest::newRow("nested directory") << base + "/sub/shared" << base << true; + + QDir(base).mkpath("path.d/sync"); + QTest::newRow("directory with dots") << base + "/path.d/sync" << base << true; + + // Per-device mode with relative path (regression test: must resolve against baseDir) + QDir(base).mkpath("reldir"); + QTest::newRow("relative directory") << "reldir" << base << true; + QTest::newRow("relative file") << "share.kdbx" << base << false; +} + +void TestSharing::testPerDeviceModeImportExport() +{ + QFETCH(QString, path); + QFETCH(int, type); + QFETCH(bool, expectedImporting); + QFETCH(bool, expectedExporting); + + KeeShareSettings::Reference reference; + reference.path = path; + reference.type = static_cast(type); + + QCOMPARE(reference.isImporting(), expectedImporting); + QCOMPARE(reference.isExporting(), expectedExporting); +} + +void TestSharing::testPerDeviceModeImportExport_data() +{ + QTest::addColumn("path"); + QTest::addColumn("type"); + QTest::addColumn("expectedImporting"); + QTest::addColumn("expectedExporting"); + + auto base = m_tempDir->path(); + QDir(base).mkpath("importexport"); + auto dir = base + "/importexport"; + + QTest::newRow("per-device sync imports") + << dir << int(KeeShareSettings::SynchronizeWith) << true << true; + QTest::newRow("per-device import only") + << dir << int(KeeShareSettings::ImportFrom) << true << false; + QTest::newRow("per-device export only") + << dir << int(KeeShareSettings::ExportTo) << false << true; + QTest::newRow("classic file sync") + << dir + "/share.kdbx" << int(KeeShareSettings::SynchronizeWith) << true << true; + QTest::newRow("inactive per-device") + << dir << int(KeeShareSettings::Inactive) << false << false; +} + const QSharedPointer TestSharing::stubkey(int index) { static QMap> keys; diff --git a/tests/TestSharing.h b/tests/TestSharing.h index cfb521e02e..7d2c994a3e 100644 --- a/tests/TestSharing.h +++ b/tests/TestSharing.h @@ -19,6 +19,7 @@ #define KEEPASSXC_TESTSHARING_H #include +#include namespace Botan { @@ -30,15 +31,21 @@ class TestSharing : public QObject private slots: void initTestCase(); + void cleanupTestCase(); void testNullObjects(); void testKeySerialization(); void testReferenceSerialization(); void testReferenceSerialization_data(); void testSettingsSerialization(); void testSettingsSerialization_data(); + void testPerDeviceMode(); + void testPerDeviceMode_data(); + void testPerDeviceModeImportExport(); + void testPerDeviceModeImportExport_data(); private: const QSharedPointer stubkey(int index = 0); + QTemporaryDir* m_tempDir = nullptr; }; #endif // KEEPASSXC_TESTSHARING_H