diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index 2e5a2325..2fa3b28f 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -56,6 +56,11 @@ defmodule Mint.HTTP1 do `Mint.TransportError` with reason `:closed` instead, since the server might have processed them. + * `:connection_upgraded` - when a pipelined request gets no response because the + server answered a previous request with a `101` response. The connection then + uses another protocol, and the server might have received the request as part + of it. + * `{:unexpected_data, data}` - when unexpected data is received from the server. * `:invalid_status_line` - when the HTTP/1 status line is invalid. @@ -865,7 +870,7 @@ defmodule Mint.HTTP1 do # treated as faulty, so the connection is closed after it without processing the # final response, and the current request fails along with the queued ones. defp decode_body(:informational, %{request: request} = conn, _data, _request_ref, responses) - when request.version < {1, 1} and request.transfer_encoding != [] do + when request.version < {1, 1} and request.transfer_encoding != nil do {conn, responses} = close_after_response(conn, responses, conn.transport.wrap_error(:closed)) {:ok, conn, responses} end @@ -873,6 +878,8 @@ defmodule Mint.HTTP1 do # Informational (1xx) responses have no body and must not finalize the # request; the final response follows on the same request ref. Reset the # request's response-side fields and continue parsing without popping it. + # A "close" option is kept, so the connection is closed after the final + # response (RFC 9112 9.6). defp decode_body(:informational, conn, data, _request_ref, responses) do request = %{ conn.request @@ -883,8 +890,8 @@ defmodule Mint.HTTP1 do headers_size: 0, data_buffer: [], content_length: nil, - connection: [], - transfer_encoding: [], + connection: if("close" in conn.request.connection, do: ["close"], else: []), + transfer_encoding: nil, body: nil } @@ -892,9 +899,12 @@ defmodule Mint.HTTP1 do decode(:status, conn, data, responses) end + # A 101 response switches the connection to another protocol (RFC 9110 + # 15.2.2), so the bytes after it belong to that protocol. defp decode_body(:single, conn, data, _request_ref, responses) do {conn, responses} = add_body(conn, data, responses) {conn, responses} = request_done(conn, responses) + {conn, responses} = fail_requests(conn, responses, wrap_error(:connection_upgraded)) {:ok, conn, responses} end @@ -1143,9 +1153,11 @@ defmodule Mint.HTTP1 do do: {:ok, %{request | connection: connection ++ connection_header}} end + # Transfer-Encoding values are kept unparsed, nil meaning there's no such field. + # They're only parsed when they decide the framing of the body, so they don't + # fail responses that have no body. defp store_header(%{transfer_encoding: transfer_encoding} = request, "transfer-encoding", value) do - with {:ok, transfer_encoding_header} <- Parse.transfer_encoding_header(value), - do: {:ok, %{request | transfer_encoding: transfer_encoding ++ transfer_encoding_header}} + {:ok, %{request | transfer_encoding: List.wrap(transfer_encoding) ++ [value]}} end defp store_header(_request, "content-length", _value) do @@ -1179,7 +1191,7 @@ defmodule Mint.HTTP1 do # RFC 9112 6.1: the framing of an HTTP/1.0 message with Transfer-Encoding is # treated as faulty, so the connection is closed after it even if kept alive. - "keep-alive" in request.connection and request.transfer_encoding == [] -> + "keep-alive" in request.connection and request.transfer_encoding == nil -> {conn, responses} true -> @@ -1201,6 +1213,15 @@ defmodule Mint.HTTP1 do # Requests pipelined behind a response that closes the connection never get a # response of their own. defp close_after_response(conn, responses, error) do + {conn, responses} = fail_requests(conn, responses, error) + {internal_close(conn), responses} + end + + # Returns an error response with the given error for the current request and + # every queued one, and removes them from the connection. If one of them is + # streaming its body, the streaming state is cleared so the rest of the body + # can't be sent. + defp fail_requests(conn, responses, error) do requests = if conn.request, do: [conn.request | :queue.to_list(conn.requests)], else: [] responses = @@ -1208,7 +1229,12 @@ defmodule Mint.HTTP1 do [{:error, request.ref, error} | responses] end) - {internal_close(%{conn | request: nil, requests: :queue.new()}), responses} + # No request can be made while one is streaming its body, so a streaming + # request is the last one and it's among the failed ones unless it's done. + streaming_request = if requests == [], do: conn.streaming_request + + conn = %{conn | request: nil, requests: :queue.new(), streaming_request: streaming_request} + {conn, responses} end defp pop_request(conn) do @@ -1239,14 +1265,12 @@ defmodule Mint.HTTP1 do %{conn | state: :closed} end - # RFC7230 3.3.3: - # > If a message is received with both a Transfer-Encoding and a - # > Content-Length header field, the Transfer-Encoding overrides the - # > Content-Length. Such a message might indicate an attempt to - # > perform request smuggling (Section 9.5) or response splitting - # > (Section 9.4) and ought to be handled as an error. A sender MUST - # > remove the received Content-Length field prior to forwarding such - # > a message downstream. + # Determines the length of a response body following RFC 9112 6.3. A response + # to HEAD, a 1xx, 204 or 304 response and a 2xx response to CONNECT have no + # body, and the bytes after a 101 response belong to the new protocol. For + # other responses, Transfer-Encoding together with Content-Length ought to be + # handled as an error, and a response with neither is read until the + # connection closes. defp message_body(%{body: nil, method: method, status: status} = request) do cond do status == 101 -> @@ -1266,19 +1290,20 @@ defmodule Mint.HTTP1 do method == "CONNECT" and status in 200..299 -> {:ok, :none} - request.transfer_encoding != [] && request.content_length -> + request.transfer_encoding != nil && request.content_length -> {:error, :transfer_encoding_and_content_length} - # RFC9112 6.3: - # > If a Transfer-Encoding header field is present in a response and the - # > chunked transfer coding is not the final encoding, the message body - # > length is determined by reading the connection until it is closed by - # > the server. - "chunked" == List.last(request.transfer_encoding) -> - {:ok, {:chunked, nil}} - - request.transfer_encoding != [] -> - {:ok, :until_closed} + # RFC 9112 6.3: with chunked as the final transfer coding the body is + # chunked. With another final coding, or a Transfer-Encoding field with no + # codings, it's read until the server closes the connection. + request.transfer_encoding != nil -> + with {:ok, codings} <- transfer_codings(request.transfer_encoding) do + if List.last(codings) == "chunked" do + {:ok, {:chunked, nil}} + else + {:ok, :until_closed} + end + end request.content_length -> {:ok, {:content_length, request.content_length}} @@ -1331,7 +1356,7 @@ defmodule Mint.HTTP1 do data_buffer: [], content_length: nil, connection: [], - transfer_encoding: [], + transfer_encoding: nil, body: nil } end @@ -1358,17 +1383,14 @@ defmodule Mint.HTTP1 do Headers.has?(headers, "content-length") -> {:ok, headers, :identity} - found = Headers.find(headers, "transfer-encoding") -> - {raw_name, value} = found + Headers.has?(headers, "transfer-encoding") -> + values = for {_name, "transfer-encoding", value} <- headers, do: value - with {:ok, tokens} <- Parse.transfer_encoding_header(value) do - if "chunked" in tokens or "identity" in tokens do + with {:ok, codings} <- transfer_codings(values) do + if "chunked" in codings or "identity" in codings do {:ok, headers, :identity} else - headers = - Headers.replace(headers, raw_name, "transfer-encoding", value <> ",chunked") - - {:ok, headers, :chunked} + {:ok, append_chunked_coding(headers), :chunked} end end @@ -1393,6 +1415,30 @@ defmodule Mint.HTTP1 do :identity} end + # Adds chunked as the final transfer coding, after the codings of the last + # Transfer-Encoding field. A last field with no codings is replaced. + defp append_chunked_coding(headers) do + headers = Enum.reverse(headers) + {name, "transfer-encoding", value} = List.keyfind(headers, "transfer-encoding", 1) + + value = + if Parse.transfer_encoding_header(value) == {:ok, []}, + do: "chunked", + else: value <> ",chunked" + + headers + |> Headers.replace(name, "transfer-encoding", value) + |> Enum.reverse() + end + + # RFC 9110 5.3: the field lines are combined with ", " into one field value, + # so a quoted string can span two of them. + defp transfer_codings(values) do + values + |> Enum.join(", ") + |> Parse.transfer_encoding_header() + end + defp wrap_error(reason) do %HTTPError{reason: reason, module: __MODULE__} end @@ -1409,6 +1455,11 @@ defmodule Mint.HTTP1 do "\"connection: close\", so it's safe to retry on a new connection" end + def format_error(:connection_upgraded) do + "request got no response because the server switched the connection to another " <> + "protocol in response to a previous request" + end + def format_error(:request_body_is_streaming) do "a request body is currently streaming, so no new requests can be issued" end diff --git a/lib/mint/http1/parse.ex b/lib/mint/http1/parse.ex index 09ec7b18..f08ca237 100644 --- a/lib/mint/http1/parse.ex +++ b/lib/mint/http1/parse.ex @@ -135,10 +135,82 @@ defmodule Mint.HTTP1.Parse do split_into_downcase_tokens(string) end + # RFC 9110 10.1.4: transfer-coding = token *( OWS ";" OWS transfer-parameter ) + # and transfer-parameter = token BWS "=" BWS ( token / quoted-string ). Only the + # coding names are returned. RFC 9112 7.1: chunked defines no parameters and + # their presence should be treated as an error. The list may have no elements + # (RFC 9110 5.6.1), which adds no transfer coding. def transfer_encoding_header(string) do - split_into_downcase_tokens(string) + with :error <- transfer_coding_list(string, []), + do: {:error, {:invalid_token_list, string}} + end + + defp transfer_coding_list(<<>>, acc), do: {:ok, :lists.reverse(acc)} + + defp transfer_coding_list(<>, acc) + when is_whitespace(char) or is_comma(char), + do: transfer_coding_list(rest, acc) + + defp transfer_coding_list(<> = string, acc) when is_tchar(char) do + {coding, rest} = take_token_downcase(string, <<>>) + transfer_parameters(trim_leading_whitespace(rest), [coding | acc]) + end + + defp transfer_coding_list(_string, _acc), do: :error + + # Parses the parameters of the last coding in acc, then the list separator. + defp transfer_parameters(<<>>, acc), do: {:ok, :lists.reverse(acc)} + + defp transfer_parameters(<>, acc) when is_comma(char), + do: transfer_coding_list(rest, acc) + + defp transfer_parameters(<>, [coding | _] = acc) when coding != "chunked" do + with {:ok, rest} <- transfer_parameter(trim_leading_whitespace(rest)), + do: transfer_parameters(trim_leading_whitespace(rest), acc) + end + + defp transfer_parameters(_string, _acc), do: :error + + defp transfer_parameter(<> = string) when is_tchar(char) do + rest = skip_token(string) + + case trim_leading_whitespace(rest) do + <> -> transfer_parameter_value(trim_leading_whitespace(rest)) + _other -> :error + end + end + + defp transfer_parameter(_string), do: :error + + defp transfer_parameter_value(<>), do: quoted_string(rest) + + defp transfer_parameter_value(<> = string) when is_tchar(char) do + {:ok, skip_token(string)} end + defp transfer_parameter_value(_string), do: :error + + # RFC 9110 5.6.4: qdtext and quoted-pair, after the opening quote. + defp quoted_string(<>), do: {:ok, rest} + + defp quoted_string(<>) + when char == 9 or char in 32..126 or char in 128..255, + do: quoted_string(rest) + + defp quoted_string(<>) + when char in [9, 32, 33] or char in 35..91 or char in 93..126 or char in 128..255, + do: quoted_string(rest) + + defp quoted_string(_string), do: :error + + defp skip_token(<>) when is_tchar(char), do: skip_token(rest) + defp skip_token(rest), do: rest + + defp take_token_downcase(<>, acc) when is_tchar(char), + do: take_token_downcase(rest, <>) + + defp take_token_downcase(rest, acc), do: {acc, rest} + defp split_into_downcase_tokens(string) do case token_list_downcase(string) do {:ok, []} -> {:error, :empty_token_list} diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index a3620eb4..d3d66d6f 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -1148,6 +1148,78 @@ defmodule Mint.HTTP1Test do ) end + for {name, final_response} <- [ + {"a final", "HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n"}, + {"a 101", "HTTP/1.1 101 Switching Protocols\r\nupgrade: foo\r\n\r\n"}, + {"an HTTP/1.0 keep-alive", "HTTP/1.0 200 OK\r\nconnection: keep-alive\r\n\r\n"} + ] do + test "connection: close in an informational response closes after #{name} response", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "HEAD", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 103 Early Hints\r\nconnection: close\r\n\r\n" <> unquote(final_response) + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 103}, + {:headers, ^ref1, [{"connection", "close"}]}, + {:status, ^ref1, _}, + {:headers, ^ref1, _}, + {:done, ^ref1}, + {:error, ^ref2, %HTTPError{reason: :unprocessed}} + ] = responses + + refute HTTP1.open?(conn) + end + end + + test "connection: close in an informational response is ignored after a 2xx response to CONNECT", + %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "CONNECT", "example.com:443", [], nil) + + response = + "HTTP/1.1 103 Early Hints\r\nconnection: close\r\n\r\n" <> + "HTTP/1.1 200 Connection established\r\n\r\n" + + assert {:ok, conn, + [ + {:status, ^ref, 103}, + {:headers, ^ref, _}, + {:status, ^ref, 200}, + {:headers, ^ref, []}, + {:done, ^ref} + ]} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert HTTP1.open?(conn) + end + + test "connection: keep-alive in an informational response doesn't apply to the final response", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 103 Early Hints\r\nconnection: keep-alive\r\n\r\n" <> + "HTTP/1.0 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 103}, + {:headers, ^ref1, _}, + {:status, ^ref1, 200}, + {:headers, ^ref1, _}, + {:data, ^ref1, "ok"}, + {:done, ^ref1}, + {:error, ^ref2, %TransportError{reason: :closed}} + ] = responses + + refute HTTP1.open?(conn) + end + test "body following a 101 switching-protocols", %{conn: conn} do {:ok, conn, ref} = HTTP1.request(conn, "GET", "/socket/websocket", [], nil) @@ -1168,6 +1240,71 @@ defmodule Mint.HTTP1Test do assert conn.buffer == <<>> end + test "a request pipelined behind a successful CONNECT gets its response from the tunnel", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "CONNECT", "example.com:80", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + assert {:ok, conn, [{:status, ^ref1, 200}, {:headers, ^ref1, []}, {:done, ^ref1}]} = + HTTP1.stream( + conn, + {:tcp, conn.socket, "HTTP/1.1 200 Connection Established\r\n\r\n"} + ) + + response = "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:ok, conn, + [{:status, ^ref2, 200}, {:headers, ^ref2, _}, {:data, ^ref2, "ok"}, {:done, ^ref2}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert HTTP1.open?(conn) + end + + test "requests pipelined behind a 101 response get no response", %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = "HTTP/1.1 101 Switching Protocols\r\nupgrade: foo\r\nconnection: upgrade\r\n\r\n" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 101}, + {:headers, ^ref1, _}, + {:done, ^ref1}, + {:error, ^ref2, %HTTPError{reason: :connection_upgraded}} + ] = responses + + assert HTTP1.open?(conn) + assert HTTP1.open_request_count(conn) == 0 + + data = "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:error, _conn, %HTTPError{reason: {:unexpected_data, ^data}}, []} = + HTTP1.stream(conn, {:tcp, conn.socket, data}) + end + + test "a streaming request pipelined behind a 101 response can't stream its body", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "POST", "/", [], :stream) + + response = "HTTP/1.1 101 Switching Protocols\r\nupgrade: foo\r\nconnection: upgrade\r\n\r\n" + + assert {:ok, conn, + [ + {:status, ^ref1, 101}, + {:headers, ^ref1, _}, + {:done, ^ref1}, + {:error, ^ref2, %HTTPError{reason: :connection_upgraded}} + ]} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert {:error, _conn, %HTTPError{reason: :unknown_request_to_stream}} = + HTTP1.stream_request_body(conn, ref2, "hello") + + assert_raise ArgumentError, fn -> HTTP1.request_body_window(conn, ref2) end + end + test "unallowed trailer headers are removed from the trailer headers", %{conn: conn} do {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) @@ -1207,6 +1344,165 @@ defmodule Mint.HTTP1Test do assert done == {:done, ref} end + test "chunked framing applies after a transfer coding with parameters", %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 200 OK\r\ntransfer-encoding: custom; level=1 ;name=\"a, b\", chunked\r\n\r\n" <> + "5\r\nhello\r\n0\r\n\r\n" <> + "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 200}, + {:headers, ^ref1, _}, + {:data, ^ref1, "hello"}, + {:done, ^ref1}, + {:status, ^ref2, 200}, + {:headers, ^ref2, _}, + {:data, ^ref2, "ok"}, + {:done, ^ref2} + ] = responses + + assert HTTP1.open?(conn) + end + + test "the chunked transfer coding with parameters is an error", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked; level=1\r\n\r\n5\r\nhello\r\n0\r\n\r\n" + + assert {:error, conn, %HTTPError{reason: {:invalid_token_list, "chunked; level=1"}}, + [{:status, ^ref, 200}, {:headers, ^ref, _}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + + refute HTTP1.open?(conn) + end + + test "a response with only an empty transfer-encoding is read until close", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + + response = "HTTP/1.1 200 OK\r\ntransfer-encoding: , \r\n\r\nhello" + + assert {:ok, conn, [{:status, ^ref, 200}, {:headers, ^ref, _}, {:data, ^ref, "hello"}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert {:ok, conn, [{:data, ^ref, "more"}]} = HTTP1.stream(conn, {:tcp, conn.socket, "more"}) + assert {:ok, conn, [{:done, ^ref}]} = HTTP1.stream(conn, {:tcp_closed, conn.socket}) + refute HTTP1.open?(conn) + end + + test "an empty transfer-encoding field adds no transfer coding", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 200 OK\r\ntransfer-encoding: gzip\r\ntransfer-encoding: ,\r\n" <> + "transfer-encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [{:status, ^ref, 200}, {:headers, ^ref, _}, {:data, ^ref, "hello"}, {:done, ^ref}] = + responses + + assert HTTP1.open?(conn) + end + + test "an HTTP/1.0 response with an empty transfer-encoding closes the connection", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "HEAD", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.0 200 OK\r\nconnection: keep-alive\r\ntransfer-encoding:\r\n\r\n" <> + "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 200}, + {:headers, ^ref1, _}, + {:done, ^ref1}, + {:error, ^ref2, %TransportError{reason: :closed}} + ] = responses + + refute HTTP1.open?(conn) + end + + test "an HTTP/1.0 1xx response with an empty transfer-encoding closes the connection", + %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.0 103 Early Hints\r\ntransfer-encoding:\r\n\r\n" <> + "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref, 103}, + {:headers, ^ref, _}, + {:error, ^ref, %TransportError{reason: :closed}} + ] = responses + + refute HTTP1.open?(conn) + end + + for {method, status} <- [{"CONNECT", 200}, {"HEAD", 200}, {"GET", 204}, {"GET", 304}] do + test "an invalid transfer-encoding is ignored in a #{status} response to #{method}", + %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, unquote(method), "example.com:443", [], nil) + response = "HTTP/1.1 #{unquote(status)} Status\r\ntransfer-encoding: chunked;a=1\r\n\r\n" + + assert {:ok, conn, [{:status, ^ref, unquote(status)}, {:headers, ^ref, _}, {:done, ^ref}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert HTTP1.open?(conn) + end + end + + for value <- ["", "chunked;a=1"] do + test "transfer-encoding #{inspect(value)} with content-length is an error", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 200 OK\r\ntransfer-encoding: #{unquote(value)}\r\ncontent-length: 2\r\n\r\nok" + + assert {:error, conn, %HTTPError{reason: :transfer_encoding_and_content_length}, + [{:status, ^ref, 200}, {:headers, ^ref, _}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + + refute HTTP1.open?(conn) + end + end + + test "transfer-encoding fields are combined before they're parsed", %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 200 OK\r\ntransfer-encoding: custom;p=\"a\r\n" <> + "transfer-encoding: b\", chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n" <> + "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 200}, + {:headers, ^ref1, _}, + {:data, ^ref1, "hello"}, + {:done, ^ref1}, + {:status, ^ref2, 200}, + {:headers, ^ref2, _}, + {:data, ^ref2, "ok"}, + {:done, ^ref2} + ] = responses + + assert HTTP1.open?(conn) + end + test "close/1", %{conn: conn} do assert HTTP1.open?(conn) assert {:ok, conn} = HTTP1.close(conn) @@ -1863,6 +2159,83 @@ defmodule Mint.HTTP1Test do assert HTTP1.open?(conn) end + test "transfer-encoding is set to chunked if present but empty", + %{conn: conn, server_socket: server_socket, port: port} do + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [{"transfer-encoding", ""}], :stream) + + assert receive_request_string(server_socket) == + request_string(""" + GET / HTTP/1.1 + host: localhost:#{port} + user-agent: #{mint_user_agent()} + transfer-encoding: chunked + + \ + """) + + {:ok, conn} = HTTP1.stream_request_body(conn, ref, "hello") + assert receive_request_string(server_socket) == "5\r\nhello\r\n" + end + + test "chunked is appended to a transfer coding with parameters", + %{conn: conn, server_socket: server_socket, port: port} do + {:ok, conn, ref} = + HTTP1.request(conn, "GET", "/", [{"transfer-encoding", "gzip;q=1"}], :stream) + + assert receive_request_string(server_socket) == + request_string(""" + GET / HTTP/1.1 + host: localhost:#{port} + user-agent: #{mint_user_agent()} + transfer-encoding: gzip;q=1,chunked + + \ + """) + + {:ok, conn} = HTTP1.stream_request_body(conn, ref, "hello") + assert receive_request_string(server_socket) == "5\r\nhello\r\n" + end + + test "chunked is appended to the last of several transfer-encoding headers", + %{conn: conn, server_socket: server_socket, port: port} do + headers = [{"transfer-encoding", "custom;p=1"}, {"transfer-encoding", "gzip"}] + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", headers, :stream) + + assert receive_request_string(server_socket) == + request_string(""" + GET / HTTP/1.1 + host: localhost:#{port} + user-agent: #{mint_user_agent()} + transfer-encoding: custom;p=1 + transfer-encoding: gzip,chunked + + \ + """) + + {:ok, conn} = HTTP1.stream_request_body(conn, ref, "hello") + assert receive_request_string(server_socket) == "5\r\nhello\r\n" + end + + test "transfer-encoding headers are combined before they're parsed", + %{conn: conn, server_socket: server_socket, port: port} do + headers = [{"transfer-encoding", ~s(custom;p="a)}, {"transfer-encoding", ~s(b", chunked)}] + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", headers, :stream) + + assert receive_request_string(server_socket) == + request_string(""" + GET / HTTP/1.1 + host: localhost:#{port} + user-agent: #{mint_user_agent()} + transfer-encoding: custom;p="a + transfer-encoding: b", chunked + + \ + """) + + {:ok, conn} = HTTP1.stream_request_body(conn, ref, "hello") + assert receive_request_string(server_socket) == "hello" + end + test "transfer-encoding is not set to chunked if already set to identity", %{conn: conn, server_socket: server_socket, port: port} do {:ok, conn, ref} = diff --git a/test/mint/http1/parse_test.exs b/test/mint/http1/parse_test.exs index 8cbe0dbd..50a6541a 100644 --- a/test/mint/http1/parse_test.exs +++ b/test/mint/http1/parse_test.exs @@ -161,7 +161,31 @@ defmodule Mint.HTTP1.ParseTest do assert transfer_encoding_header("gzip, Chunked ") == {:ok, ["gzip", "chunked"]} assert transfer_encoding_header("\n") == {:error, {:invalid_token_list, "\n"}} - assert transfer_encoding_header("") == {:error, :empty_token_list} + assert transfer_encoding_header("") == {:ok, []} + assert transfer_encoding_header(" , ,") == {:ok, []} + end + + test "transfer_encoding_header/1 with transfer coding parameters" do + assert transfer_encoding_header("Custom;Level=1") == {:ok, ["custom"]} + + assert transfer_encoding_header("custom ; a=1 ;b = x, chunked") == + {:ok, ["custom", "chunked"]} + + assert transfer_encoding_header(~s(custom; a="x, \\"y\\"; z", gzip)) == + {:ok, ["custom", "gzip"]} + + for value <- [ + "custom;", + "custom; a", + "custom; a=", + "custom; =1", + "custom; a=1 b", + ~s(custom; a="x), + "chunked; a=1", + "Chunked;a=1, gzip" + ] do + assert transfer_encoding_header(value) == {:error, {:invalid_token_list, value}} + end end describe "token_list_downcase/1" do