From 744b92823fa7510e7aadb65dc6be2cbb32081fab Mon Sep 17 00:00:00 2001 From: Edmond Date: Tue, 11 Aug 2026 17:25:46 -0400 Subject: [PATCH] fix(dav): fail propfind for unavailable mounts Assisted-by: Codex:GPT-5 Signed-off-by: Edmond --- .../composer/composer/autoload_classmap.php | 1 + .../dav/composer/composer/autoload_static.php | 1 + apps/dav/lib/Connector/Sabre/Directory.php | 33 +++- .../Sabre/PropFindMountAvailabilityPlugin.php | 52 +++++ .../dav/lib/Connector/Sabre/ServerFactory.php | 1 + apps/dav/lib/Server.php | 2 + .../unit/Connector/Sabre/DirectoryTest.php | 48 +++++ .../PropFindMountAvailabilityPluginTest.php | 161 +++++++++++++++ .../Sabre/RequestTest/ServerFactoryTest.php | 31 +++ apps/dav/tests/unit/ServerTest.php | 2 + lib/private/Files/View.php | 56 +++++- tests/lib/Files/ViewTest.php | 183 ++++++++++++++++++ 12 files changed, 563 insertions(+), 8 deletions(-) create mode 100644 apps/dav/lib/Connector/Sabre/PropFindMountAvailabilityPlugin.php create mode 100644 apps/dav/tests/unit/Connector/Sabre/PropFindMountAvailabilityPluginTest.php create mode 100644 apps/dav/tests/unit/Connector/Sabre/RequestTest/ServerFactoryTest.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 056e9fb9d5839..b7f7291934693 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -248,6 +248,7 @@ 'OCA\\DAV\\Connector\\Sabre\\ObjectTree' => $baseDir . '/../lib/Connector/Sabre/ObjectTree.php', 'OCA\\DAV\\Connector\\Sabre\\Principal' => $baseDir . '/../lib/Connector/Sabre/Principal.php', 'OCA\\DAV\\Connector\\Sabre\\PropFindMonitorPlugin' => $baseDir . '/../lib/Connector/Sabre/PropFindMonitorPlugin.php', + 'OCA\\DAV\\Connector\\Sabre\\PropFindMountAvailabilityPlugin' => $baseDir . '/../lib/Connector/Sabre/PropFindMountAvailabilityPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\PropFindPreloadNotifyPlugin' => $baseDir . '/../lib/Connector/Sabre/PropFindPreloadNotifyPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\PropfindCompressionPlugin' => $baseDir . '/../lib/Connector/Sabre/PropfindCompressionPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\PublicAuth' => $baseDir . '/../lib/Connector/Sabre/PublicAuth.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 840df1461d056..3f5decf0517e0 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -263,6 +263,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Connector\\Sabre\\ObjectTree' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ObjectTree.php', 'OCA\\DAV\\Connector\\Sabre\\Principal' => __DIR__ . '/..' . '/../lib/Connector/Sabre/Principal.php', 'OCA\\DAV\\Connector\\Sabre\\PropFindMonitorPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/PropFindMonitorPlugin.php', + 'OCA\\DAV\\Connector\\Sabre\\PropFindMountAvailabilityPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/PropFindMountAvailabilityPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\PropFindPreloadNotifyPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/PropFindPreloadNotifyPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\PropfindCompressionPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/PropfindCompressionPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\PublicAuth' => __DIR__ . '/..' . '/../lib/Connector/Sabre/PublicAuth.php', diff --git a/apps/dav/lib/Connector/Sabre/Directory.php b/apps/dav/lib/Connector/Sabre/Directory.php index 1634c512804b3..2c6d905f8a1e0 100644 --- a/apps/dav/lib/Connector/Sabre/Directory.php +++ b/apps/dav/lib/Connector/Sabre/Directory.php @@ -25,6 +25,7 @@ use OCP\Files\Mount\IMovableMount; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; +use OCP\Files\StorageInvalidException; use OCP\Files\StorageNotAvailableException; use OCP\IL10N; use OCP\IRequest; @@ -53,6 +54,7 @@ class Directory extends Node implements * @var FileInfo[] */ private ?array $dirContent = null; + private bool $dirContentIsStrict = false; /** Cached quota info */ private ?array $quotaInfo = null; @@ -251,7 +253,26 @@ public function getChild($name, $info = null, ?IRequest $request = null, ?IL10N */ #[\Override] public function getChildren() { - if (!is_null($this->dirContent)) { + return $this->getChildrenInternal(false); + } + + /** + * Return all child nodes, failing if an unavailable mount would make the listing incomplete. + * + * @return \Sabre\DAV\INode[] + * @throws \Sabre\DAV\Exception\Locked + * @throws \Sabre\DAV\Exception\ServiceUnavailable + * @throws Forbidden + */ + public function getChildrenStrict(): array { + return $this->getChildrenInternal(true); + } + + /** + * @return \Sabre\DAV\INode[] + */ + private function getChildrenInternal(bool $failOnUnavailableMount): array { + if (!is_null($this->dirContent) && (!$failOnUnavailableMount || $this->dirContentIsStrict)) { return $this->dirContent; } try { @@ -264,9 +285,16 @@ public function getChildren() { throw new Forbidden('No read permissions'); } } - $folderContent = $this->getNode()->getDirectoryListing(); + if ($failOnUnavailableMount) { + /** @psalm-suppress InternalMethod The Node API cannot request a strict mount-aware listing. */ + $folderContent = $this->fileView->getDirectoryContent($this->path, null, $this->info, true); + } else { + $folderContent = $this->getNode()->getDirectoryListing(); + } } catch (LockedException $e) { throw new Locked(); + } catch (StorageNotAvailableException|StorageInvalidException $e) { + throw new ServiceUnavailable('Storage is temporarily not available', 0, $e); } $nodes = []; @@ -278,6 +306,7 @@ public function getChildren() { $nodes[] = $node; } $this->dirContent = $nodes; + $this->dirContentIsStrict = $failOnUnavailableMount; return $this->dirContent; } diff --git a/apps/dav/lib/Connector/Sabre/PropFindMountAvailabilityPlugin.php b/apps/dav/lib/Connector/Sabre/PropFindMountAvailabilityPlugin.php new file mode 100644 index 0000000000000..7750623805247 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/PropFindMountAvailabilityPlugin.php @@ -0,0 +1,52 @@ +server = $server; + $this->server->on('method:PROPFIND', [$this, 'preflight'], 10); + } + + public function preflight(RequestInterface $request): void { + $depth = $this->server->getHTTPDepth(1); + if ($depth === 0) { + return; + } + + $node = $this->server->tree->getNodeForPath($request->getPath()); + if ($node instanceof Directory) { + $this->preflightDirectory($node, $depth === Server::DEPTH_INFINITY); + } + } + + private function preflightDirectory(Directory $directory, bool $recursive): void { + $children = $directory->getChildrenStrict(); + if (!$recursive) { + return; + } + + foreach ($children as $child) { + if ($child instanceof Directory) { + $this->preflightDirectory($child, true); + } + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php index 9639f59317b5b..7ca03b7a58299 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -102,6 +102,7 @@ public function createServer( $server->addPlugin(new PropFindMonitorPlugin()); } + $server->addPlugin(new PropFindMountAvailabilityPlugin()); $server->addPlugin(new PropFindPreloadNotifyPlugin()); // FIXME: The following line is a workaround for legacy components relying on being able to send a GET to / $server->addPlugin(new DummyGetResponsePlugin()); diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index c763bb6e58955..1321cc41e27ac 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -48,6 +48,7 @@ use OCA\DAV\Connector\Sabre\MaintenancePlugin; use OCA\DAV\Connector\Sabre\PropfindCompressionPlugin; use OCA\DAV\Connector\Sabre\PropFindMonitorPlugin; +use OCA\DAV\Connector\Sabre\PropFindMountAvailabilityPlugin; use OCA\DAV\Connector\Sabre\PropFindPreloadNotifyPlugin; use OCA\DAV\Connector\Sabre\QuotaPlugin; use OCA\DAV\Connector\Sabre\RequestIdHeaderPlugin; @@ -262,6 +263,7 @@ public function __construct( \OCP\Server::get(IDateTimeZone::class), )); $this->server->addPlugin(\OCP\Server::get(PaginatePlugin::class)); + $this->server->addPlugin(new PropFindMountAvailabilityPlugin()); $this->server->addPlugin(new PropFindPreloadNotifyPlugin()); // allow setup of additional plugins diff --git a/apps/dav/tests/unit/Connector/Sabre/DirectoryTest.php b/apps/dav/tests/unit/Connector/Sabre/DirectoryTest.php index 121871dd36484..3d1bf4b2f2f15 100644 --- a/apps/dav/tests/unit/Connector/Sabre/DirectoryTest.php +++ b/apps/dav/tests/unit/Connector/Sabre/DirectoryTest.php @@ -24,6 +24,7 @@ use OCP\Files\InvalidPathException; use OCP\Files\Mount\IMountPoint; use OCP\Files\Storage\IStorage; +use OCP\Files\StorageInvalidException; use OCP\Files\StorageNotAvailableException; use OCP\Lock\ILockingProvider; use PHPUnit\Framework\MockObject\MockObject; @@ -259,6 +260,53 @@ public function testGetChildren(): void { $dir->getChildren(); } + public function testGetChildrenStrictUsesStrictDirectoryListing(): void { + $this->view->expects($this->once()) + ->method('getDirectoryContent') + ->with('folder', null, $this->anything(), true) + ->willReturn([]); + $this->view->method('getRelativePath') + ->willReturnCallback(static fn ($path) => str_replace('/admin/files/', '', $path)); + $this->view->method('getAbsolutePath') + ->willReturn('/admin/files/folder'); + $this->overwriteService(View::class, $this->view); + + $dir = new Directory($this->view, $this->info); + $this->assertSame([], $dir->getChildrenStrict()); + + // The regular traversal reuses the strict result instead of listing the directory again. + $this->assertSame([], $dir->getChildren()); + } + + public static function strictStorageExceptionProvider(): array { + return [ + 'unavailable storage' => [StorageNotAvailableException::class], + 'invalid storage' => [StorageInvalidException::class], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('strictStorageExceptionProvider')] + public function testGetChildrenStrictConvertsStorageException(string $exceptionClass): void { + $storageException = new $exceptionClass('Unavailable mount'); + $this->view->expects($this->once()) + ->method('getDirectoryContent') + ->with('folder', null, $this->anything(), true) + ->willThrowException($storageException); + $this->view->method('getRelativePath') + ->willReturnCallback(static fn ($path) => str_replace('/admin/files/', '', $path)); + $this->view->method('getAbsolutePath') + ->willReturn('/admin/files/folder'); + $this->overwriteService(View::class, $this->view); + + try { + (new Directory($this->view, $this->info))->getChildrenStrict(); + $this->fail('Expected strict directory listing to fail'); + } catch (\Sabre\DAV\Exception\ServiceUnavailable $e) { + $this->assertSame('Storage is temporarily not available', $e->getMessage()); + $this->assertSame($storageException, $e->getPrevious()); + } + } + public function testGetChildrenNoPermission(): void { $this->expectException(\Sabre\DAV\Exception\Forbidden::class); diff --git a/apps/dav/tests/unit/Connector/Sabre/PropFindMountAvailabilityPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/PropFindMountAvailabilityPluginTest.php new file mode 100644 index 0000000000000..620fb7801900e --- /dev/null +++ b/apps/dav/tests/unit/Connector/Sabre/PropFindMountAvailabilityPluginTest.php @@ -0,0 +1,161 @@ +server = $this->createMock(Server::class); + $this->tree = $this->createMock(Tree::class); + $this->server->tree = $this->tree; + $this->plugin = new PropFindMountAvailabilityPlugin(); + PropFindMountAvailabilityTestSapi::$request = null; + PropFindMountAvailabilityTestSapi::$response = null; + } + + public function testInitialize(): void { + $this->server->expects(self::once()) + ->method('on') + ->with('method:PROPFIND', [$this->plugin, 'preflight'], 10); + + $this->plugin->initialize($this->server); + } + + public function testDepthZeroDoesNotEnumerateChildren(): void { + $request = $this->createMock(RequestInterface::class); + $this->server->expects(self::once()) + ->method('getHTTPDepth') + ->with(1) + ->willReturn(0); + $this->tree->expects(self::never()) + ->method('getNodeForPath'); + + $this->plugin->initialize($this->server); + $this->plugin->preflight($request); + } + + public function testNonFilesCollectionIsNotEnumerated(): void { + $request = $this->createMock(RequestInterface::class); + $request->expects(self::once()) + ->method('getPath') + ->willReturn('calendars/user'); + $this->server->expects(self::once()) + ->method('getHTTPDepth') + ->with(1) + ->willReturn(1); + $this->tree->expects(self::once()) + ->method('getNodeForPath') + ->with('calendars/user') + ->willReturn($this->createMock(ICollection::class)); + + $this->plugin->initialize($this->server); + $this->plugin->preflight($request); + } + + public function testFilesCollectionIsStrictlyEnumerated(): void { + $request = $this->createMock(RequestInterface::class); + $request->expects(self::once()) + ->method('getPath') + ->willReturn('files/user'); + $this->server->expects(self::once()) + ->method('getHTTPDepth') + ->with(1) + ->willReturn(1); + $directory = $this->createMock(Directory::class); + $directory->expects(self::once()) + ->method('getChildrenStrict') + ->willReturn([]); + $this->tree->expects(self::once()) + ->method('getNodeForPath') + ->with('files/user') + ->willReturn($directory); + + $this->plugin->initialize($this->server); + $this->plugin->preflight($request); + } + + public function testDepthInfinityStrictlyEnumeratesNestedDirectories(): void { + $request = $this->createMock(RequestInterface::class); + $request->method('getPath') + ->willReturn('files/user'); + $this->server->method('getHTTPDepth') + ->with(1) + ->willReturn(Server::DEPTH_INFINITY); + $nestedDirectory = $this->createMock(Directory::class); + $nestedDirectory->expects(self::once()) + ->method('getChildrenStrict') + ->willReturn([]); + $directory = $this->createMock(Directory::class); + $directory->expects(self::once()) + ->method('getChildrenStrict') + ->willReturn([$nestedDirectory]); + $this->tree->method('getNodeForPath') + ->with('files/user') + ->willReturn($directory); + + $this->plugin->initialize($this->server); + $this->plugin->preflight($request); + } + + public function testUnavailableMountReturnsServiceUnavailableBeforeStreamingMultiStatus(): void { + $directory = $this->createMock(Directory::class); + $directory->expects(self::once()) + ->method('getChildrenStrict') + ->willThrowException(new ServiceUnavailable('Storage is temporarily not available')); + PropFindMountAvailabilityTestSapi::$request = new Request('PROPFIND', '/', ['Depth' => '1']); + $sapi = new PropFindMountAvailabilityTestSapi(); + $server = new Server($directory, $sapi); + $server->addPlugin(new PropFindMountAvailabilityPlugin()); + $previousStreamMultiStatus = Server::$streamMultiStatus; + try { + Server::$streamMultiStatus = true; + $server->start(); + } finally { + Server::$streamMultiStatus = $previousStreamMultiStatus; + } + + $response = PropFindMountAvailabilityTestSapi::$response; + $this->assertNotNull($response); + $this->assertSame(503, $response->getStatus()); + $this->assertStringContainsString(ServiceUnavailable::class, $response->getBodyAsString()); + $this->assertStringNotContainsString('multistatus', $response->getBodyAsString()); + } +} diff --git a/apps/dav/tests/unit/Connector/Sabre/RequestTest/ServerFactoryTest.php b/apps/dav/tests/unit/Connector/Sabre/RequestTest/ServerFactoryTest.php new file mode 100644 index 0000000000000..facd09ce7c3a6 --- /dev/null +++ b/apps/dav/tests/unit/Connector/Sabre/RequestTest/ServerFactoryTest.php @@ -0,0 +1,31 @@ +serverFactory->createServer( + false, + '/', + 'dummy', + $this->createMock(Plugin::class), + static fn () => throw new \LogicException('View callback must not run while creating the server'), + ); + + $this->assertInstanceOf( + PropFindMountAvailabilityPlugin::class, + $server->getPlugin(PropFindMountAvailabilityPlugin::class), + ); + } +} diff --git a/apps/dav/tests/unit/ServerTest.php b/apps/dav/tests/unit/ServerTest.php index 62713b223a03a..f0c97038ee671 100644 --- a/apps/dav/tests/unit/ServerTest.php +++ b/apps/dav/tests/unit/ServerTest.php @@ -9,6 +9,7 @@ namespace OCA\DAV\Tests\unit; +use OCA\DAV\Connector\Sabre\PropFindMountAvailabilityPlugin; use OCA\DAV\Server; use OCP\IRequest; @@ -29,6 +30,7 @@ public function test(string $uri, array $plugins): void { $this->loginAsUser('admin'); $s = new Server($r, '/'); $this->assertNotNull($s->server); + $this->assertInstanceOf(PropFindMountAvailabilityPlugin::class, $s->server->getPlugin(PropFindMountAvailabilityPlugin::class)); foreach ($plugins as $plugin) { $this->assertNotNull($s->server->getPlugin($plugin)); } diff --git a/lib/private/Files/View.php b/lib/private/Files/View.php index c4162c7df046f..3a762c1b1433b 100644 --- a/lib/private/Files/View.php +++ b/lib/private/Files/View.php @@ -1504,9 +1504,12 @@ public function addSubMounts(FileInfo $info, $extOnly = false): void { * * @param string $directory path under datadirectory * @param ?non-empty-string $mimeTypeFilter limit returned content to this mimetype or mimepart + * @param bool $failOnUnavailableMount fail instead of omitting unavailable mount points * @return FileInfo[] + * @throws StorageInvalidException + * @throws StorageNotAvailableException */ - public function getDirectoryContent(string $directory, ?string $mimeTypeFilter = null, ?\OCP\Files\FileInfo $directoryInfo = null) { + public function getDirectoryContent(string $directory, ?string $mimeTypeFilter = null, ?\OCP\Files\FileInfo $directoryInfo = null, bool $failOnUnavailableMount = false) { $this->assertPathLength($directory); if (!Filesystem::isValidPath($directory)) { return []; @@ -1523,6 +1526,9 @@ public function getDirectoryContent(string $directory, ?string $mimeTypeFilter = $storage = $mount->getStorage(); $internalPath = $mount->getInternalPath($path); if (!$storage) { + if ($failOnUnavailableMount) { + throw new StorageNotAvailableException('Storage for directory "' . $path . '" is not available'); + } return []; } @@ -1577,8 +1583,12 @@ public function getDirectoryContent(string $directory, ?string $mimeTypeFilter = $mounts = Filesystem::getMountManager()->findIn($path); $dirLength = strlen($path); + $unavailableNestedMounts = []; foreach ($mounts as $mount) { $mountPoint = $mount->getMountPoint(); + $relativePath = trim(substr($mountPoint, $dirLength), '/'); + $separatorPosition = strpos($relativePath, '/'); + $entryName = $separatorPosition === false ? $relativePath : substr($relativePath, 0, $separatorPosition); $subStorage = $mount->getStorage(); if ($subStorage) { $subCache = $subStorage->getCache(''); @@ -1588,7 +1598,13 @@ public function getDirectoryContent(string $directory, ?string $mimeTypeFilter = $subScanner = $subStorage->getScanner(); try { $subScanner->scanFile(''); - } catch (StorageNotAvailableException|StorageInvalidException) { + } catch (StorageNotAvailableException|StorageInvalidException $e) { + if ($failOnUnavailableMount) { + if ($separatorPosition === false) { + throw $e; + } + $unavailableNestedMounts[$entryName] ??= $e; + } continue; } catch (\Exception $e) { // sometimes when the storage is not available it can be any exception @@ -1596,12 +1612,30 @@ public function getDirectoryContent(string $directory, ?string $mimeTypeFilter = 'exception' => $e, 'app' => 'core', ]); + if ($failOnUnavailableMount) { + $exception = new StorageNotAvailableException('Failed to scan mount point "' . $mountPoint . '"', StorageNotAvailableException::STATUS_ERROR, $e); + if ($separatorPosition === false) { + throw $exception; + } + $unavailableNestedMounts[$entryName] ??= $exception; + } continue; } $rootEntry = $subCache->get(''); } - if (!$rootEntry || !($rootEntry->getPermissions() & Constants::PERMISSION_READ)) { + if (!$rootEntry) { + if ($failOnUnavailableMount) { + $exception = new StorageNotAvailableException('Unable to read the root of mount point "' . $mountPoint . '"'); + if ($separatorPosition === false) { + throw $exception; + } + $unavailableNestedMounts[$entryName] ??= $exception; + } + continue; + } + + if (!($rootEntry->getPermissions() & Constants::PERMISSION_READ)) { continue; } @@ -1613,10 +1647,8 @@ public function getDirectoryContent(string $directory, ?string $mimeTypeFilter = } } - $relativePath = trim(substr($mountPoint, $dirLength), '/'); - if ($pos = strpos($relativePath, '/')) { + if ($separatorPosition !== false) { //mountpoint inside subfolder add size to the correct folder - $entryName = substr($relativePath, 0, $pos); // Create parent folders if the mountpoint is inside a subfolder that doesn't exist yet if (!isset($files[$entryName])) { @@ -1671,6 +1703,18 @@ public function getDirectoryContent(string $directory, ?string $mimeTypeFilter = } $files[$rootEntry->getName()] = new FileInfo($path . '/' . $rootEntry['name'], $subStorage, '', $rootEntry, $mount, $owner); } + } elseif ($failOnUnavailableMount) { + $exception = new StorageNotAvailableException('Storage for mount point "' . $mountPoint . '" is not available'); + if ($separatorPosition === false) { + throw $exception; + } + $unavailableNestedMounts[$entryName] ??= $exception; + } + } + + foreach ($unavailableNestedMounts as $entryName => $exception) { + if (!isset($files[$entryName])) { + throw $exception; } } diff --git a/tests/lib/Files/ViewTest.php b/tests/lib/Files/ViewTest.php index 3b8718564020e..ee1c46b28cb62 100644 --- a/tests/lib/Files/ViewTest.php +++ b/tests/lib/Files/ViewTest.php @@ -29,9 +29,12 @@ use OCP\Files\GenericFileException; use OCP\Files\InvalidPathException; use OCP\Files\Mount\IMountManager; +use OCP\Files\Mount\IMountPoint; use OCP\Files\NotFoundException; use OCP\Files\Storage\IStorage; use OCP\Files\Storage\IStorageFactory; +use OCP\Files\StorageInvalidException; +use OCP\Files\StorageNotAvailableException; use OCP\IConfig; use OCP\IDBConnection; use OCP\IGroup; @@ -91,6 +94,34 @@ public function hasUpdated(string $path, int $time): bool { } } +class TemporaryUnavailableStorage extends Temporary { + #[\Override] + public function getMetaData(string $path): ?array { + throw new StorageNotAvailableException('Test storage is unavailable'); + } +} + +class TemporaryInvalidStorage extends Temporary { + #[\Override] + public function getMetaData(string $path): ?array { + throw new StorageInvalidException('Test storage is invalid'); + } +} + +class TemporaryUnexpectedFailureStorage extends Temporary { + #[\Override] + public function getMetaData(string $path): ?array { + throw new \RuntimeException('Unexpected test storage failure'); + } +} + +class TemporaryMissingRootStorage extends Temporary { + #[\Override] + public function getMetaData(string $path): ?array { + return $path === '' ? null : parent::getMetaData($path); + } +} + class TestEventHandler { public function umount() { } @@ -260,6 +291,158 @@ public function testCacheAPI(): void { $this->assertEquals([], $rootView->getDirectoryContent('/non/existing')); } + public function testGetDirectoryContentSkipsUnavailableMountByDefault(): void { + $root = self::getUniqueID('/'); + $storage = $this->getTestStorage(); + $unavailableStorage = new TemporaryUnavailableStorage(); + $this->storages[] = $unavailableStorage; + + Filesystem::mount($storage, [], $root . '/'); + Filesystem::mount($unavailableStorage, [], $root . '/unavailable'); + + $folderData = (new View($root))->getDirectoryContent('/'); + + $this->assertNotContains('unavailable', array_map(static fn (FileInfo $info): string => $info->getName(), $folderData)); + } + + public static function strictStorageExceptionProvider(): array { + return [ + 'unavailable storage' => [TemporaryUnavailableStorage::class, StorageNotAvailableException::class], + 'invalid storage' => [TemporaryInvalidStorage::class, StorageInvalidException::class], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('strictStorageExceptionProvider')] + public function testGetDirectoryContentStrictFailsForStorageException(string $storageClass, string $exceptionClass): void { + $root = self::getUniqueID('/'); + $storage = $this->getTestStorage(); + $unavailableStorage = new $storageClass(); + $this->storages[] = $unavailableStorage; + + Filesystem::mount($storage, [], $root . '/'); + Filesystem::mount($unavailableStorage, [], $root . '/unavailable'); + + $this->expectException($exceptionClass); + + (new View($root))->getDirectoryContent('/', null, null, true); + } + + public function testGetDirectoryContentStrictWrapsUnexpectedStorageException(): void { + $root = self::getUniqueID('/'); + $storage = $this->getTestStorage(); + $failedStorage = new TemporaryUnexpectedFailureStorage(); + $this->storages[] = $failedStorage; + + Filesystem::mount($storage, [], $root . '/'); + Filesystem::mount($failedStorage, [], $root . '/failed'); + + try { + (new View($root))->getDirectoryContent('/', null, null, true); + $this->fail('Expected strict directory listing to fail'); + } catch (StorageNotAvailableException $e) { + $this->assertInstanceOf(\RuntimeException::class, $e->getPrevious()); + } + } + + public function testGetDirectoryContentStrictFailsForMountWithoutStorage(): void { + $root = self::getUniqueID('/'); + $storage = $this->getTestStorage(); + Filesystem::mount($storage, [], $root . '/'); + + $mount = $this->createMock(IMountPoint::class); + $mount->method('getMountPoint') + ->willReturn($root . '/unavailable/'); + $mount->method('getMountProvider') + ->willReturn(self::class); + $mount->method('getStorage') + ->willReturn(false); + Filesystem::getMountManager()->addMount($mount); + + $this->expectException(StorageNotAvailableException::class); + + (new View($root))->getDirectoryContent('/', null, null, true); + } + + public function testGetDirectoryContentStrictFailsWhenDirectoryStorageIsUnavailable(): void { + $root = self::getUniqueID('/'); + $mount = $this->createMock(IMountPoint::class); + $mount->method('getMountPoint') + ->willReturn($root . '/'); + $mount->method('getMountProvider') + ->willReturn(self::class); + $mount->method('getStorage') + ->willReturn(false); + $mount->method('getInternalPath') + ->willReturn(''); + Filesystem::getMountManager()->addMount($mount); + + $this->expectException(StorageNotAvailableException::class); + + (new View($root))->getDirectoryContent('/', null, null, true); + } + + public function testGetDirectoryContentStrictFailsWhenRootScanProducesNoEntry(): void { + $root = self::getUniqueID('/'); + $storage = $this->getTestStorage(); + $missingRootStorage = new TemporaryMissingRootStorage(); + $this->storages[] = $missingRootStorage; + + Filesystem::mount($storage, [], $root . '/'); + Filesystem::mount($missingRootStorage, [], $root . '/missing-root'); + + $this->expectException(StorageNotAvailableException::class); + + (new View($root))->getDirectoryContent('/', null, null, true); + } + + public function testGetDirectoryContentStrictStillSkipsUnreadableMount(): void { + $root = self::getUniqueID('/'); + $storage = $this->getTestStorage(); + $unreadableStorage = $this->getTestStorage(); + $rootEntry = $unreadableStorage->getCache()->get(''); + $unreadableStorage->getCache()->update($rootEntry->getId(), ['permissions' => 0]); + + Filesystem::mount($storage, [], $root . '/'); + Filesystem::mount($unreadableStorage, [], $root . '/unreadable'); + + $folderData = (new View($root))->getDirectoryContent('/', null, null, true); + + $this->assertNotContains('unreadable', array_map(static fn (FileInfo $info): string => $info->getName(), $folderData)); + } + + public function testGetDirectoryContentStrictAllowsUnavailableMountBelowExistingFolder(): void { + $root = self::getUniqueID('/'); + $storage = $this->getTestStorage(); + $unavailableStorage = new TemporaryUnavailableStorage(); + $this->storages[] = $unavailableStorage; + + Filesystem::mount($storage, [], $root . '/'); + $view = new View($root); + $view->mkdir('/existing'); + Filesystem::mount($unavailableStorage, [], $root . '/existing/unavailable'); + + $folderData = $view->getDirectoryContent('/', null, null, true); + + $this->assertContains('existing', array_map(static fn (FileInfo $info): string => $info->getName(), $folderData)); + + $this->expectException(StorageNotAvailableException::class); + $view->getDirectoryContent('/existing', null, null, true); + } + + public function testGetDirectoryContentStrictFailsWhenUnavailableMountIsOnlySourceOfNestedFolder(): void { + $root = self::getUniqueID('/'); + $storage = $this->getTestStorage(); + $unavailableStorage = new TemporaryUnavailableStorage(); + $this->storages[] = $unavailableStorage; + + Filesystem::mount($storage, [], $root . '/'); + Filesystem::mount($unavailableStorage, [], $root . '/virtual/unavailable'); + + $this->expectException(StorageNotAvailableException::class); + + (new View($root))->getDirectoryContent('/', null, null, true); + } + public function testGetPath(): void { $user = $this->createMock(IUser::class); $user->method('getUID')