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
16 changes: 14 additions & 2 deletions lib/Trash/TrashBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,18 @@ private function unwrapJails(IStorage $storage, string $internalPath): array {
return [$unJailedStorage, $unJailedInternalPath];
}

/**
* An item that is already gone from storage is not a failure to delete: its cache
* entry and trash record still have to be cleaned up, otherwise an interrupted
* delete leaves the item stuck in the trashbin forever.
*/
private function unlinkTrashNode(Node $node): bool {
$storage = $node->getStorage();
$internalPath = $node->getInternalPath();

return $storage->unlink($internalPath) !== false || !$storage->file_exists($internalPath);
}

/**
* @throws \LogicException
* @throws \Exception
Expand All @@ -247,7 +259,7 @@ public function removeItem(ITrashItem $item): void {
throw new NotPermittedException();
}

if ($node->getStorage()->unlink($node->getInternalPath()) === false) {
if (!$this->unlinkTrashNode($node)) {
throw new \Exception('Failed to remove item from trashbin');
}

Expand Down Expand Up @@ -656,7 +668,7 @@ public function expire(Expiration $expiration): array {

if ($expiration->isExpired($groupTrashItem['deleted_time'], $folder->quota > 0 && $folder->quota < ($size + $sizeInTrash))) {
$this->logger->debug('expiring ' . $node->getPath());
if ($node->getStorage()->unlink($node->getInternalPath()) === false) {
if (!$this->unlinkTrashNode($node)) {
$this->logger->error('Failed to remove item from trashbin: ' . $node->getPath());
continue;
}
Expand Down
73 changes: 73 additions & 0 deletions tests/Trash/TrashBackendTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

use OC\Files\SetupManager;
use OC\Group\Database;
use OCA\Files_Trashbin\Expiration;
use OCA\Files_Trashbin\Trash\ITrashItem;
use OCA\GroupFolders\ACL\Rule;
use OCA\GroupFolders\ACL\RuleManager;
Expand All @@ -23,8 +24,10 @@
use OCA\GroupFolders\Trash\TrashManager;
use OCP\Constants;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\Cache\ICacheEntry;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\IDBConnection;
use OCP\IUser;
use OCP\Server;
Expand Down Expand Up @@ -412,4 +415,74 @@ public function testRepairMisplacedTrashItems(): void {

$this->logout();
}

/**
* Leave the item in the state an interrupted delete produces: the file is gone from
* storage while its cache entry is still there. Object stores drop the cache entry as
* part of unlink(), so it gets restored to reach the same state on every backend.
*/
private function removeTrashFileKeepingCacheEntry(Node $node): void {
$storage = $node->getStorage();
$internalPath = $node->getInternalPath();
$cache = $storage->getCache();

$entry = $cache->get($internalPath);
$this->assertInstanceOf(ICacheEntry::class, $entry);
$this->assertTrue($storage->unlink($internalPath));

if (!$cache->inCache($internalPath)) {
$cache->put($internalPath, [
'size' => $entry->getSize(),
'mtime' => $entry->getMTime(),
'storage_mtime' => $entry->getStorageMTime(),
'mimetype' => $entry->getMimeType(),
'etag' => $entry->getEtag(),
'permissions' => $entry->getPermissions(),
]);
}

$this->assertTrue($cache->inCache($internalPath));
}

public function testExpireWithMissingTrashFile(): void {
$this->loginAsUser('manager');

$file = $this->managerUserFolder->newFile("{$this->folderName}/gone.txt", 'content');
$this->trashBackend->moveToTrash($file->getStorage(), $file->getInternalPath());

/** @var list<GroupTrashItem> $items */
$items = $this->trashBackend->listTrashRoot($this->managerUser);
$this->assertCount(1, $items);

$this->removeTrashFileKeepingCacheEntry($items[0]->getTrashNode());

$expiration = $this->createMock(Expiration::class);
$expiration->method('isExpired')->willReturn(true);
$this->trashBackend->expire($expiration);

$this->assertCount(0, $this->trashManager->listTrashForFolders([$this->folderId]));
$this->assertCount(0, $this->trashBackend->listTrashRoot($this->managerUser));

$this->logout();
}

public function testRemoveItemWithMissingTrashFile(): void {
$this->loginAsUser('manager');

$file = $this->managerUserFolder->newFile("{$this->folderName}/gone.txt", 'content');
$this->trashBackend->moveToTrash($file->getStorage(), $file->getInternalPath());

/** @var list<GroupTrashItem> $items */
$items = $this->trashBackend->listTrashRoot($this->managerUser);
$this->assertCount(1, $items);

$this->removeTrashFileKeepingCacheEntry($items[0]->getTrashNode());

$this->trashBackend->removeItem($items[0]);

$this->assertCount(0, $this->trashManager->listTrashForFolders([$this->folderId]));
$this->assertCount(0, $this->trashBackend->listTrashRoot($this->managerUser));

$this->logout();
}
}
Loading