diff --git a/lib/private/Files/Storage/Wrapper/Encryption.php b/lib/private/Files/Storage/Wrapper/Encryption.php index 4a1fa9135a46d..a3ed3f4eb871f 100644 --- a/lib/private/Files/Storage/Wrapper/Encryption.php +++ b/lib/private/Files/Storage/Wrapper/Encryption.php @@ -33,6 +33,11 @@ class Encryption extends Wrapper { use LocalTempFileTrait; private string $mountPoint; + /** + * Unencrypted sizes of files that are not (yet) reflected in the file cache, + * the entries are dropped as soon as the file cache knows the size + * @var array + */ protected array $unencryptedSize = []; private IMountPoint $mount; /** for which path we execute the repair step to avoid recursions */ @@ -76,6 +81,8 @@ public function filesize(string $path): int|float|false { // Update file cache (only if file is already cached). // Certain files are not cached (e.g. *.part). if (isset($info['fileid'])) { + $isEncryptedInCache = !empty($info['encrypted']); + if ($info instanceof ICacheEntry) { $info['encrypted'] = $info['encryptedVersion']; } else { @@ -94,6 +101,12 @@ public function filesize(string $path): int|float|false { 'unencrypted_size' => $size ]); } + + // The file cache now holds the unencrypted size and is marked as encrypted, + // so following calls can read the size from there and we can forget it here. + if ($isEncryptedInCache) { + unset($this->unencryptedSize[$fullPath]); + } } return $size; @@ -111,9 +124,17 @@ private function modifyMetaData(string $path, array $data): array { $info = $this->getCache()->get($path); if (isset($this->unencryptedSize[$fullPath])) { + $unencryptedSize = $this->unencryptedSize[$fullPath]; $data['encrypted'] = true; - $data['size'] = $this->unencryptedSize[$fullPath]; + $data['size'] = $unencryptedSize; $data['unencrypted_size'] = $data['size']; + + // Once the file cache holds the same size we do not need to remember it anymore + if ($info instanceof ICacheEntry + && $info['encrypted'] + && $info->getUnencryptedSize() === $unencryptedSize) { + unset($this->unencryptedSize[$fullPath]); + } } else { if (isset($info['fileid']) && $info['encrypted']) { $data['size'] = $this->verifyUnencryptedSize($path, $info->getUnencryptedSize()); @@ -187,39 +208,51 @@ public function unlink(string $path): bool { $this->keyStorage->deleteAllFileKeys($fullPath); } - return $this->getWrapperStorage()->unlink($path); + $result = $this->getWrapperStorage()->unlink($path); + if ($result) { + unset($this->unencryptedSize[$fullPath]); + } + + return $result; } #[\Override] public function rename(string $source, string $target): bool { $result = $this->getWrapperStorage()->rename($source, $target); + if (!$result) { + return false; + } - if ($result + $sourcePath = $this->getFullPath($source); + $targetPath = $this->getFullPath($target); + + // the source no longer exists, so its remembered unencrypted sizes belong to the target now + $this->moveUnencryptedSizes($sourcePath, $targetPath); + + if ( // versions always use the keys from the original file, so we can skip // this step for versions - && $this->isVersion($target) === false - && $this->encryptionManager->isEnabled()) { - $sourcePath = $this->getFullPath($source); - if (!$this->util->isExcluded($sourcePath)) { - $targetPath = $this->getFullPath($target); - if (isset($this->unencryptedSize[$sourcePath])) { - $this->unencryptedSize[$targetPath] = $this->unencryptedSize[$sourcePath]; - } - $this->keyStorage->renameKeys($sourcePath, $targetPath); - $module = $this->getEncryptionModule($target); - if ($module) { - $module->update($targetPath, $this->uid, []); - } + $this->isVersion($target) === false + && $this->encryptionManager->isEnabled() + && !$this->util->isExcluded($sourcePath) + ) { + $this->keyStorage->renameKeys($sourcePath, $targetPath); + $module = $this->getEncryptionModule($target); + if ($module) { + $module->update($targetPath, $this->uid, []); } } - return $result; + return true; } #[\Override] public function rmdir(string $path): bool { $result = $this->getWrapperStorage()->rmdir($path); $fullPath = $this->getFullPath($path); + if ($result) { + $this->clearUnencryptedSizes($fullPath); + } if ($result && $this->util->isExcluded($fullPath) === false && $this->encryptionManager->isEnabled() @@ -877,10 +910,50 @@ protected function getEncryptionModule(string $path): ?IEncryptionModule { return $encryptionModule; } + /** + * Remember the unencrypted size of a file until it is written to the file cache + * + * @param string $path path relative to data/ + */ public function updateUnencryptedSize(string $path, int|float $unencryptedSize): void { $this->unencryptedSize[$path] = $unencryptedSize; } + /** + * Forget the remembered unencrypted sizes of a path and everything below it + * + * @param string $fullPath path relative to data/ + */ + private function clearUnencryptedSizes(string $fullPath): void { + $prefix = rtrim($fullPath, '/') . '/'; + foreach (array_keys($this->unencryptedSize) as $path) { + if ($path === $fullPath || str_starts_with($path, $prefix)) { + unset($this->unencryptedSize[$path]); + } + } + } + + /** + * Re-key the remembered unencrypted sizes of a path and everything below it + * + * @param string $sourceFullPath path relative to data/ + * @param string $targetFullPath path relative to data/ + */ + private function moveUnencryptedSizes(string $sourceFullPath, string $targetFullPath): void { + $sourcePrefix = rtrim($sourceFullPath, '/') . '/'; + $targetPrefix = rtrim($targetFullPath, '/') . '/'; + foreach ($this->unencryptedSize as $path => $size) { + if ($path === $sourceFullPath) { + $this->unencryptedSize[$targetFullPath] = $size; + } elseif (str_starts_with($path, $sourcePrefix)) { + $this->unencryptedSize[$targetPrefix . substr($path, strlen($sourcePrefix))] = $size; + } else { + continue; + } + unset($this->unencryptedSize[$path]); + } + } + /** * copy keys to new location * diff --git a/tests/lib/Files/Storage/Wrapper/EncryptionTest.php b/tests/lib/Files/Storage/Wrapper/EncryptionTest.php index 5f7e157d67cf0..0e22db203d1ce 100644 --- a/tests/lib/Files/Storage/Wrapper/EncryptionTest.php +++ b/tests/lib/Files/Storage/Wrapper/EncryptionTest.php @@ -241,7 +241,7 @@ function ($path) use ($encrypted) { ->getMock(); if ($unencryptedSizeSet) { - $this->invokePrivate($this->instance, 'unencryptedSize', [[$path => $storedUnencryptedSize]]); + $this->instance->updateUnencryptedSize($path, $storedUnencryptedSize); } $fileEntry = $this->getMockBuilder('\OC\Files\Cache\Cache') @@ -327,6 +327,220 @@ public function testFilesize(): void { ); } + /** + * @param string[] $mockedMethods + * @param \OC\Files\Storage\Storage|null $sourceStorage the wrapped storage, defaults to a temporary storage + * @return Encryption&MockObject + */ + private function getInstanceWithMockedMethods(array $mockedMethods, $sourceStorage = null) { + return $this->getMockBuilder(Encryption::class) + ->setConstructorArgs( + [ + [ + 'storage' => $sourceStorage ?? $this->sourceStorage, + 'root' => 'foo', + 'mountPoint' => '/', + 'mount' => $this->mount + ], + $this->encryptionManager, + $this->util, + $this->logger, + $this->file, + null, + $this->keyStore, + $this->mountManager, + $this->arrayCache, + ] + ) + ->onlyMethods($mockedMethods) + ->getMock(); + } + + /** + * @param ICache&MockObject $cache + * @return Encryption&MockObject + */ + private function getInstanceWithCache(ICache $cache) { + $instance = $this->getInstanceWithMockedMethods(['getCache', 'verifyUnencryptedSize']); + $instance->expects($this->any())->method('getCache')->willReturn($cache); + + return $instance; + } + + /** + * Instance with a mocked source storage, so that no real file system is touched + * + * @return array{Encryption&MockObject, \OC\Files\Storage\Storage&MockObject} + */ + private function getInstanceWithMockedStorage(): array { + $sourceStorage = $this->createMock(\OC\Files\Storage\Storage::class); + + $instance = $this->getInstanceWithMockedMethods(['getCache', 'getEncryptionModule'], $sourceStorage); + $instance->expects($this->any())->method('getCache')->willReturn($this->cache); + $instance->expects($this->any())->method('getEncryptionModule')->willReturn($this->encryptionModule); + + return [$instance, $sourceStorage]; + } + + private function getRememberedUnencryptedSizes(Encryption $instance): array { + return self::invokePrivate($instance, 'unencryptedSize'); + } + + public function testUnencryptedSizeIsForgottenOnceStoredInFileCache(): void { + $cachedUnencryptedSize = 0; + + $cache = $this->createMock(Cache::class); + $cache->expects($this->any()) + ->method('get') + ->willReturnCallback(function () use (&$cachedUnencryptedSize) { + return new CacheEntry([ + 'encrypted' => true, + 'encryptedVersion' => 1, + 'path' => '/test.txt', + 'size' => 8192, + 'unencrypted_size' => $cachedUnencryptedSize, + 'fileid' => 1, + ]); + }); + $cache->expects($this->once()) + ->method('update') + ->with(1, ['unencrypted_size' => 42]) + ->willReturnCallback(function () use (&$cachedUnencryptedSize): void { + $cachedUnencryptedSize = 42; + }); + + $instance = $this->getInstanceWithCache($cache); + $instance->expects($this->any()) + ->method('verifyUnencryptedSize') + ->willReturnCallback(fn (string $path, int $unencryptedSize): int => $unencryptedSize); + $instance->updateUnencryptedSize('/test.txt', 42); + + $this->assertSame(42, $instance->filesize('/test.txt')); + $this->assertSame([], $this->getRememberedUnencryptedSizes($instance)); + // the size is read from the file cache from now on + $this->assertSame(42, $instance->filesize('/test.txt')); + } + + public function testUnencryptedSizeIsKeptForUncachedFiles(): void { + $cache = $this->createMock(Cache::class); + // part files have no file cache entry + $cache->expects($this->any()) + ->method('get') + ->willReturn(['encrypted' => true, 'path' => '/test.txt.part']); + $cache->expects($this->never())->method('update'); + + $instance = $this->getInstanceWithCache($cache); + $instance->updateUnencryptedSize('/test.txt.part', 42); + + $this->assertSame(42, $instance->filesize('/test.txt.part')); + $this->assertSame(['/test.txt.part' => 42], $this->getRememberedUnencryptedSizes($instance)); + } + + public function testUnencryptedSizeIsKeptIfFileCacheIsNotFlaggedEncrypted(): void { + $cache = $this->createMock(Cache::class); + $cache->expects($this->any()) + ->method('get') + ->willReturn(new CacheEntry(['encrypted' => false, 'path' => '/test.txt', 'size' => 8192, 'fileid' => 1])); + + $instance = $this->getInstanceWithCache($cache); + $instance->updateUnencryptedSize('/test.txt', 42); + + $this->assertSame(42, $instance->filesize('/test.txt')); + $this->assertSame(['/test.txt' => 42], $this->getRememberedUnencryptedSizes($instance)); + } + + public function testUnencryptedSizeIsForgottenOnUnlink(): void { + [$instance, $sourceStorage] = $this->getInstanceWithMockedStorage(); + $sourceStorage->expects($this->once())->method('unlink')->with('/test.txt')->willReturn(true); + + $instance->updateUnencryptedSize('/test.txt', 42); + + $this->assertTrue($instance->unlink('/test.txt')); + $this->assertSame([], $this->getRememberedUnencryptedSizes($instance)); + } + + public function testUnencryptedSizeIsForgottenOnRmdir(): void { + [$instance, $sourceStorage] = $this->getInstanceWithMockedStorage(); + $sourceStorage->expects($this->once())->method('rmdir')->with('/folder')->willReturn(true); + + $instance->updateUnencryptedSize('/folder', 42); + $instance->updateUnencryptedSize('/folder/test.txt', 42); + $instance->updateUnencryptedSize('/folder.txt', 12); + + $this->assertTrue($instance->rmdir('/folder')); + $this->assertSame(['/folder.txt' => 12], $this->getRememberedUnencryptedSizes($instance)); + } + + public function testUnencryptedSizeIsMovedOnRename(): void { + [$instance, $sourceStorage] = $this->getInstanceWithMockedStorage(); + $sourceStorage->expects($this->exactly(2))->method('rename')->willReturn(true); + $this->encryptionManager->expects($this->any())->method('isEnabled')->willReturn(true); + $this->keyStore->expects($this->any())->method('renameKeys')->willReturn(true); + + $instance->updateUnencryptedSize('/source.txt', 42); + $instance->updateUnencryptedSize('/folder/source.txt', 12); + + $this->assertTrue($instance->rename('/source.txt', '/target.txt')); + $this->assertTrue($instance->rename('/folder', '/renamed')); + + $this->assertEquals([ + '/target.txt' => 42, + '/renamed/source.txt' => 12, + ], $this->getRememberedUnencryptedSizes($instance)); + } + + public function testUnencryptedSizesDoNotAccumulate(): void { + $cache = $this->createMock(Cache::class); + $cache->expects($this->any()) + ->method('get') + ->willReturnCallback(fn (string $path) => new CacheEntry([ + 'encrypted' => true, + 'encryptedVersion' => 1, + 'path' => $path, + 'size' => 8192, + 'unencrypted_size' => 0, + 'fileid' => 1, + ])); + + $instance = $this->getInstanceWithCache($cache); + + for ($i = 0; $i < 1024; $i++) { + $path = '/test' . $i . '.txt'; + $instance->updateUnencryptedSize($path, 42); + $this->assertSame(42, $instance->filesize($path)); + } + + $this->assertSame([], $this->getRememberedUnencryptedSizes($instance)); + } + + public function testUnencryptedSizeIsForgottenOnceTheFileCacheAgrees(): void { + $cache = $this->createMock(Cache::class); + $cache->expects($this->any()) + ->method('get') + ->willReturn(new CacheEntry([ + 'encrypted' => true, + 'encryptedVersion' => 1, + 'path' => '/test.txt', + 'size' => 8192, + 'unencrypted_size' => 42, + 'fileid' => 1, + ])); + + $sourceStorage = $this->createMock(\OC\Files\Storage\Storage::class); + $sourceStorage->expects($this->once()) + ->method('getMetaData') + ->with('/test.txt') + ->willReturn(['size' => 8192, 'encrypted' => false, 'fileid' => 1]); + + $instance = $this->getInstanceWithMockedMethods(['getCache'], $sourceStorage); + $instance->expects($this->any())->method('getCache')->willReturn($cache); + $instance->updateUnencryptedSize('/test.txt', 42); + + $metaData = $instance->getMetaData('/test.txt'); + $this->assertSame(42, $metaData['size']); + $this->assertSame([], $this->getRememberedUnencryptedSizes($instance)); + } + /** * * @param int $encryptedSize