// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.crash;
import org.chromium.base.Log;
import org.chromium.base.VisibleForTesting;
import java.io.File;
import java.io.FilenameFilter;
import java.io.IOException;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Responsible for the Crash Report directory. It routinely scans the directory
* for new Minidump files and takes appropriate actions by either uploading new
* crash dumps or deleting old ones.
*/
public class CrashFileManager {
private static final String TAG = "CrashFileManager";
@VisibleForTesting
static final String CRASH_DUMP_DIR = "Crash Reports";
// This should mirror the C++ CrashUploadList::kReporterLogFilename variable.
@VisibleForTesting
static final String CRASH_DUMP_LOGFILE = "uploads.log";
private static final Pattern MINIDUMP_FIRST_TRY_PATTERN =
Pattern.compile("\\.dmp([0-9]*)$\\z");
private static final Pattern MINIDUMP_MIME_FIRST_TRY_PATTERN =
Pattern.compile("\\.dmp([0-9]+)$\\z");
private static final Pattern MINIDUMP_PATTERN =
Pattern.compile("\\.dmp([0-9]*)(\\.try[0-9])?\\z");
private static final Pattern UPLOADED_MINIDUMP_PATTERN = Pattern.compile("\\.up([0-9]*)\\z");
private static final String NOT_YET_UPLOADED_MINIDUMP_SUFFIX = ".dmp";
private static final String UPLOADED_MINIDUMP_SUFFIX = ".up";
private static final String UPLOAD_SKIPPED_MINIDUMP_SUFFIX = ".skipped";
private static final String UPLOAD_FORCED_MINIDUMP_SUFFIX = ".forced";
private static final String UPLOAD_ATTEMPT_DELIMITER = ".try";
@VisibleForTesting
protected static final String TMP_SUFFIX = ".tmp";
private static final Pattern TMP_PATTERN = Pattern.compile("\\.tmp\\z");
// The maximum number of non-uploaded crashes that may be kept in the crash reports directory.
// Chosen to attempt to balance between keeping a generous number of crashes, and not using up
// too much filesystem storage space for obsolete crash reports.
@VisibleForTesting
protected static final int MAX_CRASH_REPORTS_TO_KEEP = 10;
// The maximum age, in days, considered acceptable for a crash report. Reports older than this
// age will be removed. The constant is chosen to be quite conservative, while still allowing
// users to eventually reclaim filesystem storage space from obsolete crash reports.
private static final int MAX_CRASH_REPORT_AGE_IN_DAYS = 30;
/**
* Comparator used for sorting files by modification date.
* @return Comparator for prioritizing the more recently modified file
*/
@VisibleForTesting
protected static final Comparator<File> sFileComparator = new Comparator<File>() {
@Override
public int compare(File lhs, File rhs) {
if (lhs.lastModified() == rhs.lastModified()) {
return lhs.compareTo(rhs);
} else if (lhs.lastModified() < rhs.lastModified()) {
return 1;
} else {
return -1;
}
}
};
@VisibleForTesting
static boolean deleteFile(File fileToDelete) {
boolean isSuccess = fileToDelete.delete();
if (!isSuccess) {
Log.w(TAG, "Unable to delete " + fileToDelete.getAbsolutePath());
}
return isSuccess;
}
public File[] getMinidumpWithoutLogcat() {
return getMatchingFiles(MINIDUMP_FIRST_TRY_PATTERN);
}
public static boolean isMinidumpMIMEFirstTry(String path) {
return MINIDUMP_MIME_FIRST_TRY_PATTERN.matcher(path).find();
}
public static String tryIncrementAttemptNumber(File mFileToUpload) {
String newName = filenameWithIncrementedAttemptNumber(mFileToUpload.getPath());
return mFileToUpload.renameTo(new File(newName)) ? newName : null;
}
/**
* @return The file name to rename to after an addition attempt to upload
*/
@VisibleForTesting
public static String filenameWithIncrementedAttemptNumber(String filename) {
int numTried = readAttemptNumber(filename);
if (numTried > 0) {
int newCount = numTried + 1;
return filename.replace(
UPLOAD_ATTEMPT_DELIMITER + numTried, UPLOAD_ATTEMPT_DELIMITER + newCount);
} else {
return filename + UPLOAD_ATTEMPT_DELIMITER + "1";
}
}
/**
* Attempts to rename the given file to mark it as a forced upload. This is useful for allowing
* users to manually initiate previously skipped uploads.
*
* @return The renamed file, or null if renaming failed.
*/
public static File trySetForcedUpload(File fileToUpload) {
if (fileToUpload.getName().contains(UPLOADED_MINIDUMP_SUFFIX)) {
Log.w(TAG, "Refusing to reset upload attempt state for a file that has already been "
+ "successfully uploaded: " + fileToUpload.getName());
return null;
}
File renamedFile = new File(filenameWithForcedUploadState(fileToUpload.getPath()));
return fileToUpload.renameTo(renamedFile) ? renamedFile : null;
}
/**
* @return True iff the provided File was manually forced (by the user) to be uploaded.
*/
public static boolean isForcedUpload(File fileToUpload) {
return fileToUpload.getName().contains(UPLOAD_FORCED_MINIDUMP_SUFFIX);
}
/**
* @return The filename to rename to so as to manually force an upload (including clearing any
* previous upload attempt history).
*/
@VisibleForTesting
protected static String filenameWithForcedUploadState(String filename) {
int numTried = readAttemptNumber(filename);
if (numTried > 0) {
filename = filename.replace(
UPLOAD_ATTEMPT_DELIMITER + numTried, UPLOAD_ATTEMPT_DELIMITER + 0);
}
filename = filename.replace(UPLOAD_SKIPPED_MINIDUMP_SUFFIX, UPLOAD_FORCED_MINIDUMP_SUFFIX);
return filename.replace(NOT_YET_UPLOADED_MINIDUMP_SUFFIX, UPLOAD_FORCED_MINIDUMP_SUFFIX);
}
@VisibleForTesting
public static int readAttemptNumber(String filename) {
int tryIndex = filename.lastIndexOf(UPLOAD_ATTEMPT_DELIMITER);
if (tryIndex >= 0) {
tryIndex += UPLOAD_ATTEMPT_DELIMITER.length();
// To avoid out of bound exceptions
if (tryIndex < filename.length()) {
// We don't try more than 3 times.
String numTriesString = filename.substring(
tryIndex, tryIndex + 1);
try {
return Integer.parseInt(numTriesString);
} catch (NumberFormatException ignored) {
return 0;
}
}
}
return 0;
}
/**
* Marks a crash dump file as successfully uploaded, by renaming the file.
*
* Does not immediately delete the file, for testing reasons. However, if renaming fails,
* attempts to delete the file immediately.
*/
public static void markUploadSuccess(File crashDumpFile) {
CrashFileManager.renameCrashDumpFollowingUpload(crashDumpFile, UPLOADED_MINIDUMP_SUFFIX);
}
/**
* Marks a crash dump file's upload being skipped. An upload might be skipped due to lack of
* user consent, or due to this client being excluded from the sample of clients reporting
* crashes.
*
* Renames the file rather than deleting it, so that the user can manually upload the file later
* (via chrome://crashes). However, if renaming fails, attempts to delete the file immediately.
*/
public static void markUploadSkipped(File crashDumpFile) {
CrashFileManager.renameCrashDumpFollowingUpload(
crashDumpFile, UPLOAD_SKIPPED_MINIDUMP_SUFFIX);
}
/**
* Renames a crash dump file. However, if renaming fails, attempts to delete the file
* immediately.
*/
private static void renameCrashDumpFollowingUpload(File crashDumpFile, String suffix) {
// The pre-upload filename might have been either "foo.dmpN.tryM" or "foo.forcedN.tryM".
String newName = crashDumpFile.getPath()
.replace(NOT_YET_UPLOADED_MINIDUMP_SUFFIX, suffix)
.replace(UPLOAD_FORCED_MINIDUMP_SUFFIX, suffix);
boolean renamed = crashDumpFile.renameTo(new File(newName));
if (!renamed) {
Log.w(TAG, "Failed to rename " + crashDumpFile);
if (!crashDumpFile.delete()) {
Log.w(TAG, "Failed to delete " + crashDumpFile);
}
}
}
private final File mCacheDir;
public CrashFileManager(File cacheDir) {
if (cacheDir == null) {
throw new NullPointerException("Specified context cannot be null.");
} else if (!cacheDir.isDirectory()) {
throw new IllegalArgumentException(cacheDir.getAbsolutePath()
+ " is not a directory.");
}
mCacheDir = cacheDir;
}
public File[] getAllMinidumpFiles() {
return getMatchingFiles(MINIDUMP_PATTERN);
}
public File[] getAllMinidumpFilesSorted() {
File[] minidumps = getAllMinidumpFiles();
Arrays.sort(minidumps, sFileComparator);
return minidumps;
}
@VisibleForTesting
protected File[] getAllFilesSorted() {
File crashDir = getCrashDirectoryIfExists();
if (crashDir == null) {
return new File[] {};
}
File[] files = crashDir.listFiles();
Arrays.sort(files, sFileComparator);
return files;
}
public void cleanOutAllNonFreshMinidumpFiles() {
for (File f : getAllUploadedFiles()) {
deleteFile(f);
}
for (File f : getAllTempFiles()) {
deleteFile(f);
}
Set<String> recentCrashes = new HashSet<String>();
for (File f : getAllFilesSorted()) {
// The uploads.log file should always be preserved, as it stores the metadata that
// powers the chrome://crashes UI.
if (f.getName().equals(CRASH_DUMP_LOGFILE)) {
continue;
}
// Delete any crash reports that are especially old.
long ageInMillis = new Date().getTime() - f.lastModified();
long ageInDays = TimeUnit.DAYS.convert(ageInMillis, TimeUnit.MILLISECONDS);
if (ageInDays > MAX_CRASH_REPORT_AGE_IN_DAYS) {
deleteFile(f);
continue;
}
// Delete the oldest crash reports that exceed the cap on the number of allowed reports.
// Each crash typically has two files associated with it: a .dmp file and a .logcat
// file. These have the same filename other than the file extension.
String fileNameSansExtension = f.getName().split("\\.")[0];
if (recentCrashes.size() < MAX_CRASH_REPORTS_TO_KEEP) {
recentCrashes.add(fileNameSansExtension);
} else if (!recentCrashes.contains(fileNameSansExtension)) {
deleteFile(f);
}
}
}
@VisibleForTesting
File[] getMatchingFiles(final Pattern pattern) {
// Get dump dir and get all files with specified suffix. The path
// constructed here must match chrome_paths.cc (see case
// chrome::DIR_CRASH_DUMPS).
File crashDir = getCrashDirectoryIfExists();
if (crashDir == null) {
return new File[] {};
}
File[] minidumps = crashDir.listFiles(new FilenameFilter() {
@Override
public boolean accept(File dir, String filename) {
Matcher match = pattern.matcher(filename);
int tries = readAttemptNumber(filename);
return match.find() && tries < MinidumpUploadService.MAX_TRIES_ALLOWED;
}
});
return minidumps;
}
@VisibleForTesting
File[] getAllUploadedFiles() {
return getMatchingFiles(UPLOADED_MINIDUMP_PATTERN);
}
@VisibleForTesting
File getCrashDirectory() {
return new File(mCacheDir, CRASH_DUMP_DIR);
}
@VisibleForTesting
File getCrashDirectoryIfExists() {
File crashDirectory = getCrashDirectory();
if (!crashDirectory.exists()) {
Log.w(TAG, crashDirectory.getAbsolutePath() + " does not exist!");
return null;
}
if (!crashDirectory.isDirectory()) {
Log.w(TAG, crashDirectory.getAbsolutePath() + " is not a directory!");
return null;
}
return crashDirectory;
}
public File createNewTempFile(String name) throws IOException {
File f = new File(getCrashDirectory(), name);
if (f.exists()) {
if (f.delete()) {
f = new File(getCrashDirectory(), name);
} else {
Log.w(TAG, "Unable to delete previous logfile"
+ f.getAbsolutePath());
}
}
return f;
}
File getCrashFile(String filename) {
return new File(getCrashDirectory(), filename);
}
/**
* Returns the minidump file with the given local ID, or null if no minidump file has the given
* local ID.
* NOTE: Crash files that have already been successfully uploaded are not included.
*
* @param localId The local ID of the crash report.
* @return The matching File, or null if no matching file is found.
*/
File getCrashFileWithLocalId(String localId) {
for (File f : getAllFilesSorted()) {
// Only match non-uploaded or previously skipped files. In particular, do not match
// successfully uploaded files; nor files which are not minidump files, such as logcat
// files.
if (!f.getName().contains(NOT_YET_UPLOADED_MINIDUMP_SUFFIX)
&& !f.getName().contains(UPLOAD_SKIPPED_MINIDUMP_SUFFIX)
&& !f.getName().contains(UPLOAD_FORCED_MINIDUMP_SUFFIX)) {
continue;
}
String filenameSansExtension = f.getName().split("\\.")[0];
if (filenameSansExtension.endsWith(localId)) {
return f;
}
}
return null;
}
File getCrashUploadLogFile() {
return new File(getCrashDirectory(), CRASH_DUMP_LOGFILE);
}
private File[] getAllTempFiles() {
return getMatchingFiles(TMP_PATTERN);
}
}