ChunkingV2Plugin::beforeGet eagerly resolves the request path during beforeMethod:GET to block reading intermediate chunked uploads. App-provided DAV collections (versions, trashbin) are attached to the root lazily in a beforeMethod:* closure in Server.php, while uploads is registered eagerly. When beforeGet runs before that closure, getNodeForPath() throws NotFound and aborts the whole request, turning every GET under /dav/versions/ and /dav/trashbin/ into "404 File not found: versions in 'root'". PROPFIND is unaffected, since nothing resolves the path that early for it. This does not currently surface on master only by accident of listener ordering: beforeGet and the collection closure share the default priority, and because beforeGet is registered as a first-class callable (a Closure), the wildcard closure happens to sort before it, so the collections are already attached by the time beforeGet resolves the path. That ordering is not guaranteed -- it depends on how equal-priority listeners are tie-broken -- so the unguarded resolution is a latent fault that any reordering can expose. On stable 28 it is already broken, because the backport registered the handler as an array callable, which tie-breaks the other way and runs beforeGet first. Catch NotFound in beforeGet and bail out: a path that cannot be resolved is by definition not an intermediate upload. This removes the dependency on listener ordering entirely and makes the handler consistent with beforePut()/beforeMove()/beforeDelete(), which already swallow NotFound for the same reason. Add a ChunkingV2Plugin unit test (the NotFound case is the regression guard) and a file-versions integration scenario covering a real version download. Assisted-by: ClaudeCode:claude-opus-4-8[1m] Signed-off-by: David Dreschner <david.dreschner@nextcloud.com>pull/61680/head
parent
507c0a78f5
commit
d101907a18
@ -0,0 +1,105 @@ |
||||
<?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\Upload; |
||||
|
||||
use OCA\DAV\Connector\Sabre\Directory; |
||||
use OCA\DAV\Upload\ChunkingV2Plugin; |
||||
use OCA\DAV\Upload\FutureFile; |
||||
use OCA\DAV\Upload\UploadFile; |
||||
use OCP\ICache; |
||||
use OCP\ICacheFactory; |
||||
use PHPUnit\Framework\MockObject\MockObject; |
||||
use Sabre\DAV\Exception\MethodNotAllowed; |
||||
use Sabre\DAV\Exception\NotFound; |
||||
use Sabre\DAV\Server; |
||||
use Sabre\DAV\Tree; |
||||
use Sabre\HTTP\RequestInterface; |
||||
use Sabre\HTTP\ResponseInterface; |
||||
use Test\TestCase; |
||||
|
||||
class ChunkingV2PluginTest extends TestCase { |
||||
/** @var Server | MockObject */ |
||||
private $server; |
||||
/** @var Tree | MockObject */ |
||||
private $tree; |
||||
/** @var ChunkingV2Plugin */ |
||||
private $plugin; |
||||
/** @var RequestInterface | MockObject */ |
||||
private $request; |
||||
/** @var ResponseInterface | MockObject */ |
||||
private $response; |
||||
|
||||
protected function setUp(): void { |
||||
parent::setUp(); |
||||
|
||||
$this->server = $this->getMockBuilder('\Sabre\DAV\Server') |
||||
->disableOriginalConstructor() |
||||
->getMock(); |
||||
$this->tree = $this->getMockBuilder('\Sabre\DAV\Tree') |
||||
->disableOriginalConstructor() |
||||
->getMock(); |
||||
$this->server->tree = $this->tree; |
||||
|
||||
$cacheFactory = $this->createMock(ICacheFactory::class); |
||||
$cacheFactory->method('createDistributed')->willReturn($this->createMock(ICache::class)); |
||||
|
||||
$this->plugin = new ChunkingV2Plugin($cacheFactory); |
||||
|
||||
$this->request = $this->createMock(RequestInterface::class); |
||||
$this->response = $this->createMock(ResponseInterface::class); |
||||
$this->server->httpRequest = $this->request; |
||||
$this->server->httpResponse = $this->response; |
||||
|
||||
$this->plugin->initialize($this->server); |
||||
} |
||||
|
||||
/** |
||||
* The handler only blocks reading intermediate uploads. A path it cannot |
||||
* resolve (e.g. an app-provided collection such as `versions`/`trashbin` |
||||
* that is registered later in the request lifecycle) must not abort the |
||||
* request: beforeGet has to swallow the NotFound and let normal handling |
||||
* (and any real 404) take over. This is the regression guard for the |
||||
* "version/trashbin downloads return 404" bug. |
||||
*/ |
||||
public function testBeforeGetIgnoresUnresolvablePath(): void { |
||||
$this->request->method('getPath')->willReturn('versions/admin/versions/74/1782831952'); |
||||
$this->tree->expects($this->once()) |
||||
->method('getNodeForPath') |
||||
->with('versions/admin/versions/74/1782831952') |
||||
->willThrowException(new NotFound("File not found: versions in 'root'")); |
||||
|
||||
$this->assertTrue($this->plugin->beforeGet($this->request)); |
||||
} |
||||
|
||||
public function testBeforeGetBlocksFutureFile(): void { |
||||
$this->expectException(MethodNotAllowed::class); |
||||
|
||||
$this->request->method('getPath')->willReturn('uploads/admin/web-file-upload-id/1'); |
||||
$this->tree->method('getNodeForPath')->willReturn($this->createMock(FutureFile::class)); |
||||
|
||||
$this->plugin->beforeGet($this->request); |
||||
} |
||||
|
||||
public function testBeforeGetBlocksUploadFile(): void { |
||||
$this->expectException(MethodNotAllowed::class); |
||||
|
||||
$this->request->method('getPath')->willReturn('uploads/admin/web-file-upload-id/.target'); |
||||
$this->tree->method('getNodeForPath')->willReturn($this->createMock(UploadFile::class)); |
||||
|
||||
$this->plugin->beforeGet($this->request); |
||||
} |
||||
|
||||
public function testBeforeGetAllowsRegularNode(): void { |
||||
$this->request->method('getPath')->willReturn('files/admin/foo.txt'); |
||||
$this->tree->method('getNodeForPath')->willReturn($this->createMock(Directory::class)); |
||||
|
||||
$this->assertTrue($this->plugin->beforeGet($this->request)); |
||||
} |
||||
} |
||||
@ -0,0 +1,17 @@ |
||||
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
||||
# SPDX-License-Identifier: AGPL-3.0-or-later |
||||
|
||||
Feature: file-versions |
||||
Background: |
||||
Given using new dav path |
||||
|
||||
# Regression test for file version downloads returning "404 File not found: versions in 'root'". |
||||
# The versions/trash bin collections are attached to the DAV root lazily during beforeMethod, |
||||
# so an early GET handler that resolved the request path too eagerly aborted the request. |
||||
# This exercises the full plugin stack: a real previous version must be downloadable via DAV. |
||||
Scenario: Download a previous version of a file via the versions DAV endpoint |
||||
Given user "admin" uploads file with content "first version" and mtime "1111111111" to "/versioned.txt" |
||||
And user "admin" uploads file with content "second version" and mtime "2222222222" to "/versioned.txt" |
||||
When user "admin" downloads version "1111111111" of file "/versioned.txt" |
||||
Then the HTTP status code should be "200" |
||||
And Downloaded content should be "first version" |
||||
Loading…
Reference in new issue