Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit db99631

Browse files
Merge pull request #4498 from nextcloud/backport/4496/stable33
2 parents cf6e5ca + b99fe30 commit db99631

2 files changed

Lines changed: 87 additions & 12 deletions

File tree

β€Žlib/Trash/TrashBackend.phpβ€Ž

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -251,24 +251,50 @@ public function moveToTrash(IStorage $storage, string $internalPath): bool {
251251

252252
$trashFolder = $this->setupTrashFolder($folder, $storage->getUser());
253253
$trashStorage = $trashFolder->getStorage();
254+
255+
$originalLocation = $internalPath;
256+
if ($storage->instanceOfStorage(ISharedStorage::class)) {
257+
/** @var Jail $jail */
258+
$jail = $storage->getWrapperStorage();
259+
$originalLocation = $jail->getUnjailedPath($originalLocation);
260+
}
261+
262+
$deletedBy = $this->userSession->getUser();
263+
264+
// Insert the trash record before moving the file so that the
265+
// on-disk name already matches the confirmed deleted_time.
266+
// The unique constraint on (folder_id, name, deleted_time) uses
267+
// second granularity, so concurrent deletes of same-named files
268+
// can collide. Retry with an incremented timestamp on conflict.
254269
$time = time();
270+
for ($retry = 0; $retry < 5; $retry++) {
271+
try {
272+
$this->trashManager->addTrashItem($folderId, $name, $time, $originalLocation, $fileEntry->getId(), $deletedBy?->getUID() ?? '');
273+
break;
274+
} catch (\OCP\DB\Exception $e) {
275+
if ($e->getReason() === \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
276+
$time++;
277+
} else {
278+
throw $e;
279+
}
280+
}
281+
}
282+
255283
$trashName = $name . '.d' . $time;
256284
$targetInternalPath = $trashFolder->getInternalPath() . '/' . $trashName;
257285
// until the fix from https://github.com/nextcloud/server/pull/49262 is in all versions we support we need to manually disable the optimization
258-
if ($storage->instanceOfStorage(Encryption::class)) {
259-
$result = $this->moveFromEncryptedStorage($storage, $trashStorage, $internalPath, $targetInternalPath);
260-
} else {
261-
$result = $trashStorage->moveFromStorage($storage, $internalPath, $targetInternalPath);
286+
try {
287+
if ($storage->instanceOfStorage(Encryption::class)) {
288+
$result = $this->moveFromEncryptedStorage($storage, $trashStorage, $internalPath, $targetInternalPath);
289+
} else {
290+
$result = $trashStorage->moveFromStorage($storage, $internalPath, $targetInternalPath);
291+
}
292+
} catch (\Exception $e) {
293+
// Move threw β€” clean up the DB record to avoid an orphaned trash entry
294+
$this->trashManager->removeItem($folderId, $name, $time);
295+
throw $e;
262296
}
263297
if ($result) {
264-
$originalLocation = $internalPath;
265-
if ($storage->instanceOfStorage(ISharedStorage::class)) {
266-
$originalLocation = $storage->getWrapperStorage()->getUnjailedPath($originalLocation);
267-
}
268-
269-
$deletedBy = $this->userSession->getUser();
270-
$this->trashManager->addTrashItem($folderId, $name, $time, $originalLocation, $fileEntry->getId(), $deletedBy?->getUID() ?? '');
271-
272298
// some storage backends (object/encryption) can either already move the cache item or cause the target to be scanned
273299
// so we only conditionally do the cache move here
274300
if (!$trashStorage->getCache()->inCache($targetInternalPath)) {
@@ -279,6 +305,8 @@ public function moveToTrash(IStorage $storage, string $internalPath): bool {
279305
$storage->getCache()->remove($internalPath);
280306
}
281307
} else {
308+
// Move failed β€” clean up the DB record to avoid an orphaned trash entry
309+
$this->trashManager->removeItem($folderId, $name, $time);
282310
throw new \Exception('Failed to move Team folder item to trash');
283311
}
284312

β€Žtests/Trash/TrashBackendTest.phpβ€Ž

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use OCA\GroupFolders\Folder\FolderManager;
2121
use OCA\GroupFolders\Mount\GroupFolderStorage;
2222
use OCA\GroupFolders\Trash\TrashBackend;
23+
use OCA\GroupFolders\Trash\TrashManager;
2324
use OCP\Constants;
2425
use OCP\Files\Folder;
2526
use OCP\Files\IRootFolder;
@@ -37,6 +38,7 @@ class TrashBackendTest extends TestCase {
3738

3839
private string $folderName;
3940
private TrashBackend $trashBackend;
41+
private TrashManager $trashManager;
4042
private FolderManager $folderManager;
4143
private ACLManager $aclManager;
4244
private RuleManager $ruleManager;
@@ -62,6 +64,7 @@ public function setUp(): void {
6264
$groupBackend->addToGroup('normal', 'gf_normal');
6365

6466
$this->trashBackend = \OCP\Server::get(TrashBackend::class);
67+
$this->trashManager = \OCP\Server::get(TrashManager::class);
6568
$this->folderManager = \OCP\Server::get(FolderManager::class);
6669
/** @var ACLManagerFactory $aclManagerFactory */
6770
$aclManagerFactory = \OCP\Server::get(ACLManagerFactory::class);
@@ -291,4 +294,48 @@ public function testWrongOriginalLocation(): void {
291294
$this->trashBackend->restoreItem($trashedOfUserA[0]);
292295
$this->assertTrue($userAFolder->nodeExists('D/E/F/G'));
293296
}
297+
298+
public function testMoveToTrashSameNameDifferentSubfolders(): void {
299+
$this->loginAsUser('manager');
300+
301+
// Create two subfolders each containing a file with the same name
302+
$folderA = $this->managerUserFolder->newFolder("{$this->folderName}/SubA");
303+
$folderB = $this->managerUserFolder->newFolder("{$this->folderName}/SubB");
304+
$fileA = $folderA->newFile('Readme.md', 'content A');
305+
$fileB = $folderB->newFile('Readme.md', 'content B');
306+
307+
$this->assertTrue($this->managerUserFolder->nodeExists("{$this->folderName}/SubA/Readme.md"));
308+
$this->assertTrue($this->managerUserFolder->nodeExists("{$this->folderName}/SubB/Readme.md"));
309+
310+
// Wait for the start of a new second so both deletes land in the same
311+
// second and deterministically exercise the retry path.
312+
$now = time();
313+
while (time() === $now) {
314+
usleep(10000);
315+
}
316+
317+
// Delete both files β€” they share the same base name within the same
318+
// group folder which previously triggered a unique constraint violation
319+
// on (folder_id, name, deleted_time) when both deletes hit the same second.
320+
$this->trashBackend->moveToTrash($fileA->getStorage(), $fileA->getInternalPath());
321+
$this->trashBackend->moveToTrash($fileB->getStorage(), $fileB->getInternalPath());
322+
323+
$this->assertFalse($this->managerUserFolder->nodeExists("{$this->folderName}/SubA/Readme.md"));
324+
$this->assertFalse($this->managerUserFolder->nodeExists("{$this->folderName}/SubB/Readme.md"));
325+
326+
// Both files must appear in the trash
327+
$trashItems = $this->trashBackend->listTrashRoot($this->managerUser);
328+
$this->assertCount(2, $trashItems, 'Both Readme.md files should be in the trash');
329+
330+
// The two trash entries must have distinct deleted_time values
331+
$times = array_map(fn ($item) => $item->getDeletedTime(), $trashItems);
332+
$this->assertCount(2, array_unique($times), 'Trash entries should have distinct deleted_time values');
333+
334+
// Verify the DB records exist
335+
$dbRows = $this->trashManager->listTrashForFolders([$this->folderId]);
336+
$dbReadmeRows = array_values(array_filter($dbRows, fn ($row) => $row['name'] === 'Readme.md'));
337+
$this->assertCount(2, $dbReadmeRows, 'Both Readme.md entries should exist in oc_group_folders_trash');
338+
339+
$this->logout();
340+
}
294341
}

0 commit comments

Comments
Β (0)