From 80c7841e4b08e22e04879a05cc8c79598cbcdcc4 Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Tue, 3 Nov 2026 14:58:11 +0100 Subject: [PATCH] feat(LocalStorage): add config option to configure min_free_space Apply a default of `100 MiB` as reserved storage. Signed-off-by: Simon L. Assisted-by: ClaudeCode:claude-opus-5-5 --- config/config.sample.php | 16 ++++++++ lib/private/Files/Storage/Local.php | 12 +++++- tests/lib/Files/Storage/LocalTest.php | 56 +++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/config/config.sample.php b/config/config.sample.php index e3a0ede05366b..4ac5224288c0d 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -2560,6 +2560,22 @@ */ 'localstorage.unlink_on_truncate' => false, + /** + * Amount of physical disk space (in bytes) to keep available on the data + * directory's filesystem. The free space reported for local storage is + * reduced by this margin, so uploads are rejected before the disk is + * actually full. This prevents users (in particular those with unlimited + * quota) from filling up all available space, which would starve the + * database, temporary files and logs and can render the instance + * unrecoverable. + * + * Set this to a value large enough to keep the instance operational, or + * to ``0`` to report the free space as-is. + * + * Defaults to 100 MiB + */ + 'localstorage.min_free_space' => 100 * 1024 * 1024, + /** * Include external storage mounts in quota calculations. * diff --git a/lib/private/Files/Storage/Local.php b/lib/private/Files/Storage/Local.php index 99c4d96ff1f7a..58b7995a9863b 100644 --- a/lib/private/Files/Storage/Local.php +++ b/lib/private/Files/Storage/Local.php @@ -46,6 +46,8 @@ class Local extends Common { protected bool $caseInsensitive = false; + private int $minFreeSpace = 0; + public function __construct(array $parameters) { if (!isset($parameters['datadir']) || !is_string($parameters['datadir'])) { throw new \InvalidArgumentException('No data directory set for local storage'); @@ -70,6 +72,12 @@ public function __construct(array $parameters) { // support Write-Once-Read-Many file systems $this->unlinkOnTruncate = $this->config->getSystemValueBool('localstorage.unlink_on_truncate', false); + // amount of physical disk space to keep available, so that filling up + // the storage cannot starve the database, temp files or logs and render + // the instance unrecoverable. Reported free space is reduced by this + // margin, so uploads are blocked before the disk is actually full. + $this->minFreeSpace = max(0, (int)$this->config->getSystemValue('localstorage.min_free_space', 100 * 1024 * 1024)); + if (isset($parameters['isExternal']) && $parameters['isExternal'] && !$this->stat('')) { // data dir not accessible or available, can happen when using an external storage of type Local // on an unmounted system mount point @@ -498,7 +506,9 @@ public function free_space(string $path): int|float|false { if ($space === false || is_null($space)) { return FileInfo::SPACE_UNKNOWN; } - return Util::numericToNumber($space); + // keep a reserved margin available so the instance cannot be driven into + // an unrecoverable out-of-disk state, never report a negative value + return max(0, Util::numericToNumber($space) - $this->minFreeSpace); } public function search(string $query): array { diff --git a/tests/lib/Files/Storage/LocalTest.php b/tests/lib/Files/Storage/LocalTest.php index e676e6718ac31..51b7b4e549629 100644 --- a/tests/lib/Files/Storage/LocalTest.php +++ b/tests/lib/Files/Storage/LocalTest.php @@ -13,6 +13,7 @@ use OCP\Files; use OCP\Files\ForbiddenException; use OCP\Files\StorageNotAvailableException; +use OCP\IConfig; use OCP\ITempManager; use OCP\Server; @@ -144,6 +145,61 @@ public function testUnavailableNonExternal(): void { $this->assertNotNull($this->instance); } + public function testFreeSpaceWithoutReservedMargin(): void { + $realFree = disk_free_space($this->tmpDir); + if ($realFree === false) { + $this->markTestSkipped('disk_free_space is not available'); + } + + // without the config set, free space is reported as-is + $reported = $this->instance->free_space(''); + $this->assertGreaterThan(0, $reported); + } + + public function testFreeSpaceReducedByReservedMargin(): void { + $realFree = disk_free_space($this->tmpDir); + if ($realFree === false) { + $this->markTestSkipped('disk_free_space is not available'); + } + + $margin = 1024 * 1024; + + $config = Server::get(IConfig::class); + $oldValue = $config->getSystemValue('localstorage.min_free_space', 100 * 1024 * 1024); + try { + $config->setSystemValue('localstorage.min_free_space', 0); + $baseline = (new Local(['datadir' => $this->tmpDir]))->free_space(''); + + $config->setSystemValue('localstorage.min_free_space', $margin); + $reported = (new Local(['datadir' => $this->tmpDir]))->free_space(''); + } finally { + $config->setSystemValue('localstorage.min_free_space', $oldValue); + } + + // the reported free space must be reduced by (about) the margin; + // allow a small delta because the real free space can fluctuate + $this->assertEqualsWithDelta($baseline - $margin, $reported, 64 * 1024); + } + + public function testFreeSpaceNeverNegativeWithLargeMargin(): void { + if (disk_free_space($this->tmpDir) === false) { + $this->markTestSkipped('disk_free_space is not available'); + } + + $config = Server::get(IConfig::class); + $oldValue = $config->getSystemValue('localstorage.min_free_space', 100 * 1024 * 1024); + // a margin larger than any plausible disk forces the clamp to 0 + $config->setSystemValue('localstorage.min_free_space', PHP_INT_MAX); + try { + $storage = new Local(['datadir' => $this->tmpDir]); + $reported = $storage->free_space(''); + } finally { + $config->setSystemValue('localstorage.min_free_space', $oldValue); + } + + $this->assertSame(0, $reported); + } + public function testMoveNestedJail(): void { $this->instance->mkdir('foo'); $this->instance->mkdir('foo/bar');