/*
* Copyright (C) 2006-2016 DLR, Germany
*
* All rights reserved
*
* http://www.rcenvironment.de/
*/
package de.rcenvironment.core.utils.common;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import de.rcenvironment.toolkit.modules.concurrency.api.TaskDescription;
/**
* Utilities for creating automatically cleaned-up temporary directories and files. <br/>
* The major design goals were:
* <ul>
* <li>Provide central cleanup of temp files, to avoid temp files getting left behind after crashes or components/tools that do not
* implement proper cleanup</li>
* <li>Prevent collisions between multiple RCE instances on acquisition or cleanup</li>
* <li>Prevent accidential deletes outside of the created temp folders, as far as possible</li>
* <li>Heap usage and the number of file locks should not increase with the number of acquired temp files and directories, to prevent
* resource drain in long-running instances</li>
* <li>(to be continued: specific-filename temp files etc.)</li>
* </ul>
* <br/>
* Basic approach:
* <ul>
* <li>TODO</li>
* </ul>
*
* TODO (p2) add/fix missing authors
*
* @author Robert Mischke
*/
public class TempFileManager {
/**
* The placeholder that marks the place of the "random" part in filename patterns.
*/
public static final String FILENAME_PATTERN_PLACEHOLDER = "*";
private static final String LOCK_FILE_NAME = "tmpdir.lock";
private static final int MAX_ROOT_DIR_ANTI_COLLISION_ATTEMPTS = 20;
private static final int MAX_TEMP_FILE_ANTI_COLLISION_ATTEMPTS = 10;
// TODO implement explicit cleanup
private File globalRootDir;
private File instanceRootDir;
/**
* The current directory where {@link #createTempFileWithFixedFilename()} tries to create files; replaced with a new directory on a
* filename collision.
*/
private File currentDirectoryForTempFiles;
/**
* The lock file to mark a temporary directory as "in use". Note that Java {@link FileLock}s are NOT necessarily "hard" OS file locks;
* they should be treated as "advisory" (see {@link FileLock} documentation).
*/
private FileLock instanceRootDirLock;
private AtomicLong lastInstanceRootDirNumber = new AtomicLong(0);
private AtomicLong tempFileFromPatternSequenceNumber = new AtomicLong(0);
private Log log = LogFactory.getLog(TempFileManager.class);
private final TempFileServiceImpl serviceImplementation;
private final String instanceDirPrefix;
/**
* A shutdown hook to delete the temp directory; mostly relevant for cleaning up after unit tests. Extracted as a nested class to mark
* it as a shutdown hook for code quality checks.
*
* @author Robert Mischke (extracted)
*/
private final class CleanUpShutdownHook extends Thread {
@Override
@TaskDescription("Shutdown hook to delete a root temp directory used by unit tests")
public void run() {
try {
deleteInstanceDirectoryForUnitTest();
} catch (IOException e) {
log.info("Failed to delete Instance Directory", e);
}
}
}
/**
* Default {@link TempFileService} implementation.
*
* @author Robert Mischke
*/
protected final class TempFileServiceImpl implements TempFileService {
@Override
public File createManagedTempDir() throws IOException {
return createManagedTempDir(null);
}
@Override
// this method is thread-safe; no synchronization needed
public File createManagedTempDir(String infoText) throws IOException {
// generate filename
String tempDirName = Long.toString(lastInstanceRootDirNumber.incrementAndGet());
if (infoText != null && infoText.length() != 0) {
tempDirName = tempDirName + "-" + infoText;
}
// create dir and check
File tempDir = new File(getInstanceRootDir(), tempDirName);
if (!tempDir.mkdirs()) {
// throw specific exceptions to track down a case where mkdirs() actually failed (Mantis
// #6425)
if (tempDir.isDirectory()) {
throw new IOException("Unexpected collision: New temporary directory does already exist: " + tempDir);
} else if (tempDir.isFile()) {
throw new IOException("Unexpected collision: New temporary directory is blocked by a equally-named file: "
+ tempDir);
} else {
throw new IOException("Failed to create new managed temporary directory "
+ "(maybe lack of permissions, or the target drive is full?): " + tempDir);
}
}
return tempDir;
}
@Override
public File createTempFileFromPattern(String filenamePattern) throws IOException {
// validate pattern
if (filenamePattern == null || filenamePattern.length() == 0) {
throw new IllegalArgumentException("Filename pattern must not be empty");
}
if (!filenamePattern.contains(FILENAME_PATTERN_PLACEHOLDER)) {
throw new IllegalArgumentException("Filename pattern must contain the placeholder pattern " + FILENAME_PATTERN_PLACEHOLDER);
}
// increment the global sequence counter
String tempPart = Long.toString(tempFileFromPatternSequenceNumber.incrementAndGet());
// generate filename
String filename = filenamePattern.replace(FILENAME_PATTERN_PLACEHOLDER, tempPart);
// delegate
return createTempFileWithFixedFilename(filename);
}
@Override
public synchronized File createTempFileWithFixedFilename(String filename) throws IOException {
// catch some basic errors
if (filename.contains("\\") || filename.contains("/")) {
throw new IOException("Relative filenames are not allowed in this call");
}
// TODO Not all filenames are valid on all platforms, but we cannot simply enforce the check here before we have not checked
// each call of createTempFileWithFixedFilename, to ensure that our code does not create invalid filenames.
// CrossPlatformFilenameUtils.throwIOExceptionIfFilenameNotValid(filename);
// create a managed directory if not done yet or deleted meanwhile
if (currentDirectoryForTempFiles == null || !currentDirectoryForTempFiles.exists()) {
currentDirectoryForTempFiles = createManagedTempDir();
}
IOException lastException = null;
File newFile;
for (int i = 0; i < MAX_TEMP_FILE_ANTI_COLLISION_ATTEMPTS; i++) {
// try to generate new file
newFile = new File(currentDirectoryForTempFiles, filename);
try {
if (newFile.createNewFile()) {
// success -> leave retry loop
return newFile;
}
} catch (IOException e) {
lastException = e;
log.debug("Collision while trying to create temporary file '" + newFile + "'; retrying, " + (i + 1)
+ " failed attempt(s) so far");
}
// on a filename collision or error, create a new temp directory for the next attempt
currentDirectoryForTempFiles = createManagedTempDir();
}
// max retries reached -> throw exception
if (lastException != null) {
throw new IOException("Giving up after " + MAX_TEMP_FILE_ANTI_COLLISION_ATTEMPTS
+ " attempts to create a temporary file named '" + filename + "'; at least one I/O exception occurred while trying",
lastException);
} else {
throw new IOException("Giving up after " + MAX_TEMP_FILE_ANTI_COLLISION_ATTEMPTS
+ " attempts to create a temporary file named '" + filename + "'; no exception occurred");
}
}
@Override
public File writeInputStreamToTempFile(InputStream is) throws IOException {
File file = createTempFileFromPattern("stream-to-file-" + FILENAME_PATTERN_PLACEHOLDER);
FileUtils.copyInputStreamToFile(is, file);
IOUtils.closeQuietly(is);
return file;
}
@Override
public void disposeManagedTempDirOrFile(File tempFileOrDir) throws IOException {
if (instanceRootDir == null) {
throw new IOException("disposeManagedTempDirOrFile() was called with no instanceRootDir set");
}
String givenPath = tempFileOrDir.getCanonicalPath();
String rootPath = instanceRootDir.getCanonicalPath();
if (!givenPath.startsWith(rootPath)) {
throw new IOException(StringUtils
.format("Temporary file or directory '%s' does not match "
+ "the root temp directory '%s' -- ignoring delete request", givenPath, rootPath));
}
try {
if (tempFileOrDir.isDirectory()) {
FileUtils.deleteDirectory(tempFileOrDir);
} else {
// TODO react if return value is false?
tempFileOrDir.delete();
}
} catch (IOException e) {
throw new IOException("Error deleting temporary file or directory " + givenPath, e);
}
}
protected synchronized File getInstanceRootDir() throws IOException {
// lazy init
if (instanceRootDir == null) {
instanceRootDir = initializeInstanceRootDir();
File lockFile = new File(instanceRootDir, LOCK_FILE_NAME);
instanceRootDirLock = attemptLock(lockFile);
// should never happen, but catch it anyway
if (instanceRootDirLock == null) {
throw new IOException("Failed to acquire lock in new temporary directory: " + lockFile.getAbsolutePath());
}
if (log.isDebugEnabled()) {
log.debug(StringUtils.format("Initialized top-level managed temp directory %s", instanceRootDir.getAbsolutePath()));
}
}
return instanceRootDir;
}
/**
* Retrieves the configured global root directory. So far, this method is only used for unit testing.
*
* @return the global root directory, as set by {@link #setGlobalRootDir(File)}
*/
protected synchronized File getGlobalRootDir() {
return globalRootDir;
}
}
protected TempFileManager(File globalRootDir, String instanceDirPrefix) throws IOException {
setGlobalRootDir(globalRootDir);
this.instanceDirPrefix = instanceDirPrefix;
serviceImplementation = new TempFileServiceImpl();
}
protected TempFileManager(File globalRootDir, String instanceDirPrefix, boolean unitTest) throws IOException {
setGlobalRootDir(globalRootDir);
this.instanceDirPrefix = instanceDirPrefix;
serviceImplementation = new TempFileServiceImpl();
if (unitTest) {
Runtime.getRuntime().addShutdownHook(new CleanUpShutdownHook());
}
}
/**
* Performs a "garbage collection" (GC) that deletes subfolders of the "global root directory" (called "instance root directories") that
* are not locked by any RCE instance.
*
* @throws IOException on I/O errors
*/
public synchronized void runGCOnGlobalRootDir() throws IOException {
Collection<File> deleteSet = determineGCDeleteSetForGlobalRootDir();
for (File childFolderToDelete : deleteSet) {
// TODO not deleting yet
log.info("GC: (simulation) deleting unused temp file folder " + childFolderToDelete.getCanonicalPath());
}
}
/**
* Determines the set of directories that a call to {@link #runGCOnGlobalRootDir()} would delete. Intended for unit testing.
*
* @return the list of directories for deletion
* @throws IOException on I/O errors
*/
protected synchronized Collection<File> determineGCDeleteSetForGlobalRootDir() throws IOException {
// check the reliability of isActualSubfolderOf() to prevent deletion of paths like
// "rootdir/..". note that this MAY NOT guard against degenerate file system constructs
// like symlink loops to parent folders - misc_ro
File dotDotOfGlobalRootDir = new File(globalRootDir, "..");
if (!isActualSubfolderOf(dotDotOfGlobalRootDir, globalRootDir)) {
throw new IOException("Unsafe behaviour of File.getCanonicalPath(); "
+ "not running garbage collection");
}
// cross-check that the opposite condition is true
if (isActualSubfolderOf(globalRootDir, dotDotOfGlobalRootDir)) {
throw new IOException("Internal consistency violation of File.getCanonicalPath(); "
+ "not running garbage collection");
}
log.debug("Tested proper detection of folder parent relations");
List<File> globalRootContent = getActualDirectoryContent(globalRootDir);
// be extra conservative: check that all children are directories first
for (File childElement : globalRootContent) {
if (!childElement.isDirectory()) {
throw new IOException(
"Unexpected state: child element of root folder was not a directory "
+ "(not running garbage collection): " + childElement.getAbsolutePath());
}
}
List<File> deleteSet = new ArrayList<File>();
for (File childFolder : globalRootContent) {
// FIXME broken!
File lockFile = new File(childFolder, LOCK_FILE_NAME);
FileLock testLock = attemptLock(lockFile);
if (testLock != null) {
deleteSet.add(childFolder);
releaseLock(testLock);
} else {
log.info("GC: identified active temp file folder " + childFolder.getCanonicalPath());
}
}
return deleteSet;
}
// TODO add code to track RandomAccessFiles?
@SuppressWarnings("resource")
private FileLock attemptLock(File lockFile) throws IOException, FileNotFoundException {
// create an OS-level file lock
RandomAccessFile randomAccessFile = new RandomAccessFile(lockFile, "rw");
FileLock testLock;
try {
testLock = randomAccessFile.getChannel().tryLock();
return testLock;
} catch (OverlappingFileLockException e) {
// lock held by same JVM
randomAccessFile.getChannel().close();
randomAccessFile.close();
return null;
}
}
private void releaseLock(FileLock testLock) throws IOException {
testLock.release();
testLock.channel().close();
}
/**
* Retrieves the configured global root directory. So far, this method is only used for unit testing.
*
* @return the global root directory, as set by {@link #setGlobalRootDir(File)}
*/
protected synchronized File getGlobalRootDir() {
return globalRootDir;
}
protected TempFileServiceImpl getServiceImplementation() {
return serviceImplementation;
}
protected static List<File> getActualDirectoryContent(File parentDir) {
File[] filesInGlobalRoot = parentDir.listFiles();
List<File> trueEntries = new ArrayList<File>();
for (File entry : filesInGlobalRoot) {
String name = entry.getName();
if (!name.equals(".") && !name.equals("..")) {
trueEntries.add(entry);
}
}
return trueEntries;
}
/**
* Sets a new directory to use as the root of all managed temp files and folders. All previously generated temp files and folders will
* be released for cleanup (deletion), so this method should usually be called only once, typically on application startup.
*
* If this method was not called before one of the utility methods is used, a default root is chosen below the "java.io.tmpdir" path.
* This is undesirable from a cleanup standpoint, so a warning is logged, but this avoids the hassle of defining temp file roots in
* affected unit tests.
*
* @param newRootDir the new root directory; may already exist
* @throws IOException when the directory could not be created
*/
private synchronized void setGlobalRootDir(File newRootDir) throws IOException {
// if set, release the old lock to allow cleanup
if (instanceRootDirLock != null) {
// check if the same directory is already set (for example by a previous unit test)
if (globalRootDir.getAbsolutePath().equals(newRootDir.getAbsolutePath())) {
if (log.isTraceEnabled()) {
log.trace("New temp root directory is the same as the existing one; ignoring change request ("
+ newRootDir.getAbsolutePath() + ")");
}
return;
}
if (log.isDebugEnabled()) {
log.debug("Releasing lock file in directory " + instanceRootDir.getAbsolutePath());
}
releaseLock(instanceRootDirLock);
}
instanceRootDir = null;
globalRootDir = newRootDir;
}
private File initializeInstanceRootDir() throws IOException {
if (globalRootDir == null) {
throw new IllegalStateException("Internal consistency error: initialized without a global root directory");
}
String finalPrefix = "";
if (instanceDirPrefix != null && !instanceDirPrefix.isEmpty()) {
finalPrefix = instanceDirPrefix + "-";
}
String timestamp = Long.toString(System.currentTimeMillis());
int antiCollisionAttempt = 0;
String antiCollisionSuffix = "";
File newInstanceRootDir = null;
while (antiCollisionAttempt < MAX_ROOT_DIR_ANTI_COLLISION_ATTEMPTS) {
String instanceDirectoryName = StringUtils.format("%s%s%s", finalPrefix, timestamp, antiCollisionSuffix);
newInstanceRootDir = new File(globalRootDir, instanceDirectoryName);
if (!newInstanceRootDir.isDirectory()) {
// does not exist, try to create
if (newInstanceRootDir.mkdirs()) {
// successfully created (this check is safe against concurrent creation)
return newInstanceRootDir;
}
}
antiCollisionAttempt++;
antiCollisionSuffix = "(" + antiCollisionAttempt + ")";
}
if (newInstanceRootDir != null) {
throw new IOException(StringUtils.format(
"Failed to create unique instance temp directory after %s attempts; last attempted path was %s",
MAX_ROOT_DIR_ANTI_COLLISION_ATTEMPTS,
newInstanceRootDir.getAbsolutePath()));
} else {
throw new IOException(StringUtils.format(
"Failed to create unique instance temp directory after %s attempts; last attempted path was null",
MAX_ROOT_DIR_ANTI_COLLISION_ATTEMPTS));
}
}
private static boolean isActualSubfolderOf(File expectedInner, File expectedOuter) throws IOException {
String canonicalExpectedOuter = expectedOuter.getCanonicalPath();
String canonicalExpectedInner = expectedInner.getCanonicalPath();
return ((canonicalExpectedInner.length() < canonicalExpectedOuter.length()) && canonicalExpectedOuter
.startsWith(canonicalExpectedInner));
}
/**
*
* Convenient method to delete temp directory created by a unit test. There should only be the lock file left in the folder
*
* @throws IOException on error
*/
private void deleteInstanceDirectoryForUnitTest() throws IOException {
if (instanceRootDirLock != null) {
instanceRootDirLock.release();
instanceRootDirLock.channel().close();
}
if (instanceRootDir != null) {
List<File> files = (List<File>) FileUtils.listFiles(instanceRootDir, null, true);
if (files.size() == 1 && files.get(0).getName().equals(LOCK_FILE_NAME)) {
FileUtils.deleteDirectory(instanceRootDir);
log.info("Deleted instance directory: " + instanceRootDir.getAbsolutePath());
} else {
log.warn("Did not delete temp directory: " + instanceRootDir.getAbsolutePath() + " since it is not empty.");
}
}
}
}