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>
This commit is contained in:
Matthieu Gallien 2022-11-30 10:34:49 +01:00
parent 5c42da4de5
commit 602b8db5e2
No known key found for this signature in database
GPG Key ID: 7D0F74F05C22F553
35 changed files with 1791 additions and 213 deletions

View File

@ -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.

View File

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

View File

@ -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);

View File

@ -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);

View File

@ -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

View File

@ -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
*

View File

@ -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)

View File

@ -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;
}

View File

@ -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,

View File

@ -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

View File

@ -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);
}
}

View File

@ -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;
};
}

View File

@ -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>

View File

@ -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;

View File

@ -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.

View File

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

View File

@ -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 <QtCore>
#include <QAbstractListModel>
#include <QDesktopServices>
@ -20,24 +34,6 @@
#include <QJsonDocument>
#include <qloggingcategory.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 "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()) {

View File

@ -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;

View File

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

View File

@ -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();
}

View File

@ -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

View File

@ -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.

View File

@ -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;

View File

@ -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

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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:

View File

@ -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)

View File

@ -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;
}

View File

@ -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);

View File

@ -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);

View File

@ -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());

View File

@ -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));
{
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<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));
}
// 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));
QTest::qWait(10000);
}
};

View File

@ -5,13 +5,43 @@
*
*/
#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;
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)

View File

@ -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);
}
};