Преглед изворни кода

handle case clash conflicts in a similar way to content conflicts

introduce a new type of conflict for case clash filename conflicts

add proper handling including a new utility class to solve them and a
new dialog for the user to pick a fix

Signed-off-by: Matthieu Gallien <matthieu.gallien@nextcloud.com>
Matthieu Gallien пре 3 година
родитељ
комит
602b8db5e2

+ 3 - 0
CMakeLists.txt

@@ -168,6 +168,9 @@ option(BUILD_LIBRARIES_ONLY "BUILD_LIBRARIES_ONLY" OFF)
 # build the GUI component, when disabled only nextcloudcmd is built
 option(BUILD_GUI "BUILD_GUI" ON)
 
+# build the tests
+option(BUILD_TESTING "BUILD_TESTING" ON)
+
 # When this option is enabled, 5xx errors are not added to the blacklist
 # Normally you don't want to enable this option because if a particular file
 # triggers a bug on the server, you want the file to be blacklisted.

+ 5 - 0
src/common/preparedsqlquerymanager.h

@@ -91,6 +91,11 @@ public:
         DeleteKeyValueStoreQuery,
         GetConflictRecordQuery,
         SetConflictRecordQuery,
+        GetCaseClashConflictRecordQuery,
+        GetCaseClashConflictRecordByPathQuery,
+        SetCaseClashConflictRecordQuery,
+        DeleteCaseClashConflictRecordQuery,
+        GetAllCaseClashConflictPathQuery,
         DeleteConflictRecordQuery,
         GetRawPinStateQuery,
         GetEffectivePinStateQuery,

+ 107 - 0
src/common/syncjournaldb.cpp

@@ -519,6 +519,18 @@ bool SyncJournalDb::checkConnect()
         return sqlFail(QStringLiteral("Create table conflicts"), createQuery);
     }
 
+    // create the caseconflicts table.
+    createQuery.prepare("CREATE TABLE IF NOT EXISTS caseconflicts("
+        "path TEXT PRIMARY KEY,"
+        "baseFileId TEXT,"
+        "baseEtag TEXT,"
+        "baseModtime INTEGER,"
+        "basePath TEXT UNIQUE"
+        ");");
+    if (!createQuery.exec()) {
+        return sqlFail(QStringLiteral("Create table caseconflicts"), createQuery);
+    }
+
     createQuery.prepare("CREATE TABLE IF NOT EXISTS version("
                         "major INTEGER(8),"
                         "minor INTEGER(8),"
@@ -2201,6 +2213,101 @@ ConflictRecord SyncJournalDb::conflictRecord(const QByteArray &path)
     return entry;
 }
 
+void SyncJournalDb::setCaseConflictRecord(const ConflictRecord &record)
+{
+    QMutexLocker locker(&_mutex);
+    if (!checkConnect())
+        return;
+
+    const auto query = _queryManager.get(PreparedSqlQueryManager::SetCaseClashConflictRecordQuery, QByteArrayLiteral("INSERT OR REPLACE INTO caseconflicts "
+                                                                                                            "(path, baseFileId, baseModtime, baseEtag, basePath) "
+                                                                                                            "VALUES (?1, ?2, ?3, ?4, ?5);"),
+                                         _db);
+    ASSERT(query)
+    query->bindValue(1, record.path);
+    query->bindValue(2, record.baseFileId);
+    query->bindValue(3, record.baseModtime);
+    query->bindValue(4, record.baseEtag);
+    query->bindValue(5, record.initialBasePath);
+    ASSERT(query->exec())
+}
+
+ConflictRecord SyncJournalDb::caseConflictRecordByBasePath(const QString &baseNamePath)
+{
+    ConflictRecord entry;
+
+    QMutexLocker locker(&_mutex);
+    if (!checkConnect()) {
+        return entry;
+    }
+    const auto query = _queryManager.get(PreparedSqlQueryManager::GetCaseClashConflictRecordQuery, QByteArrayLiteral("SELECT path, baseFileId, baseModtime, baseEtag, basePath FROM caseconflicts WHERE basePath=?1;"), _db);
+    ASSERT(query)
+    query->bindValue(1, baseNamePath);
+    ASSERT(query->exec())
+    if (!query->next().hasData)
+        return entry;
+
+    entry.path = query->baValue(0);
+    entry.baseFileId = query->baValue(1);
+    entry.baseModtime = query->int64Value(2);
+    entry.baseEtag = query->baValue(3);
+    entry.initialBasePath = query->baValue(4);
+    return entry;
+}
+
+ConflictRecord SyncJournalDb::caseConflictRecordByPath(const QString &path)
+{
+    ConflictRecord entry;
+
+    QMutexLocker locker(&_mutex);
+    if (!checkConnect()) {
+        return entry;
+    }
+    const auto query = _queryManager.get(PreparedSqlQueryManager::GetCaseClashConflictRecordByPathQuery, QByteArrayLiteral("SELECT path, baseFileId, baseModtime, baseEtag, basePath FROM caseconflicts WHERE path=?1;"), _db);
+    ASSERT(query)
+    query->bindValue(1, path);
+    ASSERT(query->exec())
+    if (!query->next().hasData)
+        return entry;
+
+    entry.path = query->baValue(0);
+    entry.baseFileId = query->baValue(1);
+    entry.baseModtime = query->int64Value(2);
+    entry.baseEtag = query->baValue(3);
+    entry.initialBasePath = query->baValue(4);
+    return entry;
+}
+
+void SyncJournalDb::deleteCaseClashConflictByPathRecord(const QString &path)
+{
+    QMutexLocker locker(&_mutex);
+    if (!checkConnect())
+        return;
+
+    const auto query = _queryManager.get(PreparedSqlQueryManager::DeleteCaseClashConflictRecordQuery, QByteArrayLiteral("DELETE FROM caseconflicts WHERE path=?1;"), _db);
+    ASSERT(query)
+    query->bindValue(1, path);
+    ASSERT(query->exec())
+}
+
+QByteArrayList SyncJournalDb::caseClashConflictRecordPaths()
+{
+    QMutexLocker locker(&_mutex);
+    if (!checkConnect()) {
+        return {};
+    }
+
+    const auto query = _queryManager.get(PreparedSqlQueryManager::GetAllCaseClashConflictPathQuery, QByteArrayLiteral("SELECT path FROM caseconflicts;"), _db);
+    ASSERT(query)
+    ASSERT(query->exec())
+
+    QByteArrayList paths;
+    while (query->next().hasData)
+        paths.append(query->baValue(0));
+
+    return paths;
+}
+
 void SyncJournalDb::deleteConflictRecord(const QByteArray &path)
 {
     QMutexLocker locker(&_mutex);

+ 15 - 0
src/common/syncjournaldb.h

@@ -249,6 +249,21 @@ public:
     /// Retrieve a conflict record by path of the file with the conflict tag
     ConflictRecord conflictRecord(const QByteArray &path);
 
+    /// Store a new or updated record in the database
+    void setCaseConflictRecord(const ConflictRecord &record);
+
+    /// Retrieve a conflict record by path of the file with the conflict tag
+    ConflictRecord caseConflictRecordByBasePath(const QString &baseNamePath);
+
+    /// Retrieve a conflict record by path of the file with the conflict tag
+    ConflictRecord caseConflictRecordByPath(const QString &path);
+
+    /// Delete a case clash conflict record by path of the file with the conflict tag
+    void deleteCaseClashConflictByPathRecord(const QString &path);
+
+    /// Return all paths of files with a conflict tag in the name and records in the db
+    QByteArrayList caseClashConflictRecordPaths();
+
     /// Delete a conflict record by path of the file with the conflict tag
     void deleteConflictRecord(const QByteArray &path);
 

+ 36 - 22
src/common/utility.cpp

@@ -624,35 +624,21 @@ QString Utility::makeConflictFileName(
     return conflictFileName;
 }
 
-bool Utility::isConflictFile(const char *name)
-{
-    const char *bname = std::strrchr(name, '/');
-    if (bname) {
-        bname += 1;
-    } else {
-        bname = name;
-    }
-
-    // Old pattern
-    if (std::strstr(bname, "_conflict-"))
-        return true;
-
-    // New pattern
-    if (std::strstr(bname, "(conflicted copy"))
-        return true;
-
-    return false;
-}
-
 bool Utility::isConflictFile(const QString &name)
 {
     auto bname = name.midRef(name.lastIndexOf(QLatin1Char('/')) + 1);
 
-    if (bname.contains(QStringLiteral("_conflict-")))
+    if (bname.contains(QStringLiteral("_conflict-"))) {
         return true;
+    }
 
-    if (bname.contains(QStringLiteral("(conflicted copy")))
+    if (bname.contains(QStringLiteral("(conflicted copy"))) {
         return true;
+    }
+
+    if (isCaseClashConflictFile(name)) {
+        return true;
+    }
 
     return false;
 }
@@ -722,4 +708,32 @@ QString Utility::sanitizeForFileName(const QString &name)
     return result;
 }
 
+QString Utility::makeCaseClashConflictFileName(const QString &filename, const QDateTime &datetime)
+{
+    auto conflictFileName(filename);
+    // Add conflict tag before the extension.
+    auto dotLocation = conflictFileName.lastIndexOf(QLatin1Char('.'));
+    // If no extension, add it at the end  (take care of cases like foo/.hidden or foo.bar/file)
+    if (dotLocation <= conflictFileName.lastIndexOf(QLatin1Char('/')) + 1) {
+        dotLocation = conflictFileName.size();
+    }
+
+    auto conflictMarker = QStringLiteral(" (case clash from ");
+    conflictMarker += datetime.toString(QStringLiteral("yyyy-MM-dd hhmmss")) + QLatin1Char(')');
+
+    conflictFileName.insert(dotLocation, conflictMarker);
+    return conflictFileName;
+}
+
+bool Utility::isCaseClashConflictFile(const QString &name)
+{
+    auto bname = name.midRef(name.lastIndexOf(QLatin1Char('/')) + 1);
+
+    if (bname.contains(QStringLiteral("(case clash from"))) {
+        return true;
+    }
+
+    return false;
+}
+
 } // namespace OCC

+ 4 - 1
src/common/utility.h

