1 /*
2  * Copyright (C) 2018 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 com.android.internal.os;
18 
19 import android.annotation.NonNull;
20 import android.os.FileUtils;
21 import android.system.ErrnoException;
22 import android.system.Os;
23 import android.system.OsConstants;
24 import android.util.ArrayMap;
25 import android.util.Log;
26 
27 import com.android.internal.util.Preconditions;
28 
29 import java.io.File;
30 import java.io.FileDescriptor;
31 import java.io.FileOutputStream;
32 import java.io.IOException;
33 import java.util.Arrays;
34 
35 /**
36  * Helper class for performing atomic operations on a directory, by creating a
37  * backup directory until a write has successfully completed.
38  * <p>
39  * Atomic directory guarantees directory integrity by ensuring that a directory has
40  * been completely written and sync'd to disk before removing its backup.
41  * As long as the backup directory exists, the original directory is considered
42  * to be invalid (leftover from a previous attempt to write).
43  * <p>
44  * Atomic directory does not confer any file locking semantics. Do not use this
45  * class when the directory may be accessed or modified concurrently
46  * by multiple threads or processes. The caller is responsible for ensuring
47  * appropriate mutual exclusion invariants whenever it accesses the directory.
48  * <p>
49  * To ensure atomicity you must always use this class to interact with the
50  * backing directory when checking existence, making changes, and deleting.
51  */
52 public final class AtomicDirectory {
53 
54     private static final String LOG_TAG = AtomicDirectory.class.getSimpleName();
55 
56     private final @NonNull File mBaseDirectory;
57     private final @NonNull File mBackupDirectory;
58 
59     private final @NonNull ArrayMap<File, FileOutputStream> mOpenFiles = new ArrayMap<>();
60 
61     /**
62      * Creates a new instance.
63      *
64      * @param baseDirectory The base directory to treat atomically.
65      */
AtomicDirectory(@onNull File baseDirectory)66     public AtomicDirectory(@NonNull File baseDirectory) {
67         Preconditions.checkNotNull(baseDirectory, "baseDirectory cannot be null");
68         mBaseDirectory = baseDirectory;
69         mBackupDirectory = new File(baseDirectory.getPath() + "_bak");
70     }
71 
72     /**
73      * Gets the backup directory which may or may not exist. This could be
74      * useful if you are writing new state to the directory but need to access
75      * the last persisted state at the same time. This means that this call is
76      * useful in between {@link #startWrite()} and {@link #finishWrite()} or
77      * {@link #failWrite()}. You should not modify the content returned by this
78      * method.
79      *
80      * @see #startRead()
81      */
getBackupDirectory()82     public @NonNull File getBackupDirectory() {
83         return mBackupDirectory;
84     }
85 
86     /**
87      * Starts reading this directory. After calling this method you should
88      * not make any changes to its contents.
89      *
90      * @throws IOException If an error occurs.
91      *
92      * @see #finishRead()
93      * @see #startWrite()
94      */
startRead()95     public @NonNull File startRead() throws IOException {
96         restore();
97         ensureBaseDirectory();
98         return mBaseDirectory;
99     }
100 
101     /**
102      * Finishes reading this directory.
103      *
104      * @see #startRead()
105      * @see #startWrite()
106      */
finishRead()107     public void finishRead() {}
108 
109     /**
110      * Starts editing this directory. After calling this method you should
111      * add content to the directory only via the APIs on this class. To open a
112      * file for writing in this directory you should use {@link #openWrite(File)}
113      * and to close the file {@link #closeWrite(FileOutputStream)}. Once all
114      * content has been written and all files closed you should commit via a
115      * call to {@link #finishWrite()} or discard via a call to {@link #failWrite()}.
116      *
117      * @throws IOException If an error occurs.
118      *
119      * @see #startRead()
120      * @see #openWrite(File)
121      * @see #finishWrite()
122      * @see #failWrite()
123      */
startWrite()124     public @NonNull File startWrite() throws IOException {
125         backup();
126         ensureBaseDirectory();
127         return mBaseDirectory;
128     }
129 
130     /**
131      * Opens a file in this directory for writing.
132      *
133      * @param file The file to open. Must be a file in the base directory.
134      * @return An input stream for reading.
135      *
136      * @throws IOException If an I/O error occurs.
137      *
138      * @see #closeWrite(FileOutputStream)
139      */
openWrite(@onNull File file)140     public @NonNull FileOutputStream openWrite(@NonNull File file) throws IOException {
141         if (file.isDirectory() || !file.getParentFile().equals(mBaseDirectory)) {
142             throw new IllegalArgumentException("Must be a file in " + mBaseDirectory);
143         }
144         if (mOpenFiles.containsKey(file)) {
145             throw new IllegalArgumentException("Already open file " + file.getAbsolutePath());
146         }
147         final FileOutputStream destination = new FileOutputStream(file);
148         mOpenFiles.put(file, destination);
149         return destination;
150     }
151 
152     /**
153      * Closes a previously opened file.
154      *
155      * @param destination The stream to the file returned by {@link #openWrite(File)}.
156      *
157      * @see #openWrite(File)
158      */
closeWrite(@onNull FileOutputStream destination)159     public void closeWrite(@NonNull FileOutputStream destination) {
160         final int indexOfValue = mOpenFiles.indexOfValue(destination);
161         if (indexOfValue < 0) {
162             throw new IllegalArgumentException("Unknown file stream " + destination);
163         }
164         mOpenFiles.removeAt(indexOfValue);
165         FileUtils.sync(destination);
166         FileUtils.closeQuietly(destination);
167     }
168 
failWrite(@onNull FileOutputStream destination)169     public void failWrite(@NonNull FileOutputStream destination) {
170         final int indexOfValue = mOpenFiles.indexOfValue(destination);
171         if (indexOfValue < 0) {
172             throw new IllegalArgumentException("Unknown file stream " + destination);
173         }
174         mOpenFiles.removeAt(indexOfValue);
175         FileUtils.closeQuietly(destination);
176     }
177 
178     /**
179      * Finishes the edit and commits all changes.
180      *
181      * @see #startWrite()
182      *
183      * @throws IllegalStateException if some files are not closed.
184      */
finishWrite()185     public void finishWrite() {
186         throwIfSomeFilesOpen();
187 
188         syncDirectory(mBaseDirectory);
189         syncParentDirectory();
190         deleteDirectory(mBackupDirectory);
191         syncParentDirectory();
192     }
193 
194     /**
195      * Finishes the edit and discards all changes.
196      *
197      * @see #startWrite()
198      */
failWrite()199     public void failWrite() {
200         throwIfSomeFilesOpen();
201 
202         try{
203             restore();
204         } catch (IOException e) {
205             Log.e(LOG_TAG, "Failed to restore in failWrite()", e);
206         }
207     }
208 
209     /**
210      * @return Whether this directory exists.
211      */
exists()212     public boolean exists() {
213         return mBaseDirectory.exists() || mBackupDirectory.exists();
214     }
215 
216     /**
217      * Deletes this directory.
218      */
delete()219     public void delete() {
220         boolean deleted = false;
221         if (mBaseDirectory.exists()) {
222             deleted |= deleteDirectory(mBaseDirectory);
223         }
224         if (mBackupDirectory.exists()) {
225             deleted |= deleteDirectory(mBackupDirectory);
226         }
227         if (deleted) {
228             syncParentDirectory();
229         }
230     }
231 
ensureBaseDirectory()232     private void ensureBaseDirectory() throws IOException {
233         if (mBaseDirectory.exists()) {
234             return;
235         }
236 
237         if (!mBaseDirectory.mkdirs()) {
238             throw new IOException("Failed to create directory " + mBaseDirectory);
239         }
240         FileUtils.setPermissions(mBaseDirectory.getPath(),
241                 FileUtils.S_IRWXU | FileUtils.S_IRWXG | FileUtils.S_IXOTH, -1, -1);
242     }
243 
throwIfSomeFilesOpen()244     private void throwIfSomeFilesOpen() {
245         if (!mOpenFiles.isEmpty()) {
246             throw new IllegalStateException("Unclosed files: "
247                     + Arrays.toString(mOpenFiles.keySet().toArray()));
248         }
249     }
250 
backup()251     private void backup() throws IOException {
252         if (!mBaseDirectory.exists()) {
253             return;
254         }
255 
256         if (mBackupDirectory.exists()) {
257             deleteDirectory(mBackupDirectory);
258         }
259         if (!mBaseDirectory.renameTo(mBackupDirectory)) {
260             throw new IOException("Failed to backup " + mBaseDirectory + " to " + mBackupDirectory);
261         }
262         syncParentDirectory();
263     }
264 
restore()265     private void restore() throws IOException {
266         if (!mBackupDirectory.exists()) {
267             return;
268         }
269 
270         if (mBaseDirectory.exists()) {
271             deleteDirectory(mBaseDirectory);
272         }
273         if (!mBackupDirectory.renameTo(mBaseDirectory)) {
274             throw new IOException("Failed to restore " + mBackupDirectory + " to "
275                     + mBaseDirectory);
276         }
277         syncParentDirectory();
278     }
279 
deleteDirectory(@onNull File directory)280     private static boolean deleteDirectory(@NonNull File directory) {
281         return FileUtils.deleteContentsAndDir(directory);
282     }
283 
syncParentDirectory()284     private void syncParentDirectory() {
285         syncDirectory(mBaseDirectory.getParentFile());
286     }
287 
288     // Standard Java IO doesn't allow opening a directory (will throw a FileNotFoundException
289     // instead), so we have to do it manually.
syncDirectory(@onNull File directory)290     private static void syncDirectory(@NonNull File directory) {
291         String path = directory.getAbsolutePath();
292         FileDescriptor fd;
293         try {
294             fd = Os.open(path, OsConstants.O_RDONLY, 0);
295         } catch (ErrnoException e) {
296             Log.e(LOG_TAG, "Failed to open " + path, e);
297             return;
298         }
299         try {
300             Os.fsync(fd);
301         } catch (ErrnoException e) {
302             Log.e(LOG_TAG, "Failed to fsync " + path, e);
303         } finally {
304             FileUtils.closeQuietly(fd);
305         }
306     }
307 }
308