diff options
-rw-r--r-- | src/common/filesystembase.cpp | 21 | ||||
-rw-r--r-- | src/common/filesystembase.h | 5 | ||||
-rw-r--r-- | src/common/syncjournaldb.cpp | 99 | ||||
-rw-r--r-- | src/common/syncjournaldb.h | 19 | ||||
-rw-r--r-- | src/common/syncjournalfilerecord.h | 37 | ||||
-rw-r--r-- | src/common/utility.cpp | 60 | ||||
-rw-r--r-- | src/common/utility.h | 16 | ||||
-rw-r--r-- | src/csync/csync_exclude.cpp | 19 | ||||
-rw-r--r-- | src/csync/csync_exclude.h | 9 | ||||
-rw-r--r-- | src/csync/csync_private.h | 2 | ||||
-rw-r--r-- | src/csync/csync_update.cpp | 2 | ||||
-rw-r--r-- | src/libsync/capabilities.cpp | 11 | ||||
-rw-r--r-- | src/libsync/capabilities.h | 5 | ||||
-rw-r--r-- | src/libsync/owncloudpropagator.cpp | 27 | ||||
-rw-r--r-- | src/libsync/owncloudpropagator.h | 23 | ||||
-rw-r--r-- | src/libsync/propagatedownload.cpp | 66 | ||||
-rw-r--r-- | src/libsync/propagatedownload.h | 1 | ||||
-rw-r--r-- | src/libsync/propagateupload.cpp | 13 | ||||
-rw-r--r-- | src/libsync/syncengine.cpp | 58 | ||||
-rw-r--r-- | src/libsync/syncengine.h | 12 | ||||
-rw-r--r-- | test/CMakeLists.txt | 1 | ||||
-rw-r--r-- | test/testsyncconflict.cpp | 351 | ||||
-rw-r--r-- | test/testsyncjournaldb.cpp | 22 |
23 files changed, 792 insertions, 87 deletions
diff --git a/src/common/filesystembase.cpp b/src/common/filesystembase.cpp index 1d95e3918..8de47312e 100644 --- a/src/common/filesystembase.cpp +++ b/src/common/filesystembase.cpp @@ -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 diff --git a/src/common/filesystembase.h b/src/common/filesystembase.h index 9568dac12..e7c7672b1 100644 --- a/src/common/filesystembase.h +++ b/src/common/filesystembase.h @@ -132,11 +132,6 @@ namespace FileSystem { #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) */ bool OCSYNC_EXPORT isFileLocked(const QString &fileName); diff --git a/src/common/syncjournaldb.cpp b/src/common/syncjournaldb.cpp index b2d348af8..a40149d3a 100644 --- a/src/common/syncjournaldb.cpp +++ b/src/common/syncjournaldb.cpp @@ -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); diff --git a/src/common/syncjournaldb.h b/src/common/syncjournaldb.h index dc650ffa3..aa30d349c 100644 --- a/src/common/syncjournaldb.h +++ b/src/common/syncjournaldb.h @@ -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 diff --git a/src/common/syncjournalfilerecord.h b/src/common/syncjournalfilerecord.h index 76fd4cdbe..b09006365 100644 --- a/src/common/syncjournalfilerecord.h +++ b/src/common/syncjournalfilerecord.h @@ -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 diff --git a/src/common/utility.cpp b/src/common/utility.cpp index bdf603515..29308ea84 100644 --- a/src/common/utility.cpp +++ b/src/common/utility.cpp @@ -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 std::strstr(bname, "_conflict-"); +} - return false; +bool Utility::isConflictFile(const QString &name) +{ + auto bname = name.midRef(name.lastIndexOf('/') + 1); + return bname.contains("_conflict-", Utility::fsCasePreserving() ? Qt::CaseInsensitive : Qt::CaseSensitive); } -bool Utility::shouldUploadConflictFiles() +QByteArray Utility::conflictFileBaseName(const QByteArray &conflictName) { - static bool uploadConflictFiles = qEnvironmentVariableIntValue("OWNCLOUD_UPLOAD_CONFLICT_FILES") != 0; - return uploadConflictFiles; + // 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 diff --git a/src/common/utility.h b/src/common/utility.h index 666e44cfd..7247673ce 100644 --- a/src/common/utility.h +++ b/src/common/utility.h @@ -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 + * + * Will return an empty string if it's not a conflict file. * - * Experimental! Real feature planned for 2.5. + * 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); diff --git a/src/csync/csync_exclude.cpp b/src/csync/csync_exclude.cpp index 762a8eae4..f113a354e 100644 --- a/src/csync/csync_exclude.cpp +++ b/src/csync/csync_exclude.cpp @@ -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()) diff --git a/src/csync/csync_exclude.h b/src/csync/csync_exclude.h index 9f1c544d7..6f53e64a0 100644 --- a/src/csync/csync_exclude.h +++ b/src/csync/csync_exclude.h @@ -77,6 +77,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. * * @param filePath the absolute path to the file @@ -191,6 +198,8 @@ private: QRegularExpression _fullRegexFile; QRegularExpression _fullRegexDir; + bool _excludeConflictFiles = true; + friend class ExcludedFilesTest; }; diff --git a/src/csync/csync_private.h b/src/csync/csync_private.h index 9a0276507..5463b7e40 100644 --- a/src/csync/csync_private.h +++ b/src/csync/csync_private.h @@ -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(); diff --git a/src/csync/csync_update.cpp b/src/csync/csync_update.cpp index ad58ef5b6..2a96faeff 100644 --- a/src/csync/csync_update.cpp +++ b/src/csync/csync_update.cpp @@ -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; } diff --git a/src/libsync/capabilities.cpp b/src/libsync/capabilities.cpp index 333410373..08c1e45ff 100644 --- a/src/libsync/capabilities.cpp +++ b/src/libsync/capabilities.cpp @@ -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(); +} } diff --git a/src/libsync/capabilities.h b/src/libsync/capabilities.h index f9f6614a4..63a59d6c0 100644 --- a/src/libsync/capabilities.h +++ b/src/libsync/capabilities.h @@ -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; }; diff --git a/src/libsync/owncloudpropagator.cpp b/src/libsync/owncloudpropagator.cpp index e1d081141..d960b4a2d 100644 --- a/src/libsync/owncloudpropagator.cpp +++ b/src/libsync/owncloudpropagator.cpp @@ -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 diff --git a/src/libsync/owncloudpropagator.h b/src/libsync/owncloudpropagator.h index 062b3825e..4abe8eb03 100644 --- a/src/libsync/owncloudpropagator.h +++ b/src/libsync/owncloudpropagator.h @@ -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); diff --git a/src/libsync/propagatedownload.cpp b/src/libsync/propagatedownload.cpp index 2efc537ea..2f37ca304 100644 --- a/src/libsync/propagatedownload.cpp +++ b/src/libsync/propagatedownload.cpp @@ -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); } diff --git a/src/libsync/propagatedownload.h b/src/libsync/propagatedownload.h index 4b8988b65..632cb0c79 100644 --- a/src/libsync/propagatedownload.h +++ b/src/libsync/propagatedownload.h @@ -202,6 +202,7 @@ private: QPointer<GETFileJob> _job; QFile _tmpFile; bool _deleteExisting; + ConflictRecord _conflictRecord; QElapsedTimer _stopwatch; }; diff --git a/src/libsync/propagateupload.cpp b/src/libsync/propagateupload.cpp index 6de6316af..1b5bcc0c8 100644 --- a/src/libsync/propagateupload.cpp +++ b/src/libsync/propagateupload.cpp @@ -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; } diff --git a/src/libsync/syncengine.cpp b/src/libsync/syncengine.cpp index 5e387921c..31338f4f6 100644 --- a/src/libsync/syncengine.cpp +++ b/src/libsync/syncengine.cpp @@ -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 diff --git a/src/libsync/syncengine.h b/src/libsync/syncengine.h index 8be936e38..5ae335b42 100644 --- a/src/libsync/syncengine.h +++ b/src/libsync/syncengine.h @@ -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); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index b62a6ee01..b23678543 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -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") diff --git a/test/testsyncconflict.cpp b/test/testsyncconflict.cpp new file mode 100644 index 000000000..0a15bb9e9 --- /dev/null +++ b/test/testsyncconflict.cpp @@ -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" diff --git a/test/testsyncjournaldb.cpp b/test/testsyncjournaldb.cpp index 389dcec5d..389330ffa 100644 --- a/test/testsyncjournaldb.cpp +++ b/test/testsyncjournaldb.cpp @@ -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; }; |