diff --git a/okhttp-logging-interceptor/src/main/java/okhttp3/logging/HttpLoggingInterceptor.kt b/okhttp-logging-interceptor/src/main/java/okhttp3/logging/HttpLoggingInterceptor.kt index f31b005a8..5ace18ece 100644 --- a/okhttp-logging-interceptor/src/main/java/okhttp3/logging/HttpLoggingInterceptor.kt +++ b/okhttp-logging-interceptor/src/main/java/okhttp3/logging/HttpLoggingInterceptor.kt @@ -19,7 +19,7 @@ import okhttp3.Headers import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Response -import okhttp3.internal.http.HttpHeaders +import okhttp3.internal.http.promisesBody import okhttp3.internal.platform.Platform import okhttp3.internal.platform.Platform.Companion.INFO import okio.Buffer @@ -235,7 +235,7 @@ class HttpLoggingInterceptor @JvmOverloads constructor( logHeader(headers, i) } - if (!logBody || !HttpHeaders.hasBody(response)) { + if (!logBody || !response.promisesBody()) { logger.log("<-- END HTTP") } else if (bodyHasUnknownEncoding(response.headers())) { logger.log("<-- END HTTP (encoded body omitted)") diff --git a/okhttp/src/main/java/okhttp3/Cache.kt b/okhttp/src/main/java/okhttp3/Cache.kt index ba8ff4534..57175f41e 100644 --- a/okhttp/src/main/java/okhttp3/Cache.kt +++ b/okhttp/src/main/java/okhttp3/Cache.kt @@ -15,13 +15,13 @@ */ package okhttp3 +import okhttp3.internal.Util import okhttp3.internal.Util.closeQuietly import okhttp3.internal.addHeaderLenient import okhttp3.internal.cache.CacheRequest import okhttp3.internal.cache.CacheStrategy import okhttp3.internal.cache.DiskLruCache import okhttp3.internal.cache.InternalCache -import okhttp3.internal.http.HttpHeaders import okhttp3.internal.http.HttpMethod import okhttp3.internal.http.StatusLine import okhttp3.internal.io.FileSystem @@ -47,6 +47,7 @@ import java.security.cert.CertificateException import java.security.cert.CertificateFactory import java.util.ArrayList import java.util.NoSuchElementException +import java.util.TreeSet /** * Caches HTTP and HTTPS responses to the filesystem so they may be reused, saving time and @@ -228,7 +229,7 @@ class Cache internal constructor( return null } - if (HttpHeaders.hasVaryAll(response)) { + if (response.hasVaryAll()) { return null } @@ -559,7 +560,7 @@ class Cache internal constructor( internal constructor(response: Response) { this.url = response.request().url().toString() - this.varyHeaders = HttpHeaders.varyHeaders(response) + this.varyHeaders = response.varyHeaders() this.requestMethod = response.request().method() this.protocol = response.protocol() this.code = response.code() @@ -647,7 +648,7 @@ class Cache internal constructor( fun matches(request: Request, response: Response): Boolean { return url == request.url().toString() && requestMethod == request.method() && - HttpHeaders.varyMatches(response, varyHeaders, request) + varyMatches(response, varyHeaders, request) } fun response(snapshot: DiskLruCache.Snapshot): Response { @@ -736,5 +737,76 @@ class Cache internal constructor( throw IOException(e.message) } } + + /** + * Returns true if none of the Vary headers have changed between [cachedRequest] and + * [newRequest]. + */ + fun varyMatches( + cachedResponse: Response, + cachedRequest: Headers, + newRequest: Request + ): Boolean { + return cachedResponse.headers().varyFields().none { + cachedRequest.values(it) != newRequest.headers(it) + } + } + + /** Returns true if a Vary header contains an asterisk. Such responses cannot be cached. */ + fun Response.hasVaryAll(): Boolean { + val responseHeaders = headers() + val varyFields = responseHeaders.varyFields() + return varyFields.contains("*") + } + + /** + * Returns the names of the request headers that need to be checked for equality when caching. + */ + private fun Headers.varyFields(): Set { + var result: MutableSet? = null + for (i in 0 until size()) { + if (!"Vary".equals(name(i), ignoreCase = true)) { + continue + } + + val value = value(i) + if (result == null) { + result = TreeSet(String.CASE_INSENSITIVE_ORDER) + } + for (varyField in value.split(',')) { + result.add(varyField.trim()) + } + } + return result ?: emptySet() + } + + /** + * Returns the subset of the headers in this's request that impact the content of this's body. + */ + fun Response.varyHeaders(): Headers { + // Use the request headers sent over the network, since that's what the response varies on. + // Otherwise OkHttp-supplied headers like "Accept-Encoding: gzip" may be lost. + val requestHeaders = networkResponse()!!.request().headers() + val responseHeaders = headers() + return varyHeaders(requestHeaders, responseHeaders) + } + + /** + * Returns the subset of the headers in [requestHeaders] that impact the content of the + * response's body. + */ + private fun varyHeaders(requestHeaders: Headers, responseHeaders: Headers): Headers { + val varyFields = responseHeaders.varyFields() + if (varyFields.isEmpty()) return Util.EMPTY_HEADERS + + val result = Headers.Builder() + for (i in 0 until requestHeaders.size()) { + val fieldName = requestHeaders.name(i) + if (varyFields.contains(fieldName)) { + result.add(fieldName, requestHeaders.value(i)) + } + } + return result.build() + } } } diff --git a/okhttp/src/main/java/okhttp3/CacheControl.kt b/okhttp/src/main/java/okhttp3/CacheControl.kt index cb13511a9..78e04f9ff 100644 --- a/okhttp/src/main/java/okhttp3/CacheControl.kt +++ b/okhttp/src/main/java/okhttp3/CacheControl.kt @@ -15,7 +15,8 @@ */ package okhttp3 -import okhttp3.internal.http.HttpHeaders +import okhttp3.internal.indexOfNonWhitespace +import okhttp3.internal.toNonNegativeInt import java.util.concurrent.TimeUnit /** @@ -261,7 +262,7 @@ class CacheControl private constructor( var pos = 0 while (pos < value.length) { val tokenStart = pos - pos = HttpHeaders.skipUntil(value, pos, "=,;") + pos = value.indexOfElement("=,;", pos) val directive = value.substring(tokenStart, pos).trim { it <= ' ' } val parameter: String? @@ -270,19 +271,19 @@ class CacheControl private constructor( parameter = null } else { pos++ // Consume '='. - pos = HttpHeaders.skipWhitespace(value, pos) + pos = value.indexOfNonWhitespace(pos) if (pos < value.length && value[pos] == '\"') { // Quoted string. pos++ // Consume '"' open quote. val parameterStart = pos - pos = HttpHeaders.skipUntil(value, pos, "\"") + pos = value.indexOfElement("\"", pos) parameter = value.substring(parameterStart, pos) pos++ // Consume '"' close quote (if necessary). } else { // Unquoted string. val parameterStart = pos - pos = HttpHeaders.skipUntil(value, pos, ",;") + pos = value.indexOfElement(",;", pos) parameter = value.substring(parameterStart, pos).trim { it <= ' ' } } } @@ -295,10 +296,10 @@ class CacheControl private constructor( noStore = true } "max-age".equals(directive, ignoreCase = true) -> { - maxAgeSeconds = HttpHeaders.parseSeconds(parameter, -1) + maxAgeSeconds = parameter.toNonNegativeInt(-1) } "s-maxage".equals(directive, ignoreCase = true) -> { - sMaxAgeSeconds = HttpHeaders.parseSeconds(parameter, -1) + sMaxAgeSeconds = parameter.toNonNegativeInt(-1) } "private".equals(directive, ignoreCase = true) -> { isPrivate = true @@ -310,10 +311,10 @@ class CacheControl private constructor( mustRevalidate = true } "max-stale".equals(directive, ignoreCase = true) -> { - maxStaleSeconds = HttpHeaders.parseSeconds(parameter, Integer.MAX_VALUE) + maxStaleSeconds = parameter.toNonNegativeInt(Integer.MAX_VALUE) } "min-fresh".equals(directive, ignoreCase = true) -> { - minFreshSeconds = HttpHeaders.parseSeconds(parameter, -1) + minFreshSeconds = parameter.toNonNegativeInt(-1) } "only-if-cached".equals(directive, ignoreCase = true) -> { onlyIfCached = true @@ -336,5 +337,18 @@ class CacheControl private constructor( mustRevalidate, maxStaleSeconds, minFreshSeconds, onlyIfCached, noTransform, immutable, headerValue) } + + /** + * Returns the next index in this at or after [startIndex] that is a character from + * [characters]. Returns the input length if none of the requested characters can be found. + */ + private fun String.indexOfElement(characters: String, startIndex: Int = 0): Int { + for (i in startIndex until length) { + if (characters.contains(this[i])) { + return i + } + } + return length + } } } diff --git a/okhttp/src/main/java/okhttp3/Response.kt b/okhttp/src/main/java/okhttp3/Response.kt index 144071fd6..349be421b 100644 --- a/okhttp/src/main/java/okhttp3/Response.kt +++ b/okhttp/src/main/java/okhttp3/Response.kt @@ -15,14 +15,13 @@ */ package okhttp3 -import java.io.Closeable -import java.io.IOException import okhttp3.internal.connection.Exchange -import okhttp3.internal.http.HttpHeaders import okhttp3.internal.http.StatusLine.Companion.HTTP_PERM_REDIRECT import okhttp3.internal.http.StatusLine.Companion.HTTP_TEMP_REDIRECT +import okhttp3.internal.http.parseChallenges import okio.Buffer - +import java.io.Closeable +import java.io.IOException import java.net.HttpURLConnection.HTTP_MOVED_PERM import java.net.HttpURLConnection.HTTP_MOVED_TEMP import java.net.HttpURLConnection.HTTP_MULT_CHOICE @@ -177,8 +176,7 @@ class Response internal constructor( * auth param, this is up to the caller that interprets these challenges. */ fun challenges(): List { - return HttpHeaders.parseChallenges( - headers(), + return headers().parseChallenges( when (code) { HTTP_UNAUTHORIZED -> "WWW-Authenticate" HTTP_PROXY_AUTH -> "Proxy-Authenticate" diff --git a/okhttp/src/main/java/okhttp3/internal/UtilKt.kt b/okhttp/src/main/java/okhttp3/internal/UtilKt.kt index 14ab5aeeb..1bbca6ffa 100644 --- a/okhttp/src/main/java/okhttp3/internal/UtilKt.kt +++ b/okhttp/src/main/java/okhttp3/internal/UtilKt.kt @@ -13,8 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@file:JvmName("UtilKt") package okhttp3.internal +import okhttp3.Response +import okio.Buffer import okio.BufferedSink import okio.BufferedSource import java.io.IOException @@ -70,3 +73,56 @@ inline fun Executor.tryExecute(name: String, crossinline block: () -> Unit) { } catch (_: RejectedExecutionException) { } } + +fun Buffer.skipAll(b: Byte): Int { + var count = 0 + while (!exhausted() && this[0] == b) { + count++ + readByte() + } + return count +} + +/** + * Returns the index of the next non-whitespace character in this. Result is undefined if input + * contains newline characters. + */ +fun String.indexOfNonWhitespace(startIndex: Int = 0): Int { + for (i in startIndex until length) { + val c = this[i] + if (c != ' ' && c != '\t') { + return i + } + } + return length +} + +/** Returns the Content-Length as reported by the response headers. */ +fun Response.headersContentLength(): Long { + return headers()["Content-Length"]?.toLongOrDefault(-1L) ?: -1L +} + +fun String.toLongOrDefault(defaultValue: Long): Long { + return try { + toLong() + } catch (_: NumberFormatException) { + defaultValue + } +} + +/** + * Returns this as a non-negative integer, or 0 if it is negative, or [Int.MAX_VALUE] if it is too + * large, or [defaultValue] if it cannot be parsed. + */ +fun String?.toNonNegativeInt(defaultValue: Int): Int { + try { + val value = this?.toLong() ?: return defaultValue + return when { + value > Int.MAX_VALUE -> Int.MAX_VALUE + value < 0 -> 0 + else -> value.toInt() + } + } catch (_: NumberFormatException) { + return defaultValue + } +} diff --git a/okhttp/src/main/java/okhttp3/internal/cache/CacheInterceptor.kt b/okhttp/src/main/java/okhttp3/internal/cache/CacheInterceptor.kt index b9f7ffcfd..fc5fd9d1e 100644 --- a/okhttp/src/main/java/okhttp3/internal/cache/CacheInterceptor.kt +++ b/okhttp/src/main/java/okhttp3/internal/cache/CacheInterceptor.kt @@ -16,26 +16,26 @@ */ package okhttp3.internal.cache -import java.io.IOException import okhttp3.Headers import okhttp3.Interceptor import okhttp3.Protocol import okhttp3.Response import okhttp3.internal.Util -import okhttp3.internal.http.ExchangeCodec -import okhttp3.internal.http.HttpHeaders -import okhttp3.internal.http.HttpMethod -import okhttp3.internal.http.RealResponseBody -import okio.Buffer -import okio.Source -import okio.Timeout -import java.net.HttpURLConnection.HTTP_NOT_MODIFIED -import java.util.concurrent.TimeUnit.MILLISECONDS import okhttp3.internal.Util.closeQuietly import okhttp3.internal.Util.discard import okhttp3.internal.addHeaderLenient +import okhttp3.internal.http.ExchangeCodec +import okhttp3.internal.http.HttpMethod +import okhttp3.internal.http.RealResponseBody +import okhttp3.internal.http.promisesBody +import okio.Buffer +import okio.Source +import okio.Timeout import okio.buffer +import java.io.IOException import java.net.HttpURLConnection.HTTP_GATEWAY_TIMEOUT +import java.net.HttpURLConnection.HTTP_NOT_MODIFIED +import java.util.concurrent.TimeUnit.MILLISECONDS /** Serves requests from the cache and writes responses to the cache. */ class CacheInterceptor(internal val cache: InternalCache?) : Interceptor { @@ -118,7 +118,7 @@ class CacheInterceptor(internal val cache: InternalCache?) : Interceptor { .build() if (cache != null) { - if (HttpHeaders.hasBody(response) && CacheStrategy.isCacheable(response, networkRequest)) { + if (response.promisesBody() && CacheStrategy.isCacheable(response, networkRequest)) { // Offer this request to the cache. val cacheRequest = cache.put(response) return cacheWritingResponse(cacheRequest, response) diff --git a/okhttp/src/main/java/okhttp3/internal/cache/CacheStrategy.java b/okhttp/src/main/java/okhttp3/internal/cache/CacheStrategy.java index 2753cf27f..26230cfaa 100644 --- a/okhttp/src/main/java/okhttp3/internal/cache/CacheStrategy.java +++ b/okhttp/src/main/java/okhttp3/internal/cache/CacheStrategy.java @@ -21,8 +21,8 @@ import okhttp3.CacheControl; import okhttp3.Headers; import okhttp3.Request; import okhttp3.Response; +import okhttp3.internal.UtilKt; import okhttp3.internal.http.HttpDate; -import okhttp3.internal.http.HttpHeaders; import okhttp3.internal.http.StatusLine; import static java.net.HttpURLConnection.HTTP_BAD_METHOD; @@ -160,7 +160,7 @@ public final class CacheStrategy { } else if ("ETag".equalsIgnoreCase(fieldName)) { etag = value; } else if ("Age".equalsIgnoreCase(fieldName)) { - ageSeconds = HttpHeaders.parseSeconds(value, -1); + ageSeconds = UtilKt.toNonNegativeInt(value, -1); } } } diff --git a/okhttp/src/main/java/okhttp3/internal/http/BridgeInterceptor.kt b/okhttp/src/main/java/okhttp3/internal/http/BridgeInterceptor.kt index cf9abd852..62af48c5d 100644 --- a/okhttp/src/main/java/okhttp3/internal/http/BridgeInterceptor.kt +++ b/okhttp/src/main/java/okhttp3/internal/http/BridgeInterceptor.kt @@ -83,14 +83,14 @@ class BridgeInterceptor(private val cookieJar: CookieJar) : Interceptor { val networkResponse = chain.proceed(requestBuilder.build()) - HttpHeaders.receiveHeaders(cookieJar, userRequest.url(), networkResponse.headers()) + cookieJar.receiveHeaders(userRequest.url(), networkResponse.headers()) val responseBuilder = networkResponse.newBuilder() .request(userRequest) if (transparentGzip && "gzip".equals(networkResponse.header("Content-Encoding"), ignoreCase = true) && - HttpHeaders.hasBody(networkResponse)) { + networkResponse.promisesBody()) { val responseBody = networkResponse.body() if (responseBody != null) { val gzipSource = GzipSource(responseBody.source()) diff --git a/okhttp/src/main/java/okhttp3/internal/http/HttpHeaders.java b/okhttp/src/main/java/okhttp3/internal/http/HttpHeaders.java deleted file mode 100644 index 5e82d187a..000000000 --- a/okhttp/src/main/java/okhttp3/internal/http/HttpHeaders.java +++ /dev/null @@ -1,401 +0,0 @@ -/* - * Copyright (C) 2012 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 okhttp3.internal.http; - -import java.io.EOFException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.TreeSet; -import okhttp3.Challenge; -import okhttp3.Cookie; -import okhttp3.CookieJar; -import okhttp3.Headers; -import okhttp3.HttpUrl; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.internal.platform.Platform; -import okio.Buffer; -import okio.ByteString; - -import static java.net.HttpURLConnection.HTTP_NOT_MODIFIED; -import static java.net.HttpURLConnection.HTTP_NO_CONTENT; -import static okhttp3.internal.Util.EMPTY_HEADERS; -import static okhttp3.internal.http.StatusLine.HTTP_CONTINUE; - -/** Headers and utilities for internal use by OkHttp. */ -public final class HttpHeaders { - private static final ByteString QUOTED_STRING_DELIMITERS = ByteString.encodeUtf8("\"\\"); - private static final ByteString TOKEN_DELIMITERS = ByteString.encodeUtf8("\t ,="); - - private HttpHeaders() { - } - - public static long contentLength(Response response) { - return contentLength(response.headers()); - } - - public static long contentLength(Headers headers) { - return stringToLong(headers.get("Content-Length")); - } - - private static long stringToLong(String s) { - if (s == null) return -1; - try { - return Long.parseLong(s); - } catch (NumberFormatException e) { - return -1; - } - } - - /** - * Returns true if none of the Vary headers have changed between {@code cachedRequest} and {@code - * newRequest}. - */ - public static boolean varyMatches( - Response cachedResponse, Headers cachedRequest, Request newRequest) { - for (String field : varyFields(cachedResponse)) { - if (!Objects.equals(cachedRequest.values(field), newRequest.headers(field))) return false; - } - return true; - } - - /** - * Returns true if a Vary header contains an asterisk. Such responses cannot be cached. - */ - public static boolean hasVaryAll(Response response) { - return hasVaryAll(response.headers()); - } - - /** - * Returns true if a Vary header contains an asterisk. Such responses cannot be cached. - */ - public static boolean hasVaryAll(Headers responseHeaders) { - return varyFields(responseHeaders).contains("*"); - } - - private static Set varyFields(Response response) { - return varyFields(response.headers()); - } - - /** - * Returns the names of the request headers that need to be checked for equality when caching. - */ - public static Set varyFields(Headers responseHeaders) { - Set result = Collections.emptySet(); - for (int i = 0, size = responseHeaders.size(); i < size; i++) { - if (!"Vary".equalsIgnoreCase(responseHeaders.name(i))) continue; - - String value = responseHeaders.value(i); - if (result.isEmpty()) { - result = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); - } - for (String varyField : value.split(",")) { - result.add(varyField.trim()); - } - } - return result; - } - - /** - * Returns the subset of the headers in {@code response}'s request that impact the content of - * response's body. - */ - public static Headers varyHeaders(Response response) { - // Use the request headers sent over the network, since that's what the - // response varies on. Otherwise OkHttp-supplied headers like - // "Accept-Encoding: gzip" may be lost. - Headers requestHeaders = response.networkResponse().request().headers(); - Headers responseHeaders = response.headers(); - return varyHeaders(requestHeaders, responseHeaders); - } - - /** - * Returns the subset of the headers in {@code requestHeaders} that impact the content of - * response's body. - */ - public static Headers varyHeaders(Headers requestHeaders, Headers responseHeaders) { - Set varyFields = varyFields(responseHeaders); - if (varyFields.isEmpty()) return EMPTY_HEADERS; - - Headers.Builder result = new Headers.Builder(); - for (int i = 0, size = requestHeaders.size(); i < size; i++) { - String fieldName = requestHeaders.name(i); - if (varyFields.contains(fieldName)) { - result.add(fieldName, requestHeaders.value(i)); - } - } - return result.build(); - } - - /** - * Parse RFC 7235 challenges. This is awkward because we need to look ahead to know how to - * interpret a token. - * - *

For example, the first line has a parameter name/value pair and the second line has a single - * token68: - * - *

   {@code
-   *
-   *   WWW-Authenticate: Digest foo=bar
-   *   WWW-Authenticate: Digest foo=
-   * }
- * - *

Similarly, the first line has one challenge and the second line has two challenges: - * - *

   {@code
-   *
-   *   WWW-Authenticate: Digest ,foo=bar
-   *   WWW-Authenticate: Digest ,foo
-   * }
- */ - public static List parseChallenges(Headers responseHeaders, String headerName) { - List result = new ArrayList<>(); - for (int h = 0; h < responseHeaders.size(); h++) { - if (headerName.equalsIgnoreCase(responseHeaders.name(h))) { - Buffer header = new Buffer().writeUtf8(responseHeaders.value(h)); - try { - parseChallengeHeader(result, header); - } catch (EOFException e) { - Platform.get().log(Platform.WARN, "Unable to parse challenge", e); - } - } - } - return result; - } - - private static void parseChallengeHeader(List result, Buffer header) - throws EOFException { - String peek = null; - - while (true) { - // Read a scheme name for this challenge if we don't have one already. - if (peek == null) { - skipWhitespaceAndCommas(header); - peek = readToken(header); - if (peek == null) return; - } - - String schemeName = peek; - - // Read a token68, a sequence of parameters, or nothing. - boolean commaPrefixed = skipWhitespaceAndCommas(header); - peek = readToken(header); - if (peek == null) { - if (!header.exhausted()) return; // Expected a token; got something else. - result.add(new Challenge(schemeName, Collections.emptyMap())); - return; - } - - int eqCount = skipAll(header, (byte) '='); - boolean commaSuffixed = skipWhitespaceAndCommas(header); - - // It's a token68 because there isn't a value after it. - if (!commaPrefixed && (commaSuffixed || header.exhausted())) { - result.add(new Challenge(schemeName, Collections.singletonMap( - null, peek + repeat('=', eqCount)))); - peek = null; - continue; - } - - // It's a series of parameter names and values. - Map parameters = new LinkedHashMap<>(); - eqCount += skipAll(header, (byte) '='); - while (true) { - if (peek == null) { - peek = readToken(header); - if (skipWhitespaceAndCommas(header)) break; // We peeked a scheme name followed by ','. - eqCount = skipAll(header, (byte) '='); - } - if (eqCount == 0) break; // We peeked a scheme name. - if (eqCount > 1) return; // Unexpected '=' characters. - if (skipWhitespaceAndCommas(header)) return; // Unexpected ','. - - String parameterValue = !header.exhausted() && header.getByte(0) == '"' - ? readQuotedString(header) - : readToken(header); - if (parameterValue == null) return; // Expected a value. - String replaced = parameters.put(peek, parameterValue); - peek = null; - if (replaced != null) return; // Unexpected duplicate parameter. - if (!skipWhitespaceAndCommas(header) && !header.exhausted()) return; // Expected ',' or EOF. - } - result.add(new Challenge(schemeName, parameters)); - } - } - - /** Returns true if any commas were skipped. */ - private static boolean skipWhitespaceAndCommas(Buffer buffer) throws EOFException { - boolean commaFound = false; - while (!buffer.exhausted()) { - byte b = buffer.getByte(0); - if (b == ',') { - // Consume ','. - buffer.readByte(); - commaFound = true; - } else if (b == ' ' || b == '\t') { - // Consume space or tab. - buffer.readByte(); - } else { - break; - } - } - return commaFound; - } - - private static int skipAll(Buffer buffer, byte b) throws EOFException { - int count = 0; - while (!buffer.exhausted() && buffer.getByte(0) == b) { - count++; - buffer.readByte(); - } - return count; - } - - /** - * Reads a double-quoted string, unescaping quoted pairs like {@code \"} to the 2nd character in - * each sequence. Returns the unescaped string, or null if the buffer isn't prefixed with a - * double-quoted string. - */ - private static String readQuotedString(Buffer buffer) throws EOFException { - if (buffer.readByte() != '\"') throw new IllegalArgumentException(); - Buffer result = new Buffer(); - while (true) { - long i = buffer.indexOfElement(QUOTED_STRING_DELIMITERS); - if (i == -1L) return null; // Unterminated quoted string. - - if (buffer.getByte(i) == '"') { - result.write(buffer, i); - // Consume '"'. - buffer.readByte(); - return result.readUtf8(); - } - - if (buffer.size() == i + 1L) return null; // Dangling escape. - result.write(buffer, i); - // Consume '\'. - buffer.readByte(); - result.write(buffer, 1L); // The escaped character. - } - } - - /** - * Consumes and returns a non-empty token, terminating at special characters in {@link - * #TOKEN_DELIMITERS}. Returns null if the buffer is empty or prefixed with a delimiter. - */ - private static String readToken(Buffer buffer) { - try { - long tokenSize = buffer.indexOfElement(TOKEN_DELIMITERS); - if (tokenSize == -1L) tokenSize = buffer.size(); - - return tokenSize != 0L - ? buffer.readUtf8(tokenSize) - : null; - } catch (EOFException e) { - throw new AssertionError(); - } - } - - private static String repeat(char c, int count) { - char[] array = new char[count]; - Arrays.fill(array, c); - return new String(array); - } - - public static void receiveHeaders(CookieJar cookieJar, HttpUrl url, Headers headers) { - if (cookieJar == CookieJar.NO_COOKIES) return; - - List cookies = Cookie.parseAll(url, headers); - if (cookies.isEmpty()) return; - - cookieJar.saveFromResponse(url, cookies); - } - - /** Returns true if the response must have a (possibly 0-length) body. See RFC 7231. */ - public static boolean hasBody(Response response) { - // HEAD requests never yield a body regardless of the response headers. - if (response.request().method().equals("HEAD")) { - return false; - } - - int responseCode = response.code(); - if ((responseCode < HTTP_CONTINUE || responseCode >= 200) - && responseCode != HTTP_NO_CONTENT - && responseCode != HTTP_NOT_MODIFIED) { - return true; - } - - // If the Content-Length or Transfer-Encoding headers disagree with the response code, the - // response is malformed. For best compatibility, we honor the headers. - if (contentLength(response) != -1 - || "chunked".equalsIgnoreCase(response.header("Transfer-Encoding"))) { - return true; - } - - return false; - } - - /** - * Returns the next index in {@code input} at or after {@code pos} that contains a character from - * {@code characters}. Returns the input length if none of the requested characters can be found. - */ - public static int skipUntil(String input, int pos, String characters) { - for (; pos < input.length(); pos++) { - if (characters.indexOf(input.charAt(pos)) != -1) { - break; - } - } - return pos; - } - - /** - * Returns the next non-whitespace character in {@code input} that is white space. Result is - * undefined if input contains newline characters. - */ - public static int skipWhitespace(String input, int pos) { - for (; pos < input.length(); pos++) { - char c = input.charAt(pos); - if (c != ' ' && c != '\t') { - break; - } - } - return pos; - } - - /** - * Returns {@code value} as a positive integer, or 0 if it is negative, or {@code defaultValue} if - * it cannot be parsed. - */ - public static int parseSeconds(String value, int defaultValue) { - try { - long seconds = Long.parseLong(value); - if (seconds > Integer.MAX_VALUE) { - return Integer.MAX_VALUE; - } else if (seconds < 0) { - return 0; - } else { - return (int) seconds; - } - } catch (NumberFormatException e) { - return defaultValue; - } - } -} diff --git a/okhttp/src/main/java/okhttp3/internal/http/HttpHeaders.kt b/okhttp/src/main/java/okhttp3/internal/http/HttpHeaders.kt new file mode 100644 index 000000000..8eb129704 --- /dev/null +++ b/okhttp/src/main/java/okhttp3/internal/http/HttpHeaders.kt @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2012 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. + */ +@file:JvmName("HttpHeaders") +package okhttp3.internal.http + +import okhttp3.Challenge +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.Response +import okhttp3.internal.headersContentLength +import okhttp3.internal.http.StatusLine.Companion.HTTP_CONTINUE +import okhttp3.internal.platform.Platform +import okhttp3.internal.skipAll +import okio.Buffer +import okio.ByteString.Companion.encodeUtf8 +import java.io.EOFException +import java.net.HttpURLConnection.HTTP_NOT_MODIFIED +import java.net.HttpURLConnection.HTTP_NO_CONTENT +import java.util.Collections + +private val QUOTED_STRING_DELIMITERS = "\"\\".encodeUtf8() +private val TOKEN_DELIMITERS = "\t ,=".encodeUtf8() + +/** + * Parse RFC 7235 challenges. This is awkward because we need to look ahead to know how to + * interpret a token. + * + * For example, the first line has a parameter name/value pair and the second line has a single + * token68: + * + * ``` + * WWW-Authenticate: Digest foo=bar + * WWW-Authenticate: Digest foo= + * ``` + * + * Similarly, the first line has one challenge and the second line has two challenges: + * + * ``` + * WWW-Authenticate: Digest ,foo=bar + * WWW-Authenticate: Digest ,foo + * ``` + */ +fun Headers.parseChallenges(headerName: String): List { + val result = mutableListOf() + for (h in 0 until size()) { + if (headerName.equals(name(h), ignoreCase = true)) { + val header = Buffer().writeUtf8(value(h)) + try { + header.readChallengeHeader(result) + } catch (e: EOFException) { + Platform.get().log(Platform.WARN, "Unable to parse challenge", e) + } + } + } + return result +} + +@Throws(EOFException::class) +private fun Buffer.readChallengeHeader(result: MutableList) { + var peek: String? = null + + while (true) { + // Read a scheme name for this challenge if we don't have one already. + if (peek == null) { + skipCommasAndWhitespace() + peek = readToken() + if (peek == null) return + } + + val schemeName = peek + + // Read a token68, a sequence of parameters, or nothing. + val commaPrefixed = skipCommasAndWhitespace() + peek = readToken() + if (peek == null) { + if (!exhausted()) return // Expected a token; got something else. + result.add(Challenge(schemeName, emptyMap())) + return + } + + var eqCount = skipAll('='.toByte()) + val commaSuffixed = skipCommasAndWhitespace() + + // It's a token68 because there isn't a value after it. + if (!commaPrefixed && (commaSuffixed || exhausted())) { + result.add(Challenge(schemeName, + Collections.singletonMap(null, peek + "=".repeat(eqCount)))) + peek = null + continue + } + + // It's a series of parameter names and values. + val parameters = mutableMapOf() + eqCount += skipAll('='.toByte()) + while (true) { + if (peek == null) { + peek = readToken() + if (skipCommasAndWhitespace()) break // We peeked a scheme name followed by ','. + eqCount = skipAll('='.toByte()) + } + if (eqCount == 0) break // We peeked a scheme name. + if (eqCount > 1) return // Unexpected '=' characters. + if (skipCommasAndWhitespace()) return // Unexpected ','. + + val parameterValue = when { + startsWith('"'.toByte()) -> readQuotedString() + else -> readToken() + } ?: return // Expected a value. + + val replaced = parameters.put(peek, parameterValue) + peek = null + if (replaced != null) return // Unexpected duplicate parameter. + if (!skipCommasAndWhitespace() && !exhausted()) return // Expected ',' or EOF. + } + result.add(Challenge(schemeName, parameters)) + } +} + +/** Returns true if any commas were skipped. */ +private fun Buffer.skipCommasAndWhitespace(): Boolean { + var commaFound = false + loop@ while (!exhausted()) { + when (this[0]) { + ','.toByte() -> { + // Consume ','. + readByte() + commaFound = true + } + + ' '.toByte(), '\t'.toByte() -> { + readByte() + // Consume space or tab. + } + + else -> break@loop + } + } + return commaFound +} + +private fun Buffer.startsWith(prefix: Byte) = !exhausted() && this[0] == prefix + +/** + * Reads a double-quoted string, unescaping quoted pairs like `\"` to the 2nd character in each + * sequence. Returns the unescaped string, or null if the buffer isn't prefixed with a + * double-quoted string. + */ +@Throws(EOFException::class) +private fun Buffer.readQuotedString(): String? { + require(readByte() == '\"'.toByte()) + val result = Buffer() + while (true) { + val i = indexOfElement(QUOTED_STRING_DELIMITERS) + if (i == -1L) return null // Unterminated quoted string. + + if (this[i] == '"'.toByte()) { + result.write(this, i) + // Consume '"'. + readByte() + return result.readUtf8() + } + + if (size == i + 1L) return null // Dangling escape. + result.write(this, i) + // Consume '\'. + readByte() + result.write(this, 1L) // The escaped character. + } +} + +/** + * Consumes and returns a non-empty token, terminating at special characters in + * [TOKEN_DELIMITERS]. Returns null if the buffer is empty or prefixed with a delimiter. + */ +private fun Buffer.readToken(): String? { + var tokenSize = indexOfElement(TOKEN_DELIMITERS) + if (tokenSize == -1L) tokenSize = size + + return when { + tokenSize != 0L -> readUtf8(tokenSize) + else -> null + } +} + +fun CookieJar.receiveHeaders(url: HttpUrl, headers: Headers) { + if (this === CookieJar.NO_COOKIES) return + + val cookies = Cookie.parseAll(url, headers) + if (cookies.isEmpty()) return + + saveFromResponse(url, cookies) +} + +/** + * Returns true if the response headers and status indicate that this response has a (possibly + * 0-length) body. See RFC 7231. + */ +fun Response.promisesBody(): Boolean { + // HEAD requests never yield a body regardless of the response headers. + if (request().method() == "HEAD") { + return false + } + + val responseCode = code() + if ((responseCode < HTTP_CONTINUE || responseCode >= 200) && + responseCode != HTTP_NO_CONTENT && + responseCode != HTTP_NOT_MODIFIED) { + return true + } + + // If the Content-Length or Transfer-Encoding headers disagree with the response code, the + // response is malformed. For best compatibility, we honor the headers. + if (headersContentLength() != -1L || + "chunked".equals(header("Transfer-Encoding"), ignoreCase = true)) { + return true + } + + return false +} diff --git a/okhttp/src/main/java/okhttp3/internal/http1/Http1ExchangeCodec.kt b/okhttp/src/main/java/okhttp3/internal/http1/Http1ExchangeCodec.kt index d76f779ec..a7207b06f 100644 --- a/okhttp/src/main/java/okhttp3/internal/http1/Http1ExchangeCodec.kt +++ b/okhttp/src/main/java/okhttp3/internal/http1/Http1ExchangeCodec.kt @@ -24,11 +24,13 @@ import okhttp3.internal.Util import okhttp3.internal.Util.checkOffsetAndCount import okhttp3.internal.addHeaderLenient import okhttp3.internal.connection.RealConnection +import okhttp3.internal.headersContentLength import okhttp3.internal.http.ExchangeCodec -import okhttp3.internal.http.HttpHeaders import okhttp3.internal.http.RequestLine import okhttp3.internal.http.StatusLine import okhttp3.internal.http.StatusLine.Companion.HTTP_CONTINUE +import okhttp3.internal.http.promisesBody +import okhttp3.internal.http.receiveHeaders import okio.Buffer import okio.BufferedSink import okio.BufferedSource @@ -123,18 +125,18 @@ class Http1ExchangeCodec( override fun reportedContentLength(response: Response): Long { return when { - !HttpHeaders.hasBody(response) -> 0L + !response.promisesBody() -> 0L response.isChunked() -> -1L - else -> HttpHeaders.contentLength(response) + else -> response.headersContentLength() } } override fun openResponseBodySource(response: Response): Source { return when { - !HttpHeaders.hasBody(response) -> newFixedLengthSource(0) + !response.promisesBody() -> newFixedLengthSource(0) response.isChunked() -> newChunkedSource(response.request().url()) else -> { - val contentLength = HttpHeaders.contentLength(response) + val contentLength = response.headersContentLength() if (contentLength != -1L) { newFixedLengthSource(contentLength) } else { @@ -267,7 +269,7 @@ class Http1ExchangeCodec( * before proceeding. */ fun skipConnectBody(response: Response) { - val contentLength = HttpHeaders.contentLength(response) + val contentLength = response.headersContentLength() if (contentLength == -1L) return val body = newFixedLengthSource(contentLength) Util.skipAll(body, Int.MAX_VALUE, MILLISECONDS) @@ -455,7 +457,7 @@ class Http1ExchangeCodec( if (bytesRemainingInChunk == 0L) { hasMoreChunks = false trailers = readHeaders() - HttpHeaders.receiveHeaders(client!!.cookieJar(), url, trailers) + client!!.cookieJar().receiveHeaders(url, trailers!!) responseBodyComplete() } } diff --git a/okhttp/src/main/java/okhttp3/internal/http2/Http2ExchangeCodec.kt b/okhttp/src/main/java/okhttp3/internal/http2/Http2ExchangeCodec.kt index 8b38796d4..c2beef05a 100644 --- a/okhttp/src/main/java/okhttp3/internal/http2/Http2ExchangeCodec.kt +++ b/okhttp/src/main/java/okhttp3/internal/http2/Http2ExchangeCodec.kt @@ -25,8 +25,8 @@ import okhttp3.internal.Internal import okhttp3.internal.Util import okhttp3.internal.addHeaderLenient import okhttp3.internal.connection.RealConnection +import okhttp3.internal.headersContentLength import okhttp3.internal.http.ExchangeCodec -import okhttp3.internal.http.HttpHeaders import okhttp3.internal.http.RequestLine import okhttp3.internal.http.StatusLine import okhttp3.internal.http.StatusLine.Companion.HTTP_CONTINUE @@ -108,7 +108,7 @@ class Http2ExchangeCodec( } override fun reportedContentLength(response: Response): Long { - return HttpHeaders.contentLength(response) + return response.headersContentLength() } override fun openResponseBodySource(response: Response): Source {