diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index e86cf5a6ffa6d..ea0694d3fd45a 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -78,6 +78,8 @@ set(client_SRCS conflictsolver.cpp connectionvalidator.h connectionvalidator.cpp + e2eefoldermanager.h + e2eefoldermanager.cpp editlocallyjob.h editlocallyjob.cpp editlocallymanager.h diff --git a/src/gui/accountsettings.cpp b/src/gui/accountsettings.cpp index b08ac34f0563d..1951dec8a7f41 100644 --- a/src/gui/accountsettings.cpp +++ b/src/gui/accountsettings.cpp @@ -1602,41 +1602,6 @@ void AccountSettings::slotSelectiveSyncChanged(const QModelIndex &topLeft, } } -void AccountSettings::slotPossiblyUnblacklistE2EeFoldersAndRestartSync() -{ - if (!_accountState->account()->e2e()->isInitialized()) { - return; - } - - disconnect(_accountState->account()->e2e(), &ClientSideEncryption::initializationFinished, this, &AccountSettings::slotPossiblyUnblacklistE2EeFoldersAndRestartSync); - - for (const auto folder : FolderMan::instance()->map()) { - if (folder->accountState() != _accountState) { - continue; - } - bool ok = false; - const auto foldersToRemoveFromBlacklist = folder->journalDb()->getSelectiveSyncList(SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, &ok); - if (foldersToRemoveFromBlacklist.isEmpty()) { - continue; - } - auto blackList = folder->journalDb()->getSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, &ok); - const auto blackListSize = blackList.size(); - if (blackListSize == 0) { - continue; - } - for (const auto &pathToRemoveFromBlackList : foldersToRemoveFromBlacklist) { - blackList.removeAll(pathToRemoveFromBlackList); - } - if (blackList.size() != blackListSize) { - if (folder->isSyncRunning()) { - folderTerminateSyncAndUpdateBlackList(blackList, folder, foldersToRemoveFromBlacklist); - return; - } - updateBlackListAndScheduleFolderSync(blackList, folder, foldersToRemoveFromBlacklist); - } - } -} - void AccountSettings::slotE2eEncryptionCertificateNeedMigration() { const auto actionMigrateCertificate = addActionToEncryptionMessage(tr("Migrate certificate to a new one"), e2EeUiActionMigrateCertificateId); @@ -1785,8 +1750,6 @@ void AccountSettings::customizeStyle() void AccountSettings::setupE2eEncryption() { - connect(_accountState->account()->e2e(), &ClientSideEncryption::initializationFinished, this, &AccountSettings::slotPossiblyUnblacklistE2EeFoldersAndRestartSync); - if (_accountState->account()->e2e()->isInitialized()) { slotE2eEncryptionMnemonicReady(); } else { @@ -1799,7 +1762,6 @@ void AccountSettings::setupE2eEncryption() "Enter the unique mnemonic to have the encrypted folders synchronize on this device as well.")); } }); - _accountState->account()->setE2eEncryptionKeysGenerationAllowed(false); _accountState->account()->e2e()->initialize(this); } } @@ -1819,6 +1781,12 @@ void AccountSettings::forgetE2eEncryption() const auto account = _accountState->account(); if (!account->e2e()->isInitialized()) { FolderMan::instance()->removeE2eFiles(account); + + for (const auto folder : FolderMan::instance()->map()) { + if (folder->accountState()->account() == account) { + folder->journalDb()->setSelectiveSyncList(SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, {}); + } + } } } diff --git a/src/gui/accountsettings.h b/src/gui/accountsettings.h index 1b875845b4891..9a9f837ca8309 100644 --- a/src/gui/accountsettings.h +++ b/src/gui/accountsettings.h @@ -117,7 +117,6 @@ protected slots: void slotSelectiveSyncChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles); - void slotPossiblyUnblacklistE2EeFoldersAndRestartSync(); void slotE2eEncryptionCertificateNeedMigration(); diff --git a/src/gui/application.cpp b/src/gui/application.cpp index 73984261da867..15ca328a31bae 100644 --- a/src/gui/application.cpp +++ b/src/gui/application.cpp @@ -15,6 +15,7 @@ #include "configfile.h" #include "connectionvalidator.h" #include "creds/abstractcredentials.h" +#include "e2eefoldermanager.h" #include "editlocallymanager.h" #include "folder.h" #include "folderman.h" @@ -564,6 +565,8 @@ void Application::setupAccountsAndFolders() const auto foldersListSize = FolderMan::instance()->setupFolders(); FolderMan::instance()->setSyncEnabled(true); + E2EFolderManager::instance(); + const auto prettyNamesList = [](const QList &accounts) { QStringList list; for (const auto &account : accounts) { diff --git a/src/gui/e2eefoldermanager.cpp b/src/gui/e2eefoldermanager.cpp new file mode 100644 index 0000000000000..ff0fe6fa8e5e6 --- /dev/null +++ b/src/gui/e2eefoldermanager.cpp @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "e2eefoldermanager.h" +#include "accountmanager.h" +#include "clientsideencryption.h" +#include "folderman.h" +#include "folder.h" + +#include + +namespace OCC { + +Q_LOGGING_CATEGORY(lcE2eFolderManager, "nextcloud.gui.e2efoldermanager", QtInfoMsg) + +E2EFolderManager *E2EFolderManager::_instance = nullptr; + +E2EFolderManager *E2EFolderManager::instance() +{ + if (!_instance) { + _instance = new E2EFolderManager(); + } + return _instance; +} + +E2EFolderManager::E2EFolderManager(QObject *parent) + : QObject(parent) +{ + const auto accounts = AccountManager::instance()->accounts(); + for (const auto &accountState : accounts) { + if (accountState && accountState->account() && accountState->account()->e2e()) { + connectE2eSignals(accountState->account()); + } + } + + connect(AccountManager::instance(), &AccountManager::accountAdded, + this, &E2EFolderManager::slotAccountAdded); +} + +E2EFolderManager::~E2EFolderManager() +{ + _instance = nullptr; +} + +void E2EFolderManager::slotAccountAdded(AccountState *accountState) +{ + if (accountState && accountState->account() && accountState->account()->e2e()) { + connectE2eSignals(accountState->account()); + } +} + +void E2EFolderManager::connectE2eSignals(const AccountPtr &account) +{ + if (!account || !account->e2e()) { + return; + } + + connect(account->e2e(), &ClientSideEncryption::initializationFinished, + this, &E2EFolderManager::slotE2eInitializationFinished, Qt::UniqueConnection); + + if (account->e2e()->isInitialized()) { + restoreE2eFoldersForAccount(account); + } +} + +void E2EFolderManager::slotE2eInitializationFinished() +{ + qCDebug(lcE2eFolderManager) << "E2E initialization finished, restoring blacklisted E2E folders"; + + auto *e2e = qobject_cast(sender()); + if (!e2e) { + qCWarning(lcE2eFolderManager) << "slotE2eInitializationFinished called but sender is not ClientSideEncryption"; + return; + } + + const auto accounts = AccountManager::instance()->accounts(); + for (const auto &accountState : accounts) { + if (accountState->account()->e2e() == e2e) { + restoreE2eFoldersForAccount(accountState->account()); + break; + } + } +} + +void E2EFolderManager::restoreE2eFoldersForAccount(const AccountPtr &account) +{ + if (!account || !account->e2e() || !account->e2e()->isInitialized()) { + qCDebug(lcE2eFolderManager) << "Cannot restore folders - account or E2E not ready"; + return; + } + + qCDebug(lcE2eFolderManager) << "Restoring E2E folders for account:" << account->displayName(); + + auto *folderMan = FolderMan::instance(); + const auto folders = folderMan->map(); + + int foldersProcessed = 0; + for (const auto &folder : folders) { + if (folder->accountState()->account() != account) { + continue; + } + + bool ok = false; + const auto foldersToRemoveFromBlacklist = folder->journalDb()->getSelectiveSyncList( + SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, &ok); + + if (foldersToRemoveFromBlacklist.isEmpty()) { + continue; + } + + qCDebug(lcE2eFolderManager) << "Found E2E folders to restore for" << folder->alias() + << ":" << foldersToRemoveFromBlacklist; + + auto blackList = folder->journalDb()->getSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, &ok); + qCDebug(lcE2eFolderManager) << "Current blacklist:" << blackList; + const auto blackListSize = blackList.size(); + + for (const auto &pathToRemoveFromBlackList : foldersToRemoveFromBlacklist) { + blackList.removeAll(pathToRemoveFromBlackList); + } + + if (blackList.size() != blackListSize) { + if (folder->isSyncRunning()) { + qCDebug(lcE2eFolderManager) << "Folder is syncing, terminating to prevent E2E folder deletion"; + folderTerminateSyncAndUpdateBlackList(blackList, folder, foldersToRemoveFromBlacklist); + ++foldersProcessed; + continue; + } + + folder->journalDb()->setSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, blackList); + folder->journalDb()->setSelectiveSyncList(SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, {}); + + for (const auto &pathToRemoteDiscover : foldersToRemoveFromBlacklist) { + folder->journalDb()->schedulePathForRemoteDiscovery(pathToRemoteDiscover); + } + + folderMan->scheduleFolder(folder); + ++foldersProcessed; + } + } + + if (foldersProcessed > 0) { + qCDebug(lcE2eFolderManager) << "Restored E2E folders for" << foldersProcessed << "sync folders"; + } else { + qCDebug(lcE2eFolderManager) << "No E2E folders needed restoration"; + } +} + +void E2EFolderManager::folderTerminateSyncAndUpdateBlackList(const QStringList &blackList, OCC::Folder *folder, const QStringList &foldersToRemoveFromBlacklist) +{ + if (_folderConnections.contains(folder->alias())) { + qCWarning(lcE2eFolderManager) << "Folder " << folder->alias() << "is already terminating the sync."; + return; + } + // in case sync is already running - terminate it and start a new one + const QMetaObject::Connection syncTerminatedConnection = connect(folder, &Folder::syncFinished, this, [this, blackList, folder, foldersToRemoveFromBlacklist]() { + const auto foundConnectionIt = _folderConnections.find(folder->alias()); + if (foundConnectionIt != _folderConnections.end()) { + disconnect(*foundConnectionIt); + _folderConnections.erase(foundConnectionIt); + } + + folder->journalDb()->setSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, blackList); + folder->journalDb()->setSelectiveSyncList(SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, {}); + + for (const auto &pathToRemoteDiscover : foldersToRemoveFromBlacklist) { + folder->journalDb()->schedulePathForRemoteDiscovery(pathToRemoteDiscover); + } + + FolderMan::instance()->scheduleFolder(folder); + }); + _folderConnections.insert(folder->alias(), syncTerminatedConnection); + folder->slotTerminateSync(); +} + +} // namespace OCC diff --git a/src/gui/e2eefoldermanager.h b/src/gui/e2eefoldermanager.h new file mode 100644 index 0000000000000..97ba102450980 --- /dev/null +++ b/src/gui/e2eefoldermanager.h @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include "account.h" +#include "accountmanager.h" + +namespace OCC { + +class Folder; + +/** + * @brief Stateless bridge between E2E encryption and folder management + * + * This class acts as a mediator that: + * - Listens to E2E initialization signals from all accounts + * - Coordinates folder restoration when E2E becomes ready + * - Keeps E2E concerns separate from FolderMan's core responsibilities + * + * @ingroup gui + */ +class E2EFolderManager : public QObject +{ + Q_OBJECT + +public: + static E2EFolderManager *instance(); + ~E2EFolderManager() override; + +private slots: + void slotE2eInitializationFinished(); + void slotAccountAdded(AccountState *accountState); + +private: + E2EFolderManager(QObject *parent = nullptr); + + void connectE2eSignals(const AccountPtr &account); + void restoreE2eFoldersForAccount(const AccountPtr &account); + void folderTerminateSyncAndUpdateBlackList(const QStringList &blackList, OCC::Folder *folder, const QStringList &foldersToRemoveFromBlacklist); + + QMap _folderConnections; + static E2EFolderManager *_instance; +}; + +} // namespace OCC diff --git a/src/gui/folderwatcher.cpp b/src/gui/folderwatcher.cpp index 6f7c632f774a2..bec8ecd11e728 100644 --- a/src/gui/folderwatcher.cpp +++ b/src/gui/folderwatcher.cpp @@ -113,8 +113,7 @@ void FolderWatcher::performSetPermissionsTest(const QString &path) if (!QFile::exists(path)) { QFile f(path); - f.open(QIODevice::WriteOnly); - if (!f.isOpen()) { + if (!f.open(QIODevice::WriteOnly)) { qCWarning(lcFolderWatcher()) << "Failed to create test file: " << path; return; } @@ -158,7 +157,9 @@ void FolderWatcher::startNotificationTestWhenReady() FileSystem::setModTime(path, mtime + 1); } else { QFile f(path); - f.open(QIODevice::WriteOnly | QIODevice::Append); + if (!f.open(QIODevice::WriteOnly | QIODevice::Append)) { + qCWarning(lcFolderWatcher()) << "Failed to open test notification file for writing:" << path << f.errorString(); + } } FileSystem::setFileHidden(path, true); diff --git a/src/libsync/clientsideencryption.cpp b/src/libsync/clientsideencryption.cpp index 693c1bcee16b1..df2ae72484d49 100644 --- a/src/libsync/clientsideencryption.cpp +++ b/src/libsync/clientsideencryption.cpp @@ -900,6 +900,21 @@ bool ClientSideEncryption::isInitialized() const return useTokenBasedEncryption() || !getMnemonic().isEmpty(); } +void ClientSideEncryption::setInitializationState(InitializationState state) +{ + if (_initializationState == state) { + return; + } + + _initializationState = state; + emit initializationStateChanged(_initializationState); +} + +ClientSideEncryption::InitializationState ClientSideEncryption::initializationState() const +{ + return _initializationState; +} + QSslKey ClientSideEncryption::getPublicKey() const { return _encryptionCertificate.getSslPublicKey(); @@ -1048,8 +1063,17 @@ void ClientSideEncryption::initialize(QWidget *settingsDialog) Q_ASSERT(_account); qCInfo(lcCse()) << "Initializing"; + + if (_initializationState == InitializationState::Initializing) { + qCWarning(lcCse()) << "E2E encryption already initializing, ignoring duplicate request"; + return; + } + + setInitializationState(InitializationState::Initializing); + if (!_account->capabilities().clientSideEncryptionAvailable()) { qCInfo(lcCse()) << "No Client side encryption available on server."; + setInitializationState(InitializationState::Failed); emit initializationFinished(); return; } @@ -1070,6 +1094,7 @@ void ClientSideEncryption::initialize(QWidget *settingsDialog) Q_EMIT finishedDiscoveryEncryptionUsbToken(); }); } else { + setInitializationState(InitializationState::Failed); emit initializationFinished(); } } else { @@ -1795,6 +1820,9 @@ void ClientSideEncryption::forgetSensitiveData() _encryptionCertificate.clear(); _otherCertificates.clear(); _context.clear(); + + setInitializationState(InitializationState::NotStarted); + Q_EMIT canDecryptChanged(); Q_EMIT canEncryptChanged(); Q_EMIT userCertificateNeedsMigrationChanged(); @@ -1901,6 +1929,7 @@ bool ClientSideEncryption::sensitiveDataRemaining() const void ClientSideEncryption::failedToInitialize() { forgetSensitiveData(); + setInitializationState(InitializationState::Failed); Q_EMIT initializationFinished(); } @@ -2110,6 +2139,7 @@ void ClientSideEncryption::sendPublicKey() case 200: case 409: saveCertificateIdentification(); + setInitializationState(InitializationState::Initialized); emit initializationFinished(); break; @@ -2189,6 +2219,10 @@ void ClientSideEncryption::checkServerHasSavedKeys() }; const auto privateKeyOnServerIsValid = [this] () { + qCInfo(lcCse) << "Private key on server is valid, setting state to Initialized"; + setInitializationState(InitializationState::Initialized); + qCInfo(lcCse) << "State set to:" << static_cast(_initializationState); + qCInfo(lcCse) << "Emitting initializationFinished signal"; Q_EMIT initializationFinished(); }; diff --git a/src/libsync/clientsideencryption.h b/src/libsync/clientsideencryption.h index 889735e0700b5..367cb29568b81 100644 --- a/src/libsync/clientsideencryption.h +++ b/src/libsync/clientsideencryption.h @@ -245,12 +245,23 @@ class OWNCLOUDSYNC_EXPORT ClientSideEncryption : public QObject { FatalError, }; + enum class InitializationState + { + NotStarted, + Initializing, + Initialized, + Failed + }; + Q_ENUM(EncryptionErrorType) + Q_ENUM(InitializationState) explicit ClientSideEncryption(); [[nodiscard]] bool isInitialized() const; + [[nodiscard]] InitializationState initializationState() const; + [[nodiscard]] bool tokenIsSetup() const; [[nodiscard]] QSslKey getPublicKey() const; @@ -297,6 +308,7 @@ class OWNCLOUDSYNC_EXPORT ClientSideEncryption : public QObject { signals: void initializationFinished(bool isNewMnemonicGenerated = false); + void initializationStateChanged(InitializationState state); void sensitiveDataForgotten(); void privateKeyDeleted(); void certificateDeleted(); @@ -393,6 +405,8 @@ private slots: [[nodiscard]] bool checkEncryptionIsWorking(const CertificateInformation ¤tCertificate); + void setInitializationState(InitializationState state); + void failedToInitialize(); void saveCertificateIdentification() const; @@ -400,6 +414,8 @@ private slots: AccountPtr _account; + InitializationState _initializationState = InitializationState::NotStarted; + QString _mnemonic; bool _newMnemonicGenerated = false; diff --git a/src/libsync/discovery.cpp b/src/libsync/discovery.cpp index 103518e8a7f68..346bb1d581c2b 100644 --- a/src/libsync/discovery.cpp +++ b/src/libsync/discovery.cpp @@ -221,14 +221,38 @@ void ProcessDirectoryJob::process() // Recall file shall not be ignored (#4420) const auto isHidden = e.localEntry.isHidden || (!f.first.isEmpty() && f.first[0] == '.' && f.first != QLatin1String(".sys.admin#recall#")); - const auto isEncryptedFolderButE2eIsNotSetup = e.serverEntry.isValid() && e.serverEntry.isE2eEncrypted() && - _discoveryData->_account->e2e() && !_discoveryData->_account->e2e()->isInitialized(); + const auto isE2eEncryptedFolder = e.serverEntry.isValid() && e.serverEntry.isE2eEncrypted(); + const auto hasE2eCapability = _discoveryData->_account->capabilities().clientSideEncryptionAvailable(); + + auto isEncryptedFolderButE2eIsNotSetup = false; + auto shouldDeferE2eFolder = false; + + if (isE2eEncryptedFolder && hasE2eCapability) { + const auto e2eState = _discoveryData->_account->e2e()->initializationState(); + + if (e2eState == OCC::ClientSideEncryption::InitializationState::NotStarted || + e2eState == OCC::ClientSideEncryption::InitializationState::Initializing) { + shouldDeferE2eFolder = true; + } else if (e2eState == OCC::ClientSideEncryption::InitializationState::Failed) { + isEncryptedFolderButE2eIsNotSetup = true; + } + } else if (isE2eEncryptedFolder && !hasE2eCapability) { + isEncryptedFolderButE2eIsNotSetup = true; + } + + if (shouldDeferE2eFolder) { + qCDebug(lcDisco) << "E2E encrypted folder found but E2E still initializing, deferring:" << path._server; + checkAndUpdateSelectiveSyncListsForE2eeFolders(path._server + "/", E2eeFolderRestorationMode::TrackForRestoration); + continue; + } if (isEncryptedFolderButE2eIsNotSetup) { - checkAndUpdateSelectiveSyncListsForE2eeFolders(path._server + "/"); + qCDebug(lcDisco) << "Found E2E encrypted folder but E2E setup failed or unavailable:" << path._server; + checkAndUpdateSelectiveSyncListsForE2eeFolders(path._server + "/", E2eeFolderRestorationMode::DoNotTrackForRestoration); + continue; } - const auto isBlacklisted = _queryServer == InBlackList || _discoveryData->isInSelectiveSyncBlackList(path._original) || isEncryptedFolderButE2eIsNotSetup; + const auto isBlacklisted = _queryServer == InBlackList || _discoveryData->isInSelectiveSyncBlackList(path._original); const auto willBeExcluded = handleExcluded(path._target, e, entries, isHidden, isBlacklisted); @@ -529,7 +553,7 @@ bool ProcessDirectoryJob::canRemoveCaseClashConflictedCopy(const QString &path, return false; } -void ProcessDirectoryJob::checkAndUpdateSelectiveSyncListsForE2eeFolders(const QString &path) +void ProcessDirectoryJob::checkAndUpdateSelectiveSyncListsForE2eeFolders(const QString &path, E2eeFolderRestorationMode restorationMode) { bool ok = false; @@ -537,18 +561,28 @@ void ProcessDirectoryJob::checkAndUpdateSelectiveSyncListsForE2eeFolders(const Q const auto blackListList = _discoveryData->_statedb->getSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, &ok); auto blackListSet = QSet{blackListList.begin(), blackListList.end()}; + + if (blackListSet.contains(pathWithTrailingSlash)) { + qCDebug(lcDisco) << "E2E folder already blacklisted, skipping:" << pathWithTrailingSlash; + return; + } + blackListSet.insert(pathWithTrailingSlash); auto blackList = blackListSet.values(); blackList.sort(); _discoveryData->_statedb->setSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, blackList); - const auto toRemoveFromBlacklistList = _discoveryData->_statedb->getSelectiveSyncList(SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, &ok); - auto toRemoveFromBlacklistSet = QSet{toRemoveFromBlacklistList.begin(), toRemoveFromBlacklistList.end()}; - toRemoveFromBlacklistSet.insert(pathWithTrailingSlash); - // record it into a separate list to automatically remove from blacklist once the e2EE gets set up - auto toRemoveFromBlacklist = toRemoveFromBlacklistSet.values(); - toRemoveFromBlacklist.sort(); - _discoveryData->_statedb->setSelectiveSyncList(SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, toRemoveFromBlacklist); + if (restorationMode == E2eeFolderRestorationMode::TrackForRestoration) { + qCDebug(lcDisco) << "Blacklisting E2E folder until initialization:" << pathWithTrailingSlash; + const auto toRemoveFromBlacklistList = _discoveryData->_statedb->getSelectiveSyncList(SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, &ok); + auto toRemoveFromBlacklistSet = QSet{toRemoveFromBlacklistList.begin(), toRemoveFromBlacklistList.end()}; + toRemoveFromBlacklistSet.insert(pathWithTrailingSlash); + auto toRemoveFromBlacklist = toRemoveFromBlacklistSet.values(); + toRemoveFromBlacklist.sort(); + _discoveryData->_statedb->setSelectiveSyncList(SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, toRemoveFromBlacklist); + } else { + qCDebug(lcDisco) << "Blacklisting E2E folder (E2E failed or unavailable):" << pathWithTrailingSlash; + } } void ProcessDirectoryJob::processFile(PathTuple path, diff --git a/src/libsync/discovery.h b/src/libsync/discovery.h index f59cf75250863..9fcb4d767393f 100644 --- a/src/libsync/discovery.h +++ b/src/libsync/discovery.h @@ -146,7 +146,11 @@ class ProcessDirectoryJob : public QObject bool canRemoveCaseClashConflictedCopy(const QString &path, const std::map &allEntries); // check if the path is an e2e encrypted and the e2ee is not set up, and insert it into a corresponding list in the sync journal - void checkAndUpdateSelectiveSyncListsForE2eeFolders(const QString &path); + enum class E2eeFolderRestorationMode { + TrackForRestoration, + DoNotTrackForRestoration, + }; + void checkAndUpdateSelectiveSyncListsForE2eeFolders(const QString &path, E2eeFolderRestorationMode restorationMode); /** Reconcile local/remote/db information for a single item. * diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 22702af53a303..c1b191086763f 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -153,6 +153,7 @@ nextcloud_add_benchmark(LargeSync) nextcloud_add_test(Account) nextcloud_add_test(Folder) nextcloud_add_test(FolderMan) +nextcloud_add_test(E2eEFolderManager) nextcloud_add_test(RemoteWipe) if(NOT BUILD_FILE_PROVIDER_MODULE) diff --git a/test/teste2eefoldermanager.cpp b/test/teste2eefoldermanager.cpp new file mode 100644 index 0000000000000..dd682258a8d28 --- /dev/null +++ b/test/teste2eefoldermanager.cpp @@ -0,0 +1,363 @@ +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include + +#include "e2eefoldermanager.h" +#include "account.h" +#include "accountstate.h" +#include "accountmanager.h" +#include "clientsideencryption.h" +#include "configfile.h" +#include "folderman.h" +#include "folder.h" +#include "syncenginetestutils.h" +#include "foldermantestutils.h" + +using namespace OCC; + +class TestE2EFolderManager : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase() + { + OCC::Logger::instance()->setLogFlush(true); + OCC::Logger::instance()->setLogDebug(true); + QStandardPaths::setTestModeEnabled(true); + } + + void init() + { + // Clean up before each test + AccountManager::instance()->shutdown(); + } + + void cleanup() + { + // Clean up after each test + AccountManager::instance()->shutdown(); + } + + void testSingletonInstance() + { + auto *manager1 = E2EFolderManager::instance(); + auto *manager2 = E2EFolderManager::instance(); + + QVERIFY(manager1); + QCOMPARE(manager1, manager2); + } + + void testInitializeWithNoAccounts() + { + QVERIFY(AccountManager::instance()->accounts().isEmpty()); + + auto *manager = E2EFolderManager::instance(); + + QVERIFY(manager); + } + + void testInitializeWithExistingAccount() + { + auto account = Account::create(); + account->setCredentials(new FakeCredentials{new FakeQNAM({})}); + account->setUrl(QUrl("http://example.com")); + + auto accountState = new AccountState(account); + accountState->setParent(this); + AccountManager::instance()->addAccount(account); + + auto *manager = E2EFolderManager::instance(); + + QVERIFY(manager); + QCOMPARE(AccountManager::instance()->accounts().size(), 1); + } + + void testAccountAddedSignal() + { + auto *manager = E2EFolderManager::instance(); + QVERIFY(manager); + + auto account = Account::create(); + account->setCredentials(new FakeCredentials{new FakeQNAM({})}); + account->setUrl(QUrl("http://example.com")); + + auto accountState = new AccountState(account); + accountState->setParent(this); + + // Spy on the accountAdded signal to verify E2EFolderManager reacts to it + QSignalSpy accountAddedSpy(AccountManager::instance(), &AccountManager::accountAdded); + AccountManager::instance()->addAccount(account); + + QCOMPARE(accountAddedSpy.count(), 1); + QCOMPARE(AccountManager::instance()->accounts().size(), 1); + } + + void testRestoreFoldersWhenE2EInitialized() + { + // Test that E2EFolderManager responds to E2E initialization signals + QTemporaryDir dir; + ConfigFile::setConfDir(dir.path()); + + auto account = Account::create(); + account->setCredentials(new FakeCredentials{new FakeQNAM({})}); + account->setUrl(QUrl("http://example.com")); + + const QVariantMap capabilities { + {QStringLiteral("end-to-end-encryption"), QVariantMap { + {QStringLiteral("enabled"), true}, + {QStringLiteral("api-version"), QString::number(2.0)}, + }}, + }; + account->setCapabilities(capabilities); + + auto accountState = new AccountState(account); + accountState->setParent(this); + AccountManager::instance()->addAccount(account); + + // E2EFolderManager is initialized via constructor when instance() is called + auto *manager = E2EFolderManager::instance(); + QVERIFY(manager); + + QVERIFY(account->e2e()); + + // Verify E2E is not yet initialized + QVERIFY(!account->e2e()->isInitialized()); + QCOMPARE(account->e2e()->initializationState(), + ClientSideEncryption::InitializationState::NotStarted); + } + + void testNoRestorationWhenE2ENotInitialized() + { + auto account = Account::create(); + account->setCredentials(new FakeCredentials{new FakeQNAM({})}); + account->setUrl(QUrl("http://example.com")); + + QVERIFY(account->e2e()); + QVERIFY(!account->e2e()->isInitialized()); + QCOMPARE(account->e2e()->initializationState(), + ClientSideEncryption::InitializationState::NotStarted); + } + + void testMultipleAccountsHandling() + { + auto account1 = Account::create(); + account1->setCredentials(new FakeCredentials{new FakeQNAM({})}); + account1->setUrl(QUrl("http://example1.com")); + + auto account2 = Account::create(); + account2->setCredentials(new FakeCredentials{new FakeQNAM({})}); + account2->setUrl(QUrl("http://example2.com")); + + auto accountState1 = new AccountState(account1); + auto accountState2 = new AccountState(account2); + accountState1->setParent(this); + accountState2->setParent(this); + + AccountManager::instance()->addAccount(account1); + AccountManager::instance()->addAccount(account2); + + auto *manager = E2EFolderManager::instance(); + + QVERIFY(manager); + QCOMPARE(AccountManager::instance()->accounts().size(), 2); + } + + void testRestorationClearsTrackingList() + { + // Test that E2EFolderManager properly initializes with accounts + // The actual restoration clearing is tested at the FolderMan level + // in testfolderman.cpp::testE2ERestorationClearsTrackingList() + QTemporaryDir dir; + ConfigFile::setConfDir(dir.path()); + + auto account = Account::create(); + account->setCredentials(new FakeCredentials{new FakeQNAM({})}); + account->setUrl(QUrl("http://example.com")); + + const QVariantMap capabilities { + {QStringLiteral("end-to-end-encryption"), QVariantMap { + {QStringLiteral("enabled"), true}, + {QStringLiteral("api-version"), QString::number(2.0)}, + }}, + }; + account->setCapabilities(capabilities); + + auto accountState = new AccountState(account); + accountState->setParent(this); + AccountManager::instance()->addAccount(account); + + // E2EFolderManager connects to account's E2E signals in its constructor + auto *manager = E2EFolderManager::instance(); + + QVERIFY(manager); + QCOMPARE(AccountManager::instance()->accounts().size(), 1); + } + + void testOnlyRestoresForCorrectAccount() + { + // Test that E2EFolderManager handles multiple accounts correctly + QTemporaryDir dir; + ConfigFile::setConfDir(dir.path()); + + auto account1 = Account::create(); + account1->setCredentials(new FakeCredentials{new FakeQNAM({})}); + account1->setUrl(QUrl("http://example1.com")); + + const QVariantMap capabilities1 { + {QStringLiteral("end-to-end-encryption"), QVariantMap { + {QStringLiteral("enabled"), true}, + {QStringLiteral("api-version"), QString::number(2.0)}, + }}, + }; + account1->setCapabilities(capabilities1); + + auto account2 = Account::create(); + account2->setCredentials(new FakeCredentials{new FakeQNAM({})}); + account2->setUrl(QUrl("http://example2.com")); + + const QVariantMap capabilities2 { + {QStringLiteral("end-to-end-encryption"), QVariantMap { + {QStringLiteral("enabled"), true}, + {QStringLiteral("api-version"), QString::number(2.0)}, + }}, + }; + account2->setCapabilities(capabilities2); + + auto accountState1 = new AccountState(account1); + auto accountState2 = new AccountState(account2); + accountState1->setParent(this); + accountState2->setParent(this); + + AccountManager::instance()->addAccount(account1); + AccountManager::instance()->addAccount(account2); + + // E2EFolderManager connects to all accounts' E2E signals in its constructor + auto *manager = E2EFolderManager::instance(); + + // Verify manager handles both accounts + QVERIFY(manager); + QCOMPARE(AccountManager::instance()->accounts().size(), 2); + + // Verify each account has its own E2E instance + QVERIFY(account1->e2e()); + QVERIFY(account2->e2e()); + QVERIFY(account1->e2e() != account2->e2e()); + } + + void testScenario1_FoldersRestoreAfterRestart() + { + // Set up FakeFolder for a realistic sync environment + FakeFolder fakeFolder{FileInfo{}}; + auto account = fakeFolder.account(); + + const QVariantMap capabilities { + {QStringLiteral("end-to-end-encryption"), QVariantMap { + {QStringLiteral("enabled"), true}, + {QStringLiteral("api-version"), QString::number(2.0)}, + }}, + }; + account->setCapabilities(capabilities); + + // Add the FakeFolder's account to the AccountManager to be tracked + auto accountState = new AccountState(account); + accountState->setParent(this); + AccountManager::instance()->addAccount(account); + + // Simulate folders blacklisted during startup (before E2E initialized) + // Access via the real Journal DB wrapper + auto &db = fakeFolder.syncJournal(); + + QStringList e2eFoldersToRestore = {"/encrypted1/", "/encrypted2/"}; + db.setSelectiveSyncList( + SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, + e2eFoldersToRestore); + + // Verify folders are marked for restoration + bool ok = false; + auto restorationList = db.getSelectiveSyncList( + SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, &ok); + QVERIFY(ok); + QCOMPARE(restorationList.size(), 2); + + // E2EFolderManager is ready to restore folders when E2E signal fires + auto *manager = E2EFolderManager::instance(); + QVERIFY(manager); + QVERIFY(account->e2e()); + } + + void testScenario5_MultipleFoldersTrackedForRestoration() + { + // Verify multiple E2E folders can be tracked and restored + FakeFolder fakeFolder{FileInfo{}}; + auto &db = fakeFolder.syncJournal(); + + // Simulate multiple E2E folders being blacklisted during startup + QStringList multipleFolders = { + "/Documents/Private/", + "/Photos/Encrypted/", + "/Work/Confidential/", + "/Personal/Secrets/" + }; + + db.setSelectiveSyncList( + SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, + multipleFolders); + + // Verify all folders are tracked + bool ok = false; + auto restorationList = db.getSelectiveSyncList( + SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, &ok); + QVERIFY(ok); + QCOMPARE(restorationList.size(), 4); + + for (const auto &folder : multipleFolders) { + QVERIFY(restorationList.contains(folder)); + } + + // After restoration, list should be clearable + db.setSelectiveSyncList( + SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, + {}); + + restorationList = db.getSelectiveSyncList( + SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, &ok); + QVERIFY(ok); + QVERIFY(restorationList.isEmpty()); + } + + void testScenario6_UserBlacklistPreserved() + { + // Verify user-blacklisted folders are NOT added to restoration list + FakeFolder fakeFolder{FileInfo{}}; + auto &db = fakeFolder.syncJournal(); + + // User manually blacklists an E2E folder via selective sync + QStringList userBlacklist = {"/User/Excluded/"}; + db.setSelectiveSyncList( + SyncJournalDb::SelectiveSyncBlackList, + userBlacklist); + + // Verify it's blacklisted + bool ok = false; + auto blacklist = db.getSelectiveSyncList( + SyncJournalDb::SelectiveSyncBlackList, &ok); + QVERIFY(ok); + QCOMPARE(blacklist.size(), 1); + QVERIFY(blacklist.contains("/User/Excluded/")); + + // Verify it's NOT in restoration list (would be added only during E2E init) + auto restorationList = db.getSelectiveSyncList( + SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, &ok); + QVERIFY(ok); + QVERIFY(restorationList.isEmpty()); + + // This ensures user preferences are preserved across restarts + } +}; + +QTEST_GUILESS_MAIN(TestE2EFolderManager) +#include "teste2eefoldermanager.moc" diff --git a/test/testfolderman.cpp b/test/testfolderman.cpp index 79eef977cd8bc..9115c1ba001a6 100644 --- a/test/testfolderman.cpp +++ b/test/testfolderman.cpp @@ -14,6 +14,7 @@ #include "QtTest/qtestcase.h" #include "common/utility.h" +#include "common/syncjournaldb.h" #include "folderman.h" #include "account.h" #include "accountstate.h" @@ -281,7 +282,9 @@ private slots: QVERIFY(dir2.mkpath("free2/sub")); { QFile f(dir.path() + "/sub/file.txt"); - f.open(QFile::WriteOnly); + if (!f.open(QFile::WriteOnly)) { + return; + } f.write("hello"); } QString dirPath = dir2.canonicalPath(); diff --git a/test/testsyncjournaldb.cpp b/test/testsyncjournaldb.cpp index 38fb455f5b4c1..a3658f2e0e514 100644 --- a/test/testsyncjournaldb.cpp +++ b/test/testsyncjournaldb.cpp @@ -577,6 +577,89 @@ private slots: } } + void testE2EFolderBlacklistRestoration() + { + _db.setSelectiveSyncList(SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, QStringList()); + _db.setSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, QStringList()); + + QStringList e2eFoldersToRestore = {"/encrypted1/", "/encrypted2/"}; + QStringList blacklist = {"/regular_blacklisted/", "/encrypted1/", "/encrypted2/"}; + + _db.setSelectiveSyncList( + SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, + e2eFoldersToRestore); + _db.setSelectiveSyncList( + SyncJournalDb::SelectiveSyncBlackList, + blacklist); + + bool ok = false; + auto restorationList = _db.getSelectiveSyncList( + SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, &ok); + QVERIFY(ok); + QCOMPARE(restorationList.size(), 2); + QVERIFY(restorationList.contains("/encrypted1/")); + QVERIFY(restorationList.contains("/encrypted2/")); + + auto currentBlacklist = _db.getSelectiveSyncList( + SyncJournalDb::SelectiveSyncBlackList, &ok); + QVERIFY(ok); + QCOMPARE(currentBlacklist.size(), 3); + QVERIFY(currentBlacklist.contains("/encrypted1/")); + QVERIFY(currentBlacklist.contains("/encrypted2/")); + QVERIFY(currentBlacklist.contains("/regular_blacklisted/")); + } + + void testE2EFolderNotTrackedIfUserBlacklisted() + { + _db.setSelectiveSyncList(SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, QStringList()); + _db.setSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, QStringList()); + + QStringList userBlacklist = {"/user_blacklisted_e2e/"}; + _db.setSelectiveSyncList( + SyncJournalDb::SelectiveSyncBlackList, + userBlacklist); + + bool ok = false; + auto blacklist = _db.getSelectiveSyncList( + SyncJournalDb::SelectiveSyncBlackList, &ok); + QVERIFY(ok); + QCOMPARE(blacklist.size(), 1); + + auto restorationList = _db.getSelectiveSyncList( + SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, &ok); + QVERIFY(ok); + QVERIFY(restorationList.isEmpty()); + } + + void testE2ERestorationClearsTrackingList() + { + _db.setSelectiveSyncList(SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, QStringList()); + _db.setSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, QStringList()); + + QStringList e2eFoldersToRestore = {"/encrypted/"}; + _db.setSelectiveSyncList( + SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, + e2eFoldersToRestore); + _db.setSelectiveSyncList( + SyncJournalDb::SelectiveSyncBlackList, + e2eFoldersToRestore); + + bool ok = false; + auto restorationList = _db.getSelectiveSyncList( + SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, &ok); + QVERIFY(ok); + QCOMPARE(restorationList.size(), 1); + + _db.setSelectiveSyncList( + SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, + {}); + + restorationList = _db.getSelectiveSyncList( + SyncJournalDb::SelectiveSyncE2eFoldersToRemoveFromBlacklist, &ok); + QVERIFY(ok); + QVERIFY(restorationList.isEmpty()); + } + private: SyncJournalDb _db; };