diff --git a/src/libsync/discovery.cpp b/src/libsync/discovery.cpp index e50817cb36..e336cf5481 100644 --- a/src/libsync/discovery.cpp +++ b/src/libsync/discovery.cpp @@ -216,7 +216,7 @@ void ProcessDirectoryJob::process() // local stat function. // Recall file shall not be ignored (#4420) bool isHidden = e.localEntry.isHidden || (!f.first.isEmpty() && f.first[0] == '.' && f.first != QLatin1String(".sys.admin#recall#")); - if (handleExcluded(path._target, e, isHidden)) + if (handleExcluded(path._target, e, entries, isHidden)) continue; const auto isEncryptedFolderButE2eIsNotSetup = e.serverEntry.isValid() && e.serverEntry.isE2eEncrypted() && @@ -243,7 +243,7 @@ void ProcessDirectoryJob::process() QTimer::singleShot(0, _discoveryData, &DiscoveryPhase::scheduleMoreJobs); } -bool ProcessDirectoryJob::handleExcluded(const QString &path, const Entries &entries, bool isHidden) +bool ProcessDirectoryJob::handleExcluded(const QString &path, const Entries &entries, const std::map &allEntries, bool isHidden) { const auto isDirectory = entries.localEntry.isDirectory || entries.serverEntry.isDirectory; @@ -316,6 +316,14 @@ bool ProcessDirectoryJob::handleExcluded(const QString &path, const Entries &ent item->_originalFile = path; item->_instruction = CSYNC_INSTRUCTION_IGNORE; + if (excluded == CSYNC_FILE_EXCLUDE_CASE_CLASH_CONFLICT && canRemoveCaseClashConflictedCopy(path, allEntries)) { + excluded = CSYNC_NOT_EXCLUDED; + item->_instruction = CSYNC_INSTRUCTION_REMOVE; + item->_direction = SyncFileItem::Down; + emit _discoveryData->itemDiscovered(item); + return true; + } + if (entries.localEntry.isSymLink) { /* Symbolic links are ignored. */ item->_errorString = tr("Symbolic links are not supported in syncing."); @@ -393,6 +401,38 @@ bool ProcessDirectoryJob::handleExcluded(const QString &path, const Entries &ent return true; } +bool ProcessDirectoryJob::canRemoveCaseClashConflictedCopy(const QString &path, const std::map &allEntries) +{ + const auto conflictRecord = _discoveryData->_statedb->caseConflictRecordByPath(path.toUtf8()); + const auto originalBaseFileName = QFileInfo(QString(_discoveryData->_localDir + "/" + conflictRecord.initialBasePath)).fileName(); + + if (allEntries.find(originalBaseFileName) == allEntries.end()) { + // original entry is no longer on the server, remove conflicted copy + qCDebug(lcDisco) << "original entry:" << originalBaseFileName << "is no longer on the server, remove conflicted copy:" << path; + return true; + } + + auto numMatchingEntries = 0; + for (auto it = allEntries.cbegin(); it != allEntries.cend(); ++it) { + if (it->first.compare(originalBaseFileName, Qt::CaseInsensitive) == 0 && it->second.serverEntry.isValid()) { + // only case-insensitive matching entries that are present on the server + ++numMatchingEntries; + } + if (numMatchingEntries >= 2) { + break; + } + } + + if (numMatchingEntries < 2) { + // original entry is present on the server but there is no case-clash conflict anymore, remove conflicted copy (only 1 matching file found during case-insensitive search) + qCDebug(lcDisco) << "original entry:" << originalBaseFileName << "is present on the server, but there is no case-clas conflict anymore, remove conflicted copy:" << path; + _discoveryData->_anotherSyncNeeded = true; + return true; + } + + return false; +} + void ProcessDirectoryJob::checkAndUpdateSelectiveSyncListsForE2eeFolders(const QString &path) { bool ok = false; diff --git a/src/libsync/discovery.h b/src/libsync/discovery.h index c77e310821..debb4572de 100644 --- a/src/libsync/discovery.h +++ b/src/libsync/discovery.h @@ -146,7 +146,9 @@ private: // return true if the file is excluded. // path is the full relative path of the file. localName is the base name of the local entry. - bool handleExcluded(const QString &path, const Entries &entries, bool isHidden); + bool handleExcluded(const QString &path, const Entries &entries, const std::map &allEntries, bool isHidden); + + 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); diff --git a/src/libsync/syncengine.cpp b/src/libsync/syncengine.cpp index 5f955ac0dd..b6555ce522 100644 --- a/src/libsync/syncengine.cpp +++ b/src/libsync/syncengine.cpp @@ -595,6 +595,8 @@ void SyncEngine::startSync() return; } + processCaseClashConflictsBeforeDiscovery(); + _stopWatch.start(); _progressInfo->_status = ProgressInfo::Starting; emit transmissionProgress(*_progressInfo); @@ -978,6 +980,23 @@ void SyncEngine::finalize(bool success) _leadingAndTrailingSpacesFilesAllowed.clear(); } +void SyncEngine::processCaseClashConflictsBeforeDiscovery() +{ + QSet pathsToAppend; + const auto caseClashConflictPaths = _journal->caseClashConflictRecordPaths(); + for (const auto &caseClashConflictPath : caseClashConflictPaths) { + auto caseClashPathSplit = caseClashConflictPath.split('/'); + if (caseClashPathSplit.size() > 1) { + caseClashPathSplit.removeLast(); + pathsToAppend.insert(caseClashPathSplit.join('/')); + } + } + + for (const auto &pathToAppend : pathsToAppend) { + _journal->schedulePathForRemoteDiscovery(pathToAppend); + } +} + void SyncEngine::slotProgress(const SyncFileItem &item, qint64 current) { _progressInfo->setProgressItem(item, current); diff --git a/src/libsync/syncengine.h b/src/libsync/syncengine.h index ba81fe2374..437d222011 100644 --- a/src/libsync/syncengine.h +++ b/src/libsync/syncengine.h @@ -291,6 +291,8 @@ private: // cleanup and emit the finished signal void finalize(bool success); + void processCaseClashConflictsBeforeDiscovery(); + // Aggregate scheduled sync runs into interval buckets. Can be used to // schedule a sync run per bucket instead of per file, reducing load. // diff --git a/test/testsyncengine.cpp b/test/testsyncengine.cpp index 16b941638b..d1b42bb93d 100644 --- a/test/testsyncengine.cpp +++ b/test/testsyncengine.cpp @@ -1597,6 +1597,67 @@ private slots: QCOMPARE(conflicts.size(), 0); } } + + void testServer_caseClash_createConflict_thenRemoveOneRemoteFile() + { + constexpr auto testLowerCaseFile = "test"; + constexpr auto testUpperCaseFile = "TEST"; + +#if defined Q_OS_LINUX + constexpr auto shouldHaveCaseClashConflict = false; +#else + constexpr auto shouldHaveCaseClashConflict = true; +#endif + + FakeFolder fakeFolder{FileInfo{}}; + + fakeFolder.remoteModifier().insert("otherFile.txt"); + fakeFolder.remoteModifier().insert(testLowerCaseFile); + fakeFolder.remoteModifier().insert(testUpperCaseFile); + + fakeFolder.syncEngine().setLocalDiscoveryOptions(OCC::LocalDiscoveryStyle::DatabaseAndFilesystem); + QVERIFY(fakeFolder.syncOnce()); + + auto conflicts = findCaseClashConflicts(fakeFolder.currentLocalState()); + QCOMPARE(conflicts.size(), shouldHaveCaseClashConflict ? 1 : 0); + const auto hasConflict = expectConflict(fakeFolder.currentLocalState(), testLowerCaseFile); + QCOMPARE(hasConflict, shouldHaveCaseClashConflict ? true : false); + + fakeFolder.syncEngine().setLocalDiscoveryOptions(OCC::LocalDiscoveryStyle::DatabaseAndFilesystem); + QVERIFY(fakeFolder.syncOnce()); + + conflicts = findCaseClashConflicts(fakeFolder.currentLocalState()); + QCOMPARE(conflicts.size(), shouldHaveCaseClashConflict ? 1 : 0); + + // remove (UPPERCASE) file + fakeFolder.remoteModifier().remove(testUpperCaseFile); + QVERIFY(fakeFolder.syncOnce()); + + // make sure we got no conflicts now (conflicted copy gets removed) + conflicts = findCaseClashConflicts(fakeFolder.currentLocalState()); + QCOMPARE(conflicts.size(), 0); + + // insert (UPPERCASE) file back + fakeFolder.remoteModifier().insert(testUpperCaseFile); + QVERIFY(fakeFolder.syncOnce()); + + // we must get conflits + conflicts = findCaseClashConflicts(fakeFolder.currentLocalState()); + QCOMPARE(conflicts.size(), shouldHaveCaseClashConflict ? 1 : 0); + + // now remove (lowercase) file + fakeFolder.remoteModifier().remove(testLowerCaseFile); + QVERIFY(fakeFolder.syncOnce()); + + // make sure we got no conflicts now (conflicted copy gets removed) + conflicts = findCaseClashConflicts(fakeFolder.currentLocalState()); + QCOMPARE(conflicts.size(), 0); + + // remove both files from the server(lower and UPPER case) + fakeFolder.remoteModifier().remove(testLowerCaseFile); + fakeFolder.remoteModifier().remove(testUpperCaseFile); + QVERIFY(fakeFolder.syncOnce()); + } }; QTEST_GUILESS_MAIN(TestSyncEngine)