diff --git a/okhttp/src/commonMain/kotlin/okhttp3/Headers.kt b/okhttp/src/commonMain/kotlin/okhttp3/Headers.kt new file mode 100644 index 000000000..3e4769892 --- /dev/null +++ b/okhttp/src/commonMain/kotlin/okhttp3/Headers.kt @@ -0,0 +1,154 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + +import okhttp3.Headers.Builder + +/** + * The header fields of a single HTTP message. Values are uninterpreted strings; use `Request` and + * `Response` for interpreted headers. This class maintains the order of the header fields within + * the HTTP message. + * + * This class tracks header values line-by-line. A field with multiple comma- separated values on + * the same line will be treated as a field with a single value by this class. It is the caller's + * responsibility to detect and split on commas if their field permits multiple values. This + * simplifies use of single-valued fields whose values routinely contain commas, such as cookies or + * dates. + * + * This class trims whitespace from values. It never returns values with leading or trailing + * whitespace. + * + * Instances of this class are immutable. Use [Builder] to create instances. + */ +@Suppress("NAME_SHADOWING") +expect class Headers internal constructor( + namesAndValues: Array +) : Iterable> { + internal val namesAndValues: Array + + /** Returns the last value corresponding to the specified field, or null. */ + operator fun get(name: String): String? + + /** Returns the number of field values. */ + val size: Int + + /** Returns the field at `position`. */ + fun name(index: Int): String + + /** Returns the value at `index`. */ + fun value(index: Int): String + + /** Returns an immutable case-insensitive set of header names. */ + fun names(): Set + + /** Returns an immutable list of the header values for `name`. */ + fun values(name: String): List + + override operator fun iterator(): Iterator> + + fun newBuilder(): Builder + + /** + * Returns true if `other` is a `Headers` object with the same headers, with the same casing, in + * the same order. Note that two headers instances may be *semantically* equal but not equal + * according to this method. In particular, none of the following sets of headers are equal + * according to this method: + * + * 1. Original + * ``` + * Content-Type: text/html + * Content-Length: 50 + * ``` + * + * 2. Different order + * + * ``` + * Content-Length: 50 + * Content-Type: text/html + * ``` + * + * 3. Different case + * + * ``` + * content-type: text/html + * content-length: 50 + * ``` + * + * 4. Different values + * + * ``` + * Content-Type: text/html + * Content-Length: 050 + * ``` + * + * Applications that require semantically equal headers should convert them into a canonical form + * before comparing them for equality. + */ + override fun equals(other: Any?): Boolean + + /** + * Returns header names and values. The names and values are separated by `: ` and each pair is + * followed by a newline character `\n`. + * + * Since OkHttp 5 this redacts these sensitive headers: + * + * * `Authorization` + * * `Cookie` + * * `Proxy-Authorization` + * * `Set-Cookie` + */ + override fun toString(): String + + class Builder internal constructor() { + internal val namesAndValues: MutableList + + /** + * Add a header with the specified name and value. Does validation of header names and values. + */ + fun add(name: String, value: String): Builder + + /** + * Adds all headers from an existing collection. + */ + fun addAll(headers: Headers): Builder + + fun removeAll(name: String): Builder + + /** + * Set a field with the specified value. If the field is not found, it is added. If the field is + * found, the existing values are replaced. + */ + operator fun set(name: String, value: String): Builder + + /** Equivalent to `build().get(name)`, but potentially faster. */ + operator fun get(name: String): String? + + fun build(): Headers + } + + companion object { + /** + * Returns headers for the alternating header names and values. There must be an even number of + * arguments, and they must alternate between header names and values. + */ + fun headersOf(vararg namesAndValues: String): Headers + + /** Returns headers for the header names and values in the [Map]. */ + fun Map.toHeaders(): Headers + } +} diff --git a/okhttp/src/commonMain/kotlin/okhttp3/internal/-HeadersCommon.kt b/okhttp/src/commonMain/kotlin/okhttp3/internal/-HeadersCommon.kt new file mode 100644 index 000000000..7ac9864e6 --- /dev/null +++ b/okhttp/src/commonMain/kotlin/okhttp3/internal/-HeadersCommon.kt @@ -0,0 +1,191 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + +import okhttp3.Headers + +internal fun Headers.commonName(index: Int): String = namesAndValues[index * 2] + +internal fun Headers.commonValue(index: Int): String = namesAndValues[index * 2 + 1] + +internal fun Headers.commonValues(name: String): List { + var result: MutableList? = null + for (i in 0 until size) { + if (name.equals(name(i), ignoreCase = true)) { + if (result == null) result = ArrayList(2) + result.add(value(i)) + } + } + return result?.toList().orEmpty() +} + +internal fun Headers.commonIterator(): Iterator> { + return Array(size) { name(it) to value(it) }.iterator() +} + +internal fun Headers.commonNewBuilder(): Headers.Builder { + val result = Headers.Builder() + result.namesAndValues += namesAndValues + return result +} + +internal fun Headers.commonEquals(other: Any?): Boolean { + return other is Headers && namesAndValues.contentEquals(other.namesAndValues) +} + +internal fun Headers.commonHashCode(): Int = namesAndValues.contentHashCode() + +internal fun Headers.commonToString(): String { + return buildString { + for (i in 0 until size) { + val name = name(i) + val value = value(i) + append(name) + append(": ") + append(if (isSensitiveHeader(name)) "██" else value) + append("\n") + } + } +} + +internal fun commonHeadersGet(namesAndValues: Array, name: String): String? { + for (i in namesAndValues.size - 2 downTo 0 step 2) { + if (name.equals(namesAndValues[i], ignoreCase = true)) { + return namesAndValues[i + 1] + } + } + return null +} + +internal fun Headers.Builder.commonAdd(name: String, value: String) = apply { + headersCheckName(name) + headersCheckValue(value, name) + commonAddLenient(name, value) +} + +internal fun Headers.Builder.commonAddAll(headers: Headers) = apply { + for (i in 0 until headers.size) { + commonAddLenient(headers.name(i), headers.value(i)) + } +} + +internal fun Headers.Builder.commonAddLenient(name: String, value: String) = apply { + namesAndValues.add(name) + namesAndValues.add(value.trim()) +} + +internal fun Headers.Builder.commonRemoveAll(name: String) = apply { + var i = 0 + while (i < namesAndValues.size) { + if (name.equals(namesAndValues[i], ignoreCase = true)) { + namesAndValues.removeAt(i) // name + namesAndValues.removeAt(i) // value + i -= 2 + } + i += 2 + } +} + +/** + * Set a field with the specified value. If the field is not found, it is added. If the field is + * found, the existing values are replaced. + */ +internal fun Headers.Builder.commonSet(name: String, value: String) = apply { + headersCheckName(name) + headersCheckValue(value, name) + removeAll(name) + commonAddLenient(name, value) +} + +/** Equivalent to `build().get(name)`, but potentially faster. */ +internal fun Headers.Builder.commonGet(name: String): String? { + for (i in namesAndValues.size - 2 downTo 0 step 2) { + if (name.equals(namesAndValues[i], ignoreCase = true)) { + return namesAndValues[i + 1] + } + } + return null +} + +internal fun Headers.Builder.commonBuild(): Headers = Headers(namesAndValues.toTypedArray()) + +internal fun headersCheckName(name: String) { + require(name.isNotEmpty()) { "name is empty" } + for (i in name.indices) { + val c = name[i] + require(c in '\u0021'..'\u007e') { + "Unexpected char 0x${c.charCode()} at $i in header name: $name" + } + } +} + +internal fun headersCheckValue(value: String, name: String) { + for (i in value.indices) { + val c = value[i] + require(c == '\t' || c in '\u0020'..'\u007e') { + "Unexpected char 0x${c.charCode()} at $i in $name value" + + (if (isSensitiveHeader(name)) "" else ": $value") + } + } +} + +private fun Char.charCode() = code.toString(16).let { + if (it.length < 2) { + "0$it" + } else { + it + } +} + +internal fun commonHeadersOf(vararg inputNamesAndValues: String): Headers { + require(inputNamesAndValues.size % 2 == 0) { "Expected alternating header names and values" } + + // Make a defensive copy and clean it up. + val namesAndValues: Array = arrayOf(*inputNamesAndValues) + for (i in namesAndValues.indices) { + require(namesAndValues[i] != null) { "Headers cannot be null" } + namesAndValues[i] = inputNamesAndValues[i].trim() + } + + // Check for malformed headers. + for (i in namesAndValues.indices step 2) { + val name = namesAndValues[i] + val value = namesAndValues[i + 1] + headersCheckName(name) + headersCheckValue(value, name) + } + + return Headers(namesAndValues) +} + +internal fun Map.commonToHeaders(): Headers { + // Make a defensive copy and clean it up. + val namesAndValues = arrayOfNulls(size * 2) + var i = 0 + for ((k, v) in this) { + val name = k.trim() + val value = v.trim() + headersCheckName(name) + headersCheckValue(value, name) + namesAndValues[i] = name + namesAndValues[i + 1] = value + i += 2 + } + + return Headers(namesAndValues as Array) +} diff --git a/okhttp/src/commonTest/kotlin/okhttp3/HeadersTest.kt b/okhttp/src/commonTest/kotlin/okhttp3/HeadersTest.kt new file mode 100644 index 000000000..4f5ccaad7 --- /dev/null +++ b/okhttp/src/commonTest/kotlin/okhttp3/HeadersTest.kt @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2012 Square, Inc. + * + * 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 + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotEqualTo +import kotlin.test.Test +import kotlin.test.fail +import okhttp3.Headers.Companion.headersOf +import okhttp3.Headers.Companion.toHeaders + +class HeadersTest { + @Test fun ofTrims() { + val headers = headersOf("\t User-Agent \n", " \r OkHttp ") + assertThat(headers.name(0)).isEqualTo("User-Agent") + assertThat(headers.value(0)).isEqualTo("OkHttp") + } + + @Test fun ofThrowsOddNumberOfHeaders() { + try { + headersOf("User-Agent", "OkHttp", "Content-Length") + fail() + } catch (expected: IllegalArgumentException) { + } + } + + @Test fun ofThrowsOnEmptyName() { + try { + headersOf("", "OkHttp") + fail() + } catch (expected: IllegalArgumentException) { + } + } + + @Test fun ofAcceptsEmptyValue() { + val headers = headersOf("User-Agent", "") + assertThat(headers.value(0)).isEqualTo("") + } + + @Test fun ofMakesDefensiveCopy() { + val namesAndValues = arrayOf( + "User-Agent", + "OkHttp" + ) + val headers = headersOf(*namesAndValues) + namesAndValues[1] = "Chrome" + assertThat(headers.value(0)).isEqualTo("OkHttp") + } + + @Test fun ofRejectsNullChar() { + try { + headersOf("User-Agent", "Square\u0000OkHttp") + fail() + } catch (expected: IllegalArgumentException) { + } + } + + @Test fun ofMapThrowsOnEmptyName() { + try { + mapOf("" to "OkHttp").toHeaders() + fail() + } catch (expected: IllegalArgumentException) { + } + } + + @Test fun ofMapThrowsOnBlankName() { + try { + mapOf(" " to "OkHttp").toHeaders() + fail() + } catch (expected: IllegalArgumentException) { + } + } + + @Test fun ofMapAcceptsEmptyValue() { + val headers = mapOf("User-Agent" to "").toHeaders() + assertThat(headers.value(0)).isEqualTo("") + } + + @Test fun ofMapTrimsKey() { + val headers = mapOf(" User-Agent " to "OkHttp").toHeaders() + assertThat(headers.name(0)).isEqualTo("User-Agent") + } + + @Test fun ofMapTrimsValue() { + val headers = mapOf("User-Agent" to " OkHttp ").toHeaders() + assertThat(headers.value(0)).isEqualTo("OkHttp") + } + + @Test fun ofMapMakesDefensiveCopy() { + val namesAndValues = mutableMapOf() + namesAndValues["User-Agent"] = "OkHttp" + val headers = namesAndValues.toHeaders() + namesAndValues["User-Agent"] = "Chrome" + assertThat(headers.value(0)).isEqualTo("OkHttp") + } + + @Test fun ofMapRejectsNullCharInName() { + try { + mapOf("User-\u0000Agent" to "OkHttp").toHeaders() + fail() + } catch (expected: IllegalArgumentException) { + } + } + + @Test fun ofMapRejectsNullCharInValue() { + try { + mapOf("User-Agent" to "Square\u0000OkHttp").toHeaders() + fail() + } catch (expected: IllegalArgumentException) { + } + } + + @Test fun builderRejectsUnicodeInHeaderName() { + try { + Headers.Builder().add("héader1", "value1") + fail("Should have complained about invalid name") + } catch (expected: IllegalArgumentException) { + assertThat(expected.message) + .isEqualTo("Unexpected char 0xe9 at 1 in header name: héader1") + } + } + + @Test fun builderRejectsUnicodeInHeaderValue() { + try { + Headers.Builder().add("header1", "valué1") + fail("Should have complained about invalid value") + } catch (expected: IllegalArgumentException) { + assertThat(expected.message) + .isEqualTo("Unexpected char 0xe9 at 4 in header1 value: valué1") + } + } + + @Test fun varargFactoryRejectsUnicodeInHeaderName() { + try { + headersOf("héader1", "value1") + fail("Should have complained about invalid value") + } catch (expected: IllegalArgumentException) { + assertThat(expected.message) + .isEqualTo("Unexpected char 0xe9 at 1 in header name: héader1") + } + } + + @Test fun varargFactoryRejectsUnicodeInHeaderValue() { + try { + headersOf("header1", "valué1") + fail("Should have complained about invalid value") + } catch (expected: IllegalArgumentException) { + assertThat(expected.message) + .isEqualTo("Unexpected char 0xe9 at 4 in header1 value: valué1") + } + } + + @Test fun mapFactoryRejectsUnicodeInHeaderName() { + try { + mapOf("héader1" to "value1").toHeaders() + fail("Should have complained about invalid value") + } catch (expected: IllegalArgumentException) { + assertThat(expected.message) + .isEqualTo("Unexpected char 0xe9 at 1 in header name: héader1") + } + } + + @Test fun mapFactoryRejectsUnicodeInHeaderValue() { + try { + mapOf("header1" to "valué1").toHeaders() + fail("Should have complained about invalid value") + } catch (expected: IllegalArgumentException) { + assertThat(expected.message) + .isEqualTo("Unexpected char 0xe9 at 4 in header1 value: valué1") + } + } + + @Test fun sensitiveHeadersNotIncludedInExceptions() { + try { + Headers.Builder().add("Authorization", "valué1") + fail("Should have complained about invalid name") + } catch (expected: IllegalArgumentException) { + assertThat(expected.message) + .isEqualTo("Unexpected char 0xe9 at 4 in Authorization value") + } + try { + Headers.Builder().add("Cookie", "valué1") + fail("Should have complained about invalid name") + } catch (expected: IllegalArgumentException) { + assertThat(expected.message) + .isEqualTo("Unexpected char 0xe9 at 4 in Cookie value") + } + try { + Headers.Builder().add("Proxy-Authorization", "valué1") + fail("Should have complained about invalid name") + } catch (expected: IllegalArgumentException) { + assertThat(expected.message) + .isEqualTo("Unexpected char 0xe9 at 4 in Proxy-Authorization value") + } + try { + Headers.Builder().add("Set-Cookie", "valué1") + fail("Should have complained about invalid name") + } catch (expected: IllegalArgumentException) { + assertThat(expected.message) + .isEqualTo("Unexpected char 0xe9 at 4 in Set-Cookie value") + } + } + + @Test fun headersEquals() { + val headers1 = Headers.Builder() + .add("Connection", "close") + .add("Transfer-Encoding", "chunked") + .build() + val headers2 = Headers.Builder() + .add("Connection", "close") + .add("Transfer-Encoding", "chunked") + .build() + assertThat(headers2).isEqualTo(headers1) + assertThat(headers2.hashCode()).isEqualTo(headers1.hashCode()) + } + + @Test fun headersNotEquals() { + val headers1 = Headers.Builder() + .add("Connection", "close") + .add("Transfer-Encoding", "chunked") + .build() + val headers2 = Headers.Builder() + .add("Connection", "keep-alive") + .add("Transfer-Encoding", "chunked") + .build() + assertThat(headers2).isNotEqualTo(headers1) + assertThat(headers2.hashCode()).isNotEqualTo(headers1.hashCode().toLong()) + } + + @Test fun headersToString() { + val headers = Headers.Builder() + .add("A", "a") + .add("B", "bb") + .build() + assertThat(headers.toString()).isEqualTo("A: a\nB: bb\n") + } + + @Test fun headersToStringRedactsSensitiveHeaders() { + val headers = Headers.Builder() + .add("content-length", "99") + .add("authorization", "peanutbutter") + .add("proxy-authorization", "chocolate") + .add("cookie", "drink=coffee") + .add("set-cookie", "accessory=sugar") + .add("user-agent", "OkHttp") + .build() + assertThat(headers.toString()).isEqualTo( + """ + |content-length: 99 + |authorization: ██ + |proxy-authorization: ██ + |cookie: ██ + |set-cookie: ██ + |user-agent: OkHttp + |""".trimMargin() + ) + } + + @Test fun headersAddAll() { + val sourceHeaders = Headers.Builder() + .add("A", "aa") + .add("a", "aa") + .add("B", "bb") + .build() + val headers = Headers.Builder() + .add("A", "a") + .addAll(sourceHeaders) + .add("C", "c") + .build() + assertThat(headers.toString()).isEqualTo("A: a\nA: aa\na: aa\nB: bb\nC: c\n") + } +} diff --git a/okhttp/src/jsTest/kotlin/okhttp3/HeadersJsTest.kt b/okhttp/src/jsTest/kotlin/okhttp3/HeadersJsTest.kt new file mode 100644 index 000000000..151fcfe54 --- /dev/null +++ b/okhttp/src/jsTest/kotlin/okhttp3/HeadersJsTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2022 Square, Inc. + * + * 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 + +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test + +class HeadersJsTest { + @Test + fun nameIndexesAreStrict() { + val headers = Headers.headersOf("a", "b", "c", "d") + assertThat(headers.name(-1)).isEqualTo(undefined) + assertThat(headers.name(0)).isEqualTo("a") + assertThat(headers.name(1)).isEqualTo("c") + assertThat(headers.name(2)).isEqualTo(undefined) + } + + @Test + fun valueIndexesAreStrict() { + val headers = Headers.headersOf("a", "b", "c", "d") + assertThat(headers.value(-1)).isEqualTo(undefined) + assertThat(headers.value(0)).isEqualTo("b") + assertThat(headers.value(1)).isEqualTo("d") + assertThat(headers.value(2)).isEqualTo(undefined) + } +} diff --git a/okhttp/src/jvmMain/kotlin/okhttp3/Headers.kt b/okhttp/src/jvmMain/kotlin/okhttp3/Headers.kt index 036dff2d0..ab5b09dea 100644 --- a/okhttp/src/jvmMain/kotlin/okhttp3/Headers.kt +++ b/okhttp/src/jvmMain/kotlin/okhttp3/Headers.kt @@ -24,35 +24,34 @@ import java.util.Date import java.util.Locale import java.util.TreeMap import java.util.TreeSet -import okhttp3.Headers.Builder -import okhttp3.internal.format +import okhttp3.internal.commonAdd +import okhttp3.internal.commonAddAll +import okhttp3.internal.commonAddLenient +import okhttp3.internal.commonBuild +import okhttp3.internal.commonEquals +import okhttp3.internal.commonGet +import okhttp3.internal.commonHashCode +import okhttp3.internal.commonHeadersGet +import okhttp3.internal.commonHeadersOf +import okhttp3.internal.commonIterator +import okhttp3.internal.commonName +import okhttp3.internal.commonNewBuilder +import okhttp3.internal.commonRemoveAll +import okhttp3.internal.commonSet +import okhttp3.internal.commonToHeaders +import okhttp3.internal.commonToString +import okhttp3.internal.commonValue +import okhttp3.internal.commonValues +import okhttp3.internal.headersCheckName import okhttp3.internal.http.toHttpDateOrNull import okhttp3.internal.http.toHttpDateString -import okhttp3.internal.isSensitiveHeader import org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement -/** - * The header fields of a single HTTP message. Values are uninterpreted strings; use `Request` and - * `Response` for interpreted headers. This class maintains the order of the header fields within - * the HTTP message. - * - * This class tracks header values line-by-line. A field with multiple comma- separated values on - * the same line will be treated as a field with a single value by this class. It is the caller's - * responsibility to detect and split on commas if their field permits multiple values. This - * simplifies use of single-valued fields whose values routinely contain commas, such as cookies or - * dates. - * - * This class trims whitespace from values. It never returns values with leading or trailing - * whitespace. - * - * Instances of this class are immutable. Use [Builder] to create instances. - */ @Suppress("NAME_SHADOWING") -class Headers private constructor( - private val namesAndValues: Array +actual class Headers internal actual constructor( + internal actual val namesAndValues: Array ) : Iterable> { - /** Returns the last value corresponding to the specified field, or null. */ - operator fun get(name: String): String? = get(namesAndValues, name) + actual operator fun get(name: String): String? = commonHeadersGet(namesAndValues, name) /** * Returns the last value corresponding to the specified field parsed as an HTTP date, or null if @@ -70,8 +69,7 @@ class Headers private constructor( return value?.toInstant() } - /** Returns the number of field values. */ - @get:JvmName("size") val size: Int + @get:JvmName("size") actual val size: Int get() = namesAndValues.size / 2 @JvmName("-deprecated_size") @@ -81,14 +79,11 @@ class Headers private constructor( level = DeprecationLevel.ERROR) fun size(): Int = size - /** Returns the field at `position`. */ - fun name(index: Int): String = namesAndValues[index * 2] + actual fun name(index: Int): String = commonName(index) - /** Returns the value at `index`. */ - fun value(index: Int): String = namesAndValues[index * 2 + 1] + actual fun value(index: Int): String = commonValue(index) - /** Returns an immutable case-insensitive set of header names. */ - fun names(): Set { + actual fun names(): Set { val result = TreeSet(String.CASE_INSENSITIVE_ORDER) for (i in 0 until size) { result.add(name(i)) @@ -96,21 +91,7 @@ class Headers private constructor( return Collections.unmodifiableSet(result) } - /** Returns an immutable list of the header values for `name`. */ - fun values(name: String): List { - var result: MutableList? = null - for (i in 0 until size) { - if (name.equals(name(i), ignoreCase = true)) { - if (result == null) result = ArrayList(2) - result.add(value(i)) - } - } - return if (result != null) { - Collections.unmodifiableList(result) - } else { - emptyList() - } - } + actual fun values(name: String): List = commonValues(name) /** * Returns the number of bytes required to encode these headers using HTTP/1.1. This is also the @@ -129,83 +110,15 @@ class Headers private constructor( return result } - override operator fun iterator(): Iterator> { - return Array(size) { name(it) to value(it) }.iterator() - } + actual override operator fun iterator(): Iterator> = commonIterator() - fun newBuilder(): Builder { - val result = Builder() - result.namesAndValues += namesAndValues - return result - } + actual fun newBuilder(): Builder = commonNewBuilder() - /** - * Returns true if `other` is a `Headers` object with the same headers, with the same casing, in - * the same order. Note that two headers instances may be *semantically* equal but not equal - * according to this method. In particular, none of the following sets of headers are equal - * according to this method: - * - * 1. Original - * ``` - * Content-Type: text/html - * Content-Length: 50 - * ``` - * - * 2. Different order - * - * ``` - * Content-Length: 50 - * Content-Type: text/html - * ``` - * - * 3. Different case - * - * ``` - * content-type: text/html - * content-length: 50 - * ``` - * - * 4. Different values - * - * ``` - * Content-Type: text/html - * Content-Length: 050 - * ``` - * - * Applications that require semantically equal headers should convert them into a canonical form - * before comparing them for equality. - */ - override fun equals(other: Any?): Boolean { - return other is Headers && namesAndValues.contentEquals(other.namesAndValues) - } + actual override fun equals(other: Any?): Boolean = commonEquals(other) - override fun hashCode(): Int = namesAndValues.contentHashCode() + override fun hashCode(): Int = commonHashCode() - /** - * Returns header names and values. The names and values are separated by `: ` and each pair is - * followed by a newline character `\n`. - * - * Since OkHttp 5 this redacts these sensitive headers: - * - * * `Authorization` - * * `Cookie` - * * `Proxy-Authorization` - * * `Set-Cookie` - * - * To get all headers as a human-readable string use `toMultimap().toString()`. - */ - override fun toString(): String { - return buildString { - for (i in 0 until size) { - val name = name(i) - val value = value(i) - append(name) - append(": ") - append(if (isSensitiveHeader(name)) "██" else value) - append("\n") - } - } - } + actual override fun toString(): String = commonToString() fun toMultimap(): Map> { val result = TreeMap>(String.CASE_INSENSITIVE_ORDER) @@ -221,8 +134,8 @@ class Headers private constructor( return result } - class Builder { - internal val namesAndValues: MutableList = ArrayList(20) + actual class Builder { + internal actual val namesAndValues: MutableList = ArrayList(20) /** * Add a header line without any validation. Only appropriate for headers from the remote peer @@ -253,32 +166,18 @@ class Headers private constructor( add(line.substring(0, index).trim(), line.substring(index + 1)) } - /** - * Add a header with the specified name and value. Does validation of header names and values. - */ - fun add(name: String, value: String) = apply { - checkName(name) - checkValue(value, name) - addLenient(name, value) - } + actual fun add(name: String, value: String) = commonAdd(name, value) /** * Add a header with the specified name and value. Does validation of header names, allowing * non-ASCII values. */ fun addUnsafeNonAscii(name: String, value: String) = apply { - checkName(name) + headersCheckName(name) addLenient(name, value) } - /** - * Adds all headers from an existing collection. - */ - fun addAll(headers: Headers) = apply { - for (i in 0 until headers.size) { - addLenient(headers.name(i), headers.value(i)) - } - } + actual fun addAll(headers: Headers) = commonAddAll(headers) /** * Add a header with the specified name and formatted date. Does validation of header names and @@ -318,83 +217,26 @@ class Headers private constructor( * Add a field with the specified value without any validation. Only appropriate for headers * from the remote peer or cache. */ - internal fun addLenient(name: String, value: String) = apply { - namesAndValues.add(name) - namesAndValues.add(value.trim()) - } + internal fun addLenient(name: String, value: String) = commonAddLenient(name, value) - fun removeAll(name: String) = apply { - var i = 0 - while (i < namesAndValues.size) { - if (name.equals(namesAndValues[i], ignoreCase = true)) { - namesAndValues.removeAt(i) // name - namesAndValues.removeAt(i) // value - i -= 2 - } - i += 2 - } - } + actual fun removeAll(name: String) = commonRemoveAll(name) /** * Set a field with the specified value. If the field is not found, it is added. If the field is * found, the existing values are replaced. */ - operator fun set(name: String, value: String) = apply { - checkName(name) - checkValue(value, name) - removeAll(name) - addLenient(name, value) - } + actual operator fun set(name: String, value: String) = commonSet(name, value) /** Equivalent to `build().get(name)`, but potentially faster. */ - operator fun get(name: String): String? { - for (i in namesAndValues.size - 2 downTo 0 step 2) { - if (name.equals(namesAndValues[i], ignoreCase = true)) { - return namesAndValues[i + 1] - } - } - return null - } + actual operator fun get(name: String): String? = commonGet(name) - fun build(): Headers = Headers(namesAndValues.toTypedArray()) + actual fun build(): Headers = commonBuild() } - companion object { - private fun get(namesAndValues: Array, name: String): String? { - for (i in namesAndValues.size - 2 downTo 0 step 2) { - if (name.equals(namesAndValues[i], ignoreCase = true)) { - return namesAndValues[i + 1] - } - } - return null - } - - /** - * Returns headers for the alternating header names and values. There must be an even number of - * arguments, and they must alternate between header names and values. - */ + actual companion object { @JvmStatic @JvmName("of") - fun headersOf(vararg namesAndValues: String): Headers { - require(namesAndValues.size % 2 == 0) { "Expected alternating header names and values" } - - // Make a defensive copy and clean it up. - val namesAndValues: Array = namesAndValues.clone() as Array - for (i in namesAndValues.indices) { - require(namesAndValues[i] != null) { "Headers cannot be null" } - namesAndValues[i] = namesAndValues[i].trim() - } - - // Check for malformed headers. - for (i in namesAndValues.indices step 2) { - val name = namesAndValues[i] - val value = namesAndValues[i + 1] - checkName(name) - checkValue(value, name) - } - - return Headers(namesAndValues) - } + actual fun headersOf(vararg namesAndValues: String): Headers = commonHeadersOf(*namesAndValues) @JvmName("-deprecated_of") @Deprecated( @@ -405,25 +247,9 @@ class Headers private constructor( return headersOf(*namesAndValues) } - /** Returns headers for the header names and values in the [Map]. */ @JvmStatic @JvmName("of") - fun Map.toHeaders(): Headers { - // Make a defensive copy and clean it up. - val namesAndValues = arrayOfNulls(size * 2) - var i = 0 - for ((k, v) in this) { - val name = k.trim() - val value = v.trim() - checkName(name) - checkValue(value, name) - namesAndValues[i] = name - namesAndValues[i + 1] = value - i += 2 - } - - return Headers(namesAndValues as Array) - } + actual fun Map.toHeaders(): Headers = commonToHeaders() @JvmName("-deprecated_of") @Deprecated( @@ -433,25 +259,5 @@ class Headers private constructor( fun of(headers: Map): Headers { return headers.toHeaders() } - - private fun checkName(name: String) { - require(name.isNotEmpty()) { "name is empty" } - for (i in name.indices) { - val c = name[i] - require(c in '\u0021'..'\u007e') { - format("Unexpected char %#04x at %d in header name: %s", c.code, i, name) - } - } - } - - private fun checkValue(value: String, name: String) { - for (i in value.indices) { - val c = value[i] - require(c == '\t' || c in '\u0020'..'\u007e') { - format("Unexpected char %#04x at %d in %s value", c.code, i, name) + - (if (isSensitiveHeader(name)) "" else ": $value") - } - } - } } } diff --git a/okhttp/src/jvmTest/java/okhttp3/HeadersTest.kt b/okhttp/src/jvmTest/java/okhttp3/HeadersChallengesTest.kt similarity index 50% rename from okhttp/src/jvmTest/java/okhttp3/HeadersTest.kt rename to okhttp/src/jvmTest/java/okhttp3/HeadersChallengesTest.kt index 77b5b553d..ebab079c6 100644 --- a/okhttp/src/jvmTest/java/okhttp3/HeadersTest.kt +++ b/okhttp/src/jvmTest/java/okhttp3/HeadersChallengesTest.kt @@ -15,466 +15,19 @@ */ package okhttp3 -import java.time.Instant -import java.util.Date -import okhttp3.Headers.Companion.headersOf -import okhttp3.Headers.Companion.toHeaders -import okhttp3.TestUtil.headerEntries -import okhttp3.internal.EMPTY_HEADERS import okhttp3.internal.http.parseChallenges -import okhttp3.internal.http2.Http2ExchangeCodec.Companion.http2HeadersList -import okhttp3.internal.http2.Http2ExchangeCodec.Companion.readHttp2HeadersList import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Assertions.fail import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test -class HeadersTest { - @Test fun readNameValueBlockDropsForbiddenHeadersHttp2() { - val headerBlock = headersOf( - ":status", "200 OK", - ":version", "HTTP/1.1", - "connection", "close" - ) - val request = Request.Builder().url("http://square.com/").build() - val response = readHttp2HeadersList(headerBlock, Protocol.HTTP_2).request(request).build() - val headers = response.headers - assertThat(headers.size).isEqualTo(1) - assertThat(headers.name(0)).isEqualTo(":version") - assertThat(headers.value(0)).isEqualTo("HTTP/1.1") - } - - @Test fun http2HeadersListDropsForbiddenHeadersHttp2() { - val request = Request.Builder() - .url("http://square.com/") - .header("Connection", "upgrade") - .header("Upgrade", "websocket") - .header("Host", "square.com") - .header("TE", "gzip") - .build() - val expected = headerEntries( - ":method", "GET", - ":path", "/", - ":authority", "square.com", - ":scheme", "http" - ) - assertThat(http2HeadersList(request)).isEqualTo(expected) - } - - @Test fun http2HeadersListDontDropTeIfTrailersHttp2() { - val request = Request.Builder() - .url("http://square.com/") - .header("TE", "trailers") - .build() - val expected = headerEntries( - ":method", "GET", - ":path", "/", - ":scheme", "http", - "te", "trailers" - ) - assertThat(http2HeadersList(request)).isEqualTo(expected) - } - - @Test fun ofTrims() { - val headers = headersOf("\t User-Agent \n", " \r OkHttp ") - assertThat(headers.name(0)).isEqualTo("User-Agent") - assertThat(headers.value(0)).isEqualTo("OkHttp") - } - - @Test fun addParsing() { - val headers = Headers.Builder() - .add("foo: bar") - .add(" foo: baz") // Name leading whitespace is trimmed. - .add("foo : bak") // Name trailing whitespace is trimmed. - .add("\tkey\t:\tvalue\t") // '\t' also counts as whitespace - .add("ping: pong ") // Value whitespace is trimmed. - .add("kit:kat") // Space after colon is not required. - .build() - assertThat(headers.values("foo")).containsExactly("bar", "baz", "bak") - assertThat(headers.values("key")).containsExactly("value") - assertThat(headers.values("ping")).containsExactly("pong") - assertThat(headers.values("kit")).containsExactly("kat") - } - - @Test fun addThrowsOnEmptyName() { - try { - Headers.Builder().add(": bar") - fail() - } catch (expected: IllegalArgumentException) { - } - try { - Headers.Builder().add(" : bar") - fail() - } catch (expected: IllegalArgumentException) { - } - } - - @Test fun addThrowsOnNoColon() { - try { - Headers.Builder().add("foo bar") - fail() - } catch (expected: IllegalArgumentException) { - } - } - - @Test fun addThrowsOnMultiColon() { - try { - Headers.Builder().add(":status: 200 OK") - fail() - } catch (expected: IllegalArgumentException) { - } - } - - @Test fun addUnsafeNonAsciiRejectsUnicodeName() { - try { - Headers.Builder() - .addUnsafeNonAscii("héader1", "value1") - .build() - fail("Should have complained about invalid value") - } catch (expected: IllegalArgumentException) { - assertThat(expected.message).isEqualTo("Unexpected char 0xe9 at 1 in header name: héader1") - } - } - - @Test fun addUnsafeNonAsciiAcceptsUnicodeValue() { - val headers = Headers.Builder() - .addUnsafeNonAscii("header1", "valué1") - .build() - assertThat(headers.toString()).isEqualTo("header1: valué1\n") - } - - @Test fun ofThrowsOddNumberOfHeaders() { - try { - headersOf("User-Agent", "OkHttp", "Content-Length") - fail() - } catch (expected: IllegalArgumentException) { - } - } - - @Test fun ofThrowsOnEmptyName() { - try { - headersOf("", "OkHttp") - fail() - } catch (expected: IllegalArgumentException) { - } - } - - @Test fun ofAcceptsEmptyValue() { - val headers = headersOf("User-Agent", "") - assertThat(headers.value(0)).isEqualTo("") - } - - @Test fun ofMakesDefensiveCopy() { - val namesAndValues = arrayOf( - "User-Agent", - "OkHttp" - ) - val headers = headersOf(*namesAndValues) - namesAndValues[1] = "Chrome" - assertThat(headers.value(0)).isEqualTo("OkHttp") - } - - @Test fun ofRejectsNullChar() { - try { - headersOf("User-Agent", "Square\u0000OkHttp") - fail() - } catch (expected: IllegalArgumentException) { - } - } - - @Test fun ofMapThrowsOnNull() { - try { - (mapOf("User-Agent" to null) as Map).toHeaders() - fail() - } catch (expected: NullPointerException) { - } - } - - @Test fun ofMapThrowsOnEmptyName() { - try { - mapOf("" to "OkHttp").toHeaders() - fail() - } catch (expected: IllegalArgumentException) { - } - } - - @Test fun ofMapThrowsOnBlankName() { - try { - mapOf(" " to "OkHttp").toHeaders() - fail() - } catch (expected: IllegalArgumentException) { - } - } - - @Test fun ofMapAcceptsEmptyValue() { - val headers = mapOf("User-Agent" to "").toHeaders() - assertThat(headers.value(0)).isEqualTo("") - } - - @Test fun ofMapTrimsKey() { - val headers = mapOf(" User-Agent " to "OkHttp").toHeaders() - assertThat(headers.name(0)).isEqualTo("User-Agent") - } - - @Test fun ofMapTrimsValue() { - val headers = mapOf("User-Agent" to " OkHttp ").toHeaders() - assertThat(headers.value(0)).isEqualTo("OkHttp") - } - - @Test fun ofMapMakesDefensiveCopy() { - val namesAndValues = mutableMapOf() - namesAndValues["User-Agent"] = "OkHttp" - val headers = namesAndValues.toHeaders() - namesAndValues["User-Agent"] = "Chrome" - assertThat(headers.value(0)).isEqualTo("OkHttp") - } - - @Test fun ofMapRejectsNullCharInName() { - try { - mapOf("User-\u0000Agent" to "OkHttp").toHeaders() - fail() - } catch (expected: IllegalArgumentException) { - } - } - - @Test fun ofMapRejectsNullCharInValue() { - try { - mapOf("User-Agent" to "Square\u0000OkHttp").toHeaders() - fail() - } catch (expected: IllegalArgumentException) { - } - } - - @Test fun toMultimapGroupsHeaders() { - val headers = headersOf( - "cache-control", "no-cache", - "cache-control", "no-store", - "user-agent", "OkHttp" - ) - val headerMap = headers.toMultimap() - assertThat(headerMap["cache-control"]!!.size).isEqualTo(2) - assertThat(headerMap["user-agent"]!!.size).isEqualTo(1) - } - - @Test fun toMultimapUsesCanonicalCase() { - val headers = headersOf( - "cache-control", "no-store", - "Cache-Control", "no-cache", - "User-Agent", "OkHttp" - ) - val headerMap = headers.toMultimap() - assertThat(headerMap["cache-control"]!!.size).isEqualTo(2) - assertThat(headerMap["user-agent"]!!.size).isEqualTo(1) - } - - @Test fun toMultimapAllowsCaseInsensitiveGet() { - val headers = headersOf( - "cache-control", "no-store", - "Cache-Control", "no-cache" - ) - val headerMap = headers.toMultimap() - assertThat(headerMap["cache-control"]!!.size).isEqualTo(2) - assertThat(headerMap["Cache-Control"]!!.size).isEqualTo(2) - } - - @Test fun nameIndexesAreStrict() { - val headers = headersOf("a", "b", "c", "d") - try { - headers.name(-1) - fail() - } catch (expected: IndexOutOfBoundsException) { - } - assertThat(headers.name(0)).isEqualTo("a") - assertThat(headers.name(1)).isEqualTo("c") - try { - headers.name(2) - fail() - } catch (expected: IndexOutOfBoundsException) { - } - } - - @Test fun valueIndexesAreStrict() { - val headers = headersOf("a", "b", "c", "d") - try { - headers.value(-1) - fail() - } catch (expected: IndexOutOfBoundsException) { - } - assertThat(headers.value(0)).isEqualTo("b") - assertThat(headers.value(1)).isEqualTo("d") - try { - headers.value(2) - fail() - } catch (expected: IndexOutOfBoundsException) { - } - } - - @Test fun builderRejectsUnicodeInHeaderName() { - try { - Headers.Builder().add("héader1", "value1") - fail("Should have complained about invalid name") - } catch (expected: IllegalArgumentException) { - assertThat(expected.message) - .isEqualTo("Unexpected char 0xe9 at 1 in header name: héader1") - } - } - - @Test fun builderRejectsUnicodeInHeaderValue() { - try { - Headers.Builder().add("header1", "valué1") - fail("Should have complained about invalid value") - } catch (expected: IllegalArgumentException) { - assertThat(expected.message) - .isEqualTo("Unexpected char 0xe9 at 4 in header1 value: valué1") - } - } - - @Test fun varargFactoryRejectsUnicodeInHeaderName() { - try { - headersOf("héader1", "value1") - fail("Should have complained about invalid value") - } catch (expected: IllegalArgumentException) { - assertThat(expected.message) - .isEqualTo("Unexpected char 0xe9 at 1 in header name: héader1") - } - } - - @Test fun varargFactoryRejectsUnicodeInHeaderValue() { - try { - headersOf("header1", "valué1") - fail("Should have complained about invalid value") - } catch (expected: IllegalArgumentException) { - assertThat(expected.message) - .isEqualTo("Unexpected char 0xe9 at 4 in header1 value: valué1") - } - } - - @Test fun mapFactoryRejectsUnicodeInHeaderName() { - try { - mapOf("héader1" to "value1").toHeaders() - fail("Should have complained about invalid value") - } catch (expected: IllegalArgumentException) { - assertThat(expected.message) - .isEqualTo("Unexpected char 0xe9 at 1 in header name: héader1") - } - } - - @Test fun mapFactoryRejectsUnicodeInHeaderValue() { - try { - mapOf("header1" to "valué1").toHeaders() - fail("Should have complained about invalid value") - } catch (expected: IllegalArgumentException) { - assertThat(expected.message) - .isEqualTo("Unexpected char 0xe9 at 4 in header1 value: valué1") - } - } - - @Test fun sensitiveHeadersNotIncludedInExceptions() { - try { - Headers.Builder().add("Authorization", "valué1") - fail("Should have complained about invalid name") - } catch (expected: IllegalArgumentException) { - assertThat(expected.message) - .isEqualTo("Unexpected char 0xe9 at 4 in Authorization value") - } - try { - Headers.Builder().add("Cookie", "valué1") - fail("Should have complained about invalid name") - } catch (expected: IllegalArgumentException) { - assertThat(expected.message) - .isEqualTo("Unexpected char 0xe9 at 4 in Cookie value") - } - try { - Headers.Builder().add("Proxy-Authorization", "valué1") - fail("Should have complained about invalid name") - } catch (expected: IllegalArgumentException) { - assertThat(expected.message) - .isEqualTo("Unexpected char 0xe9 at 4 in Proxy-Authorization value") - } - try { - Headers.Builder().add("Set-Cookie", "valué1") - fail("Should have complained about invalid name") - } catch (expected: IllegalArgumentException) { - assertThat(expected.message) - .isEqualTo("Unexpected char 0xe9 at 4 in Set-Cookie value") - } - } - - @Test fun headersEquals() { - val headers1 = Headers.Builder() - .add("Connection", "close") - .add("Transfer-Encoding", "chunked") - .build() - val headers2 = Headers.Builder() - .add("Connection", "close") - .add("Transfer-Encoding", "chunked") - .build() - assertThat(headers2).isEqualTo(headers1) - assertThat(headers2.hashCode()).isEqualTo(headers1.hashCode()) - } - - @Test fun headersNotEquals() { - val headers1 = Headers.Builder() - .add("Connection", "close") - .add("Transfer-Encoding", "chunked") - .build() - val headers2 = Headers.Builder() - .add("Connection", "keep-alive") - .add("Transfer-Encoding", "chunked") - .build() - assertThat(headers2).isNotEqualTo(headers1) - assertThat(headers2.hashCode()).isNotEqualTo(headers1.hashCode().toLong()) - } - - @Test fun headersToString() { - val headers = Headers.Builder() - .add("A", "a") - .add("B", "bb") - .build() - assertThat(headers.toString()).isEqualTo("A: a\nB: bb\n") - } - - @Test fun headersToStringRedactsSensitiveHeaders() { - val headers = Headers.Builder() - .add("content-length", "99") - .add("authorization", "peanutbutter") - .add("proxy-authorization", "chocolate") - .add("cookie", "drink=coffee") - .add("set-cookie", "accessory=sugar") - .add("user-agent", "OkHttp") - .build() - assertThat(headers.toString()).isEqualTo( - """ - |content-length: 99 - |authorization: ██ - |proxy-authorization: ██ - |cookie: ██ - |set-cookie: ██ - |user-agent: OkHttp - |""".trimMargin() - ) - } - - @Test fun headersAddAll() { - val sourceHeaders = Headers.Builder() - .add("A", "aa") - .add("a", "aa") - .add("B", "bb") - .build() - val headers = Headers.Builder() - .add("A", "a") - .addAll(sourceHeaders) - .add("C", "c") - .build() - assertThat(headers.toString()).isEqualTo("A: a\nA: aa\na: aa\nB: bb\nC: c\n") - } +class HeadersChallengesTest { /** See https://github.com/square/okhttp/issues/2780. */ @Test fun testDigestChallengeWithStrictRfc2617Header() { val headers = Headers.Builder() .add( "WWW-Authenticate", "Digest realm=\"myrealm\", nonce=\"fjalskdflwejrlaskdfjlaskdjflaks" - + "jdflkasdf\", qop=\"auth\", stale=\"FALSE\"" + + "jdflkasdf\", qop=\"auth\", stale=\"FALSE\"" ) .build() val challenges = headers.parseChallenges("WWW-Authenticate") @@ -493,7 +46,7 @@ class HeadersTest { val headers = Headers.Builder() .add( "WWW-Authenticate", "Digest qop=\"auth\", realm=\"myrealm\", nonce=\"fjalskdflwejrlask" - + "dfjlaskdjflaksjdflkasdf\", stale=\"FALSE\"" + + "dfjlaskdjflaksjdflkasdf\", stale=\"FALSE\"" ) .build() val challenges = headers.parseChallenges("WWW-Authenticate") @@ -512,7 +65,7 @@ class HeadersTest { val headers = Headers.Builder() .add( "WWW-Authenticate", "Digest qop=\"auth\", nonce=\"fjalskdflwejrlaskdfjlaskdjflaksjdflk" - + "asdf\", realm=\"myrealm\", stale=\"FALSE\"" + + "asdf\", realm=\"myrealm\", stale=\"FALSE\"" ) .build() val challenges = headers.parseChallenges("WWW-Authenticate") @@ -531,7 +84,7 @@ class HeadersTest { val headers = Headers.Builder() .add( "WWW-Authenticate", "Digest qop=\"auth\", underrealm=\"myrealm\", nonce=\"fjalskdflwej" - + "rlaskdfjlaskdjflaksjdflkasdf\", stale=\"FALSE\"" + + "rlaskdfjlaskdjflaksjdflkasdf\", stale=\"FALSE\"" ) .build() val challenges = headers.parseChallenges("WWW-Authenticate") @@ -550,7 +103,7 @@ class HeadersTest { val headers = Headers.Builder() .add( "WWW-Authenticate", "Digest qop=\"auth\", realm=\"myrealm\", nonce=\"fjalskdflwejrl" - + "askdfjlaskdjflaksjdflkasdf\", stale=\"FALSE\"" + + "askdfjlaskdjflaksjdflkasdf\", stale=\"FALSE\"" ) .build() val challenges = headers.parseChallenges("WWW-Authenticate") @@ -569,7 +122,7 @@ class HeadersTest { val headers = Headers.Builder() .add( "WWW-Authenticate", "Digest realm=\"myrealm\", nonce=\"fjalskdflwejrlaskdfjlaskdjfl" - + "aksjdflkasdf\", qop=\"auth\", stale=\"FALSE\"" + + "aksjdflkasdf\", qop=\"auth\", stale=\"FALSE\"" ) .build() val challenges = headers.parseChallenges("WWW-Authenticate") @@ -588,7 +141,7 @@ class HeadersTest { val headers = Headers.Builder() .add( "WWW-Authenticate", "DiGeSt qop=\"auth\", rEaLm=\"myrealm\", nonce=\"fjalskdflwejrlask" - + "dfjlaskdjflaksjdflkasdf\", stale=\"FALSE\"" + + "dfjlaskdjflaksjdflkasdf\", stale=\"FALSE\"" ) .build() val challenges = headers.parseChallenges("WWW-Authenticate") @@ -608,7 +161,7 @@ class HeadersTest { val headers = Headers.Builder() .add( "WWW-Authenticate", "DIgEsT rEaLm=\"myrealm\", nonce=\"fjalskdflwejrlaskdfjlaskdjflaks" - + "jdflkasdf\", qop=\"auth\", stale=\"FALSE\"" + + "jdflkasdf\", qop=\"auth\", stale=\"FALSE\"" ) .build() val challenges = headers.parseChallenges("WWW-Authenticate") @@ -808,7 +361,7 @@ class HeadersTest { .build() assertThat(headers.parseChallenges("WWW-Authenticate")) .isEqualTo(listOf(Challenge("Other", mapOf(null to "abc=="))) - ) + ) } @Test fun token68AndAuthParams() { @@ -859,59 +412,4 @@ class HeadersTest { Challenge("Basic", mapOf("realm" to "myotherrealm")) ) } - - @Test fun byteCount() { - assertThat(EMPTY_HEADERS.byteCount()).isEqualTo(0L) - assertThat( - Headers.Builder() - .add("abc", "def") - .build() - .byteCount() - ).isEqualTo(10L) - assertThat( - Headers.Builder() - .add("abc", "def") - .add("ghi", "jkl") - .build() - .byteCount() - ).isEqualTo(20L) - } - - @Test fun addDate() { - val expected = Date(0L) - val headers = Headers.Builder() - .add("testDate", expected) - .build() - assertThat(headers["testDate"]).isEqualTo("Thu, 01 Jan 1970 00:00:00 GMT") - assertThat(headers.getDate("testDate")).isEqualTo(Date(0L)) - } - - @Test fun addInstant() { - val expected = Instant.ofEpochMilli(0L) - val headers = Headers.Builder() - .add("Test-Instant", expected) - .build() - assertThat(headers["Test-Instant"]).isEqualTo("Thu, 01 Jan 1970 00:00:00 GMT") - assertThat(headers.getInstant("Test-Instant")).isEqualTo(expected) - } - - @Test fun setDate() { - val expected = Date(1000) - val headers = Headers.Builder() - .add("testDate", Date(0L)) - .set("testDate", expected) - .build() - assertThat(headers["testDate"]).isEqualTo("Thu, 01 Jan 1970 00:00:01 GMT") - assertThat(headers.getDate("testDate")).isEqualTo(expected) - } - - @Test fun setInstant() { - val expected = Instant.ofEpochMilli(1000L) - val headers = Headers.Builder() - .add("Test-Instant", Instant.ofEpochMilli(0L)) - .set("Test-Instant", expected) - .build() - assertThat(headers["Test-Instant"]).isEqualTo("Thu, 01 Jan 1970 00:00:01 GMT") - assertThat(headers.getInstant("Test-Instant")).isEqualTo(expected) - } } diff --git a/okhttp/src/jvmTest/java/okhttp3/HeadersJvmTest.kt b/okhttp/src/jvmTest/java/okhttp3/HeadersJvmTest.kt new file mode 100644 index 000000000..58df02cc8 --- /dev/null +++ b/okhttp/src/jvmTest/java/okhttp3/HeadersJvmTest.kt @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2012 Square, Inc. + * + * 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 + +import java.time.Instant +import java.util.* +import kotlin.test.fail +import okhttp3.Headers.Companion.toHeaders +import okhttp3.internal.EMPTY_HEADERS +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class HeadersJvmTest { + + @Test fun byteCount() { + assertThat(EMPTY_HEADERS.byteCount()).isEqualTo(0L) + assertThat( + Headers.Builder() + .add("abc", "def") + .build() + .byteCount() + ).isEqualTo(10L) + assertThat( + Headers.Builder() + .add("abc", "def") + .add("ghi", "jkl") + .build() + .byteCount() + ).isEqualTo(20L) + } + + @Test fun addDate() { + val expected = Date(0L) + val headers = Headers.Builder() + .add("testDate", expected) + .build() + assertThat(headers["testDate"]).isEqualTo("Thu, 01 Jan 1970 00:00:00 GMT") + assertThat(headers.getDate("testDate")).isEqualTo(Date(0L)) + } + + @Test fun addInstant() { + val expected = Instant.ofEpochMilli(0L) + val headers = Headers.Builder() + .add("Test-Instant", expected) + .build() + assertThat(headers["Test-Instant"]).isEqualTo("Thu, 01 Jan 1970 00:00:00 GMT") + assertThat(headers.getInstant("Test-Instant")).isEqualTo(expected) + } + + @Test fun setDate() { + val expected = Date(1000) + val headers = Headers.Builder() + .add("testDate", Date(0L)) + .set("testDate", expected) + .build() + assertThat(headers["testDate"]).isEqualTo("Thu, 01 Jan 1970 00:00:01 GMT") + assertThat(headers.getDate("testDate")).isEqualTo(expected) + } + + @Test fun setInstant() { + val expected = Instant.ofEpochMilli(1000L) + val headers = Headers.Builder() + .add("Test-Instant", Instant.ofEpochMilli(0L)) + .set("Test-Instant", expected) + .build() + assertThat(headers["Test-Instant"]).isEqualTo("Thu, 01 Jan 1970 00:00:01 GMT") + assertThat(headers.getInstant("Test-Instant")).isEqualTo(expected) + } + + @Test fun addParsing() { + val headers = Headers.Builder() + .add("foo: bar") + .add(" foo: baz") // Name leading whitespace is trimmed. + .add("foo : bak") // Name trailing whitespace is trimmed. + .add("\tkey\t:\tvalue\t") // '\t' also counts as whitespace + .add("ping: pong ") // Value whitespace is trimmed. + .add("kit:kat") // Space after colon is not required. + .build() + assertThat(headers.values("foo")).containsExactly("bar", "baz", "bak") + assertThat(headers.values("key")).containsExactly("value") + assertThat(headers.values("ping")).containsExactly("pong") + assertThat(headers.values("kit")).containsExactly("kat") + } + + @Test fun addThrowsOnEmptyName() { + try { + Headers.Builder().add(": bar") + fail() + } catch (expected: IllegalArgumentException) { + } + try { + Headers.Builder().add(" : bar") + fail() + } catch (expected: IllegalArgumentException) { + } + } + + @Test fun addThrowsOnNoColon() { + try { + Headers.Builder().add("foo bar") + fail() + } catch (expected: IllegalArgumentException) { + } + } + + @Test fun addThrowsOnMultiColon() { + try { + Headers.Builder().add(":status: 200 OK") + fail() + } catch (expected: IllegalArgumentException) { + } + } + + @Test fun addUnsafeNonAsciiRejectsUnicodeName() { + try { + Headers.Builder() + .addUnsafeNonAscii("héader1", "value1") + .build() + fail("Should have complained about invalid value") + } catch (expected: IllegalArgumentException) { + assertThat(expected.message).isEqualTo("Unexpected char 0xe9 at 1 in header name: héader1") + } + } + + @Test fun addUnsafeNonAsciiAcceptsUnicodeValue() { + val headers = Headers.Builder() + .addUnsafeNonAscii("header1", "valué1") + .build() + assertThat(headers.toString()).isEqualTo("header1: valué1\n") + } + + // Fails on JS, ClassCastException: Illegal cast + @Test fun ofMapThrowsOnNull() { + try { + (mapOf("User-Agent" to null) as Map).toHeaders() + fail() + } catch (expected: NullPointerException) { + } + } + + @Test fun toMultimapGroupsHeaders() { + val headers = Headers.headersOf( + "cache-control", "no-cache", + "cache-control", "no-store", + "user-agent", "OkHttp" + ) + val headerMap = headers.toMultimap() + assertThat(headerMap["cache-control"]!!.size).isEqualTo(2) + assertThat(headerMap["user-agent"]!!.size).isEqualTo(1) + } + + @Test fun toMultimapUsesCanonicalCase() { + val headers = Headers.headersOf( + "cache-control", "no-store", + "Cache-Control", "no-cache", + "User-Agent", "OkHttp" + ) + val headerMap = headers.toMultimap() + assertThat(headerMap["cache-control"]!!.size).isEqualTo(2) + assertThat(headerMap["user-agent"]!!.size).isEqualTo(1) + } + + @Test fun toMultimapAllowsCaseInsensitiveGet() { + val headers = Headers.headersOf( + "cache-control", "no-store", + "Cache-Control", "no-cache" + ) + val headerMap = headers.toMultimap() + assertThat(headerMap["cache-control"]!!.size).isEqualTo(2) + assertThat(headerMap["Cache-Control"]!!.size).isEqualTo(2) + } + + @Test fun nameIndexesAreStrict() { + val headers = Headers.headersOf("a", "b", "c", "d") + try { + headers.name(-1) + fail() + } catch (expected: IndexOutOfBoundsException) { + } + assertThat(headers.name(0)).isEqualTo("a") + assertThat(headers.name(1)).isEqualTo("c") + try { + headers.name(2) + fail() + } catch (expected: IndexOutOfBoundsException) { + } + } + + @Test fun valueIndexesAreStrict() { + val headers = Headers.headersOf("a", "b", "c", "d") + try { + headers.value(-1) + fail() + } catch (expected: IndexOutOfBoundsException) { + } + assertThat(headers.value(0)).isEqualTo("b") + assertThat(headers.value(1)).isEqualTo("d") + try { + headers.value(2) + fail() + } catch (expected: IndexOutOfBoundsException) { + } + } +} diff --git a/okhttp/src/jvmTest/java/okhttp3/HeadersRequestTest.kt b/okhttp/src/jvmTest/java/okhttp3/HeadersRequestTest.kt new file mode 100644 index 000000000..664f75c32 --- /dev/null +++ b/okhttp/src/jvmTest/java/okhttp3/HeadersRequestTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2012 Square, Inc. + * + * 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 + +import okhttp3.Headers.Companion.headersOf +import okhttp3.TestUtil.headerEntries +import okhttp3.internal.http2.Http2ExchangeCodec.Companion.http2HeadersList +import okhttp3.internal.http2.Http2ExchangeCodec.Companion.readHttp2HeadersList +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class HeadersRequestTest { + @Test fun readNameValueBlockDropsForbiddenHeadersHttp2() { + val headerBlock = headersOf( + ":status", "200 OK", + ":version", "HTTP/1.1", + "connection", "close" + ) + val request = Request.Builder().url("http://square.com/").build() + val response = readHttp2HeadersList(headerBlock, Protocol.HTTP_2).request(request).build() + val headers = response.headers + assertThat(headers.size).isEqualTo(1) + assertThat(headers.name(0)).isEqualTo(":version") + assertThat(headers.value(0)).isEqualTo("HTTP/1.1") + } + + @Test fun http2HeadersListDropsForbiddenHeadersHttp2() { + val request = Request.Builder() + .url("http://square.com/") + .header("Connection", "upgrade") + .header("Upgrade", "websocket") + .header("Host", "square.com") + .header("TE", "gzip") + .build() + val expected = headerEntries( + ":method", "GET", + ":path", "/", + ":authority", "square.com", + ":scheme", "http" + ) + assertThat(http2HeadersList(request)).isEqualTo(expected) + } + + @Test fun http2HeadersListDontDropTeIfTrailersHttp2() { + val request = Request.Builder() + .url("http://square.com/") + .header("TE", "trailers") + .build() + val expected = headerEntries( + ":method", "GET", + ":path", "/", + ":scheme", "http", + "te", "trailers" + ) + assertThat(http2HeadersList(request)).isEqualTo(expected) + } +} diff --git a/okhttp/src/nonJvmMain/kotlin/okhttp3/Headers.kt b/okhttp/src/nonJvmMain/kotlin/okhttp3/Headers.kt new file mode 100644 index 000000000..996dfcd21 --- /dev/null +++ b/okhttp/src/nonJvmMain/kotlin/okhttp3/Headers.kt @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + +import okhttp3.internal.commonAdd +import okhttp3.internal.commonAddAll +import okhttp3.internal.commonAddLenient +import okhttp3.internal.commonBuild +import okhttp3.internal.commonEquals +import okhttp3.internal.commonGet +import okhttp3.internal.commonHashCode +import okhttp3.internal.commonHeadersGet +import okhttp3.internal.commonHeadersOf +import okhttp3.internal.commonIterator +import okhttp3.internal.commonName +import okhttp3.internal.commonNewBuilder +import okhttp3.internal.commonRemoveAll +import okhttp3.internal.commonSet +import okhttp3.internal.commonToHeaders +import okhttp3.internal.commonToString +import okhttp3.internal.commonValue +import okhttp3.internal.commonValues + +@Suppress("NAME_SHADOWING") +actual class Headers internal actual constructor( + internal actual val namesAndValues: Array +) : Iterable> { + actual operator fun get(name: String): String? = commonHeadersGet(namesAndValues, name) + + actual val size: Int + get() = namesAndValues.size / 2 + + actual fun name(index: Int): String = commonName(index) + + actual fun value(index: Int): String = commonValue(index) + + actual fun names(): Set { + return (0 until size step 2).map { name(it) }.distinctBy { it.lowercase() }.toSet() + } + + actual fun values(name: String): List = commonValues(name) + + /** + * Returns the number of bytes required to encode these headers using HTTP/1.1. This is also the + * approximate size of HTTP/2 headers before they are compressed with HPACK. This value is + * intended to be used as a metric: smaller headers are more efficient to encode and transmit. + */ + fun byteCount(): Long { + // Each header name has 2 bytes of overhead for ': ' and every header value has 2 bytes of + // overhead for '\r\n'. + var result = (namesAndValues.size * 2).toLong() + + for (i in 0 until namesAndValues.size) { + result += namesAndValues[i].length.toLong() + } + + return result + } + + actual override operator fun iterator(): Iterator> = commonIterator() + + actual fun newBuilder(): Builder = commonNewBuilder() + + actual override fun equals(other: Any?): Boolean = commonEquals(other) + + override fun hashCode(): Int = commonHashCode() + + actual override fun toString(): String = commonToString() + + actual class Builder { + internal actual val namesAndValues: MutableList = ArrayList(20) + + actual fun add(name: String, value: String) = commonAdd(name, value) + + actual fun addAll(headers: Headers) = commonAddAll(headers) + + actual fun removeAll(name: String) = commonRemoveAll(name) + + /** + * Set a field with the specified value. If the field is not found, it is added. If the field is + * found, the existing values are replaced. + */ + actual operator fun set(name: String, value: String) = commonSet(name, value) + + /** Equivalent to `build().get(name)`, but potentially faster. */ + actual operator fun get(name: String): String? = commonGet(name) + + actual fun build(): Headers = commonBuild() + } + + actual companion object { + actual fun headersOf(vararg namesAndValues: String): Headers = commonHeadersOf(*namesAndValues) + + actual fun Map.toHeaders(): Headers = commonToHeaders() + } +}