1/*
2 * Copyright (C) 2010 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 libcore.net.http;
18
19import java.io.BufferedWriter;
20import java.io.ByteArrayInputStream;
21import java.io.File;
22import java.io.FilterInputStream;
23import java.io.FilterOutputStream;
24import java.io.IOException;
25import java.io.InputStream;
26import java.io.OutputStream;
27import java.io.OutputStreamWriter;
28import java.io.Writer;
29import java.net.CacheRequest;
30import java.net.CacheResponse;
31import java.net.ExtendedResponseCache;
32import java.net.HttpURLConnection;
33import java.net.ResponseCache;
34import java.net.ResponseSource;
35import java.net.SecureCacheResponse;
36import java.net.URI;
37import java.net.URLConnection;
38import java.nio.charset.Charsets;
39import java.security.MessageDigest;
40import java.security.NoSuchAlgorithmException;
41import java.security.Principal;
42import java.security.cert.Certificate;
43import java.security.cert.CertificateEncodingException;
44import java.security.cert.CertificateException;
45import java.security.cert.CertificateFactory;
46import java.security.cert.X509Certificate;
47import java.util.Arrays;
48import java.util.List;
49import java.util.Map;
50import javax.net.ssl.HttpsURLConnection;
51import javax.net.ssl.SSLPeerUnverifiedException;
52import libcore.io.Base64;
53import libcore.io.DiskLruCache;
54import libcore.io.IoUtils;
55import libcore.io.StrictLineReader;
56
57/**
58 * Cache responses in a directory on the file system. Most clients should use
59 * {@code android.net.HttpResponseCache}, the stable, documented front end for
60 * this.
61 */
62public final class HttpResponseCache extends ResponseCache implements ExtendedResponseCache {
63    // TODO: add APIs to iterate the cache?
64    private static final int VERSION = 201105;
65    private static final int ENTRY_METADATA = 0;
66    private static final int ENTRY_BODY = 1;
67    private static final int ENTRY_COUNT = 2;
68
69    private final DiskLruCache cache;
70
71    /* read and write statistics, all guarded by 'this' */
72    private int writeSuccessCount;
73    private int writeAbortCount;
74    private int networkCount;
75    private int hitCount;
76    private int requestCount;
77
78    public HttpResponseCache(File directory, long maxSize) throws IOException {
79        cache = DiskLruCache.open(directory, VERSION, ENTRY_COUNT, maxSize);
80    }
81
82    private String uriToKey(URI uri) {
83        try {
84            MessageDigest messageDigest = MessageDigest.getInstance("MD5");
85            byte[] md5bytes = messageDigest.digest(uri.toString().getBytes(Charsets.UTF_8));
86            return IntegralToString.bytesToHexString(md5bytes, false);
87        } catch (NoSuchAlgorithmException e) {
88            throw new AssertionError(e);
89        }
90    }
91
92    @Override public CacheResponse get(URI uri, String requestMethod,
93            Map<String, List<String>> requestHeaders) {
94        String key = uriToKey(uri);
95        DiskLruCache.Snapshot snapshot;
96        Entry entry;
97        try {
98            snapshot = cache.get(key);
99            if (snapshot == null) {
100                return null;
101            }
102            entry = new Entry(snapshot.getInputStream(ENTRY_METADATA));
103        } catch (IOException e) {
104            // Give up because the cache cannot be read.
105            return null;
106        }
107
108        if (!entry.matches(uri, requestMethod, requestHeaders)) {
109            snapshot.close();
110            return null;
111        }
112
113        return entry.isHttps()
114                ? new EntrySecureCacheResponse(entry, snapshot)
115                : new EntryCacheResponse(entry, snapshot);
116    }
117
118    @Override public CacheRequest put(URI uri, URLConnection urlConnection) throws IOException {
119        if (!(urlConnection instanceof HttpURLConnection)) {
120            return null;
121        }
122
123        HttpURLConnection httpConnection = (HttpURLConnection) urlConnection;
124        String requestMethod = httpConnection.getRequestMethod();
125        String key = uriToKey(uri);
126
127        if (requestMethod.equals(HttpEngine.POST)
128                || requestMethod.equals(HttpEngine.PUT)
129                || requestMethod.equals(HttpEngine.DELETE)) {
130            try {
131                cache.remove(key);
132            } catch (IOException ignored) {
133                // The cache cannot be written.
134            }
135            return null;
136        } else if (!requestMethod.equals(HttpEngine.GET)) {
137            /*
138             * Don't cache non-GET responses. We're technically allowed to cache
139             * HEAD requests and some POST requests, but the complexity of doing
140             * so is high and the benefit is low.
141             */
142            return null;
143        }
144
145        HttpEngine httpEngine = getHttpEngine(httpConnection);
146        if (httpEngine == null) {
147            // Don't cache unless the HTTP implementation is ours.
148            return null;
149        }
150
151        ResponseHeaders response = httpEngine.getResponseHeaders();
152        if (response.hasVaryAll()) {
153            return null;
154        }
155
156        RawHeaders varyHeaders = httpEngine.getRequestHeaders().getHeaders().getAll(
157                response.getVaryFields());
158        Entry entry = new Entry(uri, varyHeaders, httpConnection);
159        DiskLruCache.Editor editor = null;
160        try {
161            editor = cache.edit(key);
162            if (editor == null) {
163                return null;
164            }
165            entry.writeTo(editor);
166            return new CacheRequestImpl(editor);
167        } catch (IOException e) {
168            abortQuietly(editor);
169            return null;
170        }
171    }
172
173    /**
174     * Handles a conditional request hit by updating the stored cache response
175     * with the headers from {@code httpConnection}. The cached response body is
176     * not updated. If the stored response has changed since {@code
177     * conditionalCacheHit} was returned, this does nothing.
178     */
179    public void update(CacheResponse conditionalCacheHit, HttpURLConnection httpConnection) {
180        HttpEngine httpEngine = getHttpEngine(httpConnection);
181        URI uri = httpEngine.getUri();
182        ResponseHeaders response = httpEngine.getResponseHeaders();
183        RawHeaders varyHeaders = httpEngine.getRequestHeaders().getHeaders()
184                .getAll(response.getVaryFields());
185        Entry entry = new Entry(uri, varyHeaders, httpConnection);
186        DiskLruCache.Snapshot snapshot = (conditionalCacheHit instanceof EntryCacheResponse)
187                ? ((EntryCacheResponse) conditionalCacheHit).snapshot
188                : ((EntrySecureCacheResponse) conditionalCacheHit).snapshot;
189        DiskLruCache.Editor editor = null;
190        try {
191            editor = snapshot.edit(); // returns null if snapshot is not current
192            if (editor != null) {
193                entry.writeTo(editor);
194                editor.commit();
195            }
196        } catch (IOException e) {
197            abortQuietly(editor);
198        }
199    }
200
201    private void abortQuietly(DiskLruCache.Editor editor) {
202        // Give up because the cache cannot be written.
203        try {
204            if (editor != null) {
205                editor.abort();
206            }
207        } catch (IOException ignored) {
208        }
209    }
210
211    private HttpEngine getHttpEngine(HttpURLConnection httpConnection) {
212        if (httpConnection instanceof HttpURLConnectionImpl) {
213            return ((HttpURLConnectionImpl) httpConnection).getHttpEngine();
214        } else if (httpConnection instanceof HttpsURLConnectionImpl) {
215            return ((HttpsURLConnectionImpl) httpConnection).getHttpEngine();
216        } else {
217            return null;
218        }
219    }
220
221    public DiskLruCache getCache() {
222        return cache;
223    }
224
225    public synchronized int getWriteAbortCount() {
226        return writeAbortCount;
227    }
228
229    public synchronized int getWriteSuccessCount() {
230        return writeSuccessCount;
231    }
232
233    public synchronized void trackResponse(ResponseSource source) {
234        requestCount++;
235
236        switch (source) {
237        case CACHE:
238            hitCount++;
239            break;
240        case CONDITIONAL_CACHE:
241        case NETWORK:
242            networkCount++;
243            break;
244        }
245    }
246
247    public synchronized void trackConditionalCacheHit() {
248        hitCount++;
249    }
250
251    public synchronized int getNetworkCount() {
252        return networkCount;
253    }
254
255    public synchronized int getHitCount() {
256        return hitCount;
257    }
258
259    public synchronized int getRequestCount() {
260        return requestCount;
261    }
262
263    private final class CacheRequestImpl extends CacheRequest {
264        private final DiskLruCache.Editor editor;
265        private OutputStream cacheOut;
266        private boolean done;
267        private OutputStream body;
268
269        public CacheRequestImpl(final DiskLruCache.Editor editor) throws IOException {
270            this.editor = editor;
271            this.cacheOut = editor.newOutputStream(ENTRY_BODY);
272            this.body = new FilterOutputStream(cacheOut) {
273                @Override public void close() throws IOException {
274                    synchronized (HttpResponseCache.this) {
275                        if (done) {
276                            return;
277                        }
278                        done = true;
279                        writeSuccessCount++;
280                    }
281                    super.close();
282                    editor.commit();
283                }
284
285                @Override
286                public void write(byte[] buffer, int offset, int length) throws IOException {
287                    // Since we don't override "write(int oneByte)", we can write directly to "out"
288                    // and avoid the inefficient implementation from the FilterOutputStream.
289                    out.write(buffer, offset, length);
290                }
291            };
292        }
293
294        @Override public void abort() {
295            synchronized (HttpResponseCache.this) {
296                if (done) {
297                    return;
298                }
299                done = true;
300                writeAbortCount++;
301            }
302            IoUtils.closeQuietly(cacheOut);
303            try {
304                editor.abort();
305            } catch (IOException ignored) {
306            }
307        }
308
309        @Override public OutputStream getBody() throws IOException {
310            return body;
311        }
312    }
313
314    private static final class Entry {
315        private final String uri;
316        private final RawHeaders varyHeaders;
317        private final String requestMethod;
318        private final RawHeaders responseHeaders;
319        private final String cipherSuite;
320        private final Certificate[] peerCertificates;
321        private final Certificate[] localCertificates;
322
323        /*
324         * Reads an entry from an input stream. A typical entry looks like this:
325         *   http://google.com/foo
326         *   GET
327         *   2
328         *   Accept-Language: fr-CA
329         *   Accept-Charset: UTF-8
330         *   HTTP/1.1 200 OK
331         *   3
332         *   Content-Type: image/png
333         *   Content-Length: 100
334         *   Cache-Control: max-age=600
335         *
336         * A typical HTTPS file looks like this:
337         *   https://google.com/foo
338         *   GET
339         *   2
340         *   Accept-Language: fr-CA
341         *   Accept-Charset: UTF-8
342         *   HTTP/1.1 200 OK
343         *   3
344         *   Content-Type: image/png
345         *   Content-Length: 100
346         *   Cache-Control: max-age=600
347         *
348         *   AES_256_WITH_MD5
349         *   2
350         *   base64-encoded peerCertificate[0]
351         *   base64-encoded peerCertificate[1]
352         *   -1
353         *
354         * The file is newline separated. The first two lines are the URL and
355         * the request method. Next is the number of HTTP Vary request header
356         * lines, followed by those lines.
357         *
358         * Next is the response status line, followed by the number of HTTP
359         * response header lines, followed by those lines.
360         *
361         * HTTPS responses also contain SSL session information. This begins
362         * with a blank line, and then a line containing the cipher suite. Next
363         * is the length of the peer certificate chain. These certificates are
364         * base64-encoded and appear each on their own line. The next line
365         * contains the length of the local certificate chain. These
366         * certificates are also base64-encoded and appear each on their own
367         * line. A length of -1 is used to encode a null array.
368         */
369        public Entry(InputStream in) throws IOException {
370            try {
371                StrictLineReader reader = new StrictLineReader(in, Charsets.US_ASCII);
372                uri = reader.readLine();
373                requestMethod = reader.readLine();
374                varyHeaders = new RawHeaders();
375                int varyRequestHeaderLineCount = reader.readInt();
376                for (int i = 0; i < varyRequestHeaderLineCount; i++) {
377                    varyHeaders.addLine(reader.readLine());
378                }
379
380                responseHeaders = new RawHeaders();
381                responseHeaders.setStatusLine(reader.readLine());
382                int responseHeaderLineCount = reader.readInt();
383                for (int i = 0; i < responseHeaderLineCount; i++) {
384                    responseHeaders.addLine(reader.readLine());
385                }
386
387                if (isHttps()) {
388                    String blank = reader.readLine();
389                    if (!blank.isEmpty()) {
390                        throw new IOException("expected \"\" but was \"" + blank + "\"");
391                    }
392                    cipherSuite = reader.readLine();
393                    peerCertificates = readCertArray(reader);
394                    localCertificates = readCertArray(reader);
395                } else {
396                    cipherSuite = null;
397                    peerCertificates = null;
398                    localCertificates = null;
399                }
400            } finally {
401                in.close();
402            }
403        }
404
405        public Entry(URI uri, RawHeaders varyHeaders, HttpURLConnection httpConnection) {
406            this.uri = uri.toString();
407            this.varyHeaders = varyHeaders;
408            this.requestMethod = httpConnection.getRequestMethod();
409            this.responseHeaders = RawHeaders.fromMultimap(httpConnection.getHeaderFields());
410
411            if (isHttps()) {
412                HttpsURLConnection httpsConnection = (HttpsURLConnection) httpConnection;
413                cipherSuite = httpsConnection.getCipherSuite();
414                Certificate[] peerCertificatesNonFinal = null;
415                try {
416                    peerCertificatesNonFinal = httpsConnection.getServerCertificates();
417                } catch (SSLPeerUnverifiedException ignored) {
418                }
419                peerCertificates = peerCertificatesNonFinal;
420                localCertificates = httpsConnection.getLocalCertificates();
421            } else {
422                cipherSuite = null;
423                peerCertificates = null;
424                localCertificates = null;
425            }
426        }
427
428        public void writeTo(DiskLruCache.Editor editor) throws IOException {
429            OutputStream out = editor.newOutputStream(ENTRY_METADATA);
430            Writer writer = new BufferedWriter(new OutputStreamWriter(out, Charsets.UTF_8));
431
432            writer.write(uri + '\n');
433            writer.write(requestMethod + '\n');
434            writer.write(Integer.toString(varyHeaders.length()) + '\n');
435            for (int i = 0; i < varyHeaders.length(); i++) {
436                writer.write(varyHeaders.getFieldName(i) + ": "
437                        + varyHeaders.getValue(i) + '\n');
438            }
439
440            writer.write(responseHeaders.getStatusLine() + '\n');
441            writer.write(Integer.toString(responseHeaders.length()) + '\n');
442            for (int i = 0; i < responseHeaders.length(); i++) {
443                writer.write(responseHeaders.getFieldName(i) + ": "
444                        + responseHeaders.getValue(i) + '\n');
445            }
446
447            if (isHttps()) {
448                writer.write('\n');
449                writer.write(cipherSuite + '\n');
450                writeCertArray(writer, peerCertificates);
451                writeCertArray(writer, localCertificates);
452            }
453            writer.close();
454        }
455
456        private boolean isHttps() {
457            return uri.startsWith("https://");
458        }
459
460        private Certificate[] readCertArray(StrictLineReader reader) throws IOException {
461            int length = reader.readInt();
462            if (length == -1) {
463                return null;
464            }
465            try {
466                CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
467                Certificate[] result = new Certificate[length];
468                for (int i = 0; i < result.length; i++) {
469                    String line = reader.readLine();
470                    byte[] bytes = Base64.decode(line.getBytes(Charsets.US_ASCII));
471                    result[i] = certificateFactory.generateCertificate(
472                            new ByteArrayInputStream(bytes));
473                }
474                return result;
475            } catch (CertificateException e) {
476                throw new IOException(e);
477            }
478        }
479
480        private void writeCertArray(Writer writer, Certificate[] certificates) throws IOException {
481            if (certificates == null) {
482                writer.write("-1\n");
483                return;
484            }
485            try {
486                writer.write(Integer.toString(certificates.length) + '\n');
487                for (Certificate certificate : certificates) {
488                    byte[] bytes = certificate.getEncoded();
489                    String line = Base64.encode(bytes);
490                    writer.write(line + '\n');
491                }
492            } catch (CertificateEncodingException e) {
493                throw new IOException(e);
494            }
495        }
496
497        public boolean matches(URI uri, String requestMethod,
498                Map<String, List<String>> requestHeaders) {
499            return this.uri.equals(uri.toString())
500                    && this.requestMethod.equals(requestMethod)
501                    && new ResponseHeaders(uri, responseHeaders)
502                            .varyMatches(varyHeaders.toMultimap(), requestHeaders);
503        }
504    }
505
506    /**
507     * Returns an input stream that reads the body of a snapshot, closing the
508     * snapshot when the stream is closed.
509     */
510    private static InputStream newBodyInputStream(final DiskLruCache.Snapshot snapshot) {
511        return new FilterInputStream(snapshot.getInputStream(ENTRY_BODY)) {
512            @Override public void close() throws IOException {
513                snapshot.close();
514                super.close();
515            }
516        };
517    }
518
519    static class EntryCacheResponse extends CacheResponse {
520        private final Entry entry;
521        private final DiskLruCache.Snapshot snapshot;
522        private final InputStream in;
523
524        public EntryCacheResponse(Entry entry, DiskLruCache.Snapshot snapshot) {
525            this.entry = entry;
526            this.snapshot = snapshot;
527            this.in = newBodyInputStream(snapshot);
528        }
529
530        @Override public Map<String, List<String>> getHeaders() {
531            return entry.responseHeaders.toMultimap();
532        }
533
534        @Override public InputStream getBody() {
535            return in;
536        }
537    }
538
539    static class EntrySecureCacheResponse extends SecureCacheResponse {
540        private final Entry entry;
541        private final DiskLruCache.Snapshot snapshot;
542        private final InputStream in;
543
544        public EntrySecureCacheResponse(Entry entry, DiskLruCache.Snapshot snapshot) {
545            this.entry = entry;
546            this.snapshot = snapshot;
547            this.in = newBodyInputStream(snapshot);
548        }
549
550        @Override public Map<String, List<String>> getHeaders() {
551            return entry.responseHeaders.toMultimap();
552        }
553
554        @Override public InputStream getBody() {
555            return in;
556        }
557
558        @Override public String getCipherSuite() {
559            return entry.cipherSuite;
560        }
561
562        @Override public List<Certificate> getServerCertificateChain()
563                throws SSLPeerUnverifiedException {
564            if (entry.peerCertificates == null || entry.peerCertificates.length == 0) {
565                throw new SSLPeerUnverifiedException(null);
566            }
567            return Arrays.asList(entry.peerCertificates.clone());
568        }
569
570        @Override public Principal getPeerPrincipal() throws SSLPeerUnverifiedException {
571            if (entry.peerCertificates == null || entry.peerCertificates.length == 0) {
572                throw new SSLPeerUnverifiedException(null);
573            }
574            return ((X509Certificate) entry.peerCertificates[0]).getSubjectX500Principal();
575        }
576
577        @Override public List<Certificate> getLocalCertificateChain() {
578            if (entry.localCertificates == null || entry.localCertificates.length == 0) {
579                return null;
580            }
581            return Arrays.asList(entry.localCertificates.clone());
582        }
583
584        @Override public Principal getLocalPrincipal() {
585            if (entry.localCertificates == null || entry.localCertificates.length == 0) {
586                return null;
587            }
588            return ((X509Certificate) entry.localCertificates[0]).getSubjectX500Principal();
589        }
590    }
591}
592