@@ -223,10 +223,13 @@ namespace Utility {
     OCSYNC_EXPORT QString makeConflictFileName(
         const QString &fn, const QDateTime &dt, const QString &user);
 
+    OCSYNC_EXPORT QString makeCaseClashConflictFileName(const QString &filename, const QDateTime &datetime);
+
     /** Returns whether a file name indicates a conflict file
      */
-    OCSYNC_EXPORT bool isConflictFile(const char *name);
+    bool isConflictFile(const char *name) = delete;
     OCSYNC_EXPORT bool isConflictFile(const QString &name);
+    OCSYNC_EXPORT bool isCaseClashConflictFile(const QString &name);
 
     /** Find the base name for a conflict file name, using name pattern only
      *

+ 17 - 16
src/csync/csync.h

@@ -104,22 +104,23 @@ Q_ENUM_NS(csync_status_codes_e)
   * the csync state of a file.
   */
 enum SyncInstructions {
-    CSYNC_INSTRUCTION_NONE            = 0,       /* Nothing to do (UPDATE|RECONCILE) */
-    CSYNC_INSTRUCTION_EVAL            = 1 << 0,  /* There was changed compared to the DB (UPDATE) */
-    CSYNC_INSTRUCTION_REMOVE          = 1 << 1,  /* The file need to be removed (RECONCILE) */
-    CSYNC_INSTRUCTION_RENAME          = 1 << 2,  /* The file need to be renamed (RECONCILE) */
-    CSYNC_INSTRUCTION_EVAL_RENAME     = 1 << 11, /* The file is new, it is the destination of a rename (UPDATE) */
-    CSYNC_INSTRUCTION_NEW             = 1 << 3,  /* The file is new compared to the db (UPDATE) */
-    CSYNC_INSTRUCTION_CONFLICT        = 1 << 4,  /* The file need to be downloaded because it is a conflict (RECONCILE) */
-    CSYNC_INSTRUCTION_IGNORE          = 1 << 5,  /* The file is ignored (UPDATE|RECONCILE) */
-    CSYNC_INSTRUCTION_SYNC            = 1 << 6,  /* The file need to be pushed to the other remote (RECONCILE) */
-    CSYNC_INSTRUCTION_STAT_ERROR      = 1 << 7,
-    CSYNC_INSTRUCTION_ERROR           = 1 << 8,
-    CSYNC_INSTRUCTION_TYPE_CHANGE     = 1 << 9,  /* Like NEW, but deletes the old entity first (RECONCILE)
-                                                    Used when the type of something changes from directory to file
-                                                    or back. */
-    CSYNC_INSTRUCTION_UPDATE_METADATA = 1 << 10, /* If the etag has been updated and need to be writen to the db,
-                                                    but without any propagation (UPDATE|RECONCILE) */
+    CSYNC_INSTRUCTION_NONE                = 0,       /* Nothing to do (UPDATE|RECONCILE) */
+    CSYNC_INSTRUCTION_EVAL                = 1 << 0,  /* There was changed compared to the DB (UPDATE) */
+    CSYNC_INSTRUCTION_REMOVE              = 1 << 1,  /* The file need to be removed (RECONCILE) */
+    CSYNC_INSTRUCTION_RENAME              = 1 << 2,  /* The file need to be renamed (RECONCILE) */
+    CSYNC_INSTRUCTION_EVAL_RENAME         = 1 << 11, /* The file is new, it is the destination of a rename (UPDATE) */
+    CSYNC_INSTRUCTION_NEW                 = 1 << 3,  /* The file is new compared to the db (UPDATE) */
+    CSYNC_INSTRUCTION_CONFLICT            = 1 << 4,  /* The file need to be downloaded because it is a conflict (RECONCILE) */
+    CSYNC_INSTRUCTION_IGNORE              = 1 << 5,  /* The file is ignored (UPDATE|RECONCILE) */
+    CSYNC_INSTRUCTION_SYNC                = 1 << 6,  /* The file need to be pushed to the other remote (RECONCILE) */
+    CSYNC_INSTRUCTION_STAT_ERROR          = 1 << 7,
+    CSYNC_INSTRUCTION_ERROR               = 1 << 8,
+    CSYNC_INSTRUCTION_TYPE_CHANGE         = 1 << 9,  /* Like NEW, but deletes the old entity first (RECONCILE)
+                                                        Used when the type of something changes from directory to file
+                                                        or back. */
+    CSYNC_INSTRUCTION_UPDATE_METADATA     = 1 << 10, /* If the etag has been updated and need to be writen to the db,
+                                                        but without any propagation (UPDATE|RECONCILE) */
+    CSYNC_INSTRUCTION_CASE_CLASH_CONFLICT = 1 << 12, /* The file need to be downloaded because it is a case clash conflict (RECONCILE) */
 };
 
 Q_ENUM_NS(SyncInstructions)

+ 7 - 3
src/csync/csync_exclude.cpp

@@ -205,10 +205,14 @@ static CSYNC_EXCLUDE_TYPE _csync_excluded_common(const QString &path, bool exclu
         return CSYNC_FILE_SILENTLY_EXCLUDED;
     }
 
-
-    if (excludeConflictFiles && OCC::Utility::isConflictFile(path)) {
-        return CSYNC_FILE_EXCLUDE_CONFLICT;
+    if (excludeConflictFiles) {
+        if (OCC::Utility::isCaseClashConflictFile(path)) {
+            return CSYNC_FILE_EXCLUDE_CASE_CLASH_CONFLICT;
+        } else if (OCC::Utility::isConflictFile(path)) {
+            return CSYNC_FILE_EXCLUDE_CONFLICT;
+        }
     }
+
     return CSYNC_NOT_EXCLUDED;
 }
 

+ 1 - 0
src/csync/csync_exclude.h

@@ -43,6 +43,7 @@ enum CSYNC_EXCLUDE_TYPE {
   CSYNC_FILE_EXCLUDE_HIDDEN,
   CSYNC_FILE_EXCLUDE_STAT_FAILED,
   CSYNC_FILE_EXCLUDE_CONFLICT,
+  CSYNC_FILE_EXCLUDE_CASE_CLASH_CONFLICT,
   CSYNC_FILE_EXCLUDE_CANNOT_ENCODE,
   CSYNC_FILE_EXCLUDE_SERVER_BLACKLISTED,
   CSYNC_FILE_EXCLUDE_LEADING_SPACE,

+ 3 - 0
src/gui/CMakeLists.txt

@@ -29,6 +29,7 @@ set(client_UI_SRCS
     accountsettings.ui
     conflictdialog.ui
     invalidfilenamedialog.ui
+    caseclashfilenamedialog.ui
     foldercreationdialog.ui
     folderwizardsourcepage.ui
     folderwizardtargetpage.ui
@@ -73,6 +74,8 @@ set(client_SRCS
     application.cpp
     invalidfilenamedialog.h
     invalidfilenamedialog.cpp
+    caseclashfilenamedialog.h
+    caseclashfilenamedialog.cpp
     callstatechecker.h
     callstatechecker.cpp
     conflictdialog.h

+ 253 - 0
src/gui/caseclashfilenamedialog.cpp

@@ -0,0 +1,253 @@
+/*
+ * Copyright (C) by Felix Weilbach <felix.weilbach@nextcloud.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * for more details.
+ */
+
+#include "caseclashfilenamedialog.h"
+#include "ui_caseclashfilenamedialog.h"
+
+#include "account.h"
+#include "folder.h"
+
+#include <QPushButton>
+#include <QDir>
+#include <QAbstractButton>
+#include <QDialogButtonBox>
+#include <QFileInfo>
+#include <QPushButton>
+#include <QDirIterator>
+#include <QDesktopServices>
+#include <QLoggingCategory>
+
+#include <array>
+
+namespace {
+constexpr std::array<QChar, 9> caseClashIllegalCharacters({ '\\', '/', ':', '?', '*', '\"', '<', '>', '|' });
+
+QVector<QChar> getCaseClashIllegalCharsFromString(const QString &string)
+{
+    QVector<QChar> result;
+    for (const auto &character : string) {
+        if (std::find(caseClashIllegalCharacters.begin(), caseClashIllegalCharacters.end(), character)
+            != caseClashIllegalCharacters.end()) {
+            result.push_back(character);
+        }
+    }
+    return result;
+}
+
+QString caseClashIllegalCharacterListToString(const QVector<QChar> &illegalCharacters)
+{
+    QString illegalCharactersString;
+    if (illegalCharacters.size() > 0) {
+        illegalCharactersString += illegalCharacters[0];
+    }
+
+    for (int i = 1; i < illegalCharacters.count(); ++i) {
+        if (illegalCharactersString.contains(illegalCharacters[i])) {
+            continue;
+        }
+        illegalCharactersString += " " + illegalCharacters[i];
+    }
+    return illegalCharactersString;
+}
+}
+
+namespace OCC {
+
+Q_LOGGING_CATEGORY(lcCaseClashConflictFialog, "nextcloud.sync.caseclash.dialog", QtInfoMsg)
+
+CaseClashFilenameDialog::CaseClashFilenameDialog(AccountPtr account,
+                                                 Folder *folder,
+                                                 const QString &conflictFilePath,
+                                                 const QString &conflictTaggedPath,
+                                                 QWidget *parent)
+    : QDialog(parent)
+    , _ui(std::make_unique<Ui::CaseClashFilenameDialog>())
+    , _conflictSolver(conflictFilePath, conflictTaggedPath, folder->remotePath(), folder->path(), account, folder->journalDb())
+    , _account(account)
+    , _folder(folder)
+    , _filePath(std::move(filePath))
+{
+    Q_ASSERT(_account);
+    Q_ASSERT(_folder);
+
+    const auto filePathFileInfo = QFileInfo(_filePath);
+    _relativeFilePath = filePathFileInfo.path() + QStringLiteral("/");
+    _relativeFilePath = _relativeFilePath.replace(folder->path(), QLatin1String());
+    _relativeFilePath = _relativeFilePath.isEmpty() ? QString() : _relativeFilePath + QStringLiteral("/");
+
+    _originalFileName = _relativeFilePath + filePathFileInfo.fileName();
+
+    _ui->setupUi(this);
+    _ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
+    _ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Rename file"));
+
+    _ui->descriptionLabel->setText(tr("The file \"%1\" could not be synced because of a case clash conflict with an existing file on this system.").arg(_originalFileName));
+    _ui->explanationLabel->setText(tr("The system you are using cannot have two file names with only casing differences."));
+    _ui->filenameLineEdit->setText(filePathFileInfo.fileName());
+
+    connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
+    connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
+
+    _ui->errorLabel->setText({}/*
+        tr("Checking rename permissions …")*/);
+    _ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
+    _ui->filenameLineEdit->setEnabled(false);
+
+    connect(_ui->filenameLineEdit, &QLineEdit::textChanged, this,
+        &CaseClashFilenameDialog::onFilenameLineEditTextChanged);
+
+    connect(&_conflictSolver, &CaseClashConflictSolver::errorStringChanged, this, [this] () {
+        _ui->errorLabel->setText(_conflictSolver.errorString());
+    });
+
+    connect(&_conflictSolver, &CaseClashConflictSolver::allowedToRenameChanged, this, [this] () {
+        _ui->buttonBox->setStandardButtons(_ui->buttonBox->standardButtons() &~ QDialogButtonBox::No);
+        if (_conflictSolver.allowedToRename()) {
+            _ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true);
+            _ui->filenameLineEdit->setEnabled(true);
+            _ui->filenameLineEdit->selectAll();
+        } else {
+            _ui->buttonBox->setStandardButtons(_ui->buttonBox->standardButtons() | QDialogButtonBox::No);
+        }
+    });
+
+    connect(&_conflictSolver, &CaseClashConflictSolver::failed, this, [this] () {
+        _ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
+    });
+
+    connect(&_conflictSolver, &CaseClashConflictSolver::done, this, [this] () {
+        Q_EMIT successfulRename(_folder->remotePath() + _newFilename);
+        QDialog::accept();
+    });
+
+    checkIfAllowedToRename();
+}
+
+CaseClashFilenameDialog::~CaseClashFilenameDialog() = default;
+
+QString CaseClashFilenameDialog::caseClashConflictFile(const QString &conflictFilePath)
+{
+    const auto filePathFileInfo = QFileInfo(conflictFilePath);
+    const auto conflictFileName = filePathFileInfo.fileName();
+
+    QDirIterator it(filePathFileInfo.path(), QDirIterator::Subdirectories);
+
+    while(it.hasNext()) {
+        const auto filePath = it.next();
+        qCDebug(lcCaseClashConflictFialog) << filePath;
+        QFileInfo fileInfo(filePath);
+
+        if(fileInfo.isDir()) {
+            continue;
+        }
+
+        const auto currentFileName = fileInfo.fileName();
+        if (currentFileName.compare(conflictFileName, Qt::CaseInsensitive) == 0 &&
+                currentFileName != conflictFileName) {
+
+            return filePath;
+        }
+    }
+
+    return {};
+}
+
+void CaseClashFilenameDialog::updateFileWidgetGroup(const QString &filePath,
+                                                    const QString &linkText,
+                                                    QLabel *filenameLabel,
+                                                    QLabel *linkLabel,
+                                                    QLabel *mtimeLabel,
+                                                    QLabel *sizeLabel,
+                                                    QToolButton *button) const
+{
+    const auto filePathFileInfo = QFileInfo(filePath);
+    const auto filename = filePathFileInfo.fileName();
+    const auto lastModifiedString = filePathFileInfo.lastModified().toString();
+    const auto fileSizeString = locale().formattedDataSize(filePathFileInfo.size());
+    const auto fileUrl = QUrl::fromLocalFile(filePath).toString();
+    const auto linkString = QStringLiteral("<a href='%1'>%2</a>").arg(fileUrl, linkText);
+    const auto mime = QMimeDatabase().mimeTypeForFile(_filePath);
+    QIcon fileTypeIcon;
+
+    qCDebug(lcCaseClashConflictFialog) << filePath << filePathFileInfo.exists() << filename << lastModifiedString << fileSizeString << fileUrl << linkString << mime;
+
+    if (QIcon::hasThemeIcon(mime.iconName())) {
+        fileTypeIcon = QIcon::fromTheme(mime.iconName());
+    } else {
+        fileTypeIcon = QIcon(":/qt-project.org/styles/commonstyle/images/file-128.png");
+    }
+
+    filenameLabel->setText(filename);
+    mtimeLabel->setText(lastModifiedString);
+    sizeLabel->setText(fileSizeString);
+    linkLabel->setText(linkString);
+    button->setIcon(fileTypeIcon);
+}
+
+void CaseClashFilenameDialog::checkIfAllowedToRename()
+{
+    _conflictSolver.checkIfAllowedToRename();
+}
+
+bool CaseClashFilenameDialog::processLeadingOrTrailingSpacesError(const QString &fileName)
+{
+    const auto hasLeadingSpaces = fileName.startsWith(QLatin1Char(' '));
+    const auto hasTrailingSpaces = fileName.endsWith(QLatin1Char(' '));
+
+    _ui->buttonBox->setStandardButtons(_ui->buttonBox->standardButtons() &~ QDialogButtonBox::No);
+
+    if (hasLeadingSpaces || hasTrailingSpaces) {
+        if (hasLeadingSpaces && hasTrailingSpaces) {
+            _ui->errorLabel->setText(tr("Filename contains leading and trailing spaces."));
+        }
+        else if (hasLeadingSpaces) {
+            _ui->errorLabel->setText(tr("Filename contains leading spaces."));
+        } else if (hasTrailingSpaces) {
+            _ui->errorLabel->setText(tr("Filename contains trailing spaces."));
+        }
+
+        if (!Utility::isWindows()) {
+            _ui->buttonBox->setStandardButtons(_ui->buttonBox->standardButtons() | QDialogButtonBox::No);
+            _ui->buttonBox->button(QDialogButtonBox::No)->setText(tr("Use invalid name"));
+        }
+
+        return true;
+    }
+
+    return false;
+}
+
+void CaseClashFilenameDialog::accept()
+{
+    _newFilename = _relativeFilePath + _ui->filenameLineEdit->text().trimmed();
+    _conflictSolver.solveConflict(_newFilename);
+}
+
+void CaseClashFilenameDialog::onFilenameLineEditTextChanged(const QString &text)
+{
+    const auto isNewFileNameDifferent = text != _originalFileName;
+    const auto illegalContainedCharacters = getCaseClashIllegalCharsFromString(text);
+    const auto containsIllegalChars = !illegalContainedCharacters.empty() || text.endsWith(QLatin1Char('.'));
+    const auto isTextValid = isNewFileNameDifferent && !containsIllegalChars;
+
+    _ui->errorLabel->setText("");
+
+    if (!processLeadingOrTrailingSpacesError(text) && !isTextValid){
+        _ui->errorLabel->setText(tr("Filename contains illegal characters: %1").arg(caseClashIllegalCharacterListToString(illegalContainedCharacters)));
+    }
+
+    _ui->buttonBox->button(QDialogButtonBox::Ok)
+        ->setEnabled(isTextValid);
+}
+}

+ 81 - 0
src/gui/caseclashfilenamedialog.h

@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) by Felix Weilbach <felix.weilbach@nextcloud.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * for more details.
+ */
+
+#pragma once
+
+#include "accountfwd.h"
+#include "caseclashconflictsolver.h"
+
+#include <QDialog>
+#include <QLabel>
+#include <QToolButton>
+#include <QNetworkReply>
+
+#include <memory>
+
+namespace OCC {
+
+class Folder;
+
+namespace Ui {
+    class CaseClashFilenameDialog;
+}
+
+
+class CaseClashFilenameDialog : public QDialog
+{
+    Q_OBJECT
+
+public:
+    explicit CaseClashFilenameDialog(AccountPtr account,
+                                     Folder *folder,
+                                     const QString &conflictFilePath,
+                                     const QString &conflictTaggedPath,
+                                     QWidget *parent = nullptr);
+
+    ~CaseClashFilenameDialog() override;
+
+    void accept() override;
+
+signals:
+    void successfulRename(const QString &filePath);
+
+private slots:
+    void updateFileWidgetGroup(const QString &filePath,
+                               const QString &linkText,
+                               QLabel *filenameLabel,
+                               QLabel *linkLabel,
+                               QLabel *mtimeLabel,
+                               QLabel *sizeLabel,
+                               QToolButton *button) const;
+
+private:
+    // Find the conflicting file path
+    static QString caseClashConflictFile(const QString &conflictFilePath);
+
+    void onFilenameLineEditTextChanged(const QString &text);
+    void checkIfAllowedToRename();
+    bool processLeadingOrTrailingSpacesError(const QString &fileName);
+
+    std::unique_ptr<Ui::CaseClashFilenameDialog> _ui;
+    CaseClashConflictSolver _conflictSolver;
+    AccountPtr _account;
+    Folder *_folder = nullptr;
+
+    QString _filePath;
+    QString _relativeFilePath;
+    QString _originalFileName;
+    QString _newFilename;
+};
+}

+ 121 - 0
src/gui/caseclashfilenamedialog.ui

@@ -0,0 +1,121 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>OCC::CaseClashFilenameDialog</class>
+ <widget class="QDialog" name="OCC::CaseClashFilenameDialog">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>411</width>
+    <height>192</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Case Clash Conflict</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <property name="sizeConstraint">
+    <enum>QLayout::SetDefaultConstraint</enum>
+   </property>
+   <item>
+    <widget class="QLabel" name="descriptionLabel">
+     <property name="text">
+      <string>The file could not be synced because it generates a case clash conflict with an existing file on this system.</string>
+     </property>
+     <property name="alignment">
+      <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
+     </property>
+     <property name="wordWrap">
+      <bool>false</bool>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QLabel" name="explanationLabel">
+     <property name="text">
+      <string>Error</string>
+     </property>
+     <property name="alignment">
+      <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
+     </property>
+     <property name="wordWrap">
+      <bool>false</bool>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QLabel" name="label">
+     <property name="text">
+      <string>Please enter a new name for the remote file:</string>
+     </property>
+     <property name="alignment">
+      <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
+     </property>
+     <property name="wordWrap">
+      <bool>false</bool>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QLineEdit" name="filenameLineEdit">
+     <property name="placeholderText">
+      <string>New filename</string>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QLabel" name="errorLabel">
+     <property name="palette">
+      <palette>
+       <active>
+        <colorrole role="WindowText">
+         <brush brushstyle="SolidPattern">
+          <color alpha="200">
+           <red>255</red>
+           <green>0</green>
+           <blue>0</blue>
+          </color>
+         </brush>
+        </colorrole>
+       </active>
+       <inactive>
+        <colorrole role="WindowText">
+         <brush brushstyle="SolidPattern">
+          <color alpha="200">
+           <red>255</red>
+           <green>0</green>
+           <blue>0</blue>
+          </color>
+         </brush>
+        </colorrole>
+       </inactive>
+       <disabled>
+        <colorrole role="WindowText">
+         <brush brushstyle="SolidPattern">
+          <color alpha="115">
+           <red>255</red>
+           <green>255</green>
+           <blue>255</blue>
+          </color>
+         </brush>
+        </colorrole>
+       </disabled>
+      </palette>
+     </property>
+     <property name="text">
+      <string/>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QDialogButtonBox" name="buttonBox">
+     <property name="standardButtons">
+      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+     </property>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>

+ 9 - 0
src/gui/folder.cpp

@@ -1264,6 +1264,15 @@ void Folder::acceptInvalidFileName(const QString &filePath)
     _engine->addAcceptedInvalidFileName(filePath);
 }
 
+void Folder::acceptCaseClashConflictFileName(const QString &filePath)
+{
+    qCInfo(lcFolder) << "going to delete case clash conflict record" << filePath;
+    _journal.deleteCaseClashConflictByPathRecord(filePath);
+
+    qCInfo(lcFolder) << "going to delete" << path() + filePath;
+    FileSystem::remove(path() + filePath);
+}
+
 void Folder::setSaveBackwardsCompatible(bool save)
 {
     _saveBackwardsCompatible = save;

+ 2 - 0
src/gui/folder.h

@@ -256,6 +256,8 @@ public:
 
     void acceptInvalidFileName(const QString &filePath);
 
+    void acceptCaseClashConflictFileName(const QString &filePath);
+
     /**
       * Migration: When this flag is true, this folder will save to
       * the backwards-compatible 'Folders' section in the config file.

+ 1 - 1
src/gui/sharemanager.h

@@ -431,6 +431,6 @@ private:
 };
 }
 
-Q_DECLARE_METATYPE(OCC::SharePtr);
+Q_DECLARE_METATYPE(OCC::SharePtr)
 
 #endif // SHAREMANAGER_H

+ 42 - 30
src/gui/tray/activitylistmodel.cpp

@@ -12,31 +12,27 @@
  * for more details.
  */
 
-#include <QtCore>
-#include <QAbstractListModel>
-#include <QDesktopServices>
-#include <QWidget>
-#include <QJsonObject>
-#include <QJsonDocument>
-#include <qloggingcategory.h>
+#include "activitylistmodel.h"
 
 #include "account.h"
 #include "accountstate.h"
 #include "accountmanager.h"
 #include "conflictdialog.h"
 #include "folderman.h"
-#include "iconjob.h"
-#include "accessmanager.h"
 #include "owncloudgui.h"
 #include "guiutility.h"
 #include "invalidfilenamedialog.h"
-
+#include "caseclashfilenamedialog.h"
 #include "activitydata.h"
-#include "activitylistmodel.h"
 #include "systray.h"
-#include "tray/usermodel.h"
 
-#include "theme.h"
+#include <QtCore>
+#include <QAbstractListModel>
+#include <QDesktopServices>
+#include <QWidget>
+#include <QJsonObject>
+#include <QJsonDocument>
+#include <qloggingcategory.h>
 
 namespace OCC {
 
@@ -548,7 +544,7 @@ void ActivityListModel::addEntriesToActivityList(const ActivityList &activityLis
 
 void ActivityListModel::addErrorToActivityList(const Activity &activity)
 {
-    qCInfo(lcActivity) << "Error successfully added to the notification list: " << activity._message << activity._subject;
+    qCInfo(lcActivity) << "Error successfully added to the notification list: " << activity._message << activity._subject << activity._syncResultStatus << activity._syncFileItemStatus;
     addEntriesToActivityList({activity});
     _notificationErrorsLists.prepend(activity);
 }
@@ -665,6 +661,9 @@ void ActivityListModel::slotTriggerDefaultAction(const int activityIndex)
         _currentConflictDialog->open();
         ownCloudGui::raiseDialog(_currentConflictDialog);
         return;
+    } else if (activity._syncFileItemStatus == SyncFileItem::FileNameClash) {
+        triggerCaseClashAction(activity);
+        return;
     } else if (activity._syncFileItemStatus == SyncFileItem::FileNameInvalid) {
         if (!_currentInvalidFilenameDialog.isNull()) {
             _currentInvalidFilenameDialog->close();
@@ -684,22 +683,6 @@ void ActivityListModel::slotTriggerDefaultAction(const int activityIndex)
         _currentInvalidFilenameDialog->open();
         ownCloudGui::raiseDialog(_currentInvalidFilenameDialog);
         return;
-    } else if (activity._syncFileItemStatus == SyncFileItem::FileNameClash) {
-        const auto folder = FolderMan::instance()->folder(activity._folder);
-        const auto relPath = activity._fileAction == QStringLiteral("file_renamed") ? activity._renamedFile : activity._file;
-        SyncJournalFileRecord record;
-
-        if (!folder || !folder->journalDb()->getFileRecord(relPath, &record)) {
-            return;
-        }
-
-        fetchPrivateLinkUrl(folder->accountState()->account(),
-                            relPath,
-                            record.numericFileId(),
-                            this,
-                            [](const QString &link) { Utility::openBrowser(link); }
-        );
-        return;
     }
 
     if (!path.isEmpty()) {
@@ -710,6 +693,35 @@ void ActivityListModel::slotTriggerDefaultAction(const int activityIndex)
     }
 }
 
+void ActivityListModel::triggerCaseClashAction(Activity activity)
+{
+    qCInfo(lcActivity) << "case clash conflict" << activity._file << activity._syncFileItemStatus;
+
+    if (!_currentCaseClashFilenameDialog.isNull()) {
+        _currentCaseClashFilenameDialog->close();
+    }
+
+    auto folder = FolderMan::instance()->folder(activity._folder);
+    const auto conflictedRelativePath = activity._file;
+    const auto conflictRecord = folder->journalDb()->caseConflictRecordByBasePath(conflictedRelativePath);
+
+    const auto dir = QDir(folder->path());
+    const auto conflictedPath = dir.filePath(conflictedRelativePath);
+    const auto conflictTaggedPath = dir.filePath(conflictRecord.path);
+
+    _currentCaseClashFilenameDialog = new CaseClashFilenameDialog(_accountState->account(),
+                                                                  folder,
+                                                                  conflictedPath,
+                                                                  conflictTaggedPath);
+    connect(_currentCaseClashFilenameDialog, &CaseClashFilenameDialog::successfulRename, folder, [folder, activity](const QString& filePath) {
+        qCInfo(lcActivity) << "successfulRename" << filePath << activity._message;
+        folder->acceptCaseClashConflictFileName(activity._message);
+        folder->scheduleThisFolderSoon();
+    });
+    _currentCaseClashFilenameDialog->open();
+    ownCloudGui::raiseDialog(_currentCaseClashFilenameDialog);
+}
+
 void ActivityListModel::slotTriggerAction(const int activityIndex, const int actionIndex)
 {
     if (activityIndex < 0 || activityIndex >= _finalList.size()) {

+ 3 - 0
src/gui/tray/activitylistmodel.h

@@ -28,6 +28,7 @@ Q_DECLARE_LOGGING_CATEGORY(lcActivity)
 class AccountState;
 class ConflictDialog;
 class InvalidFilenameDialog;
+class CaseClashFilenameDialog;
 
 /**
  * @brief The ActivityListModel
@@ -157,6 +158,7 @@ private:
     void ingestActivities(const QJsonArray &activities);
     void appendMoreActivitiesAvailableEntry();
     void insertOrRemoveDummyFetchingActivity();
+    void triggerCaseClashAction(Activity activity);
 
     Activity _notificationIgnoredFiles;
     Activity _dummyFetchingActivities;
@@ -179,6 +181,7 @@ private:
 
     QPointer<ConflictDialog> _currentConflictDialog;
     QPointer<InvalidFilenameDialog> _currentInvalidFilenameDialog;
+    QPointer<CaseClashFilenameDialog> _currentCaseClashFilenameDialog;
 
     AccountState *_accountState = nullptr;
     bool _currentlyFetching = false;

+ 2 - 0
src/libsync/CMakeLists.txt

@@ -121,6 +121,8 @@ set(libsync_SRCS
     creds/credentialscommon.cpp
     creds/keychainchunk.h
     creds/keychainchunk.cpp
+    caseclashconflictsolver.h
+    caseclashconflictsolver.cpp
 )
 
 if (WIN32)

+ 217 - 0
src/libsync/caseclashconflictsolver.cpp

@@ -0,0 +1,217 @@
+#include "caseclashconflictsolver.h"
+
+#include "networkjobs.h"
+#include "propagateremotemove.h"
+#include "account.h"
+#include "common/syncjournaldb.h"
+#include "common/filesystembase.h"
+
+#include <QDir>
+#include <QFileInfo>
+#include <QLoggingCategory>
+
+using namespace OCC;
+
+Q_LOGGING_CATEGORY(lcCaseClashConflictSolver, "nextcloud.sync.caseclash.solver", QtInfoMsg)
+
+CaseClashConflictSolver::CaseClashConflictSolver(const QString &targetFilePath,
+                                                 const QString &conflictFilePath,
+                                                 const QString &remotePath,
+                                                 const QString &localPath,
+                                                 AccountPtr account,
+                                                 SyncJournalDb *journal,
+                                                 QObject *parent)
+    : QObject{parent}
+    , _account(account)
+    , _targetFilePath(targetFilePath)
+    , _conflictFilePath(conflictFilePath)
+    , _remotePath(remotePath)
+    , _localPath(localPath)
+    , _journal(journal)
+{
+#if !defined(QT_NO_DEBUG)
+    QFileInfo targetFileInfo(_targetFilePath);
+    Q_ASSERT(targetFileInfo.isAbsolute());
+    Q_ASSERT(QFileInfo::exists(_conflictFilePath));
+#endif
+}
+
+bool CaseClashConflictSolver::allowedToRename() const
+{
+    return _allowedToRename;
+}
+
+QString CaseClashConflictSolver::errorString() const
+{
+    return _errorString;
+}
+
+void CaseClashConflictSolver::solveConflict(const QString &newFilename)
+{
+    _newFilename = newFilename;
+
+    const auto propfindJob = new PropfindJob(_account, QDir::cleanPath(remoteNewFilename()));
+    connect(propfindJob, &PropfindJob::result, this, &CaseClashConflictSolver::onRemoteDestinationFileAlreadyExists);
+    connect(propfindJob, &PropfindJob::finishedWithError, this, &CaseClashConflictSolver::onRemoteDestinationFileDoesNotExist);
+    propfindJob->start();
+}
+
+void CaseClashConflictSolver::onRemoteDestinationFileAlreadyExists()
+{
+    _allowedToRename = false;
+    emit allowedToRenameChanged();
+    _errorString = tr("Cannot rename file because a file with the same name does already exist on the server. Please pick another name.");
+    emit errorStringChanged();
+}
+
+void CaseClashConflictSolver::onRemoteDestinationFileDoesNotExist()
+{
+    const auto propfindJob = new PropfindJob(_account, QDir::cleanPath(remoteTargetFilePath()));
+    connect(propfindJob, &PropfindJob::result, this, &CaseClashConflictSolver::onRemoteSourceFileAlreadyExists);
+    connect(propfindJob, &PropfindJob::finishedWithError, this, &CaseClashConflictSolver::onRemoteSourceFileDoesNotExist);
+    propfindJob->start();
+}
+
+void CaseClashConflictSolver::onPropfindPermissionSuccess(const QVariantMap &values)
+{
+    onCheckIfAllowedToRenameComplete(values);
+}
+
+void CaseClashConflictSolver::onPropfindPermissionError(QNetworkReply *reply)
+{
+    onCheckIfAllowedToRenameComplete({}, reply);
+}
+
+void CaseClashConflictSolver::onRemoteSourceFileAlreadyExists()
+{
+    const auto remoteSource = QDir::cleanPath(remoteTargetFilePath());
+    const auto remoteDestionation = QDir::cleanPath(_account->davUrl().path() + remoteNewFilename());
+    qCInfo(lcCaseClashConflictSolver) << "rename case clashing file from" << remoteSource << "to" << remoteDestionation;
+    const auto moveJob = new MoveJob(_account, remoteSource, remoteDestionation, this);
+    connect(moveJob, &MoveJob::finishedSignal, this, &CaseClashConflictSolver::onMoveJobFinished);
+    moveJob->start();
+}
+
+void CaseClashConflictSolver::onRemoteSourceFileDoesNotExist()
+{
+    Q_EMIT failed();
+}
+
+void CaseClashConflictSolver::onMoveJobFinished()
+{
+    const auto job = qobject_cast<MoveJob *>(sender());
+    const auto error = job->reply()->error();
+
+    if (error != QNetworkReply::NoError) {
+        _errorString = tr("Could not rename file. Please make sure you are connected to the server.");
+        emit errorStringChanged();
+
+        emit failed();
+        return;
+    }
+
+    qCInfo(lcCaseClashConflictSolver) << "going to delete case clash conflict record" << _targetFilePath;
+    _journal->deleteCaseClashConflictByPathRecord(_targetFilePath);
+
+    qCInfo(lcCaseClashConflictSolver) << "going to delete" << _conflictFilePath;
+    FileSystem::remove(_conflictFilePath);
+
+    Q_EMIT done();
+}
+
+QString CaseClashConflictSolver::remoteNewFilename() const
+{
+    if (_remotePath == QStringLiteral("/")) {
+        qCDebug(lcCaseClashConflictSolver) << _newFilename << _remotePath << _newFilename;
+        return _newFilename;
+    } else {
+        const auto result = QString{_remotePath + _newFilename};
+        qCDebug(lcCaseClashConflictSolver) << result << _remotePath << _newFilename;
+        return result;
+    }
+}
+
+QString CaseClashConflictSolver::remoteTargetFilePath() const
+{
+    if (_remotePath == QStringLiteral("/")) {
+        const auto result = QString{_targetFilePath.mid(_localPath.length())};
+        qCDebug(lcCaseClashConflictSolver) << result << _remotePath << _targetFilePath << _localPath;
+        return result;
+    } else {
+        const auto result = QString{_remotePath + _targetFilePath.mid(_localPath.length())};
+        qCDebug(lcCaseClashConflictSolver) << result << _remotePath << _targetFilePath << _localPath;
+        return result;
+    }
+}
+
+void CaseClashConflictSolver::onCheckIfAllowedToRenameComplete(const QVariantMap &values, QNetworkReply *reply)
+{
+    constexpr auto CONTENT_NOT_FOUND_ERROR = 404;
+
+    const auto isAllowedToRename = [](const RemotePermissions remotePermissions) {
+        return remotePermissions.hasPermission(remotePermissions.CanRename)
+            && remotePermissions.hasPermission(remotePermissions.CanMove);
+    };
+
+    if (values.contains("permissions") && !isAllowedToRename(RemotePermissions::fromServerString(values["permissions"].toString()))) {
+        _allowedToRename = false;
+        emit allowedToRenameChanged();
+        _errorString = tr("You don't have the permission to rename this file. Please ask the author of the file to rename it.");
+        emit errorStringChanged();
+
+        return;
+    } else if (reply) {
+        if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != CONTENT_NOT_FOUND_ERROR) {
+            _allowedToRename = false;
+            emit allowedToRenameChanged();
+            _errorString = tr("Failed to fetch permissions with error %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt());
+            emit errorStringChanged();
+
+            return;
+        }
+    }
+
+    _allowedToRename = true;
+    emit allowedToRenameChanged();
+
+    const auto filePathFileInfo = QFileInfo(_newFilename);
+    const auto fileName = filePathFileInfo.fileName();
+    processLeadingOrTrailingSpacesError(fileName);
+}
+
+void CaseClashConflictSolver::processLeadingOrTrailingSpacesError(const QString &fileName)
+{
+    const auto hasLeadingSpaces = fileName.startsWith(QLatin1Char(' '));
+    const auto hasTrailingSpaces = fileName.endsWith(QLatin1Char(' '));
+
+    if (hasLeadingSpaces || hasTrailingSpaces) {
+        if (hasLeadingSpaces && hasTrailingSpaces) {
+            _errorString = tr("Filename contains leading and trailing spaces.");
+            emit errorStringChanged();
+        }
+        else if (hasLeadingSpaces) {
+            _errorString = tr("Filename contains leading spaces.");
+            emit errorStringChanged();
+        } else if (hasTrailingSpaces) {
+            _errorString = tr("Filename contains trailing spaces.");
+            emit errorStringChanged();
+        }
+
+        _allowedToRename = false;
+        emit allowedToRenameChanged();
+
+        return;
+    }
+
+    _allowedToRename = true;
+    emit allowedToRenameChanged();
+}
+
+void CaseClashConflictSolver::checkIfAllowedToRename()
+{
+    const auto propfindJob = new PropfindJob(_account, QDir::cleanPath(remoteTargetFilePath()));
+    propfindJob->setProperties({ "http://owncloud.org/ns:permissions" });
+    connect(propfindJob, &PropfindJob::result, this, &CaseClashConflictSolver::onPropfindPermissionSuccess);
+    connect(propfindJob, &PropfindJob::finishedWithError, this, &CaseClashConflictSolver::onPropfindPermissionError);
+    propfindJob->start();
+}

+ 95 - 0
src/libsync/caseclashconflictsolver.h

@@ -0,0 +1,95 @@
+#ifndef CASECLASHCONFLICTSOLVER_H
+#define CASECLASHCONFLICTSOLVER_H
+
+#include <QObject>
+
+#include "accountfwd.h"
+#include "owncloudlib.h"
+
+class QNetworkReply;
+
+namespace OCC {
+
+class SyncJournalDb;
+
+class OWNCLOUDSYNC_EXPORT CaseClashConflictSolver : public QObject
+{
+    Q_OBJECT
+
+    Q_PROPERTY(bool allowedToRename READ allowedToRename NOTIFY allowedToRenameChanged)
+
+    Q_PROPERTY(QString errorString READ errorString NOTIFY errorStringChanged)
+
+public:
+    explicit CaseClashConflictSolver(const QString &targetFilePath,
+                                     const QString &conflictFilePath,
+                                     const QString &remotePath,
+                                     const QString &localPath,
+                                     AccountPtr account,
+                                     SyncJournalDb *journal,
+                                     QObject *parent = nullptr);
+
+    [[nodiscard]] bool allowedToRename() const;
+
+    [[nodiscard]] QString errorString() const;
+
+signals:
+    void allowedToRenameChanged();
+
+    void errorStringChanged();
+
+    void done();
+
+    void failed();
+
+public slots:
+    void solveConflict(const QString &newFilename);
+
+    void checkIfAllowedToRename();
+
+private slots:
+    void onRemoteDestinationFileAlreadyExists();
+
+    void onRemoteDestinationFileDoesNotExist();
+
+    void onPropfindPermissionSuccess(const QVariantMap &values);
+
+    void onPropfindPermissionError(QNetworkReply *reply);
+
+    void onRemoteSourceFileAlreadyExists();
+
+    void onRemoteSourceFileDoesNotExist();
+
+    void onMoveJobFinished();
+
+private:
+    [[nodiscard]] QString remoteNewFilename() const;
+
+    [[nodiscard]] QString remoteTargetFilePath() const;
+
+    void onCheckIfAllowedToRenameComplete(const QVariantMap &values, QNetworkReply *reply = nullptr);
+
+    void processLeadingOrTrailingSpacesError(const QString &fileName);
+
+    AccountPtr _account;
+
+    QString _targetFilePath;
+
+    QString _conflictFilePath;
+
+    QString _newFilename;
+
+    QString _remotePath;
+
+    QString _localPath;
+
+    QString _errorString;
+
+    SyncJournalDb *_journal = nullptr;
+
+    bool _allowedToRename = false;
+};
+
+}
+
+#endif // CASECLASHCONFLICTSOLVER_H

+ 28 - 1
src/libsync/discovery.cpp

@@ -372,7 +372,11 @@ bool ProcessDirectoryJob::handleExcluded(const QString &path, const Entries &ent
         case CSYNC_FILE_EXCLUDE_CONFLICT:
             item->_errorString = tr("Conflict: Server version downloaded, local copy renamed and not uploaded.");
             item->_status = SyncFileItem::Conflict;
-        break;
+            break;
+        case CSYNC_FILE_EXCLUDE_CASE_CLASH_CONFLICT:
+            item->_errorString = tr("Case Clash Conflict: Server file downloaded and renamed to avoid clash.");
+            item->_status = SyncFileItem::FileNameClash;
+            break;
         case CSYNC_FILE_EXCLUDE_CANNOT_ENCODE:
             item->_errorString = tr("The filename cannot be encoded on your file system.");
             break;
@@ -689,6 +693,15 @@ void ProcessDirectoryJob::processFileAnalyzeRemoteInfo(
     item->_modtime = serverEntry.modtime;
     item->_size = serverEntry.size;
 
+    auto conflictRecord = _discoveryData->_statedb->caseConflictRecordByBasePath(item->_file);
+    if (conflictRecord.isValid() && QString::fromUtf8(conflictRecord.path).contains(QStringLiteral("(case clash from"))) {
+        qCInfo(lcDisco) << "should ignore" << item->_file << "has already a case clash conflict record" << conflictRecord.path;
+
+        item->_instruction = CSYNC_INSTRUCTION_IGNORE;
+
+        return;
+    }
+
     auto postProcessServerNew = [=]() mutable {
         if (item->isDirectory()) {
             _pendingAsyncJobs++;
@@ -1120,6 +1133,20 @@ void ProcessDirectoryJob::processFileAnalyzeLocalInfo(
     item->_type = localEntry.isDirectory ? ItemTypeDirectory : localEntry.isVirtualFile ? ItemTypeVirtualFile : ItemTypeFile;
     _childModified = true;
 
+    if (!localEntry.caseClashConflictingName.isEmpty()) {
+        qCInfo(lcDisco) << item->_file << "case clash conflict" << localEntry.caseClashConflictingName;
+        item->_instruction = CSYNC_INSTRUCTION_CONFLICT;
+    }
+
+    auto conflictRecord = _discoveryData->_statedb->caseConflictRecordByBasePath(item->_file);
+    if (conflictRecord.isValid() && QString::fromUtf8(conflictRecord.path).contains(QStringLiteral("(case clash from"))) {
+        qCInfo(lcDisco) << "should ignore" << item->_file << "has already a case clash conflict record" << conflictRecord.path;
+
+        item->_instruction = CSYNC_INSTRUCTION_IGNORE;
+
+        return;
+    }
+
     auto postProcessLocalNew = [item, localEntry, path, this]() {
         // TODO: We may want to execute the same logic for non-VFS mode, as, moving/renaming the same folder by 2 or more clients at the same time is not possible in Web UI.
         // Keeping it like this (for VFS files and folders only) just to fix a user issue.

+ 1 - 0
src/libsync/discoveryphase.h

@@ -88,6 +88,7 @@ struct LocalInfo
 {
     /** FileName of the entry (this does not contains any directory or path, just the plain name */
     QString name;
+    QString caseClashConflictingName;
     time_t modtime = 0;
     int64_t size = 0;
     uint64_t inode = 0;

+ 6 - 7
src/libsync/filesystem.cpp

@@ -58,9 +58,8 @@ bool FileSystem::fileEquals(const QString &fn1, const QString &fn2)
 time_t FileSystem::getModTime(const QString &filename)
 {
     csync_file_stat_t stat;
-    qint64 result = -1;
-    if (csync_vio_local_stat(filename, &stat) != -1
-        && (stat.modtime != 0)) {
+    time_t result = -1;
+    if (csync_vio_local_stat(filename, &stat) != -1 && (stat.modtime != 0)) {
         result = stat.modtime;
     } else {
         result = Utility::qDateTimeToTime_t(QFileInfo(filename).lastModified());
@@ -93,11 +92,11 @@ bool FileSystem::fileChanged(const QString &fileName,
 }
 
 bool FileSystem::verifyFileUnchanged(const QString &fileName,
-    qint64 previousSize,
-    time_t previousMtime)
+                                     qint64 previousSize,
+                                     time_t previousMtime)
 {
-    const qint64 actualSize = getSize(fileName);
-    const time_t actualMtime = getModTime(fileName);
+    const auto actualSize = getSize(fileName);
+    const auto actualMtime = getModTime(fileName);
     if ((actualSize != previousSize && actualMtime > 0) || (actualMtime != previousMtime && previousMtime > 0 && actualMtime > 0)) {
         qCInfo(lcFileSystem) << "File" << fileName << "has changed:"
                              << "size: " << previousSize << "<->" << actualSize

+ 76 - 0
src/libsync/owncloudpropagator.cpp

@@ -913,6 +913,63 @@ bool OwncloudPropagator::createConflict(const SyncFileItemPtr &item,
     return true;
 }
 
+OCC::Optional<QString> OwncloudPropagator::createCaseClashConflict(const SyncFileItemPtr &item, const QString &temporaryDownloadedFile)
+{
+    auto filename = QString{};
+
+    if (item->_type == ItemType::ItemTypeFile) {
+        filename = fullLocalPath(item->_file);
+    } else if (item->_type == ItemType::ItemTypeVirtualFileDownload) {
+        filename = fullLocalPath(item->_file + syncOptions()._vfs->fileSuffix());
+    }
+
+    const auto conflictModTime = FileSystem::getModTime(filename);
+    if (conflictModTime <= 0) {
+        return tr("Impossible to get modification time for file in conflict %1").arg(filename);
+    }
+
+    const auto conflictFileName = Utility::makeCaseClashConflictFileName(item->_file, Utility::qDateTimeFromTime_t(conflictModTime));
+    const auto conflictFilePath = fullLocalPath(conflictFileName);
+
+    emit touchedFile(filename);
+    emit touchedFile(conflictFilePath);
+
+    qCInfo(lcPropagator) << "rename from" << temporaryDownloadedFile << "to" << conflictFilePath;
+    if (QString renameError; !FileSystem::rename(temporaryDownloadedFile, conflictFilePath, &renameError)) {
+        // If the rename fails, don't replace it.
+
+        // If the file is locked, we want to retry this sync when it
+        // becomes available again.
+        if (FileSystem::isFileLocked(filename)) {
+            emit seenLockedFile(filename);
+        }
+
+        return renameError;
+    }
+    FileSystem::setFileHidden(conflictFilePath, false);
+    qCInfo(lcPropagator) << "Created case clash conflict file" << filename << "->" << conflictFilePath;
+
+    // Create a new conflict record. To get the base etag, we need to read it from the db.
+    auto conflictBasePath = item->_file.toUtf8();
+    if (!item->_renameTarget.isEmpty()) {
+        conflictBasePath = item->_renameTarget.toUtf8();
+    }
+    auto conflictRecord = ConflictRecord{conflictFileName.toUtf8(), {}, item->_previousModtime, {}, conflictBasePath};
+
+    SyncJournalFileRecord baseRecord;
+    if (_journal->getFileRecord(item->_originalFile, &baseRecord) && baseRecord.isValid()) {
+        conflictRecord.baseEtag = baseRecord._etag;
+        conflictRecord.baseFileId = baseRecord._fileId;
+    }
+
+    _journal->setCaseConflictRecord(conflictRecord);
+
+    // Need a new sync to detect the created copy of the conflicting file
+    _anotherSyncNeeded = true;
+
+    return {};
+}
+
 QString OwncloudPropagator::adjustRenamedPath(const QString &original) const
 {
     return OCC::adjustRenamedPath(_renamedDirectories, original);
@@ -1473,4 +1530,23 @@ QString OwncloudPropagator::remotePath() const
     return _remoteFolder;
 }
 
+void PropagateIgnoreJob::start()
+{
+    SyncFileItem::Status status = _item->_status;
+    if (status == SyncFileItem::NoStatus) {
+        if (_item->_instruction == CSYNC_INSTRUCTION_ERROR) {
+            status = SyncFileItem::NormalError;
+        } else {
+            status = SyncFileItem::FileIgnored;
+            ASSERT(_item->_instruction == CSYNC_INSTRUCTION_IGNORE);
+        }
+    } else if (status == SyncFileItem::FileNameClash) {
+        const auto conflictRecord = propagator()->_journal->caseConflictRecordByPath(_item->_file);
+        if (conflictRecord.isValid()) {
+            _item->_file = conflictRecord.initialBasePath;
+        }
+    }
+    done(status, _item->_errorString);
+}
+
 }

+ 9 - 13
src/libsync/owncloudpropagator.h

@@ -401,19 +401,7 @@ public:
         : PropagateItemJob(propagator, item)
     {
     }
-    void start() override
-    {
-        SyncFileItem::Status status = _item->_status;
-        if (status == SyncFileItem::NoStatus) {
-            if (_item->_instruction == CSYNC_INSTRUCTION_ERROR) {
-                status = SyncFileItem::NormalError;
-            } else {
-                status = SyncFileItem::FileIgnored;
-                ASSERT(_item->_instruction == CSYNC_INSTRUCTION_IGNORE);
-            }
-        }
-        done(status, _item->_errorString);
-    }
+    void start() override;
 };
 
 class PropagateUploadFileCommon;
@@ -586,6 +574,14 @@ public:
     bool createConflict(const SyncFileItemPtr &item,
         PropagatorCompositeJob *composite, QString *error);
 
+    /** Handles a case clash conflict by renaming the file 'item'.
+     *
+     * Sets up conflict records.
+     *
+     * Returns true on success, false and error on error.
+     */
+    OCC::Optional<QString> createCaseClashConflict(const SyncFileItemPtr &item, const QString &temporaryDownloadedFile);
+
     // Map original path (as in the DB) to target final path
     QMap<QString, QString> _renamedDirectories;
     [[nodiscard]] QString adjustRenamedPath(const QString &original) const;

+ 3 - 0
src/libsync/progressdispatcher.cpp

@@ -41,6 +41,8 @@ QString Progress::asResultString(const SyncFileItem &item)
         }
     case CSYNC_INSTRUCTION_CONFLICT:
         return QCoreApplication::translate("progress", "Server version downloaded, copied changed local file into conflict file");
+    case CSYNC_INSTRUCTION_CASE_CLASH_CONFLICT:
+        return QCoreApplication::translate("progress", "Server version downloaded, copied changed local file into case conflict conflict file");
     case CSYNC_INSTRUCTION_REMOVE:
         return QCoreApplication::translate("progress", "Deleted");
     case CSYNC_INSTRUCTION_EVAL_RENAME:
@@ -65,6 +67,7 @@ QString Progress::asActionString(const SyncFileItem &item)
 {
     switch (item._instruction) {
     case CSYNC_INSTRUCTION_CONFLICT:
+    case CSYNC_INSTRUCTION_CASE_CLASH_CONFLICT:
     case CSYNC_INSTRUCTION_SYNC:
     case CSYNC_INSTRUCTION_NEW:
     case CSYNC_INSTRUCTION_TYPE_CHANGE:

+ 50 - 35
src/libsync/propagatedownload.cpp

@@ -526,12 +526,7 @@ void PropagateDownloadFile::startAfterIsEncryptedIsChecked()
         qCWarning(lcPropagateDownload) << "ignored virtual file type of" << _item->_file;
         _item->_type = ItemTypeFile;
     }
-    if (_item->_type == ItemTypeVirtualFile) {
-        if (propagator()->localFileNameClash(_item->_file)) {
-            done(SyncFileItem::FileNameClash, tr("File %1 cannot be downloaded because of a local file name clash!").arg(QDir::toNativeSeparators(_item->_file)));
-            return;
-        }
-
+    if (_item->_type == ItemTypeVirtualFile && !propagator()->localFileNameClash(_item->_file)) {
         qCDebug(lcPropagateDownload) << "creating virtual file" << _item->_file;
         // do a klaas' case clash check.
         if (propagator()->localFileNameClash(_item->_file)) {
@@ -632,9 +627,18 @@ void PropagateDownloadFile::startDownload()
         return;
 
     // do a klaas' case clash check.
-    if (propagator()->localFileNameClash(_item->_file)) {
-        done(SyncFileItem::FileNameClash, tr("File %1 cannot be downloaded because of a local file name clash!").arg(QDir::toNativeSeparators(_item->_file)));
-        return;
+    if (propagator()->localFileNameClash(_item->_file) && _item->_type != ItemTypeVirtualFile) {
+        _item->_instruction = CSYNC_INSTRUCTION_CASE_CLASH_CONFLICT;
+        qCInfo(lcPropagateDownload) << "setting instruction to" << _item->_instruction << _item->_file;
+    } else if (propagator()->localFileNameClash(_item->_file)) {
+        _item->_instruction = CSYNC_INSTRUCTION_CASE_CLASH_CONFLICT;
+        _item->_type = CSyncEnums::ItemTypeVirtualFileDownload;
+        qCInfo(lcPropagateDownload) << "setting instruction to" << _item->_instruction << _item->_file << "setting type to" << _item->_type;
+        auto fileName = _item->_file;
+        if (propagator()->syncOptions()._vfs->mode() == Vfs::WithSuffix) {
+            fileName.chop(propagator()->syncOptions()._vfs->fileSuffix().size());
+            _item->_file = fileName;
+        }
     }
 
     propagator()->reportProgress(*_item, 0);
@@ -1147,14 +1151,7 @@ void PropagateDownloadFile::finalizeDownload()
 void PropagateDownloadFile::downloadFinished()
 {
     ASSERT(!_tmpFile.isOpen());
-    QString fn = propagator()->fullLocalPath(_item->_file);
-
-    // In case of file name clash, report an error
-    // This can happen if another parallel download saved a clashing file.
-    if (propagator()->localFileNameClash(_item->_file)) {
-        done(SyncFileItem::FileNameClash, tr("File %1 cannot be saved because of a local file name clash!").arg(QDir::toNativeSeparators(_item->_file)));
-        return;
-    }
+    const auto filename = propagator()->fullLocalPath(_item->_file);
 
     if (_item->_modtime <= 0) {
         FileSystem::remove(_tmpFile.fileName());
@@ -1179,17 +1176,22 @@ void PropagateDownloadFile::downloadFinished()
         qCWarning(lcPropagateDownload()) << "invalid modified time" << _item->_file << _item->_modtime;
     }
 
-    bool previousFileExists = FileSystem::fileExists(fn);
+    if (propagator()->localFileNameClash(_item->_file)) {
+        _item->_instruction = CSYNC_INSTRUCTION_CASE_CLASH_CONFLICT;
+        qCInfo(lcPropagateDownload) << "setting instruction to" << _item->_instruction << _item->_file;
+    }
+
+    bool previousFileExists = FileSystem::fileExists(filename) && _item->_instruction != CSYNC_INSTRUCTION_CASE_CLASH_CONFLICT;
     if (previousFileExists) {
         // Preserve the existing file permissions.
-        QFileInfo existingFile(fn);
+        const auto existingFile = QFileInfo{filename};
         if (existingFile.permissions() != _tmpFile.permissions()) {
             _tmpFile.setPermissions(existingFile.permissions());
         }
         preserveGroupOwnership(_tmpFile.fileName(), existingFile);
 
         // Make the file a hydrated placeholder if possible
-        const auto result = propagator()->syncOptions()._vfs->convertToPlaceholder(_tmpFile.fileName(), *_item, fn);
+        const auto result = propagator()->syncOptions()._vfs->convertToPlaceholder(_tmpFile.fileName(), *_item, filename);
         if (!result) {
             done(SyncFileItem::NormalError, result.error());
             return;
@@ -1199,15 +1201,28 @@ void PropagateDownloadFile::downloadFinished()
     // Apply the remote permissions
     FileSystem::setFileReadOnlyWeak(_tmpFile.fileName(), !_item->_remotePerm.isNull() && !_item->_remotePerm.hasPermission(RemotePermissions::CanWrite));
 
-    bool isConflict = _item->_instruction == CSYNC_INSTRUCTION_CONFLICT
-        && (QFileInfo(fn).isDir() || !FileSystem::fileEquals(fn, _tmpFile.fileName()));
+    const auto isConflict = (_item->_instruction == CSYNC_INSTRUCTION_CONFLICT
+                             && (QFileInfo(filename).isDir() || !FileSystem::fileEquals(filename, _tmpFile.fileName()))) ||
+        _item->_instruction == CSYNC_INSTRUCTION_CASE_CLASH_CONFLICT;
+
     if (isConflict) {
-        QString error;
-        if (!propagator()->createConflict(_item, _associatedComposite, &error)) {
-            done(SyncFileItem::SoftError, error);
+        if (_item->_instruction == CSYNC_INSTRUCTION_CASE_CLASH_CONFLICT) {
+            qCInfo(lcPropagateDownload) << "downloading case clashed file" << _item->_file;
+            const auto caseClashConflictResult = propagator()->createCaseClashConflict(_item, _tmpFile.fileName());
+            if (caseClashConflictResult) {
+                done(SyncFileItem::SoftError, *caseClashConflictResult);
+            } else {
+                done(SyncFileItem::FileNameClash, tr("File %1 downloaded but it resulted in a local file name clash!").arg(QDir::toNativeSeparators(_item->_file)));
+            }
             return;
+        } else {
+            QString error;
+            if (!propagator()->createConflict(_item, _associatedComposite, &error)) {
+                done(SyncFileItem::SoftError, error);
+            } else {
+                previousFileExists = false;
+            }
         }
-        previousFileExists = false;
     }
 
     const auto vfs = propagator()->syncOptions()._vfs;
@@ -1223,7 +1238,7 @@ void PropagateDownloadFile::downloadFinished()
         // the discovery phase and now.
         const qint64 expectedSize = _item->_previousSize;
         const time_t expectedMtime = _item->_previousModtime;
-        if (!FileSystem::verifyFileUnchanged(fn, expectedSize, expectedMtime)) {
+        if (!FileSystem::verifyFileUnchanged(filename, expectedSize, expectedMtime)) {
             propagator()->_anotherSyncNeeded = true;
             done(SyncFileItem::SoftError, tr("File has changed since discovery"));
             return;
@@ -1231,14 +1246,14 @@ void PropagateDownloadFile::downloadFinished()
     }
 
     QString error;
-    emit propagator()->touchedFile(fn);
+    emit propagator()->touchedFile(filename);
     // The fileChanged() check is done above to generate better error messages.
-    if (!FileSystem::uncheckedRenameReplace(_tmpFile.fileName(), fn, &error)) {
-        qCWarning(lcPropagateDownload) << QString("Rename failed: %1 => %2").arg(_tmpFile.fileName()).arg(fn);
+    if (!FileSystem::uncheckedRenameReplace(_tmpFile.fileName(), filename, &error)) {
+        qCWarning(lcPropagateDownload) << QString("Rename failed: %1 => %2").arg(_tmpFile.fileName()).arg(filename);
         // If the file is locked, we want to retry this sync when it
         // becomes available again, otherwise try again directly
-        if (FileSystem::isFileLocked(fn)) {
-            emit propagator()->seenLockedFile(fn);
+        if (FileSystem::isFileLocked(filename)) {
+            emit propagator()->seenLockedFile(filename);
         } else {
             propagator()->_anotherSyncNeeded = true;
         }
@@ -1250,14 +1265,14 @@ void PropagateDownloadFile::downloadFinished()
     qCInfo(lcPropagateDownload()) << propagator()->account()->davUser() << propagator()->account()->davDisplayName() << propagator()->account()->displayName();
     if (_item->_locked == SyncFileItem::LockStatus::LockedItem && (_item->_lockOwnerType != SyncFileItem::LockOwnerType::UserLock || _item->_lockOwnerId != propagator()->account()->davUser())) {
         qCInfo(lcPropagateDownload()) << "file is locked: making it read only";
-        FileSystem::setFileReadOnly(fn, true);
+        FileSystem::setFileReadOnly(filename, true);
     }
 
-    FileSystem::setFileHidden(fn, false);
+    FileSystem::setFileHidden(filename, false);
 
     // Maybe we downloaded a newer version of the file than we thought we would...
     // Get up to date information for the journal.
-    _item->_size = FileSystem::getSize(fn);
+    _item->_size = FileSystem::getSize(filename);
 
     // Maybe what we downloaded was a conflict file? If so, set a conflict record.
     // (the data was prepared in slotGetFinished above)

+ 9 - 9
src/libsync/propagatorjobs.cpp

@@ -178,7 +178,7 @@ void PropagateLocalMkdir::startLocalMkdir()
 
     if (Utility::fsCasePreserving() && propagator()->localFileNameClash(_item->_file)) {
         qCWarning(lcPropagateLocalMkdir) << "New folder to create locally already exists with different case:" << _item->_file;
-        done(SyncFileItem::FileNameClash, tr("Attention, possible case sensitivity clash with %1").arg(newDirStr));
+        done(SyncFileItem::FileNameClash, tr("Folder %1 cannot be created because of a local file or folder name clash!").arg(newDirStr));
         return;
     }
     emit propagator()->touchedFile(newDirStr);
@@ -245,14 +245,14 @@ void PropagateLocalRename::start()
 
         if (QString::compare(_item->_file, _item->_renameTarget, Qt::CaseInsensitive) != 0
             && propagator()->localFileNameClash(_item->_renameTarget)) {
-            // Only use localFileNameClash for the destination if we know that the source was not
-            // the one conflicting  (renaming  A.txt -> a.txt is OK)
-
-            // Fixme: the file that is the reason for the clash could be named here,
-            // it would have to come out the localFileNameClash function
-            done(SyncFileItem::FileNameClash,
-                tr("File %1 cannot be renamed to %2 because of a local file name clash")
-                    .arg(QDir::toNativeSeparators(_item->_file), QDir::toNativeSeparators(_item->_renameTarget)));
+
+            qCInfo(lcPropagateLocalRename) << "renaming a case clashed file" << _item->_file << _item->_renameTarget;
+            const auto caseClashConflictResult = propagator()->createCaseClashConflict(_item, existingFile);
+            if (caseClashConflictResult) {
+                done(SyncFileItem::SoftError, *caseClashConflictResult);
+            } else {
+                done(SyncFileItem::FileNameClash, tr("File %1 downloaded but it resulted in a local file name clash!").arg(QDir::toNativeSeparators(_item->_file)));
+            }
             return;
         }
 

+ 15 - 0
src/libsync/syncengine.cpp

@@ -317,6 +317,20 @@ void SyncEngine::conflictRecordMaintenance()
     }
 }
 
+void SyncEngine::caseClashConflictRecordMaintenance()
+{
+    // Remove stale conflict entries from the database
+    // by checking which files still exist and removing the
+    // missing ones.
+    const auto conflictRecordPaths = _journal->caseClashConflictRecordPaths();
+    for (const auto &path : conflictRecordPaths) {
+        const auto fsPath = _propagator->fullLocalPath(QString::fromUtf8(path));
+        if (!QFileInfo::exists(fsPath)) {
+            _journal->deleteCaseClashConflictByPathRecord(path);
+        }
+    }
+}
+
 
 void OCC::SyncEngine::slotItemDiscovered(const OCC::SyncFileItemPtr &item)
 {
@@ -906,6 +920,7 @@ void SyncEngine::slotPropagationFinished(bool success)
     }
 
     conflictRecordMaintenance();
+    caseClashConflictRecordMaintenance();
 
     _journal->deleteStaleFlagsEntries();
     _journal->commit("All Finished.", false);

+ 3 - 0
src/libsync/syncengine.h

@@ -283,6 +283,9 @@ private:
     // Removes stale and adds missing conflict records after sync
     void conflictRecordMaintenance();
 
+    // Removes stale and adds missing conflict records after sync
+    void caseClashConflictRecordMaintenance();
+
     // cleanup and emit the finished signal
     void finalize(bool success);
 

+ 10 - 3
test/syncenginetestutils.cpp

@@ -655,7 +655,8 @@ FakeGetReply::FakeGetReply(FileInfo &remoteRootFileInfo, QNetworkAccessManager::
     Q_ASSERT(!fileName.isEmpty());
     fileInfo = remoteRootFileInfo.find(fileName);
     if (!fileInfo) {
-        qDebug() << "meh;";
+        qDebug() << "url: " << request.url() << " fileName: " << fileName
+                 << " meh;";
     }
     Q_ASSERT_X(fileInfo, Q_FUNC_INFO, "Could not find file on the remote");
     QMetaObject::invokeMethod(this, &FakeGetReply::respond, Qt::QueuedConnection);
@@ -669,6 +670,12 @@ void FakeGetReply::respond()
         emit finished();
         return;
     }
+    if (!fileInfo) {
+        setError(ContentNotFoundError, QStringLiteral("File Not Found"));
+        emit metaDataChanged();
+        emit finished();
+        return;
+    }
     payload = fileInfo->contentChar;
     size = fileInfo->size;
     setHeader(QNetworkRequest::ContentLengthHeader, size);
@@ -1190,7 +1197,7 @@ void FakeFolder::execUntilItemCompleted(const QString &relativePath)
 
 void FakeFolder::toDisk(QDir &dir, const FileInfo &templateFi)
 {
-    foreach (const FileInfo &child, templateFi.children) {
+    for(const auto &child : templateFi.children) {
         if (child.isDir) {
             QDir subDir(dir);
             dir.mkdir(child.name);
@@ -1208,7 +1215,7 @@ void FakeFolder::toDisk(QDir &dir, const FileInfo &templateFi)
 
 void FakeFolder::fromDisk(QDir &dir, FileInfo &templateFi)
 {
-    foreach (const QFileInfo &diskChild, dir.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot)) {
+    for(const auto &diskChild : dir.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot)) {
         if (diskChild.isDir()) {
             QDir subDir = dir;
             subDir.cd(diskChild.fileName());

+ 70 - 63
test/teste2efiletransfer.cpp

@@ -30,79 +30,86 @@ public:
     E2eFileTransferTest() = default;
 
 private:
-    EndToEndTestHelper _helper;
-    OCC::Folder *_testFolder;
 
 private slots:
     void initTestCase()
     {
-        QSignalSpy accountReady(&_helper, &EndToEndTestHelper::accountReady);
-        _helper.startAccountConfig();
-        QVERIFY(accountReady.wait(3000));
-
-        const auto accountState = _helper.accountState();
-        QSignalSpy accountConnected(accountState.data(), &OCC::AccountState::isConnectedChanged);
-        QVERIFY(accountConnected.wait(30000));
-
-        _testFolder = _helper.configureSyncFolder();
-        QVERIFY(_testFolder);
+        qRegisterMetaType<OCC::SyncResult>("OCC::SyncResult");
     }
 
     void testSyncFolder()
     {
-        // Try the down-sync first
-        QSignalSpy folderSyncFinished(_testFolder, &OCC::Folder::syncFinished);
-        OCC::FolderMan::instance()->forceSyncForFolder(_testFolder);
-        QVERIFY(folderSyncFinished.wait(3000));
-
-        const auto testFolderPath = _testFolder->path();
-        const QString expectedFilePath(testFolderPath + QStringLiteral("welcome.txt"));
-        const QFile expectedFile(expectedFilePath);
-        qDebug() << "Checking if expected file exists at:" << expectedFilePath;
-        QVERIFY(expectedFile.exists());
-
-        // Now write a file to test the upload
-        const auto fileName = QStringLiteral("test_file.txt");
-        const QString localFilePath(_testFolder->path() + fileName);
-        QVERIFY(OCC::Utility::writeRandomFile(localFilePath));
-
-        OCC::FolderMan::instance()->forceSyncForFolder(_testFolder);
-        QVERIFY(folderSyncFinished.wait(3000));
-        qDebug() << "First folder sync complete";
-
-        const auto waitForServerToProcessTime = QTime::currentTime().addSecs(3);
-        while (QTime::currentTime() < waitForServerToProcessTime) {
-            QCoreApplication::processEvents(QEventLoop::AllEvents, 100);
-        }
-
-        // Do a propfind to check for this file
-        const QString remoteFilePath(_testFolder->remotePathTrailingSlash() + fileName);
-        auto checkFileExistsJob = new OCC::PropfindJob(_helper.account(), remoteFilePath, this);
-        QSignalSpy result(checkFileExistsJob, &OCC::PropfindJob::result);
-
-        checkFileExistsJob->setProperties(QList<QByteArray>() << "getlastmodified");
-        checkFileExistsJob->start();
-        QVERIFY(result.wait(10000));
-
-        // Now try to delete the file and check change is reflected
-        QFile createdFile(localFilePath);
-        QVERIFY(createdFile.exists());
-        createdFile.remove();
-
-        OCC::FolderMan::instance()->forceSyncForFolder(_testFolder);
-        QVERIFY(folderSyncFinished.wait(3000));
-
-        while (QTime::currentTime() < waitForServerToProcessTime) {
-            QCoreApplication::processEvents(QEventLoop::AllEvents, 100);
+        {
+            EndToEndTestHelper _helper;
+            OCC::Folder *_testFolder;
+
+            QSignalSpy accountReady(&_helper, &EndToEndTestHelper::accountReady);
+            _helper.startAccountConfig();
+            QVERIFY(accountReady.wait(3000));
+
+            const auto accountState = _helper.accountState();
+            QSignalSpy accountConnected(accountState.data(), &OCC::AccountState::isConnectedChanged);
+            QVERIFY(accountConnected.wait(30000));
+
+            _testFolder = _helper.configureSyncFolder();
+            QVERIFY(_testFolder);
+
+            // Try the down-sync first
+            QSignalSpy folderSyncFinished(_testFolder, &OCC::Folder::syncFinished);
+            OCC::FolderMan::instance()->forceSyncForFolder(_testFolder);
+            QVERIFY(folderSyncFinished.wait(3000));
+
+            const auto testFolderPath = _testFolder->path();
+            const QString expectedFilePath(testFolderPath + QStringLiteral("welcome.txt"));
+            const QFile expectedFile(expectedFilePath);
+            qDebug() << "Checking if expected file exists at:" << expectedFilePath;
+            QVERIFY(expectedFile.exists());
+
+            // Now write a file to test the upload
+            const auto fileName = QStringLiteral("test_file.txt");
+            const QString localFilePath(_testFolder->path() + fileName);
+            QVERIFY(OCC::Utility::writeRandomFile(localFilePath));
+
+            OCC::FolderMan::instance()->forceSyncForFolder(_testFolder);
+            QVERIFY(folderSyncFinished.wait(3000));
+            qDebug() << "First folder sync complete";
+
+            const auto waitForServerToProcessTime = QTime::currentTime().addSecs(3);
+            while (QTime::currentTime() < waitForServerToProcessTime) {
+                QCoreApplication::processEvents(QEventLoop::AllEvents, 100);
+            }
+
+            // Do a propfind to check for this file
+            const QString remoteFilePath(_testFolder->remotePathTrailingSlash() + fileName);
+            auto checkFileExistsJob = new OCC::PropfindJob(_helper.account(), remoteFilePath, this);
+            QSignalSpy result(checkFileExistsJob, &OCC::PropfindJob::result);
+
+            checkFileExistsJob->setProperties(QList<QByteArray>() << "getlastmodified");
+            checkFileExistsJob->start();
+            QVERIFY(result.wait(10000));
+
+            // Now try to delete the file and check change is reflected
+            QFile createdFile(localFilePath);
+            QVERIFY(createdFile.exists());
+            createdFile.remove();
+
+            OCC::FolderMan::instance()->forceSyncForFolder(_testFolder);
+            QVERIFY(folderSyncFinished.wait(3000));
+
+            while (QTime::currentTime() < waitForServerToProcessTime) {
+                QCoreApplication::processEvents(QEventLoop::AllEvents, 100);
+            }
+
+            auto checkFileDeletedJob = new OCC::PropfindJob(_helper.account(), remoteFilePath, this);
+            QSignalSpy error(checkFileDeletedJob, &OCC::PropfindJob::finishedWithError);
+
+            checkFileDeletedJob->setProperties(QList<QByteArray>() << "getlastmodified");
+            checkFileDeletedJob->start();
+
+            QVERIFY(error.wait(10000));
         }
 
-        auto checkFileDeletedJob = new OCC::PropfindJob(_helper.account(), remoteFilePath, this);
-        QSignalSpy error(checkFileDeletedJob, &OCC::PropfindJob::finishedWithError);
-
-        checkFileDeletedJob->setProperties(QList<QByteArray>() << "getlastmodified");
-        checkFileDeletedJob->start();
-
-        QVERIFY(error.wait(10000));
+        QTest::qWait(10000);
     }
 };
 

+ 299 - 9
test/testsyncengine.cpp

@@ -5,25 +5,49 @@
  *
  */
 
-#include <QtTest>
 #include "syncenginetestutils.h"
-#include <syncengine.h>
-#include <propagatorjobs.h>
+
+#include "syncengine.h"
+#include "propagatorjobs.h"
+#include "caseclashconflictsolver.h"
+
+#include <QtTest>
 
 using namespace OCC;
 
-bool itemDidComplete(const ItemCompletedSpy &spy, const QString &path)
+namespace {
+
+QStringList findCaseClashConflicts(const FileInfo &dir)
 {
-    if (auto item = spy.findItem(path)) {
-        return item->_instruction != CSYNC_INSTRUCTION_NONE && item->_instruction != CSYNC_INSTRUCTION_UPDATE_METADATA;
+    QStringList conflicts;
+    for (const auto &item : dir.children) {
+        if (item.name.contains("(case clash from")) {
+            conflicts.append(item.path());
+        }
+    }
+    return conflicts;
+}
+
+bool expectConflict(FileInfo state, const QString path)
+{
+    PathComponents pathComponents(path);
+    auto base = state.find(pathComponents.parentDirComponents());
+    if (!base)
+        return false;
+    for (const auto &item : qAsConst(base->children)) {
+        if (item.name.startsWith(pathComponents.fileName()) && item.name.contains("(case clash from")) {
+            return true;
+        }
     }
     return false;
 }
 
-bool itemInstruction(const ItemCompletedSpy &spy, const QString &path, const SyncInstructions instr)
+bool itemDidComplete(const ItemCompletedSpy &spy, const QString &path)
 {
-    auto item = spy.findItem(path);
-    return item->_instruction == instr;
+    if (auto item = spy.findItem(path)) {
+        return item->_instruction != CSYNC_INSTRUCTION_NONE && item->_instruction != CSYNC_INSTRUCTION_UPDATE_METADATA;
+    }
+    return false;
 }
 
 bool itemDidCompleteSuccessfully(const ItemCompletedSpy &spy, const QString &path)
@@ -54,6 +78,8 @@ int itemSuccessfullyCompletedGetRank(const ItemCompletedSpy &spy, const QString
     return -1;
 }
 
+}
+
 class TestSyncEngine : public QObject
 {
     Q_OBJECT
@@ -1307,6 +1333,270 @@ private slots:
         auto folderA = fakeFolder.currentLocalState().find("toDelete");
         QCOMPARE(folderA, nullptr);
     }
+
+    void testServer_caseClash_createConflict()
+    {
+        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);
+    }
+
+    void testServer_subFolderCaseClash_createConflict()
+    {
+        constexpr auto testLowerCaseFile = "a/b/test";
+        constexpr auto testUpperCaseFile = "a/b/TEST";
+
+#if defined Q_OS_LINUX
+        constexpr auto shouldHaveCaseClashConflict = false;
+#else
+        constexpr auto shouldHaveCaseClashConflict = true;
+#endif
+
+        FakeFolder fakeFolder{ FileInfo{} };
+
+        fakeFolder.remoteModifier().mkdir("a");
+        fakeFolder.remoteModifier().mkdir("a/b");
+        fakeFolder.remoteModifier().insert("a/b/otherFile.txt");
+        fakeFolder.remoteModifier().insert(testLowerCaseFile);
+        fakeFolder.remoteModifier().insert(testUpperCaseFile);
+
+        fakeFolder.syncEngine().setLocalDiscoveryOptions(OCC::LocalDiscoveryStyle::DatabaseAndFilesystem);
+        QVERIFY(fakeFolder.syncOnce());
+
+        auto conflicts = findCaseClashConflicts(*fakeFolder.currentLocalState().find("a/b"));
+        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().find("a/b"));
+        QCOMPARE(conflicts.size(), shouldHaveCaseClashConflict ? 1 : 0);
+    }
+
+    void testServer_caseClash_createConflictOnMove()
+    {
+        constexpr auto testLowerCaseFile = "test";
+        constexpr auto testUpperCaseFile = "TEST2";
+        constexpr auto testUpperCaseFileAfterMove = "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(), 0);
+        const auto hasConflict = expectConflict(fakeFolder.currentLocalState(), testLowerCaseFile);
+        QCOMPARE(hasConflict, false);
+
+        fakeFolder.remoteModifier().rename(testUpperCaseFile, testUpperCaseFileAfterMove);
+
+        fakeFolder.syncEngine().setLocalDiscoveryOptions(OCC::LocalDiscoveryStyle::DatabaseAndFilesystem);
+        QVERIFY(fakeFolder.syncOnce());
+
+        conflicts = findCaseClashConflicts(fakeFolder.currentLocalState());
+        QCOMPARE(conflicts.size(), shouldHaveCaseClashConflict ? 1 : 0);
+        const auto hasConflictAfterMove = expectConflict(fakeFolder.currentLocalState(), testUpperCaseFileAfterMove);
+        QCOMPARE(hasConflictAfterMove, shouldHaveCaseClashConflict ? true : false);
+
+        fakeFolder.syncEngine().setLocalDiscoveryOptions(OCC::LocalDiscoveryStyle::DatabaseAndFilesystem);
+        QVERIFY(fakeFolder.syncOnce());
+
+        conflicts = findCaseClashConflicts(fakeFolder.currentLocalState());
+        QCOMPARE(conflicts.size(), shouldHaveCaseClashConflict ? 1 : 0);
+    }
+
+    void testServer_subFolderCaseClash_createConflictOnMove()
+    {
+        constexpr auto testLowerCaseFile = "a/b/test";
+        constexpr auto testUpperCaseFile = "a/b/TEST2";
+        constexpr auto testUpperCaseFileAfterMove = "a/b/TEST";
+
+#if defined Q_OS_LINUX
+        constexpr auto shouldHaveCaseClashConflict = false;
+#else
+        constexpr auto shouldHaveCaseClashConflict = true;
+#endif
+
+        FakeFolder fakeFolder{ FileInfo{} };
+
+        fakeFolder.remoteModifier().mkdir("a");
+        fakeFolder.remoteModifier().mkdir("a/b");
+        fakeFolder.remoteModifier().insert("a/b/otherFile.txt");
+        fakeFolder.remoteModifier().insert(testLowerCaseFile);
+        fakeFolder.remoteModifier().insert(testUpperCaseFile);
+
+        fakeFolder.syncEngine().setLocalDiscoveryOptions(OCC::LocalDiscoveryStyle::DatabaseAndFilesystem);
+        QVERIFY(fakeFolder.syncOnce());
+
+        auto conflicts = findCaseClashConflicts(*fakeFolder.currentLocalState().find("a/b"));
+        QCOMPARE(conflicts.size(), 0);
+        const auto hasConflict = expectConflict(fakeFolder.currentLocalState(), testLowerCaseFile);
+        QCOMPARE(hasConflict, false);
+
+        fakeFolder.remoteModifier().rename(testUpperCaseFile, testUpperCaseFileAfterMove);
+
+        fakeFolder.syncEngine().setLocalDiscoveryOptions(OCC::LocalDiscoveryStyle::DatabaseAndFilesystem);
+        QVERIFY(fakeFolder.syncOnce());
+
+        conflicts = findCaseClashConflicts(*fakeFolder.currentLocalState().find("a/b"));
+        QCOMPARE(conflicts.size(), shouldHaveCaseClashConflict ? 1 : 0);
+        const auto hasConflictAfterMove = expectConflict(fakeFolder.currentLocalState(), testUpperCaseFileAfterMove);
+        QCOMPARE(hasConflictAfterMove, shouldHaveCaseClashConflict ? true : false);
+
+        fakeFolder.syncEngine().setLocalDiscoveryOptions(OCC::LocalDiscoveryStyle::DatabaseAndFilesystem);
+        QVERIFY(fakeFolder.syncOnce());
+
+        conflicts = findCaseClashConflicts(*fakeFolder.currentLocalState().find("a/b"));
+        QCOMPARE(conflicts.size(), shouldHaveCaseClashConflict ? 1 : 0);
+    }
+
+    void testServer_caseClash_createConflictAndSolveIt()
+    {
+        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);
+
+        if (shouldHaveCaseClashConflict) {
+            const auto conflictFileName = QString{conflicts.constFirst()};
+            qDebug() << conflictFileName;
+            CaseClashConflictSolver conflictSolver(fakeFolder.localPath() + testLowerCaseFile,
+                                                   fakeFolder.localPath() + conflictFileName,
+                                                   QStringLiteral("/"),
+                                                   fakeFolder.localPath(),
+                                                   fakeFolder.account(),
+                                                   &fakeFolder.syncJournal());
+
+            QSignalSpy conflictSolverDone(&conflictSolver, &CaseClashConflictSolver::done);
+            QSignalSpy conflictSolverFailed(&conflictSolver, &CaseClashConflictSolver::failed);
+
+            conflictSolver.solveConflict("test2");
+
+            QVERIFY(conflictSolverDone.wait());
+
+            QVERIFY(fakeFolder.syncOnce());
+
+            conflicts = findCaseClashConflicts(fakeFolder.currentLocalState());
+            QCOMPARE(conflicts.size(), 0);
+        }
+    }
+
+    void testServer_subFolderCaseClash_createConflictAndSolveIt()
+    {
+        constexpr auto testLowerCaseFile = "a/b/test";
+        constexpr auto testUpperCaseFile = "a/b/TEST";
+
+#if defined Q_OS_LINUX
+        constexpr auto shouldHaveCaseClashConflict = false;
+#else
+        constexpr auto shouldHaveCaseClashConflict = true;
+#endif
+
+        FakeFolder fakeFolder{ FileInfo{} };
+
+        fakeFolder.remoteModifier().mkdir("a");
+        fakeFolder.remoteModifier().mkdir("a/b");
+        fakeFolder.remoteModifier().insert("a/b/otherFile.txt");
+        fakeFolder.remoteModifier().insert(testLowerCaseFile);
+        fakeFolder.remoteModifier().insert(testUpperCaseFile);
+
+        fakeFolder.syncEngine().setLocalDiscoveryOptions(OCC::LocalDiscoveryStyle::DatabaseAndFilesystem);
+        QVERIFY(fakeFolder.syncOnce());
+
+        auto conflicts = findCaseClashConflicts(*fakeFolder.currentLocalState().find("a/b"));
+        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().find("a/b"));
+        QCOMPARE(conflicts.size(), shouldHaveCaseClashConflict ? 1 : 0);
+
+        if (shouldHaveCaseClashConflict) {
+            CaseClashConflictSolver conflictSolver(fakeFolder.localPath() + testLowerCaseFile,
+                                                   fakeFolder.localPath() + conflicts.constFirst(),
+                                                   QStringLiteral("/"),
+                                                   fakeFolder.localPath(),
+                                                   fakeFolder.account(),
+                                                   &fakeFolder.syncJournal());
+
+            QSignalSpy conflictSolverDone(&conflictSolver, &CaseClashConflictSolver::done);
+            QSignalSpy conflictSolverFailed(&conflictSolver, &CaseClashConflictSolver::failed);
+
+            conflictSolver.solveConflict("a/b/test2");
+
+            QVERIFY(conflictSolverDone.wait());
+
+            QVERIFY(fakeFolder.syncOnce());
+
+            conflicts = findCaseClashConflicts(*fakeFolder.currentLocalState().find("a/b"));
+            QCOMPARE(conflicts.size(), 0);
+        }
+    }
 };
 
 QTEST_GUILESS_MAIN(TestSyncEngine)

+ 188 - 0
test/testsyncvirtualfiles.cpp

@@ -13,6 +13,34 @@
 
 using namespace OCC;
 
+namespace {
+
+QStringList findCaseClashConflicts(const FileInfo &dir)
+{
+    QStringList conflicts;
+    for (const auto &item : dir.children) {
+        if (item.name.contains("(case clash from")) {
+            conflicts.append(item.path());
+        }
+    }
+    return conflicts;
+}
+
+bool expectConflict(FileInfo state, const QString path)
+{
+    PathComponents pathComponents(path);
+    auto base = state.find(pathComponents.parentDirComponents());
+    if (!base)
+        return false;
+    for (const auto &item : qAsConst(base->children)) {
+        if (item.name.startsWith(pathComponents.fileName()) && item.name.contains("(case clash from")) {
+            return true;
+        }
+    }
+    return false;
+}
+}
+
 #define DVSUFFIX APPLICATION_DOTVIRTUALFILE_SUFFIX
 
 bool itemInstruction(const ItemCompletedSpy &spy, const QString &path, const SyncInstructions instr)
@@ -1691,6 +1719,166 @@ private slots:
         fakeFolder.execUntilBeforePropagation();
 
         QCOMPARE(checkStatus(), SyncFileStatus::StatusError);
+
+        fakeFolder.execUntilFinished();
+    }
+
+    void testServer_caseClash_createConflict()
+    {
+        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{}};
+        setupVfs(fakeFolder);
+
+        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);
+    }
+
+    void testServer_subFolderCaseClash_createConflict()
+    {
+        constexpr auto testLowerCaseFile = "a/b/test";
+        constexpr auto testUpperCaseFile = "a/b/TEST";
+
+#if defined Q_OS_LINUX
+        constexpr auto shouldHaveCaseClashConflict = false;
+#else
+        constexpr auto shouldHaveCaseClashConflict = true;
+#endif
+
+        FakeFolder fakeFolder{ FileInfo{} };
+        setupVfs(fakeFolder);
+
+        fakeFolder.remoteModifier().mkdir("a");
+        fakeFolder.remoteModifier().mkdir("a/b");
+        fakeFolder.remoteModifier().insert("a/b/otherFile.txt");
+        fakeFolder.remoteModifier().insert(testLowerCaseFile);
+        fakeFolder.remoteModifier().insert(testUpperCaseFile);
+
+        fakeFolder.syncEngine().setLocalDiscoveryOptions(OCC::LocalDiscoveryStyle::DatabaseAndFilesystem);
+        QVERIFY(fakeFolder.syncOnce());
+
+        auto conflicts = findCaseClashConflicts(*fakeFolder.currentLocalState().find("a/b"));
+        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().find("a/b"));
+        QCOMPARE(conflicts.size(), shouldHaveCaseClashConflict ? 1 : 0);
+    }
+
+    void testServer_caseClash_createConflictOnMove()
+    {
+        constexpr auto testLowerCaseFile = "test";
+        constexpr auto testUpperCaseFile = "TEST2";
+        constexpr auto testUpperCaseFileAfterMove = "TEST";
+
+#if defined Q_OS_LINUX
+        constexpr auto shouldHaveCaseClashConflict = false;
+#else
+        constexpr auto shouldHaveCaseClashConflict = true;
+#endif
+
+        FakeFolder fakeFolder{ FileInfo{} };
+        setupVfs(fakeFolder);
+
+        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(), 0);
+        const auto hasConflict = expectConflict(fakeFolder.currentLocalState(), testLowerCaseFile);
+        QCOMPARE(hasConflict, false);
+
+        fakeFolder.remoteModifier().rename(testUpperCaseFile, testUpperCaseFileAfterMove);
+
+        fakeFolder.syncEngine().setLocalDiscoveryOptions(OCC::LocalDiscoveryStyle::DatabaseAndFilesystem);
+        QVERIFY(fakeFolder.syncOnce());
+
+        conflicts = findCaseClashConflicts(fakeFolder.currentLocalState());
+        QCOMPARE(conflicts.size(), shouldHaveCaseClashConflict ? 1 : 0);
+        const auto hasConflictAfterMove = expectConflict(fakeFolder.currentLocalState(), testUpperCaseFileAfterMove);
+        QCOMPARE(hasConflictAfterMove, shouldHaveCaseClashConflict ? true : false);
+
+        fakeFolder.syncEngine().setLocalDiscoveryOptions(OCC::LocalDiscoveryStyle::DatabaseAndFilesystem);
+        QVERIFY(fakeFolder.syncOnce());
+
+        conflicts = findCaseClashConflicts(fakeFolder.currentLocalState());
+        QCOMPARE(conflicts.size(), shouldHaveCaseClashConflict ? 1 : 0);
+    }
+
+    void testServer_subFolderCaseClash_createConflictOnMove()
+    {
+        constexpr auto testLowerCaseFile = "a/b/test";
+        constexpr auto testUpperCaseFile = "a/b/TEST2";
+        constexpr auto testUpperCaseFileAfterMove = "a/b/TEST";
+
+#if defined Q_OS_LINUX
+        constexpr auto shouldHaveCaseClashConflict = false;
+#else
+        constexpr auto shouldHaveCaseClashConflict = true;
+#endif
+
+        FakeFolder fakeFolder{ FileInfo{} };
+        setupVfs(fakeFolder);
+
+        fakeFolder.remoteModifier().mkdir("a");
+        fakeFolder.remoteModifier().mkdir("a/b");
+        fakeFolder.remoteModifier().insert("a/b/otherFile.txt");
+        fakeFolder.remoteModifier().insert(testLowerCaseFile);
+        fakeFolder.remoteModifier().insert(testUpperCaseFile);
+
+        fakeFolder.syncEngine().setLocalDiscoveryOptions(OCC::LocalDiscoveryStyle::DatabaseAndFilesystem);
+        QVERIFY(fakeFolder.syncOnce());
+
+        auto conflicts = findCaseClashConflicts(*fakeFolder.currentLocalState().find("a/b"));
+        QCOMPARE(conflicts.size(), 0);
+        const auto hasConflict = expectConflict(fakeFolder.currentLocalState(), testLowerCaseFile);
+        QCOMPARE(hasConflict, false);
+
+        fakeFolder.remoteModifier().rename(testUpperCaseFile, testUpperCaseFileAfterMove);
+
+        fakeFolder.syncEngine().setLocalDiscoveryOptions(OCC::LocalDiscoveryStyle::DatabaseAndFilesystem);
+        QVERIFY(fakeFolder.syncOnce());
+
+        conflicts = findCaseClashConflicts(*fakeFolder.currentLocalState().find("a/b"));
+        QCOMPARE(conflicts.size(), shouldHaveCaseClashConflict ? 1 : 0);
+        const auto hasConflictAfterMove = expectConflict(fakeFolder.currentLocalState(), testUpperCaseFileAfterMove);
+        QCOMPARE(hasConflictAfterMove, shouldHaveCaseClashConflict ? true : false);
+
+        fakeFolder.syncEngine().setLocalDiscoveryOptions(OCC::LocalDiscoveryStyle::DatabaseAndFilesystem);
+        QVERIFY(fakeFolder.syncOnce());
+
+        conflicts = findCaseClashConflicts(*fakeFolder.currentLocalState().find("a/b"));
+        QCOMPARE(conflicts.size(), shouldHaveCaseClashConflict ? 1 : 0);
     }
 };