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