| /* |
| * Copyright (C) 2018 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.internal.os; |
| |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.os.FileUtils; |
| import android.util.ArrayMap; |
| |
| import com.android.internal.util.Preconditions; |
| |
| import java.io.File; |
| import java.io.FileOutputStream; |
| import java.io.IOException; |
| import java.util.Arrays; |
| |
| /** |
| * Helper class for performing atomic operations on a directory, by creating a |
| * backup directory until a write has successfully completed. |
| * <p> |
| * Atomic directory guarantees directory integrity by ensuring that a directory has |
| * been completely written and sync'd to disk before removing its backup. |
| * As long as the backup directory exists, the original directory is considered |
| * to be invalid (leftover from a previous attempt to write). |
| * <p> |
| * Atomic directory does not confer any file locking semantics. Do not use this |
| * class when the directory may be accessed or modified concurrently |
| * by multiple threads or processes. The caller is responsible for ensuring |
| * appropriate mutual exclusion invariants whenever it accesses the directory. |
| * <p> |
| * To ensure atomicity you must always use this class to interact with the |
| * backing directory when checking existence, making changes, and deleting. |
| */ |
| public final class AtomicDirectory { |
| private final @NonNull ArrayMap<File, FileOutputStream> mOpenFiles = new ArrayMap<>(); |
| private final @NonNull File mBaseDirectory; |
| private final @NonNull File mBackupDirectory; |
| |
| private int mBaseDirectoryFd = -1; |
| private int mBackupDirectoryFd = -1; |
| |
| /** |
| * Creates a new instance. |
| * |
| * @param baseDirectory The base directory to treat atomically. |
| */ |
| public AtomicDirectory(@NonNull File baseDirectory) { |
| Preconditions.checkNotNull(baseDirectory, "baseDirectory cannot be null"); |
| mBaseDirectory = baseDirectory; |
| mBackupDirectory = new File(baseDirectory.getPath() + "_bak"); |
| } |
| |
| /** |
| * Gets the backup directory if present. This could be useful if you are |
| * writing new state to the dir but need to access the last persisted state |
| * at the same time. This means that this call is useful in between |
| * {@link #startWrite()} and {@link #finishWrite()} or {@link #failWrite()}. |
| * You should not modify the content returned by this method. |
| * |
| * @see #startRead() |
| */ |
| public @Nullable File getBackupDirectory() { |
| return mBackupDirectory; |
| } |
| |
| /** |
| * Starts reading this directory. After calling this method you should |
| * not make any changes to its contents. |
| * |
| * @throws IOException If an error occurs. |
| * |
| * @see #finishRead() |
| * @see #startWrite() |
| */ |
| public @NonNull File startRead() throws IOException { |
| restore(); |
| return getOrCreateBaseDirectory(); |
| } |
| |
| /** |
| * Finishes reading this directory. |
| * |
| * @see #startRead() |
| * @see #startWrite() |
| */ |
| public void finishRead() { |
| mBaseDirectoryFd = -1; |
| mBackupDirectoryFd = -1; |
| } |
| |
| /** |
| * Starts editing this directory. After calling this method you should |
| * add content to the directory only via the APIs on this class. To open a |
| * file for writing in this directory you should use {@link #openWrite(File)} |
| * and to close the file {@link #closeWrite(FileOutputStream)}. Once all |
| * content has been written and all files closed you should commit via a |
| * call to {@link #finishWrite()} or discard via a call to {@link #failWrite()}. |
| * |
| * @throws IOException If an error occurs. |
| * |
| * @see #startRead() |
| * @see #openWrite(File) |
| * @see #finishWrite() |
| * @see #failWrite() |
| */ |
| public @NonNull File startWrite() throws IOException { |
| backup(); |
| return getOrCreateBaseDirectory(); |
| } |
| |
| /** |
| * Opens a file in this directory for writing. |
| * |
| * @param file The file to open. Must be a file in the base directory. |
| * @return An input stream for reading. |
| * |
| * @throws IOException If an I/O error occurs. |
| * |
| * @see #closeWrite(FileOutputStream) |
| */ |
| public @NonNull FileOutputStream openWrite(@NonNull File file) throws IOException { |
| if (file.isDirectory() || !file.getParentFile().equals(getOrCreateBaseDirectory())) { |
| throw new IllegalArgumentException("Must be a file in " + getOrCreateBaseDirectory()); |
| } |
| final FileOutputStream destination = new FileOutputStream(file); |
| if (mOpenFiles.put(file, destination) != null) { |
| throw new IllegalArgumentException("Already open file" + file.getCanonicalPath()); |
| } |
| return destination; |
| } |
| |
| /** |
| * Closes a previously opened file. |
| * |
| * @param destination The stream to the file returned by {@link #openWrite(File)}. |
| * |
| * @see #openWrite(File) |
| */ |
| public void closeWrite(@NonNull FileOutputStream destination) { |
| final int indexOfValue = mOpenFiles.indexOfValue(destination); |
| if (mOpenFiles.removeAt(indexOfValue) == null) { |
| throw new IllegalArgumentException("Unknown file stream " + destination); |
| } |
| FileUtils.sync(destination); |
| try { |
| destination.close(); |
| } catch (IOException ignored) {} |
| } |
| |
| public void failWrite(@NonNull FileOutputStream destination) { |
| final int indexOfValue = mOpenFiles.indexOfValue(destination); |
| if (indexOfValue >= 0) { |
| mOpenFiles.removeAt(indexOfValue); |
| } |
| } |
| |
| /** |
| * Finishes the edit and commits all changes. |
| * |
| * @see #startWrite() |
| * |
| * @throws IllegalStateException is some files are not closed. |
| */ |
| public void finishWrite() { |
| throwIfSomeFilesOpen(); |
| fsyncDirectoryFd(mBaseDirectoryFd); |
| deleteDirectory(mBackupDirectory); |
| fsyncDirectoryFd(mBackupDirectoryFd); |
| mBaseDirectoryFd = -1; |
| mBackupDirectoryFd = -1; |
| } |
| |
| /** |
| * Finishes the edit and discards all changes. |
| * |
| * @see #startWrite() |
| */ |
| public void failWrite() { |
| throwIfSomeFilesOpen(); |
| try{ |
| restore(); |
| } catch (IOException ignored) {} |
| mBaseDirectoryFd = -1; |
| mBackupDirectoryFd = -1; |
| } |
| |
| /** |
| * @return Whether this directory exists. |
| */ |
| public boolean exists() { |
| return mBaseDirectory.exists() || mBackupDirectory.exists(); |
| } |
| |
| /** |
| * Deletes this directory. |
| */ |
| public void delete() { |
| if (mBaseDirectory.exists()) { |
| deleteDirectory(mBaseDirectory); |
| fsyncDirectoryFd(mBaseDirectoryFd); |
| } |
| if (mBackupDirectory.exists()) { |
| deleteDirectory(mBackupDirectory); |
| fsyncDirectoryFd(mBackupDirectoryFd); |
| } |
| } |
| |
| private @NonNull File getOrCreateBaseDirectory() throws IOException { |
| if (!mBaseDirectory.exists()) { |
| if (!mBaseDirectory.mkdirs()) { |
| throw new IOException("Couldn't create directory " + mBaseDirectory); |
| } |
| FileUtils.setPermissions(mBaseDirectory.getPath(), |
| FileUtils.S_IRWXU | FileUtils.S_IRWXG | FileUtils.S_IXOTH, |
| -1, -1); |
| } |
| if (mBaseDirectoryFd < 0) { |
| mBaseDirectoryFd = getDirectoryFd(mBaseDirectory.getCanonicalPath()); |
| } |
| return mBaseDirectory; |
| } |
| |
| private void throwIfSomeFilesOpen() { |
| if (!mOpenFiles.isEmpty()) { |
| throw new IllegalStateException("Unclosed files: " |
| + Arrays.toString(mOpenFiles.keySet().toArray())); |
| } |
| } |
| |
| private void backup() throws IOException { |
| if (!mBaseDirectory.exists()) { |
| return; |
| } |
| if (mBaseDirectoryFd < 0) { |
| mBaseDirectoryFd = getDirectoryFd(mBaseDirectory.getCanonicalPath()); |
| } |
| if (mBackupDirectory.exists()) { |
| deleteDirectory(mBackupDirectory); |
| } |
| if (!mBaseDirectory.renameTo(mBackupDirectory)) { |
| throw new IOException("Couldn't backup " + mBaseDirectory |
| + " to " + mBackupDirectory); |
| } |
| mBackupDirectoryFd = mBaseDirectoryFd; |
| mBaseDirectoryFd = -1; |
| fsyncDirectoryFd(mBackupDirectoryFd); |
| } |
| |
| private void restore() throws IOException { |
| if (!mBackupDirectory.exists()) { |
| return; |
| } |
| if (mBackupDirectoryFd == -1) { |
| mBackupDirectoryFd = getDirectoryFd(mBackupDirectory.getCanonicalPath()); |
| } |
| if (mBaseDirectory.exists()) { |
| deleteDirectory(mBaseDirectory); |
| } |
| if (!mBackupDirectory.renameTo(mBaseDirectory)) { |
| throw new IOException("Couldn't restore " + mBackupDirectory |
| + " to " + mBaseDirectory); |
| } |
| mBaseDirectoryFd = mBackupDirectoryFd; |
| mBackupDirectoryFd = -1; |
| fsyncDirectoryFd(mBaseDirectoryFd); |
| } |
| |
| private static void deleteDirectory(@NonNull File file) { |
| final File[] children = file.listFiles(); |
| if (children != null) { |
| for (File child : children) { |
| deleteDirectory(child); |
| } |
| } |
| file.delete(); |
| } |
| |
| private static native int getDirectoryFd(String path); |
| private static native void fsyncDirectoryFd(int fd); |
| } |