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