/*
* Copyright (C) 2009 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 org.conscrypt;
import java.io.DataInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import javax.net.ssl.SSLSession;
import libcore.io.IoUtils;
/**
* File-based cache implementation. Only one process should access the
* underlying directory at a time.
*/
public class FileClientSessionCache {
public static final int MAX_SIZE = 12; // ~72k
private FileClientSessionCache() {}
/**
* This cache creates one file per SSL session using "host.port" for
* the file name. Files are created or replaced when session data is put
* in the cache (see {@link #putSessionData}). Files are read on
* cache hits, but not on cache misses.
*
*
When the number of session files exceeds MAX_SIZE, we delete the
* least-recently-used file. We don't current persist the last access time,
* so the ordering actually ends up being least-recently-modified in some
* cases and even just "not accessed in this process" if the filesystem
* doesn't track last modified times.
*/
static class Impl implements SSLClientSessionCache {
/** Directory to store session files in. */
final File directory;
/**
* Map of name -> File. Keeps track of the order files were accessed in.
*/
Map accessOrder = newAccessOrder();
/** The number of files on disk. */
int size;
/**
* The initial set of files. We use this to defer adding information
* about all files to accessOrder until necessary.
*/
String[] initialFiles;
/**
* Constructs a new cache backed by the given directory.
*/
Impl(File directory) throws IOException {
boolean exists = directory.exists();
if (exists && !directory.isDirectory()) {
throw new IOException(directory + " exists but is not a directory.");
}
if (exists) {
// Read and sort initial list of files. We defer adding
// information about these files to accessOrder until necessary
// (see indexFiles()). Sorting the list enables us to detect
// cache misses in getSessionData().
// Note: Sorting an array here was faster than creating a
// HashSet on Dalvik.
initialFiles = directory.list();
if (initialFiles == null) {
// File.list() will return null in error cases without throwing IOException
// http://b/3363561
throw new IOException(directory + " exists but cannot list contents.");
}
Arrays.sort(initialFiles);
size = initialFiles.length;
} else {
// Create directory.
if (!directory.mkdirs()) {
throw new IOException("Creation of " + directory + " directory failed.");
}
size = 0;
}
this.directory = directory;
}
/**
* Creates a new access-ordered linked hash map.
*/
private static Map newAccessOrder() {
return new LinkedHashMap(
MAX_SIZE, 0.75f, true /* access order */);
}
/**
* Gets the file name for the given host and port.
*/
private static String fileName(String host, int port) {
if (host == null) {
throw new NullPointerException("host == null");
}
return host + "." + port;
}
public synchronized byte[] getSessionData(String host, int port) {
/*
* Note: This method is only called when the in-memory cache
* in SSLSessionContext misses, so it would be unnecessarily
* redundant for this cache to store data in memory.
*/
String name = fileName(host, port);
File file = accessOrder.get(name);
if (file == null) {
// File wasn't in access order. Check initialFiles...
if (initialFiles == null) {
// All files are in accessOrder, so it doesn't exist.
return null;
}
// Look in initialFiles.
if (Arrays.binarySearch(initialFiles, name) < 0) {
// Not found.
return null;
}
// The file is on disk but not in accessOrder yet.
file = new File(directory, name);
accessOrder.put(name, file);
}
FileInputStream in;
try {
in = new FileInputStream(file);
} catch (FileNotFoundException e) {
logReadError(host, file, e);
return null;
}
try {
int size = (int) file.length();
byte[] data = new byte[size];
new DataInputStream(in).readFully(data);
return data;
} catch (IOException e) {
logReadError(host, file, e);
return null;
} finally {
IoUtils.closeQuietly(in);
}
}
static void logReadError(String host, File file, Throwable t) {
System.logW("Error reading session data for " + host + " from " + file + ".", t);
}
public synchronized void putSessionData(SSLSession session,
byte[] sessionData) {
String host = session.getPeerHost();
if (sessionData == null) {
throw new NullPointerException("sessionData == null");
}
String name = fileName(host, session.getPeerPort());
File file = new File(directory, name);
// Used to keep track of whether or not we're expanding the cache.
boolean existedBefore = file.exists();
FileOutputStream out;
try {
out = new FileOutputStream(file);
} catch (FileNotFoundException e) {
// We can't write to the file.
logWriteError(host, file, e);
return;
}
// If we expanded the cache (by creating a new file)...
if (!existedBefore) {
size++;
// Delete an old file if necessary.
makeRoom();
}
boolean writeSuccessful = false;
try {
out.write(sessionData);
writeSuccessful = true;
} catch (IOException e) {
logWriteError(host, file, e);
} finally {
boolean closeSuccessful = false;
try {
out.close();
closeSuccessful = true;
} catch (IOException e) {
logWriteError(host, file, e);
} finally {
if (!writeSuccessful || !closeSuccessful) {
// Storage failed. Clean up.
delete(file);
} else {
// Success!
accessOrder.put(name, file);
}
}
}
}
/**
* Deletes old files if necessary.
*/
private void makeRoom() {
if (size <= MAX_SIZE) {
return;
}
indexFiles();
// Delete LRUed files.
int removals = size - MAX_SIZE;
Iterator i = accessOrder.values().iterator();
do {
delete(i.next());
i.remove();
} while (--removals > 0);
}
/**
* Lazily updates accessOrder to know about all files as opposed to
* just the files accessed since this process started.
*/
private void indexFiles() {
String[] initialFiles = this.initialFiles;
if (initialFiles != null) {
this.initialFiles = null;
// Files on disk only, sorted by last modified time.
// TODO: Use last access time.
Set diskOnly = new TreeSet();
for (String name : initialFiles) {
// If the file hasn't been accessed in this process...
if (!accessOrder.containsKey(name)) {
diskOnly.add(new CacheFile(directory, name));
}
}
if (!diskOnly.isEmpty()) {
// Add files not accessed in this process to the beginning
// of accessOrder.
Map newOrder = newAccessOrder();
for (CacheFile cacheFile : diskOnly) {
newOrder.put(cacheFile.name, cacheFile);
}
newOrder.putAll(accessOrder);
this.accessOrder = newOrder;
}
}
}
@SuppressWarnings("ThrowableInstanceNeverThrown")
private void delete(File file) {
if (!file.delete()) {
System.logW("Failed to delete " + file + ".", new IOException());
}
size--;
}
static void logWriteError(String host, File file, Throwable t) {
System.logW("Error writing session data for " + host + " to " + file + ".", t);
}
}
/**
* Maps directories to the cache instances that are backed by those
* directories. We synchronize access using the cache instance, so it's
* important that everyone shares the same instance.
*/
static final Map caches
= new HashMap();
/**
* Returns a cache backed by the given directory. Creates the directory
* (including parent directories) if necessary. This cache should have
* exclusive access to the given directory.
*
* @param directory to store files in
* @return a cache backed by the given directory
* @throws IOException if the file exists and is not a directory or if
* creating the directories fails
*/
public static synchronized SSLClientSessionCache usingDirectory(
File directory) throws IOException {
FileClientSessionCache.Impl cache = caches.get(directory);
if (cache == null) {
cache = new FileClientSessionCache.Impl(directory);
caches.put(directory, cache);
}
return cache;
}
/** For testing. */
static synchronized void reset() {
caches.clear();
}
/** A file containing a piece of cached data. */
static class CacheFile extends File {
final String name;
CacheFile(File dir, String name) {
super(dir, name);
this.name = name;
}
long lastModified = -1;
@Override
public long lastModified() {
long lastModified = this.lastModified;
if (lastModified == -1) {
lastModified = this.lastModified = super.lastModified();
}
return lastModified;
}
@Override
public int compareTo(File another) {
// Sort by last modified time.
long result = lastModified() - another.lastModified();
if (result == 0) {
return super.compareTo(another);
}
return result < 0 ? -1 : 1;
}
}
}