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 # build the GUI component, when disabled only nextcloudcmd is built
option(BUILD_GUI "BUILD_GUI" ON) 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 # 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 # 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. # triggers a bug on the server, you want the file to be blacklisted.

View file

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

View file

@ -519,6 +519,18 @@ bool SyncJournalDb::checkConnect()
return sqlFail(QStringLiteral("Create table conflicts"), createQuery); 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(" createQuery.prepare("CREATE TABLE IF NOT EXISTS version("
"major INTEGER(8)," "major INTEGER(8),"
"minor INTEGER(8)," "minor INTEGER(8),"
@ -2201,6 +2213,101 @@ ConflictRecord SyncJournalDb::conflictRecord(const QByteArray &path)
return entry; 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) void SyncJournalDb::deleteConflictRecord(const QByteArray &path)
{ {
QMutexLocker locker(&_mutex); QMutexLocker locker(&_mutex);

View file

@ -249,6 +249,21 @@ public:
/// Retrieve a conflict record by path of the file with the conflict tag /// Retrieve a conflict record by path of the file with the conflict tag
ConflictRecord conflictRecord(const QByteArray &path); 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 /// Delete a conflict record by path of the file with the conflict tag
void deleteConflictRecord(const QByteArray &path); void deleteConflictRecord(const QByteArray &path);

View file

@ -624,35 +624,21 @@ QString Utility::makeConflictFileName(
return conflictFileName; 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) bool Utility::isConflictFile(const QString &name)
{ {
auto bname = name.midRef(name.lastIndexOf(QLatin1Char('/')) + 1); auto bname = name.midRef(name.lastIndexOf(QLatin1Char('/')) + 1);
if (bname.contains(QStringLiteral("_conflict-"))) if (bname.contains(QStringLiteral("_conflict-"))) {
return true; return true;
}
if (bname.contains(QStringLiteral("(conflicted copy"))) if (bname.contains(QStringLiteral("(conflicted copy"))) {
return true; return true;
}
if (isCaseClashConflictFile(name)) {
return true;
}
return false; return false;
} }
@ -722,4 +708,32 @@ QString Utility::sanitizeForFileName(const QString &name)
return result; 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 } // namespace OCC

View file

@ -223,10 +223,13 @@ namespace Utility {
OCSYNC_EXPORT QString makeConflictFileName( OCSYNC_EXPORT QString makeConflictFileName(
const QString &fn, const QDateTime &dt, const QString &user); 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 /** 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 isConflictFile(const QString &name);
OCSYNC_EXPORT bool isCaseClashConflictFile(const QString &name);
/** Find the base name for a conflict file name, using name pattern only /** 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. * the csync state of a file.
*/ */
enum SyncInstructions { enum SyncInstructions {
CSYNC_INSTRUCTION_NONE = 0, /* Nothing to do (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_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_REMOVE = 1 << 1, /* The file need to be removed (RECONCILE) */
CSYNC_INSTRUCTION_RENAME = 1 << 2, /* The file need to be renamed (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_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_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_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_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_SYNC = 1 << 6, /* The file need to be pushed to the other remote (RECONCILE) */
CSYNC_INSTRUCTION_STAT_ERROR = 1 << 7, CSYNC_INSTRUCTION_STAT_ERROR = 1 << 7,
CSYNC_INSTRUCTION_ERROR = 1 << 8, CSYNC_INSTRUCTION_ERROR = 1 << 8,
CSYNC_INSTRUCTION_TYPE_CHANGE = 1 << 9, /* Like NEW, but deletes the old entity first (RECONCILE) 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 Used when the type of something changes from directory to file
or back. */ or back. */
CSYNC_INSTRUCTION_UPDATE_METADATA = 1 << 10, /* If the etag has been updated and need to be writen to the db, 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) */ 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) 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; return CSYNC_FILE_SILENTLY_EXCLUDED;
} }
if (excludeConflictFiles) {
if (excludeConflictFiles && OCC::Utility::isConflictFile(path)) { if (OCC::Utility::isCaseClashConflictFile(path)) {
return CSYNC_FILE_EXCLUDE_CONFLICT; return CSYNC_FILE_EXCLUDE_CASE_CLASH_CONFLICT;
} else if (OCC::Utility::isConflictFile(path)) {
return CSYNC_FILE_EXCLUDE_CONFLICT;
}
} }
return CSYNC_NOT_EXCLUDED; return CSYNC_NOT_EXCLUDED;
} }

View file

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

View file

@ -29,6 +29,7 @@ set(client_UI_SRCS
accountsettings.ui accountsettings.ui
conflictdialog.ui conflictdialog.ui
invalidfilenamedialog.ui invalidfilenamedialog.ui
caseclashfilenamedialog.ui
foldercreationdialog.ui foldercreationdialog.ui
folderwizardsourcepage.ui folderwizardsourcepage.ui
folderwizardtargetpage.ui folderwizardtargetpage.ui
@ -73,6 +74,8 @@ set(client_SRCS
application.cpp application.cpp
invalidfilenamedialog.h invalidfilenamedialog.h
invalidfilenamedialog.cpp invalidfilenamedialog.cpp
caseclashfilenamedialog.h
caseclashfilenamedialog.cpp
callstatechecker.h callstatechecker.h
callstatechecker.cpp callstatechecker.cpp
conflictdialog.h 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); _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) void Folder::setSaveBackwardsCompatible(bool save)
{ {
_saveBackwardsCompatible = save; _saveBackwardsCompatible = save;

View file

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

View file

@ -12,6 +12,20 @@
* for more details. * 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 <QtCore>
#include <QAbstractListModel> #include <QAbstractListModel>
#include <QDesktopServices> #include <QDesktopServices>
@ -20,24 +34,6 @@
#include <QJsonDocument> #include <QJsonDocument>
#include <qloggingcategory.h> #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 { namespace OCC {
Q_LOGGING_CATEGORY(lcActivity, "nextcloud.gui.activity", QtInfoMsg) Q_LOGGING_CATEGORY(lcActivity, "nextcloud.gui.activity", QtInfoMsg)
@ -548,7 +544,7 @@ void ActivityListModel::addEntriesToActivityList(const ActivityList &activityLis
void ActivityListModel::addErrorToActivityList(const Activity &activity) 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}); addEntriesToActivityList({activity});
_notificationErrorsLists.prepend(activity); _notificationErrorsLists.prepend(activity);
} }
@ -665,6 +661,9 @@ void ActivityListModel::slotTriggerDefaultAction(const int activityIndex)
_currentConflictDialog->open(); _currentConflictDialog->open();
ownCloudGui::raiseDialog(_currentConflictDialog); ownCloudGui::raiseDialog(_currentConflictDialog);
return; return;
} else if (activity._syncFileItemStatus == SyncFileItem::FileNameClash) {
triggerCaseClashAction(activity);
return;
} else if (activity._syncFileItemStatus == SyncFileItem::FileNameInvalid) { } else if (activity._syncFileItemStatus == SyncFileItem::FileNameInvalid) {
if (!_currentInvalidFilenameDialog.isNull()) { if (!_currentInvalidFilenameDialog.isNull()) {
_currentInvalidFilenameDialog->close(); _currentInvalidFilenameDialog->close();
@ -684,22 +683,6 @@ void ActivityListModel::slotTriggerDefaultAction(const int activityIndex)
_currentInvalidFilenameDialog->open(); _currentInvalidFilenameDialog->open();
ownCloudGui::raiseDialog(_currentInvalidFilenameDialog); ownCloudGui::raiseDialog(_currentInvalidFilenameDialog);
return; 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()) { 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) void ActivityListModel::slotTriggerAction(const int activityIndex, const int actionIndex)
{ {
if (activityIndex < 0 || activityIndex >= _finalList.size()) { if (activityIndex < 0 || activityIndex >= _finalList.size()) {

View file

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

View file

@ -121,6 +121,8 @@ set(libsync_SRCS
creds/credentialscommon.cpp creds/credentialscommon.cpp
creds/keychainchunk.h creds/keychainchunk.h
creds/keychainchunk.cpp creds/keychainchunk.cpp
caseclashconflictsolver.h
caseclashconflictsolver.cpp
) )
if (WIN32) 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: case CSYNC_FILE_EXCLUDE_CONFLICT:
item->_errorString = tr("Conflict: Server version downloaded, local copy renamed and not uploaded."); item->_errorString = tr("Conflict: Server version downloaded, local copy renamed and not uploaded.");
item->_status = SyncFileItem::Conflict; 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: case CSYNC_FILE_EXCLUDE_CANNOT_ENCODE:
item->_errorString = tr("The filename cannot be encoded on your file system."); item->_errorString = tr("The filename cannot be encoded on your file system.");
break; break;
@ -689,6 +693,15 @@ void ProcessDirectoryJob::processFileAnalyzeRemoteInfo(
item->_modtime = serverEntry.modtime; item->_modtime = serverEntry.modtime;
item->_size = serverEntry.size; 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 { auto postProcessServerNew = [=]() mutable {
if (item->isDirectory()) { if (item->isDirectory()) {
_pendingAsyncJobs++; _pendingAsyncJobs++;
@ -1120,6 +1133,20 @@ void ProcessDirectoryJob::processFileAnalyzeLocalInfo(
item->_type = localEntry.isDirectory ? ItemTypeDirectory : localEntry.isVirtualFile ? ItemTypeVirtualFile : ItemTypeFile; item->_type = localEntry.isDirectory ? ItemTypeDirectory : localEntry.isVirtualFile ? ItemTypeVirtualFile : ItemTypeFile;
_childModified = true; _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]() { 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. // 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. // 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 */ /** FileName of the entry (this does not contains any directory or path, just the plain name */
QString name; QString name;
QString caseClashConflictingName;
time_t modtime = 0; time_t modtime = 0;
int64_t size = 0; int64_t size = 0;
uint64_t inode = 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) time_t FileSystem::getModTime(const QString &filename)
{ {
csync_file_stat_t stat; csync_file_stat_t stat;
qint64 result = -1; time_t result = -1;
if (csync_vio_local_stat(filename, &stat) != -1 if (csync_vio_local_stat(filename, &stat) != -1 && (stat.modtime != 0)) {
&& (stat.modtime != 0)) {
result = stat.modtime; result = stat.modtime;
} else { } else {
result = Utility::qDateTimeToTime_t(QFileInfo(filename).lastModified()); result = Utility::qDateTimeToTime_t(QFileInfo(filename).lastModified());
@ -93,11 +92,11 @@ bool FileSystem::fileChanged(const QString &fileName,
} }
bool FileSystem::verifyFileUnchanged(const QString &fileName, bool FileSystem::verifyFileUnchanged(const QString &fileName,
qint64 previousSize, qint64 previousSize,
time_t previousMtime) time_t previousMtime)
{ {
const qint64 actualSize = getSize(fileName); const auto actualSize = getSize(fileName);
const time_t actualMtime = getModTime(fileName); const auto actualMtime = getModTime(fileName);
if ((actualSize != previousSize && actualMtime > 0) || (actualMtime != previousMtime && previousMtime > 0 && actualMtime > 0)) { if ((actualSize != previousSize && actualMtime > 0) || (actualMtime != previousMtime && previousMtime > 0 && actualMtime > 0)) {
qCInfo(lcFileSystem) << "File" << fileName << "has changed:" qCInfo(lcFileSystem) << "File" << fileName << "has changed:"
<< "size: " << previousSize << "<->" << actualSize << "size: " << previousSize << "<->" << actualSize

View file

@ -913,6 +913,63 @@ bool OwncloudPropagator::createConflict(const SyncFileItemPtr &item,
return true; 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 QString OwncloudPropagator::adjustRenamedPath(const QString &original) const
{ {
return OCC::adjustRenamedPath(_renamedDirectories, original); return OCC::adjustRenamedPath(_renamedDirectories, original);
@ -1473,4 +1530,23 @@ QString OwncloudPropagator::remotePath() const
return _remoteFolder; 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) : PropagateItemJob(propagator, item)
{ {
} }
void start() override 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);
}
}; };
class PropagateUploadFileCommon; class PropagateUploadFileCommon;
@ -586,6 +574,14 @@ public:
bool createConflict(const SyncFileItemPtr &item, bool createConflict(const SyncFileItemPtr &item,
PropagatorCompositeJob *composite, QString *error); 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 // Map original path (as in the DB) to target final path
QMap<QString, QString> _renamedDirectories; QMap<QString, QString> _renamedDirectories;
[[nodiscard]] QString adjustRenamedPath(const QString &original) const; [[nodiscard]] QString adjustRenamedPath(const QString &original) const;

View file

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

View file

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

View file

@ -178,7 +178,7 @@ void PropagateLocalMkdir::startLocalMkdir()
if (Utility::fsCasePreserving() && propagator()->localFileNameClash(_item->_file)) { if (Utility::fsCasePreserving() && propagator()->localFileNameClash(_item->_file)) {
qCWarning(lcPropagateLocalMkdir) << "New folder to create locally already exists with different case:" << _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; return;
} }
emit propagator()->touchedFile(newDirStr); emit propagator()->touchedFile(newDirStr);
@ -245,14 +245,14 @@ void PropagateLocalRename::start()
if (QString::compare(_item->_file, _item->_renameTarget, Qt::CaseInsensitive) != 0 if (QString::compare(_item->_file, _item->_renameTarget, Qt::CaseInsensitive) != 0
&& propagator()->localFileNameClash(_item->_renameTarget)) { && 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, qCInfo(lcPropagateLocalRename) << "renaming a case clashed file" << _item->_file << _item->_renameTarget;
// it would have to come out the localFileNameClash function const auto caseClashConflictResult = propagator()->createCaseClashConflict(_item, existingFile);
done(SyncFileItem::FileNameClash, if (caseClashConflictResult) {
tr("File %1 cannot be renamed to %2 because of a local file name clash") done(SyncFileItem::SoftError, *caseClashConflictResult);
.arg(QDir::toNativeSeparators(_item->_file), QDir::toNativeSeparators(_item->_renameTarget))); } else {
done(SyncFileItem::FileNameClash, tr("File %1 downloaded but it resulted in a local file name clash!").arg(QDir::toNativeSeparators(_item->_file)));
}
return; 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) void OCC::SyncEngine::slotItemDiscovered(const OCC::SyncFileItemPtr &item)
{ {
@ -906,6 +920,7 @@ void SyncEngine::slotPropagationFinished(bool success)
} }
conflictRecordMaintenance(); conflictRecordMaintenance();
caseClashConflictRecordMaintenance();
_journal->deleteStaleFlagsEntries(); _journal->deleteStaleFlagsEntries();
_journal->commit("All Finished.", false); _journal->commit("All Finished.", false);

View file

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

View file

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

View file

@ -30,79 +30,86 @@ public:
E2eFileTransferTest() = default; E2eFileTransferTest() = default;
private: private:
EndToEndTestHelper _helper;
OCC::Folder *_testFolder;
private slots: private slots:
void initTestCase() void initTestCase()
{ {
QSignalSpy accountReady(&_helper, &EndToEndTestHelper::accountReady); qRegisterMetaType<OCC::SyncResult>("OCC::SyncResult");
_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);
} }
void testSyncFolder() void testSyncFolder()
{ {
// Try the down-sync first {
QSignalSpy folderSyncFinished(_testFolder, &OCC::Folder::syncFinished); EndToEndTestHelper _helper;
OCC::FolderMan::instance()->forceSyncForFolder(_testFolder); OCC::Folder *_testFolder;
QVERIFY(folderSyncFinished.wait(3000));
const auto testFolderPath = _testFolder->path(); QSignalSpy accountReady(&_helper, &EndToEndTestHelper::accountReady);
const QString expectedFilePath(testFolderPath + QStringLiteral("welcome.txt")); _helper.startAccountConfig();
const QFile expectedFile(expectedFilePath); QVERIFY(accountReady.wait(3000));
qDebug() << "Checking if expected file exists at:" << expectedFilePath;
QVERIFY(expectedFile.exists());
// Now write a file to test the upload const auto accountState = _helper.accountState();
const auto fileName = QStringLiteral("test_file.txt"); QSignalSpy accountConnected(accountState.data(), &OCC::AccountState::isConnectedChanged);
const QString localFilePath(_testFolder->path() + fileName); QVERIFY(accountConnected.wait(30000));
QVERIFY(OCC::Utility::writeRandomFile(localFilePath));
OCC::FolderMan::instance()->forceSyncForFolder(_testFolder); _testFolder = _helper.configureSyncFolder();
QVERIFY(folderSyncFinished.wait(3000)); QVERIFY(_testFolder);
qDebug() << "First folder sync complete";
const auto waitForServerToProcessTime = QTime::currentTime().addSecs(3); // Try the down-sync first
while (QTime::currentTime() < waitForServerToProcessTime) { QSignalSpy folderSyncFinished(_testFolder, &OCC::Folder::syncFinished);
QCoreApplication::processEvents(QEventLoop::AllEvents, 100); 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 QTest::qWait(10000);
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));
} }
}; };

View file

@ -5,13 +5,43 @@
* *
*/ */
#include <QtTest>
#include "syncenginetestutils.h" #include "syncenginetestutils.h"
#include <syncengine.h>
#include <propagatorjobs.h> #include "syncengine.h"
#include "propagatorjobs.h"
#include "caseclashconflictsolver.h"
#include <QtTest>
using namespace OCC; 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) bool itemDidComplete(const ItemCompletedSpy &spy, const QString &path)
{ {
if (auto item = spy.findItem(path)) { if (auto item = spy.findItem(path)) {
@ -20,12 +50,6 @@ bool itemDidComplete(const ItemCompletedSpy &spy, const QString &path)
return false; 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) bool itemDidCompleteSuccessfully(const ItemCompletedSpy &spy, const QString &path)
{ {
if (auto item = spy.findItem(path)) { if (auto item = spy.findItem(path)) {
@ -54,6 +78,8 @@ int itemSuccessfullyCompletedGetRank(const ItemCompletedSpy &spy, const QString
return -1; return -1;
} }
}
class TestSyncEngine : public QObject class TestSyncEngine : public QObject
{ {
Q_OBJECT Q_OBJECT
@ -1307,6 +1333,270 @@ private slots:
auto folderA = fakeFolder.currentLocalState().find("toDelete"); auto folderA = fakeFolder.currentLocalState().find("toDelete");
QCOMPARE(folderA, nullptr); 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) QTEST_GUILESS_MAIN(TestSyncEngine)

View file

@ -13,6 +13,34 @@
using namespace OCC; 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 #define DVSUFFIX APPLICATION_DOTVIRTUALFILE_SUFFIX
bool itemInstruction(const ItemCompletedSpy &spy, const QString &path, const SyncInstructions instr) bool itemInstruction(const ItemCompletedSpy &spy, const QString &path, const SyncInstructions instr)
@ -1691,6 +1719,166 @@ private slots:
fakeFolder.execUntilBeforePropagation(); fakeFolder.execUntilBeforePropagation();
QCOMPARE(checkStatus(), SyncFileStatus::StatusError); 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);
} }
}; };