Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/common/syncjournaldb.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: LGPL-2.1-or-later
*/

#include <QCryptographicHash>

Check failure on line 7 in src/common/syncjournaldb.cpp

View workflow job for this annotation

GitHub Actions / build

src/common/syncjournaldb.cpp:7:10 [clang-diagnostic-error]

'QCryptographicHash' file not found
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
Expand Down Expand Up @@ -2476,6 +2476,91 @@
return blackList;
}

bool SyncJournalDb::hasSelectiveSyncDescendants(const QString &path)
{
QMutexLocker locker(&_mutex);

if (!checkConnect()) {
return false;
}

// Build the query to check if there are any selective sync blacklist entries
// that are descendants of the given path
QString pathPrefix = path;
if (!pathPrefix.endsWith(QLatin1Char('/'))) {
pathPrefix += QLatin1Char('/');
}

const QString sql = QStringLiteral("SELECT 1 FROM selectivesync WHERE path LIKE ? ESCAPE '\\' AND type == ? LIMIT 1");
// const QString sql = QStringLiteral("SELECT 1 FROM selectivesync WHERE path LIKE ? LIMIT 1");
SqlQuery query(_db);
query.prepare(sql.toUtf8());

// Escape special LIKE characters
QString escapedPath = pathPrefix;
escapedPath.replace(QLatin1String("\\"), QLatin1String("\\\\"));
escapedPath.replace(QLatin1String("%"), QLatin1String("\\%"));
escapedPath.replace(QLatin1String("_"), QLatin1String("\\_"));
escapedPath.replace(QLatin1String("["), QLatin1String("\\["));

query.bindValue(1, QString(escapedPath + QLatin1Char('%')));
query.bindValue(2, static_cast<int>(SelectiveSyncBlackList));

if (!query.exec()) {
qCWarning(lcDb) << "Error checking selective sync descendants for" << path << ":" << query.error();
return false;
}
auto result = query.next();
return result.ok && result.hasData;
}

QStringList SyncJournalDb::getSyncedDescendants(const QString &path)
{
QMutexLocker locker(&_mutex);
QStringList result;

if (!checkConnect()) {
return result;
}

// Get all files from the metadata that are descendants of the given path
// These are the files that were synced and need to be deleted
QString pathPrefix = path;
if (!pathPrefix.endsWith(QLatin1Char('/'))) {
pathPrefix += QLatin1Char('/');
}

const QString sql = QStringLiteral("SELECT path FROM metadata WHERE path LIKE ? ESCAPE '\\' ORDER BY path DESC");
SqlQuery query(_db);
query.prepare(sql.toUtf8());

// Escape special LIKE characters
QString escapedPath = pathPrefix;
escapedPath.replace(QLatin1String("\\"), QLatin1String("\\\\"));
escapedPath.replace(QLatin1String("%"), QLatin1String("\\%"));
escapedPath.replace(QLatin1String("_"), QLatin1String("\\_"));
escapedPath.replace(QLatin1String("["), QLatin1String("\\["));

QString likePath = QString(escapedPath + QLatin1Char('%'));
query.bindValue(1, likePath);

if (!query.exec()) {
qCWarning(lcDb) << "Error getting synced descendants for" << path << ":" << query.error();
return result;
}

while (query.next().hasData) {
QString filePath = query.stringValue(0);
// Exclude the exact path itself
if (filePath != path) {
// todo: only append folders that are fullySynced and files that are direct descendant
result.append(filePath);
}
}

return result;
}

