Skip to content

Commit e761005

Browse files
authored
Merge pull request #57360 from nextcloud/fix/trashbin-atomic-cache
fix(trashbin): keep cache and db consistent
2 parents a30653c + 0317e00 commit e761005

File tree

2 files changed

+171
-30
lines changed

2 files changed

+171
-30
lines changed

apps/files_trashbin/lib/Trashbin.php

Lines changed: 88 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -299,18 +299,65 @@ public static function move2trash($file_path, $ownerOnly = false) {
299299

300300
$configuredTrashbinSize = static::getConfiguredTrashbinSize($owner);
301301
if ($configuredTrashbinSize >= 0 && $sourceInfo->getSize() >= $configuredTrashbinSize) {
302+
$trashStorage->releaseLock($trashInternalPath, ILockingProvider::LOCK_EXCLUSIVE, $lockingProvider);
302303
return false;
303304
}
304305

306+
// there is still a possibility that the file has been deleted by a remote user
307+
$deletedBy = self::overwriteDeletedBy($user);
308+
309+
$query = Server::get(IDBConnection::class)->getQueryBuilder();
310+
$query->insert('files_trash')
311+
->setValue('id', $query->createNamedParameter($filename))
312+
->setValue('timestamp', $query->createNamedParameter($timestamp))
313+
->setValue('location', $query->createNamedParameter($location))
314+
->setValue('user', $query->createNamedParameter($owner))
315+
->setValue('deleted_by', $query->createNamedParameter($deletedBy));
316+
$inserted = false;
305317
try {
306-
$moveSuccessful = true;
318+
$inserted = ($query->executeStatement() === 1);
319+
} catch (\Throwable $e) {
320+
Server::get(LoggerInterface::class)->error(
321+
'trash bin database insert failed',
322+
[
323+
'app' => 'files_trashbin',
324+
'exception' => $e,
325+
'user' => $owner,
326+
'filename' => $filename,
327+
'timestamp' => $timestamp,
328+
]
329+
);
330+
}
331+
if (!$inserted) {
332+
Server::get(LoggerInterface::class)->error(
333+
'trash bin database couldn\'t be updated, skipping trash move',
334+
[
335+
'app' => 'files_trashbin',
336+
'user' => $owner,
337+
'filename' => $filename,
338+
'timestamp' => $timestamp,
339+
]
340+
);
341+
$trashStorage->releaseLock($trashInternalPath, ILockingProvider::LOCK_EXCLUSIVE, $lockingProvider);
342+
return false;
343+
}
307344

345+
$moveSuccessful = true;
346+
try {
308347
$inCache = $sourceStorage->getCache()->inCache($sourceInternalPath);
309348
$trashStorage->moveFromStorage($sourceStorage, $sourceInternalPath, $trashInternalPath);
310349
if ($inCache) {
311350
$trashStorage->getUpdater()->renameFromStorage($sourceStorage, $sourceInternalPath, $trashInternalPath);
351+
} else {
352+
$sizeDifference = $sourceInfo->getSize();
353+
if ($sizeDifference < 0) {
354+
$sizeDifference = null;
355+
} else {
356+
$sizeDifference = (int)$sizeDifference;
357+
}
358+
$trashStorage->getUpdater()->update($trashInternalPath, null, $sizeDifference);
312359
}
313-
} catch (CopyRecursiveException $e) {
360+
} catch (\Exception $e) {
314361
$moveSuccessful = false;
315362
if ($trashStorage->file_exists($trashInternalPath)) {
316363
$trashStorage->unlink($trashInternalPath);
@@ -331,24 +378,31 @@ public static function move2trash($file_path, $ownerOnly = false) {
331378
} else {
332379
$trashStorage->getUpdater()->remove($trashInternalPath);
333380
}
334-
return false;
381+
$moveSuccessful = false;
335382
}
336383

337-
if ($moveSuccessful) {
338-
// there is still a possibility that the file has been deleted by a remote user
339-
$deletedBy = self::overwriteDeletedBy($user);
340-
341-
$query = Server::get(IDBConnection::class)->getQueryBuilder();
342-
$query->insert('files_trash')
343-
->setValue('id', $query->createNamedParameter($filename))
344-
->setValue('timestamp', $query->createNamedParameter($timestamp))
345-
->setValue('location', $query->createNamedParameter($location))
346-
->setValue('user', $query->createNamedParameter($owner))
347-
->setValue('deleted_by', $query->createNamedParameter($deletedBy));
348-
$result = $query->executeStatement();
349-
if (!$result) {
350-
Server::get(LoggerInterface::class)->error('trash bin database couldn\'t be updated', ['app' => 'files_trashbin']);
384+
if (!$moveSuccessful) {
385+
Server::get(LoggerInterface::class)->error(
386+
'trash move failed, removing trash metadata and payload',
387+
[
388+
'app' => 'files_trashbin',
389+
'user' => $owner,
390+
'filename' => $filename,
391+
'timestamp' => $timestamp,
392+
]
393+
);
394+
self::deleteTrashRow($user, $filename, $timestamp);
395+
if ($trashStorage->file_exists($trashInternalPath)) {
396+
if ($trashStorage->is_dir($trashInternalPath)) {
397+
$trashStorage->rmdir($trashInternalPath);
398+
} else {
399+
$trashStorage->unlink($trashInternalPath);
400+
}
351401
}
402+
$trashStorage->getUpdater()->remove($trashInternalPath);
403+
}
404+
405+
if ($moveSuccessful) {
352406
Util::emitHook('\OCA\Files_Trashbin\Trashbin', 'post_moveToTrash', ['filePath' => Filesystem::normalizePath($file_path),
353407
'trashPath' => Filesystem::normalizePath(static::getTrashFilename($filename, $timestamp))]);
354408

@@ -545,12 +599,7 @@ public static function restore($file, $filename, $timestamp) {
545599
self::restoreVersions($view, $file, $filename, $uniqueFilename, $location, $timestamp);
546600

547601
if ($timestamp) {
548-
$query = Server::get(IDBConnection::class)->getQueryBuilder();
549-
$query->delete('files_trash')
550-
->where($query->expr()->eq('user', $query->createNamedParameter($user)))
551-
->andWhere($query->expr()->eq('id', $query->createNamedParameter($filename)))
552-
->andWhere($query->expr()->eq('timestamp', $query->createNamedParameter($timestamp)));
553-
$query->executeStatement();
602+
self::deleteTrashRow($user, $filename, $timestamp);
554603
}
555604

556605
return true;
@@ -689,13 +738,6 @@ public static function delete($filename, $user, $timestamp = null) {
689738
$size = 0;
690739

691740
if ($timestamp) {
692-
$query = Server::get(IDBConnection::class)->getQueryBuilder();
693-
$query->delete('files_trash')
694-
->where($query->expr()->eq('user', $query->createNamedParameter($user)))
695-
->andWhere($query->expr()->eq('id', $query->createNamedParameter($filename)))
696-
->andWhere($query->expr()->eq('timestamp', $query->createNamedParameter($timestamp)));
697-
$query->executeStatement();
698-
699741
$file = static::getTrashFilename($filename, $timestamp);
700742
} else {
701743
$file = $filename;
@@ -706,6 +748,9 @@ public static function delete($filename, $user, $timestamp = null) {
706748
try {
707749
$node = $userRoot->get('/files_trashbin/files/' . $file);
708750
} catch (NotFoundException $e) {
751+
if ($timestamp) {
752+
self::deleteTrashRow($user, $filename, $timestamp);
753+
}
709754
return $size;
710755
}
711756

@@ -719,9 +764,22 @@ public static function delete($filename, $user, $timestamp = null) {
719764
$node->delete();
720765
self::emitTrashbinPostDelete('/files_trashbin/files/' . $file);
721766

767+
if ($timestamp) {
768+
self::deleteTrashRow($user, $filename, $timestamp);
769+
}
770+
722771
return $size;
723772
}
724773

774+
private static function deleteTrashRow(string $user, string $filename, int $timestamp): void {
775+
$query = Server::get(IDBConnection::class)->getQueryBuilder();
776+
$query->delete('files_trash')
777+
->where($query->expr()->eq('user', $query->createNamedParameter($user)))
778+
->andWhere($query->expr()->eq('id', $query->createNamedParameter($filename)))
779+
->andWhere($query->expr()->eq('timestamp', $query->createNamedParameter($timestamp)));
780+
$query->executeStatement();
781+
}
782+
725783
/**
726784
* @param string $file
727785
* @param string $filename

apps/files_trashbin/tests/StorageTest.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,21 @@
66
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
77
* SPDX-License-Identifier: AGPL-3.0-only
88
*/
9+
910
namespace OCA\Files_Trashbin\Tests;
1011

12+
use OC\Files\Cache\Updater;
1113
use OC\Files\Filesystem;
14+
use OC\Files\ObjectStore\ObjectStoreStorage;
1215
use OC\Files\Storage\Common;
16+
use OC\Files\Storage\Local;
1317
use OC\Files\Storage\Temporary;
1418
use OC\Files\View;
1519
use OCA\Files_Trashbin\AppInfo\Application;
1620
use OCA\Files_Trashbin\Events\MoveToTrashEvent;
1721
use OCA\Files_Trashbin\Storage;
1822
use OCA\Files_Trashbin\Trash\ITrashManager;
23+
use OCA\Files_Trashbin\Trashbin;
1924
use OCP\AppFramework\Bootstrap\IBootContext;
2025
use OCP\AppFramework\Utility\ITimeFactory;
2126
use OCP\Constants;
@@ -118,6 +123,84 @@ public function testSingleStorageDeleteFile(): void {
118123
$this->assertEquals('test.txt', substr($name, 0, strrpos($name, '.')));
119124
}
120125

126+
public function testTrashEntryCreatedWhenSourceNotInCache(): void {
127+
$this->userView->file_put_contents('uncached.txt', 'foo');
128+
129+
[$storage, $internalPath] = $this->userView->resolvePath('uncached.txt');
130+
if ($storage->instanceOfStorage(ObjectStoreStorage::class)) {
131+
$this->markTestSkipped('object store always has the file in cache');
132+
}
133+
$cache = $storage->getCache();
134+
$cache->remove($internalPath);
135+
$this->assertFalse($cache->inCache($internalPath));
136+
137+
$this->userView->unlink('uncached.txt');
138+
139+
$results = $this->rootView->getDirectoryContent($this->user . '/files_trashbin/files/');
140+
$this->assertCount(1, $results);
141+
$name = $results[0]->getName();
142+
$this->assertEquals('uncached.txt', substr($name, 0, strrpos($name, '.')));
143+
144+
[$trashStorage, $trashInternalPath] = $this->rootView->resolvePath('/' . $this->user . '/files_trashbin/files/' . $name);
145+
$this->assertTrue($trashStorage->getCache()->inCache($trashInternalPath));
146+
}
147+
148+
public function testTrashEntryNotCreatedWhenDeleteFailed(): void {
149+
$storage2 = $this->getMockBuilder(Temporary::class)
150+
->setConstructorArgs([])
151+
->onlyMethods(['unlink', 'instanceOfStorage'])
152+
->getMock();
153+
$storage2->method('unlink')
154+
->willReturn(false);
155+
156+
// disable same-storage move optimization
157+
$storage2->method('instanceOfStorage')
158+
->willReturnCallback(fn (string $class) => ($class !== Local::class) && (new Temporary([]))->instanceOfStorage($class));
159+
160+
161+
Filesystem::mount($storage2, [], $this->user . '/files/substorage');
162+
$this->userView->file_put_contents('substorage/test.txt', 'foo');
163+
164+
$this->assertFalse($this->userView->unlink('substorage/test.txt'));
165+
166+
$results = $this->rootView->getDirectoryContent($this->user . '/files_trashbin/files/');
167+
$this->assertEmpty($results);
168+
169+
$trashData = Trashbin::getExtraData($this->user);
170+
$this->assertEmpty($trashData);
171+
}
172+
173+
public function testTrashEntryNotCreatedWhenCacheRowFailed(): void {
174+
$trashStorage = $this->getMockBuilder(Temporary::class)
175+
->setConstructorArgs([])
176+
->onlyMethods(['getUpdater'])
177+
->getMock();
178+
$updater = $this->getMockBuilder(Updater::class)
179+
->setConstructorArgs([$trashStorage])
180+
->onlyMethods(['renameFromStorage'])
181+
->getMock();
182+
$trashStorage->method('getUpdater')
183+
->willReturn($updater);
184+
$updater->method('renameFromStorage')
185+
->willThrowException(new \Exception());
186+
187+
Filesystem::mount($trashStorage, [], $this->user . '/files_trashbin');
188+
$this->userView->file_put_contents('test.txt', 'foo');
189+
190+
try {
191+
$this->assertFalse($this->userView->unlink('test.txt'));
192+
$this->fail();
193+
} catch (\Exception) {
194+
// expected
195+
}
196+
197+
$results = $this->rootView->getDirectoryContent($this->user . '/files_trashbin/files/');
198+
$this->assertEmpty($results);
199+
200+
$trashData = Trashbin::getExtraData($this->user);
201+
$this->assertEmpty($trashData);
202+
}
203+
121204
/**
122205
* Test that deleting a folder puts it into the trashbin.
123206
*/

0 commit comments

Comments
 (0)