blob: f24d12e0c3af1f28f516b7552839b8c4230a7799 [file] [log] [blame]
/*
* 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);
}