Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions config/config.sample.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
12 changes: 11 additions & 1 deletion lib/private/Files/Storage/Local.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
56 changes: 56 additions & 0 deletions tests/lib/Files/Storage/LocalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use OCP\Files;
use OCP\Files\ForbiddenException;
use OCP\Files\StorageNotAvailableException;
use OCP\IConfig;
use OCP\ITempManager;
use OCP\Server;

Expand Down Expand Up @@ -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');
Expand Down
Loading