Skip to content
Open
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: 12 additions & 4 deletions apps/dav/lib/Connector/Sabre/QuotaPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,17 @@ private function getPathForDestination(string $destinationPath): string {
* @return bool True if there is enough space, otherwise throws.
*/
public function checkQuota(string $path, int|float|null $length = null, bool $isDir = false): bool {
// Creating an empty directory (MKCOL) does not consume user quota bytes:
// it is only a cache / metadata entry. Leave the final decision to the
// target storage (the Quota wrapper is only attached to the home storage
// and can still refuse if needed; external mounts have no quota wrapper).
// Without this short-circuit, a user whose quota is intentionally set to 0
// (so that all content lives on external mounts) cannot create folders at
// all, including paths that actually resolve to an external mount.
if ($isDir) {
return true;
}

// Auto-detect length if not provided
if ($length === null) {
$length = $this->getLength();
Expand All @@ -257,10 +268,7 @@ public function checkQuota(string $path, int|float|null $length = null, bool $is
}

if ($length > $freeSpace) {
$msg = $isDir
? "Insufficient space in $normalizedPath. Cannot create directory"
: "Insufficient space in $normalizedPath";
throw new InsufficientStorage($msg);
throw new InsufficientStorage("Insufficient space in $normalizedPath");
}

return true;
Expand Down
30 changes: 30 additions & 0 deletions apps/dav/tests/unit/Connector/Sabre/QuotaPluginTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,36 @@ public function testCheckQuotaOnPath(int $quota, array $headers): void {
$this->assertTrue($result);
}

/**
* An MKCOL must always be allowed by the DAV preflight, regardless of the
* reported free space, because an empty directory does not consume quota
* bytes. In particular this ensures that a user with a quota of 0 (whose
* Quota wrapper reports free_space = 0 on the home storage) can still
* create directories, either as mount-point hosts or inside external
* mounts whose parent path happens to resolve through the home.
*/
public function testCheckQuotaAllowsDirectoryCreationEvenWhenFull(): void {
$this->init(0);

$this->server->httpRequest = new \Sabre\HTTP\Request('MKCOL', 'dummy.dir', []);
$result = $this->plugin->checkQuota('/some/path', 4096, true);
$this->assertTrue($result);
}

/**
* Conversely, writing a file on a storage that exposes 0 free space must
* still be blocked with InsufficientStorage: the MKCOL relaxation must
* not leak into file writes.
*/
public function testCheckQuotaStillBlocksFileUploadWhenFull(): void {
$this->expectException(\Sabre\DAV\Exception\InsufficientStorage::class);

$this->init(0);

$this->server->httpRequest = new \Sabre\HTTP\Request('PUT', 'dummy.file', ['CONTENT-LENGTH' => '1024']);
$this->plugin->checkQuota('', 1024, false);
}

public static function quotaOkayProvider(): array {
return [
[1024, []],
Expand Down
33 changes: 28 additions & 5 deletions lib/private/Files/Storage/Wrapper/Quota.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,11 @@ public function file_put_contents(string $path, mixed $data): int|float|false {
return $this->getWrapperStorage()->file_put_contents($path, $data);
}
$free = $this->free_space($path);
if ($free < 0 || strlen($data) < $free) {
// Only apply quota for files under the user's "files/" tree.
// Writes to metadata locations (files_trashbin/, files_versions/, ...)
// must not be blocked, otherwise features like the trashbin break
// for users whose quota happens to be exhausted (notably quota=0).
if ($free < 0 || !$this->shouldApplyQuota($path) || strlen($data) < $free) {
return $this->getWrapperStorage()->file_put_contents($path, $data);
} else {
return false;
Expand All @@ -108,7 +112,7 @@ public function copy(string $source, string $target): bool {
return $this->getWrapperStorage()->copy($source, $target);
}
$free = $this->free_space($target);
if ($free < 0 || $this->getSize($source) < $free) {
if ($free < 0 || !$this->shouldApplyQuota($target) || $this->getSize($source) < $free) {
return $this->getWrapperStorage()->copy($source, $target);
} else {
return false;
Expand Down Expand Up @@ -159,7 +163,12 @@ public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalP
return $this->getWrapperStorage()->copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath);
}
$free = $this->free_space($targetInternalPath);
if ($free < 0 || $this->getSize($sourceInternalPath, $sourceStorage) < $free) {
// Skip the quota check when the target lives outside of "files/"
// (e.g. files_trashbin/, files_versions/). This is essential so that
// the trashbin can store deleted items even when the user's quota is
// fully consumed: otherwise DELETE operations on external mounts fail
// with HTTP 403 because the move-to-trash copy returns false.
if ($free < 0 || !$this->shouldApplyQuota($targetInternalPath) || $this->getSize($sourceInternalPath, $sourceStorage) < $free) {
return $this->getWrapperStorage()->copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath);
} else {
return false;
Expand All @@ -171,7 +180,7 @@ public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalP
return $this->getWrapperStorage()->moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath);
}
$free = $this->free_space($targetInternalPath);
if ($free < 0 || $this->getSize($sourceInternalPath, $sourceStorage) < $free) {
if ($free < 0 || !$this->shouldApplyQuota($targetInternalPath) || $this->getSize($sourceInternalPath, $sourceStorage) < $free) {
return $this->getWrapperStorage()->moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath);
} else {
return false;
Expand All @@ -182,6 +191,18 @@ public function mkdir(string $path): bool {
if (!$this->hasQuota()) {
return $this->getWrapperStorage()->mkdir($path);
}
// A quota of exactly 0 is a valid administrative choice meaning
// "the user may not store any file bytes in their home storage"
// (typically because all content lives on external mounts).
// An empty directory does not consume quota bytes, so we must not
// block mkdir here; otherwise the user cannot even host mount points
// or create the folder structure needed to reach external storages.
// File writes (fopen / file_put_contents / copy / moveFromStorage)
// keep checking free_space and remain correctly blocked.
// Loose comparison to cover both int 0 and float 0.0.
if ($this->getQuota() == 0) {
return parent::mkdir($path);
}
$free = $this->free_space($path);
if ($this->shouldApplyQuota($path) && $free == 0) {
return false;
Expand All @@ -195,7 +216,9 @@ public function touch(string $path, ?int $mtime = null): bool {
return $this->getWrapperStorage()->touch($path, $mtime);
}
$free = $this->free_space($path);
if ($free == 0) {
// Same rule as the other write paths: only block when the target is
// actually under the user-quota controlled "files/" tree.
if ($free == 0 && $this->shouldApplyQuota($path)) {
return false;
}

Expand Down
53 changes: 49 additions & 4 deletions tests/lib/Files/Storage/Wrapper/QuotaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,17 @@ public function testInstanceOfStorageWrapper(): void {
$this->assertTrue($this->instance->instanceOfStorage('\OC\Files\Storage\Wrapper\Quota'));
}

public function testNoMkdirQuotaZero(): void {
/**
* A quota of 0 must not prevent the creation of empty directories:
* an empty directory consumes no quota bytes, and blocking mkdir here
* would prevent admins from using "quota=0" as "only external mounts
* allowed" (the user could no longer create mount-point hosts or any
* folder structure, breaking most DAV clients).
*/
public function testMkdirAllowedWhenQuotaIsZero(): void {
$instance = $this->getLimitedStorage(0.0);
$this->assertFalse($instance->mkdir('files'));
$this->assertFalse($instance->mkdir('files/foobar'));
$this->assertTrue($instance->mkdir('files'));
$this->assertTrue($instance->mkdir('files/foobar'));
}

public function testMkdirQuotaZeroTrashbin(): void {
Expand All @@ -223,8 +230,46 @@ public function testMkdirQuotaZeroTrashbin(): void {
$this->assertTrue($instance->mkdir('cache'));
}

/**
* Writing file content under "files/" must still be blocked when quota
* is 0: the relaxation only applies to directory entries and to writes
* outside the user-quota controlled area (e.g. trashbin metadata).
*/
public function testFilePutContentsBlockedWhenQuotaIsZero(): void {
$instance = $this->getLimitedStorage(0.0);
$this->assertTrue($instance->mkdir('files'));
$this->assertFalse($instance->file_put_contents('files/foo', 'x'));
// writes outside files/ (e.g. trashbin metadata, versions)
// must not be blocked, otherwise the trashbin breaks.
$this->assertTrue($instance->mkdir('files_trashbin'));
$this->assertNotFalse($instance->file_put_contents('files_trashbin/foo.json', '{}'));
}

/**
* Copying from another storage (e.g. an external SMB mount) into the
* trashbin must succeed even when the quota is exhausted: this is what
* happens on every DELETE when files_trashbin is enabled. Conversely,
* copying into "files/" must still be blocked.
*/
public function testCopyFromStorageAllowedToTrashbinWhenQuotaIsZero(): void {
$source = new Local(['datadir' => $this->tmpDir]);
$source->file_put_contents('source.txt', 'hello');

$instance = $this->getLimitedStorage(0.0);
$this->assertTrue($instance->mkdir('files_trashbin'));
$this->assertTrue($instance->copyFromStorage($source, 'source.txt', 'files_trashbin/source.txt'));

$this->assertTrue($instance->mkdir('files'));
$this->assertFalse($instance->copyFromStorage($source, 'source.txt', 'files/source.txt'));
}

public function testNoTouchQuotaZero(): void {
$instance = $this->getLimitedStorage(0.0);
$this->assertFalse($instance->touch('foobar'));
// touch is blocked only for paths under files/ (user quota area)
$this->assertTrue($instance->mkdir('files'));
$this->assertFalse($instance->touch('files/foobar'));
// touch outside files/ (trashbin, versions, ...) must remain allowed
$this->assertTrue($instance->mkdir('files_trashbin'));
$this->assertTrue($instance->touch('files_trashbin/foobar'));
}
}