Skip to content

Commit 7efb33e

Browse files
Merge pull request #61072 from nextcloud/backport/60453/stable34
[stable34] fix(dav): finalize upload metadata before post-write hooks
2 parents d21dee9 + 7c8dbc6 commit 7efb33e

3 files changed

Lines changed: 72 additions & 50 deletions

File tree

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

Lines changed: 50 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use OCP\Files\LockNotAcquiredException;
3232
use OCP\Files\NotFoundException;
3333
use OCP\Files\NotPermittedException;
34+
use OCP\Files\Storage\IStorage;
3435
use OCP\Files\Storage\IWriteStreamStorage;
3536
use OCP\Files\StorageNotAvailableException;
3637
use OCP\IConfig;
@@ -334,56 +335,64 @@ public function put($data) {
334335
}
335336
}
336337

337-
// since we skipped the view we need to scan and emit the hooks ourselves
338-
$storage->getUpdater()->update($internalPath);
339-
340-
try {
341-
$this->changeLock(ILockingProvider::LOCK_SHARED);
342-
} catch (LockedException $e) {
343-
throw new FileLocked($e->getMessage(), $e->getCode(), $e);
344-
}
345-
346-
// allow sync clients to send the mtime along in a header
347-
$mtimeHeader = $this->request->getHeader('x-oc-mtime');
348-
if ($mtimeHeader !== '') {
349-
$mtime = $this->sanitizeMtime($mtimeHeader);
350-
if ($this->fileView->touch($this->path, $mtime)) {
351-
$this->header('X-OC-MTime: accepted');
352-
}
353-
}
338+
$this->finalizeUpload($storage, $internalPath, $exists, $view);
339+
} catch (StorageNotAvailableException $e) {
340+
throw new ServiceUnavailable($this->l10n->t('Failed to check file size: %1$s', [$e->getMessage()]), 0, $e);
341+
}
354342

355-
$fileInfoUpdate = [
356-
'upload_time' => time()
357-
];
343+
return '"' . $this->info->getEtag() . '"';
344+
}
358345

359-
// allow sync clients to send the creation time along in a header
360-
$ctimeHeader = $this->request->getHeader('x-oc-ctime');
361-
if ($ctimeHeader) {
362-
$ctime = $this->sanitizeMtime($ctimeHeader);
363-
$fileInfoUpdate['creation_time'] = $ctime;
364-
$this->header('X-OC-CTime: accepted');
346+
private function finalizeUpload(IStorage $storage, string $internalPath, bool $exists, ?View $view): void {
347+
// Since we skipped the view for the final publish step, finalize the file
348+
// state explicitly here: update cache/bookkeeping, persist metadata, then
349+
// downgrade to a shared lock before emitting post-write hooks so listeners
350+
// can still access the file.
351+
$storage->getUpdater()->update($internalPath);
352+
353+
$fileInfoUpdate = [
354+
'upload_time' => time(),
355+
];
356+
357+
// allow sync clients to send the mtime along in a header
358+
$mtimeHeader = $this->request->getHeader('x-oc-mtime');
359+
if ($mtimeHeader !== '') {
360+
$mtime = $this->sanitizeMtime($mtimeHeader);
361+
if ($this->fileView->touch($this->path, $mtime)) {
362+
$this->header('X-OC-MTime: accepted');
365363
}
364+
}
366365

367-
$this->fileView->putFileInfo($this->path, $fileInfoUpdate);
366+
// allow sync clients to send the creation time along in a header
367+
$ctimeHeader = $this->request->getHeader('x-oc-ctime');
368+
if ($ctimeHeader !== '') {
369+
$ctime = $this->sanitizeMtime($ctimeHeader);
370+
$fileInfoUpdate['creation_time'] = $ctime;
371+
$this->header('X-OC-CTime: accepted');
372+
}
368373

369-
if ($view) {
370-
$this->emitPostHooks($exists);
371-
}
374+
// Persist checksum before post hooks so observers see fully finalized metadata.
375+
$checksumHeader = $this->request->getHeader('oc-checksum');
376+
if ($checksumHeader) {
377+
$fileInfoUpdate['checksum'] = trim($checksumHeader);
378+
} elseif ($this->getChecksum() !== null && $this->getChecksum() !== '') {
379+
$fileInfoUpdate['checksum'] = '';
380+
}
372381

373-
$this->refreshInfo();
382+
$this->fileView->putFileInfo($this->path, $fileInfoUpdate);
383+
$this->refreshInfo();
374384

375-
$checksumHeader = $this->request->getHeader('oc-checksum');
376-
if ($checksumHeader) {
377-
$checksum = trim($checksumHeader);
378-
$this->setChecksum($checksum);
379-
} elseif ($this->getChecksum() !== null && $this->getChecksum() !== '') {
380-
$this->setChecksum('');
381-
}
382-
} catch (StorageNotAvailableException $e) {
383-
throw new ServiceUnavailable($this->l10n->t('Failed to check file size: %1$s', [$e->getMessage()]), 0, $e);
385+
// Downgrade to shared lock before post hooks so legacy hook consumers can
386+
// still access the file during post_write.
387+
try {
388+
$this->changeLock(ILockingProvider::LOCK_SHARED);
389+
} catch (LockedException $e) {
390+
throw new FileLocked($e->getMessage(), $e->getCode(), $e);
384391
}
385392

386-
return '"' . $this->info->getEtag() . '"';
393+
if ($view) {
394+
$this->emitPostHooks($exists);
395+
}
387396
}
388397

389398
private function getPartFileBasePath($path) {

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

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,8 @@ private function doPut(string $path, ?string $viewRoot = null, ?Request $request
238238
null,
239239
[
240240
'permissions' => Constants::PERMISSION_ALL,
241-
'type' => FileInfo::TYPE_FOLDER,
241+
'type' => FileInfo::TYPE_FILE,
242+
'checksum' => '',
242243
],
243244
null
244245
);
@@ -800,7 +801,13 @@ protected function assertHookCall($callData, $signal, $hookPath) {
800801
}
801802

802803
/**
803-
* Test whether locks are set before and after the operation
804+
* Test that PUT keeps hook-time lock semantics compatible:
805+
* - pre-write hooks run while the file is shared-locked
806+
* - post-write hooks also run while the file is shared-locked
807+
*
808+
* Post-write hooks are expected to observe a fully finalized file state,
809+
* but should still be able to access the file without exclusive-lock
810+
* contention.
804811
*/
805812
public function testPutLocking(): void {
806813
$view = new View('/' . $this->user . '/files/');
@@ -812,7 +819,8 @@ public function testPutLocking(): void {
812819
null,
813820
[
814821
'permissions' => Constants::PERMISSION_ALL,
815-
'type' => FileInfo::TYPE_FOLDER,
822+
'type' => FileInfo::TYPE_FILE,
823+
'checksum' => '',
816824
],
817825
null
818826
);
@@ -832,8 +840,8 @@ public function testPutLocking(): void {
832840
$wasLockedPost = false;
833841
$eventHandler = $this->createMock(EventHandlerMock::class);
834842

835-
// both pre and post hooks might need access to the file,
836-
// so only shared lock is acceptable
843+
// Pre-write hooks should run under a shared lock so observers can safely
844+
// inspect the target while the write is in progress.
837845
$eventHandler->expects($this->once())
838846
->method('writeCallback')
839847
->willReturnCallback(
@@ -842,6 +850,10 @@ function () use ($view, $path, &$wasLockedPre): void {
842850
$wasLockedPre = $wasLockedPre && !$this->isFileLocked($view, $path, ILockingProvider::LOCK_EXCLUSIVE);
843851
}
844852
);
853+
854+
// Post-write hooks should also run under a shared lock. They are expected to
855+
// see fully finalized metadata/state, but still be able to access the file
856+
// during the callback.
845857
$eventHandler->expects($this->once())
846858
->method('postWriteCallback')
847859
->willReturnCallback(
@@ -872,8 +884,8 @@ function () use ($view, $path, &$wasLockedPost): void {
872884
// afterMethod unlocks
873885
$view->unlockFile($path, ILockingProvider::LOCK_SHARED);
874886

875-
$this->assertTrue($wasLockedPre, 'File was locked during pre-hooks');
876-
$this->assertTrue($wasLockedPost, 'File was locked during post-hooks');
887+
$this->assertTrue($wasLockedPre, 'File was shared-locked during pre-hooks');
888+
$this->assertTrue($wasLockedPost, 'File was shared-locked during post-hooks');
877889

878890
$this->assertFalse(
879891
$this->isFileLocked($view, $path, ILockingProvider::LOCK_SHARED),
@@ -1037,7 +1049,8 @@ public function testPutLockExpired(): void {
10371049
null,
10381050
[
10391051
'permissions' => Constants::PERMISSION_ALL,
1040-
'type' => FileInfo::TYPE_FOLDER,
1052+
'type' => FileInfo::TYPE_FILE,
1053+
'checksum' => '',
10411054
],
10421055
null
10431056
);

lib/private/Files/FileInfo.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ public function addSubEntry($data, $entryPath) {
382382
*/
383383
#[\Override]
384384
public function getChecksum() {
385-
return $this->data['checksum'];
385+
return $this->data['checksum'] ?? '';
386386
}
387387

388388
#[\Override]

0 commit comments

Comments
 (0)