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
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
33 changes: 31 additions & 2 deletions apps/dav/lib/Connector/Sabre/Directory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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 = [];
Expand All @@ -278,6 +306,7 @@ public function getChildren() {
$nodes[] = $node;
}
$this->dirContent = $nodes;
$this->dirContentIsStrict = $failOnUnavailableMount;
return $this->dirContent;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\DAV\Connector\Sabre;

use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\HTTP\RequestInterface;

/**
* Checks that a Files collection can be listed before Sabre starts streaming a PROPFIND response.
*/
class PropFindMountAvailabilityPlugin extends ServerPlugin {
private Server $server;

#[\Override]
public function initialize(Server $server): void {
$this->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);
}
}
}
}
1 change: 1 addition & 0 deletions apps/dav/lib/Connector/Sabre/ServerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
2 changes: 2 additions & 0 deletions apps/dav/lib/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions apps/dav/tests/unit/Connector/Sabre/DirectoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\DAV\Tests\unit\Connector\Sabre;

use OCA\DAV\Connector\Sabre\Directory;
use OCA\DAV\Connector\Sabre\PropFindMountAvailabilityPlugin;
use PHPUnit\Framework\MockObject\MockObject;
use Sabre\DAV\Exception\ServiceUnavailable;
use Sabre\DAV\ICollection;
use Sabre\DAV\Server;
use Sabre\DAV\Tree;
use Sabre\HTTP\Request;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
use Sabre\HTTP\Sapi;
use Test\TestCase;

class PropFindMountAvailabilityTestSapi extends Sapi {
public static ?Request $request = null;
public static ?ResponseInterface $response = null;

public static function getRequest(): Request {
return self::$request ?? new Request('GET', '/');
}

public static function sendResponse(ResponseInterface $response): void {
self::$response = $response;
}
}

class PropFindMountAvailabilityPluginTest extends TestCase {
private Server&MockObject $server;
private Tree&MockObject $tree;
private PropFindMountAvailabilityPlugin $plugin;

protected function setUp(): void {
parent::setUp();

$this->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());
}
}
Loading