/* * Copyright (C) by Olivier Goffart * Copyright (C) by Klaas Freitag * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License * for more details. */ #include "owncloudpropagator.h" #include "account.h" #include "common/asserts.h" #include "common/syncjournaldb.h" #include "common/syncjournalfilerecord.h" #include "common/utility.h" #include "discoveryphase.h" #include "filesystem.h" #include "propagatedownload.h" #include "propagateremotedelete.h" #include "propagateremotemkdir.h" #include "propagateremotemove.h" #include "propagateupload.h" #include "propagateuploadtus.h" #include "propagatorjobs.h" #ifdef Q_OS_WIN #include "common/utility_win.h" #endif #include #include #include #include #include #include #include using namespace std::chrono_literals; namespace OCC { Q_LOGGING_CATEGORY(lcPropagator, "sync.propagator", QtInfoMsg) Q_LOGGING_CATEGORY(lcDirectory, "sync.propagator.directory", QtInfoMsg) namespace { std::chrono::seconds getMinBlacklistTime = 5s; std::chrono::seconds getMaxBlacklistTime = 24h; } qint64 criticalFreeSpaceLimit() { return qBound(0LL, 50 * 1000 * 1000LL, freeSpaceLimit()); } qint64 freeSpaceLimit() { return 250 * 1000 * 1000LL; } OwncloudPropagator::~OwncloudPropagator() { } int OwncloudPropagator::maximumActiveTransferJob() { if (_bandwidthManager || !_syncOptions._parallelNetworkJobs()) { // disable parallelism when there is a network limit. return 1; } return std::max(3, qCeil(_syncOptions._parallelNetworkJobs() / 2.)); } /* The maximum number of active jobs in parallel */ int OwncloudPropagator::hardMaximumActiveJob() { if (!_syncOptions._parallelNetworkJobs()) return 1; return _syncOptions._parallelNetworkJobs(); } PropagateItemJob::~PropagateItemJob() { if (auto p = propagator()) { // Normally, every job should clean itself from the _activeJobList. So this should not be // needed. But if a job has a bug or is deleted before the network jobs signal get received, // we might risk end up with dangling pointer in the list which may cause crashes. p->_activeJobList.removeAll(this); } } bool PropagateItemJob::scheduleSelfOrChild() { if (state() != NotYetStarted) { return false; } qCInfo(lcPropagator) << u"Starting propagation of" << _item << u"by" << this; setState(Running); if (thread() != QApplication::instance()->thread()) { QMetaObject::invokeMethod(this, &PropagateItemJob::start); // We could be in a different thread (neon jobs) } else { start(); } return true; } /** Creates a blacklist entry, possibly taking into account an old one. * * The old entry may be invalid, then a fresh entry is created. */ static SyncJournalErrorBlacklistRecord createBlacklistEntry( const SyncJournalErrorBlacklistRecord &old, const SyncFileItem &item) { SyncJournalErrorBlacklistRecord entry; entry._file = item.localName(); entry._errorString = item._errorString; entry._lastTryModtime = item._modtime; entry._lastTryEtag = item._etag.toUtf8(); entry._lastTryTime = Utility::qDateTimeToTime_t(QDateTime::currentDateTimeUtc()); entry._renameTarget = item._renameTarget; entry._retryCount = old._retryCount + 1; entry._requestId = item._requestId; static qint64 minBlacklistTime(getMinBlacklistTime.count()); static qint64 maxBlacklistTime(qMax(getMaxBlacklistTime.count(), minBlacklistTime)); // The factor of 5 feels natural: 5s, 25s, 2 min, 10 min, ~1h, ~5h, ~24h // we start with 0 the actual value will be adjusted by qBound entry._ignoreDuration = old._ignoreDuration * 5; if (item._httpErrorCode == 403) { qCWarning(lcPropagator) << u"Probably firewall error: " << item._httpErrorCode << u", blacklisting up to 1h only"; entry._ignoreDuration = qMin(entry._ignoreDuration, qint64(60 * 60)); } else if (item._httpErrorCode == 413 || item._httpErrorCode == 415) { qCWarning(lcPropagator) << u"Fatal Error condition" << item._httpErrorCode << u", maximum blacklist ignore time!"; entry._ignoreDuration = maxBlacklistTime; } entry._ignoreDuration = qBound(minBlacklistTime, entry._ignoreDuration, maxBlacklistTime); if (item._httpErrorCode == 507) { entry._errorCategory = SyncJournalErrorBlacklistRecord::Category::InsufficientRemoteStorage; } else if (item._httpErrorCode == 0 && item._status == SyncFileItem::SoftError) { // assume a local error entry._errorCategory = SyncJournalErrorBlacklistRecord::Category::LocalSoftError; } return entry; } /** Updates, creates or removes a blacklist entry for the given item. * * May adjust the status or item._errorString. */ static void blacklistUpdate(SyncJournalDb *journal, SyncFileItem &item) { SyncJournalErrorBlacklistRecord oldEntry = journal->errorBlacklistEntry(item.localName()); const bool mayBlacklist = item._status == SyncFileItem::NormalError || item._status == SyncFileItem::SoftError || item._status == SyncFileItem::DetailError; // No new entry? Possibly remove the old one, then done. if (!mayBlacklist) { if (oldEntry.isValid()) { journal->wipeErrorBlacklistEntry(item.localName()); } return; } auto newEntry = createBlacklistEntry(oldEntry, item); journal->setErrorBlacklistEntry(newEntry); // Suppress the error if it was and continues to be blacklisted. // An ignoreDuration of 0 mean we're tracking the error, but not actively // suppressing it. // Some soft errors might become louder on repeat occurrence if (item._status == SyncFileItem::SoftError && newEntry._retryCount > 1 && item._httpErrorCode != 0) { qCWarning(lcPropagator) << u"escalating http soft error on " << item.localName() << u" to normal error, " << item._httpErrorCode; item._status = SyncFileItem::NormalError; } else if (item._status != SyncFileItem::SoftError && item._hasBlacklistEntry && newEntry._ignoreDuration > 0) { item._status = SyncFileItem::BlacklistedError; } qCInfo(lcPropagator) << u"blacklisting " << item.localName() << u" for " << newEntry._ignoreDuration << u", retry count " << newEntry._retryCount; } void PropagateItemJob::done(SyncFileItem::Status statusArg, const QString &errorString) { // Duplicate calls to done() are a logic error OC_ENFORCE(state() != Finished); setState(Finished); _item->_status = statusArg; if (_item->_isRestoration) { if (_item->_status == SyncFileItem::Success || _item->_status == SyncFileItem::Conflict) { _item->_status = SyncFileItem::Restoration; } else { _item->_errorString += tr("; Restoration Failed: %1").arg(errorString); } } else { if (_item->_errorString.isEmpty()) { _item->_errorString = errorString; } } if (propagator()->_abortRequested && (_item->_status == SyncFileItem::NormalError || _item->_status == SyncFileItem::FatalError)) { // an abort request is ongoing. Change the status to Soft-Error _item->_status = SyncFileItem::SoftError; } // Blacklist handling switch (_item->_status) { case SyncFileItem::SoftError: [[fallthrough]]; case SyncFileItem::FatalError: [[fallthrough]]; case SyncFileItem::NormalError: [[fallthrough]]; case SyncFileItem::DetailError: [[fallthrough]]; case SyncFileItem::Message: // Check the blacklist, possibly adjusting the item (including its status) blacklistUpdate(propagator()->_journal, *_item); break; case SyncFileItem::Success: [[fallthrough]]; case SyncFileItem::Restoration: if (_item->_hasBlacklistEntry) { // wipe blacklist entry. propagator()->_journal->wipeErrorBlacklistEntry(_item->localName()); // remove a blacklist entry in case the file was moved. if (_item->_originalFile != _item->localName()) { propagator()->_journal->wipeErrorBlacklistEntry(_item->_originalFile); } } break; case SyncFileItem::Conflict: [[fallthrough]]; case SyncFileItem::FileIgnored: [[fallthrough]]; case SyncFileItem::NoStatus: [[fallthrough]]; case SyncFileItem::BlacklistedError: [[fallthrough]]; case SyncFileItem::Excluded: [[fallthrough]]; case SyncFileItem::FilenameReserved: // nothing break; case SyncFileItem::StatusCount: Q_UNREACHABLE(); } if (_item->hasErrorStatus()) qCWarning(lcPropagator) << u"Could not complete propagation of" << _item->destination() << u"by" << this << u"with status" << _item->_status << u"and error:" << _item->_errorString; else qCInfo(lcPropagator) << u"Completed propagation of" << _item->destination() << u"by" << this << u"with status" << _item->_status; // Will be handled in PropagateDirectory::slotSubJobsFinished at the end if (!_item->isDirectory() || _item->_relevantDirectoyInstruction) { // we are either not a directory or we are a PropagateDirectory job // and an actual instruction was performed for this directory. Q_ASSERT(!_item->_relevantDirectoyInstruction || qobject_cast(this)); Q_EMIT propagator()->itemCompleted(_item); } else { // the directoy needs to call done() in PropagateDirectory::slotSubJobsFinished // we don't notify itemCompleted yet as the directory is only complete once its child items are complete. _item->_relevantDirectoyInstruction = true; } Q_EMIT finished(_item->_status); if (_item->_status == SyncFileItem::FatalError) { // Abort all remaining jobs. propagator()->abort(); } } // ================================================================================ PropagateItemJob *OwncloudPropagator::createJob(const SyncFileItemPtr &item) { qCDebug(lcPropagator) << u"Propagating:" << item; const bool deleteExisting = item->instruction() == CSYNC_INSTRUCTION_TYPE_CHANGE; switch (item->instruction()) { case CSYNC_INSTRUCTION_REMOVE: if (item->_direction == SyncFileItem::Down) { return new PropagateLocalRemove(this, item); } else { return new PropagateRemoteDelete(this, item); } case CSYNC_INSTRUCTION_NEW: case CSYNC_INSTRUCTION_TYPE_CHANGE: case CSYNC_INSTRUCTION_CONFLICT: if (item->isDirectory()) { // CONFLICT has _direction == None if (item->_direction != SyncFileItem::Up) { auto job = new PropagateLocalMkdir(this, item); job->setDeleteExistingFile(deleteExisting); return job; } else { auto job = new PropagateRemoteMkdir(this, item); job->setDeleteExisting(deleteExisting); return job; } } //fall through case CSYNC_INSTRUCTION_SYNC: if (item->_direction != SyncFileItem::Up) { auto job = new PropagateDownloadFile(this, item); job->setDeleteExistingFolder(deleteExisting); return job; } else { PropagateUploadFileCommon *job = nullptr; if (account()->capabilities().tusSupport().isValid()) { job = new PropagateUploadFileTUS(this, item); } else { job = new PropagateUploadFileV1(this, item); } job->setDeleteExisting(deleteExisting); return job; } case CSYNC_INSTRUCTION_RENAME: if (item->_direction == SyncFileItem::Up) { return new PropagateRemoteMove(this, item); } else { return new PropagateLocalRename(this, item); } case CSYNC_INSTRUCTION_UPDATE_METADATA: // For directories, metadata-only updates will be done after all their files are propagated. if (item->isDirectory()) { // Will be handled in PropagateDirectory::slotSubJobsFinished at the end return nullptr; } return new PropagateUpdateMetaDataJob(this, item); case CSYNC_INSTRUCTION_IGNORE: case CSYNC_INSTRUCTION_ERROR: return new PropagateIgnoreJob(this, item); case CSYNC_INSTRUCTION_NONE: return nullptr; } Q_UNREACHABLE(); } qint64 OwncloudPropagator::smallFileSize() { const qint64 smallFileSize = 100 * 1024; //default to 1 MB. Not dynamic right now. return smallFileSize; } /** * This builds all the jobs needed for the propagation. * Each directory is a PropagateDirectory job, which contains the files in it. */ void OwncloudPropagator::start(SyncFileItemSet &&items) { // The items list is sorted in such a way that an item for a directory come before any items // inside that directory. For example: // - A <--- directory // - A/B <--- directory // - A/B/1 <--- file // - A/2 <--- file // This is important, because we rely on the fact that all actions for items inside a directory // are grouped together, and that an action on the directory itself preceeds the actions for // its child items. _rootJob.reset(new PropagateRootDirectory(this)); // The algorithm could be done recursively, but the implementation is done iteratively in order // to prevent us running out of stack space. So the next 3 variables are used to maintain the // state. // The directories stack below has the current directory on top of the stack. So if we're // processing directory `/A/B/C`, then the stack looks like this: // - PropagateDirectory job for A/B/C <--- top-of-stack // - PropagateDirectory job for A/B // - PropagateDirectory job for A // - PropagateDirectory job for / <--- bottom-of-stack // // IMPORTANT: this parent stack is also used when a directory is removed // (`CSYNC_INSTRUCTION_REMOVE` in the last step below), in order to suppress updating the etag // of the parents of the to-be-removed directory. See the comment in the code for more information. QStack> directories; directories.push({QString(), _rootJob.data()}); // Due to the ordering of the items, it can happen that we have the following list: // - remove directory A // - remove file A/a // - etc // So when we find a have a sync instruction to remove a directory, we remember it in the // `currentRemoveDirectoryJob` variable, so we know/see it when processing the next item // in the list. PropagatorJob *currentRemoveDirectoryJob = nullptr; // We skip items inside conflict directories. So, when we see an item that's marked as such, // remember its name to see if in the next iteration, we will hit an item for that directory. // See the `else` statment in the second step. QString maybeConflictDirectory; for (const auto &item : std::as_const(items)) { // First check if this is an item in a directory which is going to be removed. if (currentRemoveDirectoryJob && FileSystem::isChildPathOf(item->localName(), currentRemoveDirectoryJob->path())) { // Check the sync instruction for the item: if (item->instruction() == CSYNC_INSTRUCTION_REMOVE) { // already taken care of. (by the removal of the parent directory) if (auto delDirJob = qobject_cast(currentRemoveDirectoryJob)) { // increase the number of subjobs that would be there. delDirJob->increaseAffectedCount(); } continue; } else if (item->isDirectory() && (item->instruction() & (CSYNC_INSTRUCTION_NEW | CSYNC_INSTRUCTION_TYPE_CHANGE))) { // Create a new directory within a deleted directory? That can happen if the directory // etag was not fetched properly on the previous sync because the sync was aborted // while uploading this directory (which is now removed). We can ignore it. if (auto delDirJob = qobject_cast(currentRemoveDirectoryJob)) { // increase the number of subjobs that would be there. delDirJob->increaseAffectedCount(); } continue; } else if (item->instruction() & (CSYNC_INSTRUCTION_IGNORE | CSYNC_INSTRUCTION_NONE)) { continue; } else if (item->instruction() != CSYNC_INSTRUCTION_RENAME) { // all is good, the rename will be executed before the directory deletion qCWarning(lcPropagator) << u"WARNING: Job within a removed directory? This should not happen!" << item; Q_ASSERT(false); // we shouldn't land here, but assert for debug purposes } } // Second step: if a CONFLICT item contains files, these files can't be processed because // the conflict handling is likely to rename the directory. This can happen // when there's a new local directory at the same time as a remote file. if (!maybeConflictDirectory.isEmpty()) { if (FileSystem::isChildPathOf(item->destination(), maybeConflictDirectory)) { // We're processing an item in a CONFLICT directory. qCInfo(lcPropagator) << u"Skipping job inside CONFLICT directory" << item->localName() << item->instruction(); item->setInstruction(CSYNC_INSTRUCTION_NONE); continue; } else { // This is not an item inside the conflict directory, which means we're done // processing the conflict directory in the previous iteration, so clear the string. maybeConflictDirectory.clear(); } } // Thirth step: pop the directory stack when we're finished there. // For example, we were processing directory `A/B`, which means that the // `PropagateDirectory` job for `B` is on top of the stack. If we now have // an item `A/c`, then we're back to processing directory `A`, so we have // to pop the `B` job off of the stack. while (!FileSystem::isChildPathOf(item->destination(), directories.top().first)) { directories.pop(); } // The last step is to add a subtask for the item. There are 4 cases covered here: // a type change vs. a removal, and a file vs. a directory. if (item->isDirectory()) { PropagateDirectory *dir = new PropagateDirectory(this, item); if (item->instruction() == CSYNC_INSTRUCTION_TYPE_CHANGE && item->_direction == SyncFileItem::Up) { // Skip all potential uploads to the new folder. // Processing them now leads to problems with permissions: // checkForPermissions() has already run and used the permissions // of the file we're about to delete to decide whether uploading // to the new dir is ok... for (const auto &item2 : std::as_const(items)) { if (item2 != item && FileSystem::isChildPathOf(item2->destination(), item->destination())) { item2->setInstruction(CSYNC_INSTRUCTION_NONE); _anotherSyncNeeded = true; } } } if (item->instruction() == CSYNC_INSTRUCTION_REMOVE) { // We do the removal of directories at the end, because there might be moves from // these directories that will happen later. currentRemoveDirectoryJob = dir; _rootJob->addDeleteJob(currentRemoveDirectoryJob); // We should not update the etag of parent directories of the removed directory // since it would be done before the actual remove (issue #1845) // NOTE: Currently this means that we don't update those etag at all in this sync, // but it should not be a problem, they will be updated in the next sync. for (auto &directory : directories) { if (directory.second->item()->instruction() == CSYNC_INSTRUCTION_UPDATE_METADATA) { directory.second->item()->setInstruction(CSYNC_INSTRUCTION_NONE); _anotherSyncNeeded = true; } } } else { directories.top().second->appendJob(dir); } directories.push(qMakePair(item->destination(), dir)); } else { // The item is not a directory, but a file. if (item->instruction() == CSYNC_INSTRUCTION_TYPE_CHANGE) { // will delete directories, so defer execution currentRemoveDirectoryJob = createJob(item); _rootJob->addDeleteJob(currentRemoveDirectoryJob); } else { directories.top().second->appendTask(item); } if (item->instruction() == CSYNC_INSTRUCTION_CONFLICT) { // This might be a file or a directory on the local side. If it's a // directory we want to skip processing items inside it. maybeConflictDirectory = item->localName(); } } } connect(_rootJob.data(), &PropagatorJob::finished, this, &OwncloudPropagator::emitFinished); _jobScheduled = false; scheduleNextJob(); } const SyncOptions &OwncloudPropagator::syncOptions() const { return _syncOptions; } Result OwncloudPropagator::localFileNameClash(const QString &relFile) { OC_ASSERT(!relFile.isEmpty()); if (!relFile.isEmpty() && Utility::fsCasePreserving()) { const QFileInfo fileInfo(_localDir + relFile); #ifdef Q_OS_MAC if (!fileInfo.exists()) { return false; } else { // Need to normalize to composited form because of QTBUG-39622/QTBUG-55896 const QString cName = fileInfo.canonicalFilePath().normalized(QString::NormalizationForm_C); if (fileInfo.filePath() != cName && !cName.endsWith(relFile, Qt::CaseSensitive)) { qCWarning(lcPropagator) << u"Detected case clash between" << fileInfo.filePath() << u"and" << cName; return cName; } } #elif defined(Q_OS_WIN) WIN32_FIND_DATA FindFileData; const Utility::Handle hFind(FindFirstFileW(reinterpret_cast(FileSystem::longWinPath(fileInfo.filePath()).utf16()), &FindFileData), [](HANDLE h) { FindClose(h); }); if (hFind != INVALID_HANDLE_VALUE) { const QString realFileName = QString::fromWCharArray(FindFileData.cFileName); if (!fileInfo.filePath().endsWith(realFileName, Qt::CaseSensitive)) { const QString clashName = fileInfo.path() + QLatin1Char('/') + realFileName; qCWarning(lcPropagator) << u"Detected case clash between" << fileInfo.filePath() << u"and" << clashName; return clashName; } } #else // On Linux, the file system is case sensitive, but this code is useful for testing. // Just check that there is no other file with the same name and different casing. const QString fn = fileInfo.fileName(); const QStringList list = fileInfo.dir().entryList({ fn }); if (list.count() > 1 || (list.count() == 1 && list[0] != fn)) { return list[0]; } #endif } return false; } bool OwncloudPropagator::hasCaseClashAccessibilityProblem(const QString &relfile) { #ifdef Q_OS_WIN bool result = false; const QString file(_localDir + relfile); WIN32_FIND_DATA FindFileData; HANDLE hFind; hFind = FindFirstFileW(reinterpret_cast(FileSystem::longWinPath(file).utf16()), &FindFileData); if (hFind != INVALID_HANDLE_VALUE) { QString firstFile = QString::fromWCharArray(FindFileData.cFileName); if (FindNextFile(hFind, &FindFileData)) { QString secondFile = QString::fromWCharArray(FindFileData.cFileName); // This extra check shouldn't be necessary, but ensures that there // are two different filenames that are identical when case is ignored. if (firstFile != secondFile && QString::compare(firstFile, secondFile, Qt::CaseInsensitive) == 0) { result = true; qCWarning(lcPropagator) << u"Found two filepaths that only differ in case: " << firstFile << secondFile; } } FindClose(hFind); } return result; #else Q_UNUSED(relfile); return false; #endif } QString OwncloudPropagator::fullLocalPath(const QString &tmp_file_name) const { return _localDir + tmp_file_name; } QString OwncloudPropagator::localPath() const { return _localDir; } void OwncloudPropagator::scheduleNextJob() { if (_jobScheduled) return; // don't schedule more than 1 _jobScheduled = true; QTimer::singleShot(0, this, &OwncloudPropagator::scheduleNextJobImpl); } void OwncloudPropagator::scheduleNextJobImpl() { // TODO: If we see that the automatic up-scaling has a bad impact we // need to check how to avoid this. // Down-scaling on slow networks? https://github.com/owncloud/client/issues/3382 // Making sure we do up/down at same time? https://github.com/owncloud/client/issues/1633 _jobScheduled = false; if (_activeJobList.count() < maximumActiveTransferJob()) { if (_rootJob->scheduleSelfOrChild()) { scheduleNextJob(); } } else if (_activeJobList.count() < hardMaximumActiveJob()) { int likelyFinishedQuicklyCount = 0; // NOTE: Only counts the first 3 jobs! Then for each // one that is likely finished quickly, we can launch another one. // When a job finishes another one will "move up" to be one of the first 3 and then // be counted too. for (int i = 0; i < maximumActiveTransferJob() && i < _activeJobList.count(); i++) { if (_activeJobList.at(i)->isLikelyFinishedQuickly()) { likelyFinishedQuicklyCount++; } } if (_activeJobList.count() < maximumActiveTransferJob() + likelyFinishedQuicklyCount) { qCDebug(lcPropagator) << u"Can pump in another request! activeJobs =" << _activeJobList.count(); if (_rootJob->scheduleSelfOrChild()) { scheduleNextJob(); } } } } void OwncloudPropagator::reportFileTotal(const SyncFileItem &item, qint64 newSize) { Q_EMIT updateFileTotal(item, newSize); } void OwncloudPropagator::abort() { if (_abortRequested) return; if (_rootJob) { // Connect to abortFinished which signals that abort has been asynchronously finished connect(_rootJob.data(), &PropagateDirectory::abortFinished, this, &OwncloudPropagator::emitFinished); // Use Queued Connection because we're possibly already in an item's finished stack QMetaObject::invokeMethod( _rootJob.data(), [this] { _rootJob->abort(PropagatorJob::AbortType::Asynchronous); }, Qt::QueuedConnection); // Give asynchronous abort 5 sec to finish on its own QTimer::singleShot(5s, this, &OwncloudPropagator::abortTimeout); } else { // No root job, call emitFinished emitFinished(SyncFileItem::NormalError); } } void OwncloudPropagator::reportProgress(const SyncFileItem &item, qint64 bytes) { Q_EMIT progress(item, bytes); } AccountPtr OwncloudPropagator::account() const { return _account; } OwncloudPropagator::DiskSpaceResult OwncloudPropagator::diskSpaceCheck() const { const qint64 freeBytes = Utility::freeDiskSpace(_localDir); if (freeBytes < 0) { return DiskSpaceOk; } if (freeBytes < criticalFreeSpaceLimit()) { return DiskSpaceCritical; } if (freeBytes - _rootJob->committedDiskSpace() < freeSpaceLimit()) { return DiskSpaceFailure; } return DiskSpaceOk; } bool OwncloudPropagator::createConflict(const SyncFileItemPtr &item, PropagatorCompositeJob *composite, QString *error) { QString fn = fullLocalPath(item->localName()); QString renameError; auto conflictModTime = FileSystem::getModTime(fn); QString conflictUserName; if (account()->capabilities().uploadConflictFiles()) conflictUserName = account()->davDisplayName(); QString conflictFileName = Utility::makeConflictFileName(item->localName(), Utility::qDateTimeFromTime_t(conflictModTime), conflictUserName); QString conflictFilePath = fullLocalPath(conflictFileName); // If the file is locked, we want to retry this sync when it // becomes available again. if (FileSystem::isFileLocked(fn, FileSystem::LockMode::Exclusive)) { Q_EMIT seenLockedFile(fn, FileSystem::LockMode::Exclusive); if (error) *error = tr("The file »%1« is currently in use").arg(fn); return false; } if (!FileSystem::rename(fn, conflictFilePath, &renameError)) { // If the rename fails, don't replace it. if (error) *error = renameError; return false; } qCInfo(lcPropagator) << u"Created conflict file" << fn << u"->" << 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; conflictRecord.initialBasePath = item->localName().toUtf8(); if (const auto baseRecord = _journal->getFileRecord(item->_originalFile); baseRecord.isValid()) { conflictRecord.baseEtag = baseRecord.etag().toUtf8(); conflictRecord.baseFileId = baseRecord.fileId(); } else { // We might very well end up with no fileid/etag for new/new conflicts } _journal->setConflictRecord(conflictRecord); // Create a new upload job if the new conflict file should be uploaded if (account()->capabilities().uploadConflictFiles()) { if (composite && !QFileInfo(conflictFilePath).isDir()) { SyncFileItemPtr conflictItem = SyncFileItemPtr(new SyncFileItem); conflictItem->setLocalName(conflictFileName); conflictItem->_type = ItemTypeFile; conflictItem->_direction = SyncFileItem::Up; conflictItem->setInstruction(CSYNC_INSTRUCTION_NEW); conflictItem->_modtime = conflictModTime; conflictItem->_size = item->_previousSize; Q_EMIT newItem(conflictItem); composite->appendTask(conflictItem); } else { // Directories we can't process in one go. The next sync run // will take care of uploading the conflict dir contents. _anotherSyncNeeded = true; } } return true; } QString OwncloudPropagator::adjustRenamedPath(const QString &original) const { return OCC::adjustRenamedPath(_renamedDirectories, original); } Result OwncloudPropagator::updatePlaceholder(const SyncFileItem &item, const QString &fileName, const QString &replacesFile) { Q_ASSERT([&] { if (item._type == ItemTypeVirtualFileDehydration) { // when dehydrating the file must not be pinned // don't use destinatio() with suffix placeholder const auto pin = syncOptions()._vfs->pinState(item.localName()); if (pin && pin.get() == PinState::AlwaysLocal) { return false; } } return true; }()); return syncOptions()._vfs->updateMetadata(item, fileName, replacesFile); } Result OwncloudPropagator::updateMetadata(const SyncFileItem &item) { const QString fsPath = fullLocalPath(item.destination()); if (!QFileInfo::exists(fsPath)) { qCWarning(lcPropagator) << u"Skipping updateMetadata on:" << fsPath << u"the file does no longer exist."; return Vfs::ConvertToPlaceholderResult::Ok; } const auto result = updatePlaceholder(item, fsPath, {}); if (!result) { return result; } auto itemCopy = item; if (auto inode = FileSystem::getInode(FileSystem::toFilesystemPath(fsPath))) { itemCopy._inode = inode.value(); } else { qCWarning(lcPropagator) << u"Failed to get inode for" << fsPath; } qCDebug(lcPropagator) << fsPath << u"Retrieved inode " << itemCopy._inode << u"(previous item inode: " << item._inode << u")"; auto record = SyncJournalFileRecord::fromSyncFileItem(itemCopy); if (result.get() == Vfs::ConvertToPlaceholderResult::Locked) { record.setDirtyPlaceholder(true); Q_EMIT seenLockedFile(fullLocalPath(item.localName()), FileSystem::LockMode::Exclusive); } const auto dBresult = _journal->setFileRecord(record); if (!dBresult) { return dBresult.error(); } return Vfs::ConvertToPlaceholderResult::Ok; } // ================================================================================ PropagatorJob::PropagatorJob(OwncloudPropagator *propagator, const QString &path) : QObject(propagator) , _path(path) , _jobState(NotYetStarted) { } void PropagatorJob::setState(JobState state) { _jobState = state; } OwncloudPropagator *PropagatorJob::propagator() const { return qobject_cast(parent()); } // ================================================================================ PropagatorJob::JobParallelism PropagatorCompositeJob::parallelism() { // If any of the running sub jobs is not parallel, we have to wait for (int i = 0; i < _runningJobs.count(); ++i) { if (_runningJobs.at(i)->parallelism() != FullParallelism) { return _runningJobs.at(i)->parallelism(); } } return FullParallelism; } void PropagatorCompositeJob::slotSubJobAbortFinished() { // Count that job has been finished _abortsCount--; // Emit abort if last job has been aborted if (_abortsCount == 0) { Q_EMIT abortFinished(); } } void PropagatorCompositeJob::appendJob(PropagatorJob *job) { job->setAssociatedComposite(this); _jobsToDo.append(job); } bool PropagatorCompositeJob::scheduleSelfOrChild() { if (state() == Finished) { return false; } // Start the composite job if (state() == NotYetStarted) { setState(Running); } // Ask all the running composite jobs if they have something new to schedule. for (int i = 0; i < _runningJobs.size(); ++i) { OC_ASSERT(_runningJobs.at(i)->state() == Running); if (possiblyRunNextJob(_runningJobs.at(i))) { return true; } // If any of the running sub jobs is not parallel, we have to cancel the scheduling // of the rest of the list and wait for the blocking job to finish and schedule the next one. auto paral = _runningJobs.at(i)->parallelism(); if (paral == WaitForFinished) { return false; } } // Now it's our turn, check if we have something left to do. // First, convert a task to a job if necessary while (_jobsToDo.empty() && !_tasksToDo.empty()) { const SyncFileItemPtr nextTask = *_tasksToDo.begin(); _tasksToDo.erase(_tasksToDo.begin()); PropagatorJob *job = propagator()->createJob(nextTask); if (!job) { qCWarning(lcDirectory) << u"Useless task found for file" << nextTask->destination() << u"instruction" << nextTask->instruction(); continue; } 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 // we mark this job as finished so that the propagator can schedule a new one. if (_jobsToDo.isEmpty() && _tasksToDo.empty() && _runningJobs.isEmpty()) { // Our parent jobs are already iterating over their running jobs, post to the event loop // to avoid removing ourself from that list while they iterate. QMetaObject::invokeMethod(this, &PropagatorCompositeJob::finalize, Qt::QueuedConnection); } return false; } void PropagatorCompositeJob::slotSubJobFinished(SyncFileItem::Status status) { PropagatorJob *subJob = static_cast(sender()); OC_ASSERT(subJob); // Delete the job and remove it from our list of jobs. subJob->deleteLater(); OC_ENFORCE(_runningJobs.removeAll(subJob) == 1); // we removed exactly one item // Any sub job error will cause the whole composite to fail. This is important // for knowing whether to update the etag in PropagateDirectory, for example. switch (status) { case SyncFileItem::FatalError: [[fallthrough]]; case SyncFileItem::NormalError: [[fallthrough]]; case SyncFileItem::SoftError: [[fallthrough]]; case SyncFileItem::DetailError: [[fallthrough]]; case SyncFileItem::BlacklistedError: _errorPaths.insert(subJob->path(), status); break; default: break; } if (_jobsToDo.isEmpty() && _tasksToDo.empty() && _runningJobs.isEmpty()) { finalize(); } else { propagator()->scheduleNextJob(); } } void PropagatorCompositeJob::finalize() { // The propagator will do parallel scheduling and this could be posted // multiple times on the event loop, ignore the duplicate calls. if (state() == Finished) return; setState(Finished); Q_EMIT finished(_errorPaths.empty() ? SyncFileItem::Success : _errorPaths.last()); } qint64 PropagatorCompositeJob::committedDiskSpace() const { qint64 needed = 0; for (auto *job : _runningJobs) { needed += job->committedDiskSpace(); } return needed; } PropagatorCompositeJob::PropagatorCompositeJob(OwncloudPropagator *propagator, const QString &path) : PropagatorJob(propagator, path) { } // ================================================================================ PropagateDirectory::PropagateDirectory(OwncloudPropagator *propagator, const SyncFileItemPtr &item) : PropagateItemJob(propagator, item) , _firstJob(propagator->createJob(item)) , _subJobs(propagator, path()) { if (_firstJob) { connect(_firstJob.get(), &PropagatorJob::finished, this, &PropagateDirectory::slotFirstJobFinished); _firstJob->setAssociatedComposite(&_subJobs); } connect(&_subJobs, &PropagatorJob::finished, this, &PropagateDirectory::slotSubJobsFinished); } PropagatorJob::JobParallelism PropagateDirectory::parallelism() { // If any of the non-finished sub jobs is not parallel, we have to wait if (_firstJob && _firstJob->parallelism() != FullParallelism) { return WaitForFinished; } if (_subJobs.parallelism() != FullParallelism) { return WaitForFinished; } return FullParallelism; } bool PropagateDirectory::scheduleSelfOrChild() { if (state() == Finished) { return false; } if (state() == NotYetStarted) { setState(Running); } if (_firstJob && _firstJob->state() == NotYetStarted) { return _firstJob->scheduleSelfOrChild(); } if (_firstJob && _firstJob->state() == Running) { // Don't schedule any more job until this is done. return false; } return _subJobs.scheduleSelfOrChild(); } void PropagateDirectory::slotFirstJobFinished(SyncFileItem::Status status) { _firstJob.release()->deleteLater(); switch (status) { // non critical states case SyncFileItem::Success: [[fallthrough]]; case SyncFileItem::Restoration: [[fallthrough]]; case SyncFileItem::Conflict: break; // handle all other cases as errors default: if (state() != Finished) { // Synchronously abort abort(AbortType::Synchronous); done(status); } return; } propagator()->scheduleNextJob(); } void PropagateDirectory::slotSubJobsFinished(const SyncFileItem::Status status) { if (OC_ENSURE(!_item->isEmpty())) { // report an error if the acutal action on the folder failed if (_item->_relevantDirectoyInstruction && _item->_status != SyncFileItem::Success) { Q_ASSERT(_item->_status != SyncFileItem::NoStatus); qCWarning(lcDirectory) << u"PropagateDirectory completed with" << status << u"the dirctory job itself is marked as" << _item->_status; done(_item->_status); return; } if (status == SyncFileItem::Success) { // If a directory is renamed, recursively delete any stale items // that may still exist below the old path. if (_item->instruction() == CSYNC_INSTRUCTION_RENAME && _item->_originalFile != _item->_renameTarget) { // TODO: check result but it breaks TestDatabaseError propagator()->_journal->deleteFileRecord(_item->_originalFile, true); } if (_item->instruction() == CSYNC_INSTRUCTION_NEW && _item->_direction == SyncFileItem::Down) { // special case for local MKDIR, set local directory mtime // (it's not synced later at all, but can be nice to have it set initially) OC_ASSERT(FileSystem::setModTime(propagator()->fullLocalPath(_item->destination()), _item->_modtime)); } // For new directories we always want to update the etag once // the directory has been propagated. Otherwise the directory // could appear locally without being added to the database. // Additionally we need to convert those folders to placeholders with cfapi vfs. if (_item->instruction() & (CSYNC_INSTRUCTION_RENAME | CSYNC_INSTRUCTION_NEW | CSYNC_INSTRUCTION_UPDATE_METADATA)) { // metatdata changes are relevant _item->_relevantDirectoyInstruction = true; _item->_status = SyncFileItem::Success; const auto result = propagator()->updateMetadata(*_item); if (!result) { qCWarning(lcDirectory) << u"Error writing to the database for file" << _item->localName() << u"with" << result.error(); done(SyncFileItem::FatalError, tr("Error updating metadata: %1").arg(result.error())); return; } else if (result.get() == Vfs::ConvertToPlaceholderResult::Locked) { done(SyncFileItem::SoftError, tr("The folder »%1« is currently in use").arg(_item->destination())); return; } } if (_item->_relevantDirectoyInstruction) { done(_item->_status); return; } } } // don't call done, we only propagate the state of the child items // and we don't want error handling for this folder for an error that happened on a child Q_ASSERT(state() != Finished); setState(Finished); Q_EMIT finished(status); if (_item->_relevantDirectoyInstruction) { Q_EMIT propagator()->itemCompleted(_item); } } PropagateRootDirectory::PropagateRootDirectory(OwncloudPropagator *propagator) : PropagateDirectory(propagator, SyncFileItemPtr([] { auto f = new SyncFileItem; f->setLocalName(QStringLiteral("/")); return f; }())) , _dirDeletionJobs(propagator, path()) { connect(&_dirDeletionJobs, &PropagatorJob::finished, this, &PropagateRootDirectory::slotDirDeletionJobsFinished); } PropagatorJob::JobParallelism PropagateRootDirectory::parallelism() { // the root directory parallelism isn't important return WaitForFinished; } void PropagateRootDirectory::abort(PropagatorJob::AbortType abortType) { if (_firstJob) { // Force first job to abort synchronously // even if caller allows async abort (asyncAbort) _firstJob->abort(AbortType::Synchronous); } if (abortType == AbortType::Asynchronous) { struct AbortsFinished { bool subJobsFinished = false; bool dirDeletionFinished = false; }; auto abortStatus = QSharedPointer(new AbortsFinished); connect(&_subJobs, &PropagatorCompositeJob::abortFinished, this, [this, abortStatus]() { abortStatus->subJobsFinished = true; if (abortStatus->subJobsFinished && abortStatus->dirDeletionFinished) Q_EMIT abortFinished(); }); connect(&_dirDeletionJobs, &PropagatorCompositeJob::abortFinished, this, [this, abortStatus]() { abortStatus->dirDeletionFinished = true; if (abortStatus->subJobsFinished && abortStatus->dirDeletionFinished) Q_EMIT abortFinished(); }); } _subJobs.abort(abortType); _dirDeletionJobs.abort(abortType); } qint64 PropagateRootDirectory::committedDiskSpace() const { return _subJobs.committedDiskSpace() + _dirDeletionJobs.committedDiskSpace(); } bool PropagateRootDirectory::scheduleSelfOrChild() { if (state() == Finished) { return false; } if (PropagateDirectory::scheduleSelfOrChild()) { return true; } // Important: Finish _subJobs before scheduling any deletes. if (_subJobs.state() != Finished) { return false; } return _dirDeletionJobs.scheduleSelfOrChild(); } void PropagateRootDirectory::slotSubJobsFinished(SyncFileItem::Status status) { switch (status) { // non critical states case SyncFileItem::Success: [[fallthrough]]; case SyncFileItem::Restoration: [[fallthrough]]; case SyncFileItem::Conflict: break; // handle all other cases as errors default: _status = status; if (state() != Finished) { auto subJob = qobject_cast(sender()); if (OC_ENSURE(subJob)) { const QMap errorPaths = subJob->errorPaths(); auto deleteJobs = _dirDeletionJobs.jobsToDo(); deleteJobs.erase(std::remove_if(deleteJobs.begin(), deleteJobs.end(), [&](auto *p) { for (const auto &[path, error] : Utility::asKeyValueRange(errorPaths)) { const QFileInfo info(path); if (FileSystem::isChildPathOf(p->path(), info.isDir() ? info.filePath() : info.path())) { return true; } } return false; }), deleteJobs.end()); _dirDeletionJobs.setJobsToDo(deleteJobs); } } } propagator()->scheduleNextJob(); } void PropagateRootDirectory::slotDirDeletionJobsFinished(SyncFileItem::Status status) { setState(Finished); Q_EMIT finished(_status != SyncFileItem::NoStatus ? _status : status); } void PropagateRootDirectory::addDeleteJob(PropagatorJob *job) { _dirDeletionJobs.appendJob(job); } // ================================================================================ QString OwncloudPropagator::fullRemotePath(const QString &tmp_file_name) const { // TODO: should this be part of the _item (SyncFileItemPtr)? return _remoteFolder + tmp_file_name; } QString OwncloudPropagator::remotePath() const { return _remoteFolder; } PropagateUpdateMetaDataJob::PropagateUpdateMetaDataJob(OwncloudPropagator *propagator, const SyncFileItemPtr &item) : PropagateItemJob(propagator, item) { } void OCC::PropagateUpdateMetaDataJob::start() { // Update the database: New remote fileid or Etag or RemotePerm // Or for files that were detected as "resolved conflict". // Or a local inode/mtime change // In case of "resolved conflict": there should have been a conflict because they // both were new, or both had their local mtime or remote etag modified, but the // size and mtime is the same on the server. This typically happens when the // database is removed. Nothing will be done for those files, but we still need // to update the database. const QString filePath = propagator()->fullLocalPath(_item->destination()); if (_item->_direction == SyncFileItem::Down) { const SyncJournalFileRecord prev = propagator()->_journal->getFileRecord(_item->localName()); if (prev.isValid()) { if (_item->_checksumHeader.isEmpty()) { _item->_checksumHeader = prev.checksumHeader(); } _item->_serverHasIgnoredFiles |= prev.serverHasIgnoredFiles(); } } const auto result = propagator()->updateMetadata(*_item); if (!result) { done(SyncFileItem::FatalError, tr("Could not update file: %1").arg(result.error())); return; } else if (result.get() == Vfs::ConvertToPlaceholderResult::Locked) { done(SyncFileItem::SoftError, tr("The file »%1« is currently in use").arg(_item->localName())); return; } done(SyncFileItem::Success); } }