fix: add INodeByPath to Directory

Signed-off-by: Salvatore Martire <4652631+salmart-dev@users.noreply.github.com>
This commit is contained in:
Salvatore Martire 2025-08-15 16:14:19 +02:00
parent aaf07ab73e
commit 2a4ee2df9f
2 changed files with 225 additions and 1 deletions

View file

@ -8,6 +8,7 @@
namespace OCA\DAV\Connector\Sabre;
use OC\Files\Mount\MoveableMount;
use OC\Files\Utils\PathHelper;
use OC\Files\View;
use OCA\DAV\AppInfo\Application;
use OCA\DAV\Connector\Sabre\Exception\FileLocked;
@ -38,8 +39,14 @@ use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\Exception\ServiceUnavailable;
use Sabre\DAV\IFile;
use Sabre\DAV\INode;
use Sabre\DAV\INodeByPath;
class Directory extends Node implements \Sabre\DAV\ICollection, \Sabre\DAV\IQuota, \Sabre\DAV\IMoveTarget, \Sabre\DAV\ICopyTarget {
class Directory extends Node implements
\Sabre\DAV\ICollection,
\Sabre\DAV\IQuota,
\Sabre\DAV\IMoveTarget,
\Sabre\DAV\ICopyTarget,
INodeByPath {
/**
* Cached directory content
* @var FileInfo[]
@ -490,4 +497,79 @@ class Directory extends Node implements \Sabre\DAV\ICollection, \Sabre\DAV\IQuot
public function getNode(): Folder {
return $this->node;
}
public function getNodeForPath($path): INode {
$storage = $this->info->getStorage();
$allowDirectory = false;
// Checking if we're in a file drop
// If we are, then only PUT and MKCOL are allowed (see plugin)
// so we are safe to return the directory without a risk of
// leaking files and folders structure.
if ($storage->instanceOfStorage(PublicShareWrapper::class)) {
$share = $storage->getShare();
$allowDirectory = ($share->getPermissions() & Constants::PERMISSION_READ) !== Constants::PERMISSION_READ;
}
// For file drop we need to be allowed to read the directory with the nickname
if (!$allowDirectory && !$this->info->isReadable()) {
// avoid detecting files through this way
throw new NotFound();
}
$destinationPath = PathHelper::normalizePath($this->getPath() . '/' . $path);
$destinationDir = dirname($destinationPath);
try {
$info = $this->getNode()->get($path);
} catch (NotFoundException $e) {
throw new \Sabre\DAV\Exception\NotFound('File with name ' . $destinationPath
. ' could not be located');
} catch (StorageNotAvailableException $e) {
throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage(), 0, $e);
} catch (NotPermittedException $ex) {
throw new InvalidPath($ex->getMessage(), false, $ex);
}
// if not in a public share with no read permissions, throw Forbidden
if (!$allowDirectory && !$info->isReadable()) {
if (Server::get(IAppManager::class)->isEnabledForAnyone('files_accesscontrol')) {
throw new Forbidden('No read permissions. This might be caused by files_accesscontrol, check your configured rules');
}
throw new Forbidden('No read permissions');
}
if ($info->getMimeType() === FileInfo::MIMETYPE_FOLDER) {
$node = new \OCA\DAV\Connector\Sabre\Directory($this->fileView, $info, $this->tree, $this->shareManager);
} else {
// In case reading a directory was allowed but it turns out the node was a not a directory, reject it now.
if (!$this->info->isReadable()) {
throw new NotFound();
}
$node = new File($this->fileView, $info, $this->shareManager);
}
$this->tree?->cacheNode($node);
// recurse upwards until the root and check for read permissions to keep
// ACL checks working in files_accesscontrol
if (!$allowDirectory && $destinationDir !== '') {
$scanPath = $destinationPath;
while (($scanPath = dirname($scanPath)) !== '/') {
// fileView can get the parent info in a cheaper way compared
// to the node API
/** @psalm-suppress InternalMethod */
$info = $this->fileView->getFileInfo($scanPath, false);
$directory = new Directory($this->fileView, $info, $this->tree, $this->shareManager);
$readable = $directory->getNode()->isReadable();
if (!$readable) {
throw new \Sabre\DAV\Exception\NotFound('File with name ' . $destinationPath
. ' could not be located');
}
}
}
return $node;
}
}

View file

