88
99namespace OCA \encryption \tests ;
1010
11+ use OC \Files \ObjectStore \ObjectStoreStorage ;
12+ use OC \Files \ObjectStore \StorageObjectStore ;
1113use OC \Files \Storage \Temporary ;
1214use OC \Files \Storage \Wrapper \Encryption ;
1315use OC \Files \View ;
1416use OCA \Encryption \KeyManager ;
1517use OCP \Files \Mount \IMountManager ;
18+ use OCP \Files \ObjectStore \IObjectStore ;
1619use OCP \Files \Storage \IDisableEncryptionStorage ;
1720use OCP \Server ;
1821use Test \TestCase ;
@@ -24,6 +27,10 @@ class TemporaryNoEncrypted extends Temporary implements IDisableEncryptionStorag
2427
2528}
2629
30+ class ObjectStoreNoEncrypted extends ObjectStoreStorage implements IDisableEncryptionStorage {
31+
32+ }
33+
2734#[\PHPUnit \Framework \Attributes \Group(name: 'DB ' )]
2835class EncryptedStorageTest extends TestCase {
2936 use MountProviderTrait;
@@ -69,4 +76,149 @@ public function testMoveFromEncrypted(): void {
6976 $ this ->assertEquals ('bar ' , $ unencryptedStorage ->file_get_contents ('foo.txt ' ));
7077 $ this ->assertFalse ($ unencryptedCache ->get ('foo.txt ' )->isEncrypted ());
7178 }
79+
80+ /**
81+ * The metadata only move between storages sharing an object store must not be taken
82+ * for an encrypted source: the ciphertext would stay in the object store while the
83+ * cache entry loses its `encrypted` mark.
84+ */
85+ public function testMoveFromEncryptedObjectStore (): void {
86+ [
87+ 'view ' => $ view ,
88+ 'objectStore ' => $ objectStore ,
89+ 'unencryptedStorage ' => $ unencryptedStorage ,
90+ ] = $ this ->setUpSharedObjectStoreMounts ();
91+
92+ $ view ->file_put_contents ('enc/foo.txt ' , 'bar ' );
93+ $ this ->assertEquals ('bar ' , $ view ->file_get_contents ('enc/foo.txt ' ));
94+
95+ $ view ->rename ('enc/foo.txt ' , 'unenc/foo.txt ' );
96+
97+ $ this ->assertEquals ('bar ' , $ view ->file_get_contents ('unenc/foo.txt ' ));
98+ $ this ->assertFalse ($ unencryptedStorage ->getCache ()->get ('foo.txt ' )->isEncrypted ());
99+ $ this ->assertStringStartsNotWith (
100+ 'HBEGIN: ' ,
101+ $ this ->readRawObject ($ objectStore , $ unencryptedStorage , 'foo.txt ' ),
102+ 'the object was moved verbatim and is still encrypted at rest '
103+ );
104+ // a move must not leave the source behind, neither on disk nor in the cache
105+ $ this ->assertFalse ($ view ->file_exists ('enc/foo.txt ' ), 'the source file still exists after the move ' );
106+ }
107+
108+ /**
109+ * Same as above for the copy shortcut, which hands the ciphertext to the object
110+ * store's server side copy.
111+ */
112+ public function testCopyFromEncryptedObjectStore (): void {
113+ [
114+ 'view ' => $ view ,
115+ 'objectStore ' => $ objectStore ,
116+ 'unencryptedStorage ' => $ unencryptedStorage ,
117+ ] = $ this ->setUpSharedObjectStoreMounts ();
118+
119+ $ view ->file_put_contents ('enc/foo.txt ' , 'bar ' );
120+
121+ $ view ->copy ('enc/foo.txt ' , 'unenc/foo.txt ' );
122+
123+ $ this ->assertEquals ('bar ' , $ view ->file_get_contents ('enc/foo.txt ' ));
124+ $ this ->assertEquals ('bar ' , $ view ->file_get_contents ('unenc/foo.txt ' ));
125+ $ this ->assertFalse ($ unencryptedStorage ->getCache ()->get ('foo.txt ' )->isEncrypted ());
126+ $ this ->assertStringStartsNotWith (
127+ 'HBEGIN: ' ,
128+ $ this ->readRawObject ($ objectStore , $ unencryptedStorage , 'foo.txt ' ),
129+ 'the object was copied verbatim and is still encrypted at rest '
130+ );
131+ }
132+
133+ /**
134+ * A file without the `encrypted` mark holds plain content even on a wrapped storage
135+ * (only some paths encrypt, e.g. not uploads/) and must keep the metadata only move.
136+ */
137+ public function testMoveUnencryptedFileFromEncryptionWrappedObjectStore (): void {
138+ [
139+ 'view ' => $ view ,
140+ 'unencryptedStorage ' => $ unencryptedStorage ,
141+ 'encryptedBackingStorage ' => $ encryptedBackingStorage ,
142+ ] = $ this ->setUpSharedObjectStoreMounts ();
143+
144+ // bypasses the encryption wrapper: plain content, no `encrypted` mark
145+ $ encryptedBackingStorage ->file_put_contents ('plain.txt ' , 'plain content ' );
146+ $ sourceEntry = $ encryptedBackingStorage ->getCache ()->get ('plain.txt ' );
147+ $ this ->assertFalse ($ sourceEntry ->isEncrypted ());
148+
149+ $ view ->rename ('enc/plain.txt ' , 'unenc/plain.txt ' );
150+
151+ $ this ->assertEquals ('plain content ' , $ view ->file_get_contents ('unenc/plain.txt ' ));
152+ $ this ->assertSame (
153+ $ sourceEntry ->getId (),
154+ $ unencryptedStorage ->getCache ()->get ('plain.txt ' )->getId (),
155+ 'a plain file must keep the metadata only move that preserves the file id '
156+ );
157+ $ this ->assertFalse ($ view ->file_exists ('enc/plain.txt ' ), 'the source file still exists after the move ' );
158+ }
159+
160+ /**
161+ * A folder carries no `encrypted` mark of its own while any of its children may be
162+ * encrypted, so a folder move must always take the encryption aware path.
163+ */
164+ public function testMoveFolderFromEncryptedObjectStore (): void {
165+ [
166+ 'view ' => $ view ,
167+ 'objectStore ' => $ objectStore ,
168+ 'unencryptedStorage ' => $ unencryptedStorage ,
169+ ] = $ this ->setUpSharedObjectStoreMounts ();
170+
171+ $ view ->mkdir ('enc/dir ' );
172+ $ view ->file_put_contents ('enc/dir/foo.txt ' , 'bar ' );
173+
174+ $ view ->rename ('enc/dir ' , 'unenc/dir ' );
175+
176+ $ this ->assertEquals ('bar ' , $ view ->file_get_contents ('unenc/dir/foo.txt ' ));
177+ $ this ->assertFalse ($ unencryptedStorage ->getCache ()->get ('dir/foo.txt ' )->isEncrypted ());
178+ $ this ->assertStringStartsNotWith (
179+ 'HBEGIN: ' ,
180+ $ this ->readRawObject ($ objectStore , $ unencryptedStorage , 'dir/foo.txt ' ),
181+ 'the folder took the metadata only move and left the child encrypted at rest '
182+ );
183+ $ this ->assertFalse ($ view ->file_exists ('enc/dir ' ), 'the source folder still exists after the move ' );
184+ }
185+
186+ /**
187+ * Two object store storages backed by the same object store, one mounted with and one
188+ * without the encryption wrapper.
189+ *
190+ * @return array{view: View, objectStore: IObjectStore, unencryptedStorage: ObjectStoreStorage, encryptedBackingStorage: ObjectStoreStorage}
191+ */
192+ private function setUpSharedObjectStoreMounts (): array {
193+ Server::get (KeyManager::class)->validateMasterKey ();
194+ Server::get (KeyManager::class)->validateShareKey ();
195+ $ this ->createUser ('test1 ' , 'test2 ' );
196+ $ this ->setupForUser ('test1 ' , 'test2 ' );
197+
198+ // a shared object store instance makes the storage ids match, enabling the shortcuts
199+ $ objectStore = new StorageObjectStore (new Temporary ());
200+ $ encrypted = new ObjectStoreStorage (['objectstore ' => $ objectStore , 'storageid ' => 'test-enc ' ]);
201+ $ unencrypted = new ObjectStoreNoEncrypted (['objectstore ' => $ objectStore , 'storageid ' => 'test-unenc ' ]);
202+
203+ $ this ->registerMount ('test1 ' , $ encrypted , '/test1/files/enc ' );
204+ $ this ->registerMount ('test1 ' , $ unencrypted , '/test1/files/unenc ' );
205+
206+ $ this ->loginWithEncryption ('test1 ' );
207+
208+ return [
209+ 'view ' => new View ('/test1/files ' ),
210+ 'objectStore ' => $ objectStore ,
211+ 'unencryptedStorage ' => $ unencrypted ,
212+ 'encryptedBackingStorage ' => $ encrypted ,
213+ ];
214+ }
215+
216+ private function readRawObject (IObjectStore $ objectStore , ObjectStoreStorage $ storage , string $ path ): string {
217+ $ fileId = $ storage ->getCache ()->get ($ path )->getId ();
218+ $ handle = $ objectStore ->readObject ($ storage ->getURN ($ fileId ));
219+ $ content = stream_get_contents ($ handle );
220+ fclose ($ handle );
221+
222+ return $ content ;
223+ }
72224}
0 commit comments