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