1 /*
2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.core.util;
18 
19 import static org.junit.Assert.assertArrayEquals;
20 import static org.junit.Assert.assertTrue;
21 
22 import android.app.Instrumentation;
23 import android.content.Context;
24 
25 import androidx.test.filters.MediumTest;
26 import androidx.test.platform.app.InstrumentationRegistry;
27 
28 import org.jspecify.annotations.NonNull;
29 import org.jspecify.annotations.Nullable;
30 import org.junit.After;
31 import org.junit.Before;
32 import org.junit.Test;
33 import org.junit.runner.RunWith;
34 import org.junit.runners.Parameterized;
35 
36 import java.io.ByteArrayOutputStream;
37 import java.io.File;
38 import java.io.FileInputStream;
39 import java.io.FileNotFoundException;
40 import java.io.FileOutputStream;
41 import java.io.IOException;
42 import java.io.InputStream;
43 import java.nio.charset.Charset;
44 import java.util.Arrays;
45 import java.util.List;
46 
47 @RunWith(Parameterized.class)
48 @MediumTest
49 public class AtomicFileTest {
50     private static final String BASE_NAME = "base";
51     private static final String NEW_NAME = BASE_NAME + ".new";
52     private static final String LEGACY_BACKUP_NAME = BASE_NAME + ".bak";
53     // The string isn't actually used, but we just need a different identifier.
54     private static final String BASE_NAME_DIRECTORY = BASE_NAME + ".dir";
55 
56     private enum WriteAction {
57         FINISH,
58         FAIL,
59         ABORT,
60         READ_FINISH
61     }
62 
63     private static final Charset UTF_8 = Charset.forName("UTF-8");
64     private static final byte[] BASE_BYTES = "base".getBytes(UTF_8);
65     private static final byte[] EXISTING_NEW_BYTES = "unnew".getBytes(UTF_8);
66     private static final byte[] NEW_BYTES = "new".getBytes(UTF_8);
67     private static final byte[] LEGACY_BACKUP_BYTES = "bak".getBytes(UTF_8);
68 
69     public String @Nullable [] mExistingFileNames;
70     public @Nullable WriteAction mWriteAction;
71     public byte @Nullable [] mExpectedBytes;
72 
73     private final Instrumentation mInstrumentation =
74             InstrumentationRegistry.getInstrumentation();
75     private final Context mContext = mInstrumentation.getContext();
76 
77     private final File mDirectory = mContext.getFilesDir();
78     private final File mBaseFile = new File(mDirectory, BASE_NAME);
79     private final File mNewFile = new File(mDirectory, NEW_NAME);
80     private final File mLegacyBackupFile = new File(mDirectory, LEGACY_BACKUP_NAME);
81 
82     @Parameterized.Parameters(name = "{0}")
data()83     public static List<Object[]> data() {
84         return Arrays.asList(new Object[][] {
85                 // Standard tests.
86                 { "none + none = none", new Parameters(null, null, null) },
87                 { "none + finish = new", new Parameters(null, WriteAction.FINISH, NEW_BYTES) },
88                 { "none + fail = none", new Parameters(null, WriteAction.FAIL, null) },
89                 { "none + abort = none", new Parameters(null, WriteAction.ABORT, null) },
90                 { "base + none = base", new Parameters(new String[] { BASE_NAME }, null,
91                         BASE_BYTES) },
92                 { "base + finish = new", new Parameters(new String[] { BASE_NAME },
93                         WriteAction.FINISH, NEW_BYTES) },
94                 { "base + fail = base", new Parameters(new String[] { BASE_NAME }, WriteAction.FAIL,
95                         BASE_BYTES) },
96                 { "base + abort = base", new Parameters(new String[] { BASE_NAME },
97                         WriteAction.ABORT, BASE_BYTES) },
98                 { "new + none = none", new Parameters(new String[] { NEW_NAME }, null, null) },
99                 { "new + finish = new", new Parameters(new String[] { NEW_NAME },
100                         WriteAction.FINISH, NEW_BYTES) },
101                 { "new + fail = none", new Parameters(new String[] { NEW_NAME }, WriteAction.FAIL,
102                         null) },
103                 { "new + abort = none", new Parameters(new String[] { NEW_NAME }, WriteAction.ABORT,
104                         null) },
105                 { "bak + none = bak", new Parameters(new String[] { LEGACY_BACKUP_NAME }, null,
106                         LEGACY_BACKUP_BYTES) },
107                 { "bak + finish = new", new Parameters(new String[] { LEGACY_BACKUP_NAME },
108                         WriteAction.FINISH, NEW_BYTES) },
109                 { "bak + fail = bak", new Parameters(new String[] { LEGACY_BACKUP_NAME },
110                         WriteAction.FAIL, LEGACY_BACKUP_BYTES) },
111                 { "bak + abort = bak", new Parameters(new String[] { LEGACY_BACKUP_NAME },
112                         WriteAction.ABORT, LEGACY_BACKUP_BYTES) },
113                 { "base & new + none = base", new Parameters(new String[] { BASE_NAME, NEW_NAME },
114                         null, BASE_BYTES) },
115                 { "base & new + finish = new", new Parameters(new String[] { BASE_NAME, NEW_NAME },
116                         WriteAction.FINISH, NEW_BYTES) },
117                 { "base & new + fail = base", new Parameters(new String[] { BASE_NAME, NEW_NAME },
118                         WriteAction.FAIL, BASE_BYTES) },
119                 { "base & new + abort = base", new Parameters(new String[] { BASE_NAME, NEW_NAME },
120                         WriteAction.ABORT, BASE_BYTES) },
121                 { "base & bak + none = bak", new Parameters(new String[] { BASE_NAME,
122                         LEGACY_BACKUP_NAME }, null, LEGACY_BACKUP_BYTES) },
123                 { "base & bak + finish = new", new Parameters(new String[] { BASE_NAME,
124                         LEGACY_BACKUP_NAME }, WriteAction.FINISH, NEW_BYTES) },
125                 { "base & bak + fail = bak", new Parameters(new String[] { BASE_NAME,
126                         LEGACY_BACKUP_NAME }, WriteAction.FAIL, LEGACY_BACKUP_BYTES) },
127                 { "base & bak + abort = bak", new Parameters(new String[] { BASE_NAME,
128                         LEGACY_BACKUP_NAME }, WriteAction.ABORT, LEGACY_BACKUP_BYTES) },
129                 { "new & bak + none = bak", new Parameters(new String[] { NEW_NAME,
130                         LEGACY_BACKUP_NAME }, null, LEGACY_BACKUP_BYTES) },
131                 { "new & bak + finish = new", new Parameters(new String[] { NEW_NAME,
132                         LEGACY_BACKUP_NAME }, WriteAction.FINISH, NEW_BYTES) },
133                 { "new & bak + fail = bak", new Parameters(new String[] { NEW_NAME,
134                         LEGACY_BACKUP_NAME }, WriteAction.FAIL, LEGACY_BACKUP_BYTES) },
135                 { "new & bak + abort = bak", new Parameters(new String[] { NEW_NAME,
136                         LEGACY_BACKUP_NAME }, WriteAction.ABORT, LEGACY_BACKUP_BYTES) },
137                 { "base & new & bak + none = bak", new Parameters(new String[] { BASE_NAME,
138                         NEW_NAME, LEGACY_BACKUP_NAME }, null, LEGACY_BACKUP_BYTES) },
139                 { "base & new & bak + finish = new", new Parameters(new String[] { BASE_NAME,
140                         NEW_NAME, LEGACY_BACKUP_NAME }, WriteAction.FINISH, NEW_BYTES) },
141                 { "base & new & bak + fail = bak", new Parameters(new String[] { BASE_NAME,
142                         NEW_NAME, LEGACY_BACKUP_NAME }, WriteAction.FAIL, LEGACY_BACKUP_BYTES) },
143                 { "base & new & bak + abort = bak", new Parameters(new String[] { BASE_NAME,
144                         NEW_NAME, LEGACY_BACKUP_NAME }, WriteAction.ABORT, LEGACY_BACKUP_BYTES) },
145                 // Compatibility when there is a directory in the place of base file, by replacing
146                 // no base with base.dir.
147                 { "base.dir + none = none", new Parameters(new String[] { BASE_NAME_DIRECTORY },
148                         null, null) },
149                 { "base.dir + finish = new", new Parameters(new String[] { BASE_NAME_DIRECTORY },
150                         WriteAction.FINISH, NEW_BYTES) },
151                 { "base.dir + fail = none", new Parameters(new String[] { BASE_NAME_DIRECTORY },
152                         WriteAction.FAIL, null) },
153                 { "base.dir + abort = none", new Parameters(new String[] { BASE_NAME_DIRECTORY },
154                         WriteAction.ABORT, null) },
155                 { "base.dir & new + none = none", new Parameters(new String[] { BASE_NAME_DIRECTORY,
156                         NEW_NAME }, null, null) },
157                 { "base.dir & new + finish = new", new Parameters(
158                         new String[] { BASE_NAME_DIRECTORY, NEW_NAME }, WriteAction.FINISH,
159                         NEW_BYTES) },
160                 { "base.dir & new + fail = none", new Parameters(new String[] { BASE_NAME_DIRECTORY,
161                         NEW_NAME }, WriteAction.FAIL, null) },
162                 { "base.dir & new + abort = none", new Parameters(
163                         new String[] { BASE_NAME_DIRECTORY, NEW_NAME }, WriteAction.ABORT, null) },
164                 { "base.dir & bak + none = bak", new Parameters(new String[] { BASE_NAME_DIRECTORY,
165                         LEGACY_BACKUP_NAME }, null, LEGACY_BACKUP_BYTES) },
166                 { "base.dir & bak + finish = new", new Parameters(
167                         new String[] { BASE_NAME_DIRECTORY, LEGACY_BACKUP_NAME },
168                         WriteAction.FINISH, NEW_BYTES) },
169                 { "base.dir & bak + fail = bak", new Parameters(new String[] { BASE_NAME_DIRECTORY,
170                         LEGACY_BACKUP_NAME }, WriteAction.FAIL, LEGACY_BACKUP_BYTES) },
171                 { "base.dir & bak + abort = bak", new Parameters(new String[] { BASE_NAME_DIRECTORY,
172                         LEGACY_BACKUP_NAME }, WriteAction.ABORT, LEGACY_BACKUP_BYTES) },
173                 { "base.dir & new & bak + none = bak", new Parameters(
174                         new String[] { BASE_NAME_DIRECTORY, NEW_NAME, LEGACY_BACKUP_NAME }, null,
175                         LEGACY_BACKUP_BYTES) },
176                 { "base.dir & new & bak + finish = new", new Parameters(
177                         new String[] { BASE_NAME_DIRECTORY, NEW_NAME, LEGACY_BACKUP_NAME },
178                         WriteAction.FINISH, NEW_BYTES) },
179                 { "base.dir & new & bak + fail = bak", new Parameters(
180                         new String[] { BASE_NAME_DIRECTORY, NEW_NAME, LEGACY_BACKUP_NAME },
181                         WriteAction.FAIL, LEGACY_BACKUP_BYTES) },
182                 { "base.dir & new & bak + abort = bak", new Parameters(
183                         new String[] { BASE_NAME_DIRECTORY, NEW_NAME, LEGACY_BACKUP_NAME },
184                         WriteAction.ABORT, LEGACY_BACKUP_BYTES) },
185                 // Compatibility when openRead() is called between startWrite() and finishWrite() -
186                 // the write should still succeed if it's the first write.
187                 { "none + read & finish = new", new Parameters(null, WriteAction.READ_FINISH,
188                         NEW_BYTES) },
189         });
190     }
191 
AtomicFileTest(@onNull String unusedTestName, @NonNull Parameters parameters)192     public AtomicFileTest(@NonNull String unusedTestName, @NonNull Parameters parameters) {
193         mExistingFileNames = parameters.existingFileNames;
194         mWriteAction = parameters.writeAction;
195         mExpectedBytes = parameters.expectedBytes;
196     }
197 
198     @Before
199     @After
deleteFiles()200     public void deleteFiles() {
201         mBaseFile.delete();
202         mNewFile.delete();
203         mLegacyBackupFile.delete();
204     }
205 
206     @Test
testAtomicFile()207     public void testAtomicFile() throws Exception {
208         if (mExistingFileNames != null) {
209             for (String fileName : mExistingFileNames) {
210                 switch (fileName) {
211                     case BASE_NAME:
212                         writeBytes(mBaseFile, BASE_BYTES);
213                         break;
214                     case NEW_NAME:
215                         writeBytes(mNewFile, EXISTING_NEW_BYTES);
216                         break;
217                     case LEGACY_BACKUP_NAME:
218                         writeBytes(mLegacyBackupFile, LEGACY_BACKUP_BYTES);
219                         break;
220                     case BASE_NAME_DIRECTORY:
221                         assertTrue(mBaseFile.mkdir());
222                         break;
223                     default:
224                         throw new AssertionError(fileName);
225                 }
226             }
227         }
228 
229         final AtomicFile atomicFile = new AtomicFile(mBaseFile);
230         if (mWriteAction != null) {
231             try (FileOutputStream outputStream = atomicFile.startWrite()) {
232                 outputStream.write(NEW_BYTES);
233                 switch (mWriteAction) {
234                     case FINISH:
235                         atomicFile.finishWrite(outputStream);
236                         break;
237                     case FAIL:
238                         atomicFile.failWrite(outputStream);
239                         break;
240                     case ABORT:
241                         // Neither finishing nor failing is called upon abort.
242                         break;
243                     case READ_FINISH:
244                         // We are only using this action when there is no base file.
245                         assertThrows(FileNotFoundException.class,  new ThrowingRunnable() {
246                             @Override
247                             public void run() throws Throwable {
248                                 atomicFile.openRead();
249                             }
250                         });
251                         atomicFile.finishWrite(outputStream);
252                         break;
253                     default:
254                         throw new AssertionError(mWriteAction);
255                 }
256             }
257         }
258 
259         if (mExpectedBytes != null) {
260             try (FileInputStream inputStream = atomicFile.openRead()) {
261                 assertArrayEquals(mExpectedBytes, readAllBytes(inputStream));
262             }
263         } else {
264             assertThrows(FileNotFoundException.class, new ThrowingRunnable() {
265                 @Override
266                 public void run() throws Throwable {
267                     atomicFile.openRead();
268                 }
269             });
270         }
271     }
272 
writeBytes(@onNull File file, byte @NonNull [] bytes)273     private static void writeBytes(@NonNull File file, byte @NonNull [] bytes) throws IOException {
274         try (FileOutputStream outputStream = new FileOutputStream(file)) {
275             outputStream.write(bytes);
276         }
277     }
278 
279     // InputStream.readAllBytes() is introduced in Java 9. Our files are small enough so that a
280     // naive implementation is okay.
readAllBytes(@onNull InputStream inputStream)281     private static byte[] readAllBytes(@NonNull InputStream inputStream) throws IOException {
282         try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
283             int b;
284             while ((b = inputStream.read()) != -1) {
285                 outputStream.write(b);
286             }
287             return outputStream.toByteArray();
288         }
289     }
290 
291     @SuppressWarnings("unchecked")
assertThrows(@onNull Class<T> expectedType, @NonNull ThrowingRunnable runnable)292     public static <T extends Throwable> T assertThrows(@NonNull Class<T> expectedType,
293             @NonNull ThrowingRunnable runnable) {
294         try {
295             runnable.run();
296         } catch (Throwable t) {
297             if (!expectedType.isInstance(t)) {
298                 sneakyThrow(t);
299             }
300             return (T) t;
301         }
302         throw new AssertionError(String.format("Expected %s wasn't thrown",
303                 expectedType.getSimpleName()));
304     }
305 
306     @SuppressWarnings("unchecked")
sneakyThrow(@onNull Throwable throwable)307     private static <T extends RuntimeException> void sneakyThrow(@NonNull Throwable throwable)
308             throws T {
309         throw (T) throwable;
310     }
311 
312     private interface ThrowingRunnable {
run()313         void run() throws Throwable;
314     }
315 
316     // JUnit on API 17 somehow turns null parameters into the string "null". Wrapping the parameters
317     // inside a class solves this problem.
318     private static class Parameters {
319         public String @Nullable [] existingFileNames;
320         public @Nullable WriteAction writeAction;
321         public byte @Nullable [] expectedBytes;
322 
Parameters(String @ullable [] existingFileNames, @Nullable WriteAction writeAction, byte @Nullable [] expectedBytes)323         Parameters(String @Nullable [] existingFileNames, @Nullable WriteAction writeAction,
324                 byte @Nullable [] expectedBytes) {
325             this.existingFileNames = existingFileNames;
326             this.writeAction = writeAction;
327             this.expectedBytes = expectedBytes;
328         }
329     }
330 }
331