mirror of
https://github.com/nextcloud/desktop.git
synced 2025-10-26 11:17:43 +00:00
Upload conflict files #4557
If the server has the 'uploadConflictFiles' capability conflict files will be uploaded instead of ignored. Uploaded conflict files have the following headers set during upload OC-Conflict: 1 OC-ConflictBaseFileId: 172489174instanceid OC-ConflictBaseMtime: 1235789213 OC-ConflictBaseEtag: myetag when the data is available. Downloads accept the same headers in return when downloading a conflict file. In the absence of server support clients will identify conflict files through the file name pattern and attempt to deduce the base fileid. Base etag and mtime can't be deduced though. The upload job for a new conflict file will be triggered directly from the job that created the conflict file now. No second sync run is necessary anymore. This commit does not yet introduce a 'username' like identifier that automatically gets added to conflict file filenames (to name the files foo_conflict-Fred-1345.txt instead of just foo_conflict-1345.txt).
This commit is contained in:
parent
7ae8ba35de
commit
a7c0cfc8eb
@ -403,27 +403,6 @@ QByteArray FileSystem::calcAdler32(const QString &filename)
|
||||
}
|
||||
#endif
|
||||
|
||||
QString FileSystem::makeConflictFileName(const QString &fn, const QDateTime &dt)
|
||||
{
|
||||
QString conflictFileName(fn);
|
||||
// Add _conflict-XXXX before the extension.
|
||||
int dotLocation = conflictFileName.lastIndexOf('.');
|
||||
// If no extension, add it at the end (take care of cases like foo/.hidden or foo.bar/file)
|
||||
if (dotLocation <= conflictFileName.lastIndexOf('/') + 1) {
|
||||
dotLocation = conflictFileName.size();
|
||||
}
|
||||
QString timeString = dt.toString("yyyyMMdd-hhmmss");
|
||||
|
||||
// Additional marker
|
||||
QByteArray conflictFileUserName = qgetenv("CSYNC_CONFLICT_FILE_USERNAME");
|
||||
if (conflictFileUserName.isEmpty())
|
||||
conflictFileName.insert(dotLocation, "_conflict-" + timeString);
|
||||
else
|
||||
conflictFileName.insert(dotLocation, "_conflict_" + QString::fromUtf8(conflictFileUserName) + "-" + timeString);
|
||||
|
||||
return conflictFileName;
|
||||
}
|
||||
|
||||
bool FileSystem::remove(const QString &fileName, QString *errorString)
|
||||
{
|
||||
#ifdef Q_OS_WIN
|
||||
|
||||
@ -131,11 +131,6 @@ namespace FileSystem {
|
||||
QByteArray OCSYNC_EXPORT calcAdler32(const QString &fileName);
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Returns a file name based on \a fn that's suitable for a conflict.
|
||||
*/
|
||||
QString OCSYNC_EXPORT makeConflictFileName(const QString &fn, const QDateTime &dt);
|
||||
|
||||
/**
|
||||
* Returns true when a file is locked. (Windows only)
|
||||
*/
|
||||
|
||||
@ -438,7 +438,7 @@ bool SyncJournalDb::checkConnect()
|
||||
return sqlFail("Create table version", createQuery);
|
||||
}
|
||||
|
||||
// create the checksumtype table.
|
||||
// create the datafingerprint table.
|
||||
createQuery.prepare("CREATE TABLE IF NOT EXISTS datafingerprint("
|
||||
"fingerprint TEXT UNIQUE"
|
||||
");");
|
||||
@ -446,6 +446,17 @@ bool SyncJournalDb::checkConnect()
|
||||
return sqlFail("Create table datafingerprint", createQuery);
|
||||
}
|
||||
|
||||
// create the conflicts table.
|
||||
createQuery.prepare("CREATE TABLE IF NOT EXISTS conflicts("
|
||||
"path TEXT PRIMARY KEY,"
|
||||
"baseFileId TEXT,"
|
||||
"baseEtag TEXT,"
|
||||
"baseModtime INTEGER"
|
||||
");");
|
||||
if (!createQuery.exec()) {
|
||||
return sqlFail("Create table conflicts", createQuery);
|
||||
}
|
||||
|
||||
createQuery.prepare("CREATE TABLE IF NOT EXISTS version("
|
||||
"major INTEGER(8),"
|
||||
"minor INTEGER(8),"
|
||||
@ -693,6 +704,23 @@ bool SyncJournalDb::checkConnect()
|
||||
return sqlFail("prepare _setDataFingerprintQuery2", *_setDataFingerprintQuery2);
|
||||
}
|
||||
|
||||
_getConflictRecordQuery.reset(new SqlQuery(_db));
|
||||
if (_getConflictRecordQuery->prepare("SELECT baseFileId, baseModtime, baseEtag FROM conflicts WHERE path=?1;")) {
|
||||
return sqlFail("prepare _getConflictRecordQuery", *_getConflictRecordQuery);
|
||||
}
|
||||
|
||||
_setConflictRecordQuery.reset(new SqlQuery(_db));
|
||||
if (_setConflictRecordQuery->prepare("INSERT OR REPLACE INTO conflicts "
|
||||
"(path, baseFileId, baseModtime, baseEtag) "
|
||||
"VALUES (?1, ?2, ?3, ?4);")) {
|
||||
return sqlFail("prepare _setConflictRecordQuery", *_setConflictRecordQuery);
|
||||
}
|
||||
|
||||
_deleteConflictRecordQuery.reset(new SqlQuery(_db));
|
||||
if (_deleteConflictRecordQuery->prepare("DELETE FROM conflicts WHERE path=?1;")) {
|
||||
return sqlFail("prepare _deleteConflictRecordQuery", *_deleteConflictRecordQuery);
|
||||
}
|
||||
|
||||
// don't start a new transaction now
|
||||
commitInternal(QString("checkConnect End"), false);
|
||||
|
||||
@ -741,6 +769,9 @@ void SyncJournalDb::close()
|
||||
_getDataFingerprintQuery.reset(0);
|
||||
_setDataFingerprintQuery1.reset(0);
|
||||
_setDataFingerprintQuery2.reset(0);
|
||||
_getConflictRecordQuery.reset(0);
|
||||
_setConflictRecordQuery.reset(0);
|
||||
_deleteConflictRecordQuery.reset(0);
|
||||
|
||||
_db.close();
|
||||
_avoidReadFromDbOnNextSyncFilter.clear();
|
||||
@ -1951,6 +1982,72 @@ void SyncJournalDb::setDataFingerprint(const QByteArray &dataFingerprint)
|
||||
_setDataFingerprintQuery2->exec();
|
||||
}
|
||||
|
||||
void SyncJournalDb::setConflictRecord(const ConflictRecord &record)
|
||||
{
|
||||
QMutexLocker locker(&_mutex);
|
||||
if (!checkConnect())
|
||||
return;
|
||||
|
||||
auto &query = *_setConflictRecordQuery;
|
||||
query.reset_and_clear_bindings();
|
||||
query.bindValue(1, record.path);
|
||||
query.bindValue(2, record.baseFileId);
|
||||
query.bindValue(3, record.baseModtime);
|
||||
query.bindValue(4, record.baseEtag);
|
||||
ASSERT(query.exec());
|
||||
}
|
||||
|
||||
ConflictRecord SyncJournalDb::conflictRecord(const QByteArray &path)
|
||||
{
|
||||
ConflictRecord entry;
|
||||
|
||||
QMutexLocker locker(&_mutex);
|
||||
if (!checkConnect())
|
||||
return entry;
|
||||
|
||||
auto &query = *_getConflictRecordQuery;
|
||||
query.reset_and_clear_bindings();
|
||||
query.bindValue(1, path);
|
||||
ASSERT(query.exec());
|
||||
if (!query.next())
|
||||
return entry;
|
||||
|
||||
entry.path = path;
|
||||
entry.baseFileId = query.baValue(0);
|
||||
entry.baseModtime = query.int64Value(1);
|
||||
entry.baseEtag = query.baValue(2);
|
||||
return entry;
|
||||
}
|
||||
|
||||
void SyncJournalDb::deleteConflictRecord(const QByteArray &path)
|
||||
{
|
||||
QMutexLocker locker(&_mutex);
|
||||
if (!checkConnect())
|
||||
return;
|
||||
|
||||
auto &query = *_deleteConflictRecordQuery;
|
||||
query.reset_and_clear_bindings();
|
||||
query.bindValue(1, path);
|
||||
ASSERT(query.exec());
|
||||
}
|
||||
|
||||
QByteArrayList SyncJournalDb::conflictRecordPaths()
|
||||
{
|
||||
QMutexLocker locker(&_mutex);
|
||||
if (!checkConnect())
|
||||
return {};
|
||||
|
||||
SqlQuery query(_db);
|
||||
query.prepare("SELECT path FROM conflicts");
|
||||
ASSERT(query.exec());
|
||||
|
||||
QByteArrayList paths;
|
||||
while (query.next())
|
||||
paths.append(query.baValue(0));
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
void SyncJournalDb::clearFileTable()
|
||||
{
|
||||
QMutexLocker lock(&_mutex);
|
||||
|
||||
@ -207,6 +207,22 @@ public:
|
||||
void setDataFingerprint(const QByteArray &dataFingerprint);
|
||||
QByteArray dataFingerprint();
|
||||
|
||||
|
||||
// Conflict record functions
|
||||
|
||||
/// Store a new or updated record in the database
|
||||
void setConflictRecord(const ConflictRecord &record);
|
||||
|
||||
/// Retrieve a conflict record by path of the _conflict- file
|
||||
ConflictRecord conflictRecord(const QByteArray &path);
|
||||
|
||||
/// Delete a conflict record by path of the _conflict- file
|
||||
void deleteConflictRecord(const QByteArray &path);
|
||||
|
||||
/// Return all paths of _conflict- files with records in the db
|
||||
QByteArrayList conflictRecordPaths();
|
||||
|
||||
|
||||
/**
|
||||
* Delete any file entry. This will force the next sync to re-sync everything as if it was new,
|
||||
* restoring everyfile on every remote. If a file is there both on the client and server side,
|
||||
@ -266,6 +282,9 @@ private:
|
||||
QScopedPointer<SqlQuery> _getDataFingerprintQuery;
|
||||
QScopedPointer<SqlQuery> _setDataFingerprintQuery1;
|
||||
QScopedPointer<SqlQuery> _setDataFingerprintQuery2;
|
||||
QScopedPointer<SqlQuery> _getConflictRecordQuery;
|
||||
QScopedPointer<SqlQuery> _setConflictRecordQuery;
|
||||
QScopedPointer<SqlQuery> _deleteConflictRecordQuery;
|
||||
|
||||
/* This is the list of paths we called avoidReadFromDbOnNextSync on.
|
||||
* It means that they should not be written to the DB in any case since doing
|
||||
|
||||
@ -111,6 +111,43 @@ public:
|
||||
|
||||
bool isValid() const;
|
||||
};
|
||||
|
||||
/** Represents a conflict in the conflicts table.
|
||||
*
|
||||
* In the following the "conflict file" is the file with the "_conflict-"
|
||||
* tag and the base file is the file that its a conflict for. So if
|
||||
* a/foo.txt is the base file, its conflict file could be
|
||||
* a/foo_conflict-1234.txt.
|
||||
*/
|
||||
class OCSYNC_EXPORT ConflictRecord
|
||||
{
|
||||
public:
|
||||
/** Path to the _conflict- file
|
||||
*
|
||||
* So if a/foo.txt has a conflict, this path would point to
|
||||
* a/foo_conflict-1234.txt.
|
||||
*
|
||||
* The path is sync-folder relative.
|
||||
*/
|
||||
QByteArray path;
|
||||
|
||||
/// File id of the base file
|
||||
QByteArray baseFileId;
|
||||
|
||||
/** Modtime of the base file
|
||||
*
|
||||
* may not be available and be -1
|
||||
*/
|
||||
qint64 baseModtime = -1;
|
||||
|
||||
/** Etag of the base file
|
||||
*
|
||||
* may not be available and empty
|
||||
*/
|
||||
QByteArray baseEtag;
|
||||
|
||||
bool isValid() const { return !path.isEmpty(); }
|
||||
};
|
||||
}
|
||||
|
||||
#endif // SYNCJOURNALFILERECORD_H
|
||||
|
||||
@ -542,6 +542,21 @@ QUrl Utility::concatUrlPath(const QUrl &url, const QString &concatPath,
|
||||
return tmpUrl;
|
||||
}
|
||||
|
||||
QString Utility::makeConflictFileName(const QString &fn, const QDateTime &dt)
|
||||
{
|
||||
QString conflictFileName(fn);
|
||||
// Add _conflict-XXXX before the extension.
|
||||
int dotLocation = conflictFileName.lastIndexOf('.');
|
||||
// If no extension, add it at the end (take care of cases like foo/.hidden or foo.bar/file)
|
||||
if (dotLocation <= conflictFileName.lastIndexOf('/') + 1) {
|
||||
dotLocation = conflictFileName.size();
|
||||
}
|
||||
QString timeString = dt.toString("yyyyMMdd-hhmmss");
|
||||
|
||||
conflictFileName.insert(dotLocation, "_conflict-" + timeString);
|
||||
return conflictFileName;
|
||||
}
|
||||
|
||||
bool Utility::isConflictFile(const char *name)
|
||||
{
|
||||
const char *bname = std::strrchr(name, '/');
|
||||
@ -551,32 +566,33 @@ bool Utility::isConflictFile(const char *name)
|
||||
bname = name;
|
||||
}
|
||||
|
||||
if (std::strstr(bname, "_conflict-"))
|
||||
return true;
|
||||
|
||||
if (shouldUploadConflictFiles()) {
|
||||
// For uploads, we want to consider files with any kind of username tag
|
||||
// as conflict files. (pattern *_conflict_*-)
|
||||
const char *startOfMarker = std::strstr(bname, "_conflict_");
|
||||
if (startOfMarker && std::strchr(startOfMarker, '-'))
|
||||
return true;
|
||||
} else {
|
||||
// Old behavior: optionally, files with the specific string in the env variable
|
||||
// appended are also considered conflict files.
|
||||
static auto conflictFileUsername = qgetenv("CSYNC_CONFLICT_FILE_USERNAME");
|
||||
static auto usernameConflictId = QByteArray("_conflict_" + conflictFileUsername + "-");
|
||||
if (!conflictFileUsername.isEmpty() && std::strstr(bname, usernameConflictId.constData())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return std::strstr(bname, "_conflict-");
|
||||
}
|
||||
|
||||
bool Utility::shouldUploadConflictFiles()
|
||||
bool Utility::isConflictFile(const QString &name)
|
||||
{
|
||||
static bool uploadConflictFiles = qEnvironmentVariableIntValue("OWNCLOUD_UPLOAD_CONFLICT_FILES") != 0;
|
||||
return uploadConflictFiles;
|
||||
auto bname = name.midRef(name.lastIndexOf('/') + 1);
|
||||
return bname.contains("_conflict-", Utility::fsCasePreserving() ? Qt::CaseInsensitive : Qt::CaseSensitive);
|
||||
}
|
||||
|
||||
QByteArray Utility::conflictFileBaseName(const QByteArray &conflictName)
|
||||
{
|
||||
// This function must be able to deal with conflict files for conflict files.
|
||||
// To do this, we scan backwards, for the outermost conflict marker and
|
||||
// strip only that to generate the conflict file base name.
|
||||
int from = conflictName.size();
|
||||
while (from != -1) {
|
||||
auto start = conflictName.lastIndexOf("_conflict-", from);
|
||||
if (start == -1)
|
||||
return "";
|
||||
from = start - 1;
|
||||
|
||||
auto end = conflictName.indexOf('.', start);
|
||||
if (end == -1)
|
||||
end = conflictName.size();
|
||||
return conflictName.left(start) + conflictName.mid(end);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
} // namespace OCC
|
||||
|
||||
@ -182,17 +182,23 @@ namespace Utility {
|
||||
with the given parent. If no parent is specified, the caller must destroy the settings */
|
||||
OCSYNC_EXPORT std::unique_ptr<QSettings> settingsWithGroup(const QString &group, QObject *parent = 0);
|
||||
|
||||
/** Returns a file name based on \a fn that's suitable for a conflict.
|
||||
*/
|
||||
OCSYNC_EXPORT QString makeConflictFileName(const QString &fn, const QDateTime &dt);
|
||||
|
||||
/** Returns whether a file name indicates a conflict file
|
||||
*
|
||||
* See FileSystem::makeConflictFileName.
|
||||
*/
|
||||
OCSYNC_EXPORT bool isConflictFile(const char *name);
|
||||
OCSYNC_EXPORT bool isConflictFile(const QString &name);
|
||||
|
||||
/** Returns whether conflict files should be uploaded.
|
||||
/** Find the base name for a conflict file name
|
||||
*
|
||||
* Experimental! Real feature planned for 2.5.
|
||||
* Will return an empty string if it's not a conflict file.
|
||||
*
|
||||
* Prefer to use the data from the conflicts table in the journal to determine
|
||||
* a conflict's base file.
|
||||
*/
|
||||
OCSYNC_EXPORT bool shouldUploadConflictFiles();
|
||||
OCSYNC_EXPORT QByteArray conflictFileBaseName(const QByteArray &conflictName);
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
OCSYNC_EXPORT QVariant registryGetKeyValue(HKEY hRootKey, const QString &subKey, const QString &valueName);
|
||||
|
||||
@ -218,7 +218,7 @@ bool csync_is_windows_reserved_word(const char *filename)
|
||||
return false;
|
||||
}
|
||||
|
||||
static CSYNC_EXCLUDE_TYPE _csync_excluded_common(const char *path)
|
||||
static CSYNC_EXCLUDE_TYPE _csync_excluded_common(const char *path, bool excludeConflictFiles)
|
||||
{
|
||||
const char *bname = NULL;
|
||||
size_t blen = 0;
|
||||
@ -313,11 +313,9 @@ static CSYNC_EXCLUDE_TYPE _csync_excluded_common(const char *path)
|
||||
}
|
||||
}
|
||||
|
||||
if (!OCC::Utility::shouldUploadConflictFiles()) {
|
||||
if (OCC::Utility::isConflictFile(bname)) {
|
||||
match = CSYNC_FILE_EXCLUDE_CONFLICT;
|
||||
goto out;
|
||||
}
|
||||
if (excludeConflictFiles && OCC::Utility::isConflictFile(bname)) {
|
||||
match = CSYNC_FILE_EXCLUDE_CONFLICT;
|
||||
goto out;
|
||||
}
|
||||
|
||||
out:
|
||||
@ -341,6 +339,11 @@ void ExcludedFiles::addExcludeFilePath(const QString &path)
|
||||
_excludeFiles.insert(path);
|
||||
}
|
||||
|
||||
void ExcludedFiles::setExcludeConflictFiles(bool onoff)
|
||||
{
|
||||
_excludeConflictFiles = onoff;
|
||||
}
|
||||
|
||||
void ExcludedFiles::addManualExclude(const QByteArray &expr)
|
||||
{
|
||||
_manualExcludes.append(expr);
|
||||
@ -408,7 +411,7 @@ bool ExcludedFiles::isExcluded(
|
||||
|
||||
CSYNC_EXCLUDE_TYPE ExcludedFiles::traversalPatternMatch(const char *path, ItemType filetype) const
|
||||
{
|
||||
auto match = _csync_excluded_common(path);
|
||||
auto match = _csync_excluded_common(path, _excludeConflictFiles);
|
||||
if (match != CSYNC_NOT_EXCLUDED)
|
||||
return match;
|
||||
if (_allExcludes.isEmpty())
|
||||
@ -454,7 +457,7 @@ CSYNC_EXCLUDE_TYPE ExcludedFiles::traversalPatternMatch(const char *path, ItemTy
|
||||
|
||||
CSYNC_EXCLUDE_TYPE ExcludedFiles::fullPatternMatch(const char *path, ItemType filetype) const
|
||||
{
|
||||
auto match = _csync_excluded_common(path);
|
||||
auto match = _csync_excluded_common(path, _excludeConflictFiles);
|
||||
if (match != CSYNC_NOT_EXCLUDED)
|
||||
return match;
|
||||
if (_allExcludes.isEmpty())
|
||||
|
||||
@ -76,6 +76,13 @@ public:
|
||||
*/
|
||||
void addExcludeFilePath(const QString &path);
|
||||
|
||||
/**
|
||||
* Whether conflict files shall be excluded.
|
||||
*
|
||||
* Defaults to true.
|
||||
*/
|
||||
void setExcludeConflictFiles(bool onoff);
|
||||
|
||||
/**
|
||||
* Checks whether a file or directory should be excluded.
|
||||
*
|
||||
@ -191,6 +198,8 @@ private:
|
||||
QRegularExpression _fullRegexFile;
|
||||
QRegularExpression _fullRegexDir;
|
||||
|
||||
bool _excludeConflictFiles = true;
|
||||
|
||||
friend class ExcludedFilesTest;
|
||||
};
|
||||
|
||||
|
||||
@ -203,6 +203,8 @@ struct OCSYNC_EXPORT csync_s {
|
||||
|
||||
bool ignore_hidden_files = true;
|
||||
|
||||
bool upload_conflict_files = false;
|
||||
|
||||
csync_s(const char *localUri, OCC::SyncJournalDb *statedb);
|
||||
~csync_s();
|
||||
int reinitialize();
|
||||
|
||||
@ -400,7 +400,7 @@ out:
|
||||
|
||||
// If conflict files are uploaded, they won't be marked as IGNORE / CSYNC_FILE_EXCLUDE_CONFLICT
|
||||
// but we still want them marked!
|
||||
if (OCC::Utility::shouldUploadConflictFiles()) {
|
||||
if (ctx->upload_conflict_files) {
|
||||
if (OCC::Utility::isConflictFile(fs->path.constData())) {
|
||||
fs->error_status = CSYNC_STATUS_INDIVIDUAL_IS_CONFLICT_FILE;
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
#include "capabilities.h"
|
||||
|
||||
#include <QVariantMap>
|
||||
#include <QDebug>
|
||||
|
||||
namespace OCC {
|
||||
|
||||
@ -148,4 +149,14 @@ QString Capabilities::invalidFilenameRegex() const
|
||||
{
|
||||
return _capabilities["dav"].toMap()["invalidFilenameRegex"].toString();
|
||||
}
|
||||
|
||||
bool Capabilities::uploadConflictFiles() const
|
||||
{
|
||||
static auto envIsSet = !qEnvironmentVariableIsEmpty("OWNCLOUD_UPLOAD_CONFLICT_FILES");
|
||||
static int envValue = qEnvironmentVariableIntValue("OWNCLOUD_UPLOAD_CONFLICT_FILES");
|
||||
if (envIsSet)
|
||||
return envValue != 0;
|
||||
|
||||
return _capabilities["uploadConflictFiles"].toBool();
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,6 +116,11 @@ public:
|
||||
*/
|
||||
QString invalidFilenameRegex() const;
|
||||
|
||||
/**
|
||||
* Whether conflict files should remain local (default) or should be uploaded.
|
||||
*/
|
||||
bool uploadConflictFiles() const;
|
||||
|
||||
private:
|
||||
QVariantMap _capabilities;
|
||||
};
|
||||
|
||||
@ -739,6 +739,12 @@ void PropagatorCompositeJob::slotSubJobAbortFinished()
|
||||
}
|
||||
}
|
||||
|
||||
void PropagatorCompositeJob::appendJob(PropagatorJob *job)
|
||||
{
|
||||
job->setCompositeParent(this);
|
||||
_jobsToDo.append(job);
|
||||
}
|
||||
|
||||
bool PropagatorCompositeJob::scheduleSelfOrChild()
|
||||
{
|
||||
if (_state == Finished) {
|
||||
@ -767,13 +773,8 @@ bool PropagatorCompositeJob::scheduleSelfOrChild()
|
||||
}
|
||||
|
||||
// Now it's our turn, check if we have something left to do.
|
||||
if (!_jobsToDo.isEmpty()) {
|
||||
PropagatorJob *nextJob = _jobsToDo.first();
|
||||
_jobsToDo.remove(0);
|
||||
_runningJobs.append(nextJob);
|
||||
return possiblyRunNextJob(nextJob);
|
||||
}
|
||||
while (!_tasksToDo.isEmpty()) {
|
||||
// First, convert a task to a job if necessary
|
||||
while (_jobsToDo.isEmpty() && !_tasksToDo.isEmpty()) {
|
||||
SyncFileItemPtr nextTask = _tasksToDo.first();
|
||||
_tasksToDo.remove(0);
|
||||
PropagatorJob *job = propagator()->createJob(nextTask);
|
||||
@ -781,9 +782,15 @@ bool PropagatorCompositeJob::scheduleSelfOrChild()
|
||||
qCWarning(lcDirectory) << "Useless task found for file" << nextTask->destination() << "instruction" << nextTask->_instruction;
|
||||
continue;
|
||||
}
|
||||
|
||||
_runningJobs.append(job);
|
||||
return possiblyRunNextJob(job);
|
||||
appendJob(job);
|
||||
break;
|
||||
}
|
||||
// Then run the next job
|
||||
if (!_jobsToDo.isEmpty()) {
|
||||
PropagatorJob *nextJob = _jobsToDo.first();
|
||||
_jobsToDo.remove(0);
|
||||
_runningJobs.append(nextJob);
|
||||
return possiblyRunNextJob(nextJob);
|
||||
}
|
||||
|
||||
// If neither us or our children had stuff left to do we could hang. Make sure
|
||||
|
||||
@ -49,6 +49,7 @@ qint64 freeSpaceLimit();
|
||||
|
||||
class SyncJournalDb;
|
||||
class OwncloudPropagator;
|
||||
class PropagatorCompositeJob;
|
||||
|
||||
/**
|
||||
* @brief the base class of propagator jobs
|
||||
@ -102,6 +103,12 @@ public:
|
||||
*/
|
||||
virtual qint64 committedDiskSpace() const { return 0; }
|
||||
|
||||
/** Set the composite parent job
|
||||
*
|
||||
* Used only from PropagatorCompositeJob itself, when a job is added.
|
||||
*/
|
||||
void setCompositeParent(PropagatorCompositeJob *job) { _compositeParent = job; }
|
||||
|
||||
public slots:
|
||||
/*
|
||||
* Asynchronous abort requires emit of abortFinished() signal,
|
||||
@ -128,6 +135,13 @@ signals:
|
||||
void abortFinished(SyncFileItem::Status status = SyncFileItem::NormalError);
|
||||
protected:
|
||||
OwncloudPropagator *propagator() const;
|
||||
|
||||
/** If this job gets added to a composite job, this will point to the parent.
|
||||
*
|
||||
* That can be useful for jobs that want to spawn follow-up jobs without
|
||||
* becoming composite jobs themselves.
|
||||
*/
|
||||
PropagatorCompositeJob *_compositeParent = nullptr;
|
||||
};
|
||||
|
||||
/*
|
||||
@ -214,10 +228,7 @@ public:
|
||||
qDeleteAll(_runningJobs);
|
||||
}
|
||||
|
||||
void appendJob(PropagatorJob *job)
|
||||
{
|
||||
_jobsToDo.append(job);
|
||||
}
|
||||
void appendJob(PropagatorJob *job);
|
||||
void appendTask(const SyncFileItemPtr &item)
|
||||
{
|
||||
_tasksToDo.append(item);
|
||||
@ -439,7 +450,10 @@ public:
|
||||
|
||||
QString getFilePath(const QString &tmp_file_name) const;
|
||||
|
||||
/** Creates the job for an item.
|
||||
*/
|
||||
PropagateItemJob *createJob(const SyncFileItemPtr &item);
|
||||
|
||||
void scheduleNextJob();
|
||||
void reportProgress(const SyncFileItem &, quint64 bytes);
|
||||
|
||||
@ -497,6 +511,7 @@ private slots:
|
||||
void scheduleNextJobImpl();
|
||||
|
||||
signals:
|
||||
void newItem(const SyncFileItemPtr &);
|
||||
void itemCompleted(const SyncFileItemPtr &);
|
||||
void progress(const SyncFileItem &, quint64 bytes);
|
||||
void finished(bool success);
|
||||
|
||||
@ -525,7 +525,7 @@ void PropagateDownloadFile::slotGetFinished()
|
||||
{
|
||||
propagator()->_activeJobList.removeOne(this);
|
||||
|
||||
GETFileJob *job = qobject_cast<GETFileJob *>(sender());
|
||||
GETFileJob *job = _job;
|
||||
ASSERT(job);
|
||||
|
||||
QNetworkReply::NetworkError err = job->reply()->error();
|
||||
@ -639,6 +639,26 @@ void PropagateDownloadFile::slotGetFinished()
|
||||
return;
|
||||
}
|
||||
|
||||
// Did the file come with conflict headers? If so, store them now!
|
||||
// If we download conflict files but the server doesn't send conflict
|
||||
// headers, the record will be established by SyncEngine::conflictRecordMaintenance.
|
||||
// (we can't reliably determine the file id of the base file here,
|
||||
// it might still be downloaded in a parallel job and not exist in
|
||||
// the database yet!)
|
||||
if (job->reply()->rawHeader("OC-Conflict") == "1") {
|
||||
_conflictRecord.path = _item->_file.toUtf8();
|
||||
_conflictRecord.baseFileId = job->reply()->rawHeader("OC-ConflictBaseFileId");
|
||||
_conflictRecord.baseEtag = _job->reply()->rawHeader("OC-ConflictBaseEtag");
|
||||
|
||||
auto mtimeHeader = _job->reply()->rawHeader("OC-ConflictBaseMtime");
|
||||
if (!mtimeHeader.isEmpty())
|
||||
_conflictRecord.baseModtime = mtimeHeader.toLongLong();
|
||||
|
||||
// We don't set it yet. That will only be done when the download finished
|
||||
// successfully, much further down. Here we just grab the headers because the
|
||||
// job will be deleted later.
|
||||
}
|
||||
|
||||
// Do checksum validation for the download. If there is no checksum header, the validator
|
||||
// will also emit the validated() signal to continue the flow in slot transmissionChecksumValidated()
|
||||
// as this is (still) also correct.
|
||||
@ -677,7 +697,7 @@ void PropagateDownloadFile::deleteExistingFolder()
|
||||
// on error, just try to move it away...
|
||||
}
|
||||
|
||||
QString conflictDir = FileSystem::makeConflictFileName(
|
||||
QString conflictDir = Utility::makeConflictFileName(
|
||||
existingDir, Utility::qDateTimeFromTime_t(FileSystem::getModTime(existingDir)));
|
||||
|
||||
emit propagator()->touchedFile(existingDir);
|
||||
@ -805,9 +825,11 @@ void PropagateDownloadFile::downloadFinished()
|
||||
&& !FileSystem::fileEquals(fn, _tmpFile.fileName());
|
||||
if (isConflict) {
|
||||
QString renameError;
|
||||
QString conflictFileName = FileSystem::makeConflictFileName(
|
||||
fn, Utility::qDateTimeFromTime_t(FileSystem::getModTime(fn)));
|
||||
if (!FileSystem::rename(fn, conflictFileName, &renameError)) {
|
||||
auto conflictModTime = FileSystem::getModTime(fn);
|
||||
QString conflictFileName = Utility::makeConflictFileName(
|
||||
_item->_file, Utility::qDateTimeFromTime_t(conflictModTime));
|
||||
QString conflictFilePath = propagator()->getFilePath(conflictFileName);
|
||||
if (!FileSystem::rename(fn, conflictFilePath, &renameError)) {
|
||||
// If the rename fails, don't replace it.
|
||||
|
||||
// If the file is locked, we want to retry this sync when it
|
||||
@ -820,6 +842,35 @@ void PropagateDownloadFile::downloadFinished()
|
||||
return;
|
||||
}
|
||||
qCInfo(lcPropagateDownload) << "Created conflict file" << fn << "->" << conflictFileName;
|
||||
|
||||
// Create a new conflict record. To get the base etag, we need to read it from the db.
|
||||
ConflictRecord conflictRecord;
|
||||
conflictRecord.path = conflictFileName.toUtf8();
|
||||
conflictRecord.baseModtime = _item->_previousModtime;
|
||||
|
||||
SyncJournalFileRecord baseRecord;
|
||||
if (propagator()->_journal->getFileRecord(_item->_originalFile, &baseRecord) && baseRecord.isValid()) {
|
||||
conflictRecord.baseEtag = baseRecord._etag;
|
||||
conflictRecord.baseFileId = baseRecord._fileId;
|
||||
} else {
|
||||
// We might very well end up with no fileid/etag for new/new conflicts
|
||||
}
|
||||
|
||||
propagator()->_journal->setConflictRecord(conflictRecord);
|
||||
|
||||
// Create a new upload job if the new conflict file should be uploaded
|
||||
if (propagator()->account()->capabilities().uploadConflictFiles()) {
|
||||
SyncFileItemPtr conflictItem = SyncFileItemPtr(new SyncFileItem);
|
||||
conflictItem->_file = conflictFileName;
|
||||
conflictItem->_type = SyncFileItem::File;
|
||||
conflictItem->_direction = SyncFileItem::Up;
|
||||
conflictItem->_instruction = CSYNC_INSTRUCTION_NEW;
|
||||
conflictItem->_modtime = conflictModTime;
|
||||
conflictItem->_size = _item->_previousSize;
|
||||
ASSERT(_compositeParent);
|
||||
emit propagator()->newItem(conflictItem);
|
||||
_compositeParent->appendTask(conflictItem);
|
||||
}
|
||||
}
|
||||
|
||||
FileSystem::setModTime(_tmpFile.fileName(), _item->_modtime);
|
||||
@ -886,6 +937,11 @@ void PropagateDownloadFile::downloadFinished()
|
||||
// Get up to date information for the journal.
|
||||
_item->_size = FileSystem::getSize(fn);
|
||||
|
||||
// Maybe what we downloaded was a conflict file? If so, set a conflict record.
|
||||
// (the data was prepared in slotGetFinished above)
|
||||
if (_conflictRecord.isValid())
|
||||
propagator()->_journal->setConflictRecord(_conflictRecord);
|
||||
|
||||
updateMetadata(isConflict);
|
||||
}
|
||||
|
||||
|
||||
@ -202,6 +202,7 @@ private:
|
||||
QPointer<GETFileJob> _job;
|
||||
QFile _tmpFile;
|
||||
bool _deleteExisting;
|
||||
ConflictRecord _conflictRecord;
|
||||
|
||||
QElapsedTimer _stopwatch;
|
||||
};
|
||||
|
||||
@ -605,6 +605,19 @@ QMap<QByteArray, QByteArray> PropagateUploadFileCommon::headers()
|
||||
// csync_owncloud.c's owncloud_file_id always strips the quotes.
|
||||
headers["If-Match"] = '"' + _item->_etag + '"';
|
||||
}
|
||||
|
||||
// Set up a conflict file header pointing to the original file
|
||||
auto conflictRecord = propagator()->_journal->conflictRecord(_item->_file.toUtf8());
|
||||
if (conflictRecord.isValid()) {
|
||||
headers["OC-Conflict"] = "1";
|
||||
if (!conflictRecord.baseFileId.isEmpty())
|
||||
headers["OC-ConflictBaseFileId"] = conflictRecord.baseFileId;
|
||||
if (conflictRecord.baseModtime != -1)
|
||||
headers["OC-ConflictBaseMtime"] = QByteArray::number(conflictRecord.baseModtime);
|
||||
if (!conflictRecord.baseEtag.isEmpty())
|
||||
headers["OC-ConflictBaseEtag"] = conflictRecord.baseEtag;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
|
||||
@ -329,6 +329,45 @@ void SyncEngine::deleteStaleErrorBlacklistEntries(const SyncFileItemVector &sync
|
||||
_journal->deleteStaleErrorBlacklistEntries(blacklist_file_paths);
|
||||
}
|
||||
|
||||
void SyncEngine::conflictRecordMaintenance()
|
||||
{
|
||||
// Remove stale conflict entries from the database
|
||||
// by checking which files still exist and removing the
|
||||
// missing ones.
|
||||
auto conflictRecordPaths = _journal->conflictRecordPaths();
|
||||
for (const auto &path : conflictRecordPaths) {
|
||||
auto fsPath = _propagator->getFilePath(QString::fromUtf8(path));
|
||||
if (!QFileInfo(fsPath).exists()) {
|
||||
_journal->deleteConflictRecord(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Did the sync see any conflict files that don't yet have records?
|
||||
// If so, add them now.
|
||||
//
|
||||
// This happens when the conflicts table is new or when conflict files
|
||||
// are downlaoded but the server doesn't send conflict headers.
|
||||
for (const auto &path : _seenFiles) {
|
||||
if (!Utility::isConflictFile(path))
|
||||
continue;
|
||||
|
||||
auto bapath = path.toUtf8();
|
||||
if (!conflictRecordPaths.contains(bapath)) {
|
||||
ConflictRecord record;
|
||||
record.path = bapath;
|
||||
|
||||
// Determine fileid of target file
|
||||
auto basePath = Utility::conflictFileBaseName(bapath);
|
||||
SyncJournalFileRecord baseRecord;
|
||||
if (_journal->getFileRecord(basePath, &baseRecord) && baseRecord.isValid()) {
|
||||
record.baseFileId = baseRecord._fileId;
|
||||
}
|
||||
|
||||
_journal->setConflictRecord(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int SyncEngine::treewalkLocal(csync_file_stat_t *file, csync_file_stat_t *other, void *data)
|
||||
{
|
||||
return static_cast<SyncEngine *>(data)->treewalkFile(file, other, false);
|
||||
@ -483,7 +522,7 @@ int SyncEngine::treewalkFile(csync_file_stat_t *file, csync_file_stat_t *other,
|
||||
break;
|
||||
case CSYNC_STATUS_INDIVIDUAL_IS_CONFLICT_FILE:
|
||||
item->_status = SyncFileItem::Conflict;
|
||||
if (Utility::shouldUploadConflictFiles()) {
|
||||
if (account()->capabilities().uploadConflictFiles()) {
|
||||
// For uploaded conflict files, files with no action performed on them should
|
||||
// be displayed: but we mustn't overwrite the instruction if something happens
|
||||
// to the file!
|
||||
@ -552,7 +591,7 @@ int SyncEngine::treewalkFile(csync_file_stat_t *file, csync_file_stat_t *other,
|
||||
_hasNoneFiles = true;
|
||||
}
|
||||
// Put none-instruction conflict files into the syncfileitem list
|
||||
if (Utility::shouldUploadConflictFiles()
|
||||
if (account()->capabilities().uploadConflictFiles()
|
||||
&& file->error_status == CSYNC_STATUS_INDIVIDUAL_IS_CONFLICT_FILE
|
||||
&& item->_instruction == CSYNC_INSTRUCTION_IGNORE) {
|
||||
break;
|
||||
@ -668,8 +707,6 @@ int SyncEngine::treewalkFile(csync_file_stat_t *file, csync_file_stat_t *other,
|
||||
checkErrorBlacklisting(*item);
|
||||
}
|
||||
|
||||
_progressInfo->adjustTotalsForFile(*item);
|
||||
|
||||
_needsUpdate = true;
|
||||
|
||||
if (other) {
|
||||
@ -677,6 +714,7 @@ int SyncEngine::treewalkFile(csync_file_stat_t *file, csync_file_stat_t *other,
|
||||
item->_previousSize = other->size;
|
||||
}
|
||||
|
||||
slotNewItem(item);
|
||||
_syncItemMap.insert(key, item);
|
||||
return re;
|
||||
}
|
||||
@ -805,6 +843,9 @@ void SyncEngine::startSync()
|
||||
// database creation error!
|
||||
}
|
||||
|
||||
_csync_ctx->upload_conflict_files = _account->capabilities().uploadConflictFiles();
|
||||
_excludedFiles->setExcludeConflictFiles(!_account->capabilities().uploadConflictFiles());
|
||||
|
||||
_csync_ctx->read_remote_from_db = true;
|
||||
_lastLocalDiscoveryStyle = _csync_ctx->local_discovery_style;
|
||||
|
||||
@ -894,6 +935,11 @@ void SyncEngine::slotRootEtagReceived(const QString &e)
|
||||
}
|
||||
}
|
||||
|
||||
void SyncEngine::slotNewItem(const SyncFileItemPtr &item)
|
||||
{
|
||||
_progressInfo->adjustTotalsForFile(*item);
|
||||
}
|
||||
|
||||
void SyncEngine::slotDiscoveryJobFinished(int discoveryResult)
|
||||
{
|
||||
if (discoveryResult < 0) {
|
||||
@ -1051,6 +1097,7 @@ void SyncEngine::slotDiscoveryJobFinished(int discoveryResult)
|
||||
connect(_propagator.data(), &OwncloudPropagator::touchedFile, this, &SyncEngine::slotAddTouchedFile);
|
||||
connect(_propagator.data(), &OwncloudPropagator::insufficientLocalStorage, this, &SyncEngine::slotInsufficientLocalStorage);
|
||||
connect(_propagator.data(), &OwncloudPropagator::insufficientRemoteStorage, this, &SyncEngine::slotInsufficientRemoteStorage);
|
||||
connect(_propagator.data(), &OwncloudPropagator::newItem, this, &SyncEngine::slotNewItem);
|
||||
|
||||
// apply the network limits to the propagator
|
||||
setNetworkLimits(_uploadLimit, _downloadLimit);
|
||||
@ -1116,11 +1163,12 @@ void SyncEngine::slotFinished(bool success)
|
||||
_journal->setDataFingerprint(_discoveryMainThread->_dataFingerprint);
|
||||
}
|
||||
|
||||
// emit the treewalk results.
|
||||
if (!_journal->postSyncCleanup(_seenFiles, _temporarilyUnavailablePaths)) {
|
||||
qCDebug(lcEngine) << "Cleaning of synced ";
|
||||
}
|
||||
|
||||
conflictRecordMaintenance();
|
||||
|
||||
_journal->commit("All Finished.", false);
|
||||
|
||||
// Send final progress information even if no
|
||||
|
||||
@ -161,6 +161,15 @@ signals:
|
||||
private slots:
|
||||
void slotFolderDiscovered(bool local, const QString &folder);
|
||||
void slotRootEtagReceived(const QString &);
|
||||
|
||||
/** Called when a SyncFileItem gets accepted for a sync.
|
||||
*
|
||||
* Mostly done in initial creation inside treewalkFile but
|
||||
* can also be called via the propagator for items that are
|
||||
* created during propagation.
|
||||
*/
|
||||
void slotNewItem(const SyncFileItemPtr &item);
|
||||
|
||||
void slotItemCompleted(const SyncFileItemPtr &item);
|
||||
void slotFinished(bool success);
|
||||
void slotProgress(const SyncFileItem &item, quint64 curent);
|
||||
@ -200,6 +209,9 @@ private:
|
||||
// Removes stale error blacklist entries from the journal.
|
||||
void deleteStaleErrorBlacklistEntries(const SyncFileItemVector &syncItems);
|
||||
|
||||
// Removes stale and adds missing conflict records after sync
|
||||
void conflictRecordMaintenance();
|
||||
|
||||
// cleanup and emit the finished signal
|
||||
void finalize(bool success);
|
||||
|
||||
|
||||
@ -47,6 +47,7 @@ owncloud_add_test(FileSystem "")
|
||||
owncloud_add_test(Utility "")
|
||||
owncloud_add_test(SyncEngine "syncenginetestutils.h")
|
||||
owncloud_add_test(SyncMove "syncenginetestutils.h")
|
||||
owncloud_add_test(SyncConflict "syncenginetestutils.h")
|
||||
owncloud_add_test(SyncFileStatusTracker "syncenginetestutils.h")
|
||||
owncloud_add_test(ChunkingNg "syncenginetestutils.h")
|
||||
owncloud_add_test(UploadReset "syncenginetestutils.h")
|
||||
|
||||
351
test/testsyncconflict.cpp
Normal file
351
test/testsyncconflict.cpp
Normal file
@ -0,0 +1,351 @@
|
||||
/*
|
||||
* This software is in the public domain, furnished "as is", without technical
|
||||
* support, and with no warranty, express or implied, as to its usefulness for
|
||||
* any purpose.
|
||||
*
|
||||
*/
|
||||
|
||||
#include <QtTest>
|
||||
#include "syncenginetestutils.h"
|
||||
#include <syncengine.h>
|
||||
|
||||
using namespace OCC;
|
||||
|
||||
SyncFileItemPtr findItem(const QSignalSpy &spy, const QString &path)
|
||||
{
|
||||
for (const QList<QVariant> &args : spy) {
|
||||
auto item = args[0].value<SyncFileItemPtr>();
|
||||
if (item->destination() == path)
|
||||
return item;
|
||||
}
|
||||
return SyncFileItemPtr(new SyncFileItem);
|
||||
}
|
||||
|
||||
bool itemSuccessful(const QSignalSpy &spy, const QString &path, const csync_instructions_e instr)
|
||||
{
|
||||
auto item = findItem(spy, path);
|
||||
return item->_status == SyncFileItem::Success && item->_instruction == instr;
|
||||
}
|
||||
|
||||
bool itemConflict(const QSignalSpy &spy, const QString &path)
|
||||
{
|
||||
auto item = findItem(spy, path);
|
||||
return item->_status == SyncFileItem::Conflict && item->_instruction == CSYNC_INSTRUCTION_CONFLICT;
|
||||
}
|
||||
|
||||
bool itemSuccessfulMove(const QSignalSpy &spy, const QString &path)
|
||||
{
|
||||
return itemSuccessful(spy, path, CSYNC_INSTRUCTION_RENAME);
|
||||
}
|
||||
|
||||
QStringList findConflicts(const FileInfo &dir)
|
||||
{
|
||||
QStringList conflicts;
|
||||
for (const auto &item : dir.children) {
|
||||
if (item.name.contains("conflict")) {
|
||||
conflicts.append(item.path());
|
||||
}
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
bool expectAndWipeConflict(FileModifier &local, FileInfo state, const QString path)
|
||||
{
|
||||
PathComponents pathComponents(path);
|
||||
auto base = state.find(pathComponents.parentDirComponents());
|
||||
if (!base)
|
||||
return false;
|
||||
for (const auto &item : base->children) {
|
||||
if (item.name.startsWith(pathComponents.fileName()) && item.name.contains("_conflict")) {
|
||||
local.remove(item.path());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
class TestSyncConflict : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void testNoUpload()
|
||||
{
|
||||
FakeFolder fakeFolder{ FileInfo::A12_B12_C12_S12() };
|
||||
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
|
||||
|
||||
fakeFolder.localModifier().setContents("A/a1", 'L');
|
||||
fakeFolder.remoteModifier().setContents("A/a1", 'R');
|
||||
fakeFolder.localModifier().appendByte("A/a2");
|
||||
fakeFolder.remoteModifier().appendByte("A/a2");
|
||||
fakeFolder.remoteModifier().appendByte("A/a2");
|
||||
QVERIFY(fakeFolder.syncOnce());
|
||||
QVERIFY(expectAndWipeConflict(fakeFolder.localModifier(), fakeFolder.currentLocalState(), "A/a1"));
|
||||
QVERIFY(expectAndWipeConflict(fakeFolder.localModifier(), fakeFolder.currentLocalState(), "A/a2"));
|
||||
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
|
||||
}
|
||||
|
||||
void testUploadAfterDownload()
|
||||
{
|
||||
FakeFolder fakeFolder{ FileInfo::A12_B12_C12_S12() };
|
||||
fakeFolder.syncEngine().account()->setCapabilities({ { "uploadConflictFiles", true } });
|
||||
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
|
||||
|
||||
QMap<QByteArray, QString> conflictMap;
|
||||
fakeFolder.setServerOverride([&](QNetworkAccessManager::Operation op, const QNetworkRequest &request, QIODevice *) -> QNetworkReply * {
|
||||
if (op == QNetworkAccessManager::PutOperation) {
|
||||
auto baseFileId = request.rawHeader("OC-ConflictBaseFileId");
|
||||
if (!baseFileId.isEmpty()) {
|
||||
auto components = request.url().toString().split('/');
|
||||
QString conflictFile = components.mid(components.size() - 2).join('/');
|
||||
conflictMap[baseFileId] = conflictFile;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
});
|
||||
|
||||
fakeFolder.localModifier().setContents("A/a1", 'L');
|
||||
fakeFolder.remoteModifier().setContents("A/a1", 'R');
|
||||
fakeFolder.localModifier().appendByte("A/a2");
|
||||
fakeFolder.remoteModifier().appendByte("A/a2");
|
||||
fakeFolder.remoteModifier().appendByte("A/a2");
|
||||
QVERIFY(fakeFolder.syncOnce());
|
||||
auto local = fakeFolder.currentLocalState();
|
||||
auto remote = fakeFolder.currentRemoteState();
|
||||
QCOMPARE(local, remote);
|
||||
|
||||
auto a1FileId = fakeFolder.remoteModifier().find("A/a1")->fileId;
|
||||
auto a2FileId = fakeFolder.remoteModifier().find("A/a2")->fileId;
|
||||
QVERIFY(conflictMap.contains(a1FileId));
|
||||
QVERIFY(conflictMap.contains(a2FileId));
|
||||
QCOMPARE(conflictMap.size(), 2);
|
||||
QCOMPARE(Utility::conflictFileBaseName(conflictMap[a1FileId].toUtf8()), QByteArray("A/a1"));
|
||||
|
||||
QCOMPARE(remote.find(conflictMap[a1FileId])->contentChar, 'L');
|
||||
QCOMPARE(remote.find("A/a1")->contentChar, 'R');
|
||||
|
||||
QCOMPARE(remote.find(conflictMap[a2FileId])->size, 5);
|
||||
QCOMPARE(remote.find("A/a2")->size, 6);
|
||||
}
|
||||
|
||||
void testSeparateUpload()
|
||||
{
|
||||
FakeFolder fakeFolder{ FileInfo::A12_B12_C12_S12() };
|
||||
fakeFolder.syncEngine().account()->setCapabilities({ { "uploadConflictFiles", true } });
|
||||
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
|
||||
|
||||
QMap<QByteArray, QString> conflictMap;
|
||||
fakeFolder.setServerOverride([&](QNetworkAccessManager::Operation op, const QNetworkRequest &request, QIODevice *) -> QNetworkReply * {
|
||||
if (op == QNetworkAccessManager::PutOperation) {
|
||||
auto baseFileId = request.rawHeader("OC-ConflictBaseFileId");
|
||||
if (!baseFileId.isEmpty()) {
|
||||
auto components = request.url().toString().split('/');
|
||||
QString conflictFile = components.mid(components.size() - 2).join('/');
|
||||
conflictMap[baseFileId] = conflictFile;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
});
|
||||
|
||||
// Explicitly add a conflict file to simulate the case where the upload of the
|
||||
// file didn't finish in the same sync run that the conflict was created.
|
||||
// To do that we need to create a mock conflict record.
|
||||
auto a1FileId = fakeFolder.remoteModifier().find("A/a1")->fileId;
|
||||
QString conflictName = QLatin1String("A/a1_conflict-me-1234");
|
||||
fakeFolder.localModifier().insert(conflictName, 64, 'L');
|
||||
ConflictRecord conflictRecord;
|
||||
conflictRecord.path = conflictName.toUtf8();
|
||||
conflictRecord.baseFileId = a1FileId;
|
||||
fakeFolder.syncJournal().setConflictRecord(conflictRecord);
|
||||
QVERIFY(fakeFolder.syncOnce());
|
||||
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
|
||||
QCOMPARE(conflictMap.size(), 1);
|
||||
QCOMPARE(conflictMap[a1FileId], conflictName);
|
||||
QCOMPARE(fakeFolder.currentRemoteState().find(conflictMap[a1FileId])->contentChar, 'L');
|
||||
conflictMap.clear();
|
||||
|
||||
// Now the user can locally alter the conflict file and it will be uploaded
|
||||
// as usual.
|
||||
fakeFolder.localModifier().setContents(conflictName, 'P');
|
||||
QVERIFY(fakeFolder.syncOnce());
|
||||
QCOMPARE(conflictMap.size(), 1);
|
||||
QCOMPARE(conflictMap[a1FileId], conflictName);
|
||||
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
|
||||
conflictMap.clear();
|
||||
|
||||
// Similarly, remote modifications of conflict files get propagated downwards
|
||||
fakeFolder.remoteModifier().setContents(conflictName, 'Q');
|
||||
QVERIFY(fakeFolder.syncOnce());
|
||||
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
|
||||
QVERIFY(conflictMap.isEmpty());
|
||||
|
||||
// Conflict files for conflict files!
|
||||
auto a1ConflictFileId = fakeFolder.remoteModifier().find(conflictName)->fileId;
|
||||
fakeFolder.remoteModifier().appendByte(conflictName);
|
||||
fakeFolder.remoteModifier().appendByte(conflictName);
|
||||
fakeFolder.localModifier().appendByte(conflictName);
|
||||
QVERIFY(fakeFolder.syncOnce());
|
||||
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
|
||||
QCOMPARE(conflictMap.size(), 1);
|
||||
QVERIFY(conflictMap.contains(a1ConflictFileId));
|
||||
QCOMPARE(fakeFolder.currentRemoteState().find(conflictName)->size, 66);
|
||||
QCOMPARE(fakeFolder.currentRemoteState().find(conflictMap[a1ConflictFileId])->size, 65);
|
||||
conflictMap.clear();
|
||||
}
|
||||
|
||||
// What happens if we download a conflict file? Is the metadata set up correctly?
|
||||
void testDownloadingConflictFile()
|
||||
{
|
||||
FakeFolder fakeFolder{ FileInfo::A12_B12_C12_S12() };
|
||||
fakeFolder.syncEngine().account()->setCapabilities({ { "uploadConflictFiles", true } });
|
||||
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
|
||||
|
||||
// With no headers from the server
|
||||
fakeFolder.remoteModifier().insert("A/a1_conflict-1234");
|
||||
QVERIFY(fakeFolder.syncOnce());
|
||||
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
|
||||
auto conflictRecord = fakeFolder.syncJournal().conflictRecord("A/a1_conflict-1234");
|
||||
QVERIFY(conflictRecord.isValid());
|
||||
QCOMPARE(conflictRecord.baseFileId, fakeFolder.remoteModifier().find("A/a1")->fileId);
|
||||
|
||||
// Now with server headers
|
||||
QObject parent;
|
||||
auto a2FileId = fakeFolder.remoteModifier().find("A/a2")->fileId;
|
||||
fakeFolder.setServerOverride([&](QNetworkAccessManager::Operation op, const QNetworkRequest &request, QIODevice *) -> QNetworkReply * {
|
||||
if (op == QNetworkAccessManager::GetOperation) {
|
||||
auto reply = new FakeGetReply(fakeFolder.remoteModifier(), op, request, &parent);
|
||||
reply->setRawHeader("OC-Conflict", "1");
|
||||
reply->setRawHeader("OC-ConflictBaseFileId", a2FileId);
|
||||
reply->setRawHeader("OC-ConflictBaseMtime", "1234");
|
||||
reply->setRawHeader("OC-ConflictBaseEtag", "etag");
|
||||
return reply;
|
||||
}
|
||||
return nullptr;
|
||||
});
|
||||
fakeFolder.remoteModifier().insert("A/really-a-conflict"); // doesn't look like a conflict, but headers say it is
|
||||
QVERIFY(fakeFolder.syncOnce());
|
||||
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
|
||||
conflictRecord = fakeFolder.syncJournal().conflictRecord("A/really-a-conflict");
|
||||
QVERIFY(conflictRecord.isValid());
|
||||
QCOMPARE(conflictRecord.baseFileId, a2FileId);
|
||||
QCOMPARE(conflictRecord.baseModtime, 1234);
|
||||
QCOMPARE(conflictRecord.baseEtag, QByteArray("etag"));
|
||||
}
|
||||
|
||||
// Check that conflict records are removed when the file is gone
|
||||
void testConflictRecordRemoval1()
|
||||
{
|
||||
FakeFolder fakeFolder{ FileInfo::A12_B12_C12_S12() };
|
||||
fakeFolder.syncEngine().account()->setCapabilities({ { "uploadConflictFiles", true } });
|
||||
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
|
||||
|
||||
// Make conflict records
|
||||
ConflictRecord conflictRecord;
|
||||
conflictRecord.path = "A/a1";
|
||||
fakeFolder.syncJournal().setConflictRecord(conflictRecord);
|
||||
conflictRecord.path = "A/a2";
|
||||
fakeFolder.syncJournal().setConflictRecord(conflictRecord);
|
||||
|
||||
// A nothing-to-sync keeps them alive
|
||||
QVERIFY(fakeFolder.syncOnce());
|
||||
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
|
||||
QVERIFY(fakeFolder.syncJournal().conflictRecord("A/a1").isValid());
|
||||
QVERIFY(fakeFolder.syncJournal().conflictRecord("A/a2").isValid());
|
||||
|
||||
// When the file is removed, the record is removed too
|
||||
fakeFolder.localModifier().remove("A/a2");
|
||||
QVERIFY(fakeFolder.syncOnce());
|
||||
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
|
||||
QVERIFY(fakeFolder.syncJournal().conflictRecord("A/a1").isValid());
|
||||
QVERIFY(!fakeFolder.syncJournal().conflictRecord("A/a2").isValid());
|
||||
}
|
||||
|
||||
// Same test, but with uploadConflictFiles == false
|
||||
void testConflictRecordRemoval2()
|
||||
{
|
||||
FakeFolder fakeFolder{ FileInfo::A12_B12_C12_S12() };
|
||||
fakeFolder.syncEngine().account()->setCapabilities({ { "uploadConflictFiles", false } });
|
||||
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
|
||||
|
||||
// Create two conflicts
|
||||
fakeFolder.localModifier().appendByte("A/a1");
|
||||
fakeFolder.localModifier().appendByte("A/a1");
|
||||
fakeFolder.remoteModifier().appendByte("A/a1");
|
||||
fakeFolder.localModifier().appendByte("A/a2");
|
||||
fakeFolder.localModifier().appendByte("A/a2");
|
||||
fakeFolder.remoteModifier().appendByte("A/a2");
|
||||
QVERIFY(fakeFolder.syncOnce());
|
||||
|
||||
auto conflicts = findConflicts(fakeFolder.currentLocalState().children["A"]);
|
||||
QByteArray a1conflict;
|
||||
QByteArray a2conflict;
|
||||
for (const auto & conflict : conflicts) {
|
||||
if (conflict.contains("a1"))
|
||||
a1conflict = conflict.toUtf8();
|
||||
if (conflict.contains("a2"))
|
||||
a2conflict = conflict.toUtf8();
|
||||
}
|
||||
|
||||
// A nothing-to-sync keeps them alive
|
||||
QVERIFY(fakeFolder.syncOnce());
|
||||
QVERIFY(fakeFolder.syncJournal().conflictRecord(a1conflict).isValid());
|
||||
QVERIFY(fakeFolder.syncJournal().conflictRecord(a2conflict).isValid());
|
||||
|
||||
// When the file is removed, the record is removed too
|
||||
fakeFolder.localModifier().remove(a2conflict);
|
||||
QVERIFY(fakeFolder.syncOnce());
|
||||
QVERIFY(fakeFolder.syncJournal().conflictRecord(a1conflict).isValid());
|
||||
QVERIFY(!fakeFolder.syncJournal().conflictRecord(a2conflict).isValid());
|
||||
}
|
||||
|
||||
void testConflictFileBaseName_data()
|
||||
{
|
||||
QTest::addColumn<QString>("input");
|
||||
QTest::addColumn<QString>("output");
|
||||
|
||||
QTest::newRow("")
|
||||
<< "a/b/foo"
|
||||
<< "";
|
||||
QTest::newRow("")
|
||||
<< "a/b/foo.txt"
|
||||
<< "";
|
||||
QTest::newRow("")
|
||||
<< "a/b/foo_conflict"
|
||||
<< "";
|
||||
QTest::newRow("")
|
||||
<< "a/b/foo_conflict.txt"
|
||||
<< "";
|
||||
|
||||
QTest::newRow("")
|
||||
<< "a/b/foo_conflict-123.txt"
|
||||
<< "a/b/foo.txt";
|
||||
QTest::newRow("")
|
||||
<< "a/b/foo_conflict-foo-123.txt"
|
||||
<< "a/b/foo.txt";
|
||||
|
||||
QTest::newRow("")
|
||||
<< "a/b/foo_conflict-123"
|
||||
<< "a/b/foo";
|
||||
QTest::newRow("")
|
||||
<< "a/b/foo_conflict-foo-123"
|
||||
<< "a/b/foo";
|
||||
|
||||
// double conflict files
|
||||
QTest::newRow("")
|
||||
<< "a/b/foo_conflict-123_conflict-456.txt"
|
||||
<< "a/b/foo_conflict-123.txt";
|
||||
QTest::newRow("")
|
||||
<< "a/b/foo_conflict-foo-123_conflict-bar-456.txt"
|
||||
<< "a/b/foo_conflict-foo-123.txt";
|
||||
}
|
||||
|
||||
void testConflictFileBaseName()
|
||||
{
|
||||
QFETCH(QString, input);
|
||||
QFETCH(QString, output);
|
||||
QCOMPARE(Utility::conflictFileBaseName(input.toUtf8()), output.toUtf8());
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(TestSyncConflict)
|
||||
#include "testsyncconflict.moc"
|
||||
@ -183,6 +183,28 @@ private slots:
|
||||
QCOMPARE(record.numericFileId(), QByteArray("123456789"));
|
||||
}
|
||||
|
||||
void testConflictRecord()
|
||||
{
|
||||
ConflictRecord record;
|
||||
record.path = "abc";
|
||||
record.baseFileId = "def";
|
||||
record.baseModtime = 1234;
|
||||
record.baseEtag = "ghi";
|
||||
|
||||
QVERIFY(!_db.conflictRecord(record.path).isValid());
|
||||
|
||||
_db.setConflictRecord(record);
|
||||
auto newRecord = _db.conflictRecord(record.path);
|
||||
QVERIFY(newRecord.isValid());
|
||||
QCOMPARE(newRecord.path, record.path);
|
||||
QCOMPARE(newRecord.baseFileId, record.baseFileId);
|
||||
QCOMPARE(newRecord.baseModtime, record.baseModtime);
|
||||
QCOMPARE(newRecord.baseEtag, record.baseEtag);
|
||||
|
||||
_db.deleteConflictRecord(record.path);
|
||||
QVERIFY(!_db.conflictRecord(record.path).isValid());
|
||||
}
|
||||
|
||||
private:
|
||||
SyncJournalDb _db;
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user