/* * 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; /** * 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; } @Override 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 { if (in != null) { try { in.close(); } catch (RuntimeException rethrown) { throw rethrown; } catch (Exception ignored) { } } } } static void logReadError(String host, File file, Throwable t) { System.err.println("FileClientSessionCache: Error reading session data for " + host + " from " + file + "."); t.printStackTrace(); } @Override 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()) { new IOException("FileClientSessionCache: Failed to delete " + file + ".").printStackTrace(); } size--; } static void logWriteError(String host, File file, Throwable t) { System.err.println("FileClientSessionCache: Error writing session data for " + host + " to " + file + "."); t.printStackTrace(); } } /** * 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; } } }