aboutsummaryrefslogtreecommitdiffstats
path: root/test
diff options
context:
space:
mode:
authorLoïc Hoguin <[email protected]>2023-01-31 11:07:31 +0100
committerLoïc Hoguin <[email protected]>2024-03-26 15:53:48 +0100
commit8cb9d242b0a665cada6de8b9a9dfa329e0c06ee9 (patch)
treeae2c323c3825da367e54704ea0b9ad80096059c3 /test
parent3ea8395eb8f53a57acb5d3c00b99c70296e7cdbd (diff)
downloadcowboy-8cb9d242b0a665cada6de8b9a9dfa329e0c06ee9.tar.gz
cowboy-8cb9d242b0a665cada6de8b9a9dfa329e0c06ee9.tar.bz2
cowboy-8cb9d242b0a665cada6de8b9a9dfa329e0c06ee9.zip
Initial HTTP/3 implementationhttp3
This includes Websocket over HTTP/3. Since quicer, which provides the QUIC implementation, is a NIF, Cowboy cannot depend directly on it. In order to enable QUIC and HTTP/3, users have to set the COWBOY_QUICER environment variable: export COWBOY_QUICER=1 In order to run the test suites, the same must be done for Gun: export GUN_QUICER=1 HTTP/3 support is currently not available on Windows due to compilation issues of quicer which have yet to be looked at or resolved. HTTP/3 support is also unavailable on the upcoming OTP-27 due to compilation errors in quicer dependencies. Once resolved HTTP/3 should work on OTP-27. Because of how QUIC currently works, it's possible that streams that get reset after sending a response do not receive that response. The test suite was modified to accomodate for that. A future extension to QUIC will allow us to gracefully reset streams. This also updates Erlang.mk.
Diffstat (limited to 'test')
-rw-r--r--test/compress_SUITE.erl16
-rw-r--r--test/cowboy_test.erl72
-rw-r--r--test/decompress_SUITE.erl6
-rw-r--r--test/handlers/resp_h.erl2
-rw-r--r--test/loop_handler_SUITE.erl2
-rw-r--r--test/metrics_SUITE.erl89
-rw-r--r--test/misc_SUITE.erl2
-rw-r--r--test/plain_handler_SUITE.erl13
-rw-r--r--test/req_SUITE.erl200
-rw-r--r--test/rest_handler_SUITE.erl18
-rw-r--r--test/rfc6585_SUITE.erl2
-rw-r--r--test/rfc7231_SUITE.erl27
-rw-r--r--test/rfc7538_SUITE.erl2
-rw-r--r--test/rfc7540_SUITE.erl7
-rw-r--r--test/rfc8297_SUITE.erl2
-rw-r--r--test/rfc8441_SUITE.erl13
-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.erl6
-rw-r--r--test/static_handler_SUITE.erl68
-rw-r--r--test/stream_handler_SUITE.erl167
-rw-r--r--test/tracer_SUITE.erl3
27 files changed, 3795 insertions, 224 deletions
diff --git a/test/compress_SUITE.erl b/test/compress_SUITE.erl
index 46247a4..a6a100c 100644
--- a/test/compress_SUITE.erl
+++ b/test/compress_SUITE.erl
@@ -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.
diff --git a/test/cowboy_test.erl b/test/cowboy_test.erl
index a8ee15b..5a8fb13 100644
--- a/test/cowboy_test.erl
+++ b/test/cowboy_test.erl
@@ -37,35 +37,82 @@ init_http2(Ref, ProtoOpts, Config) ->
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.
@@ -114,7 +170,7 @@ 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()
+ false -> ct_helper:get_certs_from_ets() %% @todo Wrong in current quicer.
end,
{ok, ConnPid} = gun:open("localhost", config(port, Config), Opts#{
retry => 0,
diff --git a/test/decompress_SUITE.erl b/test/decompress_SUITE.erl
index 7c3c6b7..f61bb5d 100644
--- a/test/decompress_SUITE.erl
+++ b/test/decompress_SUITE.erl
@@ -38,6 +38,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) ->
@@ -46,7 +48,9 @@ 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).
diff --git a/test/handlers/resp_h.erl b/test/handlers/resp_h.erl
index aae9eb9..6e9b5f7 100644
--- a/test/handlers/resp_h.erl
+++ b/test/handlers/resp_h.erl
@@ -182,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)
@@ -245,6 +246,7 @@ do(<<"stream_reply2">>, Req0, 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),
diff --git a/test/loop_handler_SUITE.erl b/test/loop_handler_SUITE.erl
index 635fbf2..c5daaf8 100644
--- a/test/loop_handler_SUITE.erl
+++ b/test/loop_handler_SUITE.erl
@@ -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.
diff --git a/test/metrics_SUITE.erl b/test/metrics_SUITE.erl
index 229e83a..6a272f2 100644
--- a/test/metrics_SUITE.erl
+++ b/test/metrics_SUITE.erl
@@ -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,7 +492,14 @@ 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,
[{_, #{
@@ -476,12 +512,13 @@ error_response(Config) ->
#{
ref := _,
pid := From,
- streamid := 1,
+ 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,7 +565,14 @@ 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,
[{_, #{
@@ -533,12 +585,13 @@ error_response_after_reply(Config) ->
#{
ref := _,
pid := From,
- streamid := 1,
+ 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 30abaf5..c918321 100644
--- a/test/misc_SUITE.erl
+++ b/test/misc_SUITE.erl
@@ -43,7 +43,7 @@ init_per_group(Name, Config) ->
end_per_group(env, _) ->
ok;
end_per_group(Name, _) ->
- cowboy:stop_listener(Name).
+ cowboy_test:stop_group(Name).
init_dispatch(_) ->
cowboy_router:compile([{"localhost", [
diff --git a/test/plain_handler_SUITE.erl b/test/plain_handler_SUITE.erl
index cd696df..756c0a6 100644
--- a/test/plain_handler_SUITE.erl
+++ b/test/plain_handler_SUITE.erl
@@ -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/req_SUITE.erl b/test/req_SUITE.erl
index 9f24ed1..9036cac 100644
--- a/test/req_SUITE.erl
+++ b/test/req_SUITE.erl
@@ -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.
@@ -107,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).
@@ -142,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) ->
@@ -184,7 +190,8 @@ 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(Config) ->
@@ -386,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),
@@ -412,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) ->
@@ -425,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)),
@@ -459,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.
@@ -513,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) ->
@@ -525,7 +544,13 @@ 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) ->
@@ -620,15 +645,19 @@ 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."),
@@ -824,7 +853,7 @@ set_resp_header(Config) ->
{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_get("/resp/set_resp_header_cookie", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/resp/set_resp_header_cookie", Config)),
ok.
set_resp_headers(Config) ->
@@ -833,7 +862,7 @@ 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_get("/resp/set_resp_headers_cookie", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/resp/set_resp_headers_cookie", Config)),
ok.
resp_header(Config) ->
@@ -895,28 +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),
- %% @todo How to test this properly? This isn't enough.
- {200, _} = do_get_inform("/resp/inform2/after_reply", 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_get_inform("/resp/inform3/set_cookie", Config),
+ {500, _} = do_maybe_h3_error2(do_get_inform("/resp/inform3/set_cookie", Config)),
{102, Headers, 200, _, _} = do_get_inform("/resp/inform3/twice", Config),
- %% @todo How to test this properly? This isn't enough.
- {200, _} = do_get_inform("/resp/inform3/after_reply", 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."),
@@ -924,7 +977,7 @@ 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),
+ {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.
@@ -937,9 +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_get("/resp/reply3/set_cookie", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply3/set_cookie", Config)),
ok.
reply4(Config) ->
@@ -947,9 +1000,9 @@ 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_get("/resp/reply4/set_cookie", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply4/set_cookie", Config)),
ok.
stream_reply2(Config) ->
@@ -959,12 +1012,11 @@ 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. "
- "This crash can only be properly detected in HTTP/2."),
+ 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">>}]),
@@ -983,8 +1035,10 @@ stream_reply2_twice(Config) ->
zlib:inflateInit(Z, 31),
0 = iolist_size(zlib:inflate(Z, Data)),
ok;
- %% In HTTP/2 the stream gets reset with an appropriate error.
+ %% 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).
@@ -998,9 +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_get("/resp/stream_reply3/set_cookie", Config),
+ {500, _, _} = do_maybe_h3_error3(do_get("/resp/stream_reply3/set_cookie", Config)),
ok.
stream_body_fin0(Config) ->
@@ -1084,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) ->
@@ -1187,16 +1244,24 @@ stream_trailers_set_cookie(Config) ->
{<<"accept-encoding">>, <<"gzip">>},
{<<"te">>, <<"trailers">>}
]),
- {response, nofin, 200, _} = gun:await(ConnPid, Ref, infinity),
- case config(protocol, Config) of
- http ->
+ 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;
- http2 ->
+ {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).
@@ -1224,34 +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", []),
- %% @todo How to test this properly? This isn't enough.
- {response, fin, 200, _} = gun:await(ConnPid, Ref, infinity),
+ %% 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) ->
diff --git a/test/rest_handler_SUITE.erl b/test/rest_handler_SUITE.erl
index e026552..6c1f1c1 100644
--- a/test/rest_handler_SUITE.erl
+++ b/test/rest_handler_SUITE.erl
@@ -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) ->
@@ -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) ->
@@ -650,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)"),
@@ -806,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) ->
@@ -962,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 090f028..17cbb07 100644
--- a/test/rfc6585_SUITE.erl
+++ b/test/rfc6585_SUITE.erl
@@ -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/rfc7231_SUITE.erl b/test/rfc7231_SUITE.erl
index 1d23cb9..4475899 100644
--- a/test/rfc7231_SUITE.erl
+++ b/test/rfc7231_SUITE.erl
@@ -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([{"[...]", [
@@ -237,6 +237,8 @@ http10_expect(Config) ->
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 d9bb1f6..c46d388 100644
--- a/test/rfc7538_SUITE.erl
+++ b/test/rfc7538_SUITE.erl
@@ -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 8e40c93..f040601 100644
--- a/test/rfc7540_SUITE.erl
+++ b/test/rfc7540_SUITE.erl
@@ -34,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) ->
@@ -3893,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 bf06351..c6c1c9d 100644
--- a/test/rfc8297_SUITE.erl
+++ b/test/rfc8297_SUITE.erl
@@ -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 4c46374..3e71667 100644
--- a/test/rfc8441_SUITE.erl
+++ b/test/rfc8441_SUITE.erl
@@ -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 a1ba916..666dcce 100644
--- a/test/security_SUITE.erl
+++ b/test/security_SUITE.erl
@@ -49,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) ->
@@ -66,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.
diff --git a/test/static_handler_SUITE.erl b/test/static_handler_SUITE.erl
index 17a56e0..9620f66 100644
--- a/test/static_handler_SUITE.erl
+++ b/test/static_handler_SUITE.erl
@@ -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}
].
@@ -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 bd87e40..f8e2200 100644
--- a/test/stream_handler_SUITE.erl
+++ b/test/stream_handler_SUITE.erl
@@ -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">>},
@@ -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 receive 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/tracer_SUITE.erl b/test/tracer_SUITE.erl
index d97ce44..af1f8f3 100644
--- a/test/tracer_SUITE.erl
+++ b/test/tracer_SUITE.erl
@@ -29,7 +29,8 @@ suite() ->
%% We initialize trace patterns here. Appropriate would be in
%% init_per_suite/1, but this works just as well.
all() ->
- cowboy_test:common_all().
+ %% @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(),