@ -10,6 +10,7 @@ namespace OCA\DAV\Tests\unit\Connector\Sabre;
use OC\Files\FileInfo;
use OC\Files\Filesystem;
use OC\Files\Node\Folder;
use OC\Files\Node\Node;
use OC\Files\Storage\Wrapper\Quota;
use OC\Files\View;
@ -24,6 +25,7 @@ use OCP\Files\Mount\IMountPoint;
use OCP\Files\Storage\IStorage;
use OCP\Files\StorageNotAvailableException;
use PHPUnit\Framework\MockObject\MockObject;
use Sabre\DAV\Exception\NotFound;
use Test\Traits\UserTrait;
class TestViewDirectory extends View {
@ -271,6 +273,146 @@ class DirectoryTest extends \Test\TestCase {
$dir->getChild('.');
}
public function testGetNodeForPath(): void {
$directoryNode = $this->createMock(Folder::class);
$pathNode = $this->createMock(Folder::class);
$pathParentNode = $this->createMock(Folder::class);
$storage = $this->createMock(IStorage::class);
$directoryNode->expects($this->once())
->method('getStorage')
->willReturn($storage);
$storage->expects($this->once())
->method('instanceOfStorage')
->willReturn(false);
$directoryNode->expects($this->once())
->method('isReadable')
->willReturn(true);
$directoryNode->expects($this->once())
->method('getPath')
->willReturn('/admin/files/');
$directoryNode->expects($this->once())
->method('get')
->willReturn($pathNode);
$pathNode->expects($this->once())
->method('getPath')
->willReturn('/admin/files/my/deep/folder/');
$pathNode->expects($this->once())
->method('isReadable')
->willReturn(true);
$pathNode->expects($this->once())
->method('getMimetype')
->willReturn(FileInfo::MIMETYPE_FOLDER);
$this->view->method('getRelativePath')
->willReturnCallback(function ($path) {
return str_replace('/admin/files/', '', $path);
});
$this->view->expects($this->exactly(2))
->method('getFileInfo')
->willReturn($pathParentNode);
$pathParentNode->expects($this->exactly(2))
->method('getPath')
->willReturnOnConsecutiveCalls('/my/deep', '/my');
$pathParentNode->expects($this->exactly(2))
->method('isReadable')
->willReturn(true);
$dir = new Directory($this->view, $directoryNode);
$dir->getNodeForPath('/my/deep/folder/');
}
public function testGetNodeForPathFailsWithNoReadPermissionsForParent(): void {
$directoryNode = $this->createMock(Folder::class);
$pathNode = $this->createMock(Folder::class);
$pathParentNode = $this->createMock(Folder::class);
$storage = $this->createMock(IStorage::class);
$directoryNode->expects($this->once())
->method('getStorage')
->willReturn($storage);
$storage->expects($this->once())
->method('instanceOfStorage')
->willReturn(false);
$directoryNode->expects($this->once())
->method('isReadable')
->willReturn(true);
$directoryNode->expects($this->once())
->method('getPath')
->willReturn('/admin/files/');
$directoryNode->expects($this->once())
->method('get')
->willReturn($pathNode);
$pathNode->expects($this->once())
->method('getPath')
->willReturn('/admin/files/my/deep/folder/');
$pathNode->expects($this->once())
->method('isReadable')
->willReturn(true);
$pathNode->expects($this->once())
->method('getMimetype')
->willReturn(FileInfo::MIMETYPE_FOLDER);
$this->view->method('getRelativePath')
->willReturnCallback(function ($path) {
return str_replace('/admin/files/', '', $path);
});
$this->view->expects($this->exactly(2))
->method('getFileInfo')
->willReturn($pathParentNode);
$pathParentNode->expects($this->exactly(2))
->method('getPath')
->willReturnOnConsecutiveCalls('/my/deep', '/my');
$pathParentNode->expects($this->exactly(2))
->method('isReadable')
->willReturnOnConsecutiveCalls(true, false);
$this->expectException(NotFound::class);
$dir = new Directory($this->view, $directoryNode);
$dir->getNodeForPath('/my/deep/folder/');
}
public function testGetNodeForPathFailsWithNoReadPermissionsForPath(): void {
$directoryNode = $this->createMock(Folder::class);
$pathNode = $this->createMock(Folder::class);
$storage = $this->createMock(IStorage::class);
$directoryNode->expects($this->once())
->method('getStorage')
->willReturn($storage);
$storage->expects($this->once())
->method('instanceOfStorage')
->willReturn(false);
$directoryNode->expects($this->once())
->method('isReadable')
->willReturn(true);
$directoryNode->expects($this->once())
->method('getPath')
->willReturn('/admin/files/');
$directoryNode->expects($this->once())
->method('get')
->willReturn($pathNode);
$pathNode->expects($this->once())
->method('isReadable')
->willReturn(false);
$this->expectException(\Sabre\DAV\Exception\Forbidden::class);
$dir = new Directory($this->view, $directoryNode);
$dir->getNodeForPath('/my/deep/folder/');
}
public function testGetQuotaInfoUnlimited(): void {
$this->createUser('user', 'password');
self::loginAsUser('user');