|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | +/** |
| 5 | + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
| 6 | + * SPDX-License-Identifier: AGPL-3.0-or-later |
| 7 | + */ |
| 8 | + |
| 9 | +namespace OCA\encryption\tests; |
| 10 | + |
| 11 | +use OC\Files\Storage\Temporary; |
| 12 | +use OC\Files\View; |
| 13 | +use OCA\Encryption\KeyManager; |
| 14 | +use OCP\Server; |
| 15 | +use Test\TestCase; |
| 16 | +use Test\Traits\EncryptionTrait; |
| 17 | +use Test\Traits\MountProviderTrait; |
| 18 | +use Test\Traits\UserTrait; |
| 19 | + |
| 20 | +/** |
| 21 | + * Writing through a stream does not update the file cache - that is left to the |
| 22 | + * caller. Until it does, neither the encrypted version nor the unencrypted size |
| 23 | + * of the written file can be read from the cache, but both are part of the block |
| 24 | + * signatures and have to match on the next read. |
| 25 | + */ |
| 26 | +#[\PHPUnit\Framework\Attributes\Group(name: 'DB')] |
| 27 | +class ChunkedWriteTest extends TestCase { |
| 28 | + use MountProviderTrait; |
| 29 | + use EncryptionTrait; |
| 30 | + use UserTrait; |
| 31 | + |
| 32 | + private function setUpView(): View { |
| 33 | + Server::get(KeyManager::class)->validateMasterKey(); |
| 34 | + Server::get(KeyManager::class)->validateShareKey(); |
| 35 | + $this->createUser('test1', 'test2'); |
| 36 | + $this->setupForUser('test1', 'test2'); |
| 37 | + $this->registerMount('test1', new Temporary(), '/test1/files/other'); |
| 38 | + $this->loginWithEncryption('test1'); |
| 39 | + |
| 40 | + return new View('/test1/files'); |
| 41 | + } |
| 42 | + |
| 43 | + /** |
| 44 | + * The unencrypted block size is 6072 bytes, so the chunks cover writes inside |
| 45 | + * a single block, across a block boundary and on a block boundary. |
| 46 | + * |
| 47 | + * @return array<string, array{int[]}> |
| 48 | + */ |
| 49 | + public static function chunkSizesProvider(): array { |
| 50 | + return [ |
| 51 | + 'several chunks in one block' => [[100, 100, 100]], |
| 52 | + 'chunks crossing a block' => [[4000, 4000]], |
| 53 | + 'chunks of varying size' => [[1000, 2000, 3000, 4000, 5000]], |
| 54 | + 'a single full block' => [[6072]], |
| 55 | + 'a full block in two chunks' => [[3000, 3072]], |
| 56 | + 'two full blocks' => [[6072, 6072]], |
| 57 | + 'a full block and one byte' => [[6072, 1]], |
| 58 | + 'chunks larger than a block' => [[8192, 8192, 8192]], |
| 59 | + ]; |
| 60 | + } |
| 61 | + |
| 62 | + /** |
| 63 | + * @param int[] $chunks |
| 64 | + */ |
| 65 | + #[\PHPUnit\Framework\Attributes\DataProvider('chunkSizesProvider')] |
| 66 | + public function testReadBackFileWrittenInChunks(array $chunks): void { |
| 67 | + $view = $this->setUpView(); |
| 68 | + $source = self::getUniqueID('source') . '.bin'; |
| 69 | + |
| 70 | + $expected = $this->writeInChunks($view, $source, $chunks); |
| 71 | + |
| 72 | + $this->assertEquals(strlen($expected), $view->filesize($source)); |
| 73 | + $this->assertEquals($expected, $view->file_get_contents($source)); |
| 74 | + } |
| 75 | + |
| 76 | + /** |
| 77 | + * @param int[] $chunks |
| 78 | + */ |
| 79 | + #[\PHPUnit\Framework\Attributes\DataProvider('chunkSizesProvider')] |
| 80 | + public function testCopyFileWrittenInChunks(array $chunks): void { |
| 81 | + $view = $this->setUpView(); |
| 82 | + $source = self::getUniqueID('source') . '.bin'; |
| 83 | + $target = self::getUniqueID('target') . '.bin'; |
| 84 | + |
| 85 | + $expected = $this->writeInChunks($view, $source, $chunks); |
| 86 | + |
| 87 | + $this->assertTrue($view->copy($source, $target)); |
| 88 | + $this->assertEquals($expected, $view->file_get_contents($target)); |
| 89 | + } |
| 90 | + |
| 91 | + /** |
| 92 | + * A part file is never in the file cache. With `part_file_in_storage` |
| 93 | + * disabled it is written to the user home while the target can live on |
| 94 | + * another storage, in which case moving it over has to read it back. |
| 95 | + */ |
| 96 | + public function testMovePartFileToAnotherStorage(): void { |
| 97 | + $view = $this->setUpView(); |
| 98 | + |
| 99 | + $partFile = self::getUniqueID() . '.ocTransferId1.part'; |
| 100 | + $target = 'other/' . self::getUniqueID('target') . '.bin'; |
| 101 | + |
| 102 | + $expected = $this->writeInChunks($view, $partFile, [8192, 8192, 8192]); |
| 103 | + |
| 104 | + [$partStorage, $internalPartPath] = $view->resolvePath($partFile); |
| 105 | + [$targetStorage, $internalTargetPath] = $view->resolvePath($target); |
| 106 | + $this->assertTrue($targetStorage->moveFromStorage($partStorage, $internalPartPath, $internalTargetPath)); |
| 107 | + |
| 108 | + $this->assertEquals($expected, $view->file_get_contents($target)); |
| 109 | + } |
| 110 | + |
| 111 | + /** |
| 112 | + * @param int[] $chunks |
| 113 | + * @return string the written content |
| 114 | + */ |
| 115 | + private function writeInChunks(View $view, string $path, array $chunks): string { |
| 116 | + $content = ''; |
| 117 | + $handle = $view->fopen($path, 'w'); |
| 118 | + $this->assertIsResource($handle); |
| 119 | + foreach ($chunks as $index => $length) { |
| 120 | + $chunk = str_repeat((string)($index % 10), $length); |
| 121 | + $content .= $chunk; |
| 122 | + $this->assertEquals($length, fwrite($handle, $chunk)); |
| 123 | + } |
| 124 | + fclose($handle); |
| 125 | + |
| 126 | + return $content; |
| 127 | + } |
| 128 | +} |
0 commit comments