void SyncJournalDb::avoidRenamesOnNextSync(const QByteArray &path)
{
QMutexLocker locker(&_mutex);
Expand Down
20 changes: 20 additions & 0 deletions src/common/syncjournaldb.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
#ifndef SYNCJOURNALDB_H
#define SYNCJOURNALDB_H

#include <QObject>

Check failure on line 10 in src/common/syncjournaldb.h

View workflow job for this annotation

GitHub Actions / build

src/common/syncjournaldb.h:10:10 [clang-diagnostic-error]

'QObject' file not found
#include <QDateTime>
#include <QHash>
#include <QMutex>
Expand Down Expand Up @@ -179,6 +179,26 @@

QStringList removeSelectiveSyncLists(SelectiveSyncListType type, const QString &path);

/**
* @brief Check if a path has descendants that are in selective sync blacklist
* @param path The folder path to check
* @return true if there are unsynced descendants, false otherwise
*
* This is used to prevent data loss when deleting folders that contain
* unsynced content (selective sync exclusions).
*/
[[nodiscard]] bool hasSelectiveSyncDescendants(const QString &path);

/**
* @brief Get all synced file paths that are descendants of the given path
* @param path The folder path to get synced descendants for
* @return List of synced file paths
*
* This is used for partial deletion - we only want to delete synced content,
* keeping unsynced content on the server.
*/
[[nodiscard]] QStringList getSyncedDescendants(const QString &path);

/**
* Make sure that on the next sync fileName and its parents are discovered from the server.
*
Expand Down
37 changes: 37 additions & 0 deletions src/libsync/owncloudpropagator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,43 @@
}
}

bool PropagateItemJob::addPathToSelectiveSync(SyncJournalDb *journal, const QString &folder_)

Check warning on line 218 in src/libsync/owncloudpropagator.cpp

View workflow job for this annotation

GitHub Actions / build

src/libsync/owncloudpropagator.cpp:218:24 [readability-convert-member-functions-to-static]

method 'addPathToSelectiveSync' can be made static

Check warning on line 218 in src/libsync/owncloudpropagator.cpp

View workflow job for this annotation

GitHub Actions / build

src/libsync/owncloudpropagator.cpp:218:24 [modernize-use-trailing-return-type]

use a trailing return type for this function
{
bool ok = false;

Check warning on line 220 in src/libsync/owncloudpropagator.cpp

View workflow job for this annotation

GitHub Actions / build

src/libsync/owncloudpropagator.cpp:220:10 [readability-identifier-length]

variable name 'ok' is too short, expected at least 3 characters
QStringList list = journal->getSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, &ok);

Check warning on line 221 in src/libsync/owncloudpropagator.cpp

View workflow job for this annotation

GitHub Actions / build

src/libsync/owncloudpropagator.cpp:221:17 [cppcoreguidelines-init-variables]

variable 'list' is not initialized
if (!ok)

Check warning on line 222 in src/libsync/owncloudpropagator.cpp

View workflow job for this annotation

GitHub Actions / build

src/libsync/owncloudpropagator.cpp:222:13 [readability-braces-around-statements]

statement should be inside braces
return false;

ASSERT(!folder_.endsWith(QLatin1String("/")));
QString folder = folder_ + QLatin1String("/");

Check warning on line 226 in src/libsync/owncloudpropagator.cpp

View workflow job for this annotation

GitHub Actions / build

src/libsync/owncloudpropagator.cpp:226:13 [cppcoreguidelines-init-variables]

variable 'folder' is not initialized

list.append(folder);

journal->setSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, list);
return true;
}

bool PropagateItemJob::removePathFromSelectiveSyncRecursively(SyncJournalDb *journal, const QString &folder_)

Check warning on line 234 in src/libsync/owncloudpropagator.cpp

View workflow job for this annotation

GitHub Actions / build

src/libsync/owncloudpropagator.cpp:234:24 [readability-convert-member-functions-to-static]

method 'removePathFromSelectiveSyncRecursively' can be made static

Check warning on line 234 in src/libsync/owncloudpropagator.cpp

View workflow job for this annotation

GitHub Actions / build

src/libsync/owncloudpropagator.cpp:234:24 [modernize-use-trailing-return-type]

use a trailing return type for this function
{
bool ok = false;

Check warning on line 236 in src/libsync/owncloudpropagator.cpp

View workflow job for this annotation

GitHub Actions / build

src/libsync/owncloudpropagator.cpp:236:10 [readability-identifier-length]

variable name 'ok' is too short, expected at least 3 characters
QStringList list = journal->getSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, &ok);

Check warning on line 237 in src/libsync/owncloudpropagator.cpp

View workflow job for this annotation

GitHub Actions / build

src/libsync/owncloudpropagator.cpp:237:17 [cppcoreguidelines-init-variables]

variable 'list' is not initialized
if (!ok)
return false;

ASSERT(!folder_.endsWith(QLatin1String("/")));
QString folder = folder_ + QLatin1String("/");

for (auto &s : list) {
if (s.startsWith(folder)) {
const int item = list.indexOf(s);
list.removeAt(item);
}
}

journal->setSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, list);
return true;
}

void PropagateItemJob::done(const SyncFileItem::Status statusArg, const QString &errorString, const ErrorCategory category)
{
// Duplicate calls to done() are a logic error
Expand Down
3 changes: 3 additions & 0 deletions src/libsync/owncloudpropagator.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
#ifndef OWNCLOUDPROPAGATOR_H
#define OWNCLOUDPROPAGATOR_H

#include <QHash>

Check failure on line 10 in src/libsync/owncloudpropagator.h

View workflow job for this annotation

GitHub Actions / build

src/libsync/owncloudpropagator.h:10:10 [clang-diagnostic-error]

'QHash' file not found
#include <QObject>
#include <QMap>
#include <QElapsedTimer>
Expand Down Expand Up @@ -205,6 +205,9 @@
}
~PropagateItemJob() override;

static bool removePathFromSelectiveSyncRecursively(SyncJournalDb *journal, const QString &folder);
static bool addPathToSelectiveSync(SyncJournalDb *journal, const QString &folder);

bool scheduleSelfOrChild() override
{
if (_state != NotYetStarted) {
Expand Down
121 changes: 121 additions & 0 deletions src/libsync/propagateremotedelete.cpp
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/*
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2014 ownCloud GmbH
Expand Down Expand Up @@ -26,6 +26,64 @@
if (propagator()->_abortRequested)
return;

// Partial Delete Logic: Check if this folder has unsynced descendants
// This prevents data loss when deleting folders that contain selective sync exclusions
if (_item->isDirectory() && propagator() && propagator()->_journal &&
propagator()->_journal->hasSelectiveSyncDescendants(_item->_file))
{
qCInfo(lcPropagateRemoteDelete) << "Folder" << _item->_file
<< "has unsynced descendants. Performing partial deletion...";

// Get all synced descendants that need to be deleted
_syncedItemsToDelete = propagator()->_journal->getSyncedDescendants(_item->_file);
_syncedItemsToDelete.append(_item->_file);
_syncedItemsToKeep = _syncedItemsToDelete;
_syncedItemsToDelete.removeIf([this](const QString &path){return propagator()->_journal->hasSelectiveSyncDescendants(path);});
_syncedItemsToDelete.removeIf([this](const QString &path) {
SyncJournalFileRecord rec;
assert(propagator()->_journal->getFileRecord(path,&rec));
int endpos = path.lastIndexOf("/");
QString parentPath = path.left(endpos);
if ( rec.isDirectory()
|| propagator()->_journal->hasSelectiveSyncDescendants(parentPath)) {return false;};
return _syncedItemsToDelete.contains(parentPath);
});

// add folders for selectivesync blacklist, required to imitate a proper deletion to the user
_syncedItemsToKeep.removeIf([this](const QString &path) {
bool selectiveSynced = propagator()->_journal->hasSelectiveSyncDescendants(path);
SyncJournalFileRecord rec;
propagator()->_journal->getFileRecord(path,&rec);
if (not selectiveSynced || not rec.isDirectory()) {
return true;
}
int endpos = path.lastIndexOf("/");
QString parentPath = path.left(endpos);
return _syncedItemsToKeep.contains(parentPath); // deduplicates blacklist entries
});
for (QString path: _syncedItemsToKeep) {
if (!PropagateItemJob::removePathFromSelectiveSyncRecursively(propagator()->_journal, path)) {
return;
}
if (!PropagateItemJob::addPathToSelectiveSync(propagator()->_journal, path)) {
return;
}
}
if (!_syncedItemsToDelete.isEmpty()) {
qCInfo(lcPropagateRemoteDelete) << "Partial deletion: deleting" << _syncedItemsToDelete.size()
<< "synced items while keeping unsynced content";

// Start partial deletion process
deleteNextSyncedItem();
return;
}
// No synced children to delete, just skip this operation
// The folder was never synced locally, so nothing to delete
qCInfo(lcPropagateRemoteDelete) << "No synced items to delete in folder with unsynced descendants. Skipping.";
done(SyncFileItem::Success, {}, ErrorCategory::NoError);
return;
}

if (!_item->_encryptedFileName.isEmpty() || _item->isEncrypted()) {
if (!_item->_encryptedFileName.isEmpty()) {
_deleteEncryptedHelper = new PropagateRemoteDeleteEncrypted(propagator(), _item, this);
Expand Down Expand Up @@ -129,4 +187,67 @@

done(SyncFileItem::Success, {}, ErrorCategory::NoError);
}

void PropagateRemoteDelete::deleteNextSyncedItem()
{
if (_currentDeleteIndex >= _syncedItemsToDelete.size()) {
// All synced items have been deleted successfully
// The unsynced content remains on the server
qCInfo(lcPropagateRemoteDelete) << "Partial deletion complete. Deleted" << _syncedItemsToDelete.size()
<< "synced items while preserving unsynced content.";
done(SyncFileItem::Success, {}, ErrorCategory::NoError);
return;
}

QString itemPath = _syncedItemsToDelete[_currentDeleteIndex];

qCInfo(lcPropagateRemoteDelete) << "Partial deletion: deleting item" << (_currentDeleteIndex + 1)
<< "of" << _syncedItemsToDelete.size() << ":" << itemPath;

createPartialDeleteJob(itemPath);
}

void PropagateRemoteDelete::createPartialDeleteJob(const QString &remoteFilename)
{
Q_ASSERT(propagator());

auto headers = QMap<QByteArray, QByteArray>{};
if (_item->_locked == SyncFileItem::LockStatus::LockedItem) {
headers[QByteArrayLiteral("If")] = (QLatin1String("<") + propagator()->account()->davUrl().toString() + _item->_file + "> (<opaquelocktoken:" + _item->_lockToken.toUtf8() + ">)").toUtf8();
}

_job = new DeleteJob(propagator()->account(), propagator()->fullRemotePath(remoteFilename), headers, this);
_job->setSkipTrashbin(_item->_wantsSpecificActions == SyncFileItem::SynchronizationOptions::WantsPermanentDeletion);
connect(_job.data(), &DeleteJob::finishedSignal, this, &PropagateRemoteDelete::slotPartialDeleteJobFinished);
propagator()->_activeJobList.append(this);
_job->start();
}

void PropagateRemoteDelete::slotPartialDeleteJobFinished()
{
propagator()->_activeJobList.removeOne(this);

ASSERT(_job);

QNetworkReply::NetworkError err = _job->reply()->error();
const int httpStatus = _job->reply()->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();

// Check if it was already deleted (404 is ok for partial deletion)
if (err != QNetworkReply::NoError && err != QNetworkReply::ContentNotFoundError) {
qCWarning(lcPropagateRemoteDelete) << "Partial deletion failed for item:" << _currentDeleteIndex << err;
done(SyncFileItem::SoftError, _job->errorString(), ErrorCategory::GenericError);
return;
}

// Delete the file record from the local database
QString itemPath = _syncedItemsToDelete[_currentDeleteIndex];
// todo: do I need an if(isDirectory) then recursively = true else false?
if (!propagator()->_journal->deleteFileRecord(itemPath, false)) {
qCWarning(lcPropagateRemoteDelete) << "Could not delete file record from local DB:" << itemPath;
}

// Move to next item
_currentDeleteIndex++;
deleteNextSyncedItem();
}
}
9 changes: 9 additions & 0 deletions src/libsync/propagateremotedelete.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,14 @@ class PropagateRemoteDelete : public PropagateItemJob

private slots:
void slotDeleteJobFinished();
void slotPartialDeleteJobFinished();

private:
void deleteNextSyncedItem();
void createPartialDeleteJob(const QString &filename);

QStringList _syncedItemsToDelete;
QStringList _syncedItemsToKeep;
int _currentDeleteIndex = 0;
};
}
Loading