Files
David Dreschner ebbed9453a fix(files): Continue scanning after StorageInvalidException
When a federated share was already removed on the remote end, accessing it triggers a `StorageInvalidException` after the share and its mount are removed locally. When running `occ files:scan`, this exception isn't caught by the scanner and aborts the scan of all remaining mounts of that user, printing a stack trace instead. This can be pretty frustrating on larger instances with many federated shares.

To avoid that, the `StorageInvalidException` is now caught and handled the same way as the `StorageNotAvailableException`: log it as an error, report it through the `StorageNotAvailable` hook and continue with the next mount.

Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: David Dreschner <david.dreschner@nextcloud.com>
2026-11-04 13:39:17 +00:00

291 lines
8.7 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\Files\Utils;
use OC\Files\Filesystem;
use OC\Files\Mount\MountPoint;
use OC\Files\SetupManager;
use OC\Files\Storage\Temporary;
use OC\Files\Utils\Scanner;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Config\IMountProvider;
use OCP\Files\Config\IMountProviderCollection;
use OCP\Files\Storage\IStorageFactory;
use OCP\Files\StorageInvalidException;
use OCP\Files\StorageNotAvailableException;
use OCP\IDBConnection;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Server;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use Psr\Log\LoggerInterface;
use Test\TestCase;
use Test\Util\User\Dummy;
class TestScanner extends Scanner {
/** @var array<string, MountPoint> $mounts */
private array $mounts = [];
public function addMount(MountPoint $mount): void {
$this->mounts[$mount->getMountPoint()] = $mount;
}
#[\Override]
protected function getMounts(string $dir): array {
return $this->mounts;
}
}
#[Group('DB')]
class ScannerTest extends TestCase {
private Dummy $userBackend;
#[\Override]
protected function setUp(): void {
parent::setUp();
$this->userBackend = new Dummy();
Server::get(IUserManager::class)->registerBackend($this->userBackend);
$this->loginAsUser();
}
#[\Override]
protected function tearDown(): void {
$this->logout();
Server::get(IUserManager::class)->removeBackend($this->userBackend);
parent::tearDown();
}
public function testReuseExistingRoot(): void {
$storage = new Temporary([]);
$mount = new MountPoint($storage, '');
Filesystem::getMountManager()->addMount($mount);
$cache = $storage->getCache();
$storage->mkdir('folder');
$storage->file_put_contents('foo.txt', 'qwerty');
$storage->file_put_contents('folder/bar.txt', 'qwerty');
$scanner = new TestScanner(
Server::get(IUserManager::class)->get(''),
Server::get(IDBConnection::class),
$this->createMock(IEventDispatcher::class),
Server::get(LoggerInterface::class),
Server::get(SetupManager::class),
);
$scanner->addMount($mount);
$scanner->scan('');
$this->assertTrue($cache->inCache('folder/bar.txt'));
$oldRoot = $cache->get('');
$scanner->scan('');
$newRoot = $cache->get('');
$this->assertEquals($oldRoot, $newRoot);
}
public function testReuseExistingFile(): void {
$storage = new Temporary([]);
$mount = new MountPoint($storage, '');
Filesystem::getMountManager()->addMount($mount);
$cache = $storage->getCache();
$storage->mkdir('folder');
$storage->file_put_contents('foo.txt', 'qwerty');
$storage->file_put_contents('folder/bar.txt', 'qwerty');
$scanner = new TestScanner(
Server::get(IUserManager::class)->get(''),
Server::get(IDBConnection::class),
$this->createMock(IEventDispatcher::class),
Server::get(LoggerInterface::class),
Server::get(SetupManager::class),
);
$scanner->addMount($mount);
$scanner->scan('');
$this->assertTrue($cache->inCache('folder/bar.txt'));
$old = $cache->get('folder/bar.txt');
$scanner->scan('');
$new = $cache->get('folder/bar.txt');
$this->assertEquals($old, $new);
}
public function testScanSubMount(): void {
$uid = $this->getUniqueID();
$this->userBackend->createUser($uid, 'test');
$mountProvider = $this->createMock(IMountProvider::class);
$storage = new Temporary([]);
$mount = new MountPoint($storage, '/' . $uid . '/files/foo');
$mountProvider->expects($this->any())
->method('getMountsForUser')
->willReturnCallback(function (IUser $user, IStorageFactory $storageFactory) use ($mount, $uid) {
if ($user->getUID() === $uid) {
return [$mount];
} else {
return [];
}
});
Server::get(IMountProviderCollection::class)->registerProvider($mountProvider);
$cache = $storage->getCache();
$storage->mkdir('folder');
$storage->file_put_contents('foo.txt', 'qwerty');
$storage->file_put_contents('folder/bar.txt', 'qwerty');
$scanner = new Scanner(
Server::get(IUserManager::class)->get($uid),
Server::get(IDBConnection::class),
Server::get(IEventDispatcher::class),
Server::get(LoggerInterface::class),
Server::get(SetupManager::class),
);
$this->assertFalse($cache->inCache('folder/bar.txt'));
$scanner->scan('/' . $uid . '/files/foo');
$this->assertTrue($cache->inCache('folder/bar.txt'));
}
public static function invalidPathProvider(): \Generator {
yield [ '../' ];
yield [ '..\\' ];
yield [ '../..\\../' ];
}
#[DataProvider(methodName: 'invalidPathProvider')]
public function testInvalidPathScanning(string $invalidPath): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid path to scan');
$scanner = new TestScanner(
Server::get(IUserManager::class)->get(''),
Server::get(IDBConnection::class),
$this->createMock(IEventDispatcher::class),
Server::get(LoggerInterface::class),
Server::get(SetupManager::class),
);
$scanner->scan($invalidPath);
}
public function testPropagateEtag(): void {
$storage = new Temporary([]);
$mount = new MountPoint($storage, '');
Filesystem::getMountManager()->addMount($mount);
$cache = $storage->getCache();
$storage->mkdir('folder');
$storage->file_put_contents('folder/bar.txt', 'qwerty');
$storage->touch('folder/bar.txt', time() - 200);
$scanner = new TestScanner(
Server::get(IUserManager::class)->get(''),
Server::get(IDBConnection::class),
$this->createMock(IEventDispatcher::class),
Server::get(LoggerInterface::class),
Server::get(SetupManager::class),
);
$scanner->addMount($mount);
$scanner->scan('');
$this->assertTrue($cache->inCache('folder/bar.txt'));
$oldRoot = $cache->get('');
$storage->file_put_contents('folder/bar.txt', 'qwerty');
$scanner->scan('');
$newRoot = $cache->get('');
$this->assertNotEquals($oldRoot->getEtag(), $newRoot->getEtag());
}
public function testShallow(): void {
$storage = new Temporary([]);
$mount = new MountPoint($storage, '');
Filesystem::getMountManager()->addMount($mount);
$cache = $storage->getCache();
$storage->mkdir('folder');
$storage->mkdir('folder/subfolder');
$storage->file_put_contents('foo.txt', 'qwerty');
$storage->file_put_contents('folder/bar.txt', 'qwerty');
$storage->file_put_contents('folder/subfolder/foobar.txt', 'qwerty');
$scanner = new TestScanner(
Server::get(IUserManager::class)->get(''),
Server::get(IDBConnection::class),
$this->createMock(IEventDispatcher::class),
Server::get(LoggerInterface::class),
Server::get(SetupManager::class),
);
$scanner->addMount($mount);
$scanner->scan('', false);
$this->assertTrue($cache->inCache('folder'));
$this->assertFalse($cache->inCache('folder/subfolder'));
$this->assertTrue($cache->inCache('foo.txt'));
$this->assertFalse($cache->inCache('folder/bar.txt'));
$this->assertFalse($cache->inCache('folder/subfolder/foobar.txt'));
$scanner->scan('folder', false);
$this->assertTrue($cache->inCache('folder'));
$this->assertTrue($cache->inCache('folder/subfolder'));
$this->assertTrue($cache->inCache('foo.txt'));
$this->assertTrue($cache->inCache('folder/bar.txt'));
$this->assertFalse($cache->inCache('folder/subfolder/foobar.txt'));
}
public function testScanContinuesAfterInvalidStorage(): void {
$invalidStorage = new class([]) extends Temporary {
#[\Override]
public function getMetaData(string $path): ?array {
throw new StorageInvalidException('Remote share not found');
}
};
$storage = new Temporary([]);
$storage->file_put_contents('foo.txt', 'qwerty');
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())
->method('error')
->with(
'Storage ' . $invalidStorage->getId() . ' not available',
$this->callback(fn (array $context): bool => $context['exception'] instanceof StorageInvalidException),
);
$scanner = new TestScanner(
Server::get(IUserManager::class)->get(''),
Server::get(IDBConnection::class),
$this->createMock(IEventDispatcher::class),
$logger,
Server::get(SetupManager::class),
);
$scanner->addMount(new MountPoint($invalidStorage, '/invalid'));
$scanner->addMount(new MountPoint($storage, '/valid'));
$emitted = [];
$scanner->listen('\OC\Files\Utils\Scanner', 'StorageNotAvailable', function (StorageNotAvailableException|StorageInvalidException $e) use (&$emitted): void {
$emitted[] = $e;
});
$scanner->scan('');
$this->assertCount(1, $emitted);
$this->assertInstanceOf(StorageInvalidException::class, $emitted[0]);
$this->assertSame('Remote share not found', $emitted[0]->getMessage());
$this->assertTrue($storage->getCache()->inCache('foo.txt'));
}
}