Skip to content

Commit 4e2ef40

Browse files
committed
fix(dav): keep part files when overwriting so an interrupted upload cannot destroy the target
The guard deciding whether a DAV write goes through a part file asked View::isCreatable() about the part file path itself. isCreatable() answers whether something can be created *inside* a path, so for a file that does not exist yet it is always false and the guard collapsed to "skip the part file whenever the target is updatable" - that is, for every overwrite. An upload interrupted during assembly then truncated the user's existing file in place, while oc_filecache kept asserting the previous size and etag, so no client had any reason to re-fetch until a later occ files:scan turned the divergence into a download of the empty file. Ask isCreatable() about the directory that will hold the part file instead, and keep the part file out of the two cases where it cannot stand in for the target: - Part file names were always hashed, although the commit introducing the hashing only meant to do so for names too long to fit. Encryption resolves a part file's key by stripping the .ocTransferId suffix, which only leads back to the target while the real name is kept, so a hashed name left an encrypted overwrite undecryptable. Hash only when the name would overflow the filesystem limit, and keep writing directly to the target when it must be. - A single file share maps the target and nothing else, so a part file named beside it lands on a different storage - the recipient's own, under their quota - rather than next to the file being written. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com>
1 parent 9278886 commit 4e2ef40

4 files changed

Lines changed: 395 additions & 9 deletions

File tree

apps/dav/lib/Connector/Sabre/File.php

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@
5252
use Sabre\DAV\IFile;
5353

5454
class File extends Node implements IFile {
55+
/** Longest name common filesystems (ext/xfs) accept */
56+
private const MAX_FILENAME_LENGTH = 255;
57+
/** '.ocTransferId' + a rand() value + '.part' */
58+
private const PART_FILE_SUFFIX_MAX_LENGTH = 28;
59+
5560
protected IRequest $request;
5661
protected IL10N $l10n;
5762

@@ -135,28 +140,48 @@ public function put($data) {
135140

136141
if ($needsPartFile) {
137142
$transferId = \rand();
143+
$partFileBasePath = $this->getPartFileBasePath($this->path);
138144
// mark file as partial while uploading (ignored by the scanner)
139-
$partFilePath = $this->getPartFileBasePath($this->path) . '.ocTransferId' . $transferId . '.part';
145+
$partFilePath = $partFileBasePath . '.ocTransferId' . $transferId . '.part';
140146

141-
if (!$view->isCreatable($partFilePath) && $view->isUpdatable($this->path)) {
147+
// isCreatable() asks whether something can be created *inside* a path, so
148+
// it has to be given the directory that will hold the part file
149+
if (!$view->isCreatable(dirname($partFilePath)) && $view->isUpdatable($this->path)) {
142150
$needsPartFile = false;
143151
}
144-
}
145-
if (!$needsPartFile) {
146-
// upload file directly as the final path
147-
$partFilePath = $this->path;
148152

149-
if ($view && !$this->emitPreHooks($exists)) {
150-
throw new Exception($this->l10n->t('Could not write to final file, canceled by hook'));
153+
// a hashed part file name cannot reuse the target's encryption key, so
154+
// renaming it over an existing file would leave undecryptable content
155+
if ($exists && $partFileBasePath !== $this->path) {
156+
$needsPartFile = false;
151157
}
158+
152159
}
153160

154161
// the part file and target file might be on a different storage in case of a single file storage (e.g. single file share)
155-
[$partStorage, $internalPartPath] = $this->fileView->resolvePath($partFilePath);
162+
[$partStorage, $internalPartPath] = $this->fileView->resolvePath($needsPartFile ? $partFilePath : $this->path);
156163
[$storage, $internalPath] = $this->fileView->resolvePath($this->path);
157164
if ($partStorage === null || $storage === null) {
158165
throw new ServiceUnavailable($this->l10n->t('Failed to get storage for file'));
159166
}
167+
168+
// a single file share maps the target and nothing else, so the part file
169+
// would land beside it on a different storage - the recipient's own, with
170+
// their quota - instead of next to the file being written
171+
if ($needsPartFile && $partStorage->getId() !== $storage->getId()) {
172+
$needsPartFile = false;
173+
$partStorage = $storage;
174+
$internalPartPath = $internalPath;
175+
}
176+
177+
if (!$needsPartFile) {
178+
// upload file directly as the final path
179+
$partFilePath = $this->path;
180+
181+
if ($view && !$this->emitPreHooks($exists)) {
182+
throw new Exception($this->l10n->t('Could not write to final file, canceled by hook'));
183+
}
184+
}
160185
try {
161186
if (!$needsPartFile) {
162187
try {
@@ -412,6 +437,12 @@ private function getPartFileBasePath($path) {
412437
$partFileInStorage = Server::get(IConfig::class)->getSystemValue('part_file_in_storage', true);
413438
if ($partFileInStorage) {
414439
$filename = basename($path);
440+
// only hash when the name would otherwise overflow the filesystem limit:
441+
// encryption resolves the part file's key by stripping the suffix, which
442+
// only leads back to the target while the real name is kept
443+
if (strlen($filename) + self::PART_FILE_SUFFIX_MAX_LENGTH <= self::MAX_FILENAME_LENGTH) {
444+
return $path;
445+
}
415446
// hash does not need to be secure but fast and semi unique
416447
$hashedFilename = hash('xxh128', $filename);
417448
return substr($path, 0, strlen($path) - strlen($filename)) . $hashedFilename;

apps/dav/tests/unit/Connector/Sabre/FileTest.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

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

12+
use Icewind\Streams\CallbackWrapper;
1213
use OC\AppFramework\Http\Request;
1314
use OC\Files\Filesystem;
1415
use OC\Files\Storage\Local;
@@ -1089,6 +1090,50 @@ public function testSimplePutNoCreatePermissions(): void {
10891090
$this->assertEquals('new content', $view->file_get_contents('root/file.txt'));
10901091
}
10911092

1093+
/**
1094+
* An upload that is interrupted while overwriting an existing file must not
1095+
* destroy what is already there: the data goes into a part file first and is
1096+
* only renamed over the target once it is complete.
1097+
*/
1098+
public function testPutOverwriteInterruptedKeepsOriginal(): void {
1099+
$view = new View('/' . $this->user . '/files');
1100+
$view->file_put_contents('interrupted.txt', 'original content');
1101+
1102+
[$targetStorage] = $view->resolvePath('interrupted.txt');
1103+
if (!$targetStorage->needsPartFile()) {
1104+
// object stores write straight to the final path, so there is no part
1105+
// file to protect the previous content - nothing to assert here
1106+
$this->markTestSkipped('Storage does not use part files');
1107+
}
1108+
1109+
$file = new File($view, $view->getFileInfo('interrupted.txt'));
1110+
1111+
$read = 0;
1112+
$data = CallbackWrapper::wrap($this->getStream('new content'), function ($count) use (&$read): void {
1113+
$read += $count;
1114+
if ($read > 3) {
1115+
throw new \RuntimeException('connection lost mid upload');
1116+
}
1117+
});
1118+
1119+
// beforeMethod locks
1120+
$view->lockFile('interrupted.txt', ILockingProvider::LOCK_SHARED);
1121+
try {
1122+
$file->put($data);
1123+
$this->fail('Expected the interrupted upload to fail');
1124+
} catch (\Sabre\DAV\Exception $e) {
1125+
// expected
1126+
} finally {
1127+
// afterMethod unlocks
1128+
$view->unlockFile('interrupted.txt', ILockingProvider::LOCK_SHARED);
1129+
}
1130+
1131+
// read straight from the storage: a failed write must not have touched it,
1132+
// whatever the view still holds a lock on
1133+
[$storage, $internalPath] = $view->resolvePath('interrupted.txt');
1134+
$this->assertEquals('original content', $storage->file_get_contents($internalPath));
1135+
$this->assertEmpty($this->listPartFiles($view, ''), 'No stray part files');
1136+
}
10921137

10931138
public function testPutLockExpired(): void {
10941139
$view = new View('/' . $this->user . '/files/');

0 commit comments

Comments
 (0)