1/* 2 * Copyright (C) 2009 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package org.conscrypt; 18 19import java.io.DataInputStream; 20import java.io.File; 21import java.io.FileInputStream; 22import java.io.FileNotFoundException; 23import java.io.FileOutputStream; 24import java.io.IOException; 25import java.util.Arrays; 26import java.util.HashMap; 27import java.util.Iterator; 28import java.util.LinkedHashMap; 29import java.util.Map; 30import java.util.Set; 31import java.util.TreeSet; 32import javax.net.ssl.SSLSession; 33 34/** 35 * File-based cache implementation. Only one process should access the 36 * underlying directory at a time. 37 */ 38public class FileClientSessionCache { 39 public static final int MAX_SIZE = 12; // ~72k 40 41 private FileClientSessionCache() {} 42 43 /** 44 * This cache creates one file per SSL session using "host.port" for 45 * the file name. Files are created or replaced when session data is put 46 * in the cache (see {@link #putSessionData}). Files are read on 47 * cache hits, but not on cache misses. 48 * 49 * <p>When the number of session files exceeds MAX_SIZE, we delete the 50 * least-recently-used file. We don't current persist the last access time, 51 * so the ordering actually ends up being least-recently-modified in some 52 * cases and even just "not accessed in this process" if the filesystem 53 * doesn't track last modified times. 54 */ 55 static class Impl implements SSLClientSessionCache { 56 57 /** Directory to store session files in. */ 58 final File directory; 59 60 /** 61 * Map of name -> File. Keeps track of the order files were accessed in. 62 */ 63 Map<String, File> accessOrder = newAccessOrder(); 64 65 /** The number of files on disk. */ 66 int size; 67 68 /** 69 * The initial set of files. We use this to defer adding information 70 * about all files to accessOrder until necessary. 71 */ 72 String[] initialFiles; 73 74 /** 75 * Constructs a new cache backed by the given directory. 76 */ 77 Impl(File directory) throws IOException { 78 boolean exists = directory.exists(); 79 if (exists && !directory.isDirectory()) { 80 throw new IOException(directory + " exists but is not a directory."); 81 } 82 83 if (exists) { 84 // Read and sort initial list of files. We defer adding 85 // information about these files to accessOrder until necessary 86 // (see indexFiles()). Sorting the list enables us to detect 87 // cache misses in getSessionData(). 88 // Note: Sorting an array here was faster than creating a 89 // HashSet on Dalvik. 90 initialFiles = directory.list(); 91 if (initialFiles == null) { 92 // File.list() will return null in error cases without throwing IOException 93 // http://b/3363561 94 throw new IOException(directory + " exists but cannot list contents."); 95 } 96 Arrays.sort(initialFiles); 97 size = initialFiles.length; 98 } else { 99 // Create directory. 100 if (!directory.mkdirs()) { 101 throw new IOException("Creation of " + directory + " directory failed."); 102 } 103 size = 0; 104 } 105 106 this.directory = directory; 107 } 108 109 /** 110 * Creates a new access-ordered linked hash map. 111 */ 112 private static Map<String, File> newAccessOrder() { 113 return new LinkedHashMap<String, File>( 114 MAX_SIZE, 0.75f, true /* access order */); 115 } 116 117 /** 118 * Gets the file name for the given host and port. 119 */ 120 private static String fileName(String host, int port) { 121 if (host == null) { 122 throw new NullPointerException("host == null"); 123 } 124 return host + "." + port; 125 } 126 127 @Override 128 public synchronized byte[] getSessionData(String host, int port) { 129 /* 130 * Note: This method is only called when the in-memory cache 131 * in SSLSessionContext misses, so it would be unnecessarily 132 * redundant for this cache to store data in memory. 133 */ 134 135 String name = fileName(host, port); 136 File file = accessOrder.get(name); 137 138 if (file == null) { 139 // File wasn't in access order. Check initialFiles... 140 if (initialFiles == null) { 141 // All files are in accessOrder, so it doesn't exist. 142 return null; 143 } 144 145 // Look in initialFiles. 146 if (Arrays.binarySearch(initialFiles, name) < 0) { 147 // Not found. 148 return null; 149 } 150 151 // The file is on disk but not in accessOrder yet. 152 file = new File(directory, name); 153 accessOrder.put(name, file); 154 } 155 156 FileInputStream in; 157 try { 158 in = new FileInputStream(file); 159 } catch (FileNotFoundException e) { 160 logReadError(host, file, e); 161 return null; 162 } 163 try { 164 int size = (int) file.length(); 165 byte[] data = new byte[size]; 166 new DataInputStream(in).readFully(data); 167 return data; 168 } catch (IOException e) { 169 logReadError(host, file, e); 170 return null; 171 } finally { 172 if (in != null) { 173 try { 174 in.close(); 175 } catch (RuntimeException rethrown) { 176 throw rethrown; 177 } catch (Exception ignored) { 178 } 179 } 180 } 181 } 182 183 static void logReadError(String host, File file, Throwable t) { 184 System.err.println("FileClientSessionCache: Error reading session data for " + host + " from " + file + "."); 185 t.printStackTrace(); 186 } 187 188 @Override 189 public synchronized void putSessionData(SSLSession session, 190 byte[] sessionData) { 191 String host = session.getPeerHost(); 192 if (sessionData == null) { 193 throw new NullPointerException("sessionData == null"); 194 } 195 196 String name = fileName(host, session.getPeerPort()); 197 File file = new File(directory, name); 198 199 // Used to keep track of whether or not we're expanding the cache. 200 boolean existedBefore = file.exists(); 201 202 FileOutputStream out; 203 try { 204 out = new FileOutputStream(file); 205 } catch (FileNotFoundException e) { 206 // We can't write to the file. 207 logWriteError(host, file, e); 208 return; 209 } 210 211 // If we expanded the cache (by creating a new file)... 212 if (!existedBefore) { 213 size++; 214 215 // Delete an old file if necessary. 216 makeRoom(); 217 } 218 219 boolean writeSuccessful = false; 220 try { 221 out.write(sessionData); 222 writeSuccessful = true; 223 } catch (IOException e) { 224 logWriteError(host, file, e); 225 } finally { 226 boolean closeSuccessful = false; 227 try { 228 out.close(); 229 closeSuccessful = true; 230 } catch (IOException e) { 231 logWriteError(host, file, e); 232 } finally { 233 if (!writeSuccessful || !closeSuccessful) { 234 // Storage failed. Clean up. 235 delete(file); 236 } else { 237 // Success! 238 accessOrder.put(name, file); 239 } 240 } 241 } 242 } 243 244 /** 245 * Deletes old files if necessary. 246 */ 247 private void makeRoom() { 248 if (size <= MAX_SIZE) { 249 return; 250 } 251 252 indexFiles(); 253 254 // Delete LRUed files. 255 int removals = size - MAX_SIZE; 256 Iterator<File> i = accessOrder.values().iterator(); 257 do { 258 delete(i.next()); 259 i.remove(); 260 } while (--removals > 0); 261 } 262 263 /** 264 * Lazily updates accessOrder to know about all files as opposed to 265 * just the files accessed since this process started. 266 */ 267 private void indexFiles() { 268 String[] initialFiles = this.initialFiles; 269 if (initialFiles != null) { 270 this.initialFiles = null; 271 272 // Files on disk only, sorted by last modified time. 273 // TODO: Use last access time. 274 Set<CacheFile> diskOnly = new TreeSet<CacheFile>(); 275 for (String name : initialFiles) { 276 // If the file hasn't been accessed in this process... 277 if (!accessOrder.containsKey(name)) { 278 diskOnly.add(new CacheFile(directory, name)); 279 } 280 } 281 282 if (!diskOnly.isEmpty()) { 283 // Add files not accessed in this process to the beginning 284 // of accessOrder. 285 Map<String, File> newOrder = newAccessOrder(); 286 for (CacheFile cacheFile : diskOnly) { 287 newOrder.put(cacheFile.name, cacheFile); 288 } 289 newOrder.putAll(accessOrder); 290 291 this.accessOrder = newOrder; 292 } 293 } 294 } 295 296 @SuppressWarnings("ThrowableInstanceNeverThrown") 297 private void delete(File file) { 298 if (!file.delete()) { 299 new IOException("FileClientSessionCache: Failed to delete " + file + ".").printStackTrace(); 300 } 301 size--; 302 } 303 304 static void logWriteError(String host, File file, Throwable t) { 305 System.err.println("FileClientSessionCache: Error writing session data for " + host + " to " + file + "."); 306 t.printStackTrace(); 307 } 308 } 309 310 /** 311 * Maps directories to the cache instances that are backed by those 312 * directories. We synchronize access using the cache instance, so it's 313 * important that everyone shares the same instance. 314 */ 315 static final Map<File, FileClientSessionCache.Impl> caches 316 = new HashMap<File, FileClientSessionCache.Impl>(); 317 318 /** 319 * Returns a cache backed by the given directory. Creates the directory 320 * (including parent directories) if necessary. This cache should have 321 * exclusive access to the given directory. 322 * 323 * @param directory to store files in 324 * @return a cache backed by the given directory 325 * @throws IOException if the file exists and is not a directory or if 326 * creating the directories fails 327 */ 328 public static synchronized SSLClientSessionCache usingDirectory( 329 File directory) throws IOException { 330 FileClientSessionCache.Impl cache = caches.get(directory); 331 if (cache == null) { 332 cache = new FileClientSessionCache.Impl(directory); 333 caches.put(directory, cache); 334 } 335 return cache; 336 } 337 338 /** For testing. */ 339 static synchronized void reset() { 340 caches.clear(); 341 } 342 343 /** A file containing a piece of cached data. */ 344 static class CacheFile extends File { 345 346 final String name; 347 348 CacheFile(File dir, String name) { 349 super(dir, name); 350 this.name = name; 351 } 352 353 long lastModified = -1; 354 355 @Override 356 public long lastModified() { 357 long lastModified = this.lastModified; 358 if (lastModified == -1) { 359 lastModified = this.lastModified = super.lastModified(); 360 } 361 return lastModified; 362 } 363 364 @Override 365 public int compareTo(File another) { 366 // Sort by last modified time. 367 long result = lastModified() - another.lastModified(); 368 if (result == 0) { 369 return super.compareTo(another); 370 } 371 return result < 0 ? -1 : 1; 372 } 373 } 374} 375