diff --git a/CMakeLists.txt b/CMakeLists.txt index 23f292d44..475cef06b 100644 --- a/CMakeLists.txt +++ b/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. diff --git a/src/common/preparedsqlquerymanager.h b/src/common/preparedsqlquerymanager.h index 64cecabbd..97c57e736 100644 --- a/src/common/preparedsqlquerymanager.h +++ b/src/common/preparedsqlquerymanager.h @@ -91,6 +91,11 @@ public: DeleteKeyValueStoreQuery, GetConflictRecordQuery, SetConflictRecordQuery, + GetCaseClashConflictRecordQuery, + GetCaseClashConflictRecordByPathQuery, + SetCaseClashConflictRecordQuery, + DeleteCaseClashConflictRecordQuery, + GetAllCaseClashConflictPathQuery, DeleteConflictRecordQuery, GetRawPinStateQuery, GetEffectivePinStateQuery, diff --git a/src/common/syncjournaldb.cpp b/src/common/syncjournaldb.cpp index cc4f25fd4..7fdd76560 100644 --- a/src/common/syncjournaldb.cpp +++ b/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); diff --git a/src/common/syncjournaldb.h b/src/common/syncjournaldb.h index 18e1ebd38..1cf1ee041 100644 --- a/src/common/syncjournaldb.h +++ b/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); diff --git a/src/common/utility.cpp b/src/common/utility.cpp index b024822f7..b92140d99 100644 --- a/src/common/utility.cpp +++ b/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 diff --git a/src/common/utility.h b/src/common/utility.h index d4b1f2f2a..5853f480d 100644 --- a/src/common/utility.h +++ b/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 * diff --git a/src/csync/csync.h b/src/csync/csync.h index 8796a9d21..9c4b2580c 100644 --- a/src/csync/csync.h +++ b/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) diff --git a/src/csync/csync_exclude.cpp b/src/csync/csync_exclude.cpp index 3430dae34..bddb3047e 100644 --- a/src/csync/csync_exclude.cpp +++ b/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; } diff --git a/src/csync/csync_exclude.h b/src/csync/csync_exclude.h index d701401af..34c9f4b23 100644 --- a/src/csync/csync_exclude.h +++ b/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, diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index af02aa429..7639f95ab 100644 --- a/src/gui/CMakeLists.txt +++ b/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 diff --git a/src/gui/caseclashfilenamedialog.cpp b/src/gui/caseclashfilenamedialog.cpp new file mode 100644 index 000000000..eacf5e96b --- /dev/null +++ b/src/gui/caseclashfilenamedialog.cpp @@ -0,0 +1,253 @@ +/* + * Copyright (C) by Felix Weilbach + * + * 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 +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +constexpr std::array caseClashIllegalCharacters({ '\\', '/', ':', '?', '*', '\"', '<', '>', '|' }); + +QVector getCaseClashIllegalCharsFromString(const QString &string) +{ + QVector 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 &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()) + , _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("%2").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); +} +} diff --git a/src/gui/caseclashfilenamedialog.h b/src/gui/caseclashfilenamedialog.h new file mode 100644 index 000000000..54b1be0c1 --- /dev/null +++ b/src/gui/caseclashfilenamedialog.h @@ -0,0 +1,81 @@ +/* + * Copyright (C) by Felix Weilbach + * + * 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 +#include +#include +#include + +#include + +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; + CaseClashConflictSolver _conflictSolver; + AccountPtr _account; + Folder *_folder = nullptr; + + QString _filePath; + QString _relativeFilePath; + QString _originalFileName; + QString _newFilename; +}; +} diff --git a/src/gui/caseclashfilenamedialog.ui b/src/gui/caseclashfilenamedialog.ui new file mode 100644 index 000000000..f2508cfba --- /dev/null +++ b/src/gui/caseclashfilenamedialog.ui @@ -0,0 +1,121 @@ + + + OCC::CaseClashFilenameDialog + + + + 0 + 0 + 411 + 192 + + + + Case Clash Conflict + + + + QLayout::SetDefaultConstraint + + + + + The file could not be synced because it generates a case clash conflict with an existing file on this system. + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + false + + + + + + + Error + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + false + + + + + + + Please enter a new name for the remote file: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + false + + + + + + + New filename + + + + + + + + + + + + 255 + 0 + 0 + + + + + + + + + 255 + 0 + 0 + + + + + + + + + 255 + 255 + 255 + + + + + + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/src/gui/folder.cpp b/src/gui/folder.cpp index 0ae2004ee..ea933758d 100644 --- a/src/gui/folder.cpp +++ b/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; diff --git a/src/gui/folder.h b/src/gui/folder.h index 1f9dd79e3..9e6de5797 100644 --- a/src/gui/folder.h +++ b/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. diff --git a/src/gui/sharemanager.h b/src/gui/sharemanager.h index 7afae8afa..e03628936 100644 --- a/src/gui/sharemanager.h +++ b/src/gui/sharemanager.h @@ -431,6 +431,6 @@ private: }; } -Q_DECLARE_METATYPE(OCC::SharePtr); +Q_DECLARE_METATYPE(OCC::SharePtr) #endif // SHAREMANAGER_H diff --git a/src/gui/tray/activitylistmodel.cpp b/src/gui/tray/activitylistmodel.cpp index 5e16645d6..837ce46bf 100644 --- a/src/gui/tray/activitylistmodel.cpp +++ b/src/gui/tray/activitylistmodel.cpp @@ -12,6 +12,20 @@ * for more details. */ +#include "activitylistmodel.h" + +#include "account.h" +#include "accountstate.h" +#include "accountmanager.h" +#include "conflictdialog.h" +#include "folderman.h" +#include "owncloudgui.h" +#include "guiutility.h" +#include "invalidfilenamedialog.h" +#include "caseclashfilenamedialog.h" +#include "activitydata.h" +#include "systray.h" + #include #include #include @@ -20,24 +34,6 @@ #include #include -#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 "activitydata.h" -#include "activitylistmodel.h" -#include "systray.h" -#include "tray/usermodel.h" - -#include "theme.h" - namespace OCC { Q_LOGGING_CATEGORY(lcActivity, "nextcloud.gui.activity", QtInfoMsg) @@ -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()) { diff --git a/src/gui/tray/activitylistmodel.h b/src/gui/tray/activitylistmodel.h index 17f1557db..a1dce8341 100644 --- a/src/gui/tray/activitylistmodel.h +++ b/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 _currentConflictDialog; QPointer _currentInvalidFilenameDialog; + QPointer _currentCaseClashFilenameDialog; AccountState *_accountState = nullptr; bool _currentlyFetching = false; diff --git a/src/libsync/CMakeLists.txt b/src/libsync/CMakeLists.txt index 590dbc4c7..8be29f9c1 100644 --- a/src/libsync/CMakeLists.txt +++ b/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) diff --git a/src/libsync/caseclashconflictsolver.cpp b/src/libsync/caseclashconflictsolver.cpp new file mode 100644 index 000000000..00150b502 --- /dev/null +++ b/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 +#include +#include + +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(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(); +} diff --git a/src/libsync/caseclashconflictsolver.h b/src/libsync/caseclashconflictsolver.h new file mode 100644 index 000000000..4438ecb89 --- /dev/null +++ b/src/libsync/caseclashconflictsolver.h @@ -0,0 +1,95 @@ +#ifndef CASECLASHCONFLICTSOLVER_H +#define CASECLASHCONFLICTSOLVER_H + +#include + +#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 diff --git a/src/libsync/discovery.cpp b/src/libsync/discovery.cpp index aed9b802d..b5a563e73 100644 --- a/src/libsync/discovery.cpp +++ b/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. diff --git a/src/libsync/discoveryphase.h b/src/libsync/discoveryphase.h index b905d5d7f..2b48c009b 100644 --- a/src/libsync/discoveryphase.h +++ b/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; diff --git a/src/libsync/filesystem.cpp b/src/libsync/filesystem.cpp index b63745fed..7c58c7b2b 100644 --- a/src/libsync/filesystem.cpp +++ b/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 diff --git a/src/libsync/owncloudpropagator.cpp b/src/libsync/owncloudpropagator.cpp index 380b8e2f4..0143f8f6f 100644 --- a/src/libsync/owncloudpropagator.cpp +++ b/src/libsync/owncloudpropagator.cpp @@ -913,6 +913,63 @@ bool OwncloudPropagator::createConflict(const SyncFileItemPtr &item, return true; } +OCC::Optional 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); +} + } diff --git a/src/libsync/owncloudpropagator.h b/src/libsync/owncloudpropagator.h index 87f5f0702..afd364dc9 100644 --- a/src/libsync/owncloudpropagator.h +++ b/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 createCaseClashConflict(const SyncFileItemPtr &item, const QString &temporaryDownloadedFile); + // Map original path (as in the DB) to target final path QMap _renamedDirectories; [[nodiscard]] QString adjustRenamedPath(const QString &original) const; diff --git a/src/libsync/progressdispatcher.cpp b/src/libsync/progressdispatcher.cpp index 8daf74b46..85b99fb66 100644 --- a/src/libsync/progressdispatcher.cpp +++ b/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: diff --git a/src/libsync/propagatedownload.cpp b/src/libsync/propagatedownload.cpp index 2a0981410..9f7beb0a6 100644 --- a/src/libsync/propagatedownload.cpp +++ b/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) diff --git a/src/libsync/propagatorjobs.cpp b/src/libsync/propagatorjobs.cpp index 531bdd7fe..84005cdb9 100644 --- a/src/libsync/propagatorjobs.cpp +++ b/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; } diff --git a/src/libsync/syncengine.cpp b/src/libsync/syncengine.cpp index 716dbabf6..747a92a7c 100644 --- a/src/libsync/syncengine.cpp +++ b/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); diff --git a/src/libsync/syncengine.h b/src/libsync/syncengine.h index f325ea484..68982e63b 100644 --- a/src/libsync/syncengine.h +++ b/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); diff --git a/test/syncenginetestutils.cpp b/test/syncenginetestutils.cpp index 3cf3628e3..2bf6430a5 100644 --- a/test/syncenginetestutils.cpp +++ b/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()); diff --git a/test/teste2efiletransfer.cpp b/test/teste2efiletransfer.cpp index 256a21a2d..1722b026c 100644 --- a/test/teste2efiletransfer.cpp +++ b/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"); } void testSyncFolder() { - // Try the down-sync first - QSignalSpy folderSyncFinished(_testFolder, &OCC::Folder::syncFinished); - OCC::FolderMan::instance()->forceSyncForFolder(_testFolder); - QVERIFY(folderSyncFinished.wait(3000)); + { + EndToEndTestHelper _helper; + OCC::Folder *_testFolder; - 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()); + QSignalSpy accountReady(&_helper, &EndToEndTestHelper::accountReady); + _helper.startAccountConfig(); + QVERIFY(accountReady.wait(3000)); - // 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)); + const auto accountState = _helper.accountState(); + QSignalSpy accountConnected(accountState.data(), &OCC::AccountState::isConnectedChanged); + QVERIFY(accountConnected.wait(30000)); - OCC::FolderMan::instance()->forceSyncForFolder(_testFolder); - QVERIFY(folderSyncFinished.wait(3000)); - qDebug() << "First folder sync complete"; + _testFolder = _helper.configureSyncFolder(); + QVERIFY(_testFolder); - const auto waitForServerToProcessTime = QTime::currentTime().addSecs(3); - while (QTime::currentTime() < waitForServerToProcessTime) { - QCoreApplication::processEvents(QEventLoop::AllEvents, 100); + // 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() << "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() << "getlastmodified"); + checkFileDeletedJob->start(); + + QVERIFY(error.wait(10000)); } - // 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() << "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() << "getlastmodified"); - checkFileDeletedJob->start(); - - QVERIFY(error.wait(10000)); + QTest::qWait(10000); } }; diff --git a/test/testsyncengine.cpp b/test/testsyncengine.cpp index 03f470c93..9509c7573 100644 --- a/test/testsyncengine.cpp +++ b/test/testsyncengine.cpp @@ -5,13 +5,43 @@ * */ -#include #include "syncenginetestutils.h" -#include -#include + +#include "syncengine.h" +#include "propagatorjobs.h" +#include "caseclashconflictsolver.h" + +#include 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; +} + bool itemDidComplete(const ItemCompletedSpy &spy, const QString &path) { if (auto item = spy.findItem(path)) { @@ -20,12 +50,6 @@ bool itemDidComplete(const ItemCompletedSpy &spy, const QString &path) return false; } -bool itemInstruction(const ItemCompletedSpy &spy, const QString &path, const SyncInstructions instr) -{ - auto item = spy.findItem(path); - return item->_instruction == instr; -} - bool itemDidCompleteSuccessfully(const ItemCompletedSpy &spy, const QString &path) { if (auto item = spy.findItem(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) diff --git a/test/testsyncvirtualfiles.cpp b/test/testsyncvirtualfiles.cpp index ea9cded22..1dd091dbd 100644 --- a/test/testsyncvirtualfiles.cpp +++ b/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); } };