aboutsummaryrefslogtreecommitdiffstats
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/compress_SUITE.erl54
-rw-r--r--test/cowboy_ct_hook.erl2
-rw-r--r--test/cowboy_test.erl88
-rw-r--r--test/decompress_SUITE.erl421
-rw-r--r--test/examples_SUITE.erl9
-rw-r--r--test/h2spec_SUITE.erl2
-rw-r--r--test/handlers/compress_h.erl5
-rw-r--r--test/handlers/decompress_h.erl84
-rw-r--r--test/handlers/echo_h.erl22
-rw-r--r--test/handlers/generate_etag_h.erl3
-rw-r--r--test/handlers/loop_handler_endless_h.erl25
-rw-r--r--test/handlers/loop_handler_timeout_hibernate_h.erl30
-rw-r--r--test/handlers/loop_handler_timeout_info_h.erl23
-rw-r--r--test/handlers/loop_handler_timeout_init_h.erl23
-rw-r--r--test/handlers/resp_h.erl58
-rw-r--r--test/handlers/stream_handler_h.erl8
-rw-r--r--test/handlers/streamed_result_h.erl20
-rw-r--r--test/handlers/ws_init_h.erl5
-rw-r--r--test/handlers/ws_ping_h.erl23
-rw-r--r--test/http2_SUITE.erl125
-rw-r--r--test/http_SUITE.erl369
-rw-r--r--test/loop_handler_SUITE.erl36
-rw-r--r--test/metrics_SUITE.erl99
-rw-r--r--test/misc_SUITE.erl34
-rw-r--r--test/plain_handler_SUITE.erl15
-rw-r--r--test/proxy_header_SUITE.erl33
-rw-r--r--test/req_SUITE.erl329
-rw-r--r--test/rest_handler_SUITE.erl33
-rw-r--r--test/rfc6585_SUITE.erl4
-rw-r--r--test/rfc7230_SUITE.erl44
-rw-r--r--test/rfc7231_SUITE.erl31
-rw-r--r--test/rfc7538_SUITE.erl4
-rw-r--r--test/rfc7540_SUITE.erl100
-rw-r--r--test/rfc8297_SUITE.erl4
-rw-r--r--test/rfc8441_SUITE.erl15
-rw-r--r--test/rfc9114_SUITE.erl2426
-rw-r--r--test/rfc9114_SUITE_data/client.key5
-rw-r--r--test/rfc9114_SUITE_data/client.pem12
-rw-r--r--test/rfc9114_SUITE_data/server.key5
-rw-r--r--test/rfc9114_SUITE_data/server.pem12
-rw-r--r--test/rfc9204_SUITE.erl357
-rw-r--r--test/rfc9220_SUITE.erl485
-rw-r--r--test/security_SUITE.erl81
-rw-r--r--test/static_handler_SUITE.erl72
-rw-r--r--test/stream_handler_SUITE.erl171
-rw-r--r--test/sys_SUITE.erl61
-rw-r--r--test/tracer_SUITE.erl18
-rw-r--r--test/ws_SUITE.erl17
-rw-r--r--test/ws_autobahn_SUITE.erl2
-rw-r--r--test/ws_handler_SUITE.erl10
50 files changed, 5510 insertions, 404 deletions
diff --git a/test/compress_SUITE.erl b/test/compress_SUITE.erl
index a25c427..a6a100c 100644
--- a/test/compress_SUITE.erl
+++ b/test/compress_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -23,12 +23,20 @@
%% ct.
all() ->
- [
+ All = [
{group, http_compress},
{group, https_compress},
{group, h2_compress},
- {group, h2c_compress}
- ].
+ {group, h2c_compress},
+ {group, h3_compress}
+ ],
+ %% Don't run HTTP/3 tests on Windows for now.
+ case os:type() of
+ {win32, _} ->
+ All -- [{group, h3_compress}];
+ _ ->
+ All
+ end.
groups() ->
cowboy_test:common_groups(ct_helper:all(?MODULE)).
@@ -37,7 +45,7 @@ init_per_group(Name, Config) ->
cowboy_test:init_common_groups(Name, Config, ?MODULE).
end_per_group(Name, _) ->
- cowboy:stop_listener(Name).
+ cowboy_test:stop_group(Name).
%% Routes.
@@ -67,7 +75,7 @@ gzip_accept_encoding_malformed(Config) ->
{200, Headers, _} = do_get("/reply/large",
[{<<"accept-encoding">>, <<";">>}], Config),
false = lists:keyfind(<<"content-encoding">>, 1, Headers),
- false = lists:keyfind(<<"vary">>, 1, Headers),
+ {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers),
{_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers),
ok.
@@ -76,7 +84,7 @@ gzip_accept_encoding_missing(Config) ->
{200, Headers, _} = do_get("/reply/large",
[], Config),
false = lists:keyfind(<<"content-encoding">>, 1, Headers),
- false = lists:keyfind(<<"vary">>, 1, Headers),
+ {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers),
{_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers),
ok.
@@ -85,7 +93,7 @@ gzip_accept_encoding_no_gzip(Config) ->
{200, Headers, _} = do_get("/reply/large",
[{<<"accept-encoding">>, <<"compress">>}], Config),
false = lists:keyfind(<<"content-encoding">>, 1, Headers),
- false = lists:keyfind(<<"vary">>, 1, Headers),
+ {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers),
{_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers),
ok.
@@ -94,7 +102,7 @@ gzip_accept_encoding_not_supported(Config) ->
{200, Headers, _} = do_get("/reply/large",
[{<<"accept-encoding">>, <<"application/gzip">>}], Config),
false = lists:keyfind(<<"content-encoding">>, 1, Headers),
- false = lists:keyfind(<<"vary">>, 1, Headers),
+ {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers),
{_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers),
ok.
@@ -105,7 +113,18 @@ gzip_reply_content_encoding(Config) ->
%% We set the content-encoding to compress; without actually compressing.
{_, <<"compress">>} = lists:keyfind(<<"content-encoding">>, 1, Headers),
%% The reply didn't include a vary header.
- false = lists:keyfind(<<"vary">>, 1, Headers),
+ {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers),
+ {_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers),
+ ok.
+
+gzip_reply_etag(Config) ->
+ doc("Reply with etag header; get an uncompressed response."),
+ {200, Headers, _} = do_get("/reply/etag",
+ [{<<"accept-encoding">>, <<"gzip">>}], Config),
+ %% We set a strong etag.
+ {_, <<"\"STRONK\"">>} = lists:keyfind(<<"etag">>, 1, Headers),
+ %% The reply didn't include a vary header.
+ {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers),
{_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers),
ok.
@@ -125,7 +144,7 @@ gzip_reply_sendfile(Config) ->
{200, Headers, Body} = do_get("/reply/sendfile",
[{<<"accept-encoding">>, <<"gzip">>}], Config),
false = lists:keyfind(<<"content-encoding">>, 1, Headers),
- false = lists:keyfind(<<"vary">>, 1, Headers),
+ {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers),
ct:log("Body received:~n~p~n", [Body]),
ok.
@@ -134,7 +153,7 @@ gzip_reply_small_body(Config) ->
{200, Headers, _} = do_get("/reply/small",
[{<<"accept-encoding">>, <<"gzip">>}], Config),
false = lists:keyfind(<<"content-encoding">>, 1, Headers),
- false = lists:keyfind(<<"vary">>, 1, Headers),
+ {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers),
{_, <<"100">>} = lists:keyfind(<<"content-length">>, 1, Headers),
ok.
@@ -170,7 +189,16 @@ gzip_stream_reply_content_encoding(Config) ->
{200, Headers, Body} = do_get("/stream_reply/content-encoding",
[{<<"accept-encoding">>, <<"gzip">>}], Config),
{_, <<"compress">>} = lists:keyfind(<<"content-encoding">>, 1, Headers),
- false = lists:keyfind(<<"vary">>, 1, Headers),
+ {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers),
+ 100000 = iolist_size(Body),
+ ok.
+
+gzip_stream_reply_etag(Config) ->
+ doc("Stream reply with etag header; get an uncompressed response."),
+ {200, Headers, Body} = do_get("/stream_reply/etag",
+ [{<<"accept-encoding">>, <<"gzip">>}], Config),
+ {_, <<"\"STRONK\"">>} = lists:keyfind(<<"etag">>, 1, Headers),
+ {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers),
100000 = iolist_size(Body),
ok.
diff --git a/test/cowboy_ct_hook.erl b/test/cowboy_ct_hook.erl
index 7d5a889..e76ec21 100644
--- a/test/cowboy_ct_hook.erl
+++ b/test/cowboy_ct_hook.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2014-2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2014-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
diff --git a/test/cowboy_test.erl b/test/cowboy_test.erl
index 7ebe618..5a8fb13 100644
--- a/test/cowboy_test.erl
+++ b/test/cowboy_test.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2014-2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2014-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -27,45 +27,92 @@ init_http(Ref, ProtoOpts, Config) ->
init_https(Ref, ProtoOpts, Config) ->
Opts = ct_helper:get_certs_from_ets(),
- {ok, _} = cowboy:start_tls(Ref, Opts ++ [{port, 0}], ProtoOpts),
+ {ok, _} = cowboy:start_tls(Ref, Opts ++ [{port, 0}, {verify, verify_none}], ProtoOpts),
Port = ranch:get_port(Ref),
[{ref, Ref}, {type, ssl}, {protocol, http}, {port, Port}, {opts, Opts}|Config].
init_http2(Ref, ProtoOpts, Config) ->
Opts = ct_helper:get_certs_from_ets(),
- {ok, _} = cowboy:start_tls(Ref, Opts ++ [{port, 0}], ProtoOpts),
+ {ok, _} = cowboy:start_tls(Ref, Opts ++ [{port, 0}, {verify, verify_none}], ProtoOpts),
Port = ranch:get_port(Ref),
[{ref, Ref}, {type, ssl}, {protocol, http2}, {port, Port}, {opts, Opts}|Config].
+%% @todo This will probably require TransOpts as argument.
+init_http3(Ref, ProtoOpts, Config) ->
+ %% @todo Quicer does not currently support non-file cert/key,
+ %% so we use quicer test certificates for now.
+ %% @todo Quicer also does not support cacerts which means
+ %% we currently have no authentication based security.
+ DataDir = filename:dirname(filename:dirname(config(data_dir, Config)))
+ ++ "/rfc9114_SUITE_data",
+ TransOpts = #{
+ socket_opts => [
+ {certfile, DataDir ++ "/server.pem"},
+ {keyfile, DataDir ++ "/server.key"}
+ ]
+ },
+ {ok, Listener} = cowboy:start_quic(Ref, TransOpts, ProtoOpts),
+ {ok, {_, Port}} = quicer:sockname(Listener),
+ %% @todo Keep listener information around in a better place.
+ persistent_term:put({cowboy_test_quic, Ref}, Listener),
+ [{ref, Ref}, {type, quic}, {protocol, http3}, {port, Port}, {opts, TransOpts}|Config].
+
+stop_group(Ref) ->
+ case persistent_term:get({cowboy_test_quic, Ref}, undefined) of
+ undefined ->
+ cowboy:stop_listener(Ref);
+ Listener ->
+ quicer:close_listener(Listener)
+ end.
+
%% Common group of listeners used by most suites.
common_all() ->
- [
+ All = [
{group, http},
{group, https},
{group, h2},
{group, h2c},
+ {group, h3},
{group, http_compress},
{group, https_compress},
{group, h2_compress},
- {group, h2c_compress}
- ].
+ {group, h2c_compress},
+ {group, h3_compress}
+ ],
+ %% Don't run HTTP/3 tests on Windows for now.
+ case os:type() of
+ {win32, _} ->
+ All -- [{group, h3}, {group, h3_compress}];
+ _ ->
+ All
+ end.
common_groups(Tests) ->
Opts = case os:getenv("NO_PARALLEL") of
false -> [parallel];
_ -> []
end,
- [
+ Groups = [
{http, Opts, Tests},
{https, Opts, Tests},
{h2, Opts, Tests},
{h2c, Opts, Tests},
+ {h3, Opts, Tests},
{http_compress, Opts, Tests},
{https_compress, Opts, Tests},
{h2_compress, Opts, Tests},
- {h2c_compress, Opts, Tests}
- ].
+ {h2c_compress, Opts, Tests},
+ {h3_compress, Opts, Tests}
+ ],
+ %% Don't run HTTP/3 tests on Windows for now.
+ case os:type() of
+ {win32, _} ->
+ Groups -- [{h3, Opts, Tests}, {h3_compress, Opts, Tests}];
+ _ ->
+ Groups
+ end.
+
init_common_groups(Name = http, Config, Mod) ->
init_http(Name, #{
@@ -84,6 +131,10 @@ init_common_groups(Name = h2c, Config, Mod) ->
env => #{dispatch => Mod:init_dispatch(Config)}
}, [{flavor, vanilla}|Config]),
lists:keyreplace(protocol, 1, Config1, {protocol, http2});
+init_common_groups(Name = h3, Config, Mod) ->
+ init_http3(Name, #{
+ env => #{dispatch => Mod:init_dispatch(Config)}
+ }, [{flavor, vanilla}|Config]);
init_common_groups(Name = http_compress, Config, Mod) ->
init_http(Name, #{
env => #{dispatch => Mod:init_dispatch(Config)},
@@ -104,7 +155,12 @@ init_common_groups(Name = h2c_compress, Config, Mod) ->
env => #{dispatch => Mod:init_dispatch(Config)},
stream_handlers => [cowboy_compress_h, cowboy_stream_h]
}, [{flavor, compress}|Config]),
- lists:keyreplace(protocol, 1, Config1, {protocol, http2}).
+ lists:keyreplace(protocol, 1, Config1, {protocol, http2});
+init_common_groups(Name = h3_compress, Config, Mod) ->
+ init_http3(Name, #{
+ env => #{dispatch => Mod:init_dispatch(Config)},
+ stream_handlers => [cowboy_compress_h, cowboy_stream_h]
+ }, [{flavor, compress}|Config]).
%% Support functions for testing using Gun.
@@ -112,10 +168,14 @@ gun_open(Config) ->
gun_open(Config, #{}).
gun_open(Config, Opts) ->
+ TlsOpts = case proplists:get_value(no_cert, Config, false) of
+ true -> [{verify, verify_none}];
+ false -> ct_helper:get_certs_from_ets() %% @todo Wrong in current quicer.
+ end,
{ok, ConnPid} = gun:open("localhost", config(port, Config), Opts#{
retry => 0,
transport => config(type, Config),
- tls_opts => proplists:get_value(tls_opts, Config, []),
+ tls_opts => TlsOpts,
protocols => [config(protocol, Config)]
}),
ConnPid.
@@ -153,6 +213,12 @@ raw_recv_head(Socket, Transport, Buffer) ->
Buffer
end.
+raw_recv_rest({raw_client, _, _}, Length, Buffer) when Length =:= byte_size(Buffer) ->
+ Buffer;
+raw_recv_rest({raw_client, Socket, Transport}, Length, Buffer) when Length > byte_size(Buffer) ->
+ {ok, Data} = Transport:recv(Socket, Length - byte_size(Buffer), 10000),
+ << Buffer/binary, Data/binary >>.
+
raw_recv({raw_client, Socket, Transport}, Length, Timeout) ->
Transport:recv(Socket, Length, Timeout).
diff --git a/test/decompress_SUITE.erl b/test/decompress_SUITE.erl
new file mode 100644
index 0000000..f61bb5d
--- /dev/null
+++ b/test/decompress_SUITE.erl
@@ -0,0 +1,421 @@
+%% Copyright (c) 2024, jdamanalo <[email protected]>
+%% Copyright (c) 2024, Loïc Hoguin <[email protected]>
+%%
+%% Permission to use, copy, modify, and/or distribute this software for any
+%% purpose with or without fee is hereby granted, provided that the above
+%% copyright notice and this permission notice appear in all copies.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+-module(decompress_SUITE).
+-compile(export_all).
+-compile(nowarn_export_all).
+
+-import(ct_helper, [config/2]).
+-import(ct_helper, [doc/1]).
+-import(cowboy_test, [gun_open/1]).
+
+%% ct.
+
+all() ->
+ cowboy_test:common_all().
+
+groups() ->
+ cowboy_test:common_groups(ct_helper:all(?MODULE)).
+
+init_per_group(Name = http, Config) ->
+ cowboy_test:init_http(Name, init_plain_opts(Config), Config);
+init_per_group(Name = https, Config) ->
+ cowboy_test:init_http(Name, init_plain_opts(Config), Config);
+init_per_group(Name = h2, Config) ->
+ cowboy_test:init_http2(Name, init_plain_opts(Config), Config);
+init_per_group(Name = h2c, Config) ->
+ Config1 = cowboy_test:init_http(Name, init_plain_opts(Config), Config),
+ lists:keyreplace(protocol, 1, Config1, {protocol, http2});
+init_per_group(Name = h3, Config) ->
+ cowboy_test:init_http3(Name, init_plain_opts(Config), Config);
+init_per_group(Name = http_compress, Config) ->
+ cowboy_test:init_http(Name, init_compress_opts(Config), Config);
+init_per_group(Name = https_compress, Config) ->
+ cowboy_test:init_http(Name, init_compress_opts(Config), Config);
+init_per_group(Name = h2_compress, Config) ->
+ cowboy_test:init_http2(Name, init_compress_opts(Config), Config);
+init_per_group(Name = h2c_compress, Config) ->
+ Config1 = cowboy_test:init_http(Name, init_compress_opts(Config), Config),
+ lists:keyreplace(protocol, 1, Config1, {protocol, http2});
+init_per_group(Name = h3_compress, Config) ->
+ cowboy_test:init_http3(Name, init_compress_opts(Config), Config).
+
+end_per_group(Name, _) ->
+ cowboy:stop_listener(Name).
+
+init_plain_opts(Config) ->
+ #{
+ env => #{dispatch => cowboy_router:compile(init_routes(Config))},
+ stream_handlers => [cowboy_decompress_h, cowboy_stream_h]
+ }.
+
+init_compress_opts(Config) ->
+ #{
+ env => #{dispatch => cowboy_router:compile(init_routes(Config))},
+ stream_handlers => [cowboy_decompress_h, cowboy_compress_h, cowboy_stream_h]
+ }.
+
+init_routes(_) ->
+ [{'_', [
+ {"/echo/:what", decompress_h, echo},
+ {"/test/:what", decompress_h, test}
+ ]}].
+
+%% Internal.
+
+do_post(Path, ReqHeaders, Body, Config) ->
+ ConnPid = gun_open(Config),
+ Ref = gun:post(ConnPid, Path, ReqHeaders, Body),
+ {response, IsFin, Status, RespHeaders} = gun:await(ConnPid, Ref),
+ {ok, ResponseBody} = case IsFin of
+ nofin -> gun:await_body(ConnPid, Ref);
+ fin -> {ok, <<>>}
+ end,
+ gun:close(ConnPid),
+ {Status, RespHeaders, ResponseBody}.
+
+create_gzip_bomb() ->
+ Z = zlib:open(),
+ zlib:deflateInit(Z, 9, deflated, 31, 8, default),
+ %% 1000 chunks of 100000 zeroes (100MB).
+ Bomb = do_create_gzip_bomb(Z, 1000),
+ zlib:deflateEnd(Z),
+ zlib:close(Z),
+ iolist_to_binary(Bomb).
+
+do_create_gzip_bomb(Z, 0) ->
+ zlib:deflate(Z, << >>, finish);
+do_create_gzip_bomb(Z, N) ->
+ Data = <<0:800000>>,
+ Deflate = zlib:deflate(Z, Data),
+ [Deflate | do_create_gzip_bomb(Z, N - 1)].
+
+%% Tests.
+
+content_encoding_none(Config) ->
+ doc("Requests without content-encoding are processed normally."),
+ Body = <<"test">>,
+ {200, _, Body} = do_post("/echo/normal", [], Body, Config),
+ %% The content-encoding header would be propagated,
+ %% but there was no content-encoding header to propagate.
+ {200, _, <<"undefined">>} = do_post("/test/content-encoding", [], Body, Config),
+ %% The content_decoded list is empty.
+ {200, _, <<"[]">>} = do_post("/test/content-decoded", [], Body, Config),
+ ok.
+
+content_encoding_malformed(Config) ->
+ doc("Requests with a malformed content-encoding are processed "
+ "as if no content-encoding was sent."),
+ Body = <<"test">>,
+ {200, _, Body} = do_post("/echo/normal",
+ [{<<"content-encoding">>, <<";">>}], Body, Config),
+ %% The content-encoding header is propagated.
+ {200, _, <<";">>} = do_post("/test/content-encoding",
+ [{<<"content-encoding">>, <<";">>}], Body, Config),
+ %% The content_decoded list is empty.
+ {200, _, <<"[]">>} = do_post("/test/content-decoded",
+ [{<<"content-encoding">>, <<";">>}], Body, Config),
+ ok.
+
+content_encoding_not_supported(Config) ->
+ doc("Requests with an unsupported content-encoding are processed "
+ "as if no content-encoding was sent."),
+ Body = <<"test">>,
+ {200, _, Body} = do_post("/echo/normal",
+ [{<<"content-encoding">>, <<"compress">>}], Body, Config),
+ %% The content-encoding header is propagated.
+ {200, _, <<"compress">>} = do_post("/test/content-encoding",
+ [{<<"content-encoding">>, <<"compress">>}], Body, Config),
+ %% The content_decoded list is empty.
+ {200, _, <<"[]">>} = do_post("/test/content-decoded",
+ [{<<"content-encoding">>, <<"compress">>}], Body, Config),
+ ok.
+
+content_encoding_multiple(Config) ->
+ doc("Requests with multiple content-encoding values are processed "
+ "as if no content-encoding was sent."),
+ Body = <<"test">>,
+ {200, _, Body} = do_post("/echo/normal",
+ [{<<"content-encoding">>, <<"gzip, compress">>}], Body, Config),
+ %% The content-encoding header is propagated.
+ {200, _, <<"gzip, compress">>} = do_post("/test/content-encoding",
+ [{<<"content-encoding">>, <<"gzip, compress">>}], Body, Config),
+ %% The content_decoded list is empty.
+ {200, _, <<"[]">>} = do_post("/test/content-decoded",
+ [{<<"content-encoding">>, <<"gzip, compress">>}], Body, Config),
+ ok.
+
+decompress(Config) ->
+ doc("Requests with content-encoding set to gzip and gzipped data "
+ "are transparently decompressed."),
+ Data = <<"test">>,
+ Body = zlib:gzip(Data),
+ {200, _, Data} = do_post("/echo/normal",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ %% The content-encoding header is NOT propagated.
+ {200, _, <<"undefined">>} = do_post("/test/content-encoding",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ %% The content_decoded list contains <<"gzip">>.
+ {200, _, <<"[<<\"gzip\">>]">>} = do_post("/test/content-decoded",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ ok.
+
+decompress_error(Config) ->
+ doc("Requests with content-encoding set to gzip but the data "
+ "cannot be decoded are rejected with a 400 Bad Request error."),
+ Body = <<"test">>,
+ {400, _, _} = do_post("/echo/normal",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ ok.
+
+decompress_stream(Config) ->
+ doc("Requests with content-encoding set to gzip and gzipped data "
+ "are transparently decompressed, even when the data is streamed."),
+ %% Handler read length 1KB. Compressing 3KB should be enough to trigger more.
+ Data = crypto:strong_rand_bytes(3000),
+ Body = zlib:gzip(Data),
+ Size = byte_size(Body),
+ ConnPid = gun_open(Config),
+ Ref = gun:post(ConnPid, "/echo/normal",
+ [{<<"content-encoding">>, <<"gzip">>}]),
+ gun:data(ConnPid, Ref, nofin, binary:part(Body, 0, Size div 2)),
+ timer:sleep(1000),
+ gun:data(ConnPid, Ref, fin, binary:part(Body, Size div 2, Size div 2 + Size rem 2)),
+ {response, IsFin, 200, _} = gun:await(ConnPid, Ref),
+ {ok, Data} = case IsFin of
+ nofin -> gun:await_body(ConnPid, Ref);
+ fin -> {ok, <<>>}
+ end,
+ gun:close(ConnPid),
+ %% The content-encoding header is NOT propagated.
+ ConnPid2 = gun_open(Config),
+ Ref2 = gun:post(ConnPid2, "/test/content-encoding",
+ [{<<"content-encoding">>, <<"gzip">>}]),
+ {response, nofin, 200, _} = gun:await(ConnPid2, Ref2),
+ {ok, <<"undefined">>} = gun:await_body(ConnPid2, Ref2),
+ gun:close(ConnPid2),
+ %% The content_decoded list contains <<"gzip">>.
+ ConnPid3 = gun_open(Config),
+ Ref3 = gun:post(ConnPid3, "/test/content-decoded",
+ [{<<"content-encoding">>, <<"gzip">>}]),
+ {response, nofin, 200, _} = gun:await(ConnPid3, Ref3),
+ {ok, <<"[<<\"gzip\">>]">>} = gun:await_body(ConnPid3, Ref3),
+ gun:close(ConnPid3).
+
+opts_decompress_enabled_false(Config0) ->
+ doc("Confirm that the decompress_enabled option can be set."),
+ Fun = case config(ref, Config0) of
+ HTTPS when HTTPS =:= https_compress; HTTPS =:= https -> init_https;
+ H2 when H2 =:= h2_compress; H2 =:= h2 -> init_http2;
+ _ -> init_http
+ end,
+ Config = cowboy_test:Fun(?FUNCTION_NAME, #{
+ env => #{dispatch => cowboy_router:compile(init_routes(Config0))},
+ stream_handlers => [cowboy_decompress_h, cowboy_stream_h],
+ decompress_enabled => false
+ }, Config0),
+ Data = <<"test">>,
+ Body = zlib:gzip(Data),
+ try
+ {200, Headers, Body} = do_post("/echo/normal",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ %% We do not set accept-encoding when we are disabled.
+ false = lists:keyfind(<<"accept-encoding">>, 1, Headers)
+ after
+ cowboy:stop_listener(?FUNCTION_NAME)
+ end.
+
+set_options_decompress_enabled_false(Config) ->
+ doc("Confirm that the decompress_enabled option can be dynamically "
+ "set to false and the data received is not decompressed."),
+ Data = <<"test">>,
+ Body = zlib:gzip(Data),
+ {200, Headers, Body} = do_post("/echo/decompress_disable",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ %% We do not set accept-encoding when we are disabled.
+ false = lists:keyfind(<<"accept-encoding">>, 1, Headers),
+ ok.
+
+set_options_decompress_disable_in_the_middle(Config) ->
+ doc("Confirm that setting the decompress_enabled option dynamically "
+ "to false after starting to read the body does not disable decompression "
+ "and the data received is decompressed."),
+ Data = rand:bytes(1000000),
+ Body = zlib:gzip(Data),
+ %% Since we were not ignoring before starting to read,
+ %% we receive the entire body decompressed.
+ {200, Headers, Data} = do_post("/test/disable-in-the-middle",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ %% We do set accept-encoding when we are enabled,
+ %% even if an attempt to disable in the middle is ignored.
+ {_, _} = lists:keyfind(<<"accept-encoding">>, 1, Headers),
+ ok.
+
+set_options_decompress_enable_in_the_middle(Config0) ->
+ doc("Confirm that setting the decompress_enabled option dynamically "
+ "to true after starting to read the body does not enable decompression "
+ "and the data received is not decompressed."),
+ Fun = case config(ref, Config0) of
+ HTTPS when HTTPS =:= https_compress; HTTPS =:= https -> init_https;
+ H2 when H2 =:= h2_compress; H2 =:= h2 -> init_http2;
+ _ -> init_http
+ end,
+ Config = cowboy_test:Fun(?FUNCTION_NAME, #{
+ env => #{dispatch => cowboy_router:compile(init_routes(Config0))},
+ stream_handlers => [cowboy_decompress_h, cowboy_stream_h],
+ decompress_enabled => false
+ }, Config0),
+ Data = rand:bytes(1000000),
+ Body = zlib:gzip(Data),
+ try
+ %% Since we were ignoring before starting to read,
+ %% we receive the entire body compressed.
+ {200, Headers, Body} = do_post("/test/enable-in-the-middle",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ %% We do not set accept-encoding when we are disabled,
+ %% even if an attempt to enable in the middle is ignored.
+ false = lists:keyfind(<<"accept-encoding">>, 1, Headers)
+ after
+ cowboy:stop_listener(?FUNCTION_NAME)
+ end.
+
+opts_decompress_ratio_limit(Config0) ->
+ doc("Confirm that the decompress_ratio_limit option can be set."),
+ Fun = case config(ref, Config0) of
+ HTTPS when HTTPS =:= https_compress; HTTPS =:= https -> init_https;
+ H2 when H2 =:= h2_compress; H2 =:= h2 -> init_http2;
+ _ -> init_http
+ end,
+ Config = cowboy_test:Fun(?FUNCTION_NAME, #{
+ env => #{dispatch => cowboy_router:compile(init_routes(Config0))},
+ stream_handlers => [cowboy_decompress_h, cowboy_stream_h],
+ decompress_ratio_limit => 1
+ }, Config0),
+ %% Data must be big enough for compression to be effective,
+ %% so that ratio_limit=1 will fail.
+ Data = <<0:800>>,
+ Body = zlib:gzip(Data),
+ try
+ {413, _, _} = do_post("/echo/normal",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config)
+ after
+ cowboy:stop_listener(?FUNCTION_NAME)
+ end.
+
+set_options_decompress_ratio_limit(Config) ->
+ doc("Confirm that the decompress_ratio_limit option can be dynamically set."),
+ %% Data must be big enough for compression to be effective,
+ %% so that ratio_limit=1 will fail.
+ Data = <<0:800>>,
+ Body = zlib:gzip(Data),
+ {413, _, _} = do_post("/echo/decompress_ratio_limit",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ ok.
+
+gzip_bomb(Config) ->
+ doc("Confirm that requests are rejected with a 413 Payload Too Large "
+ "error when the ratio limit is exceeded."),
+ Body = create_gzip_bomb(),
+ {413, _, _} = do_post("/echo/normal",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ ok.
+
+set_accept_encoding_response(Config) ->
+ doc("Header accept-encoding must be set on valid response command. "
+ "(RFC9110 12.5.3)"),
+ Data = <<"test">>,
+ Body = zlib:gzip(Data),
+ {200, Headers, Data} = do_post("/echo/normal",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ {_, <<"gzip">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers),
+ ok.
+
+set_accept_encoding_header(Config) ->
+ doc("Header accept-encoding must be set on valid header command. "
+ "(RFC9110 12.5.3)"),
+ Data = <<"test">>,
+ Body = zlib:gzip(Data),
+ {200, Headers, Data} = do_post("/test/header-command",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ {_, <<"gzip">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers),
+ ok.
+
+add_accept_encoding_header_valid(Config) ->
+ doc("Supported content codings must be added to the accept-encoding "
+ "header if it already exists. (RFC9110 12.5.3)"),
+ Data = <<"test">>,
+ Body = zlib:gzip(Data),
+ {200, Headers, Data} = do_post("/test/accept-identity",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ {_, <<"identity, gzip">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers),
+ ok.
+
+override_accept_encoding_header_invalid(Config) ->
+ doc("When the stream handler cannot parse the accept-encoding header "
+ "found in the response, it overrides it."),
+ Data = <<"test">>,
+ Body = zlib:gzip(Data),
+ {200, Headers, Data} = do_post("/test/invalid-header",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ {_, <<"gzip">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers),
+ ok.
+
+override_accept_encoding_excluded(Config) ->
+ doc("The stream handler must ensure that the content encodings "
+ "it supports are not marked as unsupported in response headers. "
+ "The stream handler enables gzip when explicitly excluded. "
+ "(RFC9110 12.5.3)"),
+ Data = <<"test">>,
+ Body = zlib:gzip(Data),
+ {200, Headers, Data} = do_post("/test/reject-explicit-header",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ {_, <<"identity;q=1, gzip;q=1">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers),
+ ok.
+
+%% *;q=0 will reject codings that are not listed. Supported codings
+%% must always be enabled when the handler is used.
+add_accept_encoding_excluded(Config) ->
+ doc("The stream handler must ensure that the content encodings "
+ "it supports are not marked as unsupported in response headers. "
+ "The stream handler enables gzip when implicitly excluded (*;q=0). "
+ "(RFC9110 12.5.3)"),
+ Data = <<"test">>,
+ Body = zlib:gzip(Data),
+ {200, Headers, Data} = do_post("/test/reject-implicit-header",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ {_, <<"gzip;q=1, identity;q=1, *;q=0">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers),
+ ok.
+
+no_override_accept_coding_set_explicit(Config) ->
+ doc("Confirm that accept-encoding is not overridden when the "
+ "content encodings it supports are explicitly set. "
+ "(RFC9110 12.5.3)"),
+ Data = <<"test">>,
+ Body = zlib:gzip(Data),
+ {200, Headers, Data} = do_post("/test/accept-explicit-header",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ {_, <<"identity, gzip;q=0.5">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers),
+ ok.
+
+no_override_accept_coding_set_implicit(Config) ->
+ doc("Confirm that accept-encoding is not overridden when the "
+ "content encodings it supports are implicitly set. "
+ "(RFC9110 12.5.3)"),
+ Data = <<"test">>,
+ Body = zlib:gzip(Data),
+ {200, Headers, Data} = do_post("/test/accept-implicit-header",
+ [{<<"content-encoding">>, <<"gzip">>}], Body, Config),
+ {_, <<"identity, *;q=0.5">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers),
+ ok.
diff --git a/test/examples_SUITE.erl b/test/examples_SUITE.erl
index 0a3b0eb..e2327bc 100644
--- a/test/examples_SUITE.erl
+++ b/test/examples_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2016-2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2016-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -80,7 +80,7 @@ do_compile_and_start(Example, Config) ->
%% TERM=dumb disables relx coloring.
ct:log("~s~n", [os:cmd(Make ++ " -C " ++ Dir ++ " TERM=dumb")]),
ct:log("~s~n", [os:cmd(Rel ++ " stop")]),
- ct:log("~s~n", [os:cmd(Rel ++ " start")]),
+ ct:log("~s~n", [os:cmd(Rel ++ " daemon")]),
timer:sleep(2000),
ok.
@@ -372,13 +372,16 @@ file_server(Config) ->
do_file_server(Transport, Protocol, Config) ->
%% Directory.
{200, DirHeaders, <<"<!DOCTYPE html><html>", _/bits >>} = do_get(Transport, Protocol, "/", Config),
- {_, <<"text/html">>} = lists:keyfind(<<"content-type">>, 1, DirHeaders),
+ {_, <<"text/html; charset=utf-8">>} = lists:keyfind(<<"content-type">>, 1, DirHeaders),
_ = do_rest_get(Transport, Protocol, "/", <<"application/json">>, undefined, Config),
%% Files.
{200, _, _} = do_get(Transport, Protocol, "/small.mp4", Config),
{200, _, _} = do_get(Transport, Protocol, "/small.ogv", Config),
{200, _, _} = do_get(Transport, Protocol, "/test.txt", Config),
{200, _, _} = do_get(Transport, Protocol, "/video.html", Config),
+ {200, _, _} = do_get(Transport, Protocol,
+ ["/", cow_uri:urlencode(<<"中文"/utf8>>), "/", cow_uri:urlencode(<<"中文.html"/utf8>>)],
+ Config),
ok.
%% Markdown middleware.
diff --git a/test/h2spec_SUITE.erl b/test/h2spec_SUITE.erl
index 08497e9..67ccf03 100644
--- a/test/h2spec_SUITE.erl
+++ b/test/h2spec_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
diff --git a/test/handlers/compress_h.erl b/test/handlers/compress_h.erl
index 27edbd3..658c834 100644
--- a/test/handlers/compress_h.erl
+++ b/test/handlers/compress_h.erl
@@ -19,6 +19,9 @@ init(Req0, State=reply) ->
<<"content-encoding">> ->
cowboy_req:reply(200, #{<<"content-encoding">> => <<"compress">>},
lists:duplicate(100000, $a), Req0);
+ <<"etag">> ->
+ cowboy_req:reply(200, #{<<"etag">> => <<"\"STRONK\"">>},
+ lists:duplicate(100000, $a), Req0);
<<"sendfile">> ->
AppFile = code:where_is_file("cowboy.app"),
Size = filelib:file_size(AppFile),
@@ -34,6 +37,8 @@ init(Req0, State=stream_reply) ->
stream_reply(#{}, Req0);
<<"content-encoding">> ->
stream_reply(#{<<"content-encoding">> => <<"compress">>}, Req0);
+ <<"etag">> ->
+ stream_reply(#{<<"etag">> => <<"\"STRONK\"">>}, Req0);
<<"sendfile">> ->
Data = lists:duplicate(10000, $a),
AppFile = code:where_is_file("cowboy.app"),
diff --git a/test/handlers/decompress_h.erl b/test/handlers/decompress_h.erl
new file mode 100644
index 0000000..deb6de0
--- /dev/null
+++ b/test/handlers/decompress_h.erl
@@ -0,0 +1,84 @@
+%% This module echoes a request body of to test
+%% the cowboy_decompress_h stream handler.
+
+-module(decompress_h).
+
+-export([init/2]).
+
+init(Req0, State=echo) ->
+ case cowboy_req:binding(what, Req0) of
+ <<"decompress_disable">> ->
+ cowboy_req:cast({set_options, #{decompress_enabled => false}}, Req0);
+ <<"decompress_ratio_limit">> ->
+ cowboy_req:cast({set_options, #{decompress_ratio_limit => 0.5}}, Req0);
+ <<"normal">> -> ok
+ end,
+ {ok, Body, Req1} = read_body(Req0),
+ Req = cowboy_req:reply(200, #{}, Body, Req1),
+ {ok, Req, State};
+init(Req0, State=test) ->
+ Req = test(Req0, cowboy_req:binding(what, Req0)),
+ {ok, Req, State}.
+
+test(Req, <<"content-encoding">>) ->
+ cowboy_req:reply(200, #{},
+ cowboy_req:header(<<"content-encoding">>, Req, <<"undefined">>),
+ Req);
+test(Req, <<"content-decoded">>) ->
+ cowboy_req:reply(200, #{},
+ io_lib:format("~0p", [maps:get(content_decoded, Req, undefined)]),
+ Req);
+test(Req0, <<"disable-in-the-middle">>) ->
+ {Status, Data, Req1} = cowboy_req:read_body(Req0, #{length => 1000}),
+ cowboy_req:cast({set_options, #{decompress_enabled => false}}, Req1),
+ {ok, Body, Req} = do_read_body(Status, Req1, Data),
+ cowboy_req:reply(200, #{}, Body, Req);
+test(Req0, <<"enable-in-the-middle">>) ->
+ {Status, Data, Req1} = cowboy_req:read_body(Req0, #{length => 1000}),
+ cowboy_req:cast({set_options, #{decompress_enabled => true}}, Req1),
+ {ok, Body, Req} = do_read_body(Status, Req1, Data),
+ cowboy_req:reply(200, #{}, Body, Req);
+test(Req0, <<"header-command">>) ->
+ {ok, Body, Req1} = read_body(Req0),
+ Req = cowboy_req:stream_reply(200, #{}, Req1),
+ cowboy_req:stream_body(Body, fin, Req);
+test(Req0, <<"accept-identity">>) ->
+ {ok, Body, Req} = read_body(Req0),
+ cowboy_req:reply(200,
+ #{<<"accept-encoding">> => <<"identity">>},
+ Body, Req);
+test(Req0, <<"invalid-header">>) ->
+ {ok, Body, Req} = read_body(Req0),
+ cowboy_req:reply(200,
+ #{<<"accept-encoding">> => <<";">>},
+ Body, Req);
+test(Req0, <<"reject-explicit-header">>) ->
+ {ok, Body, Req} = read_body(Req0),
+ cowboy_req:reply(200,
+ #{<<"accept-encoding">> => <<"identity, gzip;q=0">>},
+ Body, Req);
+test(Req0, <<"reject-implicit-header">>) ->
+ {ok, Body, Req} = read_body(Req0),
+ cowboy_req:reply(200,
+ #{<<"accept-encoding">> => <<"identity, *;q=0">>},
+ Body, Req);
+test(Req0, <<"accept-explicit-header">>) ->
+ {ok, Body, Req} = read_body(Req0),
+ cowboy_req:reply(200,
+ #{<<"accept-encoding">> => <<"identity, gzip;q=0.5">>},
+ Body, Req);
+test(Req0, <<"accept-implicit-header">>) ->
+ {ok, Body, Req} = read_body(Req0),
+ cowboy_req:reply(200,
+ #{<<"accept-encoding">> => <<"identity, *;q=0.5">>},
+ Body, Req).
+
+read_body(Req0) ->
+ {Status, Data, Req} = cowboy_req:read_body(Req0, #{length => 1000}),
+ do_read_body(Status, Req, Data).
+
+do_read_body(more, Req0, Acc) ->
+ {Status, Data, Req} = cowboy_req:read_body(Req0),
+ do_read_body(Status, Req, << Acc/binary, Data/binary >>);
+do_read_body(ok, Req, Acc) ->
+ {ok, Acc, Req}.
diff --git a/test/handlers/echo_h.erl b/test/handlers/echo_h.erl
index 1b672d1..d04d531 100644
--- a/test/handlers/echo_h.erl
+++ b/test/handlers/echo_h.erl
@@ -25,6 +25,8 @@ echo(<<"read_body">>, Req0, Opts) ->
timer:sleep(500),
cowboy_req:read_body(Req0);
<<"/full", _/bits>> -> read_body(Req0, <<>>);
+ <<"/auto-sync", _/bits>> -> read_body_auto_sync(Req0, <<>>);
+ <<"/auto-async", _/bits>> -> read_body_auto_async(Req0, <<>>);
<<"/length", _/bits>> ->
{_, _, Req1} = read_body(Req0, <<>>),
Length = cowboy_req:body_length(Req1),
@@ -84,6 +86,7 @@ echo(<<"match">>, Req, Opts) ->
Fields = [binary_to_atom(F, latin1) || F <- Fields0],
Value = case Type of
<<"qs">> -> cowboy_req:match_qs(Fields, Req);
+ <<"qs_with_constraints">> -> cowboy_req:match_qs([{id, integer}], Req);
<<"cookies">> -> cowboy_req:match_cookies(Fields, Req);
<<"body_qs">> ->
%% Note that the Req should not be discarded but for the
@@ -122,6 +125,25 @@ read_body(Req0, Acc) ->
{more, Data, Req} -> read_body(Req, << Acc/binary, Data/binary >>)
end.
+read_body_auto_sync(Req0, Acc) ->
+ Opts = #{length => auto, period => infinity},
+ case cowboy_req:read_body(Req0, Opts) of
+ {ok, Data, Req} -> {ok, << Acc/binary, Data/binary >>, Req};
+ {more, Data, Req} -> read_body_auto_sync(Req, << Acc/binary, Data/binary >>)
+ end.
+
+read_body_auto_async(Req, Acc) ->
+ read_body_auto_async(Req, make_ref(), Acc).
+
+read_body_auto_async(Req, ReadBodyRef, Acc) ->
+ cowboy_req:cast({read_body, self(), ReadBodyRef, auto, infinity}, Req),
+ receive
+ {request_body, ReadBodyRef, nofin, Data} ->
+ read_body_auto_async(Req, ReadBodyRef, <<Acc/binary, Data/binary>>);
+ {request_body, ReadBodyRef, fin, _, Data} ->
+ {ok, <<Acc/binary, Data/binary>>, Req}
+ end.
+
value_to_iodata(V) when is_integer(V) -> integer_to_binary(V);
value_to_iodata(V) when is_atom(V) -> atom_to_binary(V, latin1);
value_to_iodata(V) when is_list(V); is_tuple(V); is_map(V) -> io_lib:format("~999999p", [V]);
diff --git a/test/handlers/generate_etag_h.erl b/test/handlers/generate_etag_h.erl
index 97ee82b..b9e1302 100644
--- a/test/handlers/generate_etag_h.erl
+++ b/test/handlers/generate_etag_h.erl
@@ -34,6 +34,9 @@ generate_etag(Req=#{qs := <<"binary-weak-unquoted">>}, State) ->
generate_etag(Req=#{qs := <<"binary-strong-unquoted">>}, State) ->
ct_helper_error_h:ignore(cow_http_hd, parse_etag, 1),
{<<"etag-header-value">>, Req, State};
+%% Returning 'undefined' to indicate no etag.
+generate_etag(Req=#{qs := <<"undefined">>}, State) ->
+ {undefined, Req, State};
%% Simulate the callback being missing in other cases.
generate_etag(#{qs := <<"missing">>}, _) ->
no_call.
diff --git a/test/handlers/loop_handler_endless_h.erl b/test/handlers/loop_handler_endless_h.erl
new file mode 100644
index 0000000..d8c8ab5
--- /dev/null
+++ b/test/handlers/loop_handler_endless_h.erl
@@ -0,0 +1,25 @@
+%% This module implements a loop handler that streams endless data.
+
+-module(loop_handler_endless_h).
+
+-export([init/2]).
+-export([info/3]).
+
+init(Req0, #{delay := Delay} = Opts) ->
+ case cowboy_req:header(<<"x-test-pid">>, Req0) of
+ BinPid when is_binary(BinPid) ->
+ Pid = list_to_pid(binary_to_list(BinPid)),
+ Pid ! {Pid, self(), init},
+ ok;
+ _ ->
+ ok
+ end,
+ erlang:send_after(Delay, self(), timeout),
+ Req = cowboy_req:stream_reply(200, Req0),
+ {cowboy_loop, Req, Opts}.
+
+info(timeout, Req, State) ->
+ cowboy_req:stream_body(<<0:10000/unit:8>>, nofin, Req),
+ %% Equivalent to a 0 timeout.
+ self() ! timeout,
+ {ok, Req, State}.
diff --git a/test/handlers/loop_handler_timeout_hibernate_h.erl b/test/handlers/loop_handler_timeout_hibernate_h.erl
new file mode 100644
index 0000000..0485208
--- /dev/null
+++ b/test/handlers/loop_handler_timeout_hibernate_h.erl
@@ -0,0 +1,30 @@
+%% This module implements a loop handler that first
+%% sets a timeout, then hibernates, then ensures
+%% that the timeout initially set no longer triggers.
+%% If everything goes fine a 200 is returned. If the
+%% timeout triggers again a 299 is.
+
+-module(loop_handler_timeout_hibernate_h).
+
+-export([init/2]).
+-export([info/3]).
+-export([terminate/3]).
+
+init(Req, _) ->
+ self() ! message1,
+ {cowboy_loop, Req, undefined, 100}.
+
+info(message1, Req, State) ->
+ erlang:send_after(200, self(), message2),
+ {ok, Req, State, hibernate};
+info(message2, Req, State) ->
+ erlang:send_after(200, self(), message3),
+ %% Don't set a timeout now.
+ {ok, Req, State};
+info(message3, Req, State) ->
+ {stop, cowboy_req:reply(200, Req), State};
+info(timeout, Req, State) ->
+ {stop, cowboy_req:reply(<<"299 OK!">>, Req), State}.
+
+terminate(stop, _, _) ->
+ ok.
diff --git a/test/handlers/loop_handler_timeout_info_h.erl b/test/handlers/loop_handler_timeout_info_h.erl
new file mode 100644
index 0000000..7a1ccba
--- /dev/null
+++ b/test/handlers/loop_handler_timeout_info_h.erl
@@ -0,0 +1,23 @@
+%% This module implements a loop handler that changes
+%% the timeout value to 500ms after the first message
+%% then sends itself another message after 1000ms.
+%% It is expected to timeout, that is, reply a 299.
+
+-module(loop_handler_timeout_info_h).
+
+-export([init/2]).
+-export([info/3]).
+-export([terminate/3]).
+
+init(Req, _) ->
+ self() ! message,
+ {cowboy_loop, Req, undefined}.
+
+info(message, Req, State) ->
+ erlang:send_after(500, self(), message),
+ {ok, Req, State, 100};
+info(timeout, Req, State) ->
+ {stop, cowboy_req:reply(<<"299 OK!">>, Req), State}.
+
+terminate(stop, _, _) ->
+ ok.
diff --git a/test/handlers/loop_handler_timeout_init_h.erl b/test/handlers/loop_handler_timeout_init_h.erl
new file mode 100644
index 0000000..7908fda
--- /dev/null
+++ b/test/handlers/loop_handler_timeout_init_h.erl
@@ -0,0 +1,23 @@
+%% This module implements a loop handler that reads
+%% the request query for a timeout value, then sends
+%% itself a message after 1000ms. It replies a 200 when
+%% the message does not timeout and a 299 otherwise.
+
+-module(loop_handler_timeout_init_h).
+
+-export([init/2]).
+-export([info/3]).
+-export([terminate/3]).
+
+init(Req, _) ->
+ #{timeout := Timeout} = cowboy_req:match_qs([{timeout, int}], Req),
+ erlang:send_after(500, self(), message),
+ {cowboy_loop, Req, undefined, Timeout}.
+
+info(message, Req, State) ->
+ {stop, cowboy_req:reply(200, Req), State};
+info(timeout, Req, State) ->
+ {stop, cowboy_req:reply(<<"299 OK!">>, Req), State}.
+
+terminate(stop, _, _) ->
+ ok.
diff --git a/test/handlers/resp_h.erl b/test/handlers/resp_h.erl
index 8031d0e..6e9b5f7 100644
--- a/test/handlers/resp_h.erl
+++ b/test/handlers/resp_h.erl
@@ -30,6 +30,10 @@ do(<<"set_resp_cookie4">>, Req0, Opts) ->
do(<<"set_resp_header">>, Req0, Opts) ->
Req = cowboy_req:set_resp_header(<<"content-type">>, <<"text/plain">>, Req0),
{ok, cowboy_req:reply(200, #{}, "OK", Req), Opts};
+do(<<"set_resp_header_cookie">>, Req0, Opts) ->
+ ct_helper:ignore(cowboy_req, set_resp_header, 3),
+ Req = cowboy_req:set_resp_header(<<"set-cookie">>, <<"name=value">>, Req0),
+ {ok, cowboy_req:reply(200, #{}, "OK", Req), Opts};
do(<<"set_resp_header_server">>, Req0, Opts) ->
Req = cowboy_req:set_resp_header(<<"server">>, <<"nginx">>, Req0),
{ok, cowboy_req:reply(200, #{}, "OK", Req), Opts};
@@ -39,6 +43,12 @@ do(<<"set_resp_headers">>, Req0, Opts) ->
<<"content-encoding">> => <<"compress">>
}, Req0),
{ok, cowboy_req:reply(200, #{}, "OK", Req), Opts};
+do(<<"set_resp_headers_cookie">>, Req0, Opts) ->
+ ct_helper:ignore(cowboy_req, set_resp_headers, 2),
+ Req = cowboy_req:set_resp_headers(#{
+ <<"set-cookie">> => <<"name=value">>
+ }, Req0),
+ {ok, cowboy_req:reply(200, #{}, "OK", Req), Opts};
do(<<"set_resp_headers_http11">>, Req0, Opts) ->
Req = cowboy_req:set_resp_headers(#{
<<"connection">> => <<"custom-header, close">>,
@@ -130,6 +140,10 @@ do(<<"inform2">>, Req0, Opts) ->
<<"twice">> ->
cowboy_req:inform(102, Req0),
cowboy_req:inform(102, Req0);
+ <<"after_reply">> ->
+ ct_helper:ignore(cowboy_req, inform, 3),
+ Req1 = cowboy_req:reply(200, Req0),
+ cowboy_req:inform(102, Req1);
Status ->
cowboy_req:inform(binary_to_integer(Status), Req0)
end,
@@ -143,9 +157,16 @@ do(<<"inform3">>, Req0, Opts) ->
<<"error">> ->
ct_helper:ignore(cowboy_req, inform, 3),
cowboy_req:inform(ok, Headers, Req0);
+ <<"set_cookie">> ->
+ ct_helper:ignore(cowboy_req, inform, 3),
+ cowboy_req:inform(102, #{<<"set-cookie">> => <<"name=value">>}, Req0);
<<"twice">> ->
cowboy_req:inform(102, Headers, Req0),
cowboy_req:inform(102, Headers, Req0);
+ <<"after_reply">> ->
+ ct_helper:ignore(cowboy_req, inform, 3),
+ Req1 = cowboy_req:reply(200, Req0),
+ cowboy_req:inform(102, Headers, Req1);
Status ->
cowboy_req:inform(binary_to_integer(Status), Headers, Req0)
end,
@@ -161,6 +182,7 @@ do(<<"reply2">>, Req0, Opts) ->
<<"twice">> ->
ct_helper:ignore(cowboy_req, reply, 4),
Req1 = cowboy_req:reply(200, Req0),
+ timer:sleep(100),
cowboy_req:reply(200, Req1);
Status ->
cowboy_req:reply(binary_to_integer(Status), Req0)
@@ -171,6 +193,9 @@ do(<<"reply3">>, Req0, Opts) ->
<<"error">> ->
ct_helper:ignore(cowboy_req, reply, 4),
cowboy_req:reply(200, ok, Req0);
+ <<"set_cookie">> ->
+ ct_helper:ignore(cowboy_req, reply, 4),
+ cowboy_req:reply(200, #{<<"set-cookie">> => <<"name=value">>}, Req0);
Status ->
cowboy_req:reply(binary_to_integer(Status),
#{<<"content-type">> => <<"text/plain">>}, Req0)
@@ -181,11 +206,14 @@ do(<<"reply4">>, Req0, Opts) ->
<<"error">> ->
ct_helper:ignore(erlang, iolist_size, 1),
cowboy_req:reply(200, #{}, ok, Req0);
- <<"204body">> ->
+ <<"set_cookie">> ->
ct_helper:ignore(cowboy_req, reply, 4),
+ cowboy_req:reply(200, #{<<"set-cookie">> => <<"name=value">>}, <<"OK">>, Req0);
+ <<"204body">> ->
+ ct_helper:ignore(cowboy_req, do_reply_ensure_no_body, 4),
cowboy_req:reply(204, #{}, <<"OK">>, Req0);
<<"304body">> ->
- ct_helper:ignore(cowboy_req, reply, 4),
+ ct_helper:ignore(cowboy_req, do_reply_ensure_no_body, 4),
cowboy_req:reply(304, #{}, <<"OK">>, Req0);
Status ->
cowboy_req:reply(binary_to_integer(Status), #{}, <<"OK">>, Req0)
@@ -215,6 +243,14 @@ do(<<"stream_reply2">>, Req0, Opts) ->
Req = cowboy_req:stream_reply(304, Req0),
stream_body(Req),
{ok, Req, Opts};
+ <<"twice">> ->
+ ct_helper:ignore(cowboy_req, stream_reply, 3),
+ Req1 = cowboy_req:stream_reply(200, Req0),
+ timer:sleep(100),
+ %% We will crash here so the body shouldn't be sent.
+ Req = cowboy_req:stream_reply(200, Req1),
+ stream_body(Req),
+ {ok, Req, Opts};
Status ->
Req = cowboy_req:stream_reply(binary_to_integer(Status), Req0),
stream_body(Req),
@@ -225,6 +261,9 @@ do(<<"stream_reply3">>, Req0, Opts) ->
<<"error">> ->
ct_helper:ignore(cowboy_req, stream_reply, 3),
cowboy_req:stream_reply(200, ok, Req0);
+ <<"set_cookie">> ->
+ ct_helper:ignore(cowboy_req, stream_reply, 3),
+ cowboy_req:stream_reply(200, #{<<"set-cookie">> => <<"name=value">>}, Req0);
Status ->
cowboy_req:stream_reply(binary_to_integer(Status),
#{<<"content-type">> => <<"text/plain">>}, Req0)
@@ -380,6 +419,16 @@ do(<<"stream_trailers">>, Req0, Opts) ->
<<"grpc-status">> => <<"0">>
}, Req),
{ok, Req, Opts};
+ <<"set_cookie">> ->
+ ct_helper:ignore(cowboy_req, stream_trailers, 2),
+ Req = cowboy_req:stream_reply(200, #{
+ <<"trailer">> => <<"set-cookie">>
+ }, Req0),
+ cowboy_req:stream_body(<<"Hello world!">>, nofin, Req),
+ cowboy_req:stream_trailers(#{
+ <<"set-cookie">> => <<"name=value">>
+ }, Req),
+ {ok, Req, Opts};
_ ->
Req = cowboy_req:stream_reply(200, #{
<<"trailer">> => <<"grpc-status">>
@@ -403,6 +452,11 @@ do(<<"push">>, Req, Opts) ->
<<"qs">> ->
cowboy_req:push("/static/style.css", #{<<"accept">> => <<"text/css">>}, Req,
#{qs => <<"server=cowboy&version=2.0">>});
+ <<"after_reply">> ->
+ ct_helper:ignore(cowboy_req, push, 4),
+ Req1 = cowboy_req:reply(200, Req),
+ %% We will crash here so no need to worry about propagating Req1.
+ cowboy_req:push("/static/style.css", #{<<"accept">> => <<"text/css">>}, Req1);
_ ->
cowboy_req:push("/static/style.css", #{<<"accept">> => <<"text/css">>}, Req),
%% The text/plain mime is not defined by default, so a 406 will be returned.
diff --git a/test/handlers/stream_handler_h.erl b/test/handlers/stream_handler_h.erl
index 370d15a..7a1e5ec 100644
--- a/test/handlers/stream_handler_h.erl
+++ b/test/handlers/stream_handler_h.erl
@@ -44,16 +44,16 @@ init_commands(_, _, #state{test=set_options_ignore_unknown}) ->
];
init_commands(_, _, State=#state{test=shutdown_on_stream_stop}) ->
Spawn = init_process(false, State),
- [{headers, 200, #{}}, {spawn, Spawn, 5000}, stop];
+ [{spawn, Spawn, 5000}, {headers, 200, #{}}, stop];
init_commands(_, _, State=#state{test=shutdown_on_socket_close}) ->
Spawn = init_process(false, State),
- [{headers, 200, #{}}, {spawn, Spawn, 5000}];
+ [{spawn, Spawn, 5000}, {headers, 200, #{}}];
init_commands(_, _, State=#state{test=shutdown_timeout_on_stream_stop}) ->
Spawn = init_process(true, State),
- [{headers, 200, #{}}, {spawn, Spawn, 2000}, stop];
+ [{spawn, Spawn, 2000}, {headers, 200, #{}}, stop];
init_commands(_, _, State=#state{test=shutdown_timeout_on_socket_close}) ->
Spawn = init_process(true, State),
- [{headers, 200, #{}}, {spawn, Spawn, 2000}];
+ [{spawn, Spawn, 2000}, {headers, 200, #{}}];
init_commands(_, _, State=#state{test=switch_protocol_after_headers}) ->
[{headers, 200, #{}}, {switch_protocol, #{}, ?MODULE, State}];
init_commands(_, _, State=#state{test=switch_protocol_after_headers_data}) ->
diff --git a/test/handlers/streamed_result_h.erl b/test/handlers/streamed_result_h.erl
new file mode 100644
index 0000000..ea6f492
--- /dev/null
+++ b/test/handlers/streamed_result_h.erl
@@ -0,0 +1,20 @@
+-module(streamed_result_h).
+
+-export([init/2]).
+
+init(Req, Opts) ->
+ N = list_to_integer(binary_to_list(cowboy_req:binding(n, Req))),
+ Interval = list_to_integer(binary_to_list(cowboy_req:binding(interval, Req))),
+ chunked(N, Interval, Req, Opts).
+
+chunked(N, Interval, Req0, Opts) ->
+ Req = cowboy_req:stream_reply(200, Req0),
+ {ok, loop(N, Interval, Req), Opts}.
+
+loop(0, _Interval, Req) ->
+ ok = cowboy_req:stream_body("Finished!\n", fin, Req),
+ Req;
+loop(N, Interval, Req) ->
+ ok = cowboy_req:stream_body(iolist_to_binary([integer_to_list(N), <<"\n">>]), nofin, Req),
+ timer:sleep(Interval),
+ loop(N-1, Interval, Req).
diff --git a/test/handlers/ws_init_h.erl b/test/handlers/ws_init_h.erl
index db5307b..bbe9ef9 100644
--- a/test/handlers/ws_init_h.erl
+++ b/test/handlers/ws_init_h.erl
@@ -36,7 +36,10 @@ do_websocket_init(State=reply_many_hibernate) ->
do_websocket_init(State=reply_many_close) ->
{[{text, "Hello"}, close], State};
do_websocket_init(State=reply_many_close_hibernate) ->
- {[{text, "Hello"}, close], State, hibernate}.
+ {[{text, "Hello"}, close], State, hibernate};
+do_websocket_init(State=reply_trap_exit) ->
+ Text = "trap_exit: " ++ atom_to_list(element(2, process_info(self(), trap_exit))),
+ {[{text, Text}, close], State, hibernate}.
websocket_handle(_, State) ->
{[], State}.
diff --git a/test/handlers/ws_ping_h.erl b/test/handlers/ws_ping_h.erl
new file mode 100644
index 0000000..a5848fe
--- /dev/null
+++ b/test/handlers/ws_ping_h.erl
@@ -0,0 +1,23 @@
+%% This module sends an empty ping to the client and
+%% waits for a pong before sending a text frame. It
+%% is used to confirm server-initiated pings work.
+
+-module(ws_ping_h).
+-behavior(cowboy_websocket).
+
+-export([init/2]).
+-export([websocket_init/1]).
+-export([websocket_handle/2]).
+-export([websocket_info/2]).
+
+init(Req, _) ->
+ {cowboy_websocket, Req, undefined}.
+
+websocket_init(State) ->
+ {[{ping, <<>>}], State}.
+
+websocket_handle(pong, State) ->
+ {[{text, <<"OK!!">>}], State}.
+
+websocket_info(_, State) ->
+ {[], State}.
diff --git a/test/http2_SUITE.erl b/test/http2_SUITE.erl
index fe6325d..d17508a 100644
--- a/test/http2_SUITE.erl
+++ b/test/http2_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -29,7 +29,8 @@ init_dispatch(_) ->
cowboy_router:compile([{"localhost", [
{"/", hello_h, []},
{"/echo/:key", echo_h, []},
- {"/resp_iolist_body", resp_iolist_body_h, []}
+ {"/resp_iolist_body", resp_iolist_body_h, []},
+ {"/streamed_result/:n/:interval", streamed_result_h, []}
]}]).
%% Do a prior knowledge handshake (function originally copied from rfc7540_SUITE).
@@ -37,7 +38,8 @@ do_handshake(Config) ->
do_handshake(#{}, Config).
do_handshake(Settings, Config) ->
- {ok, Socket} = gen_tcp:connect("localhost", config(port, Config), [binary, {active, false}]),
+ {ok, Socket} = gen_tcp:connect("localhost", config(port, Config),
+ [binary, {active, false}|proplists:get_value(tcp_opts, Config, [])]),
%% Send a valid preface.
ok = gen_tcp:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", cow_http2:settings(Settings)]),
%% Receive the server preface.
@@ -61,7 +63,8 @@ idle_timeout(Config) ->
{ok, Socket} = do_handshake([{port, Port}|Config]),
timer:sleep(1000),
%% Receive a GOAWAY frame back with NO_ERROR.
- {ok, << _:24, 7:8, _:72, 0:32 >>} = gen_tcp:recv(Socket, 17, 1000)
+ {ok, << _:24, 7:8, _:72, 0:32 >>} = gen_tcp:recv(Socket, 17, 1000),
+ gen_tcp:close(Socket)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
@@ -78,7 +81,8 @@ idle_timeout_infinity(Config) ->
{ok, Socket} = do_handshake([{port, Port}|Config]),
timer:sleep(1000),
%% Don't receive a GOAWAY frame.
- {error, timeout} = gen_tcp:recv(Socket, 17, 1000)
+ {error, timeout} = gen_tcp:recv(Socket, 17, 1000),
+ gen_tcp:close(Socket)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
@@ -107,11 +111,21 @@ idle_timeout_reset_on_data(Config) ->
{ok, <<8:24, 6:8, 0:7, 1:1, 0:96>>} = gen_tcp:recv(Socket, 17, 1000),
%% The connection goes away soon after we stop sending data.
timer:sleep(1000),
- {ok, << _:24, 7:8, _:72, 0:32 >>} = gen_tcp:recv(Socket, 17, 1000)
+ {ok, << _:24, 7:8, _:72, 0:32 >>} = gen_tcp:recv(Socket, 17, 1000),
+ gen_tcp:close(Socket)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
+idle_timeout_on_send(Config) ->
+ doc("Ensure the idle timeout is not reset when sending (by default)."),
+ http_SUITE:do_idle_timeout_on_send(Config, http2).
+
+idle_timeout_reset_on_send(Config) ->
+ doc("Ensure the reset_idle_timeout_on_send results in the "
+ "idle timeout resetting when sending ."),
+ http_SUITE:do_idle_timeout_reset_on_send(Config, http2).
+
inactivity_timeout(Config) ->
doc("Terminate when the inactivity timeout is reached."),
ProtoOpts = #{
@@ -124,7 +138,8 @@ inactivity_timeout(Config) ->
{ok, Socket} = do_handshake([{port, Port}|Config]),
receive after 1000 -> ok end,
%% Receive a GOAWAY frame back with an INTERNAL_ERROR.
- {ok, << _:24, 7:8, _:72, 2:32 >>} = gen_tcp:recv(Socket, 17, 1000)
+ {ok, << _:24, 7:8, _:72, 2:32 >>} = gen_tcp:recv(Socket, 17, 1000),
+ gen_tcp:close(Socket)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
@@ -148,7 +163,8 @@ initial_connection_window_size(Config) ->
{ok, << 4:8, 0:40, _:Len/binary >>} = gen_tcp:recv(Socket, 6 + Len, 1000),
%% Receive a WINDOW_UPDATE frame incrementing the connection window to 100000.
{ok, <<4:24, 8:8, 0:41, Size:31>>} = gen_tcp:recv(Socket, 13, 1000),
- ConfiguredSize = Size + 65535
+ ConfiguredSize = Size + 65535,
+ gen_tcp:close(Socket)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
@@ -191,7 +207,8 @@ max_frame_size_sent(Config) ->
%% The DATA frames following must have lengths of 20000
%% and then 10000 due to the limit.
{ok, <<20000:24, 0:8, _:40, _:20000/unit:8>>} = gen_tcp:recv(Socket, 20009, 6000),
- {ok, <<10000:24, 0:8, _:40, _:10000/unit:8>>} = gen_tcp:recv(Socket, 10009, 6000)
+ {ok, <<10000:24, 0:8, _:40, _:10000/unit:8>>} = gen_tcp:recv(Socket, 10009, 6000),
+ gen_tcp:close(Socket)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
@@ -235,7 +252,7 @@ preface_timeout_infinity(Config) ->
{'DOWN', Ref, process, Pid, Reason} ->
error(Reason)
after 1000 ->
- ok
+ gen_tcp:close(Socket)
end
after
cowboy:stop_listener(?FUNCTION_NAME)
@@ -279,7 +296,7 @@ settings_timeout_infinity(Config) ->
{'DOWN', Ref, process, Pid, Reason} ->
error(Reason)
after 1000 ->
- ok
+ gen_tcp:close(Socket)
end
after
cowboy:stop_listener(?FUNCTION_NAME)
@@ -365,6 +382,10 @@ graceful_shutdown_timeout(Config) ->
graceful_shutdown_listener(Config) ->
doc("Check that connections are shut down gracefully when stopping a listener."),
+ TransOpts = #{
+ socket_opts => [{port, 0}],
+ shutdown => 1000 %% Shorter timeout to make the test case faster.
+ },
Dispatch = cowboy_router:compile([{"localhost", [
{"/delay_hello", delay_hello_h,
#{delay => 500, notify_received => self()}}
@@ -372,13 +393,15 @@ graceful_shutdown_listener(Config) ->
ProtoOpts = #{
env => #{dispatch => Dispatch}
},
- {ok, Listener} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], ProtoOpts),
+ {ok, Listener} = cowboy:start_clear(?FUNCTION_NAME, TransOpts, ProtoOpts),
Port = ranch:get_port(?FUNCTION_NAME),
ConnPid = gun_open([{type, tcp}, {protocol, http2}, {port, Port}|Config]),
Ref = gun:get(ConnPid, "/delay_hello"),
%% Shutdown listener while the handlers are working.
receive {request_received, <<"/delay_hello">>} -> ok end,
ListenerMonitorRef = monitor(process, Listener),
+ %% Note: This call does not complete quickly and will
+ %% prevent other cowboy:stop_listener/1 calls to complete.
ok = cowboy:stop_listener(?FUNCTION_NAME),
receive
{'DOWN', ListenerMonitorRef, process, Listener, _Reason} ->
@@ -392,6 +415,10 @@ graceful_shutdown_listener(Config) ->
graceful_shutdown_listener_timeout(Config) ->
doc("Check that connections are shut down when gracefully stopping a listener times out."),
+ TransOpts = #{
+ socket_opts => [{port, 0}],
+ shutdown => 1000 %% Shorter timeout to make the test case faster.
+ },
Dispatch = cowboy_router:compile([{"localhost", [
{"/long_delay_hello", delay_hello_h,
#{delay => 10000, notify_received => self()}}
@@ -401,13 +428,15 @@ graceful_shutdown_listener_timeout(Config) ->
goaway_initial_timeout => 200,
goaway_complete_timeout => 500
},
- {ok, Listener} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], ProtoOpts),
+ {ok, Listener} = cowboy:start_clear(?FUNCTION_NAME, TransOpts, ProtoOpts),
Port = ranch:get_port(?FUNCTION_NAME),
ConnPid = gun_open([{type, tcp}, {protocol, http2}, {port, Port}|Config]),
Ref = gun:get(ConnPid, "/long_delay_hello"),
%% Shutdown listener while the handlers are working.
receive {request_received, <<"/long_delay_hello">>} -> ok end,
ListenerMonitorRef = monitor(process, Listener),
+ %% Note: This call does not complete quickly and will
+ %% prevent other cowboy:stop_listener/1 calls to complete.
ok = cowboy:stop_listener(?FUNCTION_NAME),
receive
{'DOWN', ListenerMonitorRef, process, Listener, _Reason} ->
@@ -416,3 +445,73 @@ graceful_shutdown_listener_timeout(Config) ->
%% Check that the slow request is aborted.
{error, {stream_error, closed}} = gun:await(ConnPid, Ref),
gun:close(ConnPid).
+
+send_timeout_close(Config) ->
+ doc("Check that connections are closed on send timeout."),
+ TransOpts = #{
+ port => 0,
+ socket_opts => [
+ {send_timeout, 100},
+ {send_timeout_close, true},
+ {sndbuf, 10}
+ ]
+ },
+ Dispatch = cowboy_router:compile([{"localhost", [
+ {"/endless", loop_handler_endless_h, #{delay => 100}}
+ ]}]),
+ ProtoOpts = #{
+ env => #{dispatch => Dispatch},
+ idle_timeout => infinity
+ },
+ {ok, _} = cowboy:start_clear(?FUNCTION_NAME, TransOpts, ProtoOpts),
+ Port = ranch:get_port(?FUNCTION_NAME),
+ try
+ %% Connect a client that sends a request and waits indefinitely.
+ {ok, ClientSocket} = do_handshake([{port, Port},
+ {tcp_opts, [{recbuf, 10}, {buffer, 10}, {active, false}]}|Config]),
+ {HeadersBlock, _} = cow_hpack:encode([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"http">>},
+ {<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+ {<<":path">>, <<"/endless">>},
+ {<<"x-test-pid">>, pid_to_list(self())}
+ ]),
+ ok = gen_tcp:send(ClientSocket, [
+ cow_http2:headers(1, fin, HeadersBlock),
+ %% Greatly increase the window to make sure we don't run
+ %% out of space before we get send timeouts.
+ cow_http2:window_update(10000000),
+ cow_http2:window_update(1, 10000000)
+ ]),
+ %% Wait for the handler to start then get its pid,
+ %% the remote connection's pid and socket.
+ StreamPid = receive
+ {Self, StreamPid0, init} when Self =:= self() ->
+ StreamPid0
+ after 1000 ->
+ error(timeout)
+ end,
+ ServerPid = ct_helper:get_remote_pid_tcp(ClientSocket),
+ {links, ServerLinks} = process_info(ServerPid, links),
+ [ServerSocket] = [PidOrPort || PidOrPort <- ServerLinks, is_port(PidOrPort)],
+ %% Poll the socket repeatedly until it is closed by the server.
+ WaitClosedFun =
+ fun F(T) when T =< 0 ->
+ error({status, prim_inet:getstatus(ServerSocket)});
+ F(T) ->
+ Snooze = 100,
+ case inet:sockname(ServerSocket) of
+ {error, _} ->
+ timer:sleep(Snooze);
+ {ok, _} ->
+ timer:sleep(Snooze),
+ F(T - Snooze)
+ end
+ end,
+ ok = WaitClosedFun(2000),
+ false = erlang:is_process_alive(StreamPid),
+ false = erlang:is_process_alive(ServerPid),
+ gen_tcp:close(ClientSocket)
+ after
+ cowboy:stop_listener(?FUNCTION_NAME)
+ end.
diff --git a/test/http_SUITE.erl b/test/http_SUITE.erl
index d0c92e4..0325279 100644
--- a/test/http_SUITE.erl
+++ b/test/http_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2018, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -24,6 +24,7 @@
-import(cowboy_test, [raw_open/1]).
-import(cowboy_test, [raw_send/2]).
-import(cowboy_test, [raw_recv_head/1]).
+-import(cowboy_test, [raw_recv_rest/3]).
-import(cowboy_test, [raw_recv/3]).
-import(cowboy_test, [raw_expect_recv/2]).
@@ -44,7 +45,8 @@ init_dispatch(_) ->
{"/", hello_h, []},
{"/echo/:key", echo_h, []},
{"/resp/:key[/:arg]", resp_h, []},
- {"/set_options/:key", set_options_h, []}
+ {"/set_options/:key", set_options_h, []},
+ {"/streamed_result/:n/:interval", streamed_result_h, []}
]}]).
chunked_false(Config) ->
@@ -88,7 +90,7 @@ chunked_one_byte_at_a_time(Config) ->
"Transfer-encoding: chunked\r\n\r\n"),
_ = [begin
raw_send(Client, <<C>>),
- timer:sleep(10)
+ timer:sleep(1)
end || <<C>> <= ChunkedBody],
Rest = case catch raw_recv_head(Client) of
{'EXIT', _} -> error(closed);
@@ -225,6 +227,68 @@ http10_keepalive_false(Config) ->
cowboy:stop_listener(?FUNCTION_NAME)
end.
+idle_timeout_read_body(Config) ->
+ doc("Ensure the idle_timeout drops connections when the "
+ "connection is idle too long reading the request body."),
+ {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{
+ env => #{dispatch => init_dispatch(Config)},
+ request_timeout => 60000,
+ idle_timeout => 500
+ }),
+ Port = ranch:get_port(?FUNCTION_NAME),
+ try
+ ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]),
+ {ok, http} = gun:await_up(ConnPid),
+ _StreamRef = gun:post(ConnPid, "/echo/read_body",
+ #{<<"content-length">> => <<"12">>}),
+ {error, {down, {shutdown, closed}}} = gun:await(ConnPid, undefined, 1000)
+ after
+ cowboy:stop_listener(?FUNCTION_NAME)
+ end.
+
+idle_timeout_read_body_pipeline(Config) ->
+ doc("Ensure the idle_timeout drops connections when the "
+ "connection is idle too long reading the request body."),
+ {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{
+ env => #{dispatch => init_dispatch(Config)},
+ request_timeout => 60000,
+ idle_timeout => 500
+ }),
+ Port = ranch:get_port(?FUNCTION_NAME),
+ try
+ ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]),
+ {ok, http} = gun:await_up(ConnPid),
+ StreamRef1 = gun:get(ConnPid, "/"),
+ StreamRef2 = gun:get(ConnPid, "/"),
+ _StreamRef3 = gun:post(ConnPid, "/echo/read_body",
+ #{<<"content-length">> => <<"12">>}),
+ {response, nofin, 200, _} = gun:await(ConnPid, StreamRef1),
+ {response, nofin, 200, _} = gun:await(ConnPid, StreamRef2),
+ {error, {down, {shutdown, closed}}} = gun:await(ConnPid, undefined, 1000)
+ after
+ cowboy:stop_listener(?FUNCTION_NAME)
+ end.
+
+idle_timeout_skip_body(Config) ->
+ doc("Ensure the idle_timeout drops connections when the "
+ "connection is idle too long skipping the request body."),
+ {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{
+ env => #{dispatch => init_dispatch(Config)},
+ request_timeout => 60000,
+ idle_timeout => 500
+ }),
+ Port = ranch:get_port(?FUNCTION_NAME),
+ try
+ ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]),
+ {ok, http} = gun:await_up(ConnPid),
+ StreamRef = gun:post(ConnPid, "/",
+ #{<<"content-length">> => <<"12">>}),
+ {response, nofin, 200, _} = gun:await(ConnPid, StreamRef),
+ {error, {down, {shutdown, closed}}} = gun:await(ConnPid, undefined, 1000)
+ after
+ cowboy:stop_listener(?FUNCTION_NAME)
+ end.
+
idle_timeout_infinity(Config) ->
doc("Ensure the idle_timeout option accepts the infinity value."),
{ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{
@@ -245,12 +309,90 @@ idle_timeout_infinity(Config) ->
{'DOWN', Ref, process, Pid, Reason} ->
error(Reason)
after 1000 ->
- ok
+ gun:close(ConnPid)
end
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
+idle_timeout_on_send(Config) ->
+ doc("Ensure the idle timeout is not reset when sending (by default)."),
+ do_idle_timeout_on_send(Config, http).
+
+%% Also used by http2_SUITE.
+do_idle_timeout_on_send(Config, Protocol) ->
+ {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{
+ env => #{dispatch => init_dispatch(Config)},
+ idle_timeout => 1000
+ }),
+ Port = ranch:get_port(?FUNCTION_NAME),
+ try
+ ConnPid = gun_open([{type, tcp}, {protocol, Protocol}, {port, Port}|Config]),
+ {ok, Protocol} = gun:await_up(ConnPid),
+ timer:sleep(500),
+ #{socket := Socket} = gun:info(ConnPid),
+ Pid = get_remote_pid_tcp(Socket),
+ StreamRef = gun:get(ConnPid, "/streamed_result/10/250"),
+ Ref = erlang:monitor(process, Pid),
+ receive
+ {gun_response, ConnPid, StreamRef, nofin, _Status, _Headers} ->
+ do_idle_timeout_recv_loop(Ref, Pid, ConnPid, StreamRef, false)
+ after 2000 ->
+ error(timeout)
+ end
+ after
+ cowboy:stop_listener(?FUNCTION_NAME)
+ end.
+
+idle_timeout_reset_on_send(Config) ->
+ doc("Ensure the reset_idle_timeout_on_send results in the "
+ "idle timeout resetting when sending ."),
+ do_idle_timeout_reset_on_send(Config, http).
+
+%% Also used by http2_SUITE.
+do_idle_timeout_reset_on_send(Config, Protocol) ->
+ {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{
+ env => #{dispatch => init_dispatch(Config)},
+ idle_timeout => 1000,
+ reset_idle_timeout_on_send => true
+ }),
+ Port = ranch:get_port(?FUNCTION_NAME),
+ try
+ ConnPid = gun_open([{type, tcp}, {protocol, Protocol}, {port, Port}|Config]),
+ {ok, Protocol} = gun:await_up(ConnPid),
+ timer:sleep(500),
+ #{socket := Socket} = gun:info(ConnPid),
+ Pid = get_remote_pid_tcp(Socket),
+ StreamRef = gun:get(ConnPid, "/streamed_result/10/250"),
+ Ref = erlang:monitor(process, Pid),
+ receive
+ {gun_response, ConnPid, StreamRef, nofin, _Status, _Headers} ->
+ do_idle_timeout_recv_loop(Ref, Pid, ConnPid, StreamRef, true)
+ after 2000 ->
+ error(timeout)
+ end
+ after
+ cowboy:stop_listener(?FUNCTION_NAME)
+ end.
+
+do_idle_timeout_recv_loop(Ref, Pid, ConnPid, StreamRef, ExpectCompletion) ->
+ receive
+ {gun_data, ConnPid, StreamRef, nofin, _Data} ->
+ do_idle_timeout_recv_loop(Ref, Pid, ConnPid, StreamRef, ExpectCompletion);
+ {gun_data, ConnPid, StreamRef, fin, _Data} when ExpectCompletion ->
+ gun:close(ConnPid);
+ {gun_data, ConnPid, StreamRef, fin, _Data} ->
+ gun:close(ConnPid),
+ error(completed);
+ {'DOWN', Ref, process, Pid, _} when ExpectCompletion ->
+ gun:close(ConnPid),
+ error(exited);
+ {'DOWN', Ref, process, Pid, _} ->
+ ok
+ after 2000 ->
+ error(timeout)
+ end.
+
persistent_term_router(Config) ->
doc("The router can retrieve the routes from persistent_term storage."),
case erlang:function_exported(persistent_term, get, 1) of
@@ -274,6 +416,113 @@ do_persistent_term_router(Config) ->
cowboy:stop_listener(?FUNCTION_NAME)
end.
+request_timeout(Config) ->
+ doc("Ensure the request_timeout drops connections when requests "
+ "fail to come in fast enough."),
+ {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{
+ env => #{dispatch => init_dispatch(Config)},
+ request_timeout => 500
+ }),
+ Port = ranch:get_port(?FUNCTION_NAME),
+ try
+ ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]),
+ {ok, http} = gun:await_up(ConnPid),
+ {error, {down, {shutdown, closed}}} = gun:await(ConnPid, undefined, 1000)
+ after
+ cowboy:stop_listener(?FUNCTION_NAME)
+ end.
+
+request_timeout_pipeline(Config) ->
+ doc("Ensure the request_timeout drops connections when requests "
+ "fail to come in fast enough after pipelined requests went through."),
+ {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{
+ env => #{dispatch => init_dispatch(Config)},
+ request_timeout => 500
+ }),
+ Port = ranch:get_port(?FUNCTION_NAME),
+ try
+ ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]),
+ {ok, http} = gun:await_up(ConnPid),
+ StreamRef1 = gun:get(ConnPid, "/"),
+ StreamRef2 = gun:get(ConnPid, "/"),
+ StreamRef3 = gun:get(ConnPid, "/"),
+ {response, nofin, 200, _} = gun:await(ConnPid, StreamRef1),
+ {response, nofin, 200, _} = gun:await(ConnPid, StreamRef2),
+ {response, nofin, 200, _} = gun:await(ConnPid, StreamRef3),
+ {error, {down, {shutdown, closed}}} = gun:await(ConnPid, undefined, 1000)
+ after
+ cowboy:stop_listener(?FUNCTION_NAME)
+ end.
+
+request_timeout_skip_body(Config) ->
+ doc("Ensure the request_timeout drops connections when requests "
+ "fail to come in fast enough after skipping a request body."),
+ {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{
+ env => #{dispatch => init_dispatch(Config)},
+ request_timeout => 500
+ }),
+ Port = ranch:get_port(?FUNCTION_NAME),
+ try
+ Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]),
+ ok = raw_send(Client, <<
+ "POST / HTTP/1.1\r\n"
+ "host: localhost\r\n"
+ "content-length: 12\r\n\r\n"
+ >>),
+ Data = raw_recv_head(Client),
+ {'HTTP/1.1', 200, _, Rest0} = cow_http:parse_status_line(Data),
+ {Headers, Rest} = cow_http:parse_headers(Rest0),
+ {_, Len} = lists:keyfind(<<"content-length">>, 1, Headers),
+ <<"Hello world!">> = raw_recv_rest(Client, binary_to_integer(Len), Rest),
+ %% We then send the request data that should be skipped by Cowboy.
+ timer:sleep(100),
+ raw_send(Client, <<"Hello world!">>),
+ %% Connection should be closed by the request_timeout after that.
+ {error, closed} = raw_recv(Client, 1, 1000)
+ after
+ cowboy:stop_listener(?FUNCTION_NAME)
+ end.
+
+request_timeout_skip_body_more(Config) ->
+ doc("Ensure the request_timeout drops connections when requests "
+ "fail to come in fast enough after skipping a request body."),
+ {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{
+ env => #{dispatch => init_dispatch(Config)},
+ request_timeout => 500
+ }),
+ Port = ranch:get_port(?FUNCTION_NAME),
+ try
+ Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]),
+ ok = raw_send(Client, <<
+ "POST / HTTP/1.1\r\n"
+ "host: localhost\r\n"
+ "content-length: 12\r\n\r\n"
+ >>),
+ Data = raw_recv_head(Client),
+ {'HTTP/1.1', 200, _, Rest0} = cow_http:parse_status_line(Data),
+ {Headers, Rest} = cow_http:parse_headers(Rest0),
+ {_, Len} = lists:keyfind(<<"content-length">>, 1, Headers),
+ <<"Hello world!">> = raw_recv_rest(Client, binary_to_integer(Len), Rest),
+ %% We then send the request data that should be skipped by Cowboy.
+ timer:sleep(100),
+ raw_send(Client, <<"Hello world!">>),
+ %% Send the start of another request.
+ ok = raw_send(Client, <<
+ "GET / HTTP/1.1\r\n"
+ "host: localhost\r\n"
+ %% Missing final \r\n on purpose.
+ >>),
+ %% Connection should be closed by the request_timeout after that.
+ %% We attempt to send a 408 response on a best effort basis so
+ %% that is accepted as well.
+ case raw_recv(Client, 13, 1000) of
+ {error, closed} -> ok;
+ {ok, <<"HTTP/1.1 408 ", _/bits>>} -> ok
+ end
+ after
+ cowboy:stop_listener(?FUNCTION_NAME)
+ end.
+
request_timeout_infinity(Config) ->
doc("Ensure the request_timeout option accepts the infinity value."),
{ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{
@@ -292,7 +541,7 @@ request_timeout_infinity(Config) ->
{'DOWN', Ref, process, Pid, Reason} ->
error(Reason)
after 1000 ->
- ok
+ gun:close(ConnPid)
end
after
cowboy:stop_listener(?FUNCTION_NAME)
@@ -348,7 +597,8 @@ set_options_chunked_false_ignored(Config) ->
%% is not disabled for that second request.
StreamRef2 = gun:get(ConnPid, "/resp/stream_reply2/200"),
{response, nofin, 200, Headers} = gun:await(ConnPid, StreamRef2),
- {_, <<"chunked">>} = lists:keyfind(<<"transfer-encoding">>, 1, Headers)
+ {_, <<"chunked">>} = lists:keyfind(<<"transfer-encoding">>, 1, Headers),
+ gun:close(ConnPid)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
@@ -449,10 +699,10 @@ graceful_shutdown_connection(Config) ->
doc("Check that the current request is handled before gracefully "
"shutting down a connection."),
Dispatch = cowboy_router:compile([{"localhost", [
+ {"/hello", delay_hello_h,
+ #{delay => 0, notify_received => self()}},
{"/delay_hello", delay_hello_h,
- #{delay => 500, notify_received => self()}},
- {"/long_delay_hello", delay_hello_h,
- #{delay => 10000, notify_received => self()}}
+ #{delay => 1000, notify_received => self()}}
]}]),
ProtoOpts = #{
env => #{dispatch => Dispatch}
@@ -460,22 +710,27 @@ graceful_shutdown_connection(Config) ->
{ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], ProtoOpts),
Port = ranch:get_port(?FUNCTION_NAME),
try
- ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]),
- {ok, http} = gun:await_up(ConnPid),
- #{socket := Socket} = gun:info(ConnPid),
- CowboyConnPid = get_remote_pid_tcp(Socket),
- CowboyConnRef = erlang:monitor(process, CowboyConnPid),
- Ref1 = gun:get(ConnPid, "/delay_hello"),
- Ref2 = gun:get(ConnPid, "/delay_hello"),
- receive {request_received, <<"/delay_hello">>} -> ok end,
+ Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]),
+ ok = raw_send(Client,
+ "GET /delay_hello HTTP/1.1\r\n"
+ "Host: localhost\r\n\r\n"
+ "GET /hello HTTP/1.1\r\n"
+ "Host: localhost\r\n\r\n"),
receive {request_received, <<"/delay_hello">>} -> ok end,
+ receive {request_received, <<"/hello">>} -> ok end,
+ CowboyConnPid = get_remote_pid_tcp(element(2, Client)),
+ CowboyConnRef = erlang:monitor(process, CowboyConnPid),
ok = sys:terminate(CowboyConnPid, system_is_going_down),
- {response, nofin, 200, RespHeaders} = gun:await(ConnPid, Ref1),
- <<"close">> = proplists:get_value(<<"connection">>, RespHeaders),
- {ok, RespBody} = gun:await_body(ConnPid, Ref1),
- <<"Hello world!">> = iolist_to_binary(RespBody),
- {error, {stream_error, _}} = gun:await(ConnPid, Ref2),
- ok = gun_down(ConnPid),
+ Rest = case catch raw_recv_head(Client) of
+ {'EXIT', _} -> error(closed);
+ Data ->
+ {'HTTP/1.1', 200, _, Rest0} = cow_http:parse_status_line(Data),
+ {Headers, Rest1} = cow_http:parse_headers(Rest0),
+ <<"close">> = proplists:get_value(<<"connection">>, Headers),
+ Rest1
+ end,
+ <<"Hello world!">> = raw_recv_rest(Client, byte_size(<<"Hello world!">>), Rest),
+ {error, closed} = raw_recv(Client, 0, 1000),
receive
{'DOWN', CowboyConnRef, process, CowboyConnPid, _Reason} ->
ok
@@ -486,6 +741,10 @@ graceful_shutdown_connection(Config) ->
graceful_shutdown_listener(Config) ->
doc("Check that connections are shut down gracefully when stopping a listener."),
+ TransOpts = #{
+ socket_opts => [{port, 0}],
+ shutdown => 1000 %% Shorter timeout to make the test case faster.
+ },
Dispatch = cowboy_router:compile([{"localhost", [
{"/delay_hello", delay_hello_h,
#{delay => 500, notify_received => self()}},
@@ -495,7 +754,7 @@ graceful_shutdown_listener(Config) ->
ProtoOpts = #{
env => #{dispatch => Dispatch}
},
- {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], ProtoOpts),
+ {ok, _} = cowboy:start_clear(?FUNCTION_NAME, TransOpts, ProtoOpts),
Port = ranch:get_port(?FUNCTION_NAME),
ConnPid1 = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]),
Ref1 = gun:get(ConnPid1, "/delay_hello"),
@@ -504,6 +763,8 @@ graceful_shutdown_listener(Config) ->
%% Shutdown listener while the handlers are working.
receive {request_received, <<"/delay_hello">>} -> ok end,
receive {request_received, <<"/long_delay_hello">>} -> ok end,
+ %% Note: This call does not complete quickly and will
+ %% prevent other cowboy:stop_listener/1 calls to complete.
ok = cowboy:stop_listener(?FUNCTION_NAME),
%% Check that the 1st request is handled before shutting down.
{response, nofin, 200, RespHeaders} = gun:await(ConnPid1, Ref1),
@@ -514,3 +775,63 @@ graceful_shutdown_listener(Config) ->
%% Check that the 2nd (very slow) request is not handled.
{error, {stream_error, closed}} = gun:await(ConnPid2, Ref2),
gun:close(ConnPid2).
+
+send_timeout_close(_Config) ->
+ doc("Check that connections are closed on send timeout."),
+ TransOpts = #{
+ port => 0,
+ socket_opts => [
+ {send_timeout, 100},
+ {send_timeout_close, true},
+ {sndbuf, 10}
+ ]
+ },
+ Dispatch = cowboy_router:compile([{"localhost", [
+ {"/endless", loop_handler_endless_h, #{delay => 100}}
+ ]}]),
+ ProtoOpts = #{
+ env => #{dispatch => Dispatch},
+ idle_timeout => infinity
+ },
+ {ok, _} = cowboy:start_clear(?FUNCTION_NAME, TransOpts, ProtoOpts),
+ Port = ranch:get_port(?FUNCTION_NAME),
+ try
+ %% Connect a client that sends a request and waits indefinitely.
+ {ok, ClientSocket} = gen_tcp:connect("localhost", Port,
+ [{recbuf, 10}, {buffer, 10}, {active, false}, {packet, 0}]),
+ ok = gen_tcp:send(ClientSocket, [
+ "GET /endless HTTP/1.1\r\n",
+ "Host: localhost:", integer_to_list(Port), "\r\n",
+ "x-test-pid: ", pid_to_list(self()), "\r\n\r\n"
+ ]),
+ %% Wait for the handler to start then get its pid,
+ %% the remote connection's pid and socket.
+ StreamPid = receive
+ {Self, StreamPid0, init} when Self =:= self() ->
+ StreamPid0
+ after 1000 ->
+ error(timeout)
+ end,
+ ServerPid = ct_helper:get_remote_pid_tcp(ClientSocket),
+ {links, ServerLinks} = process_info(ServerPid, links),
+ [ServerSocket] = [PidOrPort || PidOrPort <- ServerLinks, is_port(PidOrPort)],
+ %% Poll the socket repeatedly until it is closed by the server.
+ WaitClosedFun =
+ fun F(T) when T =< 0 ->
+ error({status, prim_inet:getstatus(ServerSocket)});
+ F(T) ->
+ Snooze = 100,
+ case inet:sockname(ServerSocket) of
+ {error, _} ->
+ timer:sleep(Snooze);
+ {ok, _} ->
+ timer:sleep(Snooze),
+ F(T - Snooze)
+ end
+ end,
+ ok = WaitClosedFun(2000),
+ false = erlang:is_process_alive(StreamPid),
+ false = erlang:is_process_alive(ServerPid)
+ after
+ cowboy:stop_listener(?FUNCTION_NAME)
+ end.
diff --git a/test/loop_handler_SUITE.erl b/test/loop_handler_SUITE.erl
index a7b5303..c5daaf8 100644
--- a/test/loop_handler_SUITE.erl
+++ b/test/loop_handler_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2011-2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2011-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -32,7 +32,7 @@ init_per_group(Name, Config) ->
cowboy_test:init_common_groups(Name, Config, ?MODULE).
end_per_group(Name, _) ->
- cowboy:stop_listener(Name).
+ cowboy_test:stop_group(Name).
%% Dispatch configuration.
@@ -40,7 +40,10 @@ init_dispatch(_) ->
cowboy_router:compile([{'_', [
{"/long_polling", long_polling_h, []},
{"/loop_body", loop_handler_body_h, []},
- {"/loop_timeout", loop_handler_timeout_h, []}
+ {"/loop_request_timeout", loop_handler_timeout_h, []},
+ {"/loop_timeout_init", loop_handler_timeout_init_h, []},
+ {"/loop_timeout_info", loop_handler_timeout_info_h, []},
+ {"/loop_timeout_hibernate", loop_handler_timeout_hibernate_h, []}
]}]).
%% Tests.
@@ -79,6 +82,31 @@ long_polling_pipeline(Config) ->
request_timeout(Config) ->
doc("Ensure that the request_timeout isn't applied when a request is ongoing."),
ConnPid = gun_open(Config),
- Ref = gun:get(ConnPid, "/loop_timeout", [{<<"accept-encoding">>, <<"gzip">>}]),
+ Ref = gun:get(ConnPid, "/loop_request_timeout", [{<<"accept-encoding">>, <<"gzip">>}]),
{response, nofin, 200, _} = gun:await(ConnPid, Ref, 10000),
ok.
+
+timeout_hibernate(Config) ->
+ doc("Ensure that loop handler idle timeouts don't trigger after hibernate is returned."),
+ ConnPid = gun_open(Config),
+ Ref = gun:get(ConnPid, "/loop_timeout_hibernate", [{<<"accept-encoding">>, <<"gzip">>}]),
+ {response, fin, 200, _} = gun:await(ConnPid, Ref),
+ ok.
+
+timeout_info(Config) ->
+ doc("Ensure that loop handler idle timeouts trigger on time when set in info/3."),
+ ConnPid = gun_open(Config),
+ Ref = gun:get(ConnPid, "/loop_timeout_info", [{<<"accept-encoding">>, <<"gzip">>}]),
+ {response, fin, 299, _} = gun:await(ConnPid, Ref),
+ ok.
+
+timeout_init(Config) ->
+ doc("Ensure that loop handler idle timeouts trigger on time when set in init/2."),
+ ConnPid = gun_open(Config),
+ Ref = gun:get(ConnPid, "/loop_timeout_init?timeout=1000",
+ [{<<"accept-encoding">>, <<"gzip">>}]),
+ {response, fin, 200, _} = gun:await(ConnPid, Ref),
+ Ref2 = gun:get(ConnPid, "/loop_timeout_init?timeout=100",
+ [{<<"accept-encoding">>, <<"gzip">>}]),
+ {response, fin, 299, _} = gun:await(ConnPid, Ref2),
+ ok.
diff --git a/test/metrics_SUITE.erl b/test/metrics_SUITE.erl
index 74a259f..6a272f2 100644
--- a/test/metrics_SUITE.erl
+++ b/test/metrics_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -44,6 +44,8 @@ init_per_group(Name = h2, Config) ->
init_per_group(Name = h2c, Config) ->
Config1 = cowboy_test:init_http(Name, init_plain_opts(Config), Config),
lists:keyreplace(protocol, 1, Config1, {protocol, http2});
+init_per_group(Name = h3, Config) ->
+ cowboy_test:init_http3(Name, init_plain_opts(Config), Config);
init_per_group(Name = http_compress, Config) ->
cowboy_test:init_http(Name, init_compress_opts(Config), Config);
init_per_group(Name = https_compress, Config) ->
@@ -52,10 +54,12 @@ init_per_group(Name = h2_compress, Config) ->
cowboy_test:init_http2(Name, init_compress_opts(Config), Config);
init_per_group(Name = h2c_compress, Config) ->
Config1 = cowboy_test:init_http(Name, init_compress_opts(Config), Config),
- lists:keyreplace(protocol, 1, Config1, {protocol, http2}).
+ lists:keyreplace(protocol, 1, Config1, {protocol, http2});
+init_per_group(Name = h3_compress, Config) ->
+ cowboy_test:init_http3(Name, init_compress_opts(Config), Config).
end_per_group(Name, _) ->
- cowboy:stop_listener(Name).
+ cowboy_test:stop_group(Name).
init_plain_opts(Config) ->
#{
@@ -157,16 +161,24 @@ do_get(Path, UserData, Config) ->
#{
ref := _,
pid := From,
- streamid := 1,
- reason := normal,
+ streamid := StreamID,
+ reason := normal, %% @todo Getting h3_no_error here.
req := #{},
informational := [],
user_data := UserData
} = Metrics,
+ do_check_streamid(StreamID, Config),
%% All good!
gun:close(ConnPid)
end.
+do_check_streamid(StreamID, Config) ->
+ case config(protocol, Config) of
+ http -> 1 = StreamID;
+ http2 -> 1 = StreamID;
+ http3 -> 0 = StreamID
+ end.
+
post_body(Config) ->
doc("Confirm metrics are correct for a normal POST request."),
%% Perform a POST request.
@@ -218,12 +230,13 @@ post_body(Config) ->
#{
ref := _,
pid := From,
- streamid := 1,
+ streamid := StreamID,
reason := normal,
req := #{},
informational := [],
user_data := #{}
} = Metrics,
+ do_check_streamid(StreamID, Config),
%% All good!
gun:close(ConnPid)
end.
@@ -273,12 +286,13 @@ no_resp_body(Config) ->
#{
ref := _,
pid := From,
- streamid := 1,
+ streamid := StreamID,
reason := normal,
req := #{},
informational := [],
user_data := #{}
} = Metrics,
+ do_check_streamid(StreamID, Config),
%% All good!
gun:close(ConnPid)
end.
@@ -291,7 +305,8 @@ early_error(Config) ->
%% reason in both protocols.
{Method, Headers, Status, Error} = case config(protocol, Config) of
http -> {<<"GET">>, [{<<"host">>, <<"host:port">>}], 400, protocol_error};
- http2 -> {<<"TRACE">>, [], 501, no_error}
+ http2 -> {<<"TRACE">>, [], 501, no_error};
+ http3 -> {<<"TRACE">>, [], 501, h3_no_error}
end,
Ref = gun:request(ConnPid, Method, "/", [
{<<"accept-encoding">>, <<"gzip">>},
@@ -305,7 +320,7 @@ early_error(Config) ->
#{
ref := _,
pid := From,
- streamid := 1,
+ streamid := StreamID,
reason := {stream_error, Error, _},
partial_req := #{},
resp_status := Status,
@@ -313,6 +328,7 @@ early_error(Config) ->
early_error_time := _,
resp_body_length := 0
} = Metrics,
+ do_check_streamid(StreamID, Config),
ExpectedRespHeaders = maps:from_list(RespHeaders),
%% All good!
gun:close(ConnPid)
@@ -321,7 +337,8 @@ early_error(Config) ->
early_error_request_line(Config) ->
case config(protocol, Config) of
http -> do_early_error_request_line(Config);
- http2 -> doc("There are no request lines in HTTP/2.")
+ http2 -> doc("There are no request lines in HTTP/2.");
+ http3 -> doc("There are no request lines in HTTP/3.")
end.
do_early_error_request_line(Config) ->
@@ -341,7 +358,7 @@ do_early_error_request_line(Config) ->
#{
ref := _,
pid := From,
- streamid := 1,
+ streamid := StreamID,
reason := {connection_error, protocol_error, _},
partial_req := #{},
resp_status := 400,
@@ -349,6 +366,7 @@ do_early_error_request_line(Config) ->
early_error_time := _,
resp_body_length := 0
} = Metrics,
+ do_check_streamid(StreamID, Config),
ExpectedRespHeaders = maps:from_list(RespHeaders),
%% All good!
ok
@@ -362,7 +380,9 @@ stream_reply(Config) ->
ws(Config) ->
case config(protocol, Config) of
http -> do_ws(Config);
- http2 -> doc("It is not currently possible to switch to Websocket over HTTP/2.")
+ %% @todo The test can be implemented for HTTP/2.
+ http2 -> doc("It is not currently possible to switch to Websocket over HTTP/2.");
+ http3 -> {skip, "Gun does not currently support Websocket over HTTP/3."}
end.
do_ws(Config) ->
@@ -405,7 +425,7 @@ do_ws(Config) ->
#{
ref := _,
pid := From,
- streamid := 1,
+ streamid := StreamID,
reason := switch_protocol,
req := #{},
%% A 101 upgrade response was sent.
@@ -420,6 +440,7 @@ do_ws(Config) ->
}],
user_data := #{}
} = Metrics,
+ do_check_streamid(StreamID, Config),
%% All good!
ok
end,
@@ -438,7 +459,15 @@ error_response(Config) ->
{<<"accept-encoding">>, <<"gzip">>},
{<<"x-test-pid">>, pid_to_list(self())}
]),
- {response, fin, 500, RespHeaders} = gun:await(ConnPid, Ref, infinity),
+ Protocol = config(protocol, Config),
+ RespHeaders = case gun:await(ConnPid, Ref, infinity) of
+ {response, fin, 500, RespHeaders0} ->
+ RespHeaders0;
+ %% The RST_STREAM arrived before the start of the response.
+ %% See maybe_h3_error comment for details.
+ {error, {stream_error, {stream_error, h3_internal_error, _}}} when Protocol =:= http3 ->
+ unknown
+ end,
timer:sleep(100),
%% Receive the metrics and validate them.
receive
@@ -463,25 +492,33 @@ error_response(Config) ->
resp_headers := ExpectedRespHeaders,
resp_body_length := 0
} = Metrics,
- ExpectedRespHeaders = maps:from_list(RespHeaders),
+ case RespHeaders of
+ %% The HTTP/3 stream has reset too early so we can't
+ %% verify the response headers.
+ unknown ->
+ ok;
+ _ ->
+ ExpectedRespHeaders = maps:from_list(RespHeaders)
+ end,
%% The request process executed normally.
#{procs := Procs} = Metrics,
[{_, #{
spawn := ProcSpawn,
exit := ProcExit,
- reason := {crash, _StackTrace}
+ reason := {crash, StackTrace}
}}] = maps:to_list(Procs),
true = ProcSpawn =< ProcExit,
%% Confirm other metadata are as expected.
#{
ref := _,
pid := From,
- streamid := 1,
- reason := {internal_error, {'EXIT', _Pid, {crash, _StackTrace}}, 'Stream process crashed.'},
+ streamid := StreamID,
+ reason := {internal_error, {'EXIT', _Pid, {crash, StackTrace}}, 'Stream process crashed.'},
req := #{},
informational := [],
user_data := #{}
} = Metrics,
+ do_check_streamid(StreamID, Config),
%% All good!
gun:close(ConnPid)
end.
@@ -495,7 +532,15 @@ error_response_after_reply(Config) ->
{<<"accept-encoding">>, <<"gzip">>},
{<<"x-test-pid">>, pid_to_list(self())}
]),
- {response, fin, 200, RespHeaders} = gun:await(ConnPid, Ref, infinity),
+ Protocol = config(protocol, Config),
+ RespHeaders = case gun:await(ConnPid, Ref, infinity) of
+ {response, fin, 200, RespHeaders0} ->
+ RespHeaders0;
+ %% The RST_STREAM arrived before the start of the response.
+ %% See maybe_h3_error comment for details.
+ {error, {stream_error, {stream_error, h3_internal_error, _}}} when Protocol =:= http3 ->
+ unknown
+ end,
timer:sleep(100),
%% Receive the metrics and validate them.
receive
@@ -520,25 +565,33 @@ error_response_after_reply(Config) ->
resp_headers := ExpectedRespHeaders,
resp_body_length := 0
} = Metrics,
- ExpectedRespHeaders = maps:from_list(RespHeaders),
+ case RespHeaders of
+ %% The HTTP/3 stream has reset too early so we can't
+ %% verify the response headers.
+ unknown ->
+ ok;
+ _ ->
+ ExpectedRespHeaders = maps:from_list(RespHeaders)
+ end,
%% The request process executed normally.
#{procs := Procs} = Metrics,
[{_, #{
spawn := ProcSpawn,
exit := ProcExit,
- reason := {crash, _StackTrace}
+ reason := {crash, StackTrace}
}}] = maps:to_list(Procs),
true = ProcSpawn =< ProcExit,
%% Confirm other metadata are as expected.
#{
ref := _,
pid := From,
- streamid := 1,
- reason := {internal_error, {'EXIT', _Pid, {crash, _StackTrace}}, 'Stream process crashed.'},
+ streamid := StreamID,
+ reason := {internal_error, {'EXIT', _Pid, {crash, StackTrace}}, 'Stream process crashed.'},
req := #{},
informational := [],
user_data := #{}
} = Metrics,
+ do_check_streamid(StreamID, Config),
%% All good!
gun:close(ConnPid)
end.
diff --git a/test/misc_SUITE.erl b/test/misc_SUITE.erl
index 6245636..c918321 100644
--- a/test/misc_SUITE.erl
+++ b/test/misc_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -21,29 +21,29 @@
-import(cowboy_test, [gun_open/1]).
all() ->
- [{group, app}, {group, set_env}|cowboy_test:common_all()].
+ [{group, app}, {group, env}|cowboy_test:common_all()].
groups() ->
Common = ct_helper:all(?MODULE)
- -- [restart_gracefully, set_env, set_env_missing],
+ -- [restart_gracefully, get_env, set_env, set_env_missing],
[
{app, [], [restart_gracefully]},
- {set_env, [parallel], [set_env, set_env_missing]}
+ {env, [parallel], [get_env, set_env, set_env_missing]}
|cowboy_test:common_groups(Common)].
init_per_group(Name=app, Config) ->
cowboy_test:init_http(Name, #{
env => #{dispatch => init_dispatch(Config)}
}, Config);
-init_per_group(set_env, Config) ->
+init_per_group(env, Config) ->
Config;
init_per_group(Name, Config) ->
cowboy_test:init_common_groups(Name, Config, ?MODULE).
-end_per_group(set_env, _) ->
+end_per_group(env, _) ->
ok;
end_per_group(Name, _) ->
- cowboy:stop_listener(Name).
+ cowboy_test:stop_group(Name).
init_dispatch(_) ->
cowboy_router:compile([{"localhost", [
@@ -84,6 +84,26 @@ router_invalid_path(Config) ->
{response, _, 400, _} = gun:await(ConnPid, Ref),
ok.
+get_env(Config0) ->
+ doc("Ensure we can retrieve middleware environment values."),
+ Dispatch = init_dispatch(Config0),
+ _Config = cowboy_test:init_http(?FUNCTION_NAME, #{
+ env => #{
+ dispatch => Dispatch,
+ the_key => the_value
+ }
+ }, Config0),
+ try
+ Dispatch = cowboy:get_env(?FUNCTION_NAME, dispatch),
+ Dispatch = cowboy:get_env(?FUNCTION_NAME, dispatch, the_default),
+ the_value = cowboy:get_env(?FUNCTION_NAME, the_key),
+ the_value = cowboy:get_env(?FUNCTION_NAME, the_key, the_default),
+ {'EXIT', _} = (catch cowboy:get_env(?FUNCTION_NAME, missing_key)),
+ the_default = cowboy:get_env(?FUNCTION_NAME, missing_key, the_default)
+ after
+ cowboy:stop_listener(?FUNCTION_NAME)
+ end.
+
set_env(Config0) ->
doc("Live replace a middleware environment value."),
Config = cowboy_test:init_http(?FUNCTION_NAME, #{
diff --git a/test/plain_handler_SUITE.erl b/test/plain_handler_SUITE.erl
index e980d5b..756c0a6 100644
--- a/test/plain_handler_SUITE.erl
+++ b/test/plain_handler_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2018, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -39,7 +39,7 @@ init_per_group(Name, Config) ->
cowboy_test:init_common_groups(Name, Config, ?MODULE).
end_per_group(Name, _) ->
- cowboy:stop_listener(Name).
+ cowboy_test:stop_group(Name).
%% Routes.
@@ -58,8 +58,15 @@ crash_after_reply(Config) ->
Ref = gun:get(ConnPid, "/crash/reply", [
{<<"accept-encoding">>, <<"gzip">>}
]),
- {response, fin, 200, _} = gun:await(ConnPid, Ref),
- {error, timeout} = gun:await(ConnPid, Ref, 1000),
+ Protocol = config(protocol, Config),
+ _ = case gun:await(ConnPid, Ref) of
+ {response, fin, 200, _} ->
+ {error, timeout} = gun:await(ConnPid, Ref, 1000);
+ %% See maybe_h3_error comment for details.
+ {error, {stream_error, {stream_error, h3_internal_error, _}}}
+ when Protocol =:= http3 ->
+ ok
+ end,
gun:close(ConnPid).
crash_before_reply(Config) ->
diff --git a/test/proxy_header_SUITE.erl b/test/proxy_header_SUITE.erl
index be6ab04..cb6ab47 100644
--- a/test/proxy_header_SUITE.erl
+++ b/test/proxy_header_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2018, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -71,6 +71,30 @@ init_dispatch() ->
%% Tests.
+fail_gracefully_on_disconnect(Config) ->
+ doc("Probing a port must not generate a crash"),
+ {ok, Socket} = gen_tcp:connect("localhost", config(port, Config),
+ [binary, {active, false}, {packet, raw}]),
+ timer:sleep(50),
+ Pid = case config(type, Config) of
+ tcp -> ct_helper:get_remote_pid_tcp(Socket);
+ %% We connect to a TLS port using a TCP socket so we need
+ %% to first obtain the remote pid of the TCP socket, which
+ %% is a TLS socket on the server, and then get the real
+ %% remote pid from its state.
+ ssl -> ct_helper:get_remote_pid_tls_state(ct_helper:get_remote_pid_tcp(Socket))
+ end,
+ Ref = erlang:monitor(process, Pid),
+ gen_tcp:close(Socket),
+ receive
+ {'DOWN', Ref, process, Pid, {shutdown, closed}} ->
+ ok;
+ {'DOWN', Ref, process, Pid, Reason} ->
+ error(Reason)
+ after 500 ->
+ error(timeout)
+ end.
+
v1_proxy_header(Config) ->
doc("Confirm we can read the proxy header at the start of the connection."),
ProxyInfo = #{
@@ -126,7 +150,8 @@ do_proxy_header_https(Config, ProxyInfo) ->
{ok, Socket0} = gen_tcp:connect("localhost", config(port, Config),
[binary, {active, false}, {packet, raw}]),
ok = gen_tcp:send(Socket0, ranch_proxy_header:header(ProxyInfo)),
- {ok, Socket} = ssl:connect(Socket0, [], 1000),
+ TlsOpts = ct_helper:get_certs_from_ets(),
+ {ok, Socket} = ssl:connect(Socket0, TlsOpts, 1000),
do_proxy_header_http_common({raw_client, Socket, ssl}, ProxyInfo).
do_proxy_header_http_common(Client, ProxyInfo) ->
@@ -151,7 +176,9 @@ do_proxy_header_h2(Config, ProxyInfo) ->
{ok, Socket0} = gen_tcp:connect("localhost", config(port, Config),
[binary, {active, false}, {packet, raw}]),
ok = gen_tcp:send(Socket0, ranch_proxy_header:header(ProxyInfo)),
- {ok, Socket} = ssl:connect(Socket0, [{alpn_advertised_protocols, [<<"h2">>]}], 1000),
+ TlsOpts = ct_helper:get_certs_from_ets(),
+ {ok, Socket} = ssl:connect(Socket0,
+ [{alpn_advertised_protocols, [<<"h2">>]}|TlsOpts], 1000),
do_proxy_header_h2_common({raw_client, Socket, ssl}, ProxyInfo).
do_proxy_header_h2c(Config, ProxyInfo) ->
diff --git a/test/req_SUITE.erl b/test/req_SUITE.erl
index 352b2a0..9036cac 100644
--- a/test/req_SUITE.erl
+++ b/test/req_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2016-2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2016-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -46,7 +46,7 @@ init_per_group(Name, Config) ->
cowboy_test:init_common_groups(Name, Config, ?MODULE).
end_per_group(Name, _) ->
- cowboy:stop_listener(Name).
+ cowboy_test:stop_group(Name).
%% Routes.
@@ -57,13 +57,16 @@ init_dispatch(Config) ->
{"/resp/:key[/:arg]", resp_h, []},
{"/multipart[/:key]", multipart_h, []},
{"/args/:key/:arg[/:default]", echo_h, []},
- {"/crash/:key/period", echo_h, #{length => 999999999, period => 1000, crash => true}},
+ {"/crash/:key/period", echo_h,
+ #{length => 999999999, period => 1000, timeout => 5000, crash => true}},
{"/no-opts/:key", echo_h, #{crash => true}},
{"/opts/:key/length", echo_h, #{length => 1000}},
{"/opts/:key/period", echo_h, #{length => 999999999, period => 2000}},
{"/opts/:key/timeout", echo_h, #{timeout => 1000, crash => true}},
{"/100-continue/:key", echo_h, []},
{"/full/:key", echo_h, []},
+ {"/auto-sync/:key", echo_h, []},
+ {"/auto-async/:key", echo_h, []},
{"/spawn/:key", echo_h, []},
{"/no/:key", echo_h, []},
{"/direct/:key/[...]", echo_h, []},
@@ -104,13 +107,17 @@ do_get(Path, Config) ->
do_get(Path, Headers, Config) ->
ConnPid = gun_open(Config),
Ref = gun:get(ConnPid, Path, [{<<"accept-encoding">>, <<"gzip">>}|Headers]),
- {response, IsFin, Status, RespHeaders} = gun:await(ConnPid, Ref, infinity),
- {ok, RespBody} = case IsFin of
- nofin -> gun:await_body(ConnPid, Ref, infinity);
- fin -> {ok, <<>>}
- end,
- gun:close(ConnPid),
- {Status, RespHeaders, do_decode(RespHeaders, RespBody)}.
+ case gun:await(ConnPid, Ref, infinity) of
+ {response, IsFin, Status, RespHeaders} ->
+ {ok, RespBody} = case IsFin of
+ nofin -> gun:await_body(ConnPid, Ref, infinity);
+ fin -> {ok, <<>>}
+ end,
+ gun:close(ConnPid),
+ {Status, RespHeaders, do_decode(RespHeaders, RespBody)};
+ {error, {stream_error, Error}} ->
+ Error
+ end.
do_get_body(Path, Config) ->
do_get_body(Path, [], Config).
@@ -139,7 +146,9 @@ do_get_inform(Path, Config) ->
fin -> {ok, <<>>}
end,
gun:close(ConnPid),
- {InfoStatus, InfoHeaders, RespStatus, RespHeaders, do_decode(RespHeaders, RespBody)}
+ {InfoStatus, InfoHeaders, RespStatus, RespHeaders, do_decode(RespHeaders, RespBody)};
+ {error, {stream_error, Error}} ->
+ Error
end.
do_decode(Headers, Body) ->
@@ -181,25 +190,20 @@ bindings(Config) ->
cert(Config) ->
case config(type, Config) of
tcp -> doc("TLS certificates can only be provided over TLS.");
- ssl -> do_cert(Config)
+ ssl -> do_cert(Config);
+ quic -> do_cert(Config)
end.
-do_cert(Config0) ->
+do_cert(Config) ->
doc("A client TLS certificate was provided."),
- {CaCert, Cert, Key} = ct_helper:make_certs(),
- Config = [{tls_opts, [
- {cert, Cert},
- {key, Key},
- {cacerts, [CaCert]}
- ]}|Config0],
Cert = do_get_body("/cert", Config),
Cert = do_get_body("/direct/cert", Config),
ok.
cert_undefined(Config) ->
doc("No client TLS certificate was provided."),
- <<"undefined">> = do_get_body("/cert", Config),
- <<"undefined">> = do_get_body("/direct/cert", Config),
+ <<"undefined">> = do_get_body("/cert", [{no_cert, true}|Config]),
+ <<"undefined">> = do_get_body("/direct/cert", [{no_cert, true}|Config]),
ok.
header(Config) ->
@@ -239,8 +243,10 @@ match_cookies(Config) ->
<<"#{}">> = do_get_body("/match/cookies", [{<<"cookie">>, "a=b; c=d"}], Config),
<<"#{a => <<\"b\">>}">> = do_get_body("/match/cookies/a", [{<<"cookie">>, "a=b; c=d"}], Config),
<<"#{c => <<\"d\">>}">> = do_get_body("/match/cookies/c", [{<<"cookie">>, "a=b; c=d"}], Config),
- <<"#{a => <<\"b\">>,c => <<\"d\">>}">> = do_get_body("/match/cookies/a/c",
- [{<<"cookie">>, "a=b; c=d"}], Config),
+ case do_get_body("/match/cookies/a/c", [{<<"cookie">>, "a=b; c=d"}], Config) of
+ <<"#{a => <<\"b\">>,c => <<\"d\">>}">> -> ok;
+ <<"#{c => <<\"d\">>,a => <<\"b\">>}">> -> ok
+ end,
%% Ensure match errors result in a 400 response.
{400, _, _} = do_get("/match/cookies/a/c",
[{<<"cookie">>, "a=b"}], Config),
@@ -253,11 +259,21 @@ match_qs(Config) ->
<<"#{}">> = do_get_body("/match/qs?a=b&c=d", Config),
<<"#{a => <<\"b\">>}">> = do_get_body("/match/qs/a?a=b&c=d", Config),
<<"#{c => <<\"d\">>}">> = do_get_body("/match/qs/c?a=b&c=d", Config),
- <<"#{a => <<\"b\">>,c => <<\"d\">>}">> = do_get_body("/match/qs/a/c?a=b&c=d", Config),
- <<"#{a => <<\"b\">>,c => true}">> = do_get_body("/match/qs/a/c?a=b&c", Config),
- <<"#{a => true,c => <<\"d\">>}">> = do_get_body("/match/qs/a/c?a&c=d", Config),
+ case do_get_body("/match/qs/a/c?a=b&c=d", Config) of
+ <<"#{a => <<\"b\">>,c => <<\"d\">>}">> -> ok;
+ <<"#{c => <<\"d\">>,a => <<\"b\">>}">> -> ok
+ end,
+ case do_get_body("/match/qs/a/c?a=b&c", Config) of
+ <<"#{a => <<\"b\">>,c => true}">> -> ok;
+ <<"#{c => true,a => <<\"b\">>}">> -> ok
+ end,
+ case do_get_body("/match/qs/a/c?a&c=d", Config) of
+ <<"#{a => true,c => <<\"d\">>}">> -> ok;
+ <<"#{c => <<\"d\">>,a => true}">> -> ok
+ end,
%% Ensure match errors result in a 400 response.
{400, _, _} = do_get("/match/qs/a/c?a=b", [], Config),
+ {400, _, _} = do_get("/match/qs_with_constraints", [], Config),
%% This function is tested more extensively through unit tests.
ok.
@@ -377,7 +393,8 @@ port(Config) ->
Port = do_get_body("/direct/port", Config),
ExpectedPort = case config(type, Config) of
tcp -> <<"80">>;
- ssl -> <<"443">>
+ ssl -> <<"443">>;
+ quic -> <<"443">>
end,
ExpectedPort = do_get_body("/port", [{<<"host">>, <<"localhost">>}], Config),
ExpectedPort = do_get_body("/direct/port", [{<<"host">>, <<"localhost">>}], Config),
@@ -403,7 +420,8 @@ do_scheme(Path, Config) ->
Transport = config(type, Config),
case do_get_body(Path, Config) of
<<"http">> when Transport =:= tcp -> ok;
- <<"https">> when Transport =:= ssl -> ok
+ <<"https">> when Transport =:= ssl -> ok;
+ <<"https">> when Transport =:= quic -> ok
end.
sock(Config) ->
@@ -416,7 +434,8 @@ uri(Config) ->
doc("Request URI building/modification."),
Scheme = case config(type, Config) of
tcp -> <<"http">>;
- ssl -> <<"https">>
+ ssl -> <<"https">>;
+ quic -> <<"https">>
end,
SLen = byte_size(Scheme),
Port = integer_to_binary(config(port, Config)),
@@ -450,7 +469,8 @@ do_version(Path, Config) ->
Protocol = config(protocol, Config),
case do_get_body(Path, Config) of
<<"HTTP/1.1">> when Protocol =:= http -> ok;
- <<"HTTP/2">> when Protocol =:= http2 -> ok
+ <<"HTTP/2">> when Protocol =:= http2 -> ok;
+ <<"HTTP/3">> when Protocol =:= http3 -> ok
end.
%% Tests: Request body.
@@ -504,11 +524,19 @@ read_body_period(Config) ->
%% for 2 seconds. The test succeeds if we get some of the data back
%% (meaning the function will have returned after the period ends).
gun:data(ConnPid, Ref, nofin, Body),
- {response, nofin, 200, _} = gun:await(ConnPid, Ref, infinity),
- {data, _, Data} = gun:await(ConnPid, Ref, infinity),
- %% We expect to read at least some data.
- true = Data =/= <<>>,
- gun:close(ConnPid).
+ Response = gun:await(ConnPid, Ref, infinity),
+ case Response of
+ {response, nofin, 200, _} ->
+ {data, _, Data} = gun:await(ConnPid, Ref, infinity),
+ %% We expect to read at least some data.
+ true = Data =/= <<>>,
+ gun:close(ConnPid);
+ %% We got a crash, likely because the environment
+ %% was overloaded and the timeout triggered. Try again.
+ {response, _, 500, _} ->
+ gun:close(ConnPid),
+ read_body_period(Config)
+ end.
%% We expect a crash.
do_read_body_timeout(Path, Body, Config) ->
@@ -516,9 +544,21 @@ do_read_body_timeout(Path, Body, Config) ->
Ref = gun:headers(ConnPid, "POST", Path, [
{<<"content-length">>, integer_to_binary(byte_size(Body))}
]),
- {response, _, 500, _} = gun:await(ConnPid, Ref, infinity),
+ case gun:await(ConnPid, Ref, infinity) of
+ {response, _, 500, _} ->
+ ok;
+ %% See do_maybe_h3_error comment for details.
+ {error, {stream_error, {stream_error, h3_internal_error, _}}} ->
+ ok
+ end,
gun:close(ConnPid).
+read_body_auto(Config) ->
+ doc("Read the request body using auto mode."),
+ <<0:80000000>> = do_body("POST", "/auto-sync/read_body", [], <<0:80000000>>, Config),
+ <<0:80000000>> = do_body("POST", "/auto-async/read_body", [], <<0:80000000>>, Config),
+ ok.
+
read_body_spawn(Config) ->
doc("Confirm we can use cowboy_req:read_body/1,2 from another process."),
<<"hello world!">> = do_body("POST", "/spawn/read_body", [], "hello world!", Config),
@@ -549,7 +589,8 @@ do_read_body_expect_100_continue(Path, Config) ->
fin -> {ok, <<>>}
end,
gun:close(ConnPid),
- do_decode(RespHeaders, RespBody).
+ do_decode(RespHeaders, RespBody),
+ ok.
read_urlencoded_body(Config) ->
doc("application/x-www-form-urlencoded request body."),
@@ -576,8 +617,20 @@ do_read_urlencoded_body_too_large(Path, Body, Config) ->
{<<"content-length">>, integer_to_binary(iolist_size(Body))}
]),
gun:data(ConnPid, Ref, fin, Body),
- {response, _, 413, _} = gun:await(ConnPid, Ref, infinity),
- gun:close(ConnPid).
+ Response = gun:await(ConnPid, Ref, infinity),
+ gun:close(ConnPid),
+ case Response of
+ {response, _, 413, _} ->
+ ok;
+ %% We got the wrong crash, likely because the environment
+ %% was overloaded and the timeout triggered. Try again.
+ {response, _, 408, _} ->
+ do_read_urlencoded_body_too_large(Path, Body, Config);
+ %% Timing issues make it possible for the connection to be
+ %% closed before the data went through. We retry.
+ {error, {stream_error, {closed, {error,closed}}}} ->
+ do_read_urlencoded_body_too_large(Path, Body, Config)
+ end.
read_urlencoded_body_too_long(Config) ->
doc("application/x-www-form-urlencoded request body sent too slow. "
@@ -592,25 +645,37 @@ do_read_urlencoded_body_too_long(Path, Body, Config) ->
{<<"content-length">>, integer_to_binary(byte_size(Body) * 2)}
]),
gun:data(ConnPid, Ref, nofin, Body),
- {response, _, 408, RespHeaders} = gun:await(ConnPid, Ref, infinity),
- _ = case config(protocol, Config) of
- http ->
+ Protocol = config(protocol, Config),
+ case gun:await(ConnPid, Ref, infinity) of
+ {response, _, 408, RespHeaders} when Protocol =:= http ->
%% 408 error responses should close HTTP/1.1 connections.
- {_, <<"close">>} = lists:keyfind(<<"connection">>, 1, RespHeaders);
- http2 ->
- ok
- end,
- gun:close(ConnPid).
+ {_, <<"close">>} = lists:keyfind(<<"connection">>, 1, RespHeaders),
+ gun:close(ConnPid);
+ {response, _, 408, _} when Protocol =:= http2; Protocol =:= http3 ->
+ gun:close(ConnPid);
+ %% We must have hit the timeout due to busy CI environment. Retry.
+ {response, _, 500, _} ->
+ gun:close(ConnPid),
+ do_read_urlencoded_body_too_long(Path, Body, Config)
+ end.
read_and_match_urlencoded_body(Config) ->
doc("Read and match an application/x-www-form-urlencoded request body."),
<<"#{}">> = do_body("POST", "/match/body_qs", [], "a=b&c=d", Config),
<<"#{a => <<\"b\">>}">> = do_body("POST", "/match/body_qs/a", [], "a=b&c=d", Config),
<<"#{c => <<\"d\">>}">> = do_body("POST", "/match/body_qs/c", [], "a=b&c=d", Config),
- <<"#{a => <<\"b\">>,c => <<\"d\">>}">>
- = do_body("POST", "/match/body_qs/a/c", [], "a=b&c=d", Config),
- <<"#{a => <<\"b\">>,c => true}">> = do_body("POST", "/match/body_qs/a/c", [], "a=b&c", Config),
- <<"#{a => true,c => <<\"d\">>}">> = do_body("POST", "/match/body_qs/a/c", [], "a&c=d", Config),
+ case do_body("POST", "/match/body_qs/a/c", [], "a=b&c=d", Config) of
+ <<"#{a => <<\"b\">>,c => <<\"d\">>}">> -> ok;
+ <<"#{c => <<\"d\">>,a => <<\"b\">>}">> -> ok
+ end,
+ case do_body("POST", "/match/body_qs/a/c", [], "a=b&c", Config) of
+ <<"#{a => <<\"b\">>,c => true}">> -> ok;
+ <<"#{c => true,a => <<\"b\">>}">> -> ok
+ end,
+ case do_body("POST", "/match/body_qs/a/c", [], "a&c=d", Config) of
+ <<"#{a => true,c => <<\"d\">>}">> -> ok;
+ <<"#{c => <<\"d\">>,a => true}">> -> ok
+ end,
%% Ensure match errors result in a 400 response.
{400, _} = do_body_error("POST", "/match/body_qs/a/c", [], "a=b", Config),
%% Ensure parse errors result in a 400 response.
@@ -768,18 +833,18 @@ set_resp_cookie(Config) ->
doc("Response using set_resp_cookie."),
%% Single cookie, no options.
{200, Headers1, _} = do_get("/resp/set_resp_cookie3", Config),
- {_, <<"mycookie=myvalue; Version=1">>}
+ {_, <<"mycookie=myvalue">>}
= lists:keyfind(<<"set-cookie">>, 1, Headers1),
%% Single cookie, with options.
{200, Headers2, _} = do_get("/resp/set_resp_cookie4", Config),
- {_, <<"mycookie=myvalue; Version=1; Path=/resp/set_resp_cookie4">>}
+ {_, <<"mycookie=myvalue; Path=/resp/set_resp_cookie4">>}
= lists:keyfind(<<"set-cookie">>, 1, Headers2),
%% Multiple cookies.
{200, Headers3, _} = do_get("/resp/set_resp_cookie3/multiple", Config),
[_, _] = [H || H={<<"set-cookie">>, _} <- Headers3],
%% Overwrite previously set cookie.
{200, Headers4, _} = do_get("/resp/set_resp_cookie3/overwrite", Config),
- {_, <<"mycookie=overwrite; Version=1">>}
+ {_, <<"mycookie=overwrite">>}
= lists:keyfind(<<"set-cookie">>, 1, Headers4),
ok.
@@ -787,6 +852,8 @@ set_resp_header(Config) ->
doc("Response using set_resp_header."),
{200, Headers, <<"OK">>} = do_get("/resp/set_resp_header", Config),
true = lists:keymember(<<"content-type">>, 1, Headers),
+ %% The set-cookie header is special. set_resp_cookie must be used.
+ {500, _, _} = do_maybe_h3_error3(do_get("/resp/set_resp_header_cookie", Config)),
ok.
set_resp_headers(Config) ->
@@ -794,6 +861,8 @@ set_resp_headers(Config) ->
{200, Headers, <<"OK">>} = do_get("/resp/set_resp_headers", Config),
true = lists:keymember(<<"content-type">>, 1, Headers),
true = lists:keymember(<<"content-encoding">>, 1, Headers),
+ %% The set-cookie header is special. set_resp_cookie must be used.
+ {500, _, _} = do_maybe_h3_error3(do_get("/resp/set_resp_headers_cookie", Config)),
ok.
resp_header(Config) ->
@@ -855,22 +924,52 @@ delete_resp_header(Config) ->
false = lists:keymember(<<"content-type">>, 1, Headers),
ok.
+%% Data may be lost due to how RESET_STREAM QUIC frame works.
+%% Because there is ongoing work for a better way to reset streams
+%% (https://www.ietf.org/archive/id/draft-ietf-quic-reliable-stream-reset-03.html)
+%% we convert the error to a 500 to keep the tests more explicit
+%% at what we expect.
+%% @todo When RESET_STREAM_AT gets added we can remove this function.
+do_maybe_h3_error2({stream_error, h3_internal_error, _}) -> {500, []};
+do_maybe_h3_error2(Result) -> Result.
+
+do_maybe_h3_error3({stream_error, h3_internal_error, _}) -> {500, [], <<>>};
+do_maybe_h3_error3(Result) -> Result.
+
inform2(Config) ->
doc("Informational response(s) without headers, followed by the real response."),
{102, [], 200, _, _} = do_get_inform("/resp/inform2/102", Config),
{102, [], 200, _, _} = do_get_inform("/resp/inform2/binary", Config),
- {500, _} = do_get_inform("/resp/inform2/error", Config),
+ {500, _} = do_maybe_h3_error2(do_get_inform("/resp/inform2/error", Config)),
{102, [], 200, _, _} = do_get_inform("/resp/inform2/twice", Config),
- ok.
+ %% With HTTP/1.1 and HTTP/2 we will not get an error.
+ %% With HTTP/3 however the stream will occasionally
+ %% be reset before Gun receives the response.
+ case do_get_inform("/resp/inform2/after_reply", Config) of
+ {200, _} ->
+ ok;
+ {stream_error, h3_internal_error, _} ->
+ ok
+ end.
inform3(Config) ->
doc("Informational response(s) with headers, followed by the real response."),
Headers = [{<<"ext-header">>, <<"ext-value">>}],
{102, Headers, 200, _, _} = do_get_inform("/resp/inform3/102", Config),
{102, Headers, 200, _, _} = do_get_inform("/resp/inform3/binary", Config),
- {500, _} = do_get_inform("/resp/inform3/error", Config),
+ {500, _} = do_maybe_h3_error2(do_get_inform("/resp/inform3/error", Config)),
+ %% The set-cookie header is special. set_resp_cookie must be used.
+ {500, _} = do_maybe_h3_error2(do_get_inform("/resp/inform3/set_cookie", Config)),
{102, Headers, 200, _, _} = do_get_inform("/resp/inform3/twice", Config),
- ok.
+ %% With HTTP/1.1 and HTTP/2 we will not get an error.
+ %% With HTTP/3 however the stream will occasionally
+ %% be reset before Gun receives the response.
+ case do_get_inform("/resp/inform3/after_reply", Config) of
+ {200, _} ->
+ ok;
+ {stream_error, h3_internal_error, _} ->
+ ok
+ end.
reply2(Config) ->
doc("Response with default headers and no body."),
@@ -878,9 +977,8 @@ reply2(Config) ->
{201, _, _} = do_get("/resp/reply2/201", Config),
{404, _, _} = do_get("/resp/reply2/404", Config),
{200, _, _} = do_get("/resp/reply2/binary", Config),
- {500, _, _} = do_get("/resp/reply2/error", Config),
- %% @todo We want to crash when reply or stream_reply is called twice.
- %% How to test this properly? This isn't enough.
+ {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply2/error", Config)),
+ %% @todo How to test this properly? This isn't enough.
{200, _, _} = do_get("/resp/reply2/twice", Config),
ok.
@@ -892,7 +990,9 @@ reply3(Config) ->
true = lists:keymember(<<"content-type">>, 1, Headers2),
{404, Headers3, _} = do_get("/resp/reply3/404", Config),
true = lists:keymember(<<"content-type">>, 1, Headers3),
- {500, _, _} = do_get("/resp/reply3/error", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply3/error", Config)),
+ %% The set-cookie header is special. set_resp_cookie must be used.
+ {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply3/set_cookie", Config)),
ok.
reply4(Config) ->
@@ -900,11 +1000,11 @@ reply4(Config) ->
{200, _, <<"OK">>} = do_get("/resp/reply4/200", Config),
{201, _, <<"OK">>} = do_get("/resp/reply4/201", Config),
{404, _, <<"OK">>} = do_get("/resp/reply4/404", Config),
- {500, _, _} = do_get("/resp/reply4/error", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply4/error", Config)),
+ %% The set-cookie header is special. set_resp_cookie must be used.
+ {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply4/set_cookie", Config)),
ok.
-%% @todo Crash when stream_reply is called twice.
-
stream_reply2(Config) ->
doc("Response with default headers and streamed body."),
Body = <<0:8000000>>,
@@ -912,9 +1012,37 @@ stream_reply2(Config) ->
{201, _, Body} = do_get("/resp/stream_reply2/201", Config),
{404, _, Body} = do_get("/resp/stream_reply2/404", Config),
{200, _, Body} = do_get("/resp/stream_reply2/binary", Config),
- {500, _, _} = do_get("/resp/stream_reply2/error", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/resp/stream_reply2/error", Config)),
ok.
+stream_reply2_twice(Config) ->
+ doc("Attempting to stream a response twice results in a crash."),
+ ConnPid = gun_open(Config),
+ Ref = gun:get(ConnPid, "/resp/stream_reply2/twice",
+ [{<<"accept-encoding">>, <<"gzip">>}]),
+ {response, nofin, 200, _} = gun:await(ConnPid, Ref, infinity),
+ Protocol = config(protocol, Config),
+ Flavor = config(flavor, Config),
+ case {Protocol, Flavor, gun:await_body(ConnPid, Ref, infinity)} of
+ %% In HTTP/1.1 we cannot propagate an error at that point.
+ %% The response will simply not have a body.
+ {http, vanilla, {ok, <<>>}} ->
+ ok;
+ %% When compression was used we do get gzip headers. But
+ %% we do not have any data in the zlib stream.
+ {http, compress, {ok, Data}} ->
+ Z = zlib:open(),
+ zlib:inflateInit(Z, 31),
+ 0 = iolist_size(zlib:inflate(Z, Data)),
+ ok;
+ %% In HTTP/2 and HTTP/3 the stream gets reset with an appropriate error.
+ {http2, _, {error, {stream_error, {stream_error, internal_error, _}}}} ->
+ ok;
+ {http3, _, {error, {stream_error, {stream_error, h3_internal_error, _}}}} ->
+ ok
+ end,
+ gun:close(ConnPid).
+
stream_reply3(Config) ->
doc("Response with additional headers and streamed body."),
Body = <<0:8000000>>,
@@ -924,7 +1052,9 @@ stream_reply3(Config) ->
true = lists:keymember(<<"content-type">>, 1, Headers2),
{404, Headers3, Body} = do_get("/resp/stream_reply3/404", Config),
true = lists:keymember(<<"content-type">>, 1, Headers3),
- {500, _, _} = do_get("/resp/stream_reply3/error", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/resp/stream_reply3/error", Config)),
+ %% The set-cookie header is special. set_resp_cookie must be used.
+ {500, _, _} = do_maybe_h3_error3(do_get("/resp/stream_reply3/set_cookie", Config)),
ok.
stream_body_fin0(Config) ->
@@ -1008,8 +1138,11 @@ stream_body_content_length_nofin_error(Config) ->
end
end;
http2 ->
- %% @todo HTTP2 should have the same content-length checks
- ok
+ %% @todo HTTP/2 should have the same content-length checks.
+ {skip, "Implement the test for HTTP/2."};
+ http3 ->
+ %% @todo HTTP/3 should have the same content-length checks.
+ {skip, "Implement the test for HTTP/3."}
end.
stream_body_concurrent(Config) ->
@@ -1104,6 +1237,35 @@ stream_trailers_no_te(Config) ->
<<"Hello world!">> = do_decode(RespHeaders, RespBody),
gun:close(ConnPid).
+stream_trailers_set_cookie(Config) ->
+ doc("Trying to send set-cookie in trailers should result in a crash."),
+ ConnPid = gun_open(Config),
+ Ref = gun:get(ConnPid, "/resp/stream_trailers/set_cookie", [
+ {<<"accept-encoding">>, <<"gzip">>},
+ {<<"te">>, <<"trailers">>}
+ ]),
+ Protocol = config(protocol, Config),
+ case gun:await(ConnPid, Ref, infinity) of
+ {response, nofin, 200, _} when Protocol =:= http ->
+ %% Trailers are not sent because of the stream error.
+ {ok, _Body} = gun:await_body(ConnPid, Ref, infinity),
+ {error, timeout} = gun:await_body(ConnPid, Ref, 1000),
+ ok;
+ {response, nofin, 200, _} when Protocol =:= http2 ->
+ {error, {stream_error, {stream_error, internal_error, _}}}
+ = gun:await_body(ConnPid, Ref, infinity),
+ ok;
+ {response, nofin, 200, _} when Protocol =:= http3 ->
+ {error, {stream_error, {stream_error, h3_internal_error, _}}}
+ = gun:await_body(ConnPid, Ref, infinity),
+ ok;
+ %% The RST_STREAM arrived before the start of the response.
+ %% See maybe_h3_error comment for details.
+ {error, {stream_error, {stream_error, h3_internal_error, _}}} when Protocol =:= http3 ->
+ ok
+ end,
+ gun:close(ConnPid).
+
do_trailers(Path, Config) ->
ConnPid = gun_open(Config),
Ref = gun:get(ConnPid, Path, [
@@ -1127,26 +1289,45 @@ do_trailers(Path, Config) ->
push(Config) ->
case config(protocol, Config) of
http -> do_push_http("/resp/push", Config);
- http2 -> do_push_http2(Config)
+ http2 -> do_push_http2(Config);
+ http3 -> {skip, "Implement server push for HTTP/3."}
end.
+push_after_reply(Config) ->
+ doc("Trying to push a response after the final response results in a crash."),
+ ConnPid = gun_open(Config),
+ Ref = gun:get(ConnPid, "/resp/push/after_reply", []),
+ %% With HTTP/1.1 and HTTP/2 we will not get an error.
+ %% With HTTP/3 however the stream will occasionally
+ %% be reset before Gun receives the response.
+ case gun:await(ConnPid, Ref, infinity) of
+ {response, fin, 200, _} ->
+ ok;
+ {error, {stream_error, {stream_error, h3_internal_error, _}}} ->
+ ok
+ end,
+ gun:close(ConnPid).
+
push_method(Config) ->
case config(protocol, Config) of
http -> do_push_http("/resp/push/method", Config);
- http2 -> do_push_http2_method(Config)
+ http2 -> do_push_http2_method(Config);
+ http3 -> {skip, "Implement server push for HTTP/3."}
end.
push_origin(Config) ->
case config(protocol, Config) of
http -> do_push_http("/resp/push/origin", Config);
- http2 -> do_push_http2_origin(Config)
+ http2 -> do_push_http2_origin(Config);
+ http3 -> {skip, "Implement server push for HTTP/3."}
end.
push_qs(Config) ->
case config(protocol, Config) of
http -> do_push_http("/resp/push/qs", Config);
- http2 -> do_push_http2_qs(Config)
+ http2 -> do_push_http2_qs(Config);
+ http3 -> {skip, "Implement server push for HTTP/3."}
end.
do_push_http(Path, Config) ->
@@ -1154,7 +1335,7 @@ do_push_http(Path, Config) ->
ConnPid = gun_open(Config),
Ref = gun:get(ConnPid, Path, []),
{response, fin, 200, _} = gun:await(ConnPid, Ref, infinity),
- ok.
+ gun:close(ConnPid).
do_push_http2(Config) ->
doc("Pushed responses."),
diff --git a/test/rest_handler_SUITE.erl b/test/rest_handler_SUITE.erl
index 1667565..6c1f1c1 100644
--- a/test/rest_handler_SUITE.erl
+++ b/test/rest_handler_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -32,7 +32,7 @@ init_per_group(Name, Config) ->
cowboy_test:init_common_groups(Name, Config, ?MODULE).
end_per_group(Name, _) ->
- cowboy:stop_listener(Name).
+ cowboy_test:stop_group(Name).
%% Dispatch configuration.
@@ -85,7 +85,7 @@ accept_callback_missing(Config) ->
{<<"accept-encoding">>, <<"gzip">>},
{<<"content-type">>, <<"text/plain">>}
], <<"Missing!">>),
- {response, fin, 500, _} = gun:await(ConnPid, Ref),
+ {response, fin, 500, _} = do_maybe_h3_error(gun:await(ConnPid, Ref)),
ok.
accept_callback_patch_false(Config) ->
@@ -127,7 +127,7 @@ do_accept_callback_true(Config, Fun) ->
ok.
charset_in_content_types_provided(Config) ->
- doc("When a charset is matched explictly in content_types_provided, "
+ doc("When a charset is matched explicitly in content_types_provided, "
"that charset is used and the charsets_provided callback is ignored."),
ConnPid = gun_open(Config),
Ref = gun:get(ConnPid, "/charset_in_content_types_provided", [
@@ -472,7 +472,7 @@ delete_resource_missing(Config) ->
Ref = gun:delete(ConnPid, "/delete_resource?missing", [
{<<"accept-encoding">>, <<"gzip">>}
]),
- {response, _, 500, _} = gun:await(ConnPid, Ref),
+ {response, _, 500, _} = do_maybe_h3_error(gun:await(ConnPid, Ref)),
ok.
create_resource_created(Config) ->
@@ -571,6 +571,17 @@ generate_etag_missing(Config) ->
false = lists:keyfind(<<"etag">>, 1, Headers),
ok.
+generate_etag_undefined(Config) ->
+ doc("The etag header must not be sent when "
+ "the generate_etag callback returns undefined."),
+ ConnPid = gun_open(Config),
+ Ref = gun:get(ConnPid, "/generate_etag?undefined", [
+ {<<"accept-encoding">>, <<"gzip">>}
+ ]),
+ {response, _, 200, Headers} = gun:await(ConnPid, Ref),
+ false = lists:keyfind(<<"etag">>, 1, Headers),
+ ok.
+
generate_etag_binary_strong(Config) ->
doc("The etag header must be sent when the generate_etag "
"callback returns a strong binary. (RFC7232 2.3)"),
@@ -639,10 +650,16 @@ do_generate_etag(Config, Qs, ReqHeaders, Status, Etag) ->
{<<"accept-encoding">>, <<"gzip">>}
|ReqHeaders
]),
- {response, _, Status, RespHeaders} = gun:await(ConnPid, Ref),
+ {response, _, Status, RespHeaders} = do_maybe_h3_error(gun:await(ConnPid, Ref)),
Etag = lists:keyfind(<<"etag">>, 1, RespHeaders),
ok.
+%% See do_maybe_h3_error2 comment.
+do_maybe_h3_error({error, {stream_error, {stream_error, h3_internal_error, _}}}) ->
+ {response, fin, 500, []};
+do_maybe_h3_error(Result) ->
+ Result.
+
if_range_etag_equal(Config) ->
doc("When the if-range header matches, a 206 partial content "
"response is expected for an otherwise valid range request. (RFC7233 3.2)"),
@@ -795,7 +812,7 @@ provide_callback_missing(Config) ->
doc("A 500 response must be sent when the ProvideCallback can't be called."),
ConnPid = gun_open(Config),
Ref = gun:get(ConnPid, "/provide_callback_missing", [{<<"accept-encoding">>, <<"gzip">>}]),
- {response, fin, 500, _} = gun:await(ConnPid, Ref),
+ {response, fin, 500, _} = do_maybe_h3_error(gun:await(ConnPid, Ref)),
ok.
provide_range_callback(Config) ->
@@ -951,7 +968,7 @@ provide_range_callback_missing(Config) ->
{<<"accept-encoding">>, <<"gzip">>},
{<<"range">>, <<"bytes=0-">>}
]),
- {response, fin, 500, _} = gun:await(ConnPid, Ref),
+ {response, fin, 500, _} = do_maybe_h3_error(gun:await(ConnPid, Ref)),
ok.
range_ignore_unknown_unit(Config) ->
diff --git a/test/rfc6585_SUITE.erl b/test/rfc6585_SUITE.erl
index 1f65f78..17cbb07 100644
--- a/test/rfc6585_SUITE.erl
+++ b/test/rfc6585_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2018, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -30,7 +30,7 @@ init_per_group(Name, Config) ->
cowboy_test:init_common_groups(Name, Config, ?MODULE).
end_per_group(Name, _) ->
- cowboy:stop_listener(Name).
+ cowboy_test:stop_group(Name).
init_dispatch(_) ->
cowboy_router:compile([{"[...]", [
diff --git a/test/rfc7230_SUITE.erl b/test/rfc7230_SUITE.erl
index 9846a0f..17d1905 100644
--- a/test/rfc7230_SUITE.erl
+++ b/test/rfc7230_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2015-2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2015-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -22,6 +22,7 @@
-import(cowboy_test, [raw_open/1]).
-import(cowboy_test, [raw_send/2]).
-import(cowboy_test, [raw_recv_head/1]).
+-import(cowboy_test, [raw_recv_rest/3]).
-import(cowboy_test, [raw_recv/3]).
suite() ->
@@ -63,13 +64,7 @@ do_raw(Config, Data) ->
{Headers, Rest2} = cow_http:parse_headers(Rest),
case lists:keyfind(<<"content-length">>, 1, Headers) of
{_, LengthBin} when LengthBin =/= <<"0">> ->
- Length = binary_to_integer(LengthBin),
- Body = if
- byte_size(Rest2) =:= Length -> Rest2;
- true ->
- {ok, Body0} = raw_recv(Client, Length - byte_size(Rest2), 5000),
- << Rest2/bits, Body0/bits >>
- end,
+ Body = raw_recv_rest(Client, binary_to_integer(LengthBin), Rest2),
#{client => Client, version => Version, code => Code, reason => Reason, headers => Headers, body => Body};
_ ->
#{client => Client, version => Version, code => Code, reason => Reason, headers => Headers, body => <<>>}
@@ -1149,18 +1144,19 @@ reject_invalid_content_length(Config) ->
%with a message body too large must be rejected with a 413 status
%code and the closing of the connection. (RFC7230 3.3.2)
-ignore_content_length_when_transfer_encoding(Config) ->
+reject_when_both_content_length_and_transfer_encoding(Config) ->
doc("When a message includes both transfer-encoding and content-length "
- "headers, the content-length header must be removed before processing "
- "the request. (RFC7230 3.3.3)"),
- #{code := 200, body := <<"Hello world!">>} = do_raw(Config, [
+ "headers, the message may be an attempt at request smuggling. It "
+ "must be rejected with a 400 status code and the closing of the "
+ "connection. (RFC7230 3.3.3)"),
+ #{code := 400, client := Client} = do_raw(Config, [
"POST /echo/read_body HTTP/1.1\r\n"
"Host: localhost\r\n"
"Transfer-encoding: chunked\r\n"
"Content-length: 12\r\n"
"\r\n"
"6\r\nHello \r\n5\r\nworld\r\n1\r\n!\r\n0\r\n\r\n"]),
- ok.
+ {error, closed} = raw_recv(Client, 0, 1000).
%socket_error_while_reading_body(Config) ->
%If a socket error occurs while reading the body the server
@@ -1512,6 +1508,28 @@ http10_no_connection_header_close(Config) ->
{_, <<"close">>} = lists:keyfind(<<"connection">>, 1, RespHeaders),
{error, closed} = raw_recv(Client, 0, 1000).
+connection_invalid(Config) ->
+ doc("HTTP/1.1 requests with an invalid Connection header "
+ "must be rejected with a 400 status code and the closing "
+ "of the connection. (RFC7230 6.1)"),
+ #{code := 400, client := Client} = do_raw(Config, [
+ "GET / HTTP/1.1\r\n"
+ "Host: localhost\r\n"
+ "Connection: jndi{ldap127\r\n"
+ "\r\n"]),
+ {error, closed} = raw_recv(Client, 0, 1000).
+
+http10_connection_invalid(Config) ->
+ doc("HTTP/1.0 requests with an invalid Connection header "
+ "must be rejected with a 400 status code and the closing "
+ "of the connection. (RFC7230 6.1)"),
+ #{code := 400, client := Client} = do_raw(Config, [
+ "GET / HTTP/1.0\r\n"
+ "Host: localhost\r\n"
+ "Connection: jndi{ldap127\r\n"
+ "\r\n"]),
+ {error, closed} = raw_recv(Client, 0, 1000).
+
limit_requests_keepalive(Config) ->
doc("The maximum number of requests sent using a persistent connection "
"must be subject to configuration. The connection must be closed "
diff --git a/test/rfc7231_SUITE.erl b/test/rfc7231_SUITE.erl
index 6c74391..4475899 100644
--- a/test/rfc7231_SUITE.erl
+++ b/test/rfc7231_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -35,7 +35,7 @@ init_per_group(Name, Config) ->
cowboy_test:init_common_groups(Name, Config, ?MODULE).
end_per_group(Name, _) ->
- cowboy:stop_listener(Name).
+ cowboy_test:stop_group(Name).
init_dispatch(_) ->
cowboy_router:compile([{"[...]", [
@@ -230,13 +230,15 @@ expect(Config) ->
{<<"expect">>, <<"100-continue">>}
]),
{inform, 100, _} = gun:await(ConnPid, Ref),
- ok.
+ gun:close(ConnPid).
http10_expect(Config) ->
case config(protocol, Config) of
http ->
do_http10_expect(Config);
http2 ->
+ expect(Config);
+ http3 ->
expect(Config)
end.
@@ -303,6 +305,9 @@ expect_discard_body_close(Config) ->
do_expect_discard_body_close(Config);
http2 ->
doc("There's no reason to close the connection when using HTTP/2, "
+ "even if a stream body is too large. We just cancel the stream.");
+ http3 ->
+ doc("There's no reason to close the connection when using HTTP/3, "
"even if a stream body is too large. We just cancel the stream.")
end.
@@ -424,8 +429,10 @@ http10_status_code_100(Config) ->
http ->
doc("The 100 Continue status code must not "
"be sent to HTTP/1.0 endpoints. (RFC7231 6.2)"),
- do_http10_status_code_1xx(100, Config);
+ do_unsupported_status_code_1xx(100, Config);
http2 ->
+ status_code_100(Config);
+ http3 ->
status_code_100(Config)
end.
@@ -434,12 +441,16 @@ http10_status_code_101(Config) ->
http ->
doc("The 101 Switching Protocols status code must not "
"be sent to HTTP/1.0 endpoints. (RFC7231 6.2)"),
- do_http10_status_code_1xx(101, Config);
+ do_unsupported_status_code_1xx(101, Config);
http2 ->
+ status_code_101(Config);
+ http3 ->
+ %% While 101 is not supported by HTTP/3, there is no
+ %% wording in RFC9114 that forbids sending it.
status_code_101(Config)
end.
-do_http10_status_code_1xx(StatusCode, Config) ->
+do_unsupported_status_code_1xx(StatusCode, Config) ->
ConnPid = gun_open(Config, #{http_opts => #{version => 'HTTP/1.0'}}),
Ref = gun:get(ConnPid, "/resp/inform2/" ++ integer_to_list(StatusCode), [
{<<"accept-encoding">>, <<"gzip">>}
@@ -653,7 +664,9 @@ status_code_408_connection_close(Config) ->
http ->
do_http11_status_code_408_connection_close(Config);
http2 ->
- doc("HTTP/2 connections are not closed on 408 responses.")
+ doc("HTTP/2 connections are not closed on 408 responses.");
+ http3 ->
+ doc("HTTP/3 connections are not closed on 408 responses.")
end.
do_http11_status_code_408_connection_close(Config) ->
@@ -744,7 +757,9 @@ status_code_426_upgrade_header(Config) ->
http ->
do_status_code_426_upgrade_header(Config);
http2 ->
- doc("HTTP/2 does not support the HTTP/1.1 Upgrade mechanism.")
+ doc("HTTP/2 does not support the HTTP/1.1 Upgrade mechanism.");
+ http3 ->
+ doc("HTTP/3 does not support the HTTP/1.1 Upgrade mechanism.")
end.
do_status_code_426_upgrade_header(Config) ->
diff --git a/test/rfc7538_SUITE.erl b/test/rfc7538_SUITE.erl
index 5eb9705..c46d388 100644
--- a/test/rfc7538_SUITE.erl
+++ b/test/rfc7538_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2018, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -30,7 +30,7 @@ init_per_group(Name, Config) ->
cowboy_test:init_common_groups(Name, Config, ?MODULE).
end_per_group(Name, _) ->
- cowboy:stop_listener(Name).
+ cowboy_test:stop_group(Name).
init_dispatch(_) ->
cowboy_router:compile([{"[...]", [
diff --git a/test/rfc7540_SUITE.erl b/test/rfc7540_SUITE.erl
index 6d8aa91..f040601 100644
--- a/test/rfc7540_SUITE.erl
+++ b/test/rfc7540_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2016-2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2016-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -12,6 +12,12 @@
%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+%% Note that Cowboy does not implement the PRIORITY mechanism.
+%% Everyone has been moving away from it and it is widely seen
+%% as a failure. Setting priorities has been counter productive
+%% with regards to performance. Clients have been moving away
+%% from the mechanism.
+
-module(rfc7540_SUITE).
-compile(export_all).
-compile(nowarn_export_all).
@@ -28,9 +34,9 @@
all() -> [{group, clear}, {group, tls}].
groups() ->
- Modules = ct_helper:all(?MODULE),
- Clear = [M || M <- Modules, lists:sublist(atom_to_list(M), 4) =/= "alpn"] -- [prior_knowledge_reject_tls],
- TLS = [M || M <- Modules, lists:sublist(atom_to_list(M), 4) =:= "alpn"] ++ [prior_knowledge_reject_tls],
+ Tests = ct_helper:all(?MODULE),
+ Clear = [T || T <- Tests, lists:sublist(atom_to_list(T), 4) =/= "alpn"] -- [prior_knowledge_reject_tls],
+ TLS = [T || T <- Tests, lists:sublist(atom_to_list(T), 4) =:= "alpn"] ++ [prior_knowledge_reject_tls],
[{clear, [parallel], Clear}, {tls, [parallel], TLS}].
init_per_group(Name = clear, Config) ->
@@ -483,14 +489,6 @@ http_upgrade_client_preface_settings_ack_timeout(Config) ->
%% important, an OPTIONS request can be used to perform the upgrade to
%% HTTP/2, at the cost of an additional round trip.
-%% @todo If we ever handle priority, we need to check that the initial
-%% HTTP/1.1 request has default priority. The relevant RFC quote is:
-%%
-%% 3.2
-%% The HTTP/1.1 request that is sent prior to upgrade is assigned a
-%% stream identifier of 1 (see Section 5.1.1) with default priority
-%% values (Section 5.3.5).
-
http_upgrade_response(Config) ->
doc("A response must be sent to the initial HTTP/1.1 request "
"after switching to HTTP/2. The response must use "
@@ -589,16 +587,20 @@ http_upgrade_response_half_closed(Config) ->
alpn_ignore_h2c(Config) ->
doc("An h2c ALPN protocol identifier must be ignored. (RFC7540 3.3)"),
+ TlsOpts = ct_helper:get_certs_from_ets(),
{ok, Socket} = ssl:connect("localhost", config(port, Config),
- [{alpn_advertised_protocols, [<<"h2c">>, <<"http/1.1">>]}, binary, {active, false}]),
+ [{alpn_advertised_protocols, [<<"h2c">>, <<"http/1.1">>]},
+ binary, {active, false}|TlsOpts]),
{ok, <<"http/1.1">>} = ssl:negotiated_protocol(Socket),
ok.
alpn_server_preface(Config) ->
doc("The first frame must be a SETTINGS frame "
"for the server connection preface. (RFC7540 3.3, RFC7540 3.5, RFC7540 6.5)"),
+ TlsOpts = ct_helper:get_certs_from_ets(),
{ok, Socket} = ssl:connect("localhost", config(port, Config),
- [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ binary, {active, false}|TlsOpts]),
{ok, <<"h2">>} = ssl:negotiated_protocol(Socket),
%% Receive the server preface.
{ok, << _:24, 4:8, 0:40 >>} = ssl:recv(Socket, 9, 1000),
@@ -607,8 +609,10 @@ alpn_server_preface(Config) ->
alpn_client_preface_timeout(Config) ->
doc("Clients negotiating HTTP/2 and not sending a preface in "
"a timely manner must be disconnected."),
+ TlsOpts = ct_helper:get_certs_from_ets(),
{ok, Socket} = ssl:connect("localhost", config(port, Config),
- [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ binary, {active, false}|TlsOpts]),
{ok, <<"h2">>} = ssl:negotiated_protocol(Socket),
%% Receive the server preface.
{ok, << Len:24 >>} = ssl:recv(Socket, 3, 1000),
@@ -620,8 +624,10 @@ alpn_client_preface_timeout(Config) ->
alpn_reject_missing_client_preface(Config) ->
doc("Servers must treat an invalid connection preface as a "
"connection error of type PROTOCOL_ERROR. (RFC7540 3.3, RFC7540 3.5)"),
+ TlsOpts = ct_helper:get_certs_from_ets(),
{ok, Socket} = ssl:connect("localhost", config(port, Config),
- [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ binary, {active, false}|TlsOpts]),
{ok, <<"h2">>} = ssl:negotiated_protocol(Socket),
%% Send a SETTINGS frame directly instead of the proper preface.
ok = ssl:send(Socket, cow_http2:settings(#{})),
@@ -635,8 +641,10 @@ alpn_reject_missing_client_preface(Config) ->
alpn_reject_invalid_client_preface(Config) ->
doc("Servers must treat an invalid connection preface as a "
"connection error of type PROTOCOL_ERROR. (RFC7540 3.3, RFC7540 3.5)"),
+ TlsOpts = ct_helper:get_certs_from_ets(),
{ok, Socket} = ssl:connect("localhost", config(port, Config),
- [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ binary, {active, false}|TlsOpts]),
{ok, <<"h2">>} = ssl:negotiated_protocol(Socket),
%% Send a slightly incorrect preface.
ok = ssl:send(Socket, "PRI * HTTP/2.0\r\n\r\nSM: Value\r\n\r\n"),
@@ -650,8 +658,10 @@ alpn_reject_invalid_client_preface(Config) ->
alpn_reject_missing_client_preface_settings(Config) ->
doc("Servers must treat an invalid connection preface as a "
"connection error of type PROTOCOL_ERROR. (RFC7540 3.3, RFC7540 3.5)"),
+ TlsOpts = ct_helper:get_certs_from_ets(),
{ok, Socket} = ssl:connect("localhost", config(port, Config),
- [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ binary, {active, false}|TlsOpts]),
{ok, <<"h2">>} = ssl:negotiated_protocol(Socket),
%% Send a valid preface sequence except followed by a PING instead of a SETTINGS frame.
ok = ssl:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", cow_http2:ping(0)]),
@@ -665,8 +675,10 @@ alpn_reject_missing_client_preface_settings(Config) ->
alpn_reject_invalid_client_preface_settings(Config) ->
doc("Servers must treat an invalid connection preface as a "
"connection error of type PROTOCOL_ERROR. (RFC7540 3.3, RFC7540 3.5)"),
+ TlsOpts = ct_helper:get_certs_from_ets(),
{ok, Socket} = ssl:connect("localhost", config(port, Config),
- [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ binary, {active, false}|TlsOpts]),
{ok, <<"h2">>} = ssl:negotiated_protocol(Socket),
%% Send a valid preface sequence except followed by a badly formed SETTINGS frame.
ok = ssl:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", << 0:24, 4:8, 0:9, 1:31 >>]),
@@ -679,8 +691,10 @@ alpn_reject_invalid_client_preface_settings(Config) ->
alpn_accept_client_preface_empty_settings(Config) ->
doc("The SETTINGS frame in the client preface may be empty. (RFC7540 3.3, RFC7540 3.5)"),
+ TlsOpts = ct_helper:get_certs_from_ets(),
{ok, Socket} = ssl:connect("localhost", config(port, Config),
- [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ binary, {active, false}|TlsOpts]),
{ok, <<"h2">>} = ssl:negotiated_protocol(Socket),
%% Send a valid preface sequence except followed by an empty SETTINGS frame.
ok = ssl:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", cow_http2:settings(#{})]),
@@ -694,8 +708,10 @@ alpn_accept_client_preface_empty_settings(Config) ->
alpn_client_preface_settings_ack_timeout(Config) ->
doc("Failure to acknowledge the server's SETTINGS frame "
"results in a SETTINGS_TIMEOUT connection error. (RFC7540 3.5, RFC7540 6.5.3)"),
+ TlsOpts = ct_helper:get_certs_from_ets(),
{ok, Socket} = ssl:connect("localhost", config(port, Config),
- [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ binary, {active, false}|TlsOpts]),
{ok, <<"h2">>} = ssl:negotiated_protocol(Socket),
%% Send a valid preface.
ok = ssl:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", cow_http2:settings(#{})]),
@@ -710,8 +726,10 @@ alpn_client_preface_settings_ack_timeout(Config) ->
alpn(Config) ->
doc("Successful ALPN negotiation. (RFC7540 3.3)"),
+ TlsOpts = ct_helper:get_certs_from_ets(),
{ok, Socket} = ssl:connect("localhost", config(port, Config),
- [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ binary, {active, false}|TlsOpts]),
{ok, <<"h2">>} = ssl:negotiated_protocol(Socket),
%% Send a valid preface.
%% @todo Use non-empty SETTINGS here. Just because.
@@ -735,7 +753,9 @@ alpn(Config) ->
prior_knowledge_reject_tls(Config) ->
doc("Implementations that support HTTP/2 over TLS must use ALPN. (RFC7540 3.4)"),
- {ok, Socket} = ssl:connect("localhost", config(port, Config), [binary, {active, false}]),
+ TlsOpts = ct_helper:get_certs_from_ets(),
+ {ok, Socket} = ssl:connect("localhost", config(port, Config),
+ [binary, {active, false}|TlsOpts]),
%% Send a valid preface.
ok = ssl:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", cow_http2:settings(#{})]),
%% We expect the server to send an HTTP 400 error
@@ -1354,7 +1374,8 @@ max_frame_size_allow_exactly_custom(Config0) ->
{ok, << Len2:24, 1:8, _:40 >>} = gen_tcp:recv(Socket, 9, 6000),
{ok, _} = gen_tcp:recv(Socket, Len2, 6000),
%% No errors follow due to our sending of a 25000 bytes frame.
- {error, timeout} = gen_tcp:recv(Socket, 0, 1000)
+ {error, timeout} = gen_tcp:recv(Socket, 0, 1000),
+ gen_tcp:close(Socket)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
@@ -1384,7 +1405,8 @@ max_frame_size_reject_larger_than_custom(Config0) ->
cow_http2:data(1, fin, <<0:30001/unit:8>>)
]),
%% Receive a FRAME_SIZE_ERROR connection error.
- {ok, << _:24, 7:8, _:72, 6:32 >>} = gen_tcp:recv(Socket, 17, 6000)
+ {ok, << _:24, 7:8, _:72, 6:32 >>} = gen_tcp:recv(Socket, 17, 6000),
+ gen_tcp:close(Socket)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
@@ -2599,9 +2621,10 @@ settings_header_table_size_server(Config0) ->
{ok, << Len1:24, 1:8, _:40 >>} = gen_tcp:recv(Socket, 9, 6000),
{ok, RespHeadersBlock1} = gen_tcp:recv(Socket, Len1, 6000),
{RespHeaders, _} = cow_hpack:decode(RespHeadersBlock1, DecodeState),
- {_, <<"200">>} = lists:keyfind(<<":status">>, 1, RespHeaders)
+ {_, <<"200">>} = lists:keyfind(<<":status">>, 1, RespHeaders),
%% The decoding succeeded on the server, confirming that
%% the table size was updated to HeaderTableSize.
+ gen_tcp:close(Socket)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
@@ -2630,7 +2653,8 @@ settings_max_concurrent_streams(Config0) ->
cow_http2:headers(3, fin, ReqHeadersBlock2)
]),
%% Receive a REFUSED_STREAM stream error.
- {ok, << _:24, 3:8, _:8, 3:32, 7:32 >>} = gen_tcp:recv(Socket, 13, 6000)
+ {ok, << _:24, 3:8, _:8, 3:32, 7:32 >>} = gen_tcp:recv(Socket, 13, 6000),
+ gen_tcp:close(Socket)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
@@ -2654,7 +2678,8 @@ settings_max_concurrent_streams_0(Config0) ->
]),
ok = gen_tcp:send(Socket, cow_http2:headers(1, fin, HeadersBlock)),
%% Receive a REFUSED_STREAM stream error.
- {ok, << _:24, 3:8, _:8, 1:32, 7:32 >>} = gen_tcp:recv(Socket, 13, 6000)
+ {ok, << _:24, 3:8, _:8, 1:32, 7:32 >>} = gen_tcp:recv(Socket, 13, 6000),
+ gen_tcp:close(Socket)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
@@ -2722,7 +2747,8 @@ settings_initial_window_size(Config0) ->
{ok, << Len2:24, 1:8, _:40 >>} = gen_tcp:recv(Socket, 9, 6000),
{ok, _} = gen_tcp:recv(Socket, Len2, 6000),
%% No errors follow due to our sending of more than 65535 bytes of data.
- {error, timeout} = gen_tcp:recv(Socket, 0, 1000)
+ {error, timeout} = gen_tcp:recv(Socket, 0, 1000),
+ gen_tcp:close(Socket)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
@@ -2765,7 +2791,8 @@ settings_initial_window_size_after_ack(Config0) ->
cow_http2:data(1, fin, <<0:32/unit:8>>)
]),
%% Receive a FLOW_CONTROL_ERROR stream error.
- {ok, << _:24, 3:8, _:8, 1:32, 3:32 >>} = gen_tcp:recv(Socket, 13, 6000)
+ {ok, << _:24, 3:8, _:8, 1:32, 3:32 >>} = gen_tcp:recv(Socket, 13, 6000),
+ gen_tcp:close(Socket)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
@@ -2813,7 +2840,8 @@ settings_initial_window_size_before_ack(Config0) ->
{ok, << Len2:24, 1:8, _:40 >>} = gen_tcp:recv(Socket, 9, 6000),
{ok, _} = gen_tcp:recv(Socket, Len2, 6000),
%% No errors follow due to our sending of more than 0 bytes of data.
- {error, timeout} = gen_tcp:recv(Socket, 0, 1000)
+ {error, timeout} = gen_tcp:recv(Socket, 0, 1000),
+ gen_tcp:close(Socket)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
@@ -2846,7 +2874,8 @@ settings_max_frame_size(Config0) ->
{ok, << Len2:24, 1:8, _:40 >>} = gen_tcp:recv(Socket, 9, 6000),
{ok, _} = gen_tcp:recv(Socket, Len2, 6000),
%% No errors follow due to our sending of a 25000 bytes frame.
- {error, timeout} = gen_tcp:recv(Socket, 0, 1000)
+ {error, timeout} = gen_tcp:recv(Socket, 0, 1000),
+ gen_tcp:close(Socket)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
@@ -3095,7 +3124,8 @@ data_reject_overflow(Config0) ->
cow_http2:data(1, fin, <<0:15000/unit:8>>)
]),
%% Receive a FLOW_CONTROL_ERROR connection error.
- {ok, << _:24, 7:8, _:72, 3:32 >>} = gen_tcp:recv(Socket, 17, 6000)
+ {ok, << _:24, 7:8, _:72, 3:32 >>} = gen_tcp:recv(Socket, 17, 6000),
+ gen_tcp:close(Socket)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
@@ -3143,7 +3173,8 @@ data_reject_overflow_stream(Config0) ->
cow_http2:data(1, fin, <<0:15000/unit:8>>)
]),
%% Receive a FLOW_CONTROL_ERROR stream error.
- {ok, << _:24, 3:8, _:8, 1:32, 3:32 >>} = gen_tcp:recv(Socket, 13, 6000)
+ {ok, << _:24, 3:8, _:8, 1:32, 3:32 >>} = gen_tcp:recv(Socket, 13, 6000),
+ gen_tcp:close(Socket)
after
cowboy:stop_listener(?FUNCTION_NAME)
end.
@@ -3862,6 +3893,7 @@ accept_host_header_on_missing_pseudo_header_authority(Config) ->
%% When both :authority and host headers are received, the current behavior
%% is to favor :authority and ignore the host header. The specification does
%% not describe the correct behavior to follow in that case.
+%% @todo The HTTP/3 spec says both values must be identical and non-empty.
reject_many_pseudo_header_authority(Config) ->
doc("A request containing more than one authority component must be rejected "
diff --git a/test/rfc8297_SUITE.erl b/test/rfc8297_SUITE.erl
index 9ae6180..c6c1c9d 100644
--- a/test/rfc8297_SUITE.erl
+++ b/test/rfc8297_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2018, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -30,7 +30,7 @@ init_per_group(Name, Config) ->
cowboy_test:init_common_groups(Name, Config, ?MODULE).
end_per_group(Name, _) ->
- cowboy:stop_listener(Name).
+ cowboy_test:stop_group(Name).
init_dispatch(_) ->
cowboy_router:compile([{"[...]", [
diff --git a/test/rfc8441_SUITE.erl b/test/rfc8441_SUITE.erl
index 245658f..3e71667 100644
--- a/test/rfc8441_SUITE.erl
+++ b/test/rfc8441_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2018, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -126,6 +126,7 @@ reject_handshake_disabled_by_default(Config0) ->
% The Extended CONNECT Method.
+%% @todo Refer to RFC9110 7.8 about the case insensitive comparison.
accept_uppercase_pseudo_header_protocol(Config) ->
doc("The :protocol pseudo header is case insensitive. (draft-01 4)"),
%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
@@ -172,6 +173,7 @@ reject_many_pseudo_header_protocol(Config) ->
ok.
reject_unknown_pseudo_header_protocol(Config) ->
+ %% @todo This probably shouldn't send 400 but 501 instead based on RFC 9220.
doc("An extended CONNECT request with an unknown protocol must be rejected "
"with a 400 error. (draft-01 4)"),
%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
@@ -192,10 +194,11 @@ reject_unknown_pseudo_header_protocol(Config) ->
{ok, << Len1:24, 1:8, _:8, 1:32 >>} = gen_tcp:recv(Socket, 9, 1000),
{ok, RespHeadersBlock} = gen_tcp:recv(Socket, Len1, 1000),
{RespHeaders, _} = cow_hpack:decode(RespHeadersBlock),
- {_, <<"400">>} = lists:keyfind(<<":status">>, 1, RespHeaders),
+ {_, <<"501">>} = lists:keyfind(<<":status">>, 1, RespHeaders),
ok.
reject_invalid_pseudo_header_protocol(Config) ->
+ %% @todo This probably shouldn't send 400 but 501 instead based on RFC 9220.
doc("An extended CONNECT request with an invalid protocol must be rejected "
"with a 400 error. (draft-01 4)"),
%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
@@ -216,7 +219,7 @@ reject_invalid_pseudo_header_protocol(Config) ->
{ok, << Len1:24, 1:8, _:8, 1:32 >>} = gen_tcp:recv(Socket, 9, 1000),
{ok, RespHeadersBlock} = gen_tcp:recv(Socket, Len1, 1000),
{RespHeaders, _} = cow_hpack:decode(RespHeadersBlock),
- {_, <<"400">>} = lists:keyfind(<<":status">>, 1, RespHeaders),
+ {_, <<"501">>} = lists:keyfind(<<":status">>, 1, RespHeaders),
ok.
reject_missing_pseudo_header_scheme(Config) ->
@@ -293,7 +296,7 @@ reject_missing_pseudo_header_protocol(Config) ->
%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
{ok, Socket, Settings} = do_handshake(Config),
#{enable_connect_protocol := true} = Settings,
- %% Send an extended CONNECT request without a :scheme pseudo-header.
+ %% Send an extended CONNECT request without a :protocol pseudo-header.
{ReqHeadersBlock, _} = cow_hpack:encode([
{<<":method">>, <<"CONNECT">>},
{<<":scheme">>, <<"http">>},
@@ -317,7 +320,7 @@ reject_connection_header(Config) ->
%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
{ok, Socket, Settings} = do_handshake(Config),
#{enable_connect_protocol := true} = Settings,
- %% Send an extended CONNECT request without a :scheme pseudo-header.
+ %% Send an extended CONNECT request with a connection header.
{ReqHeadersBlock, _} = cow_hpack:encode([
{<<":method">>, <<"CONNECT">>},
{<<":protocol">>, <<"websocket">>},
@@ -339,7 +342,7 @@ reject_upgrade_header(Config) ->
%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
{ok, Socket, Settings} = do_handshake(Config),
#{enable_connect_protocol := true} = Settings,
- %% Send an extended CONNECT request without a :scheme pseudo-header.
+ %% Send an extended CONNECT request with a upgrade header.
{ReqHeadersBlock, _} = cow_hpack:encode([
{<<":method">>, <<"CONNECT">>},
{<<":protocol">>, <<"websocket">>},
diff --git a/test/rfc9114_SUITE.erl b/test/rfc9114_SUITE.erl
new file mode 100644
index 0000000..4a36ee1
--- /dev/null
+++ b/test/rfc9114_SUITE.erl
@@ -0,0 +1,2426 @@
+%% Copyright (c) 2023-2024, Loïc Hoguin <[email protected]>
+%%
+%% Permission to use, copy, modify, and/or distribute this software for any
+%% purpose with or without fee is hereby granted, provided that the above
+%% copyright notice and this permission notice appear in all copies.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+-module(rfc9114_SUITE).
+-compile(export_all).
+-compile(nowarn_export_all).
+
+-import(ct_helper, [config/2]).
+-import(ct_helper, [doc/1]).
+
+-ifdef(COWBOY_QUICER).
+
+-include_lib("quicer/include/quicer.hrl").
+
+all() ->
+ [{group, h3}].
+
+groups() ->
+ %% @todo Enable parallel tests but for this issues in the
+ %% QUIC accept loop need to be figured out (can't connect
+ %% concurrently somehow, no backlog?).
+ [{h3, [], ct_helper:all(?MODULE)}].
+
+init_per_group(Name = h3, Config) ->
+ cowboy_test:init_http3(Name, #{
+ env => #{dispatch => cowboy_router:compile(init_routes(Config))}
+ }, Config).
+
+end_per_group(Name, _) ->
+ cowboy_test:stop_group(Name).
+
+init_routes(_) -> [
+ {"localhost", [
+ {"/", hello_h, []},
+ {"/echo/:key", echo_h, []}
+ ]}
+].
+
+%% Starting HTTP/3 for "https" URIs.
+
+alpn(Config) ->
+ doc("Successful ALPN negotiation. (RFC9114 3.1)"),
+ {ok, Conn} = quicer:connect("localhost", config(port, Config),
+ #{alpn => ["h3"], verify => none}, 5000),
+ {ok, <<"h3">>} = quicer:negotiated_protocol(Conn),
+ %% To make sure the connection is fully established we wait
+ %% to receive the SETTINGS frame on the control stream.
+ {ok, _ControlRef, _Settings} = do_wait_settings(Conn),
+ ok.
+
+alpn_error(Config) ->
+ doc("Failed ALPN negotiation using the 'h2' token. (RFC9114 3.1)"),
+ {error, transport_down, #{status := alpn_neg_failure}}
+ = quicer:connect("localhost", config(port, Config),
+ #{alpn => ["h2"], verify => none}, 5000),
+ ok.
+
+%% @todo 3.2. Connection Establishment
+%% After the QUIC connection is established, a SETTINGS frame MUST be sent by each endpoint as the initial frame of their respective HTTP control stream.
+
+%% @todo 3.3. Connection Reuse
+%% Servers are encouraged to maintain open HTTP/3 connections for as long as
+%possible but are permitted to terminate idle connections if necessary. When
+%either endpoint chooses to close the HTTP/3 connection, the terminating
+%endpoint SHOULD first send a GOAWAY frame (Section 5.2) so that both endpoints
+%can reliably determine whether previously sent frames have been processed and
+%gracefully complete or terminate any necessary remaining tasks.
+
+%% Frame format.
+
+req_stream(Config) ->
+ doc("Complete lifecycle of a request stream. (RFC9114 4.1)"),
+ {ok, Conn} = quicer:connect("localhost", config(port, Config),
+ #{alpn => ["h3"], verify => none}, 5000),
+ %% To make sure the connection is fully established we wait
+ %% to receive the SETTINGS frame on the control stream.
+ {ok, ControlRef, _Settings} = do_wait_settings(Conn),
+ %% Send a request on a request stream.
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"0">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedRequest)),
+ EncodedRequest
+ ], ?QUIC_SEND_FLAG_FIN),
+ %% Receive the response.
+ {ok, Data} = do_receive_data(StreamRef),
+ {HLenEnc, HLenBits} = do_guess_int_encoding(Data),
+ <<
+ 1, %% HEADERS frame.
+ HLenEnc:2, HLen:HLenBits,
+ EncodedResponse:HLen/bytes,
+ Rest/bits
+ >> = Data,
+ {ok, DecodedResponse, _DecData, _DecSt}
+ = cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init(decoder)),
+ #{
+ <<":status">> := <<"200">>,
+ <<"content-length">> := BodyLen
+ } = maps:from_list(DecodedResponse),
+ {DLenEnc, DLenBits} = do_guess_int_encoding(Rest),
+ <<
+ 0, %% DATA frame.
+ DLenEnc:2, DLen:DLenBits,
+ Body:DLen/bytes
+ >> = Rest,
+ <<"Hello world!">> = Body,
+ BodyLen = integer_to_binary(byte_size(Body)),
+ ok = do_wait_peer_send_shutdown(StreamRef),
+ ok = do_wait_stream_closed(StreamRef).
+
+%% @todo Same test as above but with content-length unset?
+
+req_stream_two_requests(Config) ->
+ doc("Receipt of multiple requests on a single stream must "
+ "be rejected with an H3_MESSAGE_ERROR stream error. "
+ "(RFC9114 4.1, RFC9114 4.1.2)"),
+ {ok, Conn} = quicer:connect("localhost", config(port, Config),
+ #{alpn => ["h3"], verify => none}, 5000),
+ %% To make sure the connection is fully established we wait
+ %% to receive the SETTINGS frame on the control stream.
+ {ok, ControlRef, _Settings} = do_wait_settings(Conn),
+ %% Send two requests on a request stream.
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedRequest1, _EncData1, EncSt0} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"0">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, EncodedRequest2, _EncData2, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"0">>}
+ ], 0, EncSt0),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedRequest1)),
+ EncodedRequest1,
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedRequest2)),
+ EncodedRequest2
+ ]),
+ %% The stream should have been aborted.
+ #{reason := h3_message_error} = do_wait_stream_aborted(StreamRef),
+ ok.
+
+headers_then_trailers(Config) ->
+ doc("Receipt of HEADERS followed by trailer HEADERS must be accepted. (RFC9114 4.1)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"0">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([
+ {<<"content-type">>, <<"text/plain">>}
+ ], 0, EncSt0),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders,
+ <<1>>, %% HEADERS frame for trailers.
+ cow_http3:encode_int(iolist_size(EncodedTrailers)),
+ EncodedTrailers
+ ], ?QUIC_SEND_FLAG_FIN),
+ #{
+ headers := #{<<":status">> := <<"200">>},
+ body := <<"Hello world!">>
+ } = do_receive_response(StreamRef),
+ ok.
+
+headers_then_data_then_trailers(Config) ->
+ doc("Receipt of HEADERS followed by DATA followed by trailer HEADERS "
+ "must be accepted. (RFC9114 4.1)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"13">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([
+ {<<"content-type">>, <<"text/plain">>}
+ ], 0, EncSt0),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders,
+ <<0>>, %% DATA frame.
+ cow_http3:encode_int(13),
+ <<"Hello server!">>,
+ <<1>>, %% HEADERS frame for trailers.
+ cow_http3:encode_int(iolist_size(EncodedTrailers)),
+ EncodedTrailers
+ ], ?QUIC_SEND_FLAG_FIN),
+ #{
+ headers := #{<<":status">> := <<"200">>},
+ body := <<"Hello world!">>
+ } = do_receive_response(StreamRef),
+ ok.
+
+data_then_headers(Config) ->
+ doc("Receipt of DATA before HEADERS must be rejected "
+ "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 4.1)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData1, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"13">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<0>>, %% DATA frame.
+ cow_http3:encode_int(13),
+ <<"Hello server!">>,
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders
+ ], ?QUIC_SEND_FLAG_FIN),
+ %% The connection should have been closed.
+ #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn),
+ ok.
+
+headers_then_trailers_then_data(Config) ->
+ doc("Receipt of DATA after trailer HEADERS must be rejected "
+ "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 4.1)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([
+ {<<"content-type">>, <<"text/plain">>}
+ ], 0, EncSt0),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders,
+ <<1>>, %% HEADERS frame for trailers.
+ cow_http3:encode_int(iolist_size(EncodedTrailers)),
+ EncodedTrailers,
+ <<0>>, %% DATA frame.
+ cow_http3:encode_int(13),
+ <<"Hello server!">>
+ ], ?QUIC_SEND_FLAG_FIN),
+ %% The connection should have been closed.
+ #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn),
+ ok.
+
+headers_then_data_then_trailers_then_data(Config) ->
+ doc("Receipt of DATA after trailer HEADERS must be rejected "
+ "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 4.1)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"13">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([
+ {<<"content-type">>, <<"text/plain">>}
+ ], 0, EncSt0),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders,
+ <<0>>, %% DATA frame.
+ cow_http3:encode_int(13),
+ <<"Hello server!">>,
+ <<1>>, %% HEADERS frame for trailers.
+ cow_http3:encode_int(iolist_size(EncodedTrailers)),
+ EncodedTrailers,
+ <<0>>, %% DATA frame.
+ cow_http3:encode_int(13),
+ <<"Hello server!">>
+ ], ?QUIC_SEND_FLAG_FIN),
+ %% The connection should have been closed.
+ #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn),
+ ok.
+
+headers_then_data_then_trailers_then_trailers(Config) ->
+ doc("Receipt of DATA after trailer HEADERS must be rejected "
+ "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 4.1)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"13">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, EncodedTrailers1, _EncData2, EncSt1} = cow_qpack:encode_field_section([
+ {<<"content-type">>, <<"text/plain">>}
+ ], 0, EncSt0),
+ {ok, EncodedTrailers2, _EncData3, _EncSt} = cow_qpack:encode_field_section([
+ {<<"content-type">>, <<"text/plain">>}
+ ], 0, EncSt1),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders,
+ <<0>>, %% DATA frame.
+ cow_http3:encode_int(13),
+ <<"Hello server!">>,
+ <<1>>, %% HEADERS frame for trailers.
+ cow_http3:encode_int(iolist_size(EncodedTrailers1)),
+ EncodedTrailers1,
+ <<1>>, %% HEADERS frame for trailers.
+ cow_http3:encode_int(iolist_size(EncodedTrailers2)),
+ EncodedTrailers2
+ ], ?QUIC_SEND_FLAG_FIN),
+ %% The connection should have been closed.
+ #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn),
+ ok.
+
+unknown_then_headers(Config) ->
+ doc("Receipt of unknown frame followed by HEADERS "
+ "must be accepted. (RFC9114 4.1, RFC9114 9)"),
+ unknown_then_headers(Config, do_unknown_frame_type(),
+ rand:bytes(rand:uniform(4096))).
+
+unknown_then_headers(Config, Type, Bytes) ->
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"0">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ cow_http3:encode_int(Type), %% Unknown frame.
+ cow_http3:encode_int(iolist_size(Bytes)),
+ Bytes,
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders
+ ], ?QUIC_SEND_FLAG_FIN),
+ #{
+ headers := #{<<":status">> := <<"200">>},
+ body := <<"Hello world!">>
+ } = do_receive_response(StreamRef),
+ ok.
+
+headers_then_unknown(Config) ->
+ doc("Receipt of HEADERS followed by unknown frame "
+ "must be accepted. (RFC9114 4.1, RFC9114 9)"),
+ headers_then_unknown(Config, do_unknown_frame_type(),
+ rand:bytes(rand:uniform(4096))).
+
+headers_then_unknown(Config, Type, Bytes) ->
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"0">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders,
+ cow_http3:encode_int(Type), %% Unknown frame.
+ cow_http3:encode_int(iolist_size(Bytes)),
+ Bytes
+ ], ?QUIC_SEND_FLAG_FIN),
+ #{
+ headers := #{<<":status">> := <<"200">>},
+ body := <<"Hello world!">>
+ } = do_receive_response(StreamRef),
+ ok.
+
+headers_then_data_then_unknown(Config) ->
+ doc("Receipt of HEADERS followed by DATA followed by unknown frame "
+ "must be accepted. (RFC9114 4.1, RFC9114 9)"),
+ headers_then_data_then_unknown(Config, do_unknown_frame_type(),
+ rand:bytes(rand:uniform(4096))).
+
+headers_then_data_then_unknown(Config, Type, Bytes) ->
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"13">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders,
+ <<0>>, %% DATA frame.
+ cow_http3:encode_int(13),
+ <<"Hello server!">>,
+ cow_http3:encode_int(Type), %% Unknown frame.
+ cow_http3:encode_int(iolist_size(Bytes)),
+ Bytes
+ ], ?QUIC_SEND_FLAG_FIN),
+ #{
+ headers := #{<<":status">> := <<"200">>},
+ body := <<"Hello world!">>
+ } = do_receive_response(StreamRef),
+ ok.
+
+headers_then_trailers_then_unknown(Config) ->
+ doc("Receipt of HEADERS followed by trailer HEADERS followed by unknown frame "
+ "must be accepted. (RFC9114 4.1, RFC9114 9)"),
+ headers_then_data_then_unknown(Config, do_unknown_frame_type(),
+ rand:bytes(rand:uniform(4096))).
+
+headers_then_trailers_then_unknown(Config, Type, Bytes) ->
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData, EncSt0} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([
+ {<<"content-type">>, <<"text/plain">>}
+ ], 0, EncSt0),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders,
+ <<1>>, %% HEADERS frame for trailers.
+ cow_http3:encode_int(iolist_size(EncodedTrailers)),
+ EncodedTrailers,
+ cow_http3:encode_int(Type), %% Unknown frame.
+ cow_http3:encode_int(iolist_size(Bytes)),
+ Bytes
+ ], ?QUIC_SEND_FLAG_FIN),
+ #{
+ headers := #{<<":status">> := <<"200">>},
+ body := <<"Hello world!">>
+ } = do_receive_response(StreamRef),
+ ok.
+
+headers_then_data_then_unknown_then_trailers(Config) ->
+ doc("Receipt of HEADERS followed by DATA followed by "
+ "unknown frame followed by trailer HEADERS "
+ "must be accepted. (RFC9114 4.1, RFC9114 9)"),
+ headers_then_data_then_unknown_then_trailers(Config,
+ do_unknown_frame_type(), rand:bytes(rand:uniform(4096))).
+
+headers_then_data_then_unknown_then_trailers(Config, Type, Bytes) ->
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData, EncSt0} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"13">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([
+ {<<"content-type">>, <<"text/plain">>}
+ ], 0, EncSt0),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders,
+ <<0>>, %% DATA frame.
+ cow_http3:encode_int(13),
+ <<"Hello server!">>,
+ cow_http3:encode_int(Type), %% Unknown frame.
+ cow_http3:encode_int(iolist_size(Bytes)),
+ Bytes,
+ <<1>>, %% HEADERS frame for trailers.
+ cow_http3:encode_int(iolist_size(EncodedTrailers)),
+ EncodedTrailers
+ ], ?QUIC_SEND_FLAG_FIN),
+ #{
+ headers := #{<<":status">> := <<"200">>},
+ body := <<"Hello world!">>
+ } = do_receive_response(StreamRef),
+ ok.
+
+headers_then_data_then_unknown_then_data(Config) ->
+ doc("Receipt of HEADERS followed by DATA followed by "
+ "unknown frame followed by DATA "
+ "must be accepted. (RFC9114 4.1, RFC9114 9)"),
+ headers_then_data_then_unknown_then_data(Config,
+ do_unknown_frame_type(), rand:bytes(rand:uniform(4096))).
+
+headers_then_data_then_unknown_then_data(Config, Type, Bytes) ->
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"13">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders,
+ <<0>>, %% DATA frame.
+ cow_http3:encode_int(6),
+ <<"Hello ">>,
+ cow_http3:encode_int(Type), %% Unknown frame.
+ cow_http3:encode_int(iolist_size(Bytes)),
+ Bytes,
+ <<0>>, %% DATA frame.
+ cow_http3:encode_int(7),
+ <<"server!">>
+ ], ?QUIC_SEND_FLAG_FIN),
+ #{
+ headers := #{<<":status">> := <<"200">>},
+ body := <<"Hello world!">>
+ } = do_receive_response(StreamRef),
+ ok.
+
+headers_then_data_then_trailers_then_unknown(Config) ->
+ doc("Receipt of HEADERS followed by DATA followed by "
+ "trailer HEADERS followed by unknown frame "
+ "must be accepted. (RFC9114 4.1, RFC9114 9)"),
+ headers_then_data_then_trailers_then_unknown(Config,
+ do_unknown_frame_type(), rand:bytes(rand:uniform(4096))).
+
+headers_then_data_then_trailers_then_unknown(Config, Type, Bytes) ->
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData, EncSt0} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"13">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([
+ {<<"content-type">>, <<"text/plain">>}
+ ], 0, EncSt0),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders,
+ <<0>>, %% DATA frame.
+ cow_http3:encode_int(13),
+ <<"Hello server!">>,
+ <<1>>, %% HEADERS frame for trailers.
+ cow_http3:encode_int(iolist_size(EncodedTrailers)),
+ EncodedTrailers,
+ cow_http3:encode_int(Type), %% Unknown frame.
+ cow_http3:encode_int(iolist_size(Bytes)),
+ Bytes
+ ], ?QUIC_SEND_FLAG_FIN),
+ #{
+ headers := #{<<":status">> := <<"200">>},
+ body := <<"Hello world!">>
+ } = do_receive_response(StreamRef),
+ ok.
+
+do_unknown_frame_type() ->
+ Type = rand:uniform(4611686018427387904) - 1,
+ %% Retry if we get a value that's specified.
+ case lists:member(Type, [
+ 16#0, 16#1, 16#3, 16#4, 16#5, 16#7, 16#d, %% HTTP/3 core frame types.
+ 16#2, 16#6, 16#8, 16#9 %% HTTP/3 reserved frame types that must be rejected.
+ ]) of
+ true -> do_unknown_frame_type();
+ false -> Type
+ end.
+
+reserved_then_headers(Config) ->
+ doc("Receipt of reserved frame followed by HEADERS "
+ "must be accepted when the reserved frame type is "
+ "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"),
+ unknown_then_headers(Config, do_reserved_type(),
+ rand:bytes(rand:uniform(4096))).
+
+headers_then_reserved(Config) ->
+ doc("Receipt of HEADERS followed by reserved frame "
+ "must be accepted when the reserved frame type is "
+ "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"),
+ headers_then_unknown(Config, do_reserved_type(),
+ rand:bytes(rand:uniform(4096))).
+
+headers_then_data_then_reserved(Config) ->
+ doc("Receipt of HEADERS followed by DATA followed by reserved frame "
+ "must be accepted when the reserved frame type is "
+ "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"),
+ headers_then_data_then_unknown(Config, do_reserved_type(),
+ rand:bytes(rand:uniform(4096))).
+
+headers_then_trailers_then_reserved(Config) ->
+ doc("Receipt of HEADERS followed by trailer HEADERS followed by reserved frame "
+ "must be accepted when the reserved frame type is "
+ "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"),
+ headers_then_trailers_then_unknown(Config, do_reserved_type(),
+ rand:bytes(rand:uniform(4096))).
+
+headers_then_data_then_reserved_then_trailers(Config) ->
+ doc("Receipt of HEADERS followed by DATA followed by "
+ "reserved frame followed by trailer HEADERS "
+ "must be accepted when the reserved frame type is "
+ "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"),
+ headers_then_data_then_unknown_then_trailers(Config,
+ do_reserved_type(), rand:bytes(rand:uniform(4096))).
+
+headers_then_data_then_reserved_then_data(Config) ->
+ doc("Receipt of HEADERS followed by DATA followed by "
+ "reserved frame followed by DATA "
+ "must be accepted when the reserved frame type is "
+ "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"),
+ headers_then_data_then_unknown_then_data(Config,
+ do_reserved_type(), rand:bytes(rand:uniform(4096))).
+
+headers_then_data_then_trailers_then_reserved(Config) ->
+ doc("Receipt of HEADERS followed by DATA followed by "
+ "trailer HEADERS followed by reserved frame "
+ "must be accepted when the reserved frame type is "
+ "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"),
+ headers_then_data_then_trailers_then_unknown(Config,
+ do_reserved_type(), rand:bytes(rand:uniform(4096))).
+
+reject_transfer_encoding_header_with_body(Config) ->
+ doc("Requests containing a transfer-encoding header must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.1, RFC9114 4.1.2, RFC9114 4.2)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData1, _EncSt0} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"transfer-encoding">>, <<"chunked">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders,
+ <<0>>, %% DATA frame.
+ cow_http3:encode_int(24),
+ <<"13\r\nHello server!\r\n0\r\n\r\n">>
+ ]),
+ %% The stream should have been aborted.
+ #{reason := h3_message_error} = do_wait_stream_aborted(StreamRef),
+ ok.
+
+%% 4. Expressing HTTP Semantics in HTTP/3
+%% 4.1. HTTP Message Framing
+
+%% An HTTP request/response exchange fully consumes a client-initiated
+%bidirectional QUIC stream. After sending a request, a client MUST close the
+%stream for sending. Unless using the CONNECT method (see Section 4.4), clients
+%MUST NOT make stream closure dependent on receiving a response to their
+%request. After sending a final response, the server MUST close the stream for
+%sending. At this point, the QUIC stream is fully closed.
+%% @todo What to do with clients that DON'T close the stream
+%% for sending after the request is sent?
+
+%% If a client-initiated stream terminates without enough of the HTTP message
+%to provide a complete response, the server SHOULD abort its response stream
+%with the error code H3_REQUEST_INCOMPLETE.
+%% @todo difficult!!
+
+%% When the server does not need to receive the remainder of the request, it
+%MAY abort reading the request stream, send a complete response, and cleanly
+%close the sending part of the stream. The error code H3_NO_ERROR SHOULD be
+%used when requesting that the client stop sending on the request stream.
+%% @todo read_body related; h2 has this behavior but there is no corresponding test
+
+%% 4.1.1. Request Cancellation and Rejection
+
+%% When possible, it is RECOMMENDED that servers send an HTTP response with an
+%appropriate status code rather than cancelling a request it has already begun
+%processing.
+
+%% Implementations SHOULD cancel requests by abruptly terminating any
+%directions of a stream that are still open. To do so, an implementation resets
+%the sending parts of streams and aborts reading on the receiving parts of
+%streams; see Section 2.4 of [QUIC-TRANSPORT].
+
+%% When the server cancels a request without performing any application
+%processing, the request is considered "rejected". The server SHOULD abort its
+%response stream with the error code H3_REQUEST_REJECTED. In this context,
+%"processed" means that some data from the stream was passed to some higher
+%layer of software that might have taken some action as a result. The client
+%can treat requests rejected by the server as though they had never been sent
+%at all, thereby allowing them to be retried later.
+
+%% Servers MUST NOT use the H3_REQUEST_REJECTED error code for requests that
+%were partially or fully processed. When a server abandons a response after
+%partial processing, it SHOULD abort its response stream with the error code
+%H3_REQUEST_CANCELLED.
+%% @todo
+
+%% Client SHOULD use the error code H3_REQUEST_CANCELLED to cancel requests.
+%Upon receipt of this error code, a server MAY abruptly terminate the response
+%using the error code H3_REQUEST_REJECTED if no processing was performed.
+%Clients MUST NOT use the H3_REQUEST_REJECTED error code, except when a server
+%has requested closure of the request stream with this error code.
+%% @todo
+
+%4.1.2. Malformed Requests and Responses
+%A malformed request or response is one that is an otherwise valid sequence of
+%frames but is invalid due to:
+%
+%the presence of prohibited fields or pseudo-header fields,
+%% @todo reject_response_pseudo_headers
+%% @todo reject_unknown_pseudo_headers
+%% @todo reject_pseudo_headers_in_trailers
+
+%the absence of mandatory pseudo-header fields,
+%invalid values for pseudo-header fields,
+%pseudo-header fields after fields,
+%% @todo reject_pseudo_headers_after_regular_headers
+
+%an invalid sequence of HTTP messages,
+%the inclusion of invalid characters in field names or values.
+%
+%A request or response that is defined as having content when it contains a
+%Content-Length header field (Section 8.6 of [HTTP]) is malformed if the value
+%of the Content-Length header field does not equal the sum of the DATA frame
+%lengths received. A response that is defined as never having content, even
+%when a Content-Length is present, can have a non-zero Content-Length header
+%field even though no content is included in DATA frames.
+%
+%For malformed requests, a server MAY send an HTTP response indicating the
+%error prior to closing or resetting the stream.
+%% @todo All the malformed tests
+
+headers_reject_uppercase_header_name(Config) ->
+ doc("Requests containing uppercase header names must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"),
+ do_reject_malformed_header(Config,
+ {<<"I-AM-GIGANTIC">>, <<"How's the weather up there?">>}
+ ).
+
+%% 4.2. HTTP Fields
+%% An endpoint MUST NOT generate an HTTP/3 field section containing
+%connection-specific fields; any message containing connection-specific fields
+%MUST be treated as malformed.
+
+reject_connection_header(Config) ->
+ doc("Requests containing a connection header must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"),
+ do_reject_malformed_header(Config,
+ {<<"connection">>, <<"close">>}
+ ).
+
+reject_keep_alive_header(Config) ->
+ doc("Requests containing a keep-alive header must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"),
+ do_reject_malformed_header(Config,
+ {<<"keep-alive">>, <<"timeout=5, max=1000">>}
+ ).
+
+reject_proxy_authenticate_header(Config) ->
+ doc("Requests containing a proxy-authenticate header must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"),
+ do_reject_malformed_header(Config,
+ {<<"proxy-authenticate">>, <<"Basic">>}
+ ).
+
+reject_proxy_authorization_header(Config) ->
+ doc("Requests containing a proxy-authorization header must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"),
+ do_reject_malformed_header(Config,
+ {<<"proxy-authorization">>, <<"Basic YWxhZGRpbjpvcGVuc2VzYW1l">>}
+ ).
+
+reject_transfer_encoding_header(Config) ->
+ doc("Requests containing a transfer-encoding header must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"),
+ do_reject_malformed_header(Config,
+ {<<"transfer-encoding">>, <<"chunked">>}
+ ).
+
+reject_upgrade_header(Config) ->
+ doc("Requests containing an upgrade header must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.5, RFC9114 4.1.2)"),
+ do_reject_malformed_header(Config,
+ {<<"upgrade">>, <<"websocket">>}
+ ).
+
+accept_te_header_value_trailers(Config) ->
+ doc("Requests containing a TE header with a value of \"trailers\" "
+ "must be accepted. (RFC9114 4.2)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"0">>},
+ {<<"te">>, <<"trailers">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([
+ {<<"content-type">>, <<"text/plain">>}
+ ], 0, EncSt0),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders,
+ <<1>>, %% HEADERS frame for trailers.
+ cow_http3:encode_int(iolist_size(EncodedTrailers)),
+ EncodedTrailers
+ ], ?QUIC_SEND_FLAG_FIN),
+ #{
+ headers := #{<<":status">> := <<"200">>},
+ body := <<"Hello world!">>
+ } = do_receive_response(StreamRef),
+ ok.
+
+reject_te_header_other_values(Config) ->
+ doc("Requests containing a TE header with a value other than \"trailers\" must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"),
+ do_reject_malformed_header(Config,
+ {<<"te">>, <<"trailers, deflate;q=0.5">>}
+ ).
+
+%% @todo response_dont_send_header_in_connection
+%% @todo response_dont_send_connection_header
+%% @todo response_dont_send_keep_alive_header
+%% @todo response_dont_send_proxy_connection_header
+%% @todo response_dont_send_transfer_encoding_header
+%% @todo response_dont_send_upgrade_header
+
+%% 4.2.1. Field Compression
+%% To allow for better compression efficiency, the Cookie header field
+%([COOKIES]) MAY be split into separate field lines, each with one or more
+%cookie-pairs, before compression. If a decompressed field section contains
+%multiple cookie field lines, these MUST be concatenated into a single byte
+%string using the two-byte delimiter of "; " (ASCII 0x3b, 0x20) before being
+%passed into a context other than HTTP/2 or HTTP/3, such as an HTTP/1.1
+%connection, or a generic HTTP server application.
+
+%% 4.2.2. Header Size Constraints
+%% An HTTP/3 implementation MAY impose a limit on the maximum size of the
+%message header it will accept on an individual HTTP message. A server that
+%receives a larger header section than it is willing to handle can send an HTTP
+%431 (Request Header Fields Too Large) status code ([RFC6585]). The size of a
+%field list is calculated based on the uncompressed size of fields, including
+%the length of the name and value in bytes plus an overhead of 32 bytes for
+%each field.
+%% If an implementation wishes to advise its peer of this limit, it can be
+%conveyed as a number of bytes in the SETTINGS_MAX_FIELD_SECTION_SIZE
+%parameter.
+
+reject_unknown_pseudo_headers(Config) ->
+ doc("Requests containing unknown pseudo-headers must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3, RFC9114 4.1.2)"),
+ do_reject_malformed_header(Config,
+ {<<":upgrade">>, <<"websocket">>}
+ ).
+
+reject_response_pseudo_headers(Config) ->
+ doc("Requests containing response pseudo-headers must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3, RFC9114 4.1.2)"),
+ do_reject_malformed_header(Config,
+ {<<":status">>, <<"200">>}
+ ).
+
+reject_pseudo_headers_in_trailers(Config) ->
+ doc("Requests containing pseudo-headers in trailers must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3, RFC9114 4.1.2)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"trailer">>, <<"x-checksum">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([
+ {<<"x-checksum">>, <<"md5:4cc909a007407f3706399b6496babec3">>},
+ {<<":path">>, <<"/">>}
+ ], 0, EncSt0),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders,
+ <<0>>, %% DATA frame.
+ cow_http3:encode_int(10000),
+ <<0:10000/unit:8>>,
+ <<1>>, %% HEADERS frame for trailers.
+ cow_http3:encode_int(iolist_size(EncodedTrailers)),
+ EncodedTrailers
+ ]),
+ %% The stream should have been aborted.
+ #{reason := h3_message_error} = do_wait_stream_aborted(StreamRef),
+ ok.
+
+reject_pseudo_headers_after_regular_headers(Config) ->
+ doc("Requests containing pseudo-headers after regular headers must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3, RFC9114 4.1.2)"),
+ do_reject_malformed_headers(Config, [
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<"content-length">>, <<"0">>},
+ {<<":path">>, <<"/">>}
+ ]).
+
+reject_userinfo(Config) ->
+ doc("An authority containing a userinfo component must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"),
+ do_reject_malformed_headers(Config, [
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"http">>},
+ {<<":authority">>, <<"user@localhost">>},
+ {<<":path">>, <<"/">>}
+ ]).
+
+%% To ensure that the HTTP/1.1 request line can be reproduced accurately, this
+%% pseudo-header field (:authority) MUST be omitted when translating from an
+%% HTTP/1.1 request that has a request target in a method-specific form;
+%% see Section 7.1 of [HTTP].
+
+reject_empty_path(Config) ->
+ doc("A request containing an empty path component must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"),
+ do_reject_malformed_headers(Config, [
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"http">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<>>}
+ ]).
+
+reject_missing_pseudo_header_method(Config) ->
+ doc("A request without a method component must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"),
+ do_reject_malformed_headers(Config, [
+ {<<":scheme">>, <<"http">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>}
+ ]).
+
+reject_many_pseudo_header_method(Config) ->
+ doc("A request containing more than one method component must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"),
+ do_reject_malformed_headers(Config, [
+ {<<":method">>, <<"GET">>},
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"http">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>}
+ ]).
+
+reject_missing_pseudo_header_scheme(Config) ->
+ doc("A request without a scheme component must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"),
+ do_reject_malformed_headers(Config, [
+ {<<":method">>, <<"GET">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>}
+ ]).
+
+reject_many_pseudo_header_scheme(Config) ->
+ doc("A request containing more than one scheme component must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"),
+ do_reject_malformed_headers(Config, [
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"http">>},
+ {<<":scheme">>, <<"http">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>}
+ ]).
+
+reject_missing_pseudo_header_authority(Config) ->
+ doc("A request without an authority or host component must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"),
+ do_reject_malformed_headers(Config, [
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"http">>},
+ {<<":path">>, <<"/">>}
+ ]).
+
+accept_host_header_on_missing_pseudo_header_authority(Config) ->
+ doc("A request without an authority but with a host header must be accepted. "
+ "(RFC9114 4.3.1)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData1, _EncSt0} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":path">>, <<"/">>},
+ {<<"host">>, <<"localhost">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders
+ ], ?QUIC_SEND_FLAG_FIN),
+ #{
+ headers := #{<<":status">> := <<"200">>},
+ body := <<"Hello world!">>
+ } = do_receive_response(StreamRef),
+ ok.
+
+%% @todo
+%% If the :scheme pseudo-header field identifies a scheme that has a mandatory
+%% authority component (including "http" and "https"), the request MUST contain
+%% either an :authority pseudo-header field or a Host header field.
+%% - If both fields are present, they MUST NOT be empty.
+%% - If both fields are present, they MUST contain the same value.
+
+reject_many_pseudo_header_authority(Config) ->
+ doc("A request containing more than one authority component must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"),
+ do_reject_malformed_headers(Config, [
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"http">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>}
+ ]).
+
+reject_missing_pseudo_header_path(Config) ->
+ doc("A request without a path component must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"),
+ do_reject_malformed_headers(Config, [
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"http">>},
+ {<<":authority">>, <<"localhost">>}
+ ]).
+
+reject_many_pseudo_header_path(Config) ->
+ doc("A request containing more than one path component must be rejected "
+ "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"),
+ do_reject_malformed_headers(Config, [
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"http">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<":path">>, <<"/">>}
+ ]).
+
+do_reject_malformed_header(Config, Header) ->
+ do_reject_malformed_headers(Config, [
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ Header
+ ]).
+
+do_reject_malformed_headers(Config, Headers) ->
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData1, _EncSt0}
+ = cow_qpack:encode_field_section(Headers, 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders
+ ]),
+ %% The stream should have been aborted.
+ #{reason := h3_message_error} = do_wait_stream_aborted(StreamRef),
+ ok.
+
+%% For responses, a single ":status" pseudo-header field is defined that
+%% carries the HTTP status code; see Section 15 of [HTTP]. This pseudo-header
+%% field MUST be included in all responses; otherwise, the response is malformed
+%% (see Section 4.1.2).
+
+%% @todo Implement CONNECT. (RFC9114 4.4. The CONNECT Method)
+
+%% @todo Maybe block the sending of 101 responses? (RFC9114 4.5. HTTP Upgrade) - also HTTP/2.
+
+%% @todo Implement server push (RFC9114 4.6. Server Push)
+
+%% @todo - need a way to list connections
+%% 5.2. Connection Shutdown
+%% Endpoints initiate the graceful shutdown of an HTTP/3 connection by sending
+%% a GOAWAY frame. The GOAWAY frame contains an identifier that indicates to the
+%% receiver the range of requests or pushes that were or might be processed in
+%% this connection. The server sends a client-initiated bidirectional stream ID;
+%% the client sends a push ID. Requests or pushes with the indicated identifier
+%% or greater are rejected (Section 4.1.1) by the sender of the GOAWAY. This
+%% identifier MAY be zero if no requests or pushes were processed.
+
+%% @todo
+%% Upon sending a GOAWAY frame, the endpoint SHOULD explicitly cancel (see
+%% Sections 4.1.1 and 7.2.3) any requests or pushes that have identifiers greater
+%% than or equal to the one indicated, in order to clean up transport state for
+%% the affected streams. The endpoint SHOULD continue to do so as more requests
+%% or pushes arrive.
+
+%% @todo
+%% Endpoints MUST NOT initiate new requests or promise new pushes on the
+%% connection after receipt of a GOAWAY frame from the peer.
+
+%% @todo
+%% Requests on stream IDs less than the stream ID in a GOAWAY frame from the
+%% server might have been processed; their status cannot be known until a
+%% response is received, the stream is reset individually, another GOAWAY is
+%% received with a lower stream ID than that of the request in question, or the
+%% connection terminates.
+
+%% @todo
+%% Servers MAY reject individual requests on streams below the indicated ID if
+%% these requests were not processed.
+
+%% @todo
+%% If a server receives a GOAWAY frame after having promised pushes with a push
+%% ID greater than or equal to the identifier contained in the GOAWAY frame,
+%% those pushes will not be accepted.
+
+%% @todo
+%% Servers SHOULD send a GOAWAY frame when the closing of a connection is known
+%% in advance, even if the advance notice is small, so that the remote peer can
+%% know whether or not a request has been partially processed.
+
+%% @todo
+%% An endpoint MAY send multiple GOAWAY frames indicating different
+%% identifiers, but the identifier in each frame MUST NOT be greater than the
+%% identifier in any previous frame, since clients might already have retried
+%% unprocessed requests on another HTTP connection. Receiving a GOAWAY containing
+%% a larger identifier than previously received MUST be treated as a connection
+%% error of type H3_ID_ERROR.
+
+%% @todo
+%% An endpoint that is attempting to gracefully shut down a connection can send
+%% a GOAWAY frame with a value set to the maximum possible value (2^62-4 for
+%% servers, 2^62-1 for clients).
+
+%% @todo
+%% Even when a GOAWAY indicates that a given request or push will not be
+%% processed or accepted upon receipt, the underlying transport resources still
+%% exist. The endpoint that initiated these requests can cancel them to clean up
+%% transport state.
+
+%% @todo
+%% Once all accepted requests and pushes have been processed, the endpoint can
+%% permit the connection to become idle, or it MAY initiate an immediate closure
+%% of the connection. An endpoint that completes a graceful shutdown SHOULD use
+%% the H3_NO_ERROR error code when closing the connection.
+
+%% @todo
+%% If a client has consumed all available bidirectional stream IDs with
+%% requests, the server need not send a GOAWAY frame, since the client is unable
+%% to make further requests. @todo OK that one's some weird stuff lol
+
+%% @todo
+%% 5.3. Immediate Application Closure
+%% Before closing the connection, a GOAWAY frame MAY be sent to allow the
+%% client to retry some requests. Including the GOAWAY frame in the same packet
+%% as the QUIC CONNECTION_CLOSE frame improves the chances of the frame being
+%% received by clients.
+
+bidi_allow_at_least_a_hundred(Config) ->
+ doc("Endpoints must allow the peer to create at least "
+ "one hundred bidirectional streams. (RFC9114 6.1"),
+ #{conn := Conn} = do_connect(Config),
+ receive
+ {quic, streams_available, Conn, #{bidi_streams := NumStreams}} ->
+ true = NumStreams >= 100,
+ ok
+ after 5000 ->
+ error(timeout)
+ end.
+
+unidi_allow_at_least_three(Config) ->
+ doc("Endpoints must allow the peer to create at least "
+ "three unidirectional streams. (RFC9114 6.2"),
+ #{conn := Conn} = do_connect(Config),
+ %% Confirm that the server advertised support for at least 3 unidi streams.
+ receive
+ {quic, streams_available, Conn, #{unidi_streams := NumStreams}} ->
+ true = NumStreams >= 3,
+ ok
+ after 5000 ->
+ error(timeout)
+ end,
+ %% Confirm that we can create the unidi streams.
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, _} = quicer:send(ControlRef, [<<0>>, SettingsBin]),
+ {ok, EncoderRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, _} = quicer:send(EncoderRef, <<2>>),
+ {ok, DecoderRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, _} = quicer:send(DecoderRef, <<3>>),
+ %% Streams shouldn't get closed.
+ fun Loop() ->
+ receive
+ %% We don't care about these messages.
+ {quic, dgram_state_changed, Conn, _} ->
+ Loop();
+ {quic, peer_needs_streams, Conn, _} ->
+ Loop();
+ %% Any other we do care.
+ Msg ->
+ error(Msg)
+ after 1000 ->
+ ok
+ end
+ end().
+
+unidi_create_critical_first(Config) ->
+ doc("Endpoints should create the HTTP control stream as well as "
+ "the QPACK encoder and decoder streams first. (RFC9114 6.2"),
+ %% The control stream is accepted in the do_connect/1 function.
+ #{conn := Conn} = do_connect(Config, #{peer_unidi_stream_count => 3}),
+ Unidi1 = do_accept_qpack_stream(Conn),
+ Unidi2 = do_accept_qpack_stream(Conn),
+ case {Unidi1, Unidi2} of
+ {{encoder, _}, {decoder, _}} ->
+ ok;
+ {{decoder, _}, {encoder, _}} ->
+ ok
+ end.
+
+do_accept_qpack_stream(Conn) ->
+ receive
+ {quic, new_stream, StreamRef, #{flags := Flags}} ->
+ ok = quicer:setopt(StreamRef, active, true),
+ true = quicer:is_unidirectional(Flags),
+ receive {quic, <<Type>>, StreamRef, _} ->
+ {case Type of
+ 2 -> encoder;
+ 3 -> decoder
+ end, StreamRef}
+ after 5000 ->
+ error(timeout)
+ end
+ after 5000 ->
+ error(timeout)
+ end.
+
+%% @todo We should also confirm that there's at least 1,024 bytes of
+%% flow-control credit for each unidi stream the server creates. (How?)
+%% It can be set via stream_recv_window_default in quicer.
+
+unidi_abort_unknown_type(Config) ->
+ doc("Receipt of an unknown stream type must be aborted "
+ "with an H3_STREAM_CREATION_ERROR stream error. (RFC9114 6.2, RFC9114 9)"),
+ #{conn := Conn} = do_connect(Config),
+ %% Create an unknown unidirectional stream.
+ {ok, StreamRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, _} = quicer:send(StreamRef, [
+ cow_http3:encode_int(1 + do_reserved_type()),
+ rand:bytes(rand:uniform(4096))
+ ]),
+ %% The stream should have been aborted.
+ #{reason := h3_stream_creation_error} = do_wait_stream_aborted(StreamRef),
+ ok.
+
+unidi_abort_reserved_type(Config) ->
+ doc("Receipt of a reserved stream type must be aborted "
+ "with an H3_STREAM_CREATION_ERROR stream error. "
+ "(RFC9114 6.2, RFC9114 6.2.3, RFC9114 9)"),
+ #{conn := Conn} = do_connect(Config),
+ %% Create a reserved unidirectional stream.
+ {ok, StreamRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, _} = quicer:send(StreamRef, [
+ cow_http3:encode_int(do_reserved_type()),
+ rand:bytes(rand:uniform(4096))
+ ]),
+ %% The stream should have been aborted.
+ #{reason := h3_stream_creation_error} = do_wait_stream_aborted(StreamRef),
+ ok.
+
+%% As certain stream types can affect connection state, a recipient SHOULD NOT
+%% discard data from incoming unidirectional streams prior to reading the stream type.
+
+%% Implementations MAY send stream types before knowing whether the peer
+%supports them. However, stream types that could modify the state or semantics
+%of existing protocol components, including QPACK or other extensions, MUST NOT
+%be sent until the peer is known to support them.
+%% @todo It may make sense for Cowboy to delay the creation of unidi streams
+%% a little in order to save resources. We could create them when the
+%% client does as well, or something similar.
+
+%% A receiver MUST tolerate unidirectional streams being closed or reset prior
+%% to the reception of the unidirectional stream header.
+
+%% Each side MUST initiate a single control stream at the beginning of the
+%% connection and send its SETTINGS frame as the first frame on this stream.
+%% @todo What to do when the client never opens a control stream?
+%% @todo Similarly, a stream could be opened but with no data being sent.
+%% @todo Similarly, a control stream could be opened with no SETTINGS frame sent.
+
+control_reject_first_frame_data(Config) ->
+ doc("The first frame on a control stream must be a SETTINGS frame "
+ "or the connection must be closed with an H3_MISSING_SETTINGS "
+ "connection error. (RFC9114 6.2.1, RFC9114 9)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ <<0>>, %% DATA frame.
+ cow_http3:encode_int(12),
+ <<"Hello world!">>
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_missing_settings} = do_wait_connection_closed(Conn),
+ ok.
+
+control_reject_first_frame_headers(Config) ->
+ doc("The first frame on a control stream must be a SETTINGS frame "
+ "or the connection must be closed with an H3_MISSING_SETTINGS "
+ "connection error. (RFC9114 6.2.1, RFC9114 9)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"0">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_missing_settings} = do_wait_connection_closed(Conn),
+ ok.
+
+control_reject_first_frame_cancel_push(Config) ->
+ doc("The first frame on a control stream must be a SETTINGS frame "
+ "or the connection must be closed with an H3_MISSING_SETTINGS "
+ "connection error. (RFC9114 6.2.1, RFC9114 9)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ <<3>>, %% CANCEL_PUSH frame.
+ cow_http3:encode_int(1),
+ cow_http3:encode_int(0)
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_missing_settings} = do_wait_connection_closed(Conn),
+ ok.
+
+control_accept_first_frame_settings(Config) ->
+ doc("The first frame on a control stream "
+ "must be a SETTINGS frame. (RFC9114 6.2.1, RFC9114 9)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ SettingsBin
+ ]),
+ %% The connection should remain up.
+ receive
+ {quic, shutdown, Conn, {unknown_quic_status, Code}} ->
+ Reason = cow_http3:code_to_error(Code),
+ error(Reason)
+ after 1000 ->
+ ok
+ end.
+
+control_reject_first_frame_push_promise(Config) ->
+ doc("The first frame on a control stream must be a SETTINGS frame "
+ "or the connection must be closed with an H3_MISSING_SETTINGS "
+ "connection error. (RFC9114 6.2.1, RFC9114 9)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"0">>}
+ ], 0, cow_qpack:init(encoder)),
+
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ <<5>>, %% PUSH_PROMISE frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders) + 1),
+ cow_http3:encode_int(0),
+ EncodedHeaders
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_missing_settings} = do_wait_connection_closed(Conn),
+ ok.
+
+control_reject_first_frame_goaway(Config) ->
+ doc("The first frame on a control stream must be a SETTINGS frame "
+ "or the connection must be closed with an H3_MISSING_SETTINGS "
+ "connection error. (RFC9114 6.2.1, RFC9114 9)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ <<7>>, %% GOAWAY frame.
+ cow_http3:encode_int(1),
+ cow_http3:encode_int(0)
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_missing_settings} = do_wait_connection_closed(Conn),
+ ok.
+
+control_reject_first_frame_max_push_id(Config) ->
+ doc("The first frame on a control stream must be a SETTINGS frame "
+ "or the connection must be closed with an H3_MISSING_SETTINGS "
+ "connection error. (RFC9114 6.2.1, RFC9114 9)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ <<13>>, %% MAX_PUSH_ID frame.
+ cow_http3:encode_int(1),
+ cow_http3:encode_int(0)
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_missing_settings} = do_wait_connection_closed(Conn),
+ ok.
+
+control_reject_first_frame_reserved(Config) ->
+ doc("The first frame on a control stream must be a SETTINGS frame "
+ "or the connection must be closed with an H3_MISSING_SETTINGS "
+ "connection error. (RFC9114 6.2.1, RFC9114 9)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ Len = rand:uniform(512),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ cow_http3:encode_int(do_reserved_type()),
+ cow_http3:encode_int(Len),
+ rand:bytes(Len)
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_missing_settings} = do_wait_connection_closed(Conn),
+ ok.
+
+control_reject_multiple(Config) ->
+ doc("Endpoints must not create multiple control streams. (RFC9114 6.2.1)"),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ do_critical_reject_multiple(Config, [<<0>>, SettingsBin]).
+
+do_critical_reject_multiple(Config, HeaderData) ->
+ #{conn := Conn} = do_connect(Config),
+ %% Create two critical streams.
+ {ok, StreamRef1} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, _} = quicer:send(StreamRef1, HeaderData),
+ {ok, StreamRef2} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, _} = quicer:send(StreamRef2, HeaderData),
+ %% The connection should have been closed.
+ #{reason := h3_stream_creation_error} = do_wait_connection_closed(Conn),
+ ok.
+
+control_local_closed_abort(Config) ->
+ doc("Endpoints must not close the control stream. (RFC9114 6.2.1)"),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ do_critical_local_closed_abort(Config, [<<0>>, SettingsBin]).
+
+do_critical_local_closed_abort(Config, HeaderData) ->
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, _} = quicer:send(StreamRef, HeaderData),
+ %% Wait a little to make sure the stream data was received before we abort.
+ timer:sleep(100),
+ %% Close the critical stream.
+ quicer:async_shutdown_stream(StreamRef, ?QUIC_STREAM_SHUTDOWN_FLAG_ABORT, 0),
+ %% The connection should have been closed.
+ timer:sleep(1000),
+ #{reason := h3_closed_critical_stream} = do_wait_connection_closed(Conn),
+ ok.
+
+control_local_closed_graceful(Config) ->
+ doc("Endpoints must not close the control stream. (RFC9114 6.2.1)"),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ do_critical_local_closed_graceful(Config, [<<0>>, SettingsBin]).
+
+do_critical_local_closed_graceful(Config, HeaderData) ->
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, _} = quicer:send(StreamRef, HeaderData),
+ %% Close the critical stream.
+ quicer:async_shutdown_stream(StreamRef, ?QUIC_STREAM_SHUTDOWN_FLAG_GRACEFUL, 0),
+ %% The connection should have been closed.
+ #{reason := h3_closed_critical_stream} = do_wait_connection_closed(Conn),
+ ok.
+
+control_remote_closed_abort(Config) ->
+ doc("Endpoints must not close the control stream. (RFC9114 6.2.1)"),
+ #{conn := Conn, control := ControlRef} = do_connect(Config),
+ %% Close the control stream.
+ quicer:async_shutdown_stream(ControlRef, ?QUIC_STREAM_SHUTDOWN_FLAG_ABORT, 0),
+ %% The connection should have been closed.
+ #{reason := h3_closed_critical_stream} = do_wait_connection_closed(Conn),
+ ok.
+
+%% We cannot gracefully shutdown a remote unidi stream; only abort reading.
+
+%% Because the contents of the control stream are used to manage the behavior
+%% of other streams, endpoints SHOULD provide enough flow-control credit to keep
+%% the peer's control stream from becoming blocked.
+
+%% @todo Implement server push (RFC9114 6.2.2 Push Streams)
+
+data_frame_can_span_multiple_packets(Config) ->
+ doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/echo/read_body">>},
+ {<<"content-length">>, <<"13">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders,
+ <<0>>, %% DATA frame.
+ cow_http3:encode_int(13),
+ <<"Hello ">>
+ ]),
+ timer:sleep(100),
+ {ok, _} = quicer:send(StreamRef, [
+ <<"server!">>
+ ], ?QUIC_SEND_FLAG_FIN),
+ #{
+ headers := #{<<":status">> := <<"200">>},
+ body := <<"Hello server!">>
+ } = do_receive_response(StreamRef),
+ ok.
+
+headers_frame_can_span_multiple_packets(Config) ->
+ doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"0">>}
+ ], 0, cow_qpack:init(encoder)),
+ Half = iolist_size(EncodedHeaders) div 2,
+ <<EncodedHeadersPart1:Half/binary, EncodedHeadersPart2/bits>>
+ = iolist_to_binary(EncodedHeaders),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeadersPart1
+ ]),
+ timer:sleep(100),
+ {ok, _} = quicer:send(StreamRef, [
+ EncodedHeadersPart2
+ ]),
+ #{
+ headers := #{<<":status">> := <<"200">>},
+ body := <<"Hello world!">>
+ } = do_receive_response(StreamRef),
+ ok.
+
+%% @todo Implement server push. cancel_push_frame_can_span_multiple_packets(Config) ->
+
+settings_frame_can_span_multiple_packets(Config) ->
+ doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ <<SettingsPart1:1/binary, SettingsPart2/bits>> = SettingsBin,
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ SettingsPart1
+ ]),
+ timer:sleep(100),
+ {ok, _} = quicer:send(ControlRef, [
+ SettingsPart2
+ ]),
+ %% The connection should remain up.
+ receive
+ {quic, shutdown, Conn, {unknown_quic_status, Code}} ->
+ Reason = cow_http3:code_to_error(Code),
+ error(Reason)
+ after 1000 ->
+ ok
+ end.
+
+goaway_frame_can_span_multiple_packets(Config) ->
+ doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ SettingsBin,
+ <<7>>, cow_http3:encode_int(1) %% GOAWAY part 1.
+ ]),
+ timer:sleep(100),
+ {ok, _} = quicer:send(ControlRef, [
+ cow_http3:encode_int(0) %% GOAWAY part 2.
+ ]),
+ %% The connection should be closed gracefully.
+ receive
+ {quic, shutdown, Conn, {unknown_quic_status, Code}} ->
+ h3_no_error = cow_http3:code_to_error(Code),
+ ok;
+ %% @todo Temporarily also accept this message. I am
+ %% not sure why it happens but it isn't wrong per se.
+ {quic, shutdown, Conn, success} ->
+ ok
+ after 1000 ->
+ error(timeout)
+ end.
+
+max_push_id_frame_can_span_multiple_packets(Config) ->
+ doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ SettingsBin,
+ <<13>>, cow_http3:encode_int(1) %% MAX_PUSH_ID part 1.
+ ]),
+ timer:sleep(100),
+ {ok, _} = quicer:send(ControlRef, [
+ cow_http3:encode_int(0) %% MAX_PUSH_ID part 2.
+ ]),
+ %% The connection should remain up.
+ receive
+ {quic, shutdown, Conn, {unknown_quic_status, Code}} ->
+ Reason = cow_http3:code_to_error(Code),
+ error(Reason)
+ after 1000 ->
+ ok
+ end.
+
+unknown_frame_can_span_multiple_packets(Config) ->
+ doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, _} = quicer:send(StreamRef, [
+ cow_http3:encode_int(do_unknown_frame_type()),
+ cow_http3:encode_int(16383)
+ ]),
+ timer:sleep(100),
+ {ok, _} = quicer:send(StreamRef, rand:bytes(4096)),
+ timer:sleep(100),
+ {ok, _} = quicer:send(StreamRef, rand:bytes(4096)),
+ timer:sleep(100),
+ {ok, _} = quicer:send(StreamRef, rand:bytes(4096)),
+ timer:sleep(100),
+ {ok, _} = quicer:send(StreamRef, rand:bytes(4095)),
+ {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders
+ ], ?QUIC_SEND_FLAG_FIN),
+ #{
+ headers := #{<<":status">> := <<"200">>},
+ body := <<"Hello world!">>
+ } = do_receive_response(StreamRef),
+ ok.
+
+%% The DATA and SETTINGS frames can be zero-length therefore
+%% they cannot be too short.
+
+headers_frame_too_short(Config) ->
+ doc("Frames that terminate before the end of identified fields "
+ "must be rejected with an H3_FRAME_ERROR connection error. "
+ "(RFC9114 7.1, RFC9114 10.8)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(0)
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_frame_error} = do_wait_connection_closed(Conn),
+ ok.
+
+%% @todo Implement server push. cancel_push_frame_too_short(Config) ->
+
+goaway_frame_too_short(Config) ->
+ doc("Frames that terminate before the end of identified fields "
+ "must be rejected with an H3_FRAME_ERROR connection error. "
+ "(RFC9114 7.1, RFC9114 10.8)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ SettingsBin,
+ <<7>>, cow_http3:encode_int(0) %% GOAWAY.
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_frame_error} = do_wait_connection_closed(Conn),
+ ok.
+
+max_push_id_frame_too_short(Config) ->
+ doc("Frames that terminate before the end of identified fields "
+ "must be rejected with an H3_FRAME_ERROR connection error. "
+ "(RFC9114 7.1, RFC9114 10.8)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ SettingsBin,
+ <<13>>, cow_http3:encode_int(0) %% MAX_PUSH_ID.
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_frame_error} = do_wait_connection_closed(Conn),
+ ok.
+
+data_frame_truncated(Config) ->
+ doc("Truncated frames must be rejected with an "
+ "H3_FRAME_ERROR connection error. (RFC9114 7.1, RFC9114 10.8)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/echo/read_body">>},
+ {<<"content-length">>, <<"13">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders,
+ <<0>>, %% DATA frame.
+ cow_http3:encode_int(13),
+ <<"Hello ">>
+ ], ?QUIC_SEND_FLAG_FIN),
+ %% The connection should have been closed.
+ #{reason := h3_frame_error} = do_wait_connection_closed(Conn),
+ ok.
+
+headers_frame_truncated(Config) ->
+ doc("Truncated frames must be rejected with an "
+ "H3_FRAME_ERROR connection error. (RFC9114 7.1, RFC9114 10.8)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"0">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders))
+ ], ?QUIC_SEND_FLAG_FIN),
+ %% The connection should have been closed.
+ #{reason := h3_frame_error} = do_wait_connection_closed(Conn),
+ ok.
+
+%% I am not sure how to test truncated CANCEL_PUSH, SETTINGS, GOAWAY
+%% or MAX_PUSH_ID frames, as those are sent on the control stream,
+%% which we cannot terminate.
+
+%% The DATA, HEADERS and SETTINGS frames can be of any length
+%% therefore they cannot be too long per se, even if unwanted
+%% data can be included at the end of the frame's payload.
+
+%% @todo Implement server push. cancel_push_frame_too_long(Config) ->
+
+goaway_frame_too_long(Config) ->
+ doc("Frames that contain additional bytes after the end of identified fields "
+ "must be rejected with an H3_FRAME_ERROR connection error. "
+ "(RFC9114 7.1, RFC9114 10.8)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ SettingsBin,
+ <<7>>, cow_http3:encode_int(3), %% GOAWAY.
+ <<0, 1, 2>>
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_frame_error} = do_wait_connection_closed(Conn),
+ ok.
+
+max_push_id_frame_too_long(Config) ->
+ doc("Frames that contain additional bytes after the end of identified fields "
+ "must be rejected with an H3_FRAME_ERROR connection error. "
+ "(RFC9114 7.1, RFC9114 10.8)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ SettingsBin,
+ <<13>>, cow_http3:encode_int(9), %% MAX_PUSH_ID.
+ <<0, 1, 2, 3, 4, 5, 6, 7, 8>>
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_frame_error} = do_wait_connection_closed(Conn),
+ ok.
+
+%% Streams may terminate abruptly in the middle of frames.
+
+data_frame_rejected_on_control_stream(Config) ->
+ doc("DATA frames received on the control stream must be rejected "
+ "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.1)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ SettingsBin,
+ <<0>>, %% DATA frame.
+ cow_http3:encode_int(12),
+ <<"Hello world!">>
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn),
+ ok.
+
+headers_frame_rejected_on_control_stream(Config) ->
+ doc("HEADERS frames received on the control stream must be rejected "
+ "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.2)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"0">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ SettingsBin,
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn),
+ ok.
+
+%% @todo Implement server push. (RFC9114 7.2.3. CANCEL_PUSH)
+
+settings_twice(Config) ->
+ doc("Receipt of a second SETTINGS frame on the control stream "
+ "must be rejected with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.4)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ SettingsBin,
+ SettingsBin
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn),
+ ok.
+
+settings_on_bidi_stream(Config) ->
+ doc("Receipt of a SETTINGS frame on a bidirectional stream "
+ "must be rejected with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.4)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"0">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ SettingsBin,
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedRequest)),
+ EncodedRequest
+ ], ?QUIC_SEND_FLAG_FIN),
+ %% The connection should have been closed.
+ #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn),
+ ok.
+
+settings_identifier_twice(Config) ->
+ doc("Receipt of a duplicate SETTINGS identifier must be rejected "
+ "with an H3_SETTINGS_ERROR connection error. (RFC9114 7.2.4)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ SettingsPayload = [
+ cow_http3:encode_int(6), cow_http3:encode_int(4096),
+ cow_http3:encode_int(6), cow_http3:encode_int(8192)
+ ],
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ <<4>>, %% SETTINGS frame.
+ cow_http3:encode_int(iolist_size(SettingsPayload)),
+ SettingsPayload
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_settings_error} = do_wait_connection_closed(Conn),
+ ok.
+
+settings_ignore_unknown_identifier(Config) ->
+ doc("Unknown SETTINGS identifiers must be ignored (RFC9114 7.2.4, RFC9114 9)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ SettingsPayload = [
+ cow_http3:encode_int(999), cow_http3:encode_int(4096)
+ ],
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ <<4>>, %% SETTINGS frame.
+ cow_http3:encode_int(iolist_size(SettingsPayload)),
+ SettingsPayload
+ ]),
+ %% The connection should remain up.
+ receive
+ {quic, shutdown, Conn, {unknown_quic_status, Code}} ->
+ Reason = cow_http3:code_to_error(Code),
+ error(Reason)
+ after 1000 ->
+ ok
+ end.
+
+settings_ignore_reserved_identifier(Config) ->
+ doc("Reserved SETTINGS identifiers must be ignored (RFC9114 7.2.4.1)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ SettingsPayload = [
+ cow_http3:encode_int(do_reserved_type()), cow_http3:encode_int(4096)
+ ],
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ <<4>>, %% SETTINGS frame.
+ cow_http3:encode_int(iolist_size(SettingsPayload)),
+ SettingsPayload
+ ]),
+ %% The connection should remain up.
+ receive
+ {quic, shutdown, Conn, {unknown_quic_status, Code}} ->
+ Reason = cow_http3:code_to_error(Code),
+ error(Reason)
+ after 1000 ->
+ ok
+ end.
+
+%% @todo Check that we send a reserved SETTINGS identifier when sending a
+%% non-empty SETTINGS frame. (7.2.4.1. Defined SETTINGS Parameters)
+
+%% @todo Check that setting SETTINGS_MAX_FIELD_SECTION_SIZE works.
+
+%% It is unclear whether the SETTINGS identifier 0x00 must be rejected or ignored.
+
+settings_reject_http2_0x02(Config) ->
+ do_settings_reject_http2(Config, 2, 1).
+
+settings_reject_http2_0x03(Config) ->
+ do_settings_reject_http2(Config, 3, 100).
+
+settings_reject_http2_0x04(Config) ->
+ do_settings_reject_http2(Config, 4, 128000).
+
+settings_reject_http2_0x05(Config) ->
+ do_settings_reject_http2(Config, 5, 1000000).
+
+do_settings_reject_http2(Config, Identifier, Value) ->
+ doc("Receipt of an unused HTTP/2 SETTINGS identifier must be rejected "
+ "with an H3_SETTINGS_ERROR connection error. (RFC9114 7.2.4, RFC9114 11.2.2)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ SettingsPayload = [
+ cow_http3:encode_int(Identifier), cow_http3:encode_int(Value)
+ ],
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ <<4>>, %% SETTINGS frame.
+ cow_http3:encode_int(iolist_size(SettingsPayload)),
+ SettingsPayload
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_settings_error} = do_wait_connection_closed(Conn),
+ ok.
+
+%% 7.2.4.2. Initialization
+%% An HTTP implementation MUST NOT send frames or requests that would be
+%% invalid based on its current understanding of the peer's settings.
+%% @todo In the case of SETTINGS_MAX_FIELD_SECTION_SIZE I don't think we have a choice.
+
+%% All settings begin at an initial value. Each endpoint SHOULD use these
+%% initial values to send messages before the peer's SETTINGS frame has arrived,
+%% as packets carrying the settings can be lost or delayed. When the SETTINGS
+%% frame arrives, any settings are changed to their new values.
+
+%% Endpoints MUST NOT require any data to be received from the peer prior to
+%% sending the SETTINGS frame; settings MUST be sent as soon as the transport is
+%% ready to send data.
+
+%% @todo Implement 0-RTT. (7.2.4.2. Initialization)
+
+%% @todo Implement server push. (7.2.5. PUSH_PROMISE)
+
+goaway_on_bidi_stream(Config) ->
+ doc("Receipt of a GOAWAY frame on a bidirectional stream "
+ "must be rejected with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.6)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, _} = quicer:send(StreamRef, [
+ <<7>>, cow_http3:encode_int(1), cow_http3:encode_int(0) %% GOAWAY.
+ ], ?QUIC_SEND_FLAG_FIN),
+ %% The connection should have been closed.
+ #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn),
+ ok.
+
+%% @todo Implement server push. (7.2.6 GOAWAY - will have to reject too large push IDs)
+
+max_push_id_on_bidi_stream(Config) ->
+ doc("Receipt of a MAX_PUSH_ID frame on a bidirectional stream "
+ "must be rejected with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.7)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, _} = quicer:send(StreamRef, [
+ <<13>>, cow_http3:encode_int(1), cow_http3:encode_int(0) %% MAX_PUSH_ID.
+ ], ?QUIC_SEND_FLAG_FIN),
+ %% The connection should have been closed.
+ #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn),
+ ok.
+
+%% @todo Implement server push. (7.2.7 MAX_PUSH_ID)
+
+max_push_id_reject_lower(Config) ->
+ doc("Receipt of a MAX_PUSH_ID value lower than previously received "
+ "must be rejected with an H3_ID_ERROR connection error. (RFC9114 7.2.7)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ SettingsBin,
+ <<13>>, cow_http3:encode_int(1), cow_http3:encode_int(20), %% MAX_PUSH_ID.
+ <<13>>, cow_http3:encode_int(1), cow_http3:encode_int(10) %% MAX_PUSH_ID.
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_id_error} = do_wait_connection_closed(Conn),
+ ok.
+
+reserved_on_control_stream(Config) ->
+ doc("Receipt of a reserved frame type on a control stream "
+ "must be ignored. (RFC9114 7.2.8)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ Len = rand:uniform(512),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ SettingsBin,
+ cow_http3:encode_int(do_reserved_type()),
+ cow_http3:encode_int(Len),
+ rand:bytes(Len)
+ ]),
+ %% The connection should remain up.
+ receive
+ {quic, shutdown, Conn, {unknown_quic_status, Code}} ->
+ Reason = cow_http3:code_to_error(Code),
+ error(Reason)
+ after 1000 ->
+ ok
+ end.
+
+reserved_reject_http2_0x02_control(Config) ->
+ do_reserved_reject_http2_control(Config, 2).
+
+reserved_reject_http2_0x06_control(Config) ->
+ do_reserved_reject_http2_control(Config, 6).
+
+reserved_reject_http2_0x08_control(Config) ->
+ do_reserved_reject_http2_control(Config, 8).
+
+reserved_reject_http2_0x09_control(Config) ->
+ do_reserved_reject_http2_control(Config, 9).
+
+do_reserved_reject_http2_control(Config, Type) ->
+ doc("Receipt of an unused HTTP/2 frame type must be rejected "
+ "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.8, RFC9114 11.2.1)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, ControlRef} = quicer:start_stream(Conn,
+ #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}),
+ {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}),
+ Len = rand:uniform(512),
+ {ok, _} = quicer:send(ControlRef, [
+ <<0>>, %% CONTROL stream.
+ SettingsBin,
+ cow_http3:encode_int(Type),
+ cow_http3:encode_int(Len),
+ rand:bytes(Len)
+ ]),
+ %% The connection should have been closed.
+ #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn),
+ ok.
+
+reserved_reject_http2_0x02_bidi(Config) ->
+ do_reserved_reject_http2_bidi(Config, 2).
+
+reserved_reject_http2_0x06_bidi(Config) ->
+ do_reserved_reject_http2_bidi(Config, 6).
+
+reserved_reject_http2_0x08_bidi(Config) ->
+ do_reserved_reject_http2_bidi(Config, 8).
+
+reserved_reject_http2_0x09_bidi(Config) ->
+ do_reserved_reject_http2_bidi(Config, 9).
+
+do_reserved_reject_http2_bidi(Config, Type) ->
+ doc("Receipt of an unused HTTP/2 frame type must be rejected "
+ "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.8, RFC9114 11.2.1)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>},
+ {<<":path">>, <<"/">>},
+ {<<"content-length">>, <<"0">>}
+ ], 0, cow_qpack:init(encoder)),
+ Len = rand:uniform(512),
+ {ok, _} = quicer:send(StreamRef, [
+ cow_http3:encode_int(Type),
+ cow_http3:encode_int(Len),
+ rand:bytes(Len),
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedHeaders)),
+ EncodedHeaders
+ ], ?QUIC_SEND_FLAG_FIN),
+ %% The connection should have been closed.
+ #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn),
+ ok.
+
+%% An endpoint MAY choose to treat a stream error as a connection error under
+%% certain circumstances, closing the entire connection in response to a
+%% condition on a single stream.
+
+%% Because new error codes can be defined without negotiation (see Section 9),
+%% use of an error code in an unexpected context or receipt of an unknown error
+%% code MUST be treated as equivalent to H3_NO_ERROR.
+
+%% 8.1. HTTP/3 Error Codes
+%% H3_INTERNAL_ERROR (0x0102): An internal error has occurred in the HTTP stack.
+%% H3_EXCESSIVE_LOAD (0x0107): The endpoint detected that its peer is
+%% exhibiting a behavior that might be generating excessive load.
+%% H3_MISSING_SETTINGS (0x010a): No SETTINGS frame was received
+%% at the beginning of the control stream.
+%% H3_REQUEST_REJECTED (0x010b): A server rejected a request without
+%% performing any application processing.
+%% H3_REQUEST_CANCELLED (0x010c): The request or its response
+%% (including pushed response) is cancelled.
+%% H3_REQUEST_INCOMPLETE (0x010d): The client's stream terminated
+%% without containing a fully formed request.
+%% H3_CONNECT_ERROR (0x010f): The TCP connection established in
+%% response to a CONNECT request was reset or abnormally closed.
+%% H3_VERSION_FALLBACK (0x0110): The requested operation cannot
+%% be served over HTTP/3. The peer should retry over HTTP/1.1.
+
+%% 9. Extensions to HTTP/3
+%% If a setting is used for extension negotiation, the default value MUST be
+%% defined in such a fashion that the extension is disabled if the setting is
+%% omitted.
+
+%% 10. Security Considerations
+%% 10.3. Intermediary-Encapsulation Attacks
+%% Requests or responses containing invalid field names MUST be treated as malformed.
+%% Any request or response that contains a character not permitted in a field
+%% value MUST be treated as malformed.
+
+%% 10.5. Denial-of-Service Considerations
+%% Implementations SHOULD track the use of these features and set limits on
+%% their use. An endpoint MAY treat activity that is suspicious as a connection
+%% error of type H3_EXCESSIVE_LOAD, but false positives will result in disrupting
+%% valid connections and requests.
+
+reject_large_unknown_frame(Config) ->
+ doc("Large unknown frames may risk denial-of-service "
+ "and should be rejected. (RFC9114 10.5)"),
+ #{conn := Conn} = do_connect(Config),
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, _} = quicer:send(StreamRef, [
+ cow_http3:encode_int(do_unknown_frame_type()),
+ cow_http3:encode_int(16385)
+ ]),
+ #{reason := h3_excessive_load} = do_wait_connection_closed(Conn),
+ ok.
+
+%% 10.5.1. Limits on Field Section Size
+%% An endpoint can use the SETTINGS_MAX_FIELD_SECTION_SIZE (Section 4.2.2)
+%% setting to advise peers of limits that might apply on the size of field
+%% sections.
+%%
+%% A server that receives a larger field section than it is willing to handle
+%% can send an HTTP 431 (Request Header Fields Too Large) status code
+%% ([RFC6585]).
+
+%% 10.6. Use of Compression
+%% Implementations communicating on a secure channel MUST NOT compress content
+%% that includes both confidential and attacker-controlled data unless separate
+%% compression contexts are used for each source of data. Compression MUST NOT be
+%% used if the source of data cannot be reliably determined.
+
+%% 10.9. Early Data
+%% The anti-replay mitigations in [HTTP-REPLAY] MUST be applied when using HTTP/3 with 0-RTT.
+
+%% 10.10. Migration
+%% Certain HTTP implementations use the client address for logging or
+%% access-control purposes. Since a QUIC client's address might change during a
+%% connection (and future versions might support simultaneous use of multiple
+%% addresses), such implementations will need to either actively retrieve the
+%% client's current address or addresses when they are relevant or explicitly
+%% accept that the original address might change. @todo Document this behavior.
+
+%% Appendix A. Considerations for Transitioning from HTTP/2
+%% A.1. Streams
+%% QUIC considers a stream closed when all data has been received and sent data
+%% has been acknowledged by the peer. HTTP/2 considers a stream closed when the
+%% frame containing the END_STREAM bit has been committed to the transport. As a
+%% result, the stream for an equivalent exchange could remain "active" for a
+%% longer period of time. HTTP/3 servers might choose to permit a larger number
+%% of concurrent client-initiated bidirectional streams to achieve equivalent
+%% concurrency to HTTP/2, depending on the expected usage patterns. @todo Document this.
+
+%% Helper functions.
+
+%% @todo Maybe have a function in cow_http3.
+do_reserved_type() ->
+ 16#1f * (rand:uniform(148764065110560900) - 1) + 16#21.
+
+do_connect(Config) ->
+ do_connect(Config, #{}).
+
+do_connect(Config, Opts) ->
+ {ok, Conn} = quicer:connect("localhost", config(port, Config),
+ Opts#{alpn => ["h3"], verify => none}, 5000),
+ %% To make sure the connection is fully established we wait
+ %% to receive the SETTINGS frame on the control stream.
+ {ok, ControlRef, Settings} = do_wait_settings(Conn),
+ #{
+ conn => Conn,
+ control => ControlRef, %% This is the peer control stream.
+ settings => Settings
+ }.
+
+do_wait_settings(Conn) ->
+ receive
+ {quic, new_stream, StreamRef, #{flags := Flags}} ->
+ ok = quicer:setopt(StreamRef, active, true),
+ true = quicer:is_unidirectional(Flags),
+ receive {quic, <<
+ 0, %% Control stream.
+ SettingsFrame/bits
+ >>, StreamRef, _} ->
+ {ok, {settings, Settings}, <<>>} = cow_http3:parse(SettingsFrame),
+ {ok, StreamRef, Settings}
+ after 5000 ->
+ {error, timeout}
+ end
+ after 5000 ->
+ {error, timeout}
+ end.
+
+do_receive_data(StreamRef) ->
+ receive
+ {quic, Data, StreamRef, _Flags} when is_binary(Data) ->
+ {ok, Data}
+ after 5000 ->
+ {error, timeout}
+ end.
+
+do_guess_int_encoding(Data) ->
+ SizeWithLen = byte_size(Data) - 1,
+ if
+ SizeWithLen < 64 + 1 ->
+ {0, 6};
+ SizeWithLen < 16384 + 2 ->
+ {1, 14};
+ SizeWithLen < 1073741824 + 4 ->
+ {2, 30};
+ SizeWithLen < 4611686018427387904 + 8 ->
+ {3, 62}
+ end.
+
+do_wait_peer_send_shutdown(StreamRef) ->
+ receive
+ {quic, peer_send_shutdown, StreamRef, undefined} ->
+ ok
+ after 5000 ->
+ {error, timeout}
+ end.
+
+do_wait_stream_aborted(StreamRef) ->
+ receive
+ {quic, peer_send_aborted, StreamRef, Code} ->
+ Reason = cow_http3:code_to_error(Code),
+ #{reason => Reason};
+ {quic, peer_receive_aborted, StreamRef, Code} ->
+ Reason = cow_http3:code_to_error(Code),
+ #{reason => Reason}
+ after 5000 ->
+ {error, timeout}
+ end.
+
+do_wait_stream_closed(StreamRef) ->
+ receive
+ {quic, stream_closed, StreamRef, #{error := Error, is_conn_shutdown := false}} ->
+ 0 = Error,
+ ok
+ after 5000 ->
+ {error, timeout}
+ end.
+
+do_receive_response(StreamRef) ->
+ {ok, Data} = do_receive_data(StreamRef),
+ {HLenEnc, HLenBits} = do_guess_int_encoding(Data),
+ <<
+ 1, %% HEADERS frame.
+ HLenEnc:2, HLen:HLenBits,
+ EncodedResponse:HLen/bytes,
+ Rest/bits
+ >> = Data,
+ {ok, DecodedResponse, _DecData, _DecSt}
+ = cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init(decoder)),
+ Headers = maps:from_list(DecodedResponse),
+ #{<<"content-length">> := BodyLen} = Headers,
+ {DLenEnc, DLenBits} = do_guess_int_encoding(Rest),
+ Body = case Rest of
+ <<>> ->
+ <<>>;
+ <<
+ 0, %% DATA frame.
+ DLenEnc:2, DLen:DLenBits,
+ Body0:DLen/bytes
+ >> ->
+ BodyLen = integer_to_binary(byte_size(Body0)),
+ Body0
+ end,
+ ok = do_wait_peer_send_shutdown(StreamRef),
+ #{
+ headers => Headers,
+ body => Body
+ }.
+
+do_wait_connection_closed(Conn) ->
+ receive
+ {quic, shutdown, Conn, {unknown_quic_status, Code}} ->
+ Reason = cow_http3:code_to_error(Code),
+ #{reason => Reason}
+ after 5000 ->
+ {error, timeout}
+ end.
+
+-endif.
diff --git a/test/rfc9114_SUITE_data/client.key b/test/rfc9114_SUITE_data/client.key
new file mode 100644
index 0000000..9c5e1ce
--- /dev/null
+++ b/test/rfc9114_SUITE_data/client.key
@@ -0,0 +1,5 @@
+-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVJakPYfQA1Hr6Gnq
+GYmpMfXpxUi2QwDBrZfw8dBcVqKhRANCAAQDHeeAvjwD7p+Mg1F+G9FBNy+7Wcms
+HEw4sGMzhUL4wjwsqKHpoiuQg3qUXXK0gamx0l77vFjrUc6X1al4+ZM5
+-----END PRIVATE KEY-----
diff --git a/test/rfc9114_SUITE_data/client.pem b/test/rfc9114_SUITE_data/client.pem
new file mode 100644
index 0000000..cd9dc8c
--- /dev/null
+++ b/test/rfc9114_SUITE_data/client.pem
@@ -0,0 +1,12 @@
+-----BEGIN CERTIFICATE-----
+MIIBtTCCAVugAwIBAgIUeAPi9oyMIE/KRpsRdukfx2eMuuswCgYIKoZIzj0EAwIw
+IDELMAkGA1UEBhMCU0UxETAPBgNVBAoMCE5PQk9EWUFCMB4XDTIzMDcwNTEwMjIy
+MloXDTI0MTExNjEwMjIyMlowMTELMAkGA1UEBhMCU0UxETAPBgNVBAoMCE5PQk9E
+WUFCMQ8wDQYDVQQDDAZjbGllbnQwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQD
+HeeAvjwD7p+Mg1F+G9FBNy+7WcmsHEw4sGMzhUL4wjwsqKHpoiuQg3qUXXK0gamx
+0l77vFjrUc6X1al4+ZM5o2IwYDALBgNVHQ8EBAMCA4gwEQYDVR0RBAowCIIGY2xp
+ZW50MB0GA1UdDgQWBBTnhPpO+rSIFAxvkwVjlkKOO2jOeDAfBgNVHSMEGDAWgBSD
+Hw8A4XXG3jB1Atrqux7AUsf+KjAKBggqhkjOPQQDAgNIADBFAiEA2qf29EBp2hcL
+sEO7MM0ZLm4gnaMdcxtyneF3+c7Lg3cCIBFTVP8xHlhCJyb8ESV7S052VU0bKQFN
+ioyoYtcycxuZ
+-----END CERTIFICATE-----
diff --git a/test/rfc9114_SUITE_data/server.key b/test/rfc9114_SUITE_data/server.key
new file mode 100644
index 0000000..45ea890
--- /dev/null
+++ b/test/rfc9114_SUITE_data/server.key
@@ -0,0 +1,5 @@
+-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgvykUYMOS2gW8XTTh
+HgmeJM36NT8GGTNXzzt4sIs0o9ahRANCAATnQOMkKbLFQCZY/cxf8otEJG2tVuG6
+QvLqUdERV2+gzE+4ROGDqbb2Jk1szyz4CfBMB4ZfLA/PdSiO+KrOeOcj
+-----END PRIVATE KEY-----
diff --git a/test/rfc9114_SUITE_data/server.pem b/test/rfc9114_SUITE_data/server.pem
new file mode 100644
index 0000000..43cce8e
--- /dev/null
+++ b/test/rfc9114_SUITE_data/server.pem
@@ -0,0 +1,12 @@
+-----BEGIN CERTIFICATE-----
+MIIBtTCCAVugAwIBAgIUeAPi9oyMIE/KRpsRdukfx2eMuuowCgYIKoZIzj0EAwIw
+IDELMAkGA1UEBhMCU0UxETAPBgNVBAoMCE5PQk9EWUFCMB4XDTIzMDcwNTEwMjIy
+MloXDTI0MTExNjEwMjIyMlowMTELMAkGA1UEBhMCU0UxETAPBgNVBAoMCE5PQk9E
+WUFCMQ8wDQYDVQQDDAZzZXJ2ZXIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATn
+QOMkKbLFQCZY/cxf8otEJG2tVuG6QvLqUdERV2+gzE+4ROGDqbb2Jk1szyz4CfBM
+B4ZfLA/PdSiO+KrOeOcjo2IwYDALBgNVHQ8EBAMCA4gwEQYDVR0RBAowCIIGc2Vy
+dmVyMB0GA1UdDgQWBBS+Np5J8BtmWU534pm9hqhrG/EQ7zAfBgNVHSMEGDAWgBSD
+Hw8A4XXG3jB1Atrqux7AUsf+KjAKBggqhkjOPQQDAgNIADBFAiEApRfjIEJfO1VH
+ETgNG3/MzDayYScPocVn4v8U15ygEw8CIFUY3xMZzJ5AmiRe9PhIUgueOKQNMtds
+wdF9+097+Ey0
+-----END CERTIFICATE-----
diff --git a/test/rfc9204_SUITE.erl b/test/rfc9204_SUITE.erl
new file mode 100644
index 0000000..e8defd2
--- /dev/null
+++ b/test/rfc9204_SUITE.erl
@@ -0,0 +1,357 @@
+%% Copyright (c) 2024, Loïc Hoguin <[email protected]>
+%%
+%% Permission to use, copy, modify, and/or distribute this software for any
+%% purpose with or without fee is hereby granted, provided that the above
+%% copyright notice and this permission notice appear in all copies.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+-module(rfc9204_SUITE).
+-compile(export_all).
+-compile(nowarn_export_all).
+
+-import(ct_helper, [config/2]).
+-import(ct_helper, [doc/1]).
+
+-ifdef(COWBOY_QUICER).
+
+-include_lib("quicer/include/quicer.hrl").
+
+all() ->
+ [{group, h3}].
+
+groups() ->
+ %% @todo Enable parallel tests but for this issues in the
+ %% QUIC accept loop need to be figured out (can't connect
+ %% concurrently somehow, no backlog?).
+ [{h3, [], ct_helper:all(?MODULE)}].
+
+init_per_group(Name = h3, Config) ->
+ cowboy_test:init_http3(Name, #{
+ env => #{dispatch => cowboy_router:compile(init_routes(Config))}
+ }, Config).
+
+end_per_group(Name, _) ->
+ cowboy_test:stop_group(Name).
+
+init_routes(_) -> [
+ {"localhost", [
+ {"/", hello_h, []}
+ ]}
+].
+
+%% Encoder.
+
+%% 2.1
+%% QPACK preserves the ordering of field lines within
+%% each field section. An encoder MUST emit field
+%% representations in the order they appear in the
+%% input field section.
+
+%% 2.1.1
+%% If the dynamic table does not contain enough room
+%% for a new entry without evicting other entries,
+%% and the entries that would be evicted are not evictable,
+%% the encoder MUST NOT insert that entry into the dynamic
+%% table (including duplicates of existing entries).
+%% In order to avoid this, an encoder that uses the
+%% dynamic table has to keep track of each dynamic
+%% table entry referenced by each field section until
+%% those representations are acknowledged by the decoder;
+%% see Section 4.4.1.
+
+%% 2.1.2
+%% The decoder specifies an upper bound on the number
+%% of streams that can be blocked using the
+%% SETTINGS_QPACK_BLOCKED_STREAMS setting; see Section 5.
+%% An encoder MUST limit the number of streams that could
+%% become blocked to the value of SETTINGS_QPACK_BLOCKED_STREAMS
+%% at all times. If a decoder encounters more blocked streams
+%% than it promised to support, it MUST treat this as a
+%% connection error of type QPACK_DECOMPRESSION_FAILED.
+
+%% 2.1.3
+%% To avoid these deadlocks, an encoder SHOULD NOT
+%% write an instruction unless sufficient stream and
+%% connection flow-control credit is available for
+%% the entire instruction.
+
+%% Decoder.
+
+%% 2.2
+%% The decoder MUST emit field lines in the order their
+%% representations appear in the encoded field section.
+
+%% 2.2.1
+%% While blocked, encoded field section data SHOULD
+%% remain in the blocked stream's flow-control window.
+
+%% If it encounters a Required Insert Count smaller than
+%% expected, it MUST treat this as a connection error of
+%% type QPACK_DECOMPRESSION_FAILED; see Section 2.2.3.
+
+%% If it encounters a Required Insert Count larger than
+%% expected, it MAY treat this as a connection error of
+%% type QPACK_DECOMPRESSION_FAILED.
+
+%% After the decoder finishes decoding a field section
+%% encoded using representations containing dynamic table
+%% references, it MUST emit a Section Acknowledgment
+%% instruction (Section 4.4.1).
+
+%% 2.2.2.2
+%% A decoder with a maximum dynamic table capacity
+%% (Section 3.2.3) equal to zero MAY omit sending Stream
+%% Cancellations, because the encoder cannot have any
+%% dynamic table references.
+
+%% 2.2.3
+%% If the decoder encounters a reference in a field line
+%% representation to a dynamic table entry that has already
+%% been evicted or that has an absolute index greater than
+%% or equal to the declared Required Insert Count (Section 4.5.1),
+%% it MUST treat this as a connection error of type
+%% QPACK_DECOMPRESSION_FAILED.
+
+%% If the decoder encounters a reference in an encoder
+%% instruction to a dynamic table entry that has already
+%% been evicted, it MUST treat this as a connection error
+%% of type QPACK_ENCODER_STREAM_ERROR.
+
+%% Static table.
+
+%% 3.1
+%% When the decoder encounters an invalid static table index
+%% in a field line representation, it MUST treat this as a
+%% connection error of type QPACK_DECOMPRESSION_FAILED.
+%%
+%% If this index is received on the encoder stream, this
+%% MUST be treated as a connection error of type
+%% QPACK_ENCODER_STREAM_ERROR.
+
+%% Dynamic table.
+
+%% 3.2
+%% The dynamic table can contain duplicate entries
+%% (i.e., entries with the same name and same value).
+%% Therefore, duplicate entries MUST NOT be treated
+%% as an error by the decoder.
+
+%% 3.2.2
+%% The encoder MUST NOT cause a dynamic table entry to be
+%% evicted unless that entry is evictable; see Section 2.1.1.
+
+%% It is an error if the encoder attempts to add an entry
+%% that is larger than the dynamic table capacity; the
+%% decoder MUST treat this as a connection error of type
+%% QPACK_ENCODER_STREAM_ERROR.
+
+%% 3.2.3
+%% The encoder MUST NOT set a dynamic table capacity that
+%% exceeds this maximum, but it can choose to use a lower
+%% dynamic table capacity; see Section 4.3.1.
+
+%% When the client's 0-RTT value of the SETTING is zero,
+%% the server MAY set it to a non-zero value in its SETTINGS
+%% frame. If the remembered value is non-zero, the server
+%% MUST send the same non-zero value in its SETTINGS frame.
+%% If it specifies any other value, or omits
+%% SETTINGS_QPACK_MAX_TABLE_CAPACITY from SETTINGS,
+%% the encoder must treat this as a connection error of
+%% type QPACK_DECODER_STREAM_ERROR.
+
+%% When the maximum table capacity is zero, the encoder
+%% MUST NOT insert entries into the dynamic table and
+%% MUST NOT send any encoder instructions on the encoder stream.
+
+%% Wire format.
+
+%% 4.1.1
+%% QPACK implementations MUST be able to decode integers
+%% up to and including 62 bits long.
+
+%% Encoder and decoder streams.
+
+decoder_reject_multiple(Config) ->
+ doc("Endpoints must not create multiple decoder streams. (RFC9204 4.2)"),
+ rfc9114_SUITE:do_critical_reject_multiple(Config, <<3>>).
+
+encoder_reject_multiple(Config) ->
+ doc("Endpoints must not create multiple encoder streams. (RFC9204 4.2)"),
+ rfc9114_SUITE:do_critical_reject_multiple(Config, <<2>>).
+
+%% 4.2
+%% The sender MUST NOT close either of these streams,
+%% and the receiver MUST NOT request that the sender close
+%% either of these streams. Closure of either unidirectional
+%% stream type MUST be treated as a connection error of type
+%% H3_CLOSED_CRITICAL_STREAM.
+
+decoder_local_closed_abort(Config) ->
+ doc("Endpoints must not close the decoder stream. (RFC9204 4.2)"),
+ rfc9114_SUITE:do_critical_local_closed_abort(Config, <<3>>).
+
+decoder_local_closed_graceful(Config) ->
+ doc("Endpoints must not close the decoder stream. (RFC9204 4.2)"),
+ rfc9114_SUITE:do_critical_local_closed_graceful(Config, <<3>>).
+
+decoder_remote_closed_abort(Config) ->
+ doc("Endpoints must not close the decoder stream. (RFC9204 4.2)"),
+ #{conn := Conn} = rfc9114_SUITE:do_connect(Config, #{peer_unidi_stream_count => 3}),
+ {ok, #{decoder := StreamRef}} = do_wait_unidi_streams(Conn, #{}),
+ %% Close the control stream.
+ quicer:async_shutdown_stream(StreamRef, ?QUIC_STREAM_SHUTDOWN_FLAG_ABORT, 0),
+ %% The connection should have been closed.
+ #{reason := h3_closed_critical_stream} = rfc9114_SUITE:do_wait_connection_closed(Conn),
+ ok.
+
+encoder_local_closed_abort(Config) ->
+ doc("Endpoints must not close the encoder stream. (RFC9204 4.2)"),
+ rfc9114_SUITE:do_critical_local_closed_abort(Config, <<2>>).
+
+encoder_local_closed_graceful(Config) ->
+ doc("Endpoints must not close the encoder stream. (RFC9204 4.2)"),
+ rfc9114_SUITE:do_critical_local_closed_graceful(Config, <<2>>).
+
+encoder_remote_closed_abort(Config) ->
+ doc("Endpoints must not close the encoder stream. (RFC9204 4.2)"),
+ #{conn := Conn} = rfc9114_SUITE:do_connect(Config, #{peer_unidi_stream_count => 3}),
+ {ok, #{encoder := StreamRef}} = do_wait_unidi_streams(Conn, #{}),
+ %% Close the control stream.
+ quicer:async_shutdown_stream(StreamRef, ?QUIC_STREAM_SHUTDOWN_FLAG_ABORT, 0),
+ %% The connection should have been closed.
+ #{reason := h3_closed_critical_stream} = rfc9114_SUITE:do_wait_connection_closed(Conn),
+ ok.
+
+do_wait_unidi_streams(_, Acc=#{decoder := _, encoder := _}) ->
+ {ok, Acc};
+do_wait_unidi_streams(Conn, Acc) ->
+ receive
+ {quic, new_stream, StreamRef, #{flags := Flags}} ->
+ ok = quicer:setopt(StreamRef, active, true),
+ true = quicer:is_unidirectional(Flags),
+ receive {quic, <<TypeValue>>, StreamRef, _} ->
+ Type = case TypeValue of
+ 2 -> encoder;
+ 3 -> decoder
+ end,
+ do_wait_unidi_streams(Conn, Acc#{Type => StreamRef})
+ after 5000 ->
+ {error, timeout}
+ end
+ after 5000 ->
+ {error, timeout}
+ end.
+
+%% An endpoint MAY avoid creating an encoder stream if it will
+%% not be used (for example, if its encoder does not wish to
+%% use the dynamic table or if the maximum size of the dynamic
+%% table permitted by the peer is zero).
+
+%% An endpoint MAY avoid creating a decoder stream if its
+%% decoder sets the maximum capacity of the dynamic table to zero.
+
+%% An endpoint MUST allow its peer to create an encoder stream
+%% and a decoder stream even if the connection's settings
+%% prevent their use.
+
+%% Encoder instructions.
+
+%% 4.3.1
+%% The new capacity MUST be lower than or equal to the limit
+%% described in Section 3.2.3. In HTTP/3, this limit is the
+%% value of the SETTINGS_QPACK_MAX_TABLE_CAPACITY parameter
+%% (Section 5) received from the decoder. The decoder MUST
+%% treat a new dynamic table capacity value that exceeds this
+%% limit as a connection error of type QPACK_ENCODER_STREAM_ERROR.
+
+%% Reducing the dynamic table capacity can cause entries to be
+%% evicted; see Section 3.2.2. This MUST NOT cause the eviction
+%% of entries that are not evictable; see Section 2.1.1.
+
+%% Decoder instructions.
+
+%% 4.4.1
+%% If an encoder receives a Section Acknowledgment instruction
+%% referring to a stream on which every encoded field section
+%% with a non-zero Required Insert Count has already been
+%% acknowledged, this MUST be treated as a connection error
+%% of type QPACK_DECODER_STREAM_ERROR.
+
+%% 4.4.3
+%% An encoder that receives an Increment field equal to zero,
+%% or one that increases the Known Received Count beyond what
+%% the encoder has sent, MUST treat this as a connection error
+%% of type QPACK_DECODER_STREAM_ERROR.
+
+%% Field line representation.
+
+%% 4.5.1.1
+%% If the decoder encounters a value of EncodedInsertCount that
+%% could not have been produced by a conformant encoder, it MUST
+%% treat this as a connection error of type QPACK_DECOMPRESSION_FAILED.
+
+%% 4.5.1.2
+%% The value of Base MUST NOT be negative. Though the protocol
+%% might operate correctly with a negative Base using post-Base
+%% indexing, it is unnecessary and inefficient. An endpoint MUST
+%% treat a field block with a Sign bit of 1 as invalid if the
+%% value of Required Insert Count is less than or equal to the
+%% value of Delta Base.
+
+%% 4.5.4
+%% When the 'N' bit is set, the encoded field line MUST always
+%% be encoded with a literal representation. In particular,
+%% when a peer sends a field line that it received represented
+%% as a literal field line with the 'N' bit set, it MUST use a
+%% literal representation to forward this field line. This bit
+%% is intended for protecting field values that are not to be
+%% put at risk by compressing them; see Section 7.1 for more details.
+
+%% Configuration.
+
+%% 5
+%% SETTINGS_QPACK_MAX_TABLE_CAPACITY
+%% SETTINGS_QPACK_BLOCKED_STREAMS
+
+%% Security considerations.
+
+%% 7.1.2
+%% (security if used as a proxy merging many connections into one)
+%% An ideal solution segregates access to the dynamic table
+%% based on the entity that is constructing the message.
+%% Field values that are added to the table are attributed
+%% to an entity, and only the entity that created a particular
+%% value can extract that value.
+
+%% 7.1.3
+%% An intermediary MUST NOT re-encode a value that uses a
+%% literal representation with the 'N' bit set with another
+%% representation that would index it. If QPACK is used for
+%% re-encoding, a literal representation with the 'N' bit set
+%% MUST be used. If HPACK is used for re-encoding, the
+%% never-indexed literal representation (see Section 6.2.3
+%% of [RFC7541]) MUST be used.
+
+%% 7.4
+%% An implementation has to set a limit for the values it
+%% accepts for integers, as well as for the encoded length;
+%% see Section 4.1.1. In the same way, it has to set a limit
+%% to the length it accepts for string literals; see Section 4.1.2.
+%% These limits SHOULD be large enough to process the largest
+%% individual field the HTTP implementation can be configured
+%% to accept.
+
+%% If an implementation encounters a value larger than it is
+%% able to decode, this MUST be treated as a stream error of
+%% type QPACK_DECOMPRESSION_FAILED if on a request stream or
+%% a connection error of the appropriate type if on the
+%% encoder or decoder stream.
+
+-endif.
diff --git a/test/rfc9220_SUITE.erl b/test/rfc9220_SUITE.erl
new file mode 100644
index 0000000..7f447ed
--- /dev/null
+++ b/test/rfc9220_SUITE.erl
@@ -0,0 +1,485 @@
+%% Copyright (c) 2018, Loïc Hoguin <[email protected]>
+%%
+%% Permission to use, copy, modify, and/or distribute this software for any
+%% purpose with or without fee is hereby granted, provided that the above
+%% copyright notice and this permission notice appear in all copies.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+-module(rfc9220_SUITE).
+-compile(export_all).
+-compile(nowarn_export_all).
+
+-import(ct_helper, [config/2]).
+-import(ct_helper, [doc/1]).
+
+all() ->
+ [{group, enabled}].
+
+groups() ->
+ Tests = ct_helper:all(?MODULE),
+ [{enabled, [], Tests}]. %% @todo Enable parallel when all is better.
+
+init_per_group(Name = enabled, Config) ->
+ cowboy_test:init_http3(Name, #{
+ enable_connect_protocol => true,
+ env => #{dispatch => cowboy_router:compile(init_routes(Config))}
+ }, Config).
+
+end_per_group(Name, _) ->
+ cowboy_test:stop_group(Name).
+
+init_routes(_) -> [
+ {"localhost", [
+ {"/ws", ws_echo, []}
+ ]}
+].
+
+% The SETTINGS_ENABLE_CONNECT_PROTOCOL SETTINGS Parameter.
+
+% The new parameter name is SETTINGS_ENABLE_CONNECT_PROTOCOL. The
+% value of the parameter MUST be 0 or 1.
+
+% Upon receipt of SETTINGS_ENABLE_CONNECT_PROTOCOL with a value of 1 a
+% client MAY use the Extended CONNECT definition of this document when
+% creating new streams. Receipt of this parameter by a server does not
+% have any impact.
+%% @todo ignore_client_enable_setting(Config) ->
+
+reject_handshake_when_disabled(Config0) ->
+ doc("Extended CONNECT requests MUST be rejected with a "
+ "H3_MESSAGE_ERROR stream error when enable_connect_protocol=false. "
+ "(RFC9220, RFC8441 4)"),
+ Config = cowboy_test:init_http3(disabled, #{
+ enable_connect_protocol => false,
+ env => #{dispatch => cowboy_router:compile(init_routes(Config0))}
+ }, Config0),
+ %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 0.
+ #{
+ conn := Conn,
+ settings := Settings
+ } = rfc9114_SUITE:do_connect(Config),
+ case Settings of
+ #{enable_connect_protocol := false} -> ok;
+ _ when map_size(Settings) =:= 0 -> ok
+ end,
+ %% Send a CONNECT :protocol request to upgrade the stream to Websocket.
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"CONNECT">>},
+ {<<":protocol">>, <<"websocket">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":path">>, <<"/ws">>},
+ {<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+ {<<"sec-websocket-version">>, <<"13">>},
+ {<<"origin">>, <<"http://localhost">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedRequest)),
+ EncodedRequest
+ ]),
+ %% The stream should have been aborted.
+ #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+ ok.
+
+reject_handshake_disabled_by_default(Config0) ->
+ doc("Extended CONNECT requests MUST be rejected with a "
+ "H3_MESSAGE_ERROR stream error when enable_connect_protocol=false. "
+ "(RFC9220, RFC8441 4)"),
+ Config = cowboy_test:init_http3(disabled, #{
+ env => #{dispatch => cowboy_router:compile(init_routes(Config0))}
+ }, Config0),
+ %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 0.
+ #{
+ conn := Conn,
+ settings := Settings
+ } = rfc9114_SUITE:do_connect(Config),
+ case Settings of
+ #{enable_connect_protocol := false} -> ok;
+ _ when map_size(Settings) =:= 0 -> ok
+ end,
+ %% Send a CONNECT :protocol request to upgrade the stream to Websocket.
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"CONNECT">>},
+ {<<":protocol">>, <<"websocket">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":path">>, <<"/ws">>},
+ {<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+ {<<"sec-websocket-version">>, <<"13">>},
+ {<<"origin">>, <<"http://localhost">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedRequest)),
+ EncodedRequest
+ ]),
+ %% The stream should have been aborted.
+ #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+ ok.
+
+% The Extended CONNECT Method.
+
+accept_uppercase_pseudo_header_protocol(Config) ->
+ doc("The :protocol pseudo header is case insensitive. (RFC9220, RFC8441 4, RFC9110 7.8)"),
+ %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+ #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+ #{enable_connect_protocol := true} = Settings,
+ %% Send a CONNECT :protocol request to upgrade the stream to Websocket.
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"CONNECT">>},
+ {<<":protocol">>, <<"WEBSOCKET">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":path">>, <<"/ws">>},
+ {<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+ {<<"sec-websocket-version">>, <<"13">>},
+ {<<"origin">>, <<"http://localhost">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedRequest)),
+ EncodedRequest
+ ]),
+ %% Receive a 200 response.
+ {ok, Data} = rfc9114_SUITE:do_receive_data(StreamRef),
+ {HLenEnc, HLenBits} = rfc9114_SUITE:do_guess_int_encoding(Data),
+ <<
+ 1, %% HEADERS frame.
+ HLenEnc:2, HLen:HLenBits,
+ EncodedResponse:HLen/bytes
+ >> = Data,
+ {ok, DecodedResponse, _DecData, _DecSt}
+ = cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init(decoder)),
+ #{<<":status">> := <<"200">>} = maps:from_list(DecodedResponse),
+ ok.
+
+reject_many_pseudo_header_protocol(Config) ->
+ doc("An extended CONNECT request containing more than one "
+ "protocol component must be rejected with a H3_MESSAGE_ERROR "
+ "stream error. (RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"),
+ %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+ #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+ #{enable_connect_protocol := true} = Settings,
+ %% Send an extended CONNECT request with more than one :protocol pseudo-header.
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"CONNECT">>},
+ {<<":protocol">>, <<"websocket">>},
+ {<<":protocol">>, <<"mqtt">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":path">>, <<"/ws">>},
+ {<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+ {<<"sec-websocket-version">>, <<"13">>},
+ {<<"origin">>, <<"http://localhost">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedRequest)),
+ EncodedRequest
+ ]),
+ %% The stream should have been aborted.
+ #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+ ok.
+
+reject_unknown_pseudo_header_protocol(Config) ->
+ doc("An extended CONNECT request containing more than one "
+ "protocol component must be rejected with a 501 Not Implemented "
+ "response. (RFC9220, RFC8441 4)"),
+ %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+ #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+ #{enable_connect_protocol := true} = Settings,
+ %% Send an extended CONNECT request with an unknown protocol.
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"CONNECT">>},
+ {<<":protocol">>, <<"mqtt">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":path">>, <<"/ws">>},
+ {<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+ {<<"sec-websocket-version">>, <<"13">>},
+ {<<"origin">>, <<"http://localhost">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedRequest)),
+ EncodedRequest
+ ]),
+ %% The stream should have been rejected with a 501 Not Implemented.
+ #{headers := #{<<":status">> := <<"501">>}} = rfc9114_SUITE:do_receive_response(StreamRef),
+ ok.
+
+reject_invalid_pseudo_header_protocol(Config) ->
+ doc("An extended CONNECT request with an invalid protocol "
+ "component must be rejected with a 501 Not Implemented "
+ "response. (RFC9220, RFC8441 4)"),
+ %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+ #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+ #{enable_connect_protocol := true} = Settings,
+ %% Send an extended CONNECT request with an invalid protocol.
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"CONNECT">>},
+ {<<":protocol">>, <<"websocket mqtt">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":path">>, <<"/ws">>},
+ {<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+ {<<"sec-websocket-version">>, <<"13">>},
+ {<<"origin">>, <<"http://localhost">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedRequest)),
+ EncodedRequest
+ ]),
+ %% The stream should have been rejected with a 501 Not Implemented.
+ #{headers := #{<<":status">> := <<"501">>}} = rfc9114_SUITE:do_receive_response(StreamRef),
+ ok.
+
+reject_missing_pseudo_header_scheme(Config) ->
+ doc("An extended CONNECT request whtout a scheme component "
+ "must be rejected with a H3_MESSAGE_ERROR stream error. "
+ "(RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"),
+ %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+ #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+ #{enable_connect_protocol := true} = Settings,
+ %% Send an extended CONNECT request without a :scheme pseudo-header.
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"CONNECT">>},
+ {<<":protocol">>, <<"websocket">>},
+ {<<":path">>, <<"/ws">>},
+ {<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+ {<<"sec-websocket-version">>, <<"13">>},
+ {<<"origin">>, <<"http://localhost">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedRequest)),
+ EncodedRequest
+ ]),
+ %% The stream should have been aborted.
+ #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+ ok.
+
+reject_missing_pseudo_header_path(Config) ->
+ doc("An extended CONNECT request whtout a path component "
+ "must be rejected with a H3_MESSAGE_ERROR stream error. "
+ "(RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"),
+ %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+ #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+ #{enable_connect_protocol := true} = Settings,
+ %% Send an extended CONNECT request without a :path pseudo-header.
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"CONNECT">>},
+ {<<":protocol">>, <<"websocket">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+ {<<"sec-websocket-version">>, <<"13">>},
+ {<<"origin">>, <<"http://localhost">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedRequest)),
+ EncodedRequest
+ ]),
+ %% The stream should have been aborted.
+ #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+ ok.
+
+% On requests bearing the :protocol pseudo-header, the :authority
+% pseudo-header field is interpreted according to Section 8.1.2.3 of
+% [RFC7540] instead of Section 8.3 of [RFC7540]. In particular the
+% server MUST not make a new TCP connection to the host and port
+% indicated by the :authority.
+
+reject_missing_pseudo_header_authority(Config) ->
+ doc("An extended CONNECT request whtout an authority component "
+ "must be rejected with a H3_MESSAGE_ERROR stream error. "
+ "(RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"),
+ %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+ #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+ #{enable_connect_protocol := true} = Settings,
+ %% Send an extended CONNECT request without an :authority pseudo-header.
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"CONNECT">>},
+ {<<":protocol">>, <<"websocket">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":path">>, <<"/ws">>},
+ {<<"sec-websocket-version">>, <<"13">>},
+ {<<"origin">>, <<"http://localhost">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedRequest)),
+ EncodedRequest
+ ]),
+ %% The stream should have been aborted.
+ #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+ ok.
+
+% Using Extended CONNECT To Bootstrap The WebSocket Protocol.
+
+reject_missing_pseudo_header_protocol(Config) ->
+ doc("An extended CONNECT request whtout a protocol component "
+ "must be rejected with a H3_MESSAGE_ERROR stream error. "
+ "(RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"),
+ %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+ #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+ #{enable_connect_protocol := true} = Settings,
+ %% Send an extended CONNECT request without a :protocol pseudo-header.
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"CONNECT">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":path">>, <<"/ws">>},
+ {<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+ {<<"sec-websocket-version">>, <<"13">>},
+ {<<"origin">>, <<"http://localhost">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedRequest)),
+ EncodedRequest
+ ]),
+ %% The stream should have been aborted.
+ #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+ ok.
+
+% The scheme of the Target URI [RFC7230] MUST be https for wss schemed
+% WebSockets. HTTP/3 does not provide support for ws schemed WebSockets.
+% The websocket URI is still used for proxy autoconfiguration.
+
+reject_connection_header(Config) ->
+ doc("An extended CONNECT request with a connection header "
+ "must be rejected with a H3_MESSAGE_ERROR stream error. "
+ "(RFC9220, RFC8441 4, RFC9114 4.2, RFC9114 4.5, RFC9114 4.1.2)"),
+ %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+ #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+ #{enable_connect_protocol := true} = Settings,
+ %% Send an extended CONNECT request with a connection header.
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"CONNECT">>},
+ {<<":protocol">>, <<"websocket">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":path">>, <<"/ws">>},
+ {<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+ {<<"connection">>, <<"upgrade">>},
+ {<<"sec-websocket-version">>, <<"13">>},
+ {<<"origin">>, <<"http://localhost">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedRequest)),
+ EncodedRequest
+ ]),
+ %% The stream should have been aborted.
+ #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+ ok.
+
+reject_upgrade_header(Config) ->
+ doc("An extended CONNECT request with a upgrade header "
+ "must be rejected with a H3_MESSAGE_ERROR stream error. "
+ "(RFC9220, RFC8441 4, RFC9114 4.2, RFC9114 4.5, RFC9114 4.1.2)"),
+ %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+ #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+ #{enable_connect_protocol := true} = Settings,
+ %% Send an extended CONNECT request with a upgrade header.
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"CONNECT">>},
+ {<<":protocol">>, <<"websocket">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":path">>, <<"/ws">>},
+ {<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+ {<<"upgrade">>, <<"websocket">>},
+ {<<"sec-websocket-version">>, <<"13">>},
+ {<<"origin">>, <<"http://localhost">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedRequest)),
+ EncodedRequest
+ ]),
+ %% The stream should have been aborted.
+ #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+ ok.
+
+% After successfully processing the opening handshake the peers should
+% proceed with The WebSocket Protocol [RFC6455] using the HTTP/2 stream
+% from the CONNECT transaction as if it were the TCP connection
+% referred to in [RFC6455]. The state of the WebSocket connection at
+% this point is OPEN as defined by [RFC6455], Section 4.1.
+%% @todo I'm guessing we should test for things like RST_STREAM,
+%% closing the connection and others?
+
+% Examples.
+
+accept_handshake_when_enabled(Config) ->
+ doc("Confirm the example for Websocket over HTTP/2 works. (RFC9220, RFC8441 5.1)"),
+ %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+ #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+ #{enable_connect_protocol := true} = Settings,
+ %% Send a CONNECT :protocol request to upgrade the stream to Websocket.
+ {ok, StreamRef} = quicer:start_stream(Conn, #{}),
+ {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+ {<<":method">>, <<"CONNECT">>},
+ {<<":protocol">>, <<"websocket">>},
+ {<<":scheme">>, <<"https">>},
+ {<<":path">>, <<"/ws">>},
+ {<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+ {<<"sec-websocket-version">>, <<"13">>},
+ {<<"origin">>, <<"http://localhost">>}
+ ], 0, cow_qpack:init(encoder)),
+ {ok, _} = quicer:send(StreamRef, [
+ <<1>>, %% HEADERS frame.
+ cow_http3:encode_int(iolist_size(EncodedRequest)),
+ EncodedRequest
+ ]),
+ %% Receive a 200 response.
+ {ok, Data} = rfc9114_SUITE:do_receive_data(StreamRef),
+ {HLenEnc, HLenBits} = rfc9114_SUITE:do_guess_int_encoding(Data),
+ <<
+ 1, %% HEADERS frame.
+ HLenEnc:2, HLen:HLenBits,
+ EncodedResponse:HLen/bytes
+ >> = Data,
+ {ok, DecodedResponse, _DecData, _DecSt}
+ = cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init(decoder)),
+ #{<<":status">> := <<"200">>} = maps:from_list(DecodedResponse),
+ %% Masked text hello echoed back clear by the server.
+ Mask = 16#37fa213d,
+ MaskedHello = ws_SUITE:do_mask(<<"Hello">>, Mask, <<>>),
+ {ok, _} = quicer:send(StreamRef, cow_http3:data(
+ <<1:1, 0:3, 1:4, 1:1, 5:7, Mask:32, MaskedHello/binary>>)),
+ {ok, WsData} = rfc9114_SUITE:do_receive_data(StreamRef),
+ <<
+ 0, %% DATA frame.
+ 0:2, 7:6, %% Length (2 bytes header + "Hello").
+ 1:1, 0:3, 1:4, 0:1, 5:7, "Hello" %% Websocket frame.
+ >> = WsData,
+ ok.
+
+%% Closing a Websocket stream.
+
+% The HTTP/3 stream closure is also analogous to the TCP connection
+% closure of [RFC6455]. Orderly TCP-level closures are represented
+% as a FIN bit on the stream (Section 4.4 of [HTTP/3]). RST exceptions
+% are represented with a stream error (Section 8 of [HTTP/3]) of type
+% H3_REQUEST_CANCELLED (Section 8.1 of [HTTP/3]).
+
+%% @todo client close frame with FIN
+%% @todo server close frame with FIN
+%% @todo client other frame with FIN
+%% @todo server other frame with FIN
+%% @todo client close connection
diff --git a/test/security_SUITE.erl b/test/security_SUITE.erl
index f06cec5..944c491 100644
--- a/test/security_SUITE.erl
+++ b/test/security_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2018, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -33,10 +33,12 @@ groups() ->
Tests = [nc_rand, nc_zero],
H1Tests = [slowloris, slowloris_chunks],
H2CTests = [
+ http2_cancel_flood,
http2_data_dribble,
http2_empty_frame_flooding_data,
http2_empty_frame_flooding_headers_continuation,
http2_empty_frame_flooding_push_promise,
+ http2_infinite_continuations,
http2_ping_flood,
http2_reset_flood,
http2_settings_flood,
@@ -47,10 +49,12 @@ groups() ->
{https, [parallel], Tests ++ H1Tests},
{h2, [parallel], Tests},
{h2c, [parallel], Tests ++ H2CTests},
+ {h3, [], Tests},
{http_compress, [parallel], Tests ++ H1Tests},
{https_compress, [parallel], Tests ++ H1Tests},
{h2_compress, [parallel], Tests},
- {h2c_compress, [parallel], Tests ++ H2CTests}
+ {h2c_compress, [parallel], Tests ++ H2CTests},
+ {h3_compress, [], Tests}
].
init_per_suite(Config) ->
@@ -64,7 +68,7 @@ init_per_group(Name, Config) ->
cowboy_test:init_common_groups(Name, Config, ?MODULE).
end_per_group(Name, _) ->
- cowboy:stop_listener(Name).
+ cowboy_test:stop_group(Name).
%% Routes.
@@ -72,12 +76,51 @@ init_dispatch(_) ->
cowboy_router:compile([{"localhost", [
{"/", hello_h, []},
{"/echo/:key", echo_h, []},
+ {"/delay_hello", delay_hello_h, 1000},
{"/long_polling", long_polling_h, []},
{"/resp/:key[/:arg]", resp_h, []}
]}]).
%% Tests.
+http2_cancel_flood(Config) ->
+ doc("Confirm that Cowboy detects the rapid reset attack. (CVE-2023-44487)"),
+ do_http2_cancel_flood(Config, 1, 500),
+ do_http2_cancel_flood(Config, 10, 50),
+ do_http2_cancel_flood(Config, 500, 1),
+ ok.
+
+do_http2_cancel_flood(Config, NumStreamsPerBatch, NumBatches) ->
+ {ok, Socket} = rfc7540_SUITE:do_handshake(Config),
+ {HeadersBlock, _} = cow_hpack:encode([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"http">>},
+ {<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+ {<<":path">>, <<"/delay_hello">>}
+ ]),
+ AllStreamIDs = lists:seq(1, NumBatches * NumStreamsPerBatch * 2, 2),
+ _ = lists:foldl(
+ fun (_BatchNumber, AvailableStreamIDs) ->
+ %% Take a bunch of IDs from the available stream IDs.
+ %% Send HEADERS for all these and then cancel them.
+ {IDs, RemainingStreamIDs} = lists:split(NumStreamsPerBatch, AvailableStreamIDs),
+ _ = gen_tcp:send(Socket, [cow_http2:headers(ID, fin, HeadersBlock) || ID <- IDs]),
+ _ = gen_tcp:send(Socket, [<<4:24, 3:8, 0:8, ID:32, 8:32>> || ID <- IDs]),
+ RemainingStreamIDs
+ end,
+ AllStreamIDs,
+ lists:seq(1, NumBatches, 1)),
+ %% When Cowboy detects a flood it must close the connection.
+ case gen_tcp:recv(Socket, 17, 6000) of
+ {ok, <<_:24, 7:8, 0:8, 0:32, _LastStreamId:32, 11:32>>} ->
+ %% GOAWAY with error code 11 = ENHANCE_YOUR_CALM.
+ ok;
+ %% We also accept the connection being closed immediately,
+ %% which may happen because we send the GOAWAY right before closing.
+ {error, closed} ->
+ ok
+ end.
+
http2_data_dribble(Config) ->
doc("Request a very large response then update the window 1 byte at a time. (CVE-2019-9511)"),
{ok, Socket} = rfc7540_SUITE:do_handshake(Config),
@@ -179,6 +222,38 @@ http2_empty_frame_flooding_push_promise(Config) ->
{ok, <<_:24, 7:8, _:72, 1:32>>} = gen_tcp:recv(Socket, 17, 6000),
ok.
+http2_infinite_continuations(Config) ->
+ doc("Confirm that Cowboy rejects CONTINUATION frames when the "
+ "total size of HEADERS + CONTINUATION(s) exceeds the limit. (VU#421644)"),
+ {ok, Socket} = rfc7540_SUITE:do_handshake(Config),
+ %% Send a HEADERS frame followed by a large number
+ %% of continuation frames.
+ {HeadersBlock, _} = cow_hpack:encode([
+ {<<":method">>, <<"GET">>},
+ {<<":scheme">>, <<"http">>},
+ {<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+ {<<":path">>, <<"/">>}
+ ]),
+ HeadersBlockLen = iolist_size(HeadersBlock),
+ ok = gen_tcp:send(Socket, [
+ %% HEADERS frame.
+ <<
+ HeadersBlockLen:24, 1:8, 0:5,
+ 0:1, %% END_HEADERS
+ 0:1,
+ 1:1, %% END_STREAM
+ 0:1,
+ 1:31 %% Stream ID.
+ >>,
+ HeadersBlock,
+ %% CONTINUATION frames.
+ [<<1024:24, 9:8, 0:8, 0:1, 1:31, 0:1024/unit:8>>
+ || _ <- lists:seq(1, 100)]
+ ]),
+ %% Receive an ENHANCE_YOUR_CALM connection error.
+ {ok, <<_:24, 7:8, _:72, 11:32>>} = gen_tcp:recv(Socket, 17, 6000),
+ ok.
+
%% @todo http2_internal_data_buffering(Config) -> I do not know how to test this.
% doc("Request many very large responses, with a larger than necessary window size, "
% "but do not attempt to read from the socket. (CVE-2019-9517)"),
diff --git a/test/static_handler_SUITE.erl b/test/static_handler_SUITE.erl
index 71a9619..9620f66 100644
--- a/test/static_handler_SUITE.erl
+++ b/test/static_handler_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2016-2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2016-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -20,6 +20,12 @@
-import(ct_helper, [doc/1]).
-import(cowboy_test, [gun_open/1]).
+%% Import useful functions from req_SUITE.
+%% @todo Maybe move these functions to cowboy_test.
+-import(req_SUITE, [do_get/2]).
+-import(req_SUITE, [do_get/3]).
+-import(req_SUITE, [do_maybe_h3_error3/1]).
+
%% ct.
all() ->
@@ -39,16 +45,22 @@ groups() ->
{dir, [parallel], DirTests},
{priv_dir, [parallel], DirTests}
],
+ GroupTestsNoParallel = OtherTests ++ [
+ {dir, [], DirTests},
+ {priv_dir, [], DirTests}
+ ],
[
{http, [parallel], GroupTests},
{https, [parallel], GroupTests},
{h2, [parallel], GroupTests},
{h2c, [parallel], GroupTests},
+ {h3, [], GroupTestsNoParallel}, %% @todo Enable parallel when it works better.
{http_compress, [parallel], GroupTests},
{https_compress, [parallel], GroupTests},
{h2_compress, [parallel], GroupTests},
{h2c_compress, [parallel], GroupTests},
- %% No real need to test sendfile disabled against https or h2.
+ {h3_compress, [], GroupTestsNoParallel}, %% @todo Enable parallel when it works better.
+ %% No real need to test sendfile disabled against https, h2 or h3.
{http_no_sendfile, [parallel], GroupTests},
{h2c_no_sendfile, [parallel], GroupTests}
].
@@ -65,7 +77,7 @@ init_per_suite(Config) ->
%% Add a simple Erlang application archive containing one file
%% in its priv directory.
true = code:add_pathz(filename:join(
- [config(data_dir, Config), "static_files_app", "ebin"])),
+ [config(data_dir, Config), "static_files_app.ez", "static_files_app", "ebin"])),
ok = application:load(static_files_app),
%% A special folder contains files of 1 character from 1 to 127
%% excluding / and \ as they are always rejected.
@@ -116,6 +128,17 @@ init_per_group(Name=h2c_no_sendfile, Config) ->
sendfile => false
}, [{flavor, vanilla}|Config]),
lists:keyreplace(protocol, 1, Config1, {protocol, http2});
+init_per_group(Name=h3, Config) ->
+ cowboy_test:init_http3(Name, #{
+ env => #{dispatch => init_dispatch(Config)},
+ middlewares => [?MODULE, cowboy_router, cowboy_handler]
+ }, [{flavor, vanilla}|Config]);
+init_per_group(Name=h3_compress, Config) ->
+ cowboy_test:init_http3(Name, #{
+ env => #{dispatch => init_dispatch(Config)},
+ middlewares => [?MODULE, cowboy_router, cowboy_handler],
+ stream_handlers => [cowboy_compress_h, cowboy_stream_h]
+ }, [{flavor, vanilla}|Config]);
init_per_group(Name, Config) ->
Config1 = cowboy_test:init_common_groups(Name, Config, ?MODULE),
Opts = ranch:get_protocol_options(Name),
@@ -129,7 +152,7 @@ end_per_group(dir, _) ->
end_per_group(priv_dir, _) ->
ok;
end_per_group(Name, _) ->
- cowboy:stop_listener(Name).
+ cowboy_test:stop_group(Name).
%% Large file.
@@ -248,25 +271,11 @@ do_mime_custom(Path) ->
_ -> {<<"application">>, <<"octet-stream">>, []}
end.
-do_get(Path, Config) ->
- do_get(Path, [], Config).
-
-do_get(Path, ReqHeaders, Config) ->
- ConnPid = gun_open(Config),
- Ref = gun:get(ConnPid, Path, [{<<"accept-encoding">>, <<"gzip">>}|ReqHeaders]),
- {response, IsFin, Status, RespHeaders} = gun:await(ConnPid, Ref),
- {ok, Body} = case IsFin of
- nofin -> gun:await_body(ConnPid, Ref);
- fin -> {ok, <<>>}
- end,
- gun:close(ConnPid),
- {Status, RespHeaders, Body}.
-
%% Tests.
bad(Config) ->
doc("Bad cowboy_static options: not a tuple."),
- {500, _, _} = do_get("/bad", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/bad", Config)),
ok.
bad_dir_path(Config) ->
@@ -276,7 +285,7 @@ bad_dir_path(Config) ->
bad_dir_route(Config) ->
doc("Bad cowboy_static options: missing [...] in route."),
- {500, _, _} = do_get("/bad/dir/route", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/bad/dir/route", Config)),
ok.
bad_file_in_priv_dir_in_ez_archive(Config) ->
@@ -291,27 +300,27 @@ bad_file_path(Config) ->
bad_options(Config) ->
doc("Bad cowboy_static extra options: not a list."),
- {500, _, _} = do_get("/bad/options", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/bad/options", Config)),
ok.
bad_options_charset(Config) ->
doc("Bad cowboy_static extra options: invalid charset option."),
- {500, _, _} = do_get("/bad/options/charset", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/bad/options/charset", Config)),
ok.
bad_options_etag(Config) ->
doc("Bad cowboy_static extra options: invalid etag option."),
- {500, _, _} = do_get("/bad/options/etag", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/bad/options/etag", Config)),
ok.
bad_options_mime(Config) ->
doc("Bad cowboy_static extra options: invalid mimetypes option."),
- {500, _, _} = do_get("/bad/options/mime", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/bad/options/mime", Config)),
ok.
bad_priv_dir_app(Config) ->
doc("Bad cowboy_static options: wrong application name."),
- {500, _, _} = do_get("/bad/priv_dir/app/style.css", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/bad/priv_dir/app/style.css", Config)),
ok.
bad_priv_dir_in_ez_archive(Config) ->
@@ -331,12 +340,12 @@ bad_priv_dir_path(Config) ->
bad_priv_dir_route(Config) ->
doc("Bad cowboy_static options: missing [...] in route."),
- {500, _, _} = do_get("/bad/priv_dir/route", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/bad/priv_dir/route", Config)),
ok.
bad_priv_file_app(Config) ->
doc("Bad cowboy_static options: wrong application name."),
- {500, _, _} = do_get("/bad/priv_file/app", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/bad/priv_file/app", Config)),
ok.
bad_priv_file_in_ez_archive(Config) ->
@@ -535,7 +544,7 @@ dir_unknown(Config) ->
etag_crash(Config) ->
doc("Get a file with a crashing etag function."),
- {500, _, _} = do_get("/etag/crash", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/etag/crash", Config)),
ok.
etag_custom(Config) ->
@@ -813,7 +822,7 @@ mime_all_uppercase(Config) ->
mime_crash(Config) ->
doc("Get a file with a crashing mimetype function."),
- {500, _, _} = do_get("/mime/crash/style.css", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/mime/crash/style.css", Config)),
ok.
mime_custom_cowboy(Config) ->
@@ -848,7 +857,7 @@ mime_hardcode_tuple(Config) ->
charset_crash(Config) ->
doc("Get a file with a crashing charset function."),
- {500, _, _} = do_get("/charset/crash/style.css", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/charset/crash/style.css", Config)),
ok.
charset_custom_cowboy(Config) ->
@@ -933,7 +942,8 @@ unicode_basic_error(Config) ->
%% # and ? indicate fragment and query components
%% and are therefore not part of the path.
http -> "\r\s#?";
- http2 -> "#?"
+ http2 -> "#?";
+ http3 -> "#?"
end,
_ = [case do_get("/char/" ++ [C], Config) of
{400, _, _} -> ok;
diff --git a/test/stream_handler_SUITE.erl b/test/stream_handler_SUITE.erl
index 46a05b2..f8e2200 100644
--- a/test/stream_handler_SUITE.erl
+++ b/test/stream_handler_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -31,50 +31,42 @@ groups() ->
%% We set this module as a logger in order to silence expected errors.
init_per_group(Name = http, Config) ->
- cowboy_test:init_http(Name, #{
- logger => ?MODULE,
- stream_handlers => [stream_handler_h]
- }, Config);
+ cowboy_test:init_http(Name, init_plain_opts(), Config);
init_per_group(Name = https, Config) ->
- cowboy_test:init_https(Name, #{
- logger => ?MODULE,
- stream_handlers => [stream_handler_h]
- }, Config);
+ cowboy_test:init_https(Name, init_plain_opts(), Config);
init_per_group(Name = h2, Config) ->
- cowboy_test:init_http2(Name, #{
- logger => ?MODULE,
- stream_handlers => [stream_handler_h]
- }, Config);
+ cowboy_test:init_http2(Name, init_plain_opts(), Config);
init_per_group(Name = h2c, Config) ->
- Config1 = cowboy_test:init_http(Name, #{
- logger => ?MODULE,
- stream_handlers => [stream_handler_h]
- }, Config),
+ Config1 = cowboy_test:init_http(Name, init_plain_opts(), Config),
lists:keyreplace(protocol, 1, Config1, {protocol, http2});
+init_per_group(Name = h3, Config) ->
+ cowboy_test:init_http3(Name, init_plain_opts(), Config);
init_per_group(Name = http_compress, Config) ->
- cowboy_test:init_http(Name, #{
- logger => ?MODULE,
- stream_handlers => [cowboy_compress_h, stream_handler_h]
- }, Config);
+ cowboy_test:init_http(Name, init_compress_opts(), Config);
init_per_group(Name = https_compress, Config) ->
- cowboy_test:init_https(Name, #{
- logger => ?MODULE,
- stream_handlers => [cowboy_compress_h, stream_handler_h]
- }, Config);
+ cowboy_test:init_https(Name, init_compress_opts(), Config);
init_per_group(Name = h2_compress, Config) ->
- cowboy_test:init_http2(Name, #{
- logger => ?MODULE,
- stream_handlers => [cowboy_compress_h, stream_handler_h]
- }, Config);
+ cowboy_test:init_http2(Name, init_compress_opts(), Config);
init_per_group(Name = h2c_compress, Config) ->
- Config1 = cowboy_test:init_http(Name, #{
- logger => ?MODULE,
- stream_handlers => [cowboy_compress_h, stream_handler_h]
- }, Config),
- lists:keyreplace(protocol, 1, Config1, {protocol, http2}).
+ Config1 = cowboy_test:init_http(Name, init_compress_opts(), Config),
+ lists:keyreplace(protocol, 1, Config1, {protocol, http2});
+init_per_group(Name = h3_compress, Config) ->
+ cowboy_test:init_http3(Name, init_compress_opts(), Config).
end_per_group(Name, _) ->
- cowboy:stop_listener(Name).
+ cowboy_test:stop_group(Name).
+
+init_plain_opts() ->
+ #{
+ logger => ?MODULE,
+ stream_handlers => [stream_handler_h]
+ }.
+
+init_compress_opts() ->
+ #{
+ logger => ?MODULE,
+ stream_handlers => [cowboy_compress_h, stream_handler_h]
+ }.
%% Logger function silencing the expected crashes.
@@ -99,15 +91,20 @@ crash_in_init(Config) ->
%% Confirm terminate/3 is NOT called. We have no state to give to it.
receive {Self, Pid, terminate, _, _, _} -> error(terminate) after 1000 -> ok end,
%% Confirm early_error/5 is called in HTTP/1.1's case.
- %% HTTP/2 does not send a response back so there is no early_error call.
+ %% HTTP/2 and HTTP/3 do not send a response back so there is no early_error call.
case config(protocol, Config) of
http -> receive {Self, Pid, early_error, _, _, _, _, _} -> ok after 1000 -> error(timeout) end;
- http2 -> ok
+ http2 -> ok;
+ http3 -> ok
end,
- %% Receive a 500 error response.
- case gun:await(ConnPid, Ref) of
- {response, fin, 500, _} -> ok;
- {error, {stream_error, {stream_error, internal_error, _}}} -> ok
+ do_await_internal_error(ConnPid, Ref, Config).
+
+do_await_internal_error(ConnPid, Ref, Config) ->
+ Protocol = config(protocol, Config),
+ case {Protocol, gun:await(ConnPid, Ref)} of
+ {http, {response, fin, 500, _}} -> ok;
+ {http2, {error, {stream_error, {stream_error, internal_error, _}}}} -> ok;
+ {http3, {error, {stream_error, {stream_error, h3_internal_error, _}}}} -> ok
end.
crash_in_data(Config) ->
@@ -126,11 +123,7 @@ crash_in_data(Config) ->
gun:data(ConnPid, Ref, fin, <<"Hello!">>),
%% Confirm terminate/3 is called, indicating the stream ended.
receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end,
- %% Receive a 500 error response.
- case gun:await(ConnPid, Ref) of
- {response, fin, 500, _} -> ok;
- {error, {stream_error, {stream_error, internal_error, _}}} -> ok
- end.
+ do_await_internal_error(ConnPid, Ref, Config).
crash_in_info(Config) ->
doc("Confirm an error is sent when a stream handler crashes in info/3."),
@@ -144,14 +137,14 @@ crash_in_info(Config) ->
%% Confirm init/3 is called.
Pid = receive {Self, P, init, _, _, _} -> P after 1000 -> error(timeout) end,
%% Send a message to make the stream handler crash.
- Pid ! {{Pid, 1}, crash},
+ StreamID = case config(protocol, Config) of
+ http3 -> 0;
+ _ -> 1
+ end,
+ Pid ! {{Pid, StreamID}, crash},
%% Confirm terminate/3 is called, indicating the stream ended.
receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end,
- %% Receive a 500 error response.
- case gun:await(ConnPid, Ref) of
- {response, fin, 500, _} -> ok;
- {error, {stream_error, {stream_error, internal_error, _}}} -> ok
- end.
+ do_await_internal_error(ConnPid, Ref, Config).
crash_in_terminate(Config) ->
doc("Confirm the state is correct when a stream handler crashes in terminate/3."),
@@ -185,10 +178,12 @@ crash_in_terminate(Config) ->
{ok, <<"Hello world!">>} = gun:await_body(ConnPid, Ref2),
ok.
+%% @todo The callbacks ARE used for HTTP/2 and HTTP/3 CONNECT/TRACE requests.
crash_in_early_error(Config) ->
case config(protocol, Config) of
http -> do_crash_in_early_error(Config);
- http2 -> doc("The callback early_error/5 is not currently used for HTTP/2.")
+ http2 -> doc("The callback early_error/5 is not currently used for HTTP/2.");
+ http3 -> doc("The callback early_error/5 is not currently used for HTTP/3.")
end.
do_crash_in_early_error(Config) ->
@@ -225,10 +220,12 @@ do_crash_in_early_error(Config) ->
{response, fin, 500, _} = gun:await(ConnPid, Ref2),
ok.
+%% @todo The callbacks ARE used for HTTP/2 and HTTP/3 CONNECT/TRACE requests.
crash_in_early_error_fatal(Config) ->
case config(protocol, Config) of
http -> do_crash_in_early_error_fatal(Config);
- http2 -> doc("The callback early_error/5 is not currently used for HTTP/2.")
+ http2 -> doc("The callback early_error/5 is not currently used for HTTP/2.");
+ http3 -> doc("The callback early_error/5 is not currently used for HTTP/3.")
end.
do_crash_in_early_error_fatal(Config) ->
@@ -262,7 +259,8 @@ early_error_stream_error_reason(Config) ->
%% reason in both protocols.
{Method, Headers, Status, Error} = case config(protocol, Config) of
http -> {<<"GET">>, [{<<"host">>, <<"host:port">>}], 400, protocol_error};
- http2 -> {<<"TRACE">>, [], 501, no_error}
+ http2 -> {<<"TRACE">>, [], 501, no_error};
+ http3 -> {<<"TRACE">>, [], 501, h3_no_error}
end,
Ref = gun:request(ConnPid, Method, "/long_polling", [
{<<"accept-encoding">>, <<"gzip">>},
@@ -293,7 +291,7 @@ flow_after_body_fully_read(Config) ->
%% Receive a 200 response, sent after the second flow command,
%% confirming that the flow command was accepted.
{response, _, 200, _} = gun:await(ConnPid, Ref),
- ok.
+ gun:close(ConnPid).
set_options_ignore_unknown(Config) ->
doc("Confirm that unknown options are ignored when using the set_options commands."),
@@ -355,11 +353,20 @@ shutdown_on_socket_close(Config) ->
Spawn ! {Self, ready},
%% Close the socket.
ok = gun:close(ConnPid),
- %% Confirm terminate/3 is called, indicating the stream ended.
- receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end,
- %% Confirm we receive a DOWN message for the child process.
- receive {'DOWN', MRef, process, Spawn, shutdown} -> ok after 1000 -> error(timeout) end,
- ok.
+ Protocol = config(protocol, Config),
+ try
+ %% Confirm terminate/3 is called, indicating the stream ended.
+ receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end,
+ %% Confirm we receive a DOWN message for the child process.
+ receive {'DOWN', MRef, process, Spawn, shutdown} -> ok after 1000 -> error(timeout) end,
+ ok
+ catch error:timeout when Protocol =:= http3 ->
+ %% @todo Figure out why this happens. Could be a timing issue
+ %% or a legitimate bug. I suspect that the server just
+ %% doesn't receive the GOAWAY frame from Gun because
+ %% Gun is too quick to close the connection.
+ shutdown_on_socket_close(Config)
+ end.
shutdown_timeout_on_stream_stop(Config) ->
doc("Confirm supervised processes are killed "
@@ -406,33 +413,45 @@ shutdown_timeout_on_socket_close(Config) ->
Spawn ! {Self, ready},
%% Close the socket.
ok = gun:close(ConnPid),
- %% Confirm terminate/3 is called, indicating the stream ended.
- receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end,
- %% We should NOT receive a DOWN message immediately.
- receive {'DOWN', MRef, process, Spawn, killed} -> error(killed) after 1500 -> ok end,
- %% We should received it now.
- receive {'DOWN', MRef, process, Spawn, killed} -> ok after 1000 -> error(timeout) end,
- ok.
+ Protocol = config(protocol, Config),
+ try
+ %% Confirm terminate/3 is called, indicating the stream ended.
+ receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end,
+ %% We should NOT receive a DOWN message immediately.
+ receive {'DOWN', MRef, process, Spawn, killed} -> error(killed) after 1500 -> ok end,
+ %% We should receive it now.
+ receive {'DOWN', MRef, process, Spawn, killed} -> ok after 1000 -> error(timeout) end,
+ ok
+ catch error:timeout when Protocol =:= http3 ->
+ %% @todo Figure out why this happens. Could be a timing issue
+ %% or a legitimate bug. I suspect that the server just
+ %% doesn't receive the GOAWAY frame from Gun because
+ %% Gun is too quick to close the connection.
+ shutdown_timeout_on_socket_close(Config)
+ end.
switch_protocol_after_headers(Config) ->
case config(protocol, Config) of
http -> do_switch_protocol_after_response(
<<"switch_protocol_after_headers">>, Config);
- http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.")
+ http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.");
+ http3 -> doc("The switch_protocol command is not currently supported for HTTP/3.")
end.
switch_protocol_after_headers_data(Config) ->
case config(protocol, Config) of
http -> do_switch_protocol_after_response(
<<"switch_protocol_after_headers_data">>, Config);
- http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.")
+ http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.");
+ http3 -> doc("The switch_protocol command is not currently supported for HTTP/3.")
end.
switch_protocol_after_response(Config) ->
case config(protocol, Config) of
http -> do_switch_protocol_after_response(
<<"switch_protocol_after_response">>, Config);
- http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.")
+ http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.");
+ http3 -> doc("The switch_protocol command is not currently supported for HTTP/3.")
end.
do_switch_protocol_after_response(TestCase, Config) ->
@@ -502,7 +521,12 @@ terminate_on_stop(Config) ->
{response, fin, 204, _} = gun:await(ConnPid, Ref),
%% Confirm the stream is still alive even though we
%% received the response fully, and tell it to stop.
- Pid ! {{Pid, 1}, please_stop},
+ StreamID = case config(protocol, Config) of
+ http -> 1;
+ http2 -> 1;
+ http3 -> 0
+ end,
+ Pid ! {{Pid, StreamID}, please_stop},
receive {Self, Pid, info, _, please_stop, _} -> ok after 1000 -> error(timeout) end,
%% Confirm terminate/3 is called.
receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end,
@@ -511,7 +535,8 @@ terminate_on_stop(Config) ->
terminate_on_switch_protocol(Config) ->
case config(protocol, Config) of
http -> do_terminate_on_switch_protocol(Config);
- http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.")
+ http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.");
+ http3 -> doc("The switch_protocol command is not currently supported for HTTP/3.")
end.
do_terminate_on_switch_protocol(Config) ->
diff --git a/test/sys_SUITE.erl b/test/sys_SUITE.erl
index 175219c..2feb716 100644
--- a/test/sys_SUITE.erl
+++ b/test/sys_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2018, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -22,7 +22,6 @@
-import(ct_helper, [get_remote_pid_tcp/1]).
-import(ct_helper, [get_remote_pid_tls/1]).
-import(ct_helper, [is_process_down/1]).
--import(cowboy_test, [gun_open/1]).
all() ->
[{group, sys}].
@@ -109,7 +108,8 @@ bad_system_from_h1(Config) ->
bad_system_from_h2(Config) ->
doc("h2: Sending a system message with a bad From value results in a process crash."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
%% Skip the SETTINGS frame.
{ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000),
timer:sleep(100),
@@ -176,7 +176,8 @@ bad_system_message_h1(Config) ->
bad_system_message_h2(Config) ->
doc("h2: Sending a system message with a bad Request value results in an error."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
%% Skip the SETTINGS frame.
{ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000),
timer:sleep(100),
@@ -252,7 +253,8 @@ good_system_message_h1(Config) ->
good_system_message_h2(Config) ->
doc("h2: System messages are handled properly."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
%% Skip the SETTINGS frame.
{ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000),
timer:sleep(100),
@@ -336,7 +338,8 @@ trap_exit_parent_exit_h2(Config) ->
doc("h2: A process trapping exits must stop when receiving "
"an 'EXIT' message from its parent."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
%% Skip the SETTINGS frame.
{ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000),
timer:sleep(100),
@@ -408,7 +411,8 @@ trap_exit_other_exit_h2(Config) ->
doc("h2: A process trapping exits must ignore "
"'EXIT' messages from unknown processes."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
do_http2_handshake(Socket),
Pid = get_remote_pid_tls(Socket),
Pid ! {'EXIT', self(), {shutdown, ?MODULE}},
@@ -526,7 +530,8 @@ sys_change_code_h1(Config) ->
sys_change_code_h2(Config) ->
doc("h2: The sys:change_code/4 function works as expected."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
do_http2_handshake(Socket),
Pid = get_remote_pid_tls(Socket),
%% Suspend the process and try to get a request in. The
@@ -609,7 +614,8 @@ sys_get_state_h1(Config) ->
sys_get_state_h2(Config) ->
doc("h2: The sys:get_state/1 function works as expected."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
%% Skip the SETTINGS frame.
{ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000),
timer:sleep(100),
@@ -653,7 +659,7 @@ sys_get_state_loop(Config) ->
timer:sleep(100),
SupPid = get_remote_pid_tcp(Socket),
[{_, Pid, _, _}] = supervisor:which_children(SupPid),
- {Req, Env, long_polling_sys_h, undefined} = sys:get_state(Pid),
+ {Req, Env, long_polling_sys_h, undefined, infinity} = sys:get_state(Pid),
#{pid := _, streamid := _} = Req,
#{dispatch := _} = Env,
ok.
@@ -671,7 +677,8 @@ sys_get_status_h1(Config) ->
sys_get_status_h2(Config) ->
doc("h2: The sys:get_status/1 function works as expected."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
%% Skip the SETTINGS frame.
{ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000),
timer:sleep(100),
@@ -732,7 +739,8 @@ sys_replace_state_h1(Config) ->
sys_replace_state_h2(Config) ->
doc("h2: The sys:replace_state/2 function works as expected."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
%% Skip the SETTINGS frame.
{ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000),
timer:sleep(100),
@@ -776,7 +784,7 @@ sys_replace_state_loop(Config) ->
timer:sleep(100),
SupPid = get_remote_pid_tcp(Socket),
[{_, Pid, _, _}] = supervisor:which_children(SupPid),
- {Req, Env, long_polling_sys_h, undefined} = sys:replace_state(Pid, fun(S) -> S end),
+ {Req, Env, long_polling_sys_h, undefined, infinity} = sys:replace_state(Pid, fun(S) -> S end),
#{pid := _, streamid := _} = Req,
#{dispatch := _} = Env,
ok.
@@ -801,7 +809,8 @@ sys_suspend_and_resume_h1(Config) ->
sys_suspend_and_resume_h2(Config) ->
doc("h2: The sys:suspend/1 and sys:resume/1 functions work as expected."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
do_http2_handshake(Socket),
Pid = get_remote_pid_tls(Socket),
%% Suspend the process and try to get a request in. The
@@ -880,7 +889,8 @@ sys_terminate_h1(Config) ->
sys_terminate_h2(Config) ->
doc("h2: The sys:terminate/2,3 function works as expected."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
%% Skip the SETTINGS frame.
{ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000),
timer:sleep(100),
@@ -983,7 +993,8 @@ supervisor_count_children_h1(Config) ->
supervisor_count_children_h2(Config) ->
doc("h2: The function supervisor:count_children/1 must work."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
do_http2_handshake(Socket),
Pid = get_remote_pid_tls(Socket),
%% No request was sent so there's no children.
@@ -1055,7 +1066,8 @@ supervisor_delete_child_not_found_h1(Config) ->
supervisor_delete_child_not_found_h2(Config) ->
doc("h2: The function supervisor:delete_child/2 must return {error, not_found}."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
do_http2_handshake(Socket),
Pid = get_remote_pid_tls(Socket),
%% When no children exist.
@@ -1114,7 +1126,8 @@ supervisor_get_childspec_not_found_h1(Config) ->
supervisor_get_childspec_not_found_h2(Config) ->
doc("h2: The function supervisor:get_childspec/2 must return {error, not_found}."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
do_http2_handshake(Socket),
Pid = get_remote_pid_tls(Socket),
%% When no children exist.
@@ -1173,7 +1186,8 @@ supervisor_restart_child_not_found_h1(Config) ->
supervisor_restart_child_not_found_h2(Config) ->
doc("h2: The function supervisor:restart_child/2 must return {error, not_found}."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
do_http2_handshake(Socket),
Pid = get_remote_pid_tls(Socket),
%% When no children exist.
@@ -1227,7 +1241,8 @@ supervisor_start_child_not_found_h1(Config) ->
supervisor_start_child_not_found_h2(Config) ->
doc("h2: The function supervisor:start_child/2 must return {error, start_child_disabled}."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
do_http2_handshake(Socket),
Pid = get_remote_pid_tls(Socket),
{error, start_child_disabled} = supervisor:start_child(Pid, #{
@@ -1281,7 +1296,8 @@ supervisor_terminate_child_not_found_h1(Config) ->
supervisor_terminate_child_not_found_h2(Config) ->
doc("h2: The function supervisor:terminate_child/2 must return {error, not_found}."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
do_http2_handshake(Socket),
Pid = get_remote_pid_tls(Socket),
%% When no children exist.
@@ -1344,7 +1360,8 @@ supervisor_which_children_h1(Config) ->
supervisor_which_children_h2(Config) ->
doc("h2: The function supervisor:which_children/1 must work."),
{ok, Socket} = ssl:connect("localhost", config(tls_port, Config),
- [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]),
+ [{alpn_advertised_protocols, [<<"h2">>]},
+ {active, false}, binary|config(tls_opts, Config)]),
do_http2_handshake(Socket),
Pid = get_remote_pid_tls(Socket),
%% No request was sent so there's no children.
diff --git a/test/tracer_SUITE.erl b/test/tracer_SUITE.erl
index d5683a0..af1f8f3 100644
--- a/test/tracer_SUITE.erl
+++ b/test/tracer_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -29,13 +29,15 @@ suite() ->
%% We initialize trace patterns here. Appropriate would be in
%% init_per_suite/1, but this works just as well.
all() ->
- case code:is_module_native(?MODULE) of
- true ->
- {skip, "The Cowboy tracer is not compatible with native code."};
- false ->
- cowboy_tracer_h:set_trace_patterns(),
- cowboy_test:common_all()
- end.
+ %% @todo Implement these tests for HTTP/3.
+ cowboy_test:common_all() -- [{group, h3}, {group, h3_compress}].
+
+init_per_suite(Config) ->
+ cowboy_tracer_h:set_trace_patterns(),
+ Config.
+
+end_per_suite(_) ->
+ ok.
%% We want tests for each group to execute sequentially
%% because we need to modify the protocol options. Groups
diff --git a/test/ws_SUITE.erl b/test/ws_SUITE.erl
index 9abeaca..3b74339 100644
--- a/test/ws_SUITE.erl
+++ b/test/ws_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2011-2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2011-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -68,7 +68,8 @@ init_dispatch() ->
{"/ws_timeout_cancel", ws_timeout_cancel, []},
{"/ws_max_frame_size", ws_max_frame_size, []},
{"/ws_deflate_opts", ws_deflate_opts_h, []},
- {"/ws_dont_validate_utf8", ws_dont_validate_utf8_h, []}
+ {"/ws_dont_validate_utf8", ws_dont_validate_utf8_h, []},
+ {"/ws_ping", ws_ping_h, []}
]}
]).
@@ -341,6 +342,7 @@ ws_first_frame_with_handshake(Config) ->
{ok, <<1:1, 0:3, 1:4, 0:1, 5:7, "Hello">>} = gen_tcp:recv(Socket, 0, 6000),
ok.
+%% @todo Move these tests to ws_handler_SUITE.
ws_init_return_ok(Config) ->
doc("Handler does nothing."),
{ok, Socket, _} = do_handshake("/ws_init?ok", Config),
@@ -471,6 +473,17 @@ ws_max_frame_size_intermediate_fragment_close(Config) ->
{error, closed} = gen_tcp:recv(Socket, 0, 6000),
ok.
+ws_ping(Config) ->
+ doc("Server initiated pings can receive a pong in response."),
+ {ok, Socket, _} = do_handshake("/ws_ping", Config),
+ %% Receive a server-sent ping.
+ {ok, << 1:1, 0:3, 9:4, 0:1, 0:7 >>} = gen_tcp:recv(Socket, 0, 6000),
+ %% Send a pong back with a 0 mask.
+ ok = gen_tcp:send(Socket, << 1:1, 0:3, 10:4, 1:1, 0:7, 0:32 >>),
+ %% Receive a text frame as a result.
+ {ok, << 1:1, 0:3, 1:4, 0:1, 4:7, "OK!!" >>} = gen_tcp:recv(Socket, 0, 6000),
+ ok.
+
ws_send_close(Config) ->
doc("Server-initiated close frame ends the connection."),
{ok, Socket, _} = do_handshake("/ws_send_close", Config),
diff --git a/test/ws_autobahn_SUITE.erl b/test/ws_autobahn_SUITE.erl
index 24d76e8..71e5c81 100644
--- a/test/ws_autobahn_SUITE.erl
+++ b/test/ws_autobahn_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2011-2017, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2011-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
diff --git a/test/ws_handler_SUITE.erl b/test/ws_handler_SUITE.erl
index 435600f..ab1ffc8 100644
--- a/test/ws_handler_SUITE.erl
+++ b/test/ws_handler_SUITE.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2018, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
@@ -50,6 +50,7 @@ init_dispatch(Name) ->
{"/init", ws_init_commands_h, RunOrHibernate},
{"/handle", ws_handle_commands_h, RunOrHibernate},
{"/info", ws_info_commands_h, RunOrHibernate},
+ {"/trap_exit", ws_init_h, RunOrHibernate},
{"/active", ws_active_commands_h, RunOrHibernate},
{"/deflate", ws_deflate_commands_h, RunOrHibernate},
{"/set_options", ws_set_options_commands_h, RunOrHibernate},
@@ -211,6 +212,13 @@ do_many_frames_then_close_frame(Config, Path) ->
{ok, close} = receive_ws(ConnPid, StreamRef),
gun_down(ConnPid).
+websocket_init_trap_exit_false(Config) ->
+ doc("The trap_exit process flag must be set back to false before "
+ "the connection is taken over by Websocket."),
+ {ok, ConnPid, StreamRef} = gun_open_ws(Config, "/trap_exit?reply_trap_exit", []),
+ {ok, {text, <<"trap_exit: false">>}} = receive_ws(ConnPid, StreamRef),
+ ok.
+
websocket_active_false(Config) ->
doc("The {active, false} command stops receiving data from the socket. "
"The {active, true} command reenables it."),