From a60e7437c309cc6a97572f017418affe00afc3de Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 29 Apr 2026 19:13:09 +0200 Subject: [PATCH 1/3] fix: block writing empty files with 0 quota Signed-off-by: Robin Appelman --- lib/private/Files/Storage/Wrapper/Quota.php | 22 ++++++++++--------- tests/lib/Files/Storage/Wrapper/QuotaTest.php | 6 +++++ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/lib/private/Files/Storage/Wrapper/Quota.php b/lib/private/Files/Storage/Wrapper/Quota.php index 35a265f8c8e72..97cb20335dfc6 100644 --- a/lib/private/Files/Storage/Wrapper/Quota.php +++ b/lib/private/Files/Storage/Wrapper/Quota.php @@ -116,19 +116,21 @@ public function copy(string $source, string $target): bool { } public function fopen(string $path, string $mode) { - if (!$this->hasQuota()) { + if (!$this->hasQuota() || $this->isPartFile($path)) { return $this->storage->fopen($path, $mode); } - $source = $this->storage->fopen($path, $mode); - // don't apply quota for part files - if (!$this->isPartFile($path)) { - $free = $this->free_space($path); - if ($source && (is_int($free) || is_float($free)) && $free >= 0 && $mode !== 'r' && $mode !== 'rb') { - // only apply quota for files, not metadata, trash or others - if ($this->shouldApplyQuota($path)) { - return \OC\Files\Stream\Quota::wrap($source, $free); - } + $free = $this->free_space($path); + if ($this->shouldApplyQuota($path) && $free == 0) { + return false; + } + + // todo: storage or getWrapperStorage() ? + $source = $this->getWrapperStorage()->fopen($path, $mode); + if ($source && (is_int($free) || is_float($free)) && $free >= 0 && $mode !== 'r' && $mode !== 'rb') { + // only apply quota for files, not metadata, trash or others + if ($this->shouldApplyQuota($path)) { + return \OC\Files\Stream\Quota::wrap($source, $free); } } diff --git a/tests/lib/Files/Storage/Wrapper/QuotaTest.php b/tests/lib/Files/Storage/Wrapper/QuotaTest.php index 2878fe6ca925f..27aeae5dd7fa0 100644 --- a/tests/lib/Files/Storage/Wrapper/QuotaTest.php +++ b/tests/lib/Files/Storage/Wrapper/QuotaTest.php @@ -229,4 +229,10 @@ public function testNoTouchQuotaZero(): void { $instance = $this->getLimitedStorage(0.0); $this->assertFalse($instance->touch('foobar')); } + + public function testNoFopenQuotaZero(): void { + $instance = $this->getLimitedStorage(0.0); + $fh = $instance->fopen('files/test.txt', 'w'); + $this->assertFalse($fh); + } } From 78cad9e3edc41e9536c77ff097d15a1752c262c7 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 29 Apr 2026 19:14:57 +0200 Subject: [PATCH 2/3] fix: apply quota with writeStream Signed-off-by: Robin Appelman --- lib/private/Files/Storage/Wrapper/Quota.php | 29 +++++++++++++++++++ lib/private/Files/Storage/Wrapper/Wrapper.php | 29 ++++++++++++++----- tests/lib/Files/Storage/Wrapper/QuotaTest.php | 21 ++++++++++++++ 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/lib/private/Files/Storage/Wrapper/Quota.php b/lib/private/Files/Storage/Wrapper/Quota.php index 97cb20335dfc6..7632660379b16 100644 --- a/lib/private/Files/Storage/Wrapper/Quota.php +++ b/lib/private/Files/Storage/Wrapper/Quota.php @@ -11,6 +11,8 @@ use OC\SystemConfig; use OCP\Files\Cache\ICacheEntry; use OCP\Files\FileInfo; +use OCP\Files\GenericFileException; +use OCP\Files\NotEnoughSpaceException; use OCP\Files\Storage\IStorage; class Quota extends Wrapper { @@ -207,4 +209,31 @@ public function touch(string $path, ?int $mtime = null): bool { public function enableQuota(bool $enabled): void { $this->enabled = $enabled; } + + #[\Override] + public function writeStream(string $path, $stream, ?int $size = null): int { + if (!$this->hasQuota()) { + return parent::writeStream($path, $stream, $size); + } + + $free = $this->free_space($path); + if ($this->shouldApplyQuota($path) && $free == 0) { + throw new NotEnoughSpaceException(); + } + + if ($size !== null) { + if ($size < $free) { + return parent::writeStream($path, $stream, $size); + } else { + throw new NotEnoughSpaceException(); + } + } else { + // force fallback through `fopen` to handle the quota + try { + return parent::writeStreamFallback($path, $stream); + } catch (GenericFileException) { + throw new NotEnoughSpaceException(); + } + } + } } diff --git a/lib/private/Files/Storage/Wrapper/Wrapper.php b/lib/private/Files/Storage/Wrapper/Wrapper.php index 7af11dd5ef7cb..5080fb76fd4ac 100644 --- a/lib/private/Files/Storage/Wrapper/Wrapper.php +++ b/lib/private/Files/Storage/Wrapper/Wrapper.php @@ -9,12 +9,12 @@ use OC\Files\Storage\FailedStorage; use OC\Files\Storage\Storage; -use OCP\Files; use OCP\Files\Cache\ICache; use OCP\Files\Cache\IPropagator; use OCP\Files\Cache\IScanner; use OCP\Files\Cache\IUpdater; use OCP\Files\Cache\IWatcher; +use OCP\Files\GenericFileException; use OCP\Files\Storage\ILockingStorage; use OCP\Files\Storage\IStorage; use OCP\Files\Storage\IWriteStreamStorage; @@ -321,13 +321,28 @@ public function writeStream(string $path, $stream, ?int $size = null): int { if ($storage->instanceOfStorage(IWriteStreamStorage::class)) { /** @var IWriteStreamStorage $storage */ return $storage->writeStream($path, $stream, $size); - } else { - $target = $this->fopen($path, 'w'); - $count = Files::streamCopy($stream, $target); - fclose($stream); - fclose($target); - return $count; } + + return $this->writeStreamFallback($path, $stream); + } + + /** + * @param resource $stream + */ + protected function writeStreamFallback(string $path, $stream): int { + $target = $this->fopen($path, 'w'); + if ($target === false) { + throw new GenericFileException('Failed to open ' . $path); + } + + $count = stream_copy_to_stream($stream, $target); + fclose($stream); + fclose($target); + if ($count === false) { + throw new GenericFileException('Failed to copy stream.'); + } + + return $count; } public function getDirectoryContent(string $directory): \Traversable { diff --git a/tests/lib/Files/Storage/Wrapper/QuotaTest.php b/tests/lib/Files/Storage/Wrapper/QuotaTest.php index 27aeae5dd7fa0..9033e41edbe39 100644 --- a/tests/lib/Files/Storage/Wrapper/QuotaTest.php +++ b/tests/lib/Files/Storage/Wrapper/QuotaTest.php @@ -235,4 +235,25 @@ public function testNoFopenQuotaZero(): void { $fh = $instance->fopen('files/test.txt', 'w'); $this->assertFalse($fh); } + + public function testNoWriteStreamQuota(): void { + $instance = $this->getLimitedStorage(5.0); + $stream = fopen('php://temp', 'w+'); + fwrite($stream, 'foo'); + rewind($stream); + $instance->writeStream('files/test.txt', $stream); + + $stream = fopen('php://temp', 'w+'); + fwrite($stream, 'foobar'); + rewind($stream); + $this->expectException(Files\NotEnoughSpaceException::class); + $instance->writeStream('files/test.txt', $stream); + } + + public function testNoWriteStreamQuotaZero(): void { + $instance = $this->getLimitedStorage(0.0); + $stream = fopen('php://temp', 'w+'); + $this->expectException(Files\NotEnoughSpaceException::class); + $instance->writeStream('files/test.txt', $stream); + } } From 05bea1847dd4c526f5b52ba622d07ba5e177d773 Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Wed, 29 Apr 2026 19:15:26 +0200 Subject: [PATCH 3/3] fix: translate NotEnoughSpaceException to dav exception Signed-off-by: Robin Appelman --- apps/dav/lib/Connector/Sabre/File.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/dav/lib/Connector/Sabre/File.php b/apps/dav/lib/Connector/Sabre/File.php index b1a0a93968dd5..453502c756589 100644 --- a/apps/dav/lib/Connector/Sabre/File.php +++ b/apps/dav/lib/Connector/Sabre/File.php @@ -604,6 +604,9 @@ private function convertToSabreException(\Exception $e) { if ($e instanceof NotFoundException) { throw new NotFound($this->l10n->t('File not found: %1$s', [$e->getMessage()]), 0, $e); } + if ($e instanceof Files\NotEnoughSpaceException) { + throw new EntityTooLarge($this->l10n->t('Insufficient space'), 0, $e); + } throw new \Sabre\DAV\Exception($e->getMessage(), 0, $e); }