diff options
Diffstat (limited to 'test')
67 files changed, 8690 insertions, 475 deletions
diff --git a/test/compress_SUITE.erl b/test/compress_SUITE.erl index a25c427..9da9769 100644 --- a/test/compress_SUITE.erl +++ b/test/compress_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -23,12 +23,20 @@ %% ct. all() -> - [ + All = [ {group, http_compress}, {group, https_compress}, {group, h2_compress}, - {group, h2c_compress} - ]. + {group, h2c_compress}, + {group, h3_compress} + ], + %% Don't run HTTP/3 tests on Windows for now. + case os:type() of + {win32, _} -> + All -- [{group, h3_compress}]; + _ -> + All + end. groups() -> cowboy_test:common_groups(ct_helper:all(?MODULE)). @@ -37,7 +45,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Routes. @@ -67,7 +75,7 @@ gzip_accept_encoding_malformed(Config) -> {200, Headers, _} = do_get("/reply/large", [{<<"accept-encoding">>, <<";">>}], Config), false = lists:keyfind(<<"content-encoding">>, 1, Headers), - false = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), {_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers), ok. @@ -76,7 +84,7 @@ gzip_accept_encoding_missing(Config) -> {200, Headers, _} = do_get("/reply/large", [], Config), false = lists:keyfind(<<"content-encoding">>, 1, Headers), - false = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), {_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers), ok. @@ -85,7 +93,7 @@ gzip_accept_encoding_no_gzip(Config) -> {200, Headers, _} = do_get("/reply/large", [{<<"accept-encoding">>, <<"compress">>}], Config), false = lists:keyfind(<<"content-encoding">>, 1, Headers), - false = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), {_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers), ok. @@ -94,7 +102,7 @@ gzip_accept_encoding_not_supported(Config) -> {200, Headers, _} = do_get("/reply/large", [{<<"accept-encoding">>, <<"application/gzip">>}], Config), false = lists:keyfind(<<"content-encoding">>, 1, Headers), - false = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), {_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers), ok. @@ -105,7 +113,18 @@ gzip_reply_content_encoding(Config) -> %% We set the content-encoding to compress; without actually compressing. {_, <<"compress">>} = lists:keyfind(<<"content-encoding">>, 1, Headers), %% The reply didn't include a vary header. - false = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers), + ok. + +gzip_reply_etag(Config) -> + doc("Reply with etag header; get an uncompressed response."), + {200, Headers, _} = do_get("/reply/etag", + [{<<"accept-encoding">>, <<"gzip">>}], Config), + %% We set a strong etag. + {_, <<"\"STRONK\"">>} = lists:keyfind(<<"etag">>, 1, Headers), + %% The reply didn't include a vary header. + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), {_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers), ok. @@ -125,7 +144,7 @@ gzip_reply_sendfile(Config) -> {200, Headers, Body} = do_get("/reply/sendfile", [{<<"accept-encoding">>, <<"gzip">>}], Config), false = lists:keyfind(<<"content-encoding">>, 1, Headers), - false = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), ct:log("Body received:~n~p~n", [Body]), ok. @@ -134,7 +153,7 @@ gzip_reply_small_body(Config) -> {200, Headers, _} = do_get("/reply/small", [{<<"accept-encoding">>, <<"gzip">>}], Config), false = lists:keyfind(<<"content-encoding">>, 1, Headers), - false = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), {_, <<"100">>} = lists:keyfind(<<"content-length">>, 1, Headers), ok. @@ -170,7 +189,16 @@ gzip_stream_reply_content_encoding(Config) -> {200, Headers, Body} = do_get("/stream_reply/content-encoding", [{<<"accept-encoding">>, <<"gzip">>}], Config), {_, <<"compress">>} = lists:keyfind(<<"content-encoding">>, 1, Headers), - false = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), + 100000 = iolist_size(Body), + ok. + +gzip_stream_reply_etag(Config) -> + doc("Stream reply with etag header; get an uncompressed response."), + {200, Headers, Body} = do_get("/stream_reply/etag", + [{<<"accept-encoding">>, <<"gzip">>}], Config), + {_, <<"\"STRONK\"">>} = lists:keyfind(<<"etag">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), 100000 = iolist_size(Body), ok. diff --git a/test/cowboy_ct_hook.erl b/test/cowboy_ct_hook.erl index 7d5a889..46e56a2 100644 --- a/test/cowboy_ct_hook.erl +++ b/test/cowboy_ct_hook.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2014-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/test/cowboy_test.erl b/test/cowboy_test.erl index 7ebe618..541e8f9 100644 --- a/test/cowboy_test.erl +++ b/test/cowboy_test.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2014-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -27,84 +27,149 @@ init_http(Ref, ProtoOpts, Config) -> init_https(Ref, ProtoOpts, Config) -> Opts = ct_helper:get_certs_from_ets(), - {ok, _} = cowboy:start_tls(Ref, Opts ++ [{port, 0}], ProtoOpts), + {ok, _} = cowboy:start_tls(Ref, Opts ++ [{port, 0}, {verify, verify_none}], ProtoOpts), Port = ranch:get_port(Ref), [{ref, Ref}, {type, ssl}, {protocol, http}, {port, Port}, {opts, Opts}|Config]. init_http2(Ref, ProtoOpts, Config) -> Opts = ct_helper:get_certs_from_ets(), - {ok, _} = cowboy:start_tls(Ref, Opts ++ [{port, 0}], ProtoOpts), + {ok, _} = cowboy:start_tls(Ref, Opts ++ [{port, 0}, {verify, verify_none}], ProtoOpts), Port = ranch:get_port(Ref), [{ref, Ref}, {type, ssl}, {protocol, http2}, {port, Port}, {opts, Opts}|Config]. +%% @todo This will probably require TransOpts as argument. +init_http3(Ref, ProtoOpts, Config) -> + %% @todo Quicer does not currently support non-file cert/key, + %% so we use quicer test certificates for now. + %% @todo Quicer also does not support cacerts which means + %% we currently have no authentication based security. + DataDir = filename:dirname(filename:dirname(config(data_dir, Config))) + ++ "/rfc9114_SUITE_data", + TransOpts = #{ + socket_opts => [ + {certfile, DataDir ++ "/server.pem"}, + {keyfile, DataDir ++ "/server.key"} + ] + }, + {ok, Listener} = cowboy:start_quic(Ref, TransOpts, ProtoOpts), + {ok, {_, Port}} = quicer:sockname(Listener), + %% @todo Keep listener information around in a better place. + persistent_term:put({cowboy_test_quic, Ref}, Listener), + [{ref, Ref}, {type, quic}, {protocol, http3}, {port, Port}, {opts, TransOpts}|Config]. + +stop_group(Ref) -> + case persistent_term:get({cowboy_test_quic, Ref}, undefined) of + undefined -> + cowboy:stop_listener(Ref); + Listener -> + quicer:close_listener(Listener) + end. + %% Common group of listeners used by most suites. common_all() -> - [ + All = [ {group, http}, {group, https}, {group, h2}, {group, h2c}, + {group, h3}, {group, http_compress}, {group, https_compress}, {group, h2_compress}, - {group, h2c_compress} - ]. + {group, h2c_compress}, + {group, h3_compress} + ], + %% Don't run HTTP/3 tests on Windows for now. + case os:type() of + {win32, _} -> + All -- [{group, h3}, {group, h3_compress}]; + _ -> + All + end. common_groups(Tests) -> - Opts = case os:getenv("NO_PARALLEL") of - false -> [parallel]; - _ -> [] + Parallel = case os:getenv("NO_PARALLEL") of + false -> parallel; + _ -> no_parallel end, - [ + common_groups(Tests, Parallel). + +common_groups(Tests, Parallel) -> + Opts = case Parallel of + parallel -> [parallel]; + no_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, Config, Mod) -> + init_common_groups(Name, Config, Mod, #{}). -init_common_groups(Name = http, Config, Mod) -> - init_http(Name, #{ +init_common_groups(Name = http, Config, Mod, ProtoOpts) -> + init_http(Name, ProtoOpts#{ env => #{dispatch => Mod:init_dispatch(Config)} }, [{flavor, vanilla}|Config]); -init_common_groups(Name = https, Config, Mod) -> - init_https(Name, #{ +init_common_groups(Name = https, Config, Mod, ProtoOpts) -> + init_https(Name, ProtoOpts#{ env => #{dispatch => Mod:init_dispatch(Config)} }, [{flavor, vanilla}|Config]); -init_common_groups(Name = h2, Config, Mod) -> - init_http2(Name, #{ +init_common_groups(Name = h2, Config, Mod, ProtoOpts) -> + init_http2(Name, ProtoOpts#{ env => #{dispatch => Mod:init_dispatch(Config)} }, [{flavor, vanilla}|Config]); -init_common_groups(Name = h2c, Config, Mod) -> - Config1 = init_http(Name, #{ +init_common_groups(Name = h2c, Config, Mod, ProtoOpts) -> + Config1 = init_http(Name, ProtoOpts#{ env => #{dispatch => Mod:init_dispatch(Config)} }, [{flavor, vanilla}|Config]), lists:keyreplace(protocol, 1, Config1, {protocol, http2}); -init_common_groups(Name = http_compress, Config, Mod) -> - init_http(Name, #{ +init_common_groups(Name = h3, Config, Mod, ProtoOpts) -> + init_http3(Name, ProtoOpts#{ + env => #{dispatch => Mod:init_dispatch(Config)} + }, [{flavor, vanilla}|Config]); +init_common_groups(Name = http_compress, Config, Mod, ProtoOpts) -> + init_http(Name, ProtoOpts#{ env => #{dispatch => Mod:init_dispatch(Config)}, stream_handlers => [cowboy_compress_h, cowboy_stream_h] }, [{flavor, compress}|Config]); -init_common_groups(Name = https_compress, Config, Mod) -> - init_https(Name, #{ +init_common_groups(Name = https_compress, Config, Mod, ProtoOpts) -> + init_https(Name, ProtoOpts#{ env => #{dispatch => Mod:init_dispatch(Config)}, stream_handlers => [cowboy_compress_h, cowboy_stream_h] }, [{flavor, compress}|Config]); -init_common_groups(Name = h2_compress, Config, Mod) -> - init_http2(Name, #{ +init_common_groups(Name = h2_compress, Config, Mod, ProtoOpts) -> + init_http2(Name, ProtoOpts#{ env => #{dispatch => Mod:init_dispatch(Config)}, stream_handlers => [cowboy_compress_h, cowboy_stream_h] }, [{flavor, compress}|Config]); -init_common_groups(Name = h2c_compress, Config, Mod) -> - Config1 = init_http(Name, #{ +init_common_groups(Name = h2c_compress, Config, Mod, ProtoOpts) -> + Config1 = init_http(Name, ProtoOpts#{ 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, ProtoOpts) -> + init_http3(Name, ProtoOpts#{ + env => #{dispatch => Mod:init_dispatch(Config)}, + stream_handlers => [cowboy_compress_h, cowboy_stream_h] + }, [{flavor, compress}|Config]). %% Support functions for testing using Gun. @@ -112,10 +177,14 @@ gun_open(Config) -> gun_open(Config, #{}). gun_open(Config, Opts) -> + TlsOpts = case proplists:get_value(no_cert, Config, false) of + true -> [{verify, verify_none}]; + false -> ct_helper:get_certs_from_ets() %% @todo Wrong in current quicer. + end, {ok, ConnPid} = gun:open("localhost", config(port, Config), Opts#{ retry => 0, transport => config(type, Config), - tls_opts => proplists:get_value(tls_opts, Config, []), + tls_opts => TlsOpts, protocols => [config(protocol, Config)] }), ConnPid. @@ -153,6 +222,12 @@ raw_recv_head(Socket, Transport, Buffer) -> Buffer end. +raw_recv_rest({raw_client, _, _}, Length, Buffer) when Length =:= byte_size(Buffer) -> + Buffer; +raw_recv_rest({raw_client, Socket, Transport}, Length, Buffer) when Length > byte_size(Buffer) -> + {ok, Data} = Transport:recv(Socket, Length - byte_size(Buffer), 10000), + << Buffer/binary, Data/binary >>. + raw_recv({raw_client, Socket, Transport}, Length, Timeout) -> Transport:recv(Socket, Length, Timeout). diff --git a/test/decompress_SUITE.erl b/test/decompress_SUITE.erl new file mode 100644 index 0000000..f1eb13a --- /dev/null +++ b/test/decompress_SUITE.erl @@ -0,0 +1,421 @@ +%% Copyright (c) jdamanalo <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> +%% +%% Permission to use, copy, modify, and/or distribute this software for any +%% purpose with or without fee is hereby granted, provided that the above +%% copyright notice and this permission notice appear in all copies. +%% +%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +-module(decompress_SUITE). +-compile(export_all). +-compile(nowarn_export_all). + +-import(ct_helper, [config/2]). +-import(ct_helper, [doc/1]). +-import(cowboy_test, [gun_open/1]). + +%% ct. + +all() -> + cowboy_test:common_all(). + +groups() -> + cowboy_test:common_groups(ct_helper:all(?MODULE)). + +init_per_group(Name = http, Config) -> + cowboy_test:init_http(Name, init_plain_opts(Config), Config); +init_per_group(Name = https, Config) -> + cowboy_test:init_http(Name, init_plain_opts(Config), Config); +init_per_group(Name = h2, Config) -> + cowboy_test:init_http2(Name, init_plain_opts(Config), Config); +init_per_group(Name = h2c, Config) -> + Config1 = cowboy_test:init_http(Name, init_plain_opts(Config), Config), + lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3, Config) -> + cowboy_test:init_http3(Name, init_plain_opts(Config), Config); +init_per_group(Name = http_compress, Config) -> + cowboy_test:init_http(Name, init_compress_opts(Config), Config); +init_per_group(Name = https_compress, Config) -> + cowboy_test:init_http(Name, init_compress_opts(Config), Config); +init_per_group(Name = h2_compress, Config) -> + cowboy_test:init_http2(Name, init_compress_opts(Config), Config); +init_per_group(Name = h2c_compress, Config) -> + Config1 = cowboy_test:init_http(Name, init_compress_opts(Config), Config), + lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3_compress, Config) -> + cowboy_test:init_http3(Name, init_compress_opts(Config), Config). + +end_per_group(Name, _) -> + cowboy:stop_listener(Name). + +init_plain_opts(Config) -> + #{ + env => #{dispatch => cowboy_router:compile(init_routes(Config))}, + stream_handlers => [cowboy_decompress_h, cowboy_stream_h] + }. + +init_compress_opts(Config) -> + #{ + env => #{dispatch => cowboy_router:compile(init_routes(Config))}, + stream_handlers => [cowboy_decompress_h, cowboy_compress_h, cowboy_stream_h] + }. + +init_routes(_) -> + [{'_', [ + {"/echo/:what", decompress_h, echo}, + {"/test/:what", decompress_h, test} + ]}]. + +%% Internal. + +do_post(Path, ReqHeaders, Body, Config) -> + ConnPid = gun_open(Config), + Ref = gun:post(ConnPid, Path, ReqHeaders, Body), + {response, IsFin, Status, RespHeaders} = gun:await(ConnPid, Ref), + {ok, ResponseBody} = case IsFin of + nofin -> gun:await_body(ConnPid, Ref); + fin -> {ok, <<>>} + end, + gun:close(ConnPid), + {Status, RespHeaders, ResponseBody}. + +create_gzip_bomb() -> + Z = zlib:open(), + zlib:deflateInit(Z, 9, deflated, 31, 8, default), + %% 1000 chunks of 100000 zeroes (100MB). + Bomb = do_create_gzip_bomb(Z, 1000), + zlib:deflateEnd(Z), + zlib:close(Z), + iolist_to_binary(Bomb). + +do_create_gzip_bomb(Z, 0) -> + zlib:deflate(Z, << >>, finish); +do_create_gzip_bomb(Z, N) -> + Data = <<0:800000>>, + Deflate = zlib:deflate(Z, Data), + [Deflate | do_create_gzip_bomb(Z, N - 1)]. + +%% Tests. + +content_encoding_none(Config) -> + doc("Requests without content-encoding are processed normally."), + Body = <<"test">>, + {200, _, Body} = do_post("/echo/normal", [], Body, Config), + %% The content-encoding header would be propagated, + %% but there was no content-encoding header to propagate. + {200, _, <<"undefined">>} = do_post("/test/content-encoding", [], Body, Config), + %% The content_decoded list is empty. + {200, _, <<"[]">>} = do_post("/test/content-decoded", [], Body, Config), + ok. + +content_encoding_malformed(Config) -> + doc("Requests with a malformed content-encoding are processed " + "as if no content-encoding was sent."), + Body = <<"test">>, + {200, _, Body} = do_post("/echo/normal", + [{<<"content-encoding">>, <<";">>}], Body, Config), + %% The content-encoding header is propagated. + {200, _, <<";">>} = do_post("/test/content-encoding", + [{<<"content-encoding">>, <<";">>}], Body, Config), + %% The content_decoded list is empty. + {200, _, <<"[]">>} = do_post("/test/content-decoded", + [{<<"content-encoding">>, <<";">>}], Body, Config), + ok. + +content_encoding_not_supported(Config) -> + doc("Requests with an unsupported content-encoding are processed " + "as if no content-encoding was sent."), + Body = <<"test">>, + {200, _, Body} = do_post("/echo/normal", + [{<<"content-encoding">>, <<"compress">>}], Body, Config), + %% The content-encoding header is propagated. + {200, _, <<"compress">>} = do_post("/test/content-encoding", + [{<<"content-encoding">>, <<"compress">>}], Body, Config), + %% The content_decoded list is empty. + {200, _, <<"[]">>} = do_post("/test/content-decoded", + [{<<"content-encoding">>, <<"compress">>}], Body, Config), + ok. + +content_encoding_multiple(Config) -> + doc("Requests with multiple content-encoding values are processed " + "as if no content-encoding was sent."), + Body = <<"test">>, + {200, _, Body} = do_post("/echo/normal", + [{<<"content-encoding">>, <<"gzip, compress">>}], Body, Config), + %% The content-encoding header is propagated. + {200, _, <<"gzip, compress">>} = do_post("/test/content-encoding", + [{<<"content-encoding">>, <<"gzip, compress">>}], Body, Config), + %% The content_decoded list is empty. + {200, _, <<"[]">>} = do_post("/test/content-decoded", + [{<<"content-encoding">>, <<"gzip, compress">>}], Body, Config), + ok. + +decompress(Config) -> + doc("Requests with content-encoding set to gzip and gzipped data " + "are transparently decompressed."), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, _, Data} = do_post("/echo/normal", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + %% The content-encoding header is NOT propagated. + {200, _, <<"undefined">>} = do_post("/test/content-encoding", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + %% The content_decoded list contains <<"gzip">>. + {200, _, <<"[<<\"gzip\">>]">>} = do_post("/test/content-decoded", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + ok. + +decompress_error(Config) -> + doc("Requests with content-encoding set to gzip but the data " + "cannot be decoded are rejected with a 400 Bad Request error."), + Body = <<"test">>, + {400, _, _} = do_post("/echo/normal", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + ok. + +decompress_stream(Config) -> + doc("Requests with content-encoding set to gzip and gzipped data " + "are transparently decompressed, even when the data is streamed."), + %% Handler read length 1KB. Compressing 3KB should be enough to trigger more. + Data = crypto:strong_rand_bytes(3000), + Body = zlib:gzip(Data), + Size = byte_size(Body), + ConnPid = gun_open(Config), + Ref = gun:post(ConnPid, "/echo/normal", + [{<<"content-encoding">>, <<"gzip">>}]), + gun:data(ConnPid, Ref, nofin, binary:part(Body, 0, Size div 2)), + timer:sleep(1000), + gun:data(ConnPid, Ref, fin, binary:part(Body, Size div 2, Size div 2 + Size rem 2)), + {response, IsFin, 200, _} = gun:await(ConnPid, Ref), + {ok, Data} = case IsFin of + nofin -> gun:await_body(ConnPid, Ref); + fin -> {ok, <<>>} + end, + gun:close(ConnPid), + %% The content-encoding header is NOT propagated. + ConnPid2 = gun_open(Config), + Ref2 = gun:post(ConnPid2, "/test/content-encoding", + [{<<"content-encoding">>, <<"gzip">>}]), + {response, nofin, 200, _} = gun:await(ConnPid2, Ref2), + {ok, <<"undefined">>} = gun:await_body(ConnPid2, Ref2), + gun:close(ConnPid2), + %% The content_decoded list contains <<"gzip">>. + ConnPid3 = gun_open(Config), + Ref3 = gun:post(ConnPid3, "/test/content-decoded", + [{<<"content-encoding">>, <<"gzip">>}]), + {response, nofin, 200, _} = gun:await(ConnPid3, Ref3), + {ok, <<"[<<\"gzip\">>]">>} = gun:await_body(ConnPid3, Ref3), + gun:close(ConnPid3). + +opts_decompress_enabled_false(Config0) -> + doc("Confirm that the decompress_enabled option can be set."), + Fun = case config(ref, Config0) of + HTTPS when HTTPS =:= https_compress; HTTPS =:= https -> init_https; + H2 when H2 =:= h2_compress; H2 =:= h2 -> init_http2; + _ -> init_http + end, + Config = cowboy_test:Fun(?FUNCTION_NAME, #{ + env => #{dispatch => cowboy_router:compile(init_routes(Config0))}, + stream_handlers => [cowboy_decompress_h, cowboy_stream_h], + decompress_enabled => false + }, Config0), + Data = <<"test">>, + Body = zlib:gzip(Data), + try + {200, Headers, Body} = do_post("/echo/normal", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + %% We do not set accept-encoding when we are disabled. + false = lists:keyfind(<<"accept-encoding">>, 1, Headers) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +set_options_decompress_enabled_false(Config) -> + doc("Confirm that the decompress_enabled option can be dynamically " + "set to false and the data received is not decompressed."), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Body} = do_post("/echo/decompress_disable", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + %% We do not set accept-encoding when we are disabled. + false = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +set_options_decompress_disable_in_the_middle(Config) -> + doc("Confirm that setting the decompress_enabled option dynamically " + "to false after starting to read the body does not disable decompression " + "and the data received is decompressed."), + Data = rand:bytes(1000000), + Body = zlib:gzip(Data), + %% Since we were not ignoring before starting to read, + %% we receive the entire body decompressed. + {200, Headers, Data} = do_post("/test/disable-in-the-middle", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + %% We do set accept-encoding when we are enabled, + %% even if an attempt to disable in the middle is ignored. + {_, _} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +set_options_decompress_enable_in_the_middle(Config0) -> + doc("Confirm that setting the decompress_enabled option dynamically " + "to true after starting to read the body does not enable decompression " + "and the data received is not decompressed."), + Fun = case config(ref, Config0) of + HTTPS when HTTPS =:= https_compress; HTTPS =:= https -> init_https; + H2 when H2 =:= h2_compress; H2 =:= h2 -> init_http2; + _ -> init_http + end, + Config = cowboy_test:Fun(?FUNCTION_NAME, #{ + env => #{dispatch => cowboy_router:compile(init_routes(Config0))}, + stream_handlers => [cowboy_decompress_h, cowboy_stream_h], + decompress_enabled => false + }, Config0), + Data = rand:bytes(1000000), + Body = zlib:gzip(Data), + try + %% Since we were ignoring before starting to read, + %% we receive the entire body compressed. + {200, Headers, Body} = do_post("/test/enable-in-the-middle", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + %% We do not set accept-encoding when we are disabled, + %% even if an attempt to enable in the middle is ignored. + false = lists:keyfind(<<"accept-encoding">>, 1, Headers) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +opts_decompress_ratio_limit(Config0) -> + doc("Confirm that the decompress_ratio_limit option can be set."), + Fun = case config(ref, Config0) of + HTTPS when HTTPS =:= https_compress; HTTPS =:= https -> init_https; + H2 when H2 =:= h2_compress; H2 =:= h2 -> init_http2; + _ -> init_http + end, + Config = cowboy_test:Fun(?FUNCTION_NAME, #{ + env => #{dispatch => cowboy_router:compile(init_routes(Config0))}, + stream_handlers => [cowboy_decompress_h, cowboy_stream_h], + decompress_ratio_limit => 1 + }, Config0), + %% Data must be big enough for compression to be effective, + %% so that ratio_limit=1 will fail. + Data = <<0:800>>, + Body = zlib:gzip(Data), + try + {413, _, _} = do_post("/echo/normal", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +set_options_decompress_ratio_limit(Config) -> + doc("Confirm that the decompress_ratio_limit option can be dynamically set."), + %% Data must be big enough for compression to be effective, + %% so that ratio_limit=1 will fail. + Data = <<0:800>>, + Body = zlib:gzip(Data), + {413, _, _} = do_post("/echo/decompress_ratio_limit", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + ok. + +gzip_bomb(Config) -> + doc("Confirm that requests are rejected with a 413 Payload Too Large " + "error when the ratio limit is exceeded."), + Body = create_gzip_bomb(), + {413, _, _} = do_post("/echo/normal", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + ok. + +set_accept_encoding_response(Config) -> + doc("Header accept-encoding must be set on valid response command. " + "(RFC9110 12.5.3)"), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Data} = do_post("/echo/normal", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + {_, <<"gzip">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +set_accept_encoding_header(Config) -> + doc("Header accept-encoding must be set on valid header command. " + "(RFC9110 12.5.3)"), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Data} = do_post("/test/header-command", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + {_, <<"gzip">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +add_accept_encoding_header_valid(Config) -> + doc("Supported content codings must be added to the accept-encoding " + "header if it already exists. (RFC9110 12.5.3)"), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Data} = do_post("/test/accept-identity", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + {_, <<"identity, gzip">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +override_accept_encoding_header_invalid(Config) -> + doc("When the stream handler cannot parse the accept-encoding header " + "found in the response, it overrides it."), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Data} = do_post("/test/invalid-header", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + {_, <<"gzip">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +override_accept_encoding_excluded(Config) -> + doc("The stream handler must ensure that the content encodings " + "it supports are not marked as unsupported in response headers. " + "The stream handler enables gzip when explicitly excluded. " + "(RFC9110 12.5.3)"), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Data} = do_post("/test/reject-explicit-header", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + {_, <<"identity;q=1, gzip;q=1">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +%% *;q=0 will reject codings that are not listed. Supported codings +%% must always be enabled when the handler is used. +add_accept_encoding_excluded(Config) -> + doc("The stream handler must ensure that the content encodings " + "it supports are not marked as unsupported in response headers. " + "The stream handler enables gzip when implicitly excluded (*;q=0). " + "(RFC9110 12.5.3)"), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Data} = do_post("/test/reject-implicit-header", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + {_, <<"gzip;q=1, identity;q=1, *;q=0">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +no_override_accept_coding_set_explicit(Config) -> + doc("Confirm that accept-encoding is not overridden when the " + "content encodings it supports are explicitly set. " + "(RFC9110 12.5.3)"), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Data} = do_post("/test/accept-explicit-header", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + {_, <<"identity, gzip;q=0.5">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +no_override_accept_coding_set_implicit(Config) -> + doc("Confirm that accept-encoding is not overridden when the " + "content encodings it supports are implicitly set. " + "(RFC9110 12.5.3)"), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Data} = do_post("/test/accept-implicit-header", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + {_, <<"identity, *;q=0.5">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. diff --git a/test/draft_h3_webtransport_SUITE.erl b/test/draft_h3_webtransport_SUITE.erl new file mode 100644 index 0000000..05a6c17 --- /dev/null +++ b/test/draft_h3_webtransport_SUITE.erl @@ -0,0 +1,814 @@ +%% Copyright (c) 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(draft_h3_webtransport_SUITE). +-compile(export_all). +-compile(nowarn_export_all). + +-import(ct_helper, [config/2]). +-import(ct_helper, [doc/1]). +-import(rfc9114_SUITE, [do_wait_stream_aborted/1]). + +-ifdef(COWBOY_QUICER). + +-include_lib("quicer/include/quicer.hrl"). + +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, + h3_datagram => true, + enable_webtransport => true, %% For compatibility with draft-02. + wt_max_sessions => 10, + env => #{dispatch => cowboy_router:compile(init_routes(Config))} + }, Config). + +end_per_group(Name, _) -> + cowboy_test:stop_group(Name). + +init_routes(_) -> [ + {"localhost", [ + {"/wt", wt_echo_h, []} + ]} +]. + +%% Temporary. + +%% To start Chromium the command line is roughly: +%% chromium --ignore-certificate-errors-spki-list=LeLykt63i2FRAm+XO91yBoSjKfrXnAFygqe5xt0zgDA= --ignore-certificate-errors --user-data-dir=/tmp/chromium-wt --allow-insecure-localhost --webtransport-developer-mode --enable-quic https://googlechrome.github.io/samples/webtransport/client.html +%% +%% To find the SPKI the command is roughly: +%% openssl x509 -in ~/ninenines/cowboy/test/rfc9114_SUITE_data/server.pem -pubkey -noout | \ +%% openssl pkey -pubin -outform der | \ +%% openssl dgst -sha256 -binary | \ +%% openssl enc -base64 + +%run(Config) -> +% ct:pal("port ~p", [config(port, Config)]), +% timer:sleep(infinity). + +%% 3. Session Establishment + +%% 3.1. Establishing a WebTransport-Capable HTTP/3 Connection + +%% In order to indicate support for WebTransport, the server MUST send a SETTINGS_WT_MAX_SESSIONS value greater than "0" in its SETTINGS frame. (3.1) +%% @todo reject_session_disabled +%% @todo accept_session_below +%% @todo accept_session_equal +%% @todo reject_session_above + +%% The client MUST NOT send a WebTransport request until it has received the setting indicating WebTransport support from the server. (3.1) + +%% For draft verisons of WebTransport only, the server MUST NOT process any incoming WebTransport requests until the client settings have been received, as the client may be using a version of the WebTransport extension that is different from the one used by the server. (3.1) + +%% Because WebTransport over HTTP/3 requires support for HTTP/3 datagrams and the Capsule Protocol, both the client and the server MUST indicate support for HTTP/3 datagrams by sending a SETTINGS_H3_DATAGRAM value set to 1 in their SETTINGS frame (see Section 2.1.1 of [HTTP-DATAGRAM]). (3.1) +%% @todo settings_h3_datagram_enabled + +%% WebTransport over HTTP/3 also requires support for QUIC datagrams. To indicate support, both the client and the server MUST send a max_datagram_frame_size transport parameter with a value greater than 0 (see Section 3 of [QUIC-DATAGRAM]). (3.1) +%% @todo quic_datagram_enabled (if size is too low the CONNECT stream can be used for capsules) + +%% Any WebTransport requests sent by the client without enabling QUIC and HTTP datagrams MUST be treated as malformed by the server, as described in Section 4.1.2 of [HTTP3]. (3.1) +%% @todo reject_h3_datagram_disabled +%% @todo reject_quic_datagram_disabled + +%% WebTransport over HTTP/3 relies on the RESET_STREAM_AT frame defined in [RESET-STREAM-AT]. To indicate support, both the client and the server MUST enable the extension as described in Section 3 of [RESET-STREAM-AT]. (3.1) +%% @todo reset_stream_at_enabled + +%% 3.2. Extended CONNECT in HTTP/3 + +%% [RFC8441] defines an extended CONNECT method in Section 4, enabled by the SETTINGS_ENABLE_CONNECT_PROTOCOL setting. That setting is defined for HTTP/3 by [RFC9220]. A server supporting WebTransport over HTTP/3 MUST send both the SETTINGS_WT_MAX_SESSIONS setting with a value greater than "0" and the SETTINGS_ENABLE_CONNECT_PROTOCOL setting with a value of "1". (3.2) +%% @todo settings_enable_connect_protocol_enabled +%% @todo reject_settings_enable_connect_protocol_disabled + +%% 3.3. Creating a New Session + +%% As WebTransport sessions are established over HTTP/3, they are identified using the https URI scheme ([HTTP], Section 4.2.2). (3.3) + +%% In order to create a new WebTransport session, a client can send an HTTP CONNECT request. The :protocol pseudo-header field ([RFC8441]) MUST be set to webtransport. The :scheme field MUST be https. Both the :authority and the :path value MUST be set; those fields indicate the desired WebTransport server. If the WebTransport session is coming from a browser client, an Origin header [RFC6454] MUST be provided within the request; otherwise, the header is OPTIONAL. (3.3) + +%% If it does not (have a WT server), it SHOULD reply with status code 404 (Section 15.5.5 of [HTTP]). (3.3) + +%% When the request contains the Origin header, the WebTransport server MUST verify the Origin header to ensure that the specified origin is allowed to access the server in question. If the verification fails, the WebTransport server SHOULD reply with status code 403 (Section 15.5.4 of [HTTP]). (3.3) + +accept_session_when_enabled(Config) -> + doc("Confirm that a WebTransport session can be established over HTTP/3. " + "(draft_webtrans_http3 3.3, RFC9220)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + session_id := SessionID + } = do_webtransport_connect(Config), + %% Create a bidi stream, send Hello, get Hello back. + {ok, BidiStreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(BidiStreamRef, <<1:2, 16#41:14, 0:2, SessionID:6, "Hello">>), + {nofin, <<"Hello">>} = do_receive_data(BidiStreamRef), + ok. + +%% If the server accepts 0-RTT, the server MUST NOT reduce the limit of maximum open WebTransport sessions from the one negotiated during the previous session; such change would be deemed incompatible, and MUST result in a H3_SETTINGS_ERROR connection error. (3.3) + +%% The capsule-protocol header field Section 3.4 of [HTTP-DATAGRAM] is not required by WebTransport and can safely be ignored by WebTransport endpoints. (3.3) + +%% 3.4. Application Protocol Negotiation + +application_protocol_negotiation(Config) -> + doc("Applications can negotiate a protocol to use via WebTransport. " + "(draft_webtrans_http3 3.4)"), + %% Connect to the WebTransport server. + WTAvailableProtocols = cow_http_hd:wt_available_protocols([<<"foo">>, <<"bar">>]), + #{ + resp_headers := RespHeaders + } = do_webtransport_connect(Config, [{<<"wt-available-protocols">>, WTAvailableProtocols}]), + {<<"wt-protocol">>, WTProtocol} = lists:keyfind(<<"wt-protocol">>, 1, RespHeaders), + <<"foo">> = iolist_to_binary(cow_http_hd:parse_wt_protocol(WTProtocol)), + ok. + +%% Both WT-Available-Protocols and WT-Protocol are Structured Fields [RFC8941]. WT-Available-Protocols is a List of Tokens, and WT-Protocol is a Token. The token in the WT-Protocol response header field MUST be one of the tokens listed in WT-Available-Protocols of the request. (3.4) + +%% @todo 3.5 Prioritization + +%% 4. WebTransport Features + +%% The client MAY optimistically open unidirectional and bidirectional streams, as well as send datagrams, for a session that it has sent the CONNECT request for, even if it has not yet received the server's response to the request. (4) + +%% If at any point a session ID is received that cannot be a valid ID for a client-initiated bidirectional stream, the recipient MUST close the connection with an H3_ID_ERROR error code. (4) +%% @todo Open bidi with Session ID 0, then do the CONNECT request. + +%% 4.1. Unidirectional streams + +unidirectional_streams(Config) -> + doc("Both endpoints can open and use unidirectional streams. " + "(draft_webtrans_http3 4.1)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + session_id := SessionID + } = do_webtransport_connect(Config), + %% Create a unidi stream, send Hello with a Fin flag. + {ok, LocalStreamRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(LocalStreamRef, + <<1:2, 16#54:14, 0:2, SessionID:6, "Hello">>, + ?QUIC_SEND_FLAG_FIN), + %% Accept an identical unidi stream. + {unidi, RemoteStreamRef} = do_receive_new_stream(), + {nofin, <<1:2, 16#54:14, 0:2, SessionID:6>>} = do_receive_data(RemoteStreamRef), + {fin, <<"Hello">>} = do_receive_data(RemoteStreamRef), + ok. + +%% 4.2. Bidirectional Streams + +bidirectional_streams_client(Config) -> + doc("The WT client can open and use bidirectional streams. " + "(draft_webtrans_http3 4.2)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + session_id := SessionID + } = do_webtransport_connect(Config), + %% Create a bidi stream, send Hello, get Hello back. + {ok, LocalStreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(LocalStreamRef, <<1:2, 16#41:14, 0:2, SessionID:6, "Hello">>), + {nofin, <<"Hello">>} = do_receive_data(LocalStreamRef), + ok. + +bidirectional_streams_server(Config) -> + doc("The WT server can open and use bidirectional streams. " + "(draft_webtrans_http3 4.2)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + session_id := SessionID + } = do_webtransport_connect(Config), + %% Create a bidi stream, send a special instruction + %% to make the server create another bidi stream. + {ok, LocalStreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(LocalStreamRef, <<1:2, 16#41:14, 0:2, SessionID:6, "TEST:open_bidi">>), + %% Accept the bidi stream and receive the data. + {bidi, RemoteStreamRef} = do_receive_new_stream(), + {nofin, <<1:2, 16#41:14, 0:2, SessionID:6>>} = do_receive_data(RemoteStreamRef), + {ok, _} = quicer:send(RemoteStreamRef, <<"Hello">>, + ?QUIC_SEND_FLAG_FIN), + {fin, <<"Hello">>} = do_receive_data(RemoteStreamRef), + ok. + +%% Endpoints MUST NOT send WT_STREAM as a frame type on HTTP/3 streams other than the very first bytes of a request stream. Receiving this frame type in any other circumstances MUST be treated as a connection error of type H3_FRAME_ERROR. (4.2) + +%% 4.3. Resetting Data Streams + +%% A WebTransport endpoint may send a RESET_STREAM or a STOP_SENDING frame for a WebTransport data stream. Those signals are propagated by the WebTransport implementation to the application. (4.3) + +%% A WebTransport application SHALL provide an error code for those operations. (4.3) + +%% WebTransport implementations MUST use the RESET_STREAM_AT frame [RESET-STREAM-AT] with a Reliable Size set to at least the size of the WebTransport header when resetting a WebTransport data stream. This ensures that the ID field associating the data stream with a WebTransport session is always delivered. (4.3) + +%% WebTransport implementations SHALL forward the error code for a stream associated with a known session to the application that owns that session (4.3) + +%% 4.4. Datagrams + +datagrams(Config) -> + doc("Both endpoints can send and receive datagrams. (draft_webtrans_http3 4.4)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + session_id := SessionID + } = do_webtransport_connect(Config), + QuarterID = SessionID div 4, + %% Send a Hello datagram. + {ok, _} = quicer:send_dgram(Conn, <<0:2, QuarterID:6, "Hello">>), + %% Receive a Hello datagram back. + {datagram, SessionID, <<"Hello">>} = do_receive_datagram(Conn), + ok. + +%% @todo datagrams_via_capsule? + +%% 4.5. Buffering Incoming Streams and Datagrams + +%% To handle this case (out of order stream_open/CONNECT), WebTransport endpoints SHOULD buffer streams and datagrams until those can be associated with an established session. (4.5) + +%% To avoid resource exhaustion, the endpoints MUST limit the number of buffered streams and datagrams. When the number of buffered streams is exceeded, a stream SHALL be closed by sending a RESET_STREAM and/or STOP_SENDING with the WT_BUFFERED_STREAM_REJECTED error code. When the number of buffered datagrams is exceeded, a datagram SHALL be dropped. It is up to an implementation to choose what stream or datagram to discard. (4.5) + +%% 4.6. Interaction with HTTP/3 GOAWAY frame + +%% A client receiving GOAWAY cannot initiate CONNECT requests for new WebTransport sessions on that HTTP/3 connection; it must open a new HTTP/3 connection to initiate new WebTransport sessions with the same peer. (4.6) + +%% An HTTP/3 GOAWAY frame is also a signal to applications to initiate shutdown for all WebTransport sessions. (4.6) + +%% @todo Currently receipt of a GOAWAY frame immediately ends the connection. +%% We want to allow WT sessions to gracefully shut down before that. +%goaway_client(Config) -> +% doc("The HTTP/3 client can initiate the close of all WT sessions " +% "by sending a GOAWAY frame. (draft_webtrans_http3 4.6)"), +% %% Connect to the WebTransport server. +% #{ +% conn := Conn, +% connect_stream_ref := ConnectStreamRef, +% session_id := SessionID +% } = do_webtransport_connect(Config), +% %% Open a control stream and send a GOAWAY frame. +% {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>>, %% GOAWAY frame. +% cow_http3:encode_int(1), +% cow_http3:encode_int(0) +% ]), +% %% Receive a datagram indicating processing by the WT handler. +% {datagram, SessionID, <<"TEST:close_initiated">>} = do_receive_datagram(Conn), +% ok. + +wt_drain_session_client(Config) -> + doc("The WT client can initiate the close of a single session. " + "(draft_webtrans_http3 4.6)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + connect_stream_ref := ConnectStreamRef, + session_id := SessionID + } = do_webtransport_connect(Config), + %% Send the WT_DRAIN_SESSION capsule on the CONNECT stream. + {ok, _} = quicer:send(ConnectStreamRef, cow_capsule:wt_drain_session()), + %% Receive a datagram indicating processing by the WT handler. + {datagram, SessionID, <<"TEST:close_initiated">>} = do_receive_datagram(Conn), + ok. + +wt_drain_session_server(Config) -> + doc("The WT server can initiate the close of a single session. " + "(draft_webtrans_http3 4.6)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + connect_stream_ref := ConnectStreamRef, + session_id := SessionID + } = do_webtransport_connect(Config), + %% Create a bidi stream, send a special instruction to make it initiate the close. + {ok, LocalStreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(LocalStreamRef, <<1:2, 16#41:14, 0:2, SessionID:6, "TEST:initiate_close">>), + %% Receive the WT_DRAIN_SESSION capsule on the CONNECT stream. + DrainWTSessionCapsule = cow_capsule:wt_drain_session(), + {nofin, DrainWTSessionCapsule} = do_receive_data(ConnectStreamRef), + ok. + +wt_drain_session_continue_client(Config) -> + doc("After the WT client has initiated the close of the session, " + "both client and server can continue using the session and " + "open new streams. (draft_webtrans_http3 4.6)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + connect_stream_ref := ConnectStreamRef, + session_id := SessionID + } = do_webtransport_connect(Config), + %% Send the WT_DRAIN_SESSION capsule on the CONNECT stream. + {ok, _} = quicer:send(ConnectStreamRef, cow_capsule:wt_drain_session()), + %% Receive a datagram indicating processing by the WT handler. + {datagram, SessionID, <<"TEST:close_initiated">>} = do_receive_datagram(Conn), + %% Create a new bidi stream, send Hello, get Hello back. + {ok, ContinueStreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(ContinueStreamRef, <<1:2, 16#41:14, 0:2, SessionID:6, "Hello">>), + {nofin, <<"Hello">>} = do_receive_data(ContinueStreamRef), + ok. + +wt_drain_session_continue_server(Config) -> + doc("After the WT server has initiated the close of the session, " + "both client and server can continue using the session and " + "open new streams. (draft_webtrans_http3 4.6)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + connect_stream_ref := ConnectStreamRef, + session_id := SessionID + } = do_webtransport_connect(Config), + %% Create a bidi stream, send a special instruction to make it initiate the close. + {ok, LocalStreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(LocalStreamRef, <<1:2, 16#41:14, 0:2, SessionID:6, "TEST:initiate_close">>), + %% Receive the WT_DRAIN_SESSION capsule on the CONNECT stream. + DrainWTSessionCapsule = cow_capsule:wt_drain_session(), + {nofin, DrainWTSessionCapsule} = do_receive_data(ConnectStreamRef), + %% Create a new bidi stream, send Hello, get Hello back. + {ok, ContinueStreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(ContinueStreamRef, <<1:2, 16#41:14, 0:2, SessionID:6, "Hello">>), + {nofin, <<"Hello">>} = do_receive_data(ContinueStreamRef), + ok. + +%% @todo 4.7. Use of Keying Material Exporters + +%% 5. Flow Control + +%% 5.1. Limiting the Number of Simultaneous Sessions + +%% This document defines a SETTINGS_WT_MAX_SESSIONS parameter that allows the server to limit the maximum number of concurrent WebTransport sessions on a single HTTP/3 connection. The client MUST NOT open more simultaneous sessions than indicated in the server SETTINGS parameter. The server MUST NOT close the connection if the client opens sessions exceeding this limit, as the client and the server do not have a consistent view of how many sessions are open due to the asynchronous nature of the protocol; instead, it MUST reset all of the CONNECT streams it is not willing to process with the H3_REQUEST_REJECTED status defined in [HTTP3]. (5.1) + +%% 5.2. Limiting the Number of Streams Within a Session + +%% The WT_MAX_STREAMS capsule (Section 5.6.1) establishes a limit on the number of streams within a WebTransport session. (5.2) + +%% Note that the CONNECT stream for the session is not included in either the bidirectional or the unidirectional stream limits (5.2) + +%% The session-level stream limit applies in addition to the QUIC MAX_STREAMS frame, which provides a connection-level stream limit. New streams can only be created within the session if both the stream- and the connection-level limit permit (5.2) + +%% The WT_STREAMS_BLOCKED capsule (Section 5.7) can be sent to indicate that an endpoint was unable to create a stream due to the session-level stream limit. (5.2) + +%% Note that enforcing this limit requires reliable resets for stream headers so that both endpoints can agree on the number of streams that are open. (5.2) + +%% 5.3. Data Limits + +%% The WT_MAX_DATA capsule (Section 5.8) establishes a limit on the amount of data that can be sent within a WebTransport session. This limit counts all data that is sent on streams of the corresponding type, excluding the stream header (see Section 4.1 and Section 4.2). (5.3) + +%% Implementing WT_MAX_DATA requires that the QUIC stack provide the WebTransport implementation with information about the final size of streams; see { {Section 4.5 of !RFC9000}}. This allows both endpoints to agree on how much data was consumed by that stream, although the stream header exclusion above applies. (5.3) + +%% The WT_DATA_BLOCKED capsule (Section 5.9) can be sent to indicate that an endpoint was unable to send data due to a limit set by the WT_MAX_DATA capsule. (5.3) + +%% The WT_MAX_STREAM_DATA and WT_STREAM_DATA_BLOCKED capsules (Part XX of [I-D.ietf-webtrans-http2]) are not used and so are prohibited. Endpoints MUST treat receipt of a WT_MAX_STREAM_DATA or a WT_STREAM_DATA_BLOCKED capsule as a session error. (5.3) + +%% 5.4. Flow Control and Intermediaries + +%% In practice, an intermediary that translates flow control signals between similar WebTransport protocols, such as between two HTTP/3 connections, can often simply reexpress the same limits received on one connection directly on the other connection. (5.4) + +%% 5.5. Flow Control SETTINGS + +%% WT_MAX_STREAMS via SETTINGS_WT_INITIAL_MAX_STREAMS_UNI and SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI (5.5) + +%% WT_MAX_DATA via SETTINGS_WT_INITIAL_MAX_DATA (5.5) + +%% 5.6. Flow Control Capsules + +%% 5.6.1. WT_MAX_STREAMS Capsule + +%% An HTTP capsule [HTTP-DATAGRAM] called WT_MAX_STREAMS is introduced to inform the peer of the cumulative number of streams of a given type it is permitted to open. A WT_MAX_STREAMS capsule with a type of 0x190B4D3F applies to bidirectional streams, and a WT_MAX_STREAMS capsule with a type of 0x190B4D40 applies to unidirectional streams. (5.6.1) + +%% Note that, because Maximum Streams is a cumulative value representing the total allowed number of streams, including previously closed streams, endpoints repeatedly send new WT_MAX_STREAMS capsules with increasing Maximum Streams values as streams are opened. (5.6.1) + +%% Maximum Streams: A count of the cumulative number of streams of the corresponding type that can be opened over the lifetime of the session. This value cannot exceed 260, as it is not possible to encode stream IDs larger than 262-1. (5.6.1) + +%% An endpoint MUST NOT open more streams than permitted by the current stream limit set by its peer. (5.6.1) + +%% Note that this limit includes streams that have been closed as well as those that are open. (5.6.1) + +%% Initial values for these limits MAY be communicated by sending non-zero values for SETTINGS_WT_INITIAL_MAX_STREAMS_UNI and SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI. (5.6.1) + +%% 5.7. WT_STREAMS_BLOCKED Capsule + +%% A sender SHOULD send a WT_STREAMS_BLOCKED capsule (type=0x190B4D43 for bidi or 0x190B4D44 for unidi) when it wishes to open a stream but is unable to do so due to the maximum stream limit set by its peer. (5.7) + +%% 5.8. WT_MAX_DATA Capsule + +%% An HTTP capsule [HTTP-DATAGRAM] called WT_MAX_DATA (type=0x190B4D3D) is introduced to inform the peer of the maximum amount of data that can be sent on the WebTransport session as a whole. (5.8) + +%% This limit counts all data that is sent on streams of the corresponding type, excluding the stream header (see Section 4.1 and Section 4.2). Implementing WT_MAX_DATA requires that the QUIC stack provide the WebTransport implementation with information about the final size of streams; see Section 4.5 of [RFC9000]. (5.8) + +%% All data sent in WT_STREAM capsules counts toward this limit. The sum of the lengths of Stream Data fields in WT_STREAM capsules MUST NOT exceed the value advertised by a receiver. (5.8) + +%% The initial value for this limit MAY be communicated by sending a non-zero value for SETTINGS_WT_INITIAL_MAX_DATA. (5.8) + +%% 5.9. WT_DATA_BLOCKED Capsule + +%% A sender SHOULD send a WT_DATA_BLOCKED capsule (type=0x190B4D41) when it wishes to send data but is unable to do so due to WebTransport session-level flow control. (5.9) + +%% WT_DATA_BLOCKED capsules can be used as input to tuning of flow control algorithms. (5.9) + +%% 6. Session Termination + +%% A WebTransport session over HTTP/3 is considered terminated when either of the following conditions is met: +%% * the CONNECT stream is closed, either cleanly or abruptly, on either side; or +%% * a WT_CLOSE_SESSION capsule is either sent or received. +%% (6) + +wt_close_session_client(Config) -> + doc("The WT client can close a single session. (draft_webtrans_http3 4.6)"), + %% Connect to the WebTransport server. + #{ + connect_stream_ref := ConnectStreamRef + } = do_webtransport_connect(Config), + %% Send the WT_CLOSE_SESSION capsule on the CONNECT stream. + {ok, _} = quicer:send(ConnectStreamRef, + cow_capsule:wt_close_session(0, <<>>), + ?QUIC_SEND_FLAG_FIN), + %% Normally we should also stop reading but in order to detect + %% that the server stops the stream we must not otherwise the + %% stream will be de facto closed on our end. + %% + %% The recipient must close or reset the stream in response. + receive + {quic, stream_closed, ConnectStreamRef, _} -> + ok + after 1000 -> + error({timeout, waiting_for_stream_closed}) + end. + +wt_close_session_server(Config) -> + doc("The WT server can close a single session. (draft_webtrans_http3 4.6)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + connect_stream_ref := ConnectStreamRef, + session_id := SessionID + } = do_webtransport_connect(Config), + %% Create a bidi stream, send a special instruction to make it initiate the close. + {ok, LocalStreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(LocalStreamRef, <<1:2, 16#41:14, 0:2, SessionID:6, "TEST:close">>), + %% Receive the WT_CLOSE_SESSION capsule on the CONNECT stream. + CloseWTSessionCapsule = cow_capsule:wt_close_session(0, <<>>), + {fin, CloseWTSessionCapsule} = do_receive_data(ConnectStreamRef), + ok. + +wt_session_gone_client(Config) -> + doc("Upon learning that the session has been terminated, " + "the WT server must reset associated streams with the " + "WEBTRANSPORT_SESSION_GONE error code. (draft_webtrans_http3 4.6)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + connect_stream_ref := ConnectStreamRef, + session_id := SessionID + } = do_webtransport_connect(Config), + %% Create a unidi stream. + {ok, LocalUnidiStreamRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(LocalUnidiStreamRef, + <<1:2, 16#54:14, 0:2, SessionID:6, "Hello">>), + %% Accept an identical unidi stream. + {unidi, RemoteUnidiStreamRef} = do_receive_new_stream(), + {nofin, <<1:2, 16#54:14, 0:2, SessionID:6>>} = do_receive_data(RemoteUnidiStreamRef), + {nofin, <<"Hello">>} = do_receive_data(RemoteUnidiStreamRef), + %% Create a bidi stream, send a special instruction + %% to make the server create another bidi stream. + {ok, LocalBidiStreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(LocalBidiStreamRef, <<1:2, 16#41:14, 0:2, SessionID:6, "TEST:open_bidi">>), + %% Accept the bidi stream and receive the data. + {bidi, RemoteBidiStreamRef} = do_receive_new_stream(), + {nofin, <<1:2, 16#41:14, 0:2, SessionID:6>>} = do_receive_data(RemoteBidiStreamRef), + {ok, _} = quicer:send(RemoteBidiStreamRef, <<"Hello">>), + {nofin, <<"Hello">>} = do_receive_data(RemoteBidiStreamRef), + %% Send the WT_CLOSE_SESSION capsule on the CONNECT stream. + {ok, _} = quicer:send(ConnectStreamRef, + cow_capsule:wt_close_session(0, <<>>), + ?QUIC_SEND_FLAG_FIN), + %% All streams from that WT session have been aborted. + #{reason := wt_session_gone} = do_wait_stream_aborted(LocalUnidiStreamRef), + #{reason := wt_session_gone} = do_wait_stream_aborted(RemoteUnidiStreamRef), + #{reason := wt_session_gone} = do_wait_stream_aborted(LocalBidiStreamRef), + #{reason := wt_session_gone} = do_wait_stream_aborted(RemoteBidiStreamRef), + ok. + +wt_session_gone_server(Config) -> + doc("After the session has been terminated by the WT server, " + "the WT server must reset associated streams with the " + "WT_SESSION_GONE error code. (draft_webtrans_http3 4.6)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + connect_stream_ref := ConnectStreamRef, + session_id := SessionID + } = do_webtransport_connect(Config), + %% Create a unidi stream. + {ok, LocalUnidiStreamRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(LocalUnidiStreamRef, + <<1:2, 16#54:14, 0:2, SessionID:6, "Hello">>), + %% Accept an identical unidi stream. + {unidi, RemoteUnidiStreamRef} = do_receive_new_stream(), + {nofin, <<1:2, 16#54:14, 0:2, SessionID:6>>} = do_receive_data(RemoteUnidiStreamRef), + {nofin, <<"Hello">>} = do_receive_data(RemoteUnidiStreamRef), + %% Create a bidi stream, send a special instruction + %% to make the server create another bidi stream. + {ok, LocalBidiStreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(LocalBidiStreamRef, <<1:2, 16#41:14, 0:2, SessionID:6, "TEST:open_bidi">>), + %% Accept the bidi stream and receive the data. + {bidi, RemoteBidiStreamRef} = do_receive_new_stream(), + {nofin, <<1:2, 16#41:14, 0:2, SessionID:6>>} = do_receive_data(RemoteBidiStreamRef), + {ok, _} = quicer:send(RemoteBidiStreamRef, <<"Hello">>), + {nofin, <<"Hello">>} = do_receive_data(RemoteBidiStreamRef), + + %% Send a special instruction to make the server initiate the close. + {ok, _} = quicer:send(LocalBidiStreamRef, <<"TEST:close">>), + %% Receive the WT_CLOSE_SESSION capsule on the CONNECT stream. + CloseWTSessionCapsule = cow_capsule:wt_close_session(0, <<>>), + {fin, CloseWTSessionCapsule} = do_receive_data(ConnectStreamRef), + %% All streams from that WT session have been aborted. + #{reason := wt_session_gone} = do_wait_stream_aborted(LocalUnidiStreamRef), + #{reason := wt_session_gone} = do_wait_stream_aborted(RemoteUnidiStreamRef), + #{reason := wt_session_gone} = do_wait_stream_aborted(LocalBidiStreamRef), + #{reason := wt_session_gone} = do_wait_stream_aborted(RemoteBidiStreamRef), + ok. + +%% Application Error Message: A UTF-8 encoded error message string provided by the application closing the session. The message takes up the remainder of the capsule, and its length MUST NOT exceed 1024 bytes. (6) +%% @todo What if it's larger? + +wt_close_session_app_code_msg_client(Config) -> + doc("The WT client can close a single session with an application error code " + "and an application error message. (draft_webtrans_http3 4.6)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + connect_stream_ref := ConnectStreamRef, + session_id := SessionID + } = do_webtransport_connect(Config), + %% Create a bidi stream, send a special instruction to make it propagate events. + {ok, LocalStreamRef} = quicer:start_stream(Conn, #{}), + EventPidBin = term_to_binary(self()), + {ok, _} = quicer:send(LocalStreamRef, <<1:2, 16#41:14, 0:2, SessionID:6, + "TEST:event_pid:", EventPidBin/binary>>), + %% Send the WT_CLOSE_SESSION capsule on the CONNECT stream. + {ok, _} = quicer:send(ConnectStreamRef, + cow_capsule:wt_close_session(17, <<"seventeen">>), + ?QUIC_SEND_FLAG_FIN), + %% @todo Stop reading from the CONNECt stream too. (STOP_SENDING) + %% Receive the terminate event from the WT handler. + receive + {'$wt_echo_h', terminate, {closed, 17, <<"seventeen">>}, _, _} -> + ok + after 1000 -> + error({timeout, waiting_for_terminate_event}) + end. + +wt_close_session_app_code_server(Config) -> + doc("The WT server can close a single session with an application error code. " + "(draft_webtrans_http3 4.6)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + connect_stream_ref := ConnectStreamRef, + session_id := SessionID + } = do_webtransport_connect(Config), + %% Create a bidi stream, send a special instruction to make it initiate the close. + {ok, LocalStreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(LocalStreamRef, <<1:2, 16#41:14, 0:2, SessionID:6, + "TEST:close_app_code">>), + %% Receive the WT_CLOSE_SESSION capsule on the CONNECT stream. + CloseWTSessionCapsule = cow_capsule:wt_close_session(1234567890, <<>>), + {fin, CloseWTSessionCapsule} = do_receive_data(ConnectStreamRef), + ok. + +wt_close_session_app_code_msg_server(Config) -> + doc("The WT server can close a single session with an application error code " + "and an application error message. (draft_webtrans_http3 4.6)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + connect_stream_ref := ConnectStreamRef, + session_id := SessionID + } = do_webtransport_connect(Config), + %% Create a bidi stream, send a special instruction to make it initiate the close. + {ok, LocalStreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(LocalStreamRef, <<1:2, 16#41:14, 0:2, SessionID:6, + "TEST:close_app_code_msg">>), + %% Receive the WT_CLOSE_SESSION capsule on the CONNECT stream. + CloseWTSessionCapsule = iolist_to_binary(cow_capsule:wt_close_session(1234567890, + <<"onetwothreefourfivesixseveneightnineten">>)), + {fin, CloseWTSessionCapsule} = do_receive_data(ConnectStreamRef), + ok. + +%% An endpoint that sends a WT_CLOSE_SESSION capsule MUST immediately send a FIN. The endpoint MAY send a STOP_SENDING to indicate it is no longer reading from the CONNECT stream. The recipient MUST either close or reset the stream in response. (6) +%% @todo wt_close_session_server_fin +%% @todo The part about close/reset should be tested in wt_close_session_client. + +%% If any additional stream data is received on the CONNECT stream after receiving a WT_CLOSE_SESSION capsule, the stream MUST be reset with code H3_MESSAGE_ERROR. (6) +%% @todo wt_close_session_followed_by_data + +connect_stream_closed_cleanly_fin(Config) -> + doc("The WT client closing the CONNECT stream cleanly " + "is equivalent to a capsule with an application error code of 0 " + "and an empty error string. (draft_webtrans_http3 4.6)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + connect_stream_ref := ConnectStreamRef, + session_id := SessionID + } = do_webtransport_connect(Config), + %% Create a bidi stream, send a special instruction to make it propagate events. + {ok, LocalStreamRef} = quicer:start_stream(Conn, #{}), + EventPidBin = term_to_binary(self()), + {ok, _} = quicer:send(LocalStreamRef, <<1:2, 16#41:14, 0:2, SessionID:6, + "TEST:event_pid:", EventPidBin/binary>>), + {nofin, <<"event_pid_received">>} = do_receive_data(LocalStreamRef), + %% Cleanly terminate the CONNECT stream. + {ok, _} = quicer:send(ConnectStreamRef, <<>>, ?QUIC_SEND_FLAG_FIN), + %% Receive the terminate event from the WT handler. + receive + {'$wt_echo_h', terminate, {closed, 0, <<>>}, _, _} -> + ok + after 1000 -> + error({timeout, waiting_for_terminate_event}) + end. + +connect_stream_closed_cleanly_shutdown(Config) -> + doc("The WT client closing the CONNECT stream cleanly " + "is equivalent to a capsule with an application error code of 0 " + "and an empty error string. (draft_webtrans_http3 4.6)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + connect_stream_ref := ConnectStreamRef, + session_id := SessionID + } = do_webtransport_connect(Config), + %% Create a bidi stream, send a special instruction to make it propagate events. + {ok, LocalStreamRef} = quicer:start_stream(Conn, #{}), + EventPidBin = term_to_binary(self()), + {ok, _} = quicer:send(LocalStreamRef, <<1:2, 16#41:14, 0:2, SessionID:6, + "TEST:event_pid:", EventPidBin/binary>>), + {nofin, <<"event_pid_received">>} = do_receive_data(LocalStreamRef), + %% Cleanly terminate the CONNECT stream. + _ = quicer:shutdown_stream(ConnectStreamRef), + %% Receive the terminate event from the WT handler. + receive + {'$wt_echo_h', terminate, {closed, 0, <<>>}, _, _} -> + ok + after 1000 -> + error({timeout, waiting_for_terminate_event}) + end. + +connect_stream_closed_abruptly(Config) -> + doc("The WT client may close the CONNECT stream abruptly. " + "(draft_webtrans_http3 4.6)"), + %% Connect to the WebTransport server. + #{ + conn := Conn, + connect_stream_ref := ConnectStreamRef, + session_id := SessionID + } = do_webtransport_connect(Config), + %% Create a bidi stream, send a special instruction to make it propagate events. + {ok, LocalStreamRef} = quicer:start_stream(Conn, #{}), + EventPidBin = term_to_binary(self()), + {ok, _} = quicer:send(LocalStreamRef, <<1:2, 16#41:14, 0:2, SessionID:6, + "TEST:event_pid:", EventPidBin/binary>>), + {nofin, <<"event_pid_received">>} = do_receive_data(LocalStreamRef), + %% Abruptly terminate the CONNECT stream. + _ = quicer:shutdown_stream(ConnectStreamRef, ?QUIC_STREAM_SHUTDOWN_FLAG_ABORT, + 0, infinity), + %% Receive the terminate event from the WT handler. + receive + %% @todo It would be good to forward a stream error as well + %% so that a WT error can be sent, but I have been unsuccessful. + {'$wt_echo_h', terminate, closed_abruptly, _, _} -> + ok + after 1000 -> + error({timeout, waiting_for_terminate_event}) + end. + +%% @todo This one is about gracefully closing HTTP/3 connection with WT sessions. +%% the endpoint SHOULD wait until all CONNECT streams have been closed by the peer before sending the CONNECTION_CLOSE (6) + +%% Helpers. + +do_webtransport_connect(Config) -> + do_webtransport_connect(Config, []). + +do_webtransport_connect(Config, ExtraHeaders) -> + %% Connect to server. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config, #{ + peer_unidi_stream_count => 100, + datagram_send_enabled => 1, + datagram_receive_enabled => 1 + }), + %% Confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{enable_connect_protocol := true} = Settings, + %% Confirm that SETTINGS_WT_MAX_SESSIONS >= 1. + #{wt_max_sessions := WTMaxSessions} = Settings, + true = WTMaxSessions >= 1, + %% Confirm that SETTINGS_H3_DATAGRAM = 1. + #{h3_datagram := true} = Settings, + %% Confirm that QUIC's max_datagram_size > 0. + receive {quic, dgram_state_changed, Conn, DatagramState} -> + #{ + dgram_max_len := DatagramMaxLen, + dgram_send_enabled := DatagramSendEnabled + } = DatagramState, + true = DatagramMaxLen > 0, + true = DatagramSendEnabled, + ok + after 5000 -> + error({timeout, waiting_for_datagram_state_change}) + end, + %% Send a CONNECT :protocol request to upgrade the stream to Websocket. + {ok, ConnectStreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"webtransport">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/wt">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"origin">>, <<"https://localhost">>} + |ExtraHeaders], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(ConnectStreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% Receive a 200 response. + {nofin, Data} = do_receive_data(ConnectStreamRef), + {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), + %% Retrieve the Session ID. + {ok, SessionID} = quicer:get_stream_id(ConnectStreamRef), + %% Accept QPACK streams to avoid conflicts with unidi streams from tests. + Unidi1 = rfc9114_SUITE:do_accept_qpack_stream(Conn), + Unidi2 = rfc9114_SUITE:do_accept_qpack_stream(Conn), + %% Done. + #{ + conn => Conn, + connect_stream_ref => ConnectStreamRef, + session_id => SessionID, + resp_headers => DecodedResponse, + enc_or_dec1 => Unidi1, + enc_or_dec2 => Unidi2 + }. + +do_receive_new_stream() -> + receive + {quic, new_stream, StreamRef, #{flags := Flags}} -> + ok = quicer:setopt(StreamRef, active, true), + case quicer:is_unidirectional(Flags) of + true -> {unidi, StreamRef}; + false -> {bidi, StreamRef} + end + after 5000 -> + error({timeout, waiting_for_stream}) + end. + +do_receive_data(StreamRef) -> + receive {quic, Data, StreamRef, #{flags := Flags}} -> + IsFin = case Flags band ?QUIC_RECEIVE_FLAG_FIN of + ?QUIC_RECEIVE_FLAG_FIN -> fin; + _ -> nofin + end, + {IsFin, Data} + after 5000 -> + error({timeout, waiting_for_data}) + end. + +do_receive_datagram(Conn) -> + receive {quic, <<0:2, QuarterID:6, Data/bits>>, Conn, Flags} when is_integer(Flags) -> + {datagram, QuarterID * 4, Data} + after 5000 -> + ct:pal("~p", [process_info(self(), messages)]), + error({timeout, waiting_for_datagram}) + end. + +-endif. diff --git a/test/examples_SUITE.erl b/test/examples_SUITE.erl index c60315b..3d7c48b 100644 --- a/test/examples_SUITE.erl +++ b/test/examples_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2016-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -35,10 +35,10 @@ init_per_suite(Config) -> %% reuse the same build across all tests. Make = do_find_make_cmd(), CommonDir = config(priv_dir, Config), - ct:log("~s~n", [os:cmd("git clone --depth 1 https://github.com/ninenines/cowboy " + ct:log("~ts~n", [os:cmd("git clone --depth 1 https://github.com/ninenines/cowboy " ++ CommonDir ++ "cowboy")]), - ct:log("~s~n", [os:cmd(Make ++ " -C " ++ CommonDir ++ "cowboy distclean")]), - ct:log("~s~n", [os:cmd(Make ++ " -C " ++ CommonDir ++ "cowboy DEPS_DIR=" ++ CommonDir)]), + ct:log("~ts~n", [os:cmd(Make ++ " -C " ++ CommonDir ++ "cowboy distclean")]), + ct:log("~ts~n", [os:cmd(Make ++ " -C " ++ CommonDir ++ "cowboy DEPS_DIR=" ++ CommonDir)]), Config. end_per_suite(_) -> @@ -70,24 +70,24 @@ do_get_paths(Example0) -> do_compile_and_start(Example, Config) -> Make = do_find_make_cmd(), {Dir, Rel, _} = do_get_paths(Example), - ct:log("~s~n", [os:cmd(Make ++ " -C " ++ Dir ++ " distclean")]), + ct:log("~ts~n", [os:cmd(Make ++ " -C " ++ Dir ++ " distclean")]), %% We use a common build for Cowboy, Cowlib and Ranch to speed things up. CommonDir = config(priv_dir, Config), - ct:log("~s~n", [os:cmd("mkdir " ++ Dir ++ "/deps")]), - ct:log("~s~n", [os:cmd("ln -s " ++ CommonDir ++ "cowboy " ++ Dir ++ "/deps/cowboy")]), - ct:log("~s~n", [os:cmd("ln -s " ++ CommonDir ++ "cowlib " ++ Dir ++ "/deps/cowlib")]), - ct:log("~s~n", [os:cmd("ln -s " ++ CommonDir ++ "ranch " ++ Dir ++ "/deps/ranch")]), + ct:log("~ts~n", [os:cmd("mkdir " ++ Dir ++ "/deps")]), + ct:log("~ts~n", [os:cmd("ln -s " ++ CommonDir ++ "cowboy " ++ Dir ++ "/deps/cowboy")]), + ct:log("~ts~n", [os:cmd("ln -s " ++ CommonDir ++ "cowlib " ++ Dir ++ "/deps/cowlib")]), + ct:log("~ts~n", [os:cmd("ln -s " ++ CommonDir ++ "ranch " ++ Dir ++ "/deps/ranch")]), %% TERM=dumb disables relx coloring. - ct:log("~s~n", [os:cmd(Make ++ " -C " ++ Dir ++ " TERM=dumb")]), - ct:log("~s~n", [os:cmd(Rel ++ " stop")]), - ct:log("~s~n", [os:cmd(Rel ++ " start")]), + ct:log("~ts~n", [os:cmd(Make ++ " -C " ++ Dir ++ " TERM=dumb")]), + ct:log("~ts~n", [os:cmd(Rel ++ " stop")]), + ct:log("~ts~n", [os:cmd(Rel ++ " daemon")]), timer:sleep(2000), ok. do_stop(Example) -> {_, Rel, Log} = do_get_paths(Example), - ct:log("~s~n", [os:cmd(Rel ++ " stop")]), - ct:log("~s~n", [element(2, file:read_file(Log))]), + ct:log("~ts~n", [os:cmd(Rel ++ " stop")]), + ct:log("~ts~n", [element(2, file:read_file(Log))]), ok. %% Fetch a response. @@ -372,13 +372,16 @@ file_server(Config) -> do_file_server(Transport, Protocol, Config) -> %% Directory. {200, DirHeaders, <<"<!DOCTYPE html><html>", _/bits >>} = do_get(Transport, Protocol, "/", Config), - {_, <<"text/html">>} = lists:keyfind(<<"content-type">>, 1, DirHeaders), + {_, <<"text/html; charset=utf-8">>} = lists:keyfind(<<"content-type">>, 1, DirHeaders), _ = do_rest_get(Transport, Protocol, "/", <<"application/json">>, undefined, Config), %% Files. {200, _, _} = do_get(Transport, Protocol, "/small.mp4", Config), {200, _, _} = do_get(Transport, Protocol, "/small.ogv", Config), {200, _, _} = do_get(Transport, Protocol, "/test.txt", Config), {200, _, _} = do_get(Transport, Protocol, "/video.html", Config), + {200, _, _} = do_get(Transport, Protocol, + ["/", cow_uri:urlencode(<<"中文"/utf8>>), "/", cow_uri:urlencode(<<"中文.html"/utf8>>)], + Config), ok. %% Markdown middleware. @@ -458,14 +461,14 @@ websocket(Config) -> exit(timeout) end, %% Check that we receive the echoed message. - gun:ws_send(Pid, {text, <<"hello">>}), + gun:ws_send(Pid, StreamRef, {text, <<"hello">>}), receive {gun_ws, Pid, StreamRef, {text, <<"That's what she said! hello">>}} -> ok after 500 -> exit(timeout) end, - gun:ws_send(Pid, close) + gun:ws_send(Pid, StreamRef, close) after do_stop(websocket) end. diff --git a/test/h2spec_SUITE.erl b/test/h2spec_SUITE.erl index 08497e9..71a8a41 100644 --- a/test/h2spec_SUITE.erl +++ b/test/h2spec_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/test/handlers/compress_h.erl b/test/handlers/compress_h.erl index 27edbd3..658c834 100644 --- a/test/handlers/compress_h.erl +++ b/test/handlers/compress_h.erl @@ -19,6 +19,9 @@ init(Req0, State=reply) -> <<"content-encoding">> -> cowboy_req:reply(200, #{<<"content-encoding">> => <<"compress">>}, lists:duplicate(100000, $a), Req0); + <<"etag">> -> + cowboy_req:reply(200, #{<<"etag">> => <<"\"STRONK\"">>}, + lists:duplicate(100000, $a), Req0); <<"sendfile">> -> AppFile = code:where_is_file("cowboy.app"), Size = filelib:file_size(AppFile), @@ -34,6 +37,8 @@ init(Req0, State=stream_reply) -> stream_reply(#{}, Req0); <<"content-encoding">> -> stream_reply(#{<<"content-encoding">> => <<"compress">>}, Req0); + <<"etag">> -> + stream_reply(#{<<"etag">> => <<"\"STRONK\"">>}, Req0); <<"sendfile">> -> Data = lists:duplicate(10000, $a), AppFile = code:where_is_file("cowboy.app"), diff --git a/test/handlers/content_types_provided_h.erl b/test/handlers/content_types_provided_h.erl index 5220c19..397026b 100644 --- a/test/handlers/content_types_provided_h.erl +++ b/test/handlers/content_types_provided_h.erl @@ -11,9 +11,14 @@ init(Req, Opts) -> {cowboy_rest, Req, Opts}. +content_types_provided(Req=#{qs := <<"invalid-type">>}, State) -> + ct_helper:ignore(cowboy_rest, normalize_content_types, 2), + {[{{'*', '*', '*'}, get_text_plain}], Req, State}; content_types_provided(Req=#{qs := <<"wildcard-param">>}, State) -> {[{{<<"text">>, <<"plain">>, '*'}, get_text_plain}], Req, State}. +get_text_plain(Req=#{qs := <<"invalid-type">>}, State) -> + {<<"invalid-type">>, Req, State}; get_text_plain(Req=#{qs := <<"wildcard-param">>}, State) -> {_, _, Param} = maps:get(media_type, Req), Body = if diff --git a/test/handlers/crash_h.erl b/test/handlers/crash_h.erl index b687aba..57d4d85 100644 --- a/test/handlers/crash_h.erl +++ b/test/handlers/crash_h.erl @@ -7,6 +7,9 @@ -export([init/2]). -spec init(_, _) -> no_return(). +init(_, external_exit) -> + ct_helper:ignore(?MODULE, init, 2), + exit(self(), ct_helper_ignore); init(_, no_reply) -> ct_helper:ignore(?MODULE, init, 2), error(crash); diff --git a/test/handlers/create_resource_h.erl b/test/handlers/create_resource_h.erl new file mode 100644 index 0000000..f82e610 --- /dev/null +++ b/test/handlers/create_resource_h.erl @@ -0,0 +1,28 @@ +-module(create_resource_h). + +-export([init/2]). +-export([allowed_methods/2]). +-export([resource_exists/2]). +-export([content_types_accepted/2]). +-export([from_text/2]). + +init(Req, Opts) -> + {cowboy_rest, Req, Opts}. + +allowed_methods(Req, State) -> + {[<<"POST">>], Req, State}. + +resource_exists(Req, State) -> + {true, Req, State}. + +content_types_accepted(Req, State) -> + {[{{<<"application">>, <<"text">>, []}, from_text}], Req, State}. + +from_text(Req=#{qs := Qs}, State) -> + NewURI = [cowboy_req:uri(Req), "/foo"], + case Qs of + <<"created">> -> + {{created, NewURI}, Req, State}; + <<"see_other">> -> + {{see_other, NewURI}, Req, State} + end. diff --git a/test/handlers/decompress_h.erl b/test/handlers/decompress_h.erl new file mode 100644 index 0000000..deb6de0 --- /dev/null +++ b/test/handlers/decompress_h.erl @@ -0,0 +1,84 @@ +%% This module echoes a request body of to test +%% the cowboy_decompress_h stream handler. + +-module(decompress_h). + +-export([init/2]). + +init(Req0, State=echo) -> + case cowboy_req:binding(what, Req0) of + <<"decompress_disable">> -> + cowboy_req:cast({set_options, #{decompress_enabled => false}}, Req0); + <<"decompress_ratio_limit">> -> + cowboy_req:cast({set_options, #{decompress_ratio_limit => 0.5}}, Req0); + <<"normal">> -> ok + end, + {ok, Body, Req1} = read_body(Req0), + Req = cowboy_req:reply(200, #{}, Body, Req1), + {ok, Req, State}; +init(Req0, State=test) -> + Req = test(Req0, cowboy_req:binding(what, Req0)), + {ok, Req, State}. + +test(Req, <<"content-encoding">>) -> + cowboy_req:reply(200, #{}, + cowboy_req:header(<<"content-encoding">>, Req, <<"undefined">>), + Req); +test(Req, <<"content-decoded">>) -> + cowboy_req:reply(200, #{}, + io_lib:format("~0p", [maps:get(content_decoded, Req, undefined)]), + Req); +test(Req0, <<"disable-in-the-middle">>) -> + {Status, Data, Req1} = cowboy_req:read_body(Req0, #{length => 1000}), + cowboy_req:cast({set_options, #{decompress_enabled => false}}, Req1), + {ok, Body, Req} = do_read_body(Status, Req1, Data), + cowboy_req:reply(200, #{}, Body, Req); +test(Req0, <<"enable-in-the-middle">>) -> + {Status, Data, Req1} = cowboy_req:read_body(Req0, #{length => 1000}), + cowboy_req:cast({set_options, #{decompress_enabled => true}}, Req1), + {ok, Body, Req} = do_read_body(Status, Req1, Data), + cowboy_req:reply(200, #{}, Body, Req); +test(Req0, <<"header-command">>) -> + {ok, Body, Req1} = read_body(Req0), + Req = cowboy_req:stream_reply(200, #{}, Req1), + cowboy_req:stream_body(Body, fin, Req); +test(Req0, <<"accept-identity">>) -> + {ok, Body, Req} = read_body(Req0), + cowboy_req:reply(200, + #{<<"accept-encoding">> => <<"identity">>}, + Body, Req); +test(Req0, <<"invalid-header">>) -> + {ok, Body, Req} = read_body(Req0), + cowboy_req:reply(200, + #{<<"accept-encoding">> => <<";">>}, + Body, Req); +test(Req0, <<"reject-explicit-header">>) -> + {ok, Body, Req} = read_body(Req0), + cowboy_req:reply(200, + #{<<"accept-encoding">> => <<"identity, gzip;q=0">>}, + Body, Req); +test(Req0, <<"reject-implicit-header">>) -> + {ok, Body, Req} = read_body(Req0), + cowboy_req:reply(200, + #{<<"accept-encoding">> => <<"identity, *;q=0">>}, + Body, Req); +test(Req0, <<"accept-explicit-header">>) -> + {ok, Body, Req} = read_body(Req0), + cowboy_req:reply(200, + #{<<"accept-encoding">> => <<"identity, gzip;q=0.5">>}, + Body, Req); +test(Req0, <<"accept-implicit-header">>) -> + {ok, Body, Req} = read_body(Req0), + cowboy_req:reply(200, + #{<<"accept-encoding">> => <<"identity, *;q=0.5">>}, + Body, Req). + +read_body(Req0) -> + {Status, Data, Req} = cowboy_req:read_body(Req0, #{length => 1000}), + do_read_body(Status, Req, Data). + +do_read_body(more, Req0, Acc) -> + {Status, Data, Req} = cowboy_req:read_body(Req0), + do_read_body(Status, Req, << Acc/binary, Data/binary >>); +do_read_body(ok, Req, Acc) -> + {ok, Acc, Req}. diff --git a/test/handlers/delay_hello_h.erl b/test/handlers/delay_hello_h.erl index 7e59be6..ee3ee9c 100644 --- a/test/handlers/delay_hello_h.erl +++ b/test/handlers/delay_hello_h.erl @@ -4,6 +4,14 @@ -export([init/2]). -init(Req, Delay) -> +init(Req, Delay) when is_integer(Delay) -> + init(Req, #{delay => Delay}); +init(Req, Opts=#{delay := Delay}) -> + _ = case Opts of + #{notify_received := Pid} -> + Pid ! {request_received, maps:get(path, Req)}; + _ -> + ok + end, timer:sleep(Delay), {ok, cowboy_req:reply(200, #{}, <<"Hello world!">>, Req), Delay}. diff --git a/test/handlers/echo_h.erl b/test/handlers/echo_h.erl index 1b672d1..d04d531 100644 --- a/test/handlers/echo_h.erl +++ b/test/handlers/echo_h.erl @@ -25,6 +25,8 @@ echo(<<"read_body">>, Req0, Opts) -> timer:sleep(500), cowboy_req:read_body(Req0); <<"/full", _/bits>> -> read_body(Req0, <<>>); + <<"/auto-sync", _/bits>> -> read_body_auto_sync(Req0, <<>>); + <<"/auto-async", _/bits>> -> read_body_auto_async(Req0, <<>>); <<"/length", _/bits>> -> {_, _, Req1} = read_body(Req0, <<>>), Length = cowboy_req:body_length(Req1), @@ -84,6 +86,7 @@ echo(<<"match">>, Req, Opts) -> Fields = [binary_to_atom(F, latin1) || F <- Fields0], Value = case Type of <<"qs">> -> cowboy_req:match_qs(Fields, Req); + <<"qs_with_constraints">> -> cowboy_req:match_qs([{id, integer}], Req); <<"cookies">> -> cowboy_req:match_cookies(Fields, Req); <<"body_qs">> -> %% Note that the Req should not be discarded but for the @@ -122,6 +125,25 @@ read_body(Req0, Acc) -> {more, Data, Req} -> read_body(Req, << Acc/binary, Data/binary >>) end. +read_body_auto_sync(Req0, Acc) -> + Opts = #{length => auto, period => infinity}, + case cowboy_req:read_body(Req0, Opts) of + {ok, Data, Req} -> {ok, << Acc/binary, Data/binary >>, Req}; + {more, Data, Req} -> read_body_auto_sync(Req, << Acc/binary, Data/binary >>) + end. + +read_body_auto_async(Req, Acc) -> + read_body_auto_async(Req, make_ref(), Acc). + +read_body_auto_async(Req, ReadBodyRef, Acc) -> + cowboy_req:cast({read_body, self(), ReadBodyRef, auto, infinity}, Req), + receive + {request_body, ReadBodyRef, nofin, Data} -> + read_body_auto_async(Req, ReadBodyRef, <<Acc/binary, Data/binary>>); + {request_body, ReadBodyRef, fin, _, Data} -> + {ok, <<Acc/binary, Data/binary>>, Req} + end. + value_to_iodata(V) when is_integer(V) -> integer_to_binary(V); value_to_iodata(V) when is_atom(V) -> atom_to_binary(V, latin1); value_to_iodata(V) when is_list(V); is_tuple(V); is_map(V) -> io_lib:format("~999999p", [V]); diff --git a/test/handlers/generate_etag_h.erl b/test/handlers/generate_etag_h.erl index 97ee82b..b9e1302 100644 --- a/test/handlers/generate_etag_h.erl +++ b/test/handlers/generate_etag_h.erl @@ -34,6 +34,9 @@ generate_etag(Req=#{qs := <<"binary-weak-unquoted">>}, State) -> generate_etag(Req=#{qs := <<"binary-strong-unquoted">>}, State) -> ct_helper_error_h:ignore(cow_http_hd, parse_etag, 1), {<<"etag-header-value">>, Req, State}; +%% Returning 'undefined' to indicate no etag. +generate_etag(Req=#{qs := <<"undefined">>}, State) -> + {undefined, Req, State}; %% Simulate the callback being missing in other cases. generate_etag(#{qs := <<"missing">>}, _) -> no_call. diff --git a/test/handlers/loop_handler_endless_h.erl b/test/handlers/loop_handler_endless_h.erl new file mode 100644 index 0000000..d8c8ab5 --- /dev/null +++ b/test/handlers/loop_handler_endless_h.erl @@ -0,0 +1,25 @@ +%% This module implements a loop handler that streams endless data. + +-module(loop_handler_endless_h). + +-export([init/2]). +-export([info/3]). + +init(Req0, #{delay := Delay} = Opts) -> + case cowboy_req:header(<<"x-test-pid">>, Req0) of + BinPid when is_binary(BinPid) -> + Pid = list_to_pid(binary_to_list(BinPid)), + Pid ! {Pid, self(), init}, + ok; + _ -> + ok + end, + erlang:send_after(Delay, self(), timeout), + Req = cowboy_req:stream_reply(200, Req0), + {cowboy_loop, Req, Opts}. + +info(timeout, Req, State) -> + cowboy_req:stream_body(<<0:10000/unit:8>>, nofin, Req), + %% Equivalent to a 0 timeout. + self() ! timeout, + {ok, Req, State}. diff --git a/test/handlers/loop_handler_timeout_hibernate_h.erl b/test/handlers/loop_handler_timeout_hibernate_h.erl new file mode 100644 index 0000000..0485208 --- /dev/null +++ b/test/handlers/loop_handler_timeout_hibernate_h.erl @@ -0,0 +1,30 @@ +%% This module implements a loop handler that first +%% sets a timeout, then hibernates, then ensures +%% that the timeout initially set no longer triggers. +%% If everything goes fine a 200 is returned. If the +%% timeout triggers again a 299 is. + +-module(loop_handler_timeout_hibernate_h). + +-export([init/2]). +-export([info/3]). +-export([terminate/3]). + +init(Req, _) -> + self() ! message1, + {cowboy_loop, Req, undefined, 100}. + +info(message1, Req, State) -> + erlang:send_after(200, self(), message2), + {ok, Req, State, hibernate}; +info(message2, Req, State) -> + erlang:send_after(200, self(), message3), + %% Don't set a timeout now. + {ok, Req, State}; +info(message3, Req, State) -> + {stop, cowboy_req:reply(200, Req), State}; +info(timeout, Req, State) -> + {stop, cowboy_req:reply(<<"299 OK!">>, Req), State}. + +terminate(stop, _, _) -> + ok. diff --git a/test/handlers/loop_handler_timeout_info_h.erl b/test/handlers/loop_handler_timeout_info_h.erl new file mode 100644 index 0000000..7a1ccba --- /dev/null +++ b/test/handlers/loop_handler_timeout_info_h.erl @@ -0,0 +1,23 @@ +%% This module implements a loop handler that changes +%% the timeout value to 500ms after the first message +%% then sends itself another message after 1000ms. +%% It is expected to timeout, that is, reply a 299. + +-module(loop_handler_timeout_info_h). + +-export([init/2]). +-export([info/3]). +-export([terminate/3]). + +init(Req, _) -> + self() ! message, + {cowboy_loop, Req, undefined}. + +info(message, Req, State) -> + erlang:send_after(500, self(), message), + {ok, Req, State, 100}; +info(timeout, Req, State) -> + {stop, cowboy_req:reply(<<"299 OK!">>, Req), State}. + +terminate(stop, _, _) -> + ok. diff --git a/test/handlers/loop_handler_timeout_init_h.erl b/test/handlers/loop_handler_timeout_init_h.erl new file mode 100644 index 0000000..7908fda --- /dev/null +++ b/test/handlers/loop_handler_timeout_init_h.erl @@ -0,0 +1,23 @@ +%% This module implements a loop handler that reads +%% the request query for a timeout value, then sends +%% itself a message after 1000ms. It replies a 200 when +%% the message does not timeout and a 299 otherwise. + +-module(loop_handler_timeout_init_h). + +-export([init/2]). +-export([info/3]). +-export([terminate/3]). + +init(Req, _) -> + #{timeout := Timeout} = cowboy_req:match_qs([{timeout, int}], Req), + erlang:send_after(500, self(), message), + {cowboy_loop, Req, undefined, Timeout}. + +info(message, Req, State) -> + {stop, cowboy_req:reply(200, Req), State}; +info(timeout, Req, State) -> + {stop, cowboy_req:reply(<<"299 OK!">>, Req), State}. + +terminate(stop, _, _) -> + ok. diff --git a/test/handlers/read_body_h.erl b/test/handlers/read_body_h.erl new file mode 100644 index 0000000..a0de3b3 --- /dev/null +++ b/test/handlers/read_body_h.erl @@ -0,0 +1,15 @@ +%% This module reads the request body fully and send a 204 response. + +-module(read_body_h). + +-export([init/2]). + +init(Req0, Opts) -> + {ok, Req} = read_body(Req0), + {ok, cowboy_req:reply(200, #{}, Req), Opts}. + +read_body(Req0) -> + case cowboy_req:read_body(Req0) of + {ok, _, Req} -> {ok, Req}; + {more, _, Req} -> read_body(Req) + end. diff --git a/test/handlers/resp_h.erl b/test/handlers/resp_h.erl index 8031d0e..d1c46e0 100644 --- a/test/handlers/resp_h.erl +++ b/test/handlers/resp_h.erl @@ -30,6 +30,10 @@ do(<<"set_resp_cookie4">>, Req0, Opts) -> do(<<"set_resp_header">>, Req0, Opts) -> Req = cowboy_req:set_resp_header(<<"content-type">>, <<"text/plain">>, Req0), {ok, cowboy_req:reply(200, #{}, "OK", Req), Opts}; +do(<<"set_resp_header_cookie">>, Req0, Opts) -> + ct_helper:ignore(cowboy_req, set_resp_header, 3), + Req = cowboy_req:set_resp_header(<<"set-cookie">>, <<"name=value">>, Req0), + {ok, cowboy_req:reply(200, #{}, "OK", Req), Opts}; do(<<"set_resp_header_server">>, Req0, Opts) -> Req = cowboy_req:set_resp_header(<<"server">>, <<"nginx">>, Req0), {ok, cowboy_req:reply(200, #{}, "OK", Req), Opts}; @@ -39,6 +43,27 @@ do(<<"set_resp_headers">>, Req0, Opts) -> <<"content-encoding">> => <<"compress">> }, Req0), {ok, cowboy_req:reply(200, #{}, "OK", Req), Opts}; +do(<<"set_resp_headers_list">>, Req0, Opts) -> + Req = cowboy_req:set_resp_headers([ + {<<"content-type">>, <<"text/plain">>}, + {<<"test-header">>, <<"one">>}, + {<<"content-encoding">>, <<"compress">>}, + {<<"test-header">>, <<"two">>} + ], Req0), + {ok, cowboy_req:reply(200, #{}, "OK", Req), Opts}; +do(<<"set_resp_headers_cookie">>, Req0, Opts) -> + ct_helper:ignore(cowboy_req, set_resp_headers, 2), + Req = cowboy_req:set_resp_headers(#{ + <<"set-cookie">> => <<"name=value">> + }, Req0), + {ok, cowboy_req:reply(200, #{}, "OK", Req), Opts}; +do(<<"set_resp_headers_list_cookie">>, Req0, Opts) -> + ct_helper:ignore(cowboy_req, set_resp_headers_list, 3), + Req = cowboy_req:set_resp_headers([ + {<<"set-cookie">>, <<"name=value">>}, + {<<"set-cookie">>, <<"name2=value2">>} + ], Req0), + {ok, cowboy_req:reply(200, #{}, "OK", Req), Opts}; do(<<"set_resp_headers_http11">>, Req0, Opts) -> Req = cowboy_req:set_resp_headers(#{ <<"connection">> => <<"custom-header, close">>, @@ -130,6 +155,10 @@ do(<<"inform2">>, Req0, Opts) -> <<"twice">> -> cowboy_req:inform(102, Req0), cowboy_req:inform(102, Req0); + <<"after_reply">> -> + ct_helper:ignore(cowboy_req, inform, 3), + Req1 = cowboy_req:reply(200, Req0), + cowboy_req:inform(102, Req1); Status -> cowboy_req:inform(binary_to_integer(Status), Req0) end, @@ -143,9 +172,16 @@ do(<<"inform3">>, Req0, Opts) -> <<"error">> -> ct_helper:ignore(cowboy_req, inform, 3), cowboy_req:inform(ok, Headers, Req0); + <<"set_cookie">> -> + ct_helper:ignore(cowboy_req, inform, 3), + cowboy_req:inform(102, #{<<"set-cookie">> => <<"name=value">>}, Req0); <<"twice">> -> cowboy_req:inform(102, Headers, Req0), cowboy_req:inform(102, Headers, Req0); + <<"after_reply">> -> + ct_helper:ignore(cowboy_req, inform, 3), + Req1 = cowboy_req:reply(200, Req0), + cowboy_req:inform(102, Headers, Req1); Status -> cowboy_req:inform(binary_to_integer(Status), Headers, Req0) end, @@ -161,6 +197,7 @@ do(<<"reply2">>, Req0, Opts) -> <<"twice">> -> ct_helper:ignore(cowboy_req, reply, 4), Req1 = cowboy_req:reply(200, Req0), + timer:sleep(100), cowboy_req:reply(200, Req1); Status -> cowboy_req:reply(binary_to_integer(Status), Req0) @@ -171,6 +208,9 @@ do(<<"reply3">>, Req0, Opts) -> <<"error">> -> ct_helper:ignore(cowboy_req, reply, 4), cowboy_req:reply(200, ok, Req0); + <<"set_cookie">> -> + ct_helper:ignore(cowboy_req, reply, 4), + cowboy_req:reply(200, #{<<"set-cookie">> => <<"name=value">>}, Req0); Status -> cowboy_req:reply(binary_to_integer(Status), #{<<"content-type">> => <<"text/plain">>}, Req0) @@ -181,11 +221,14 @@ do(<<"reply4">>, Req0, Opts) -> <<"error">> -> ct_helper:ignore(erlang, iolist_size, 1), cowboy_req:reply(200, #{}, ok, Req0); - <<"204body">> -> + <<"set_cookie">> -> ct_helper:ignore(cowboy_req, reply, 4), + cowboy_req:reply(200, #{<<"set-cookie">> => <<"name=value">>}, <<"OK">>, Req0); + <<"204body">> -> + ct_helper:ignore(cowboy_req, do_reply_ensure_no_body, 4), cowboy_req:reply(204, #{}, <<"OK">>, Req0); <<"304body">> -> - ct_helper:ignore(cowboy_req, reply, 4), + ct_helper:ignore(cowboy_req, do_reply_ensure_no_body, 4), cowboy_req:reply(304, #{}, <<"OK">>, Req0); Status -> cowboy_req:reply(binary_to_integer(Status), #{}, <<"OK">>, Req0) @@ -215,6 +258,14 @@ do(<<"stream_reply2">>, Req0, Opts) -> Req = cowboy_req:stream_reply(304, Req0), stream_body(Req), {ok, Req, Opts}; + <<"twice">> -> + ct_helper:ignore(cowboy_req, stream_reply, 3), + Req1 = cowboy_req:stream_reply(200, Req0), + timer:sleep(100), + %% We will crash here so the body shouldn't be sent. + Req = cowboy_req:stream_reply(200, Req1), + stream_body(Req), + {ok, Req, Opts}; Status -> Req = cowboy_req:stream_reply(binary_to_integer(Status), Req0), stream_body(Req), @@ -225,6 +276,9 @@ do(<<"stream_reply3">>, Req0, Opts) -> <<"error">> -> ct_helper:ignore(cowboy_req, stream_reply, 3), cowboy_req:stream_reply(200, ok, Req0); + <<"set_cookie">> -> + ct_helper:ignore(cowboy_req, stream_reply, 3), + cowboy_req:stream_reply(200, #{<<"set-cookie">> => <<"name=value">>}, Req0); Status -> cowboy_req:stream_reply(binary_to_integer(Status), #{<<"content-type">> => <<"text/plain">>}, Req0) @@ -380,6 +434,16 @@ do(<<"stream_trailers">>, Req0, Opts) -> <<"grpc-status">> => <<"0">> }, Req), {ok, Req, Opts}; + <<"set_cookie">> -> + ct_helper:ignore(cowboy_req, stream_trailers, 2), + Req = cowboy_req:stream_reply(200, #{ + <<"trailer">> => <<"set-cookie">> + }, Req0), + cowboy_req:stream_body(<<"Hello world!">>, nofin, Req), + cowboy_req:stream_trailers(#{ + <<"set-cookie">> => <<"name=value">> + }, Req), + {ok, Req, Opts}; _ -> Req = cowboy_req:stream_reply(200, #{ <<"trailer">> => <<"grpc-status">> @@ -403,6 +467,11 @@ do(<<"push">>, Req, Opts) -> <<"qs">> -> cowboy_req:push("/static/style.css", #{<<"accept">> => <<"text/css">>}, Req, #{qs => <<"server=cowboy&version=2.0">>}); + <<"after_reply">> -> + ct_helper:ignore(cowboy_req, push, 4), + Req1 = cowboy_req:reply(200, Req), + %% We will crash here so no need to worry about propagating Req1. + cowboy_req:push("/static/style.css", #{<<"accept">> => <<"text/css">>}, Req1); _ -> cowboy_req:push("/static/style.css", #{<<"accept">> => <<"text/css">>}, Req), %% The text/plain mime is not defined by default, so a 406 will be returned. diff --git a/test/handlers/stream_handler_h.erl b/test/handlers/stream_handler_h.erl index 370d15a..7a1e5ec 100644 --- a/test/handlers/stream_handler_h.erl +++ b/test/handlers/stream_handler_h.erl @@ -44,16 +44,16 @@ init_commands(_, _, #state{test=set_options_ignore_unknown}) -> ]; init_commands(_, _, State=#state{test=shutdown_on_stream_stop}) -> Spawn = init_process(false, State), - [{headers, 200, #{}}, {spawn, Spawn, 5000}, stop]; + [{spawn, Spawn, 5000}, {headers, 200, #{}}, stop]; init_commands(_, _, State=#state{test=shutdown_on_socket_close}) -> Spawn = init_process(false, State), - [{headers, 200, #{}}, {spawn, Spawn, 5000}]; + [{spawn, Spawn, 5000}, {headers, 200, #{}}]; init_commands(_, _, State=#state{test=shutdown_timeout_on_stream_stop}) -> Spawn = init_process(true, State), - [{headers, 200, #{}}, {spawn, Spawn, 2000}, stop]; + [{spawn, Spawn, 2000}, {headers, 200, #{}}, stop]; init_commands(_, _, State=#state{test=shutdown_timeout_on_socket_close}) -> Spawn = init_process(true, State), - [{headers, 200, #{}}, {spawn, Spawn, 2000}]; + [{spawn, Spawn, 2000}, {headers, 200, #{}}]; init_commands(_, _, State=#state{test=switch_protocol_after_headers}) -> [{headers, 200, #{}}, {switch_protocol, #{}, ?MODULE, State}]; init_commands(_, _, State=#state{test=switch_protocol_after_headers_data}) -> diff --git a/test/handlers/stream_hello_h.erl b/test/handlers/stream_hello_h.erl new file mode 100644 index 0000000..e67e220 --- /dev/null +++ b/test/handlers/stream_hello_h.erl @@ -0,0 +1,15 @@ +%% This module is the fastest way of producing a Hello world! + +-module(stream_hello_h). + +-export([init/3]). +-export([terminate/3]). + +init(_, _, State) -> + {[ + {response, 200, #{<<"content-length">> => <<"12">>}, <<"Hello world!">>}, + stop + ], State}. + +terminate(_, _, _) -> + ok. diff --git a/test/handlers/streamed_result_h.erl b/test/handlers/streamed_result_h.erl new file mode 100644 index 0000000..ea6f492 --- /dev/null +++ b/test/handlers/streamed_result_h.erl @@ -0,0 +1,20 @@ +-module(streamed_result_h). + +-export([init/2]). + +init(Req, Opts) -> + N = list_to_integer(binary_to_list(cowboy_req:binding(n, Req))), + Interval = list_to_integer(binary_to_list(cowboy_req:binding(interval, Req))), + chunked(N, Interval, Req, Opts). + +chunked(N, Interval, Req0, Opts) -> + Req = cowboy_req:stream_reply(200, Req0), + {ok, loop(N, Interval, Req), Opts}. + +loop(0, _Interval, Req) -> + ok = cowboy_req:stream_body("Finished!\n", fin, Req), + Req; +loop(N, Interval, Req) -> + ok = cowboy_req:stream_body(iolist_to_binary([integer_to_list(N), <<"\n">>]), nofin, Req), + timer:sleep(Interval), + loop(N-1, Interval, Req). diff --git a/test/handlers/ws_ignore.erl b/test/handlers/ws_ignore.erl new file mode 100644 index 0000000..9fe3322 --- /dev/null +++ b/test/handlers/ws_ignore.erl @@ -0,0 +1,20 @@ +%% Feel free to use, reuse and abuse the code in this file. + +-module(ws_ignore). + +-export([init/2]). +-export([websocket_handle/2]). +-export([websocket_info/2]). + +init(Req, _) -> + {cowboy_websocket, Req, undefined, #{ + compress => true + }}. + +websocket_handle({text, <<"CHECK">>}, State) -> + {[{text, <<"CHECK">>}], State}; +websocket_handle(_Frame, State) -> + {[], State}. + +websocket_info(_Info, State) -> + {[], State}. diff --git a/test/handlers/ws_init_h.erl b/test/handlers/ws_init_h.erl index db5307b..bbe9ef9 100644 --- a/test/handlers/ws_init_h.erl +++ b/test/handlers/ws_init_h.erl @@ -36,7 +36,10 @@ do_websocket_init(State=reply_many_hibernate) -> do_websocket_init(State=reply_many_close) -> {[{text, "Hello"}, close], State}; do_websocket_init(State=reply_many_close_hibernate) -> - {[{text, "Hello"}, close], State, hibernate}. + {[{text, "Hello"}, close], State, hibernate}; +do_websocket_init(State=reply_trap_exit) -> + Text = "trap_exit: " ++ atom_to_list(element(2, process_info(self(), trap_exit))), + {[{text, Text}, close], State, hibernate}. websocket_handle(_, State) -> {[], State}. diff --git a/test/handlers/ws_ping_h.erl b/test/handlers/ws_ping_h.erl new file mode 100644 index 0000000..a5848fe --- /dev/null +++ b/test/handlers/ws_ping_h.erl @@ -0,0 +1,23 @@ +%% This module sends an empty ping to the client and +%% waits for a pong before sending a text frame. It +%% is used to confirm server-initiated pings work. + +-module(ws_ping_h). +-behavior(cowboy_websocket). + +-export([init/2]). +-export([websocket_init/1]). +-export([websocket_handle/2]). +-export([websocket_info/2]). + +init(Req, _) -> + {cowboy_websocket, Req, undefined}. + +websocket_init(State) -> + {[{ping, <<>>}], State}. + +websocket_handle(pong, State) -> + {[{text, <<"OK!!">>}], State}. + +websocket_info(_, State) -> + {[], State}. diff --git a/test/handlers/ws_set_options_commands_h.erl b/test/handlers/ws_set_options_commands_h.erl index 88d4e72..1ab0af4 100644 --- a/test/handlers/ws_set_options_commands_h.erl +++ b/test/handlers/ws_set_options_commands_h.erl @@ -11,10 +11,21 @@ init(Req, RunOrHibernate) -> {cowboy_websocket, Req, RunOrHibernate, #{idle_timeout => infinity}}. -websocket_handle(Frame={text, <<"idle_timeout_short">>}, State=run) -> - {[{set_options, #{idle_timeout => 500}}, Frame], State}; -websocket_handle(Frame={text, <<"idle_timeout_short">>}, State=hibernate) -> - {[{set_options, #{idle_timeout => 500}}, Frame], State, hibernate}. +%% Set the idle_timeout option dynamically. +websocket_handle({text, <<"idle_timeout_short">>}, State=run) -> + {[{set_options, #{idle_timeout => 500}}], State}; +websocket_handle({text, <<"idle_timeout_short">>}, State=hibernate) -> + {[{set_options, #{idle_timeout => 500}}], State, hibernate}; +%% Set the max_frame_size option dynamically. +websocket_handle({text, <<"max_frame_size_small">>}, State=run) -> + {[{set_options, #{max_frame_size => 1000}}], State}; +websocket_handle({text, <<"max_frame_size_small">>}, State=hibernate) -> + {[{set_options, #{max_frame_size => 1000}}], State, hibernate}; +%% We just echo binary frames. +websocket_handle(Frame={binary, _}, State=run) -> + {[Frame], State}; +websocket_handle(Frame={binary, _}, State=hibernate) -> + {[Frame], State, hibernate}. websocket_info(_Info, State) -> {[], State}. diff --git a/test/handlers/wt_echo_h.erl b/test/handlers/wt_echo_h.erl new file mode 100644 index 0000000..5198565 --- /dev/null +++ b/test/handlers/wt_echo_h.erl @@ -0,0 +1,103 @@ +%% This module echoes client events back, +%% including creating new streams. + +-module(wt_echo_h). +-behavior(cowboy_webtransport). + +-export([init/2]). +-export([webtransport_handle/2]). +-export([webtransport_info/2]). +-export([terminate/3]). + +%% -define(DEBUG, 1). +-ifdef(DEBUG). +-define(LOG(Fmt, Args), ct:pal(Fmt, Args)). +-else. +-define(LOG(Fmt, Args), _ = Fmt, _ = Args, ok). +-endif. + +init(Req0, _) -> + ?LOG("WT init ~p~n", [Req0]), + Req = case cowboy_req:parse_header(<<"wt-available-protocols">>, Req0) of + undefined -> + Req0; + [Protocol|_] -> + cowboy_req:set_resp_header(<<"wt-protocol">>, cow_http_hd:wt_protocol(Protocol), Req0) + end, + {cowboy_webtransport, Req, #{}}. + +webtransport_handle(Event = {stream_open, StreamID, bidi}, Streams) -> + ?LOG("WT handle ~p~n", [Event]), + {[], Streams#{StreamID => bidi}}; +webtransport_handle(Event = {stream_open, StreamID, unidi}, Streams) -> + ?LOG("WT handle ~p~n", [Event]), + OpenStreamRef = make_ref(), + {[{open_stream, OpenStreamRef, unidi, <<>>}], Streams#{ + StreamID => {unidi_remote, OpenStreamRef}, + OpenStreamRef => {unidi_local, StreamID}}}; +webtransport_handle(Event = {opened_stream_id, OpenStreamRef, OpenStreamID}, Streams) -> + ?LOG("WT handle ~p~n", [Event]), + case Streams of + #{OpenStreamRef := bidi} -> + {[], maps:remove(OpenStreamRef, Streams#{ + OpenStreamID => bidi + })}; + #{OpenStreamRef := {unidi_local, RemoteStreamID}} -> + #{RemoteStreamID := {unidi_remote, OpenStreamRef}} = Streams, + {[], maps:remove(OpenStreamRef, Streams#{ + RemoteStreamID => {unidi_remote, OpenStreamID}, + OpenStreamID => {unidi_local, RemoteStreamID} + })} + end; +webtransport_handle(Event = {stream_data, StreamID, _IsFin, <<"TEST:", Test/bits>>}, Streams) -> + ?LOG("WT handle ~p~n", [Event]), + case Test of + <<"open_bidi">> -> + OpenStreamRef = make_ref(), + {[{open_stream, OpenStreamRef, bidi, <<>>}], + Streams#{OpenStreamRef => bidi}}; + <<"initiate_close">> -> + {[initiate_close], Streams}; + <<"close">> -> + {[close], Streams}; + <<"close_app_code">> -> + {[{close, 1234567890}], Streams}; + <<"close_app_code_msg">> -> + {[{close, 1234567890, <<"onetwothreefourfivesixseveneightnineten">>}], Streams}; + <<"event_pid:", EventPidBin/bits>> -> + {[{send, StreamID, nofin, <<"event_pid_received">>}], + Streams#{event_pid => binary_to_term(EventPidBin)}} + end; +webtransport_handle(Event = {stream_data, StreamID, IsFin, Data}, Streams) -> + ?LOG("WT handle ~p~n", [Event]), + case Streams of + #{StreamID := bidi} -> + {[{send, StreamID, IsFin, Data}], Streams}; + #{StreamID := {unidi_remote, Ref}} when is_reference(Ref) -> + %% The stream isn't ready. We try again later. + erlang:send_after(100, self(), {try_again, Event}), + {[], Streams}; + #{StreamID := {unidi_remote, LocalStreamID}} -> + {[{send, LocalStreamID, IsFin, Data}], Streams} + end; +webtransport_handle(Event = {datagram, Data}, Streams) -> + ?LOG("WT handle ~p~n", [Event]), + {[{send, datagram, Data}], Streams}; +webtransport_handle(Event = close_initiated, Streams) -> + ?LOG("WT handle ~p~n", [Event]), + {[{send, datagram, <<"TEST:close_initiated">>}], Streams}; +webtransport_handle(Event, Streams) -> + ?LOG("WT handle ignore ~p~n", [Event]), + {[], Streams}. + +webtransport_info({try_again, Event}, Streams) -> + ?LOG("WT try_again ~p", [Event]), + webtransport_handle(Event, Streams). + +terminate(Reason, Req, State=#{event_pid := EventPid}) -> + ?LOG("WT terminate ~0p~n~0p~n~0p", [Reason, Req, State]), + EventPid ! {'$wt_echo_h', terminate, Reason, Req, State}, + ok; +terminate(Reason, Req, State) -> + ?LOG("WT terminate ~0p~n~0p~n~0p", [Reason, Req, State]), + ok. diff --git a/test/http2_SUITE.erl b/test/http2_SUITE.erl index 44fc5cc..6f2d020 100644 --- a/test/http2_SUITE.erl +++ b/test/http2_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -29,7 +29,8 @@ init_dispatch(_) -> cowboy_router:compile([{"localhost", [ {"/", hello_h, []}, {"/echo/:key", echo_h, []}, - {"/resp_iolist_body", resp_iolist_body_h, []} + {"/resp_iolist_body", resp_iolist_body_h, []}, + {"/streamed_result/:n/:interval", streamed_result_h, []} ]}]). %% Do a prior knowledge handshake (function originally copied from rfc7540_SUITE). @@ -37,7 +38,8 @@ do_handshake(Config) -> do_handshake(#{}, Config). do_handshake(Settings, Config) -> - {ok, Socket} = gen_tcp:connect("localhost", config(port, Config), [binary, {active, false}]), + {ok, Socket} = gen_tcp:connect("localhost", config(port, Config), + [binary, {active, false}|proplists:get_value(tcp_opts, Config, [])]), %% Send a valid preface. ok = gen_tcp:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", cow_http2:settings(Settings)]), %% Receive the server preface. @@ -49,6 +51,27 @@ do_handshake(Settings, Config) -> {ok, << 0:24, 4:8, 1:8, 0:32 >>} = gen_tcp:recv(Socket, 9, 1000), {ok, Socket}. +hibernate(Config) -> + doc("Ensure that we can enable hibernation for HTTP/1.1 connections."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + hibernate => true + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, http2}, {port, Port}|Config]), + {ok, http2} = gun:await_up(ConnPid), + StreamRef1 = gun:get(ConnPid, "/"), + StreamRef2 = gun:get(ConnPid, "/"), + StreamRef3 = gun:get(ConnPid, "/"), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef1), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef2), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef3), + gun:close(ConnPid) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + idle_timeout(Config) -> doc("Terminate when the idle timeout is reached."), ProtoOpts = #{ @@ -61,7 +84,8 @@ idle_timeout(Config) -> {ok, Socket} = do_handshake([{port, Port}|Config]), timer:sleep(1000), %% Receive a GOAWAY frame back with NO_ERROR. - {ok, << _:24, 7:8, _:72, 0:32 >>} = gen_tcp:recv(Socket, 17, 1000) + {ok, << _:24, 7:8, _:72, 0:32 >>} = gen_tcp:recv(Socket, 17, 1000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -78,7 +102,8 @@ idle_timeout_infinity(Config) -> {ok, Socket} = do_handshake([{port, Port}|Config]), timer:sleep(1000), %% Don't receive a GOAWAY frame. - {error, timeout} = gen_tcp:recv(Socket, 17, 1000) + {error, timeout} = gen_tcp:recv(Socket, 17, 1000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -107,11 +132,21 @@ idle_timeout_reset_on_data(Config) -> {ok, <<8:24, 6:8, 0:7, 1:1, 0:96>>} = gen_tcp:recv(Socket, 17, 1000), %% The connection goes away soon after we stop sending data. timer:sleep(1000), - {ok, << _:24, 7:8, _:72, 0:32 >>} = gen_tcp:recv(Socket, 17, 1000) + {ok, << _:24, 7:8, _:72, 0:32 >>} = gen_tcp:recv(Socket, 17, 1000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. +idle_timeout_on_send(Config) -> + doc("Ensure the idle timeout is not reset when sending (by default)."), + http_SUITE:do_idle_timeout_on_send(Config, http2). + +idle_timeout_reset_on_send(Config) -> + doc("Ensure the reset_idle_timeout_on_send results in the " + "idle timeout resetting when sending ."), + http_SUITE:do_idle_timeout_reset_on_send(Config, http2). + inactivity_timeout(Config) -> doc("Terminate when the inactivity timeout is reached."), ProtoOpts = #{ @@ -124,7 +159,8 @@ inactivity_timeout(Config) -> {ok, Socket} = do_handshake([{port, Port}|Config]), receive after 1000 -> ok end, %% Receive a GOAWAY frame back with an INTERNAL_ERROR. - {ok, << _:24, 7:8, _:72, 2:32 >>} = gen_tcp:recv(Socket, 17, 1000) + {ok, << _:24, 7:8, _:72, 2:32 >>} = gen_tcp:recv(Socket, 17, 1000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -148,7 +184,8 @@ initial_connection_window_size(Config) -> {ok, << 4:8, 0:40, _:Len/binary >>} = gen_tcp:recv(Socket, 6 + Len, 1000), %% Receive a WINDOW_UPDATE frame incrementing the connection window to 100000. {ok, <<4:24, 8:8, 0:41, Size:31>>} = gen_tcp:recv(Socket, 13, 1000), - ConfiguredSize = Size + 65535 + ConfiguredSize = Size + 65535, + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -191,7 +228,8 @@ max_frame_size_sent(Config) -> %% The DATA frames following must have lengths of 20000 %% and then 10000 due to the limit. {ok, <<20000:24, 0:8, _:40, _:20000/unit:8>>} = gen_tcp:recv(Socket, 20009, 6000), - {ok, <<10000:24, 0:8, _:40, _:10000/unit:8>>} = gen_tcp:recv(Socket, 10009, 6000) + {ok, <<10000:24, 0:8, _:40, _:10000/unit:8>>} = gen_tcp:recv(Socket, 10009, 6000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -235,7 +273,7 @@ preface_timeout_infinity(Config) -> {'DOWN', Ref, process, Pid, Reason} -> error(Reason) after 1000 -> - ok + gen_tcp:close(Socket) end after cowboy:stop_listener(?FUNCTION_NAME) @@ -279,8 +317,222 @@ settings_timeout_infinity(Config) -> {'DOWN', Ref, process, Pid, Reason} -> error(Reason) after 1000 -> - ok + gen_tcp:close(Socket) end after cowboy:stop_listener(?FUNCTION_NAME) end. + +graceful_shutdown_connection(Config) -> + doc("Check that ongoing requests are handled before gracefully shutting down a connection."), + Dispatch = cowboy_router:compile([{"localhost", [ + {"/delay_hello", delay_hello_h, + #{delay => 500, notify_received => self()}} + ]}]), + ProtoOpts = #{ + env => #{dispatch => Dispatch} + }, + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], ProtoOpts), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, http2}, {port, Port}|Config]), + Ref = gun:get(ConnPid, "/delay_hello"), + %% Make sure the request is received. + receive {request_received, <<"/delay_hello">>} -> ok end, + %% Tell the connection to shutdown while the handler is working. + [CowboyConnPid] = ranch:procs(?FUNCTION_NAME, connections), + monitor(process, CowboyConnPid), + ok = sys:terminate(CowboyConnPid, goaway), + %% Check that the response is sent to the client before the + %% connection goes down. + {response, nofin, 200, _RespHeaders} = gun:await(ConnPid, Ref), + {ok, RespBody} = gun:await_body(ConnPid, Ref), + <<"Hello world!">> = iolist_to_binary(RespBody), + %% Check that the connection is gone soon afterwards. (The exit + %% reason is supposed to be 'goaway' as passed to + %% sys:terminate/2, but it is {shutdown, closed}.) + receive + {'DOWN', _, process, CowboyConnPid, _Reason} -> + ok + end, + [] = ranch:procs(?FUNCTION_NAME, connections), + gun:close(ConnPid) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +graceful_shutdown_timeout(Config) -> + doc("Check that a connection is closed when gracefully shutting down times out."), + Dispatch = cowboy_router:compile([{"localhost", [ + {"/long_delay_hello", delay_hello_h, + #{delay => 10000, notify_received => self()}} + ]}]), + ProtoOpts = #{ + env => #{dispatch => Dispatch}, + goaway_initial_timeout => 200, + goaway_complete_timeout => 500 + }, + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], ProtoOpts), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, http2}, {port, Port}|Config]), + Ref = gun:get(ConnPid, "/long_delay_hello"), + %% Make sure the request is received. + receive {request_received, <<"/long_delay_hello">>} -> ok end, + %% Tell the connection to shutdown while the handler is working. + [CowboyConnPid] = ranch:procs(?FUNCTION_NAME, connections), + monitor(process, CowboyConnPid), + ok = sys:terminate(CowboyConnPid, goaway), + %% Check that connection didn't wait for the slow handler. + {error, {stream_error, closed}} = gun:await(ConnPid, Ref), + %% Check that the connection is gone. (The exit reason is + %% supposed to be 'goaway' as passed to sys:terminate/2, but it + %% is {shutdown, {stop, {exit, goaway}, 'Graceful shutdown timed + %% out.'}}.) + receive + {'DOWN', _, process, CowboyConnPid, _Reason} -> + ok + after 100 -> + error(still_alive) + end, + [] = ranch:procs(?FUNCTION_NAME, connections), + gun:close(ConnPid) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +graceful_shutdown_listener(Config) -> + doc("Check that connections are shut down gracefully when stopping a listener."), + TransOpts = #{ + socket_opts => [{port, 0}], + shutdown => 1000 %% Shorter timeout to make the test case faster. + }, + Dispatch = cowboy_router:compile([{"localhost", [ + {"/delay_hello", delay_hello_h, + #{delay => 500, notify_received => self()}} + ]}]), + ProtoOpts = #{ + env => #{dispatch => Dispatch} + }, + {ok, Listener} = cowboy:start_clear(?FUNCTION_NAME, TransOpts, ProtoOpts), + Port = ranch:get_port(?FUNCTION_NAME), + ConnPid = gun_open([{type, tcp}, {protocol, http2}, {port, Port}|Config]), + Ref = gun:get(ConnPid, "/delay_hello"), + %% Shutdown listener while the handlers are working. + receive {request_received, <<"/delay_hello">>} -> ok end, + ListenerMonitorRef = monitor(process, Listener), + %% Note: This call does not complete quickly and will + %% prevent other cowboy:stop_listener/1 calls to complete. + ok = cowboy:stop_listener(?FUNCTION_NAME), + receive + {'DOWN', ListenerMonitorRef, process, Listener, _Reason} -> + ok + end, + %% Check that the request is handled before shutting down. + {response, nofin, 200, _RespHeaders} = gun:await(ConnPid, Ref), + {ok, RespBody} = gun:await_body(ConnPid, Ref), + <<"Hello world!">> = iolist_to_binary(RespBody), + gun:close(ConnPid). + +graceful_shutdown_listener_timeout(Config) -> + doc("Check that connections are shut down when gracefully stopping a listener times out."), + TransOpts = #{ + socket_opts => [{port, 0}], + shutdown => 1000 %% Shorter timeout to make the test case faster. + }, + Dispatch = cowboy_router:compile([{"localhost", [ + {"/long_delay_hello", delay_hello_h, + #{delay => 10000, notify_received => self()}} + ]}]), + ProtoOpts = #{ + env => #{dispatch => Dispatch}, + goaway_initial_timeout => 200, + goaway_complete_timeout => 500 + }, + {ok, Listener} = cowboy:start_clear(?FUNCTION_NAME, TransOpts, ProtoOpts), + Port = ranch:get_port(?FUNCTION_NAME), + ConnPid = gun_open([{type, tcp}, {protocol, http2}, {port, Port}|Config]), + Ref = gun:get(ConnPid, "/long_delay_hello"), + %% Shutdown listener while the handlers are working. + receive {request_received, <<"/long_delay_hello">>} -> ok end, + ListenerMonitorRef = monitor(process, Listener), + %% Note: This call does not complete quickly and will + %% prevent other cowboy:stop_listener/1 calls to complete. + ok = cowboy:stop_listener(?FUNCTION_NAME), + receive + {'DOWN', ListenerMonitorRef, process, Listener, _Reason} -> + ok + end, + %% Check that the slow request is aborted. + {error, {stream_error, closed}} = gun:await(ConnPid, Ref), + gun:close(ConnPid). + +send_timeout_close(Config) -> + doc("Check that connections are closed on send timeout."), + TransOpts = #{ + socket_opts => [ + {port, 0}, + {send_timeout, 100}, + {send_timeout_close, true}, + {sndbuf, 10} + ] + }, + Dispatch = cowboy_router:compile([{"localhost", [ + {"/endless", loop_handler_endless_h, #{delay => 100}} + ]}]), + ProtoOpts = #{ + env => #{dispatch => Dispatch}, + idle_timeout => infinity + }, + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, TransOpts, ProtoOpts), + Port = ranch:get_port(?FUNCTION_NAME), + try + %% Connect a client that sends a request and waits indefinitely. + {ok, ClientSocket} = do_handshake([{port, Port}, + {tcp_opts, [{recbuf, 10}, {buffer, 10}, {active, false}]}|Config]), + {HeadersBlock, _} = cow_hpack:encode([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<":path">>, <<"/endless">>}, + {<<"x-test-pid">>, pid_to_list(self())} + ]), + ok = gen_tcp:send(ClientSocket, [ + cow_http2:headers(1, fin, HeadersBlock), + %% Greatly increase the window to make sure we don't run + %% out of space before we get send timeouts. + cow_http2:window_update(10000000), + cow_http2:window_update(1, 10000000) + ]), + %% Wait for the handler to start then get its pid, + %% the remote connection's pid and socket. + StreamPid = receive + {Self, StreamPid0, init} when Self =:= self() -> + StreamPid0 + after 1000 -> + error(timeout) + end, + ServerPid = ct_helper:get_remote_pid_tcp(ClientSocket), + {links, ServerLinks} = process_info(ServerPid, links), + [ServerSocket] = [PidOrPort || PidOrPort <- ServerLinks, is_port(PidOrPort)], + %% Poll the socket repeatedly until it is closed by the server. + WaitClosedFun = + fun F(T) when T =< 0 -> + error({status, prim_inet:getstatus(ServerSocket)}); + F(T) -> + Snooze = 100, + case inet:sockname(ServerSocket) of + {error, _} -> + timer:sleep(Snooze); + {ok, _} -> + timer:sleep(Snooze), + F(T - Snooze) + end + end, + ok = WaitClosedFun(2000), + false = erlang:is_process_alive(StreamPid), + false = erlang:is_process_alive(ServerPid), + gen_tcp:close(ClientSocket) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. diff --git a/test/http_SUITE.erl b/test/http_SUITE.erl index 0b4edd9..9928136 100644 --- a/test/http_SUITE.erl +++ b/test/http_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -20,15 +20,24 @@ -import(ct_helper, [doc/1]). -import(ct_helper, [get_remote_pid_tcp/1]). -import(cowboy_test, [gun_open/1]). +-import(cowboy_test, [gun_down/1]). -import(cowboy_test, [raw_open/1]). -import(cowboy_test, [raw_send/2]). -import(cowboy_test, [raw_recv_head/1]). +-import(cowboy_test, [raw_recv_rest/3]). -import(cowboy_test, [raw_recv/3]). -import(cowboy_test, [raw_expect_recv/2]). -all() -> [{group, clear}]. +all() -> + [{group, clear_no_parallel}, {group, clear}]. -groups() -> [{clear, [parallel], ct_helper:all(?MODULE)}]. +groups() -> + [ + %% cowboy:stop_listener can be slow when called many times + %% in parallel so we must run this test separately from the others. + {clear_no_parallel, [], [graceful_shutdown_listener]}, + {clear, [parallel], ct_helper:all(?MODULE) -- [graceful_shutdown_listener]} + ]. init_per_group(Name, Config) -> cowboy_test:init_http(Name, #{ @@ -41,9 +50,11 @@ end_per_group(Name, _) -> init_dispatch(_) -> cowboy_router:compile([{"localhost", [ {"/", hello_h, []}, + {"/delay_hello", delay_hello_h, #{delay => 1000, notify_received => self()}}, {"/echo/:key", echo_h, []}, {"/resp/:key[/:arg]", resp_h, []}, - {"/set_options/:key", set_options_h, []} + {"/set_options/:key", set_options_h, []}, + {"/streamed_result/:n/:interval", streamed_result_h, []} ]}]). chunked_false(Config) -> @@ -87,7 +98,7 @@ chunked_one_byte_at_a_time(Config) -> "Transfer-encoding: chunked\r\n\r\n"), _ = [begin raw_send(Client, <<C>>), - timer:sleep(10) + timer:sleep(1) end || <<C>> <= ChunkedBody], Rest = case catch raw_recv_head(Client) of {'EXIT', _} -> error(closed); @@ -195,6 +206,94 @@ do_chunked_body(ChunkSize0, Data, Acc) -> do_chunked_body(ChunkSize, Rest, [iolist_to_binary(cow_http_te:chunk(Chunk))|Acc]). +disable_http1_tls(Config) -> + doc("Ensure that we can disable HTTP/1.1 over TLS (force HTTP/2)."), + TlsOpts = ct_helper:get_certs_from_ets(), + {ok, _} = cowboy:start_tls(?FUNCTION_NAME, TlsOpts ++ [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + alpn_default_protocol => http2 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + {ok, Socket} = ssl:connect("localhost", Port, + [binary, {active, false}|TlsOpts]), + %% ALPN was not negotiated but we're still over HTTP/2. + {error, protocol_not_negotiated} = ssl:negotiated_protocol(Socket), + %% Send a valid preface. + ok = ssl:send(Socket, [ + "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", + cow_http2:settings(#{})]), + %% Receive the server preface. + {ok, << Len:24 >>} = ssl:recv(Socket, 3, 1000), + {ok, << 4:8, 0:40, _:Len/binary >>} = ssl:recv(Socket, 6 + Len, 1000), + ok + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +disable_http2_prior_knowledge(Config) -> + doc("Ensure that we can disable prior knowledge HTTP/2 upgrade."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + protocols => [http] + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + {ok, Socket} = gen_tcp:connect("localhost", Port, [binary, {active, false}]), + %% Send a valid preface. + ok = gen_tcp:send(Socket, [ + "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", + cow_http2:settings(#{})]), + {ok, <<"HTTP/1.1 501">>} = gen_tcp:recv(Socket, 12, 1000), + ok + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +disable_http2_upgrade(Config) -> + doc("Ensure that we can disable HTTP/1.1 upgrade to HTTP/2."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + protocols => [http] + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + {ok, Socket} = gen_tcp:connect("localhost", Port, [binary, {active, false}]), + %% Send a valid preface. + ok = gen_tcp:send(Socket, [ + "GET / HTTP/1.1\r\n" + "Host: localhost\r\n" + "Connection: Upgrade, HTTP2-Settings\r\n" + "Upgrade: h2c\r\n" + "HTTP2-Settings: ", base64:encode(cow_http2:settings_payload(#{})), "\r\n", + "\r\n"]), + {ok, <<"HTTP/1.1 200">>} = gen_tcp:recv(Socket, 12, 1000), + ok + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +hibernate(Config) -> + doc("Ensure that we can enable hibernation for HTTP/1.1 connections."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + hibernate => true + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]), + {ok, http} = gun:await_up(ConnPid), + StreamRef1 = gun:get(ConnPid, "/"), + StreamRef2 = gun:get(ConnPid, "/"), + StreamRef3 = gun:get(ConnPid, "/"), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef1), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef2), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef3), + gun:close(ConnPid) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + http10_keepalive_false(Config) -> doc("Confirm the option http10_keepalive => false disables keep-alive " "completely for HTTP/1.0 connections."), @@ -224,6 +323,68 @@ http10_keepalive_false(Config) -> cowboy:stop_listener(?FUNCTION_NAME) end. +idle_timeout_read_body(Config) -> + doc("Ensure the idle_timeout drops connections when the " + "connection is idle too long reading the request body."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + request_timeout => 60000, + idle_timeout => 500 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]), + {ok, http} = gun:await_up(ConnPid), + _StreamRef = gun:post(ConnPid, "/echo/read_body", + #{<<"content-length">> => <<"12">>}), + {error, {down, {shutdown, closed}}} = gun:await(ConnPid, undefined, 1000) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +idle_timeout_read_body_pipeline(Config) -> + doc("Ensure the idle_timeout drops connections when the " + "connection is idle too long reading the request body."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + request_timeout => 60000, + idle_timeout => 500 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]), + {ok, http} = gun:await_up(ConnPid), + StreamRef1 = gun:get(ConnPid, "/"), + StreamRef2 = gun:get(ConnPid, "/"), + _StreamRef3 = gun:post(ConnPid, "/echo/read_body", + #{<<"content-length">> => <<"12">>}), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef1), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef2), + {error, {down, {shutdown, closed}}} = gun:await(ConnPid, undefined, 1000) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +idle_timeout_skip_body(Config) -> + doc("Ensure the idle_timeout drops connections when the " + "connection is idle too long skipping the request body."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + request_timeout => 60000, + idle_timeout => 500 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]), + {ok, http} = gun:await_up(ConnPid), + StreamRef = gun:post(ConnPid, "/", + #{<<"content-length">> => <<"12">>}), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef), + {error, {down, {shutdown, closed}}} = gun:await(ConnPid, undefined, 1000) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + idle_timeout_infinity(Config) -> doc("Ensure the idle_timeout option accepts the infinity value."), {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ @@ -244,12 +405,90 @@ idle_timeout_infinity(Config) -> {'DOWN', Ref, process, Pid, Reason} -> error(Reason) after 1000 -> - ok + gun:close(ConnPid) end after cowboy:stop_listener(?FUNCTION_NAME) end. +idle_timeout_on_send(Config) -> + doc("Ensure the idle timeout is not reset when sending (by default)."), + do_idle_timeout_on_send(Config, http). + +%% Also used by http2_SUITE. +do_idle_timeout_on_send(Config, Protocol) -> + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + idle_timeout => 1000 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, Protocol}, {port, Port}|Config]), + {ok, Protocol} = gun:await_up(ConnPid), + timer:sleep(500), + #{socket := Socket} = gun:info(ConnPid), + Pid = get_remote_pid_tcp(Socket), + StreamRef = gun:get(ConnPid, "/streamed_result/10/250"), + Ref = erlang:monitor(process, Pid), + receive + {gun_response, ConnPid, StreamRef, nofin, _Status, _Headers} -> + do_idle_timeout_recv_loop(Ref, Pid, ConnPid, StreamRef, false) + after 2000 -> + error(timeout) + end + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +idle_timeout_reset_on_send(Config) -> + doc("Ensure the reset_idle_timeout_on_send results in the " + "idle timeout resetting when sending ."), + do_idle_timeout_reset_on_send(Config, http). + +%% Also used by http2_SUITE. +do_idle_timeout_reset_on_send(Config, Protocol) -> + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + idle_timeout => 1000, + reset_idle_timeout_on_send => true + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, Protocol}, {port, Port}|Config]), + {ok, Protocol} = gun:await_up(ConnPid), + timer:sleep(500), + #{socket := Socket} = gun:info(ConnPid), + Pid = get_remote_pid_tcp(Socket), + StreamRef = gun:get(ConnPid, "/streamed_result/10/250"), + Ref = erlang:monitor(process, Pid), + receive + {gun_response, ConnPid, StreamRef, nofin, _Status, _Headers} -> + do_idle_timeout_recv_loop(Ref, Pid, ConnPid, StreamRef, true) + after 2000 -> + error(timeout) + end + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +do_idle_timeout_recv_loop(Ref, Pid, ConnPid, StreamRef, ExpectCompletion) -> + receive + {gun_data, ConnPid, StreamRef, nofin, _Data} -> + do_idle_timeout_recv_loop(Ref, Pid, ConnPid, StreamRef, ExpectCompletion); + {gun_data, ConnPid, StreamRef, fin, _Data} when ExpectCompletion -> + gun:close(ConnPid); + {gun_data, ConnPid, StreamRef, fin, _Data} -> + gun:close(ConnPid), + error(completed); + {'DOWN', Ref, process, Pid, _} when ExpectCompletion -> + gun:close(ConnPid), + error(exited); + {'DOWN', Ref, process, Pid, _} -> + ok + after 2000 -> + error(timeout) + end. + persistent_term_router(Config) -> doc("The router can retrieve the routes from persistent_term storage."), case erlang:function_exported(persistent_term, get, 1) of @@ -273,6 +512,133 @@ do_persistent_term_router(Config) -> cowboy:stop_listener(?FUNCTION_NAME) end. +request_timeout(Config) -> + doc("Ensure the request_timeout drops connections when requests " + "fail to come in fast enough."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + request_timeout => 500 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]), + {ok, http} = gun:await_up(ConnPid), + {error, {down, {shutdown, closed}}} = gun:await(ConnPid, undefined, 1000) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +request_timeout_pipeline(Config) -> + doc("Ensure the request_timeout drops connections when requests " + "fail to come in fast enough after pipelined requests went through."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + request_timeout => 500 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]), + {ok, http} = gun:await_up(ConnPid), + StreamRef1 = gun:get(ConnPid, "/"), + StreamRef2 = gun:get(ConnPid, "/"), + StreamRef3 = gun:get(ConnPid, "/"), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef1), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef2), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef3), + {error, {down, {shutdown, closed}}} = gun:await(ConnPid, undefined, 1000) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +request_timeout_pipeline_delay(Config) -> + doc("Ensure the request_timeout does not trigger on requests " + "coming in after a large request body."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + request_timeout => 500 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]), + {ok, http} = gun:await_up(ConnPid), + StreamRef1 = gun:post(ConnPid, "/", #{}, <<0:8000000>>), + StreamRef2 = gun:get(ConnPid, "/delay_hello"), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef1), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef2), + {error, {down, {shutdown, closed}}} = gun:await(ConnPid, undefined, 1000) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +request_timeout_skip_body(Config) -> + doc("Ensure the request_timeout drops connections when requests " + "fail to come in fast enough after skipping a request body."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + request_timeout => 500 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]), + ok = raw_send(Client, << + "POST / HTTP/1.1\r\n" + "host: localhost\r\n" + "content-length: 12\r\n\r\n" + >>), + Data = raw_recv_head(Client), + {'HTTP/1.1', 200, _, Rest0} = cow_http:parse_status_line(Data), + {Headers, Rest} = cow_http:parse_headers(Rest0), + {_, Len} = lists:keyfind(<<"content-length">>, 1, Headers), + <<"Hello world!">> = raw_recv_rest(Client, binary_to_integer(Len), Rest), + %% We then send the request data that should be skipped by Cowboy. + timer:sleep(100), + raw_send(Client, <<"Hello world!">>), + %% Connection should be closed by the request_timeout after that. + {error, closed} = raw_recv(Client, 1, 1000) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +request_timeout_skip_body_more(Config) -> + doc("Ensure the request_timeout drops connections when requests " + "fail to come in fast enough after skipping a request body."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + request_timeout => 500 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]), + ok = raw_send(Client, << + "POST / HTTP/1.1\r\n" + "host: localhost\r\n" + "content-length: 12\r\n\r\n" + >>), + Data = raw_recv_head(Client), + {'HTTP/1.1', 200, _, Rest0} = cow_http:parse_status_line(Data), + {Headers, Rest} = cow_http:parse_headers(Rest0), + {_, Len} = lists:keyfind(<<"content-length">>, 1, Headers), + <<"Hello world!">> = raw_recv_rest(Client, binary_to_integer(Len), Rest), + %% We then send the request data that should be skipped by Cowboy. + timer:sleep(100), + raw_send(Client, <<"Hello world!">>), + %% Send the start of another request. + ok = raw_send(Client, << + "GET / HTTP/1.1\r\n" + "host: localhost\r\n" + %% Missing final \r\n on purpose. + >>), + %% Connection should be closed by the request_timeout after that. + %% We attempt to send a 408 response on a best effort basis so + %% that is accepted as well. + case raw_recv(Client, 13, 1000) of + {error, closed} -> ok; + {ok, <<"HTTP/1.1 408 ", _/bits>>} -> ok + end + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + request_timeout_infinity(Config) -> doc("Ensure the request_timeout option accepts the infinity value."), {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ @@ -291,7 +657,7 @@ request_timeout_infinity(Config) -> {'DOWN', Ref, process, Pid, Reason} -> error(Reason) after 1000 -> - ok + gun:close(ConnPid) end after cowboy:stop_listener(?FUNCTION_NAME) @@ -347,7 +713,8 @@ set_options_chunked_false_ignored(Config) -> %% is not disabled for that second request. StreamRef2 = gun:get(ConnPid, "/resp/stream_reply2/200"), {response, nofin, 200, Headers} = gun:await(ConnPid, StreamRef2), - {_, <<"chunked">>} = lists:keyfind(<<"transfer-encoding">>, 1, Headers) + {_, <<"chunked">>} = lists:keyfind(<<"transfer-encoding">>, 1, Headers), + gun:close(ConnPid) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -443,3 +810,144 @@ switch_protocol_flush(Config) -> after cowboy:stop_listener(?FUNCTION_NAME) end. + +graceful_shutdown_connection(Config) -> + doc("Check that the current request is handled before gracefully " + "shutting down a connection."), + Dispatch = cowboy_router:compile([{"localhost", [ + {"/hello", delay_hello_h, + #{delay => 0, notify_received => self()}}, + {"/delay_hello", delay_hello_h, + #{delay => 1000, notify_received => self()}} + ]}]), + ProtoOpts = #{ + env => #{dispatch => Dispatch} + }, + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], ProtoOpts), + Port = ranch:get_port(?FUNCTION_NAME), + try + Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]), + ok = raw_send(Client, + "GET /delay_hello HTTP/1.1\r\n" + "Host: localhost\r\n\r\n" + "GET /hello HTTP/1.1\r\n" + "Host: localhost\r\n\r\n"), + receive {request_received, <<"/delay_hello">>} -> ok end, + receive {request_received, <<"/hello">>} -> ok end, + CowboyConnPid = get_remote_pid_tcp(element(2, Client)), + CowboyConnRef = erlang:monitor(process, CowboyConnPid), + ok = sys:terminate(CowboyConnPid, system_is_going_down), + Rest = case catch raw_recv_head(Client) of + {'EXIT', _} -> error(closed); + Data -> + {'HTTP/1.1', 200, _, Rest0} = cow_http:parse_status_line(Data), + {Headers, Rest1} = cow_http:parse_headers(Rest0), + <<"close">> = proplists:get_value(<<"connection">>, Headers), + Rest1 + end, + <<"Hello world!">> = raw_recv_rest(Client, byte_size(<<"Hello world!">>), Rest), + {error, closed} = raw_recv(Client, 0, 1000), + receive + {'DOWN', CowboyConnRef, process, CowboyConnPid, _Reason} -> + ok + end + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +graceful_shutdown_listener(Config) -> + doc("Check that connections are shut down gracefully when stopping a listener."), + TransOpts = #{ + socket_opts => [{port, 0}], + shutdown => 1000 %% Shorter timeout to make the test case faster. + }, + Dispatch = cowboy_router:compile([{"localhost", [ + {"/delay_hello", delay_hello_h, + #{delay => 500, notify_received => self()}}, + {"/long_delay_hello", delay_hello_h, + #{delay => 10000, notify_received => self()}} + ]}]), + ProtoOpts = #{ + env => #{dispatch => Dispatch} + }, + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, TransOpts, ProtoOpts), + Port = ranch:get_port(?FUNCTION_NAME), + ConnPid1 = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]), + Ref1 = gun:get(ConnPid1, "/delay_hello"), + ConnPid2 = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]), + Ref2 = gun:get(ConnPid2, "/long_delay_hello"), + %% Shutdown listener while the handlers are working. + receive {request_received, <<"/delay_hello">>} -> ok end, + receive {request_received, <<"/long_delay_hello">>} -> ok end, + %% Note: This call does not complete quickly and will + %% prevent other cowboy:stop_listener/1 calls to complete. + ok = cowboy:stop_listener(?FUNCTION_NAME), + %% Check that the 1st request is handled before shutting down. + {response, nofin, 200, RespHeaders} = gun:await(ConnPid1, Ref1), + <<"close">> = proplists:get_value(<<"connection">>, RespHeaders), + {ok, RespBody} = gun:await_body(ConnPid1, Ref1), + <<"Hello world!">> = iolist_to_binary(RespBody), + gun:close(ConnPid1), + %% Check that the 2nd (very slow) request is not handled. + {error, {stream_error, closed}} = gun:await(ConnPid2, Ref2), + gun:close(ConnPid2). + +send_timeout_close(_Config) -> + doc("Check that connections are closed on send timeout."), + TransOpts = #{ + socket_opts => [ + {port, 0}, + {send_timeout, 100}, + {send_timeout_close, true}, + {sndbuf, 10} + ] + }, + Dispatch = cowboy_router:compile([{"localhost", [ + {"/endless", loop_handler_endless_h, #{delay => 100}} + ]}]), + ProtoOpts = #{ + env => #{dispatch => Dispatch}, + idle_timeout => infinity + }, + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, TransOpts, ProtoOpts), + Port = ranch:get_port(?FUNCTION_NAME), + try + %% Connect a client that sends a request and waits indefinitely. + {ok, ClientSocket} = gen_tcp:connect("localhost", Port, + [{recbuf, 10}, {buffer, 10}, {active, false}, {packet, 0}]), + ok = gen_tcp:send(ClientSocket, [ + "GET /endless HTTP/1.1\r\n", + "Host: localhost:", integer_to_list(Port), "\r\n", + "x-test-pid: ", pid_to_list(self()), "\r\n\r\n" + ]), + %% Wait for the handler to start then get its pid, + %% the remote connection's pid and socket. + StreamPid = receive + {Self, StreamPid0, init} when Self =:= self() -> + StreamPid0 + after 1000 -> + error(timeout) + end, + ServerPid = ct_helper:get_remote_pid_tcp(ClientSocket), + {links, ServerLinks} = process_info(ServerPid, links), + [ServerSocket] = [PidOrPort || PidOrPort <- ServerLinks, is_port(PidOrPort)], + %% Poll the socket repeatedly until it is closed by the server. + WaitClosedFun = + fun F(T) when T =< 0 -> + error({status, prim_inet:getstatus(ServerSocket)}); + F(T) -> + Snooze = 100, + case inet:sockname(ServerSocket) of + {error, _} -> + timer:sleep(Snooze); + {ok, _} -> + timer:sleep(Snooze), + F(T - Snooze) + end + end, + ok = WaitClosedFun(2000), + false = erlang:is_process_alive(StreamPid), + false = erlang:is_process_alive(ServerPid) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. diff --git a/test/http_perf_SUITE.erl b/test/http_perf_SUITE.erl new file mode 100644 index 0000000..1484c03 --- /dev/null +++ b/test/http_perf_SUITE.erl @@ -0,0 +1,220 @@ +%% Copyright (c) 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(http_perf_SUITE). +-compile(export_all). +-compile(nowarn_export_all). + +-import(ct_helper, [config/2]). +-import(ct_helper, [doc/1]). +-import(cowboy_test, [gun_open/1]). + +%% ct. + +all() -> + %% @todo Enable HTTP/3 for this test suite. + cowboy_test:common_all() -- [{group, h3}, {group, h3_compress}]. + +groups() -> + cowboy_test:common_groups(ct_helper:all(?MODULE), no_parallel). + +init_per_suite(Config) -> + do_log("", []), + %% Optionally enable `perf` for the current node. +% spawn(fun() -> ct:pal(os:cmd("perf record -g -F 9999 -o /tmp/http_perf.data -p " ++ os:getpid() ++ " -- sleep 60")) end), + Config. + +end_per_suite(_) -> + ok. + +init_per_group(Name, Config) -> + [{group, Name}|cowboy_test:init_common_groups(Name, Config, ?MODULE, #{ + %% HTTP/1.1 + max_keepalive => infinity, + %% HTTP/2 + %% @todo Must configure Gun for performance too. + connection_window_margin_size => 64*1024, + enable_connect_protocol => true, + env => #{dispatch => init_dispatch(Config)}, + max_frame_size_sent => 64*1024, + max_frame_size_received => 16384 * 1024 - 1, + max_received_frame_rate => {10_000_000, 1}, + stream_window_data_threshold => 1024, + stream_window_margin_size => 64*1024 + })]. + +end_per_group(Name, _) -> + do_log("", []), + cowboy_test:stop_group(Name). + +%% Routes. + +init_dispatch(_) -> + cowboy_router:compile([{'_', [ + {"/", hello_h, []}, + {"/read_body", read_body_h, []} + ]}]). + +%% Tests: Hello world. + +plain_h_hello_1(Config) -> + doc("Plain HTTP handler Hello World; 10K requests per 1 client."), + do_bench_get(?FUNCTION_NAME, "/", #{}, 1, 10000, Config). + +plain_h_hello_10(Config) -> + doc("Plain HTTP handler Hello World; 10K requests per 10 clients."), + do_bench_get(?FUNCTION_NAME, "/", #{}, 10, 10000, Config). + +stream_h_hello_1(Config) -> + doc("Stream handler Hello World; 10K requests per 1 client."), + do_stream_h_hello(Config, 1). + +stream_h_hello_10(Config) -> + doc("Stream handler Hello World; 10K requests per 10 clients."), + do_stream_h_hello(Config, 10). + +do_stream_h_hello(Config, NumClients) -> + Ref = config(ref, Config), + ProtoOpts = ranch:get_protocol_options(Ref), + StreamHandlers = case ProtoOpts of + #{stream_handlers := _} -> [cowboy_compress_h, stream_hello_h]; + _ -> [stream_hello_h] + end, + ranch:set_protocol_options(Ref, ProtoOpts#{ + env => #{}, + stream_handlers => StreamHandlers + }), + do_bench_get(?FUNCTION_NAME, "/", #{}, NumClients, 10000, Config), + ranch:set_protocol_options(Ref, ProtoOpts). + +%% Tests: Large body upload. + +plain_h_1M_post_1(Config) -> + doc("Plain HTTP handler body reading; 10K requests per 1 client."), + do_bench_post(?FUNCTION_NAME, "/read_body", #{}, <<0:8_000_000>>, 1, 10000, Config). + +plain_h_1M_post_10(Config) -> + doc("Plain HTTP handler body reading; 10K requests per 10 clients."), + do_bench_post(?FUNCTION_NAME, "/read_body", #{}, <<0:8_000_000>>, 10, 10000, Config). + +plain_h_10G_post(Config) -> + doc("Plain HTTP handler body reading; 1 request with a 10GB body."), + do_bench_post_one_large(?FUNCTION_NAME, "/read_body", #{}, 10_000, <<0:8_000_000>>, Config). + +%% Internal. + +do_bench_get(What, Path, Headers, NumClients, NumRuns, Config) -> + Clients = [spawn_link(?MODULE, do_bench_get_proc, + [self(), What, Path, Headers, NumRuns, Config]) + || _ <- lists:seq(1, NumClients)], + {Time, _} = timer:tc(?MODULE, do_bench_wait, [What, Clients]), + do_log("~32s: ~8bµs ~8.1freqs/s", [ + [atom_to_list(config(group, Config)), $., atom_to_list(What)], + Time, + (NumClients * NumRuns) / Time * 1_000_000]), + ok. + +do_bench_get_proc(Parent, What, Path, Headers0, NumRuns, Config) -> + ConnPid = gun_open(Config), + Headers = Headers0#{<<"accept-encoding">> => <<"gzip">>}, + Parent ! {What, ready}, + receive {What, go} -> ok end, + do_bench_get_run(ConnPid, Path, Headers, NumRuns), + Parent ! {What, done}, + gun:close(ConnPid). + +do_bench_get_run(_, _, _, 0) -> + ok; +do_bench_get_run(ConnPid, Path, Headers, Num) -> + Ref = gun:request(ConnPid, <<"GET">>, Path, Headers, <<>>), + {response, IsFin, 200, _RespHeaders} = gun:await(ConnPid, Ref, infinity), + {ok, _} = case IsFin of + nofin -> gun:await_body(ConnPid, Ref, infinity); + fin -> {ok, <<>>} + end, + do_bench_get_run(ConnPid, Path, Headers, Num - 1). + +do_bench_post(What, Path, Headers, Body, NumClients, NumRuns, Config) -> + Clients = [spawn_link(?MODULE, do_bench_post_proc, + [self(), What, Path, Headers, Body, NumRuns, Config]) + || _ <- lists:seq(1, NumClients)], + {Time, _} = timer:tc(?MODULE, do_bench_wait, [What, Clients]), + do_log("~32s: ~8bµs ~8.1freqs/s", [ + [atom_to_list(config(group, Config)), $., atom_to_list(What)], + Time, + (NumClients * NumRuns) / Time * 1_000_000]), + ok. + +do_bench_post_proc(Parent, What, Path, Headers0, Body, NumRuns, Config) -> + ConnPid = gun_open(Config), + Headers = Headers0#{<<"accept-encoding">> => <<"gzip">>}, + Parent ! {What, ready}, + receive {What, go} -> ok end, + do_bench_post_run(ConnPid, Path, Headers, Body, NumRuns), + Parent ! {What, done}, + gun:close(ConnPid). + +do_bench_post_run(_, _, _, _, 0) -> + ok; +do_bench_post_run(ConnPid, Path, Headers, Body, Num) -> + Ref = gun:request(ConnPid, <<"POST">>, Path, Headers, Body), + {response, IsFin, 200, _RespHeaders} = gun:await(ConnPid, Ref, infinity), + {ok, _} = case IsFin of + nofin -> gun:await_body(ConnPid, Ref, infinity); + fin -> {ok, <<>>} + end, + do_bench_post_run(ConnPid, Path, Headers, Body, Num - 1). + +do_bench_post_one_large(What, Path, Headers, NumChunks, BodyChunk, Config) -> + Client = spawn_link(?MODULE, do_bench_post_one_large_proc, + [self(), What, Path, Headers, NumChunks, BodyChunk, Config]), + {Time, _} = timer:tc(?MODULE, do_bench_wait, [What, [Client]]), + do_log("~32s: ~8bµs ~8.1freqs/s", [ + [atom_to_list(config(group, Config)), $., atom_to_list(What)], + Time, + 1 / Time * 1_000_000]), + ok. + +do_bench_post_one_large_proc(Parent, What, Path, Headers0, NumChunks, BodyChunk, Config) -> + ConnPid = gun_open(Config), + Headers = Headers0#{<<"accept-encoding">> => <<"gzip">>}, + Parent ! {What, ready}, + receive {What, go} -> ok end, + StreamRef = gun:headers(ConnPid, <<"POST">>, Path, Headers#{ + <<"content-length">> => integer_to_binary(NumChunks * byte_size(BodyChunk)) + }), + do_bench_post_one_large_run(ConnPid, StreamRef, NumChunks - 1, BodyChunk), + {response, IsFin, 200, _RespHeaders} = gun:await(ConnPid, StreamRef, infinity), + {ok, _} = case IsFin of + nofin -> gun:await_body(ConnPid, StreamRef, infinity); + fin -> {ok, <<>>} + end, + Parent ! {What, done}, + gun:close(ConnPid). + +do_bench_post_one_large_run(ConnPid, StreamRef, 0, BodyChunk) -> + gun:data(ConnPid, StreamRef, fin, BodyChunk); +do_bench_post_one_large_run(ConnPid, StreamRef, NumChunks, BodyChunk) -> + gun:data(ConnPid, StreamRef, nofin, BodyChunk), + do_bench_post_one_large_run(ConnPid, StreamRef, NumChunks - 1, BodyChunk). + +do_bench_wait(What, Clients) -> + _ = [receive {What, ready} -> ok end || _ <- Clients], + _ = [ClientPid ! {What, go} || ClientPid <- Clients], + _ = [receive {What, done} -> ok end || _ <- Clients], + ok. + +do_log(Str, Args) -> + ct:log(Str, Args), + io:format(ct_default_gl, Str ++ "~n", Args). diff --git a/test/loop_handler_SUITE.erl b/test/loop_handler_SUITE.erl index a7b5303..71aa801 100644 --- a/test/loop_handler_SUITE.erl +++ b/test/loop_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -32,7 +32,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Dispatch configuration. @@ -40,7 +40,10 @@ init_dispatch(_) -> cowboy_router:compile([{'_', [ {"/long_polling", long_polling_h, []}, {"/loop_body", loop_handler_body_h, []}, - {"/loop_timeout", loop_handler_timeout_h, []} + {"/loop_request_timeout", loop_handler_timeout_h, []}, + {"/loop_timeout_init", loop_handler_timeout_init_h, []}, + {"/loop_timeout_info", loop_handler_timeout_info_h, []}, + {"/loop_timeout_hibernate", loop_handler_timeout_hibernate_h, []} ]}]). %% Tests. @@ -79,6 +82,31 @@ long_polling_pipeline(Config) -> request_timeout(Config) -> doc("Ensure that the request_timeout isn't applied when a request is ongoing."), ConnPid = gun_open(Config), - Ref = gun:get(ConnPid, "/loop_timeout", [{<<"accept-encoding">>, <<"gzip">>}]), + Ref = gun:get(ConnPid, "/loop_request_timeout", [{<<"accept-encoding">>, <<"gzip">>}]), {response, nofin, 200, _} = gun:await(ConnPid, Ref, 10000), ok. + +timeout_hibernate(Config) -> + doc("Ensure that loop handler idle timeouts don't trigger after hibernate is returned."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/loop_timeout_hibernate", [{<<"accept-encoding">>, <<"gzip">>}]), + {response, fin, 200, _} = gun:await(ConnPid, Ref), + ok. + +timeout_info(Config) -> + doc("Ensure that loop handler idle timeouts trigger on time when set in info/3."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/loop_timeout_info", [{<<"accept-encoding">>, <<"gzip">>}]), + {response, fin, 299, _} = gun:await(ConnPid, Ref), + ok. + +timeout_init(Config) -> + doc("Ensure that loop handler idle timeouts trigger on time when set in init/2."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/loop_timeout_init?timeout=1000", + [{<<"accept-encoding">>, <<"gzip">>}]), + {response, fin, 200, _} = gun:await(ConnPid, Ref), + Ref2 = gun:get(ConnPid, "/loop_timeout_init?timeout=100", + [{<<"accept-encoding">>, <<"gzip">>}]), + {response, fin, 299, _} = gun:await(ConnPid, Ref2), + ok. diff --git a/test/metrics_SUITE.erl b/test/metrics_SUITE.erl index 74a259f..784bec1 100644 --- a/test/metrics_SUITE.erl +++ b/test/metrics_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -44,6 +44,8 @@ init_per_group(Name = h2, Config) -> init_per_group(Name = h2c, Config) -> Config1 = cowboy_test:init_http(Name, init_plain_opts(Config), Config), lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3, Config) -> + cowboy_test:init_http3(Name, init_plain_opts(Config), Config); init_per_group(Name = http_compress, Config) -> cowboy_test:init_http(Name, init_compress_opts(Config), Config); init_per_group(Name = https_compress, Config) -> @@ -52,10 +54,12 @@ init_per_group(Name = h2_compress, Config) -> cowboy_test:init_http2(Name, init_compress_opts(Config), Config); init_per_group(Name = h2c_compress, Config) -> Config1 = cowboy_test:init_http(Name, init_compress_opts(Config), Config), - lists:keyreplace(protocol, 1, Config1, {protocol, http2}). + lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3_compress, Config) -> + cowboy_test:init_http3(Name, init_compress_opts(Config), Config). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_plain_opts(Config) -> #{ @@ -157,16 +161,24 @@ do_get(Path, UserData, Config) -> #{ ref := _, pid := From, - streamid := 1, - reason := normal, + streamid := StreamID, + reason := normal, %% @todo Getting h3_no_error here. req := #{}, informational := [], user_data := UserData } = Metrics, + do_check_streamid(StreamID, Config), %% All good! gun:close(ConnPid) end. +do_check_streamid(StreamID, Config) -> + case config(protocol, Config) of + http -> 1 = StreamID; + http2 -> 1 = StreamID; + http3 -> 0 = StreamID + end. + post_body(Config) -> doc("Confirm metrics are correct for a normal POST request."), %% Perform a POST request. @@ -218,12 +230,13 @@ post_body(Config) -> #{ ref := _, pid := From, - streamid := 1, + streamid := StreamID, reason := normal, req := #{}, informational := [], user_data := #{} } = Metrics, + do_check_streamid(StreamID, Config), %% All good! gun:close(ConnPid) end. @@ -273,12 +286,13 @@ no_resp_body(Config) -> #{ ref := _, pid := From, - streamid := 1, + streamid := StreamID, reason := normal, req := #{}, informational := [], user_data := #{} } = Metrics, + do_check_streamid(StreamID, Config), %% All good! gun:close(ConnPid) end. @@ -291,7 +305,8 @@ early_error(Config) -> %% reason in both protocols. {Method, Headers, Status, Error} = case config(protocol, Config) of http -> {<<"GET">>, [{<<"host">>, <<"host:port">>}], 400, protocol_error}; - http2 -> {<<"TRACE">>, [], 501, no_error} + http2 -> {<<"TRACE">>, [], 501, no_error}; + http3 -> {<<"TRACE">>, [], 501, h3_no_error} end, Ref = gun:request(ConnPid, Method, "/", [ {<<"accept-encoding">>, <<"gzip">>}, @@ -305,7 +320,7 @@ early_error(Config) -> #{ ref := _, pid := From, - streamid := 1, + streamid := StreamID, reason := {stream_error, Error, _}, partial_req := #{}, resp_status := Status, @@ -313,6 +328,7 @@ early_error(Config) -> early_error_time := _, resp_body_length := 0 } = Metrics, + do_check_streamid(StreamID, Config), ExpectedRespHeaders = maps:from_list(RespHeaders), %% All good! gun:close(ConnPid) @@ -321,7 +337,8 @@ early_error(Config) -> early_error_request_line(Config) -> case config(protocol, Config) of http -> do_early_error_request_line(Config); - http2 -> doc("There are no request lines in HTTP/2.") + http2 -> doc("There are no request lines in HTTP/2."); + http3 -> doc("There are no request lines in HTTP/3.") end. do_early_error_request_line(Config) -> @@ -341,7 +358,7 @@ do_early_error_request_line(Config) -> #{ ref := _, pid := From, - streamid := 1, + streamid := StreamID, reason := {connection_error, protocol_error, _}, partial_req := #{}, resp_status := 400, @@ -349,6 +366,7 @@ do_early_error_request_line(Config) -> early_error_time := _, resp_body_length := 0 } = Metrics, + do_check_streamid(StreamID, Config), ExpectedRespHeaders = maps:from_list(RespHeaders), %% All good! ok @@ -362,7 +380,9 @@ stream_reply(Config) -> ws(Config) -> case config(protocol, Config) of http -> do_ws(Config); - http2 -> doc("It is not currently possible to switch to Websocket over HTTP/2.") + %% @todo The test can be implemented for HTTP/2. + http2 -> doc("It is not currently possible to switch to Websocket over HTTP/2."); + http3 -> {skip, "Gun does not currently support Websocket over HTTP/3."} end. do_ws(Config) -> @@ -405,7 +425,7 @@ do_ws(Config) -> #{ ref := _, pid := From, - streamid := 1, + streamid := StreamID, reason := switch_protocol, req := #{}, %% A 101 upgrade response was sent. @@ -420,6 +440,7 @@ do_ws(Config) -> }], user_data := #{} } = Metrics, + do_check_streamid(StreamID, Config), %% All good! ok end, @@ -438,7 +459,15 @@ error_response(Config) -> {<<"accept-encoding">>, <<"gzip">>}, {<<"x-test-pid">>, pid_to_list(self())} ]), - {response, fin, 500, RespHeaders} = gun:await(ConnPid, Ref, infinity), + Protocol = config(protocol, Config), + RespHeaders = case gun:await(ConnPid, Ref, infinity) of + {response, fin, 500, RespHeaders0} -> + RespHeaders0; + %% The RST_STREAM arrived before the start of the response. + %% See maybe_h3_error comment for details. + {error, {stream_error, {stream_error, h3_internal_error, _}}} when Protocol =:= http3 -> + unknown + end, timer:sleep(100), %% Receive the metrics and validate them. receive @@ -463,25 +492,33 @@ error_response(Config) -> resp_headers := ExpectedRespHeaders, resp_body_length := 0 } = Metrics, - ExpectedRespHeaders = maps:from_list(RespHeaders), + case RespHeaders of + %% The HTTP/3 stream has reset too early so we can't + %% verify the response headers. + unknown -> + ok; + _ -> + ExpectedRespHeaders = maps:from_list(RespHeaders) + end, %% The request process executed normally. #{procs := Procs} = Metrics, [{_, #{ spawn := ProcSpawn, exit := ProcExit, - reason := {crash, _StackTrace} + reason := {crash, StackTrace} }}] = maps:to_list(Procs), true = ProcSpawn =< ProcExit, %% Confirm other metadata are as expected. #{ ref := _, pid := From, - streamid := 1, - reason := {internal_error, {'EXIT', _Pid, {crash, _StackTrace}}, 'Stream process crashed.'}, + streamid := StreamID, + reason := {internal_error, {'EXIT', _Pid, {crash, StackTrace}}, 'Stream process crashed.'}, req := #{}, informational := [], user_data := #{} } = Metrics, + do_check_streamid(StreamID, Config), %% All good! gun:close(ConnPid) end. @@ -495,7 +532,15 @@ error_response_after_reply(Config) -> {<<"accept-encoding">>, <<"gzip">>}, {<<"x-test-pid">>, pid_to_list(self())} ]), - {response, fin, 200, RespHeaders} = gun:await(ConnPid, Ref, infinity), + Protocol = config(protocol, Config), + RespHeaders = case gun:await(ConnPid, Ref, infinity) of + {response, fin, 200, RespHeaders0} -> + RespHeaders0; + %% The RST_STREAM arrived before the start of the response. + %% See maybe_h3_error comment for details. + {error, {stream_error, {stream_error, h3_internal_error, _}}} when Protocol =:= http3 -> + unknown + end, timer:sleep(100), %% Receive the metrics and validate them. receive @@ -520,25 +565,33 @@ error_response_after_reply(Config) -> resp_headers := ExpectedRespHeaders, resp_body_length := 0 } = Metrics, - ExpectedRespHeaders = maps:from_list(RespHeaders), + case RespHeaders of + %% The HTTP/3 stream has reset too early so we can't + %% verify the response headers. + unknown -> + ok; + _ -> + ExpectedRespHeaders = maps:from_list(RespHeaders) + end, %% The request process executed normally. #{procs := Procs} = Metrics, [{_, #{ spawn := ProcSpawn, exit := ProcExit, - reason := {crash, _StackTrace} + reason := {crash, StackTrace} }}] = maps:to_list(Procs), true = ProcSpawn =< ProcExit, %% Confirm other metadata are as expected. #{ ref := _, pid := From, - streamid := 1, - reason := {internal_error, {'EXIT', _Pid, {crash, _StackTrace}}, 'Stream process crashed.'}, + streamid := StreamID, + reason := {internal_error, {'EXIT', _Pid, {crash, StackTrace}}, 'Stream process crashed.'}, req := #{}, informational := [], user_data := #{} } = Metrics, + do_check_streamid(StreamID, Config), %% All good! gun:close(ConnPid) end. diff --git a/test/misc_SUITE.erl b/test/misc_SUITE.erl index 6245636..e834156 100644 --- a/test/misc_SUITE.erl +++ b/test/misc_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -21,29 +21,29 @@ -import(cowboy_test, [gun_open/1]). all() -> - [{group, app}, {group, set_env}|cowboy_test:common_all()]. + [{group, app}, {group, env}|cowboy_test:common_all()]. groups() -> Common = ct_helper:all(?MODULE) - -- [restart_gracefully, set_env, set_env_missing], + -- [restart_gracefully, get_env, set_env, set_env_missing], [ {app, [], [restart_gracefully]}, - {set_env, [parallel], [set_env, set_env_missing]} + {env, [parallel], [get_env, set_env, set_env_missing]} |cowboy_test:common_groups(Common)]. init_per_group(Name=app, Config) -> cowboy_test:init_http(Name, #{ env => #{dispatch => init_dispatch(Config)} }, Config); -init_per_group(set_env, Config) -> +init_per_group(env, Config) -> Config; init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). -end_per_group(set_env, _) -> +end_per_group(env, _) -> ok; end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_dispatch(_) -> cowboy_router:compile([{"localhost", [ @@ -84,6 +84,26 @@ router_invalid_path(Config) -> {response, _, 400, _} = gun:await(ConnPid, Ref), ok. +get_env(Config0) -> + doc("Ensure we can retrieve middleware environment values."), + Dispatch = init_dispatch(Config0), + _Config = cowboy_test:init_http(?FUNCTION_NAME, #{ + env => #{ + dispatch => Dispatch, + the_key => the_value + } + }, Config0), + try + Dispatch = cowboy:get_env(?FUNCTION_NAME, dispatch), + Dispatch = cowboy:get_env(?FUNCTION_NAME, dispatch, the_default), + the_value = cowboy:get_env(?FUNCTION_NAME, the_key), + the_value = cowboy:get_env(?FUNCTION_NAME, the_key, the_default), + {'EXIT', _} = (catch cowboy:get_env(?FUNCTION_NAME, missing_key)), + the_default = cowboy:get_env(?FUNCTION_NAME, missing_key, the_default) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + set_env(Config0) -> doc("Live replace a middleware environment value."), Config = cowboy_test:init_http(?FUNCTION_NAME, #{ diff --git a/test/plain_handler_SUITE.erl b/test/plain_handler_SUITE.erl index e980d5b..7684e6b 100644 --- a/test/plain_handler_SUITE.erl +++ b/test/plain_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -39,12 +39,13 @@ 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. init_dispatch(_) -> cowboy_router:compile([{"localhost", [ + {"/crash/external_exit", crash_h, external_exit}, {"/crash/no_reply", crash_h, no_reply}, {"/crash/reply", crash_h, reply} ]}]). @@ -58,8 +59,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) -> @@ -71,3 +79,13 @@ crash_before_reply(Config) -> ]), {response, fin, 500, _} = gun:await(ConnPid, Ref), gun:close(ConnPid). + +external_exit_before_reply(Config) -> + doc("A plain handler exits externally before a response was sent " + "results in a 500 response."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/crash/external_exit", [ + {<<"accept-encoding">>, <<"gzip">>} + ]), + {response, fin, 500, _} = gun:await(ConnPid, Ref), + gun:close(ConnPid). diff --git a/test/proxy_header_SUITE.erl b/test/proxy_header_SUITE.erl index be6ab04..c8f63a3 100644 --- a/test/proxy_header_SUITE.erl +++ b/test/proxy_header_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -71,6 +71,30 @@ init_dispatch() -> %% Tests. +fail_gracefully_on_disconnect(Config) -> + doc("Probing a port must not generate a crash"), + {ok, Socket} = gen_tcp:connect("localhost", config(port, Config), + [binary, {active, false}, {packet, raw}]), + timer:sleep(50), + Pid = case config(type, Config) of + tcp -> ct_helper:get_remote_pid_tcp(Socket); + %% We connect to a TLS port using a TCP socket so we need + %% to first obtain the remote pid of the TCP socket, which + %% is a TLS socket on the server, and then get the real + %% remote pid from its state. + ssl -> ct_helper:get_remote_pid_tls_state(ct_helper:get_remote_pid_tcp(Socket)) + end, + Ref = erlang:monitor(process, Pid), + gen_tcp:close(Socket), + receive + {'DOWN', Ref, process, Pid, {shutdown, closed}} -> + ok; + {'DOWN', Ref, process, Pid, Reason} -> + error(Reason) + after 500 -> + error(timeout) + end. + v1_proxy_header(Config) -> doc("Confirm we can read the proxy header at the start of the connection."), ProxyInfo = #{ @@ -126,7 +150,8 @@ do_proxy_header_https(Config, ProxyInfo) -> {ok, Socket0} = gen_tcp:connect("localhost", config(port, Config), [binary, {active, false}, {packet, raw}]), ok = gen_tcp:send(Socket0, ranch_proxy_header:header(ProxyInfo)), - {ok, Socket} = ssl:connect(Socket0, [], 1000), + TlsOpts = ct_helper:get_certs_from_ets(), + {ok, Socket} = ssl:connect(Socket0, TlsOpts, 1000), do_proxy_header_http_common({raw_client, Socket, ssl}, ProxyInfo). do_proxy_header_http_common(Client, ProxyInfo) -> @@ -151,7 +176,9 @@ do_proxy_header_h2(Config, ProxyInfo) -> {ok, Socket0} = gen_tcp:connect("localhost", config(port, Config), [binary, {active, false}, {packet, raw}]), ok = gen_tcp:send(Socket0, ranch_proxy_header:header(ProxyInfo)), - {ok, Socket} = ssl:connect(Socket0, [{alpn_advertised_protocols, [<<"h2">>]}], 1000), + TlsOpts = ct_helper:get_certs_from_ets(), + {ok, Socket} = ssl:connect(Socket0, + [{alpn_advertised_protocols, [<<"h2">>]}|TlsOpts], 1000), do_proxy_header_h2_common({raw_client, Socket, ssl}, ProxyInfo). do_proxy_header_h2c(Config, ProxyInfo) -> diff --git a/test/req_SUITE.erl b/test/req_SUITE.erl index 8216a95..9adc6e4 100644 --- a/test/req_SUITE.erl +++ b/test/req_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2016-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -46,7 +46,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Routes. @@ -57,13 +57,16 @@ init_dispatch(Config) -> {"/resp/:key[/:arg]", resp_h, []}, {"/multipart[/:key]", multipart_h, []}, {"/args/:key/:arg[/:default]", echo_h, []}, - {"/crash/:key/period", echo_h, #{length => 999999999, period => 1000, crash => true}}, + {"/crash/:key/period", echo_h, + #{length => 999999999, period => 1000, timeout => 5000, crash => true}}, {"/no-opts/:key", echo_h, #{crash => true}}, {"/opts/:key/length", echo_h, #{length => 1000}}, {"/opts/:key/period", echo_h, #{length => 999999999, period => 2000}}, {"/opts/:key/timeout", echo_h, #{timeout => 1000, crash => true}}, {"/100-continue/:key", echo_h, []}, {"/full/:key", echo_h, []}, + {"/auto-sync/:key", echo_h, []}, + {"/auto-async/:key", echo_h, []}, {"/spawn/:key", echo_h, []}, {"/no/:key", echo_h, []}, {"/direct/:key/[...]", echo_h, []}, @@ -104,13 +107,17 @@ do_get(Path, Config) -> do_get(Path, Headers, Config) -> ConnPid = gun_open(Config), Ref = gun:get(ConnPid, Path, [{<<"accept-encoding">>, <<"gzip">>}|Headers]), - {response, IsFin, Status, RespHeaders} = gun:await(ConnPid, Ref, infinity), - {ok, RespBody} = case IsFin of - nofin -> gun:await_body(ConnPid, Ref, infinity); - fin -> {ok, <<>>} - end, - gun:close(ConnPid), - {Status, RespHeaders, do_decode(RespHeaders, RespBody)}. + case gun:await(ConnPid, Ref, infinity) of + {response, IsFin, Status, RespHeaders} -> + {ok, RespBody} = case IsFin of + nofin -> gun:await_body(ConnPid, Ref, infinity); + fin -> {ok, <<>>} + end, + gun:close(ConnPid), + {Status, RespHeaders, do_decode(RespHeaders, RespBody)}; + {error, {stream_error, Error}} -> + Error + end. do_get_body(Path, Config) -> do_get_body(Path, [], Config). @@ -139,7 +146,9 @@ do_get_inform(Path, Config) -> fin -> {ok, <<>>} end, gun:close(ConnPid), - {InfoStatus, InfoHeaders, RespStatus, RespHeaders, do_decode(RespHeaders, RespBody)} + {InfoStatus, InfoHeaders, RespStatus, RespHeaders, do_decode(RespHeaders, RespBody)}; + {error, {stream_error, Error}} -> + Error end. do_decode(Headers, Body) -> @@ -181,25 +190,20 @@ bindings(Config) -> cert(Config) -> case config(type, Config) of tcp -> doc("TLS certificates can only be provided over TLS."); - ssl -> do_cert(Config) + ssl -> do_cert(Config); + quic -> do_cert(Config) end. -do_cert(Config0) -> +do_cert(Config) -> doc("A client TLS certificate was provided."), - {CaCert, Cert, Key} = ct_helper:make_certs(), - Config = [{tls_opts, [ - {cert, Cert}, - {key, Key}, - {cacerts, [CaCert]} - ]}|Config0], Cert = do_get_body("/cert", Config), Cert = do_get_body("/direct/cert", Config), ok. cert_undefined(Config) -> doc("No client TLS certificate was provided."), - <<"undefined">> = do_get_body("/cert", Config), - <<"undefined">> = do_get_body("/direct/cert", Config), + <<"undefined">> = do_get_body("/cert", [{no_cert, true}|Config]), + <<"undefined">> = do_get_body("/direct/cert", [{no_cert, true}|Config]), ok. header(Config) -> @@ -239,8 +243,10 @@ match_cookies(Config) -> <<"#{}">> = do_get_body("/match/cookies", [{<<"cookie">>, "a=b; c=d"}], Config), <<"#{a => <<\"b\">>}">> = do_get_body("/match/cookies/a", [{<<"cookie">>, "a=b; c=d"}], Config), <<"#{c => <<\"d\">>}">> = do_get_body("/match/cookies/c", [{<<"cookie">>, "a=b; c=d"}], Config), - <<"#{a => <<\"b\">>,c => <<\"d\">>}">> = do_get_body("/match/cookies/a/c", - [{<<"cookie">>, "a=b; c=d"}], Config), + case do_get_body("/match/cookies/a/c", [{<<"cookie">>, "a=b; c=d"}], Config) of + <<"#{a => <<\"b\">>,c => <<\"d\">>}">> -> ok; + <<"#{c => <<\"d\">>,a => <<\"b\">>}">> -> ok + end, %% Ensure match errors result in a 400 response. {400, _, _} = do_get("/match/cookies/a/c", [{<<"cookie">>, "a=b"}], Config), @@ -253,11 +259,21 @@ match_qs(Config) -> <<"#{}">> = do_get_body("/match/qs?a=b&c=d", Config), <<"#{a => <<\"b\">>}">> = do_get_body("/match/qs/a?a=b&c=d", Config), <<"#{c => <<\"d\">>}">> = do_get_body("/match/qs/c?a=b&c=d", Config), - <<"#{a => <<\"b\">>,c => <<\"d\">>}">> = do_get_body("/match/qs/a/c?a=b&c=d", Config), - <<"#{a => <<\"b\">>,c => true}">> = do_get_body("/match/qs/a/c?a=b&c", Config), - <<"#{a => true,c => <<\"d\">>}">> = do_get_body("/match/qs/a/c?a&c=d", Config), + case do_get_body("/match/qs/a/c?a=b&c=d", Config) of + <<"#{a => <<\"b\">>,c => <<\"d\">>}">> -> ok; + <<"#{c => <<\"d\">>,a => <<\"b\">>}">> -> ok + end, + case do_get_body("/match/qs/a/c?a=b&c", Config) of + <<"#{a => <<\"b\">>,c => true}">> -> ok; + <<"#{c => true,a => <<\"b\">>}">> -> ok + end, + case do_get_body("/match/qs/a/c?a&c=d", Config) of + <<"#{a => true,c => <<\"d\">>}">> -> ok; + <<"#{c => <<\"d\">>,a => true}">> -> ok + end, %% Ensure match errors result in a 400 response. {400, _, _} = do_get("/match/qs/a/c?a=b", [], Config), + {400, _, _} = do_get("/match/qs_with_constraints", [], Config), %% This function is tested more extensively through unit tests. ok. @@ -308,7 +324,7 @@ filter_then_parse_cookies(Config) -> [{<<"cookie">>, "bad name=strawberry"}], Config), <<"[{<<\"cake\">>,<<\"strawberry\">>}]">> = do_get_body("/filter_then_parse_cookies", - [{<<"cookie">>, "bad name=strawberry; cake=strawberry"}], Config), + [{<<"cookie">>, "bad name=strawberry; another bad name=strawberry; cake=strawberry"}], Config), <<"[]">> = do_get_body("/filter_then_parse_cookies", [{<<"cookie">>, "Blocked by http://www.example.com/upgrade-to-remove"}], Config), @@ -377,7 +393,8 @@ port(Config) -> Port = do_get_body("/direct/port", Config), ExpectedPort = case config(type, Config) of tcp -> <<"80">>; - ssl -> <<"443">> + ssl -> <<"443">>; + quic -> <<"443">> end, ExpectedPort = do_get_body("/port", [{<<"host">>, <<"localhost">>}], Config), ExpectedPort = do_get_body("/direct/port", [{<<"host">>, <<"localhost">>}], Config), @@ -403,7 +420,8 @@ do_scheme(Path, Config) -> Transport = config(type, Config), case do_get_body(Path, Config) of <<"http">> when Transport =:= tcp -> ok; - <<"https">> when Transport =:= ssl -> ok + <<"https">> when Transport =:= ssl -> ok; + <<"https">> when Transport =:= quic -> ok end. sock(Config) -> @@ -416,7 +434,8 @@ uri(Config) -> doc("Request URI building/modification."), Scheme = case config(type, Config) of tcp -> <<"http">>; - ssl -> <<"https">> + ssl -> <<"https">>; + quic -> <<"https">> end, SLen = byte_size(Scheme), Port = integer_to_binary(config(port, Config)), @@ -450,7 +469,8 @@ do_version(Path, Config) -> Protocol = config(protocol, Config), case do_get_body(Path, Config) of <<"HTTP/1.1">> when Protocol =:= http -> ok; - <<"HTTP/2">> when Protocol =:= http2 -> ok + <<"HTTP/2">> when Protocol =:= http2 -> ok; + <<"HTTP/3">> when Protocol =:= http3 -> ok end. %% Tests: Request body. @@ -504,11 +524,19 @@ read_body_period(Config) -> %% for 2 seconds. The test succeeds if we get some of the data back %% (meaning the function will have returned after the period ends). gun:data(ConnPid, Ref, nofin, Body), - {response, nofin, 200, _} = gun:await(ConnPid, Ref, infinity), - {data, _, Data} = gun:await(ConnPid, Ref, infinity), - %% We expect to read at least some data. - true = Data =/= <<>>, - gun:close(ConnPid). + Response = gun:await(ConnPid, Ref, infinity), + case Response of + {response, nofin, 200, _} -> + {data, _, Data} = gun:await(ConnPid, Ref, infinity), + %% We expect to read at least some data. + true = Data =/= <<>>, + gun:close(ConnPid); + %% We got a crash, likely because the environment + %% was overloaded and the timeout triggered. Try again. + {response, _, 500, _} -> + gun:close(ConnPid), + read_body_period(Config) + end. %% We expect a crash. do_read_body_timeout(Path, Body, Config) -> @@ -516,9 +544,21 @@ do_read_body_timeout(Path, Body, Config) -> Ref = gun:headers(ConnPid, "POST", Path, [ {<<"content-length">>, integer_to_binary(byte_size(Body))} ]), - {response, _, 500, _} = gun:await(ConnPid, Ref, infinity), + case gun:await(ConnPid, Ref, infinity) of + {response, _, 500, _} -> + ok; + %% See do_maybe_h3_error comment for details. + {error, {stream_error, {stream_error, h3_internal_error, _}}} -> + ok + end, gun:close(ConnPid). +read_body_auto(Config) -> + doc("Read the request body using auto mode."), + <<0:80000000>> = do_body("POST", "/auto-sync/read_body", [], <<0:80000000>>, Config), + <<0:80000000>> = do_body("POST", "/auto-async/read_body", [], <<0:80000000>>, Config), + ok. + read_body_spawn(Config) -> doc("Confirm we can use cowboy_req:read_body/1,2 from another process."), <<"hello world!">> = do_body("POST", "/spawn/read_body", [], "hello world!", Config), @@ -549,7 +589,8 @@ do_read_body_expect_100_continue(Path, Config) -> fin -> {ok, <<>>} end, gun:close(ConnPid), - do_decode(RespHeaders, RespBody). + do_decode(RespHeaders, RespBody), + ok. read_urlencoded_body(Config) -> doc("application/x-www-form-urlencoded request body."), @@ -576,8 +617,20 @@ do_read_urlencoded_body_too_large(Path, Body, Config) -> {<<"content-length">>, integer_to_binary(iolist_size(Body))} ]), gun:data(ConnPid, Ref, fin, Body), - {response, _, 413, _} = gun:await(ConnPid, Ref, infinity), - gun:close(ConnPid). + Response = gun:await(ConnPid, Ref, infinity), + gun:close(ConnPid), + case Response of + {response, _, 413, _} -> + ok; + %% We got the wrong crash, likely because the environment + %% was overloaded and the timeout triggered. Try again. + {response, _, 408, _} -> + do_read_urlencoded_body_too_large(Path, Body, Config); + %% Timing issues make it possible for the connection to be + %% closed before the data went through. We retry. + {error, {stream_error, {closed, {error,closed}}}} -> + do_read_urlencoded_body_too_large(Path, Body, Config) + end. read_urlencoded_body_too_long(Config) -> doc("application/x-www-form-urlencoded request body sent too slow. " @@ -592,25 +645,37 @@ do_read_urlencoded_body_too_long(Path, Body, Config) -> {<<"content-length">>, integer_to_binary(byte_size(Body) * 2)} ]), gun:data(ConnPid, Ref, nofin, Body), - {response, _, 408, RespHeaders} = gun:await(ConnPid, Ref, infinity), - _ = case config(protocol, Config) of - http -> + Protocol = config(protocol, Config), + case gun:await(ConnPid, Ref, infinity) of + {response, _, 408, RespHeaders} when Protocol =:= http -> %% 408 error responses should close HTTP/1.1 connections. - {_, <<"close">>} = lists:keyfind(<<"connection">>, 1, RespHeaders); - http2 -> - ok - end, - gun:close(ConnPid). + {_, <<"close">>} = lists:keyfind(<<"connection">>, 1, RespHeaders), + gun:close(ConnPid); + {response, _, 408, _} when Protocol =:= http2; Protocol =:= http3 -> + gun:close(ConnPid); + %% We must have hit the timeout due to busy CI environment. Retry. + {response, _, 500, _} -> + gun:close(ConnPid), + do_read_urlencoded_body_too_long(Path, Body, Config) + end. read_and_match_urlencoded_body(Config) -> doc("Read and match an application/x-www-form-urlencoded request body."), <<"#{}">> = do_body("POST", "/match/body_qs", [], "a=b&c=d", Config), <<"#{a => <<\"b\">>}">> = do_body("POST", "/match/body_qs/a", [], "a=b&c=d", Config), <<"#{c => <<\"d\">>}">> = do_body("POST", "/match/body_qs/c", [], "a=b&c=d", Config), - <<"#{a => <<\"b\">>,c => <<\"d\">>}">> - = do_body("POST", "/match/body_qs/a/c", [], "a=b&c=d", Config), - <<"#{a => <<\"b\">>,c => true}">> = do_body("POST", "/match/body_qs/a/c", [], "a=b&c", Config), - <<"#{a => true,c => <<\"d\">>}">> = do_body("POST", "/match/body_qs/a/c", [], "a&c=d", Config), + case do_body("POST", "/match/body_qs/a/c", [], "a=b&c=d", Config) of + <<"#{a => <<\"b\">>,c => <<\"d\">>}">> -> ok; + <<"#{c => <<\"d\">>,a => <<\"b\">>}">> -> ok + end, + case do_body("POST", "/match/body_qs/a/c", [], "a=b&c", Config) of + <<"#{a => <<\"b\">>,c => true}">> -> ok; + <<"#{c => true,a => <<\"b\">>}">> -> ok + end, + case do_body("POST", "/match/body_qs/a/c", [], "a&c=d", Config) of + <<"#{a => true,c => <<\"d\">>}">> -> ok; + <<"#{c => <<\"d\">>,a => true}">> -> ok + end, %% Ensure match errors result in a 400 response. {400, _} = do_body_error("POST", "/match/body_qs/a/c", [], "a=b", Config), %% Ensure parse errors result in a 400 response. @@ -768,18 +833,18 @@ set_resp_cookie(Config) -> doc("Response using set_resp_cookie."), %% Single cookie, no options. {200, Headers1, _} = do_get("/resp/set_resp_cookie3", Config), - {_, <<"mycookie=myvalue; Version=1">>} + {_, <<"mycookie=myvalue">>} = lists:keyfind(<<"set-cookie">>, 1, Headers1), %% Single cookie, with options. {200, Headers2, _} = do_get("/resp/set_resp_cookie4", Config), - {_, <<"mycookie=myvalue; Version=1; Path=/resp/set_resp_cookie4">>} + {_, <<"mycookie=myvalue; Path=/resp/set_resp_cookie4">>} = lists:keyfind(<<"set-cookie">>, 1, Headers2), %% Multiple cookies. {200, Headers3, _} = do_get("/resp/set_resp_cookie3/multiple", Config), [_, _] = [H || H={<<"set-cookie">>, _} <- Headers3], %% Overwrite previously set cookie. {200, Headers4, _} = do_get("/resp/set_resp_cookie3/overwrite", Config), - {_, <<"mycookie=overwrite; Version=1">>} + {_, <<"mycookie=overwrite">>} = lists:keyfind(<<"set-cookie">>, 1, Headers4), ok. @@ -787,13 +852,22 @@ set_resp_header(Config) -> doc("Response using set_resp_header."), {200, Headers, <<"OK">>} = do_get("/resp/set_resp_header", Config), true = lists:keymember(<<"content-type">>, 1, Headers), + %% The set-cookie header is special. set_resp_cookie must be used. + {500, _, _} = do_maybe_h3_error3(do_get("/resp/set_resp_header_cookie", Config)), ok. set_resp_headers(Config) -> doc("Response using set_resp_headers."), - {200, Headers, <<"OK">>} = do_get("/resp/set_resp_headers", Config), - true = lists:keymember(<<"content-type">>, 1, Headers), - true = lists:keymember(<<"content-encoding">>, 1, Headers), + {200, Headers1, <<"OK">>} = do_get("/resp/set_resp_headers", Config), + true = lists:keymember(<<"content-type">>, 1, Headers1), + true = lists:keymember(<<"content-encoding">>, 1, Headers1), + {200, Headers2, <<"OK">>} = do_get("/resp/set_resp_headers_list", Config), + true = lists:keymember(<<"content-type">>, 1, Headers2), + true = lists:keymember(<<"content-encoding">>, 1, Headers2), + {_, <<"one, two">>} = lists:keyfind(<<"test-header">>, 1, Headers2), + %% The set-cookie header is special. set_resp_cookie must be used. + {500, _, _} = do_maybe_h3_error3(do_get("/resp/set_resp_headers_cookie", Config)), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/set_resp_headers_list_cookie", Config)), ok. resp_header(Config) -> @@ -855,22 +929,52 @@ delete_resp_header(Config) -> false = lists:keymember(<<"content-type">>, 1, Headers), ok. +%% Data may be lost due to how RESET_STREAM QUIC frame works. +%% Because there is ongoing work for a better way to reset streams +%% (https://www.ietf.org/archive/id/draft-ietf-quic-reliable-stream-reset-03.html) +%% we convert the error to a 500 to keep the tests more explicit +%% at what we expect. +%% @todo When RESET_STREAM_AT gets added we can remove this function. +do_maybe_h3_error2({stream_error, h3_internal_error, _}) -> {500, []}; +do_maybe_h3_error2(Result) -> Result. + +do_maybe_h3_error3({stream_error, h3_internal_error, _}) -> {500, [], <<>>}; +do_maybe_h3_error3(Result) -> Result. + inform2(Config) -> doc("Informational response(s) without headers, followed by the real response."), {102, [], 200, _, _} = do_get_inform("/resp/inform2/102", Config), {102, [], 200, _, _} = do_get_inform("/resp/inform2/binary", Config), - {500, _} = do_get_inform("/resp/inform2/error", Config), + {500, _} = do_maybe_h3_error2(do_get_inform("/resp/inform2/error", Config)), {102, [], 200, _, _} = do_get_inform("/resp/inform2/twice", Config), - ok. + %% With HTTP/1.1 and HTTP/2 we will not get an error. + %% With HTTP/3 however the stream will occasionally + %% be reset before Gun receives the response. + case do_get_inform("/resp/inform2/after_reply", Config) of + {200, _} -> + ok; + {stream_error, h3_internal_error, _} -> + ok + end. inform3(Config) -> doc("Informational response(s) with headers, followed by the real response."), Headers = [{<<"ext-header">>, <<"ext-value">>}], {102, Headers, 200, _, _} = do_get_inform("/resp/inform3/102", Config), {102, Headers, 200, _, _} = do_get_inform("/resp/inform3/binary", Config), - {500, _} = do_get_inform("/resp/inform3/error", Config), + {500, _} = do_maybe_h3_error2(do_get_inform("/resp/inform3/error", Config)), + %% The set-cookie header is special. set_resp_cookie must be used. + {500, _} = do_maybe_h3_error2(do_get_inform("/resp/inform3/set_cookie", Config)), {102, Headers, 200, _, _} = do_get_inform("/resp/inform3/twice", Config), - ok. + %% With HTTP/1.1 and HTTP/2 we will not get an error. + %% With HTTP/3 however the stream will occasionally + %% be reset before Gun receives the response. + case do_get_inform("/resp/inform3/after_reply", Config) of + {200, _} -> + ok; + {stream_error, h3_internal_error, _} -> + ok + end. reply2(Config) -> doc("Response with default headers and no body."), @@ -878,9 +982,8 @@ reply2(Config) -> {201, _, _} = do_get("/resp/reply2/201", Config), {404, _, _} = do_get("/resp/reply2/404", Config), {200, _, _} = do_get("/resp/reply2/binary", Config), - {500, _, _} = do_get("/resp/reply2/error", Config), - %% @todo We want to crash when reply or stream_reply is called twice. - %% How to test this properly? This isn't enough. + {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply2/error", Config)), + %% @todo How to test this properly? This isn't enough. {200, _, _} = do_get("/resp/reply2/twice", Config), ok. @@ -892,7 +995,9 @@ reply3(Config) -> true = lists:keymember(<<"content-type">>, 1, Headers2), {404, Headers3, _} = do_get("/resp/reply3/404", Config), true = lists:keymember(<<"content-type">>, 1, Headers3), - {500, _, _} = do_get("/resp/reply3/error", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply3/error", Config)), + %% The set-cookie header is special. set_resp_cookie must be used. + {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply3/set_cookie", Config)), ok. reply4(Config) -> @@ -900,11 +1005,11 @@ reply4(Config) -> {200, _, <<"OK">>} = do_get("/resp/reply4/200", Config), {201, _, <<"OK">>} = do_get("/resp/reply4/201", Config), {404, _, <<"OK">>} = do_get("/resp/reply4/404", Config), - {500, _, _} = do_get("/resp/reply4/error", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply4/error", Config)), + %% The set-cookie header is special. set_resp_cookie must be used. + {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply4/set_cookie", Config)), ok. -%% @todo Crash when stream_reply is called twice. - stream_reply2(Config) -> doc("Response with default headers and streamed body."), Body = <<0:8000000>>, @@ -912,9 +1017,37 @@ stream_reply2(Config) -> {201, _, Body} = do_get("/resp/stream_reply2/201", Config), {404, _, Body} = do_get("/resp/stream_reply2/404", Config), {200, _, Body} = do_get("/resp/stream_reply2/binary", Config), - {500, _, _} = do_get("/resp/stream_reply2/error", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/stream_reply2/error", Config)), ok. +stream_reply2_twice(Config) -> + doc("Attempting to stream a response twice results in a crash."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/resp/stream_reply2/twice", + [{<<"accept-encoding">>, <<"gzip">>}]), + {response, nofin, 200, _} = gun:await(ConnPid, Ref, infinity), + Protocol = config(protocol, Config), + Flavor = config(flavor, Config), + case {Protocol, Flavor, gun:await_body(ConnPid, Ref, infinity)} of + %% In HTTP/1.1 we cannot propagate an error at that point. + %% The response will simply not have a body. + {http, vanilla, {ok, <<>>}} -> + ok; + %% When compression was used we do get gzip headers. But + %% we do not have any data in the zlib stream. + {http, compress, {ok, Data}} -> + Z = zlib:open(), + zlib:inflateInit(Z, 31), + 0 = iolist_size(zlib:inflate(Z, Data)), + ok; + %% In HTTP/2 and HTTP/3 the stream gets reset with an appropriate error. + {http2, _, {error, {stream_error, {stream_error, internal_error, _}}}} -> + ok; + {http3, _, {error, {stream_error, {stream_error, h3_internal_error, _}}}} -> + ok + end, + gun:close(ConnPid). + stream_reply3(Config) -> doc("Response with additional headers and streamed body."), Body = <<0:8000000>>, @@ -924,7 +1057,9 @@ stream_reply3(Config) -> true = lists:keymember(<<"content-type">>, 1, Headers2), {404, Headers3, Body} = do_get("/resp/stream_reply3/404", Config), true = lists:keymember(<<"content-type">>, 1, Headers3), - {500, _, _} = do_get("/resp/stream_reply3/error", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/stream_reply3/error", Config)), + %% The set-cookie header is special. set_resp_cookie must be used. + {500, _, _} = do_maybe_h3_error3(do_get("/resp/stream_reply3/set_cookie", Config)), ok. stream_body_fin0(Config) -> @@ -1008,10 +1143,23 @@ 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) -> + ConnPid = gun_open(Config), + Ref1 = gun:get(ConnPid, "/resp/stream_body/loop", [{<<"accept-encoding">>, <<"gzip">>}]), + Ref2 = gun:get(ConnPid, "/resp/stream_body/loop", [{<<"accept-encoding">>, <<"gzip">>}]), + {response, nofin, 200, _} = gun:await(ConnPid, Ref1, infinity), + {ok, _} = gun:await_body(ConnPid, Ref1, infinity), + {response, nofin, 200, _} = gun:await(ConnPid, Ref2, infinity), + {ok, _} = gun:await_body(ConnPid, Ref2, infinity), + gun:close(ConnPid). + %% @todo Crash when calling stream_body after the fin flag has been set. %% @todo Crash when calling stream_body after calling reply. %% @todo Crash when calling stream_body before calling stream_reply. @@ -1094,6 +1242,35 @@ stream_trailers_no_te(Config) -> <<"Hello world!">> = do_decode(RespHeaders, RespBody), gun:close(ConnPid). +stream_trailers_set_cookie(Config) -> + doc("Trying to send set-cookie in trailers should result in a crash."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/resp/stream_trailers/set_cookie", [ + {<<"accept-encoding">>, <<"gzip">>}, + {<<"te">>, <<"trailers">>} + ]), + Protocol = config(protocol, Config), + case gun:await(ConnPid, Ref, infinity) of + {response, nofin, 200, _} when Protocol =:= http -> + %% Trailers are not sent because of the stream error. + {ok, _Body} = gun:await_body(ConnPid, Ref, infinity), + {error, timeout} = gun:await_body(ConnPid, Ref, 1000), + ok; + {response, nofin, 200, _} when Protocol =:= http2 -> + {error, {stream_error, {stream_error, internal_error, _}}} + = gun:await_body(ConnPid, Ref, infinity), + ok; + {response, nofin, 200, _} when Protocol =:= http3 -> + {error, {stream_error, {stream_error, h3_internal_error, _}}} + = gun:await_body(ConnPid, Ref, infinity), + ok; + %% The RST_STREAM arrived before the start of the response. + %% See maybe_h3_error comment for details. + {error, {stream_error, {stream_error, h3_internal_error, _}}} when Protocol =:= http3 -> + ok + end, + gun:close(ConnPid). + do_trailers(Path, Config) -> ConnPid = gun_open(Config), Ref = gun:get(ConnPid, Path, [ @@ -1117,26 +1294,45 @@ do_trailers(Path, Config) -> push(Config) -> case config(protocol, Config) of http -> do_push_http("/resp/push", Config); - http2 -> do_push_http2(Config) + http2 -> do_push_http2(Config); + http3 -> {skip, "Implement server push for HTTP/3."} end. +push_after_reply(Config) -> + doc("Trying to push a response after the final response results in a crash."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/resp/push/after_reply", []), + %% With HTTP/1.1 and HTTP/2 we will not get an error. + %% With HTTP/3 however the stream will occasionally + %% be reset before Gun receives the response. + case gun:await(ConnPid, Ref, infinity) of + {response, fin, 200, _} -> + ok; + {error, {stream_error, {stream_error, h3_internal_error, _}}} -> + ok + end, + gun:close(ConnPid). + push_method(Config) -> case config(protocol, Config) of http -> do_push_http("/resp/push/method", Config); - http2 -> do_push_http2_method(Config) + http2 -> do_push_http2_method(Config); + http3 -> {skip, "Implement server push for HTTP/3."} end. push_origin(Config) -> case config(protocol, Config) of http -> do_push_http("/resp/push/origin", Config); - http2 -> do_push_http2_origin(Config) + http2 -> do_push_http2_origin(Config); + http3 -> {skip, "Implement server push for HTTP/3."} end. push_qs(Config) -> case config(protocol, Config) of http -> do_push_http("/resp/push/qs", Config); - http2 -> do_push_http2_qs(Config) + http2 -> do_push_http2_qs(Config); + http3 -> {skip, "Implement server push for HTTP/3."} end. do_push_http(Path, Config) -> @@ -1144,7 +1340,7 @@ do_push_http(Path, Config) -> ConnPid = gun_open(Config), Ref = gun:get(ConnPid, Path, []), {response, fin, 200, _} = gun:await(ConnPid, Ref, infinity), - ok. + gun:close(ConnPid). do_push_http2(Config) -> doc("Pushed responses."), diff --git a/test/rest_handler_SUITE.erl b/test/rest_handler_SUITE.erl index 43695c3..a3d9533 100644 --- a/test/rest_handler_SUITE.erl +++ b/test/rest_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -32,7 +32,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Dispatch configuration. @@ -52,6 +52,7 @@ init_dispatch(_) -> {"/content_types_accepted", content_types_accepted_h, []}, {"/content_types_provided", content_types_provided_h, []}, {"/delete_resource", delete_resource_h, []}, + {"/create_resource", create_resource_h, []}, {"/expires", expires_h, []}, {"/generate_etag", generate_etag_h, []}, {"/if_range", if_range_h, []}, @@ -84,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) -> @@ -126,7 +127,7 @@ do_accept_callback_true(Config, Fun) -> ok. charset_in_content_types_provided(Config) -> - doc("When a charset is matched explictly in content_types_provided, " + doc("When a charset is matched explicitly in content_types_provided, " "that charset is used and the charsets_provided callback is ignored."), ConnPid = gun_open(Config), Ref = gun:get(ConnPid, "/charset_in_content_types_provided", [ @@ -403,6 +404,18 @@ content_types_accepted_wildcard_param_content_type_with_param(Config) -> {response, fin, 204, _} = gun:await(ConnPid, Ref), ok. +content_types_provided_invalid_type(Config) -> + doc("When an invalid type is returned from the " + "content_types_provided callback, the " + "resource is incorrect and a 500 response is expected."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/content_types_provided?invalid-type", [ + {<<"accept">>, <<"*/*">>}, + {<<"accept-encoding">>, <<"gzip">>} + ]), + {response, _, 500, _} = do_maybe_h3_error(gun:await(ConnPid, Ref)), + ok. + content_types_provided_wildcard_param_no_accept_param(Config) -> doc("When a wildcard is returned for parameters from the " "content_types_provided callback, an accept header " @@ -471,7 +484,30 @@ 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) -> + doc("POST to an existing resource to create a new resource. " + "When the accept callback returns {created, NewURI}, " + "the expected reply is 201 Created."), + ConnPid = gun_open(Config), + Ref = gun:post(ConnPid, "/create_resource?created", [ + {<<"content-type">>, <<"application/text">>} + ], <<"hello">>, #{}), + {response, _, 201, _} = gun:await(ConnPid, Ref), + ok. + +create_resource_see_other(Config) -> + doc("POST to an existing resource to create a new resource. " + "When the accept callback returns {see_other, NewURI}, " + "the expected reply is 303 See Other with a location header set."), + ConnPid = gun_open(Config), + Ref = gun:post(ConnPid, "/create_resource?see_other", [ + {<<"content-type">>, <<"application/text">>} + ], <<"hello">>, #{}), + {response, _, 303, RespHeaders} = gun:await(ConnPid, Ref), + {_, _} = lists:keyfind(<<"location">>, 1, RespHeaders), ok. error_on_malformed_accept(Config) -> @@ -547,6 +583,17 @@ generate_etag_missing(Config) -> false = lists:keyfind(<<"etag">>, 1, Headers), ok. +generate_etag_undefined(Config) -> + doc("The etag header must not be sent when " + "the generate_etag callback returns undefined."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/generate_etag?undefined", [ + {<<"accept-encoding">>, <<"gzip">>} + ]), + {response, _, 200, Headers} = gun:await(ConnPid, Ref), + false = lists:keyfind(<<"etag">>, 1, Headers), + ok. + generate_etag_binary_strong(Config) -> doc("The etag header must be sent when the generate_etag " "callback returns a strong binary. (RFC7232 2.3)"), @@ -615,10 +662,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)"), @@ -764,6 +817,7 @@ provide_callback(Config) -> ]), {response, nofin, 200, Headers} = gun:await(ConnPid, Ref), {_, <<"text/plain">>} = lists:keyfind(<<"content-type">>, 1, Headers), + {_, <<"HEAD, GET, OPTIONS">>} = lists:keyfind(<<"allow">>, 1, Headers), {ok, <<"This is REST!">>} = gun:await_body(ConnPid, Ref), ok. @@ -771,7 +825,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) -> @@ -927,7 +981,7 @@ provide_range_callback_missing(Config) -> {<<"accept-encoding">>, <<"gzip">>}, {<<"range">>, <<"bytes=0-">>} ]), - {response, fin, 500, _} = gun:await(ConnPid, Ref), + {response, fin, 500, _} = do_maybe_h3_error(gun:await(ConnPid, Ref)), ok. range_ignore_unknown_unit(Config) -> diff --git a/test/rfc6585_SUITE.erl b/test/rfc6585_SUITE.erl index 1f65f78..4a627e5 100644 --- a/test/rfc6585_SUITE.erl +++ b/test/rfc6585_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -30,7 +30,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_dispatch(_) -> cowboy_router:compile([{"[...]", [ diff --git a/test/rfc7230_SUITE.erl b/test/rfc7230_SUITE.erl index 9846a0f..d0da0df 100644 --- a/test/rfc7230_SUITE.erl +++ b/test/rfc7230_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2015-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -22,6 +22,7 @@ -import(cowboy_test, [raw_open/1]). -import(cowboy_test, [raw_send/2]). -import(cowboy_test, [raw_recv_head/1]). +-import(cowboy_test, [raw_recv_rest/3]). -import(cowboy_test, [raw_recv/3]). suite() -> @@ -63,13 +64,7 @@ do_raw(Config, Data) -> {Headers, Rest2} = cow_http:parse_headers(Rest), case lists:keyfind(<<"content-length">>, 1, Headers) of {_, LengthBin} when LengthBin =/= <<"0">> -> - Length = binary_to_integer(LengthBin), - Body = if - byte_size(Rest2) =:= Length -> Rest2; - true -> - {ok, Body0} = raw_recv(Client, Length - byte_size(Rest2), 5000), - << Rest2/bits, Body0/bits >> - end, + Body = raw_recv_rest(Client, binary_to_integer(LengthBin), Rest2), #{client => Client, version => Version, code => Code, reason => Reason, headers => Headers, body => Body}; _ -> #{client => Client, version => Version, code => Code, reason => Reason, headers => Headers, body => <<>>} @@ -1149,18 +1144,19 @@ reject_invalid_content_length(Config) -> %with a message body too large must be rejected with a 413 status %code and the closing of the connection. (RFC7230 3.3.2) -ignore_content_length_when_transfer_encoding(Config) -> +reject_when_both_content_length_and_transfer_encoding(Config) -> doc("When a message includes both transfer-encoding and content-length " - "headers, the content-length header must be removed before processing " - "the request. (RFC7230 3.3.3)"), - #{code := 200, body := <<"Hello world!">>} = do_raw(Config, [ + "headers, the message may be an attempt at request smuggling. It " + "must be rejected with a 400 status code and the closing of the " + "connection. (RFC7230 3.3.3)"), + #{code := 400, client := Client} = do_raw(Config, [ "POST /echo/read_body HTTP/1.1\r\n" "Host: localhost\r\n" "Transfer-encoding: chunked\r\n" "Content-length: 12\r\n" "\r\n" "6\r\nHello \r\n5\r\nworld\r\n1\r\n!\r\n0\r\n\r\n"]), - ok. + {error, closed} = raw_recv(Client, 0, 1000). %socket_error_while_reading_body(Config) -> %If a socket error occurs while reading the body the server @@ -1512,6 +1508,28 @@ http10_no_connection_header_close(Config) -> {_, <<"close">>} = lists:keyfind(<<"connection">>, 1, RespHeaders), {error, closed} = raw_recv(Client, 0, 1000). +connection_invalid(Config) -> + doc("HTTP/1.1 requests with an invalid Connection header " + "must be rejected with a 400 status code and the closing " + "of the connection. (RFC7230 6.1)"), + #{code := 400, client := Client} = do_raw(Config, [ + "GET / HTTP/1.1\r\n" + "Host: localhost\r\n" + "Connection: jndi{ldap127\r\n" + "\r\n"]), + {error, closed} = raw_recv(Client, 0, 1000). + +http10_connection_invalid(Config) -> + doc("HTTP/1.0 requests with an invalid Connection header " + "must be rejected with a 400 status code and the closing " + "of the connection. (RFC7230 6.1)"), + #{code := 400, client := Client} = do_raw(Config, [ + "GET / HTTP/1.0\r\n" + "Host: localhost\r\n" + "Connection: jndi{ldap127\r\n" + "\r\n"]), + {error, closed} = raw_recv(Client, 0, 1000). + limit_requests_keepalive(Config) -> doc("The maximum number of requests sent using a persistent connection " "must be subject to configuration. The connection must be closed " diff --git a/test/rfc7231_SUITE.erl b/test/rfc7231_SUITE.erl index 6c74391..183fa0f 100644 --- a/test/rfc7231_SUITE.erl +++ b/test/rfc7231_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -35,7 +35,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_dispatch(_) -> cowboy_router:compile([{"[...]", [ @@ -230,13 +230,15 @@ expect(Config) -> {<<"expect">>, <<"100-continue">>} ]), {inform, 100, _} = gun:await(ConnPid, Ref), - ok. + gun:close(ConnPid). http10_expect(Config) -> case config(protocol, Config) of http -> do_http10_expect(Config); http2 -> + expect(Config); + http3 -> expect(Config) end. @@ -303,6 +305,9 @@ expect_discard_body_close(Config) -> do_expect_discard_body_close(Config); http2 -> doc("There's no reason to close the connection when using HTTP/2, " + "even if a stream body is too large. We just cancel the stream."); + http3 -> + doc("There's no reason to close the connection when using HTTP/3, " "even if a stream body is too large. We just cancel the stream.") end. @@ -424,8 +429,10 @@ http10_status_code_100(Config) -> http -> doc("The 100 Continue status code must not " "be sent to HTTP/1.0 endpoints. (RFC7231 6.2)"), - do_http10_status_code_1xx(100, Config); + do_unsupported_status_code_1xx(100, Config); http2 -> + status_code_100(Config); + http3 -> status_code_100(Config) end. @@ -434,12 +441,16 @@ http10_status_code_101(Config) -> http -> doc("The 101 Switching Protocols status code must not " "be sent to HTTP/1.0 endpoints. (RFC7231 6.2)"), - do_http10_status_code_1xx(101, Config); + do_unsupported_status_code_1xx(101, Config); http2 -> + status_code_101(Config); + http3 -> + %% While 101 is not supported by HTTP/3, there is no + %% wording in RFC9114 that forbids sending it. status_code_101(Config) end. -do_http10_status_code_1xx(StatusCode, Config) -> +do_unsupported_status_code_1xx(StatusCode, Config) -> ConnPid = gun_open(Config, #{http_opts => #{version => 'HTTP/1.0'}}), Ref = gun:get(ConnPid, "/resp/inform2/" ++ integer_to_list(StatusCode), [ {<<"accept-encoding">>, <<"gzip">>} @@ -653,7 +664,9 @@ status_code_408_connection_close(Config) -> http -> do_http11_status_code_408_connection_close(Config); http2 -> - doc("HTTP/2 connections are not closed on 408 responses.") + doc("HTTP/2 connections are not closed on 408 responses."); + http3 -> + doc("HTTP/3 connections are not closed on 408 responses.") end. do_http11_status_code_408_connection_close(Config) -> @@ -744,7 +757,9 @@ status_code_426_upgrade_header(Config) -> http -> do_status_code_426_upgrade_header(Config); http2 -> - doc("HTTP/2 does not support the HTTP/1.1 Upgrade mechanism.") + doc("HTTP/2 does not support the HTTP/1.1 Upgrade mechanism."); + http3 -> + doc("HTTP/3 does not support the HTTP/1.1 Upgrade mechanism.") end. do_status_code_426_upgrade_header(Config) -> diff --git a/test/rfc7538_SUITE.erl b/test/rfc7538_SUITE.erl index 5eb9705..ea51209 100644 --- a/test/rfc7538_SUITE.erl +++ b/test/rfc7538_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -30,7 +30,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_dispatch(_) -> cowboy_router:compile([{"[...]", [ diff --git a/test/rfc7540_SUITE.erl b/test/rfc7540_SUITE.erl index aec0aa1..76aa95f 100644 --- a/test/rfc7540_SUITE.erl +++ b/test/rfc7540_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2016-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -12,12 +12,19 @@ %% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF %% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +%% Note that Cowboy does not implement the PRIORITY mechanism. +%% Everyone has been moving away from it and it is widely seen +%% as a failure. Setting priorities has been counter productive +%% with regards to performance. Clients have been moving away +%% from the mechanism. + -module(rfc7540_SUITE). -compile(export_all). -compile(nowarn_export_all). -import(ct_helper, [config/2]). -import(ct_helper, [doc/1]). +-import(ct_helper, [get_remote_pid_tcp/1]). -import(cowboy_test, [gun_open/1]). -import(cowboy_test, [raw_open/1]). -import(cowboy_test, [raw_send/2]). @@ -27,9 +34,10 @@ 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), + RejectTLS = [http_upgrade_reject_tls, prior_knowledge_reject_tls], + Clear = [T || T <- Tests, lists:sublist(atom_to_list(T), 4) =/= "alpn"] -- RejectTLS, + TLS = [T || T <- Tests, lists:sublist(atom_to_list(T), 4) =:= "alpn"] ++ RejectTLS, [{clear, [parallel], Clear}, {tls, [parallel], TLS}]. init_per_group(Name = clear, Config) -> @@ -52,6 +60,7 @@ init_routes(_) -> [ {"localhost", [ {"/", hello_h, []}, {"/echo/:key", echo_h, []}, + {"/delay_hello", delay_hello_h, 1200}, {"/long_polling", long_polling_h, []}, {"/loop_handler_abort", loop_handler_abort_h, []}, {"/resp/:key[/:arg]", resp_h, []} @@ -60,6 +69,24 @@ init_routes(_) -> [ %% Starting HTTP/2 for "http" URIs. +http_upgrade_reject_tls(Config) -> + doc("Implementations that support HTTP/2 over TLS must use ALPN. (RFC7540 3.4)"), + TlsOpts = ct_helper:get_certs_from_ets(), + {ok, Socket} = ssl:connect("localhost", config(port, Config), + [binary, {active, false}|TlsOpts]), + %% Send a valid preface. + ok = ssl:send(Socket, [ + "GET / HTTP/1.1\r\n" + "Host: localhost\r\n" + "Connection: Upgrade, HTTP2-Settings\r\n" + "Upgrade: h2c\r\n" + "HTTP2-Settings: ", base64:encode(cow_http2:settings_payload(#{})), "\r\n", + "\r\n"]), + %% We expect the server to send an HTTP 400 error + %% when trying to use HTTP/2 without going through ALPN negotiation. + {ok, <<"HTTP/1.1 400">>} = ssl:recv(Socket, 12, 1000), + ok. + http_upgrade_ignore_h2(Config) -> doc("An h2 token in an Upgrade field must be ignored. (RFC7540 3.2)"), {ok, Socket} = gen_tcp:connect("localhost", config(port, Config), [binary, {active, false}]), @@ -481,14 +508,6 @@ http_upgrade_client_preface_settings_ack_timeout(Config) -> %% important, an OPTIONS request can be used to perform the upgrade to %% HTTP/2, at the cost of an additional round trip. -%% @todo If we ever handle priority, we need to check that the initial -%% HTTP/1.1 request has default priority. The relevant RFC quote is: -%% -%% 3.2 -%% The HTTP/1.1 request that is sent prior to upgrade is assigned a -%% stream identifier of 1 (see Section 5.1.1) with default priority -%% values (Section 5.3.5). - http_upgrade_response(Config) -> doc("A response must be sent to the initial HTTP/1.1 request " "after switching to HTTP/2. The response must use " @@ -587,16 +606,20 @@ http_upgrade_response_half_closed(Config) -> alpn_ignore_h2c(Config) -> doc("An h2c ALPN protocol identifier must be ignored. (RFC7540 3.3)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2c">>, <<"http/1.1">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2c">>, <<"http/1.1">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"http/1.1">>} = ssl:negotiated_protocol(Socket), ok. alpn_server_preface(Config) -> doc("The first frame must be a SETTINGS frame " "for the server connection preface. (RFC7540 3.3, RFC7540 3.5, RFC7540 6.5)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Receive the server preface. {ok, << _:24, 4:8, 0:40 >>} = ssl:recv(Socket, 9, 1000), @@ -605,8 +628,10 @@ alpn_server_preface(Config) -> alpn_client_preface_timeout(Config) -> doc("Clients negotiating HTTP/2 and not sending a preface in " "a timely manner must be disconnected."), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Receive the server preface. {ok, << Len:24 >>} = ssl:recv(Socket, 3, 1000), @@ -618,8 +643,10 @@ alpn_client_preface_timeout(Config) -> alpn_reject_missing_client_preface(Config) -> doc("Servers must treat an invalid connection preface as a " "connection error of type PROTOCOL_ERROR. (RFC7540 3.3, RFC7540 3.5)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Send a SETTINGS frame directly instead of the proper preface. ok = ssl:send(Socket, cow_http2:settings(#{})), @@ -633,8 +660,10 @@ alpn_reject_missing_client_preface(Config) -> alpn_reject_invalid_client_preface(Config) -> doc("Servers must treat an invalid connection preface as a " "connection error of type PROTOCOL_ERROR. (RFC7540 3.3, RFC7540 3.5)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Send a slightly incorrect preface. ok = ssl:send(Socket, "PRI * HTTP/2.0\r\n\r\nSM: Value\r\n\r\n"), @@ -648,8 +677,10 @@ alpn_reject_invalid_client_preface(Config) -> alpn_reject_missing_client_preface_settings(Config) -> doc("Servers must treat an invalid connection preface as a " "connection error of type PROTOCOL_ERROR. (RFC7540 3.3, RFC7540 3.5)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Send a valid preface sequence except followed by a PING instead of a SETTINGS frame. ok = ssl:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", cow_http2:ping(0)]), @@ -663,8 +694,10 @@ alpn_reject_missing_client_preface_settings(Config) -> alpn_reject_invalid_client_preface_settings(Config) -> doc("Servers must treat an invalid connection preface as a " "connection error of type PROTOCOL_ERROR. (RFC7540 3.3, RFC7540 3.5)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Send a valid preface sequence except followed by a badly formed SETTINGS frame. ok = ssl:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", << 0:24, 4:8, 0:9, 1:31 >>]), @@ -677,8 +710,10 @@ alpn_reject_invalid_client_preface_settings(Config) -> alpn_accept_client_preface_empty_settings(Config) -> doc("The SETTINGS frame in the client preface may be empty. (RFC7540 3.3, RFC7540 3.5)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Send a valid preface sequence except followed by an empty SETTINGS frame. ok = ssl:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", cow_http2:settings(#{})]), @@ -692,8 +727,10 @@ alpn_accept_client_preface_empty_settings(Config) -> alpn_client_preface_settings_ack_timeout(Config) -> doc("Failure to acknowledge the server's SETTINGS frame " "results in a SETTINGS_TIMEOUT connection error. (RFC7540 3.5, RFC7540 6.5.3)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Send a valid preface. ok = ssl:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", cow_http2:settings(#{})]), @@ -708,8 +745,10 @@ alpn_client_preface_settings_ack_timeout(Config) -> alpn(Config) -> doc("Successful ALPN negotiation. (RFC7540 3.3)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Send a valid preface. %% @todo Use non-empty SETTINGS here. Just because. @@ -733,7 +772,9 @@ alpn(Config) -> prior_knowledge_reject_tls(Config) -> doc("Implementations that support HTTP/2 over TLS must use ALPN. (RFC7540 3.4)"), - {ok, Socket} = ssl:connect("localhost", config(port, Config), [binary, {active, false}]), + TlsOpts = ct_helper:get_certs_from_ets(), + {ok, Socket} = ssl:connect("localhost", config(port, Config), + [binary, {active, false}|TlsOpts]), %% Send a valid preface. ok = ssl:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", cow_http2:settings(#{})]), %% We expect the server to send an HTTP 400 error @@ -1352,7 +1393,8 @@ max_frame_size_allow_exactly_custom(Config0) -> {ok, << Len2:24, 1:8, _:40 >>} = gen_tcp:recv(Socket, 9, 6000), {ok, _} = gen_tcp:recv(Socket, Len2, 6000), %% No errors follow due to our sending of a 25000 bytes frame. - {error, timeout} = gen_tcp:recv(Socket, 0, 1000) + {error, timeout} = gen_tcp:recv(Socket, 0, 1000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -1382,7 +1424,8 @@ max_frame_size_reject_larger_than_custom(Config0) -> cow_http2:data(1, fin, <<0:30001/unit:8>>) ]), %% Receive a FRAME_SIZE_ERROR connection error. - {ok, << _:24, 7:8, _:72, 6:32 >>} = gen_tcp:recv(Socket, 17, 6000) + {ok, << _:24, 7:8, _:72, 6:32 >>} = gen_tcp:recv(Socket, 17, 6000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -2597,9 +2640,10 @@ settings_header_table_size_server(Config0) -> {ok, << Len1:24, 1:8, _:40 >>} = gen_tcp:recv(Socket, 9, 6000), {ok, RespHeadersBlock1} = gen_tcp:recv(Socket, Len1, 6000), {RespHeaders, _} = cow_hpack:decode(RespHeadersBlock1, DecodeState), - {_, <<"200">>} = lists:keyfind(<<":status">>, 1, RespHeaders) + {_, <<"200">>} = lists:keyfind(<<":status">>, 1, RespHeaders), %% The decoding succeeded on the server, confirming that %% the table size was updated to HeaderTableSize. + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -2628,7 +2672,8 @@ settings_max_concurrent_streams(Config0) -> cow_http2:headers(3, fin, ReqHeadersBlock2) ]), %% Receive a REFUSED_STREAM stream error. - {ok, << _:24, 3:8, _:8, 3:32, 7:32 >>} = gen_tcp:recv(Socket, 13, 6000) + {ok, << _:24, 3:8, _:8, 3:32, 7:32 >>} = gen_tcp:recv(Socket, 13, 6000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -2652,7 +2697,8 @@ settings_max_concurrent_streams_0(Config0) -> ]), ok = gen_tcp:send(Socket, cow_http2:headers(1, fin, HeadersBlock)), %% Receive a REFUSED_STREAM stream error. - {ok, << _:24, 3:8, _:8, 1:32, 7:32 >>} = gen_tcp:recv(Socket, 13, 6000) + {ok, << _:24, 3:8, _:8, 1:32, 7:32 >>} = gen_tcp:recv(Socket, 13, 6000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -2720,7 +2766,8 @@ settings_initial_window_size(Config0) -> {ok, << Len2:24, 1:8, _:40 >>} = gen_tcp:recv(Socket, 9, 6000), {ok, _} = gen_tcp:recv(Socket, Len2, 6000), %% No errors follow due to our sending of more than 65535 bytes of data. - {error, timeout} = gen_tcp:recv(Socket, 0, 1000) + {error, timeout} = gen_tcp:recv(Socket, 0, 1000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -2763,7 +2810,8 @@ settings_initial_window_size_after_ack(Config0) -> cow_http2:data(1, fin, <<0:32/unit:8>>) ]), %% Receive a FLOW_CONTROL_ERROR stream error. - {ok, << _:24, 3:8, _:8, 1:32, 3:32 >>} = gen_tcp:recv(Socket, 13, 6000) + {ok, << _:24, 3:8, _:8, 1:32, 3:32 >>} = gen_tcp:recv(Socket, 13, 6000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -2811,7 +2859,8 @@ settings_initial_window_size_before_ack(Config0) -> {ok, << Len2:24, 1:8, _:40 >>} = gen_tcp:recv(Socket, 9, 6000), {ok, _} = gen_tcp:recv(Socket, Len2, 6000), %% No errors follow due to our sending of more than 0 bytes of data. - {error, timeout} = gen_tcp:recv(Socket, 0, 1000) + {error, timeout} = gen_tcp:recv(Socket, 0, 1000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -2844,7 +2893,8 @@ settings_max_frame_size(Config0) -> {ok, << Len2:24, 1:8, _:40 >>} = gen_tcp:recv(Socket, 9, 6000), {ok, _} = gen_tcp:recv(Socket, Len2, 6000), %% No errors follow due to our sending of a 25000 bytes frame. - {error, timeout} = gen_tcp:recv(Socket, 0, 1000) + {error, timeout} = gen_tcp:recv(Socket, 0, 1000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -2955,39 +3005,64 @@ client_settings_disable_push(Config) -> %% (RFC7540 6.8) GOAWAY % @todo GOAWAY frames have a reserved bit in the payload that must be ignored. % -%% @todo We should eventually implement the mechanism for gracefully -%% shutting down of the connection. (Send the GOAWAY, finish processing -%% the current set of streams, give up after a certain timeout.) -% -%% @todo If we graceful shutdown and receive a GOAWAY, we give up too. % A GOAWAY frame might not immediately precede closing of the % connection; a receiver of a GOAWAY that has no more use for the % connection SHOULD still send a GOAWAY frame before terminating the % connection. -% -%% @todo And it gets more complex when you think about h1 to h2 proxies. -% A server that is attempting to gracefully shut down a -% connection SHOULD send an initial GOAWAY frame with the last stream -% identifier set to 2^31-1 and a NO_ERROR code. This signals to the -% client that a shutdown is imminent and that initiating further -% requests is prohibited. After allowing time for any in-flight stream -% creation (at least one round-trip time), the server can send another -% GOAWAY frame with an updated last stream identifier. This ensures -% that a connection can be cleanly shut down without losing requests. -% -%% @todo And of course even if we shutdown we need to be careful about -%% the connection state. -% After sending a GOAWAY frame, the sender can discard frames for -% streams initiated by the receiver with identifiers higher than the -% identified last stream. However, any frames that alter connection -% state cannot be completely ignored. For instance, HEADERS, -% PUSH_PROMISE, and CONTINUATION frames MUST be minimally processed to -% ensure the state maintained for header compression is consistent (see -% Section 4.3); similarly, DATA frames MUST be counted toward the -% connection flow-control window. Failure to process these frames can -% cause flow control or header compression state to become -% unsynchronized. -% + +graceful_shutdown_client_stays(Config) -> + doc("A server gracefully shutting down must send a GOAWAY frame with the " + "last stream identifier set to 2^31-1 and a NO_ERROR code. After allowing " + "time for any in-flight stream creation the server can send another GOAWAY " + "frame with an updated last stream identifier. (RFC7540 6.8)"), + {ok, Socket} = do_handshake(Config), + ServerConnPid = get_remote_pid_tcp(Socket), + ok = sys:terminate(ServerConnPid, whatever), + %% First GOAWAY frame. + {ok, <<_:24, 7:8, 0:8, 0:1, 0:31, 0:1, 16#7fffffff:31, 0:32>>} = gen_tcp:recv(Socket, 17, 500), + %% Second GOAWAY frame. + {ok, <<_:24, 7:8, 0:8, 0:1, 0:31, 0:1, 0:31, 0:32>>} = gen_tcp:recv(Socket, 17, 1500), + {error, closed} = gen_tcp:recv(Socket, 3, 1000), + ok. + +%% @todo We should add this test also for discarded DATA and CONTINUATION frames. +%% The test can be the same for CONTINUATION (just send headers differently) but +%% the DATA test should make sure the global window is not corrupted. +%% +%% @todo We should extend this test to have two requests: one initiated before +%% the second GOAWAY, but not terminated; another initiated after the GOAWAY, terminated. +%% Finally the first request is terminated by sending a body and a trailing +%% HEADERS frame. This way we know for sure that the connection state is not corrupt. +graceful_shutdown_race_condition(Config) -> + doc("A server in the process of gracefully shutting down must discard frames " + "for streams initiated by the receiver with identifiers higher than the " + "identified last stream. This may include frames that alter connection " + "state such as HEADERS frames. (RFC7540 6.8)"), + {ok, Socket} = do_handshake(Config), + ServerConnPid = get_remote_pid_tcp(Socket), + ok = sys:terminate(ServerConnPid, whatever), + %% First GOAWAY frame. + {ok, <<_:24, 7:8, 0:8, 0:1, 0:31, 0:1, 16#7fffffff:31, 0:32>>} = gen_tcp:recv(Socket, 17, 500), + %% Simulate an in-flight request, sent by the client before the + %% GOAWAY frame arrived to the client. + {HeadersBlock, _} = cow_hpack:encode([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<":path">>, <<"/delay_hello">>} + ]), + ok = gen_tcp:send(Socket, cow_http2:headers(1, fin, HeadersBlock)), + %% Second GOAWAY frame. + {ok, <<_:24, 7:8, 0:8, 0:1, 0:31, 0:1, 1:31, 0:32>>} = gen_tcp:recv(Socket, 17, 2000), + %% The client tries to send another request, ignoring the GOAWAY. + ok = gen_tcp:send(Socket, cow_http2:headers(3, fin, HeadersBlock)), + %% The server responds to the first request (streamid 1) and closes. + {ok, <<RespHeadersPayloadLength:24, 1, 4, 0:1, 1:31>>} = gen_tcp:recv(Socket, 9, 1000), + {ok, _RespHeaders} = gen_tcp:recv(Socket, RespHeadersPayloadLength, 1000), + {ok, <<12:24, 0, 1, 0:1, 1:31, "Hello world!">>} = gen_tcp:recv(Socket, 21, 1000), + {error, closed} = gen_tcp:recv(Socket, 3, 1000), + ok. + % The GOAWAY frame applies to the connection, not a specific stream. % An endpoint MUST treat a GOAWAY frame with a stream identifier other % than 0x0 as a connection error (Section 5.4.1) of type @@ -3068,7 +3143,8 @@ data_reject_overflow(Config0) -> cow_http2:data(1, fin, <<0:15000/unit:8>>) ]), %% Receive a FLOW_CONTROL_ERROR connection error. - {ok, << _:24, 7:8, _:72, 3:32 >>} = gen_tcp:recv(Socket, 17, 6000) + {ok, << _:24, 7:8, _:72, 3:32 >>} = gen_tcp:recv(Socket, 17, 6000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -3116,7 +3192,8 @@ data_reject_overflow_stream(Config0) -> cow_http2:data(1, fin, <<0:15000/unit:8>>) ]), %% Receive a FLOW_CONTROL_ERROR stream error. - {ok, << _:24, 3:8, _:8, 1:32, 3:32 >>} = gen_tcp:recv(Socket, 13, 6000) + {ok, << _:24, 3:8, _:8, 1:32, 3:32 >>} = gen_tcp:recv(Socket, 13, 6000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -3835,6 +3912,7 @@ accept_host_header_on_missing_pseudo_header_authority(Config) -> %% When both :authority and host headers are received, the current behavior %% is to favor :authority and ignore the host header. The specification does %% not describe the correct behavior to follow in that case. +%% @todo The HTTP/3 spec says both values must be identical and non-empty. reject_many_pseudo_header_authority(Config) -> doc("A request containing more than one authority component must be rejected " diff --git a/test/rfc8297_SUITE.erl b/test/rfc8297_SUITE.erl index 9ae6180..42ae92e 100644 --- a/test/rfc8297_SUITE.erl +++ b/test/rfc8297_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -30,7 +30,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_dispatch(_) -> cowboy_router:compile([{"[...]", [ diff --git a/test/rfc8441_SUITE.erl b/test/rfc8441_SUITE.erl index 245658f..b788f9f 100644 --- a/test/rfc8441_SUITE.erl +++ b/test/rfc8441_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -126,6 +126,7 @@ reject_handshake_disabled_by_default(Config0) -> % The Extended CONNECT Method. +%% @todo Refer to RFC9110 7.8 about the case insensitive comparison. accept_uppercase_pseudo_header_protocol(Config) -> doc("The :protocol pseudo header is case insensitive. (draft-01 4)"), %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. @@ -172,6 +173,7 @@ reject_many_pseudo_header_protocol(Config) -> ok. reject_unknown_pseudo_header_protocol(Config) -> + %% @todo This probably shouldn't send 400 but 501 instead based on RFC 9220. doc("An extended CONNECT request with an unknown protocol must be rejected " "with a 400 error. (draft-01 4)"), %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. @@ -192,10 +194,11 @@ reject_unknown_pseudo_header_protocol(Config) -> {ok, << Len1:24, 1:8, _:8, 1:32 >>} = gen_tcp:recv(Socket, 9, 1000), {ok, RespHeadersBlock} = gen_tcp:recv(Socket, Len1, 1000), {RespHeaders, _} = cow_hpack:decode(RespHeadersBlock), - {_, <<"400">>} = lists:keyfind(<<":status">>, 1, RespHeaders), + {_, <<"501">>} = lists:keyfind(<<":status">>, 1, RespHeaders), ok. reject_invalid_pseudo_header_protocol(Config) -> + %% @todo This probably shouldn't send 400 but 501 instead based on RFC 9220. doc("An extended CONNECT request with an invalid protocol must be rejected " "with a 400 error. (draft-01 4)"), %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. @@ -216,7 +219,7 @@ reject_invalid_pseudo_header_protocol(Config) -> {ok, << Len1:24, 1:8, _:8, 1:32 >>} = gen_tcp:recv(Socket, 9, 1000), {ok, RespHeadersBlock} = gen_tcp:recv(Socket, Len1, 1000), {RespHeaders, _} = cow_hpack:decode(RespHeadersBlock), - {_, <<"400">>} = lists:keyfind(<<":status">>, 1, RespHeaders), + {_, <<"501">>} = lists:keyfind(<<":status">>, 1, RespHeaders), ok. reject_missing_pseudo_header_scheme(Config) -> @@ -293,7 +296,7 @@ reject_missing_pseudo_header_protocol(Config) -> %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. {ok, Socket, Settings} = do_handshake(Config), #{enable_connect_protocol := true} = Settings, - %% Send an extended CONNECT request without a :scheme pseudo-header. + %% Send an extended CONNECT request without a :protocol pseudo-header. {ReqHeadersBlock, _} = cow_hpack:encode([ {<<":method">>, <<"CONNECT">>}, {<<":scheme">>, <<"http">>}, @@ -317,7 +320,7 @@ reject_connection_header(Config) -> %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. {ok, Socket, Settings} = do_handshake(Config), #{enable_connect_protocol := true} = Settings, - %% Send an extended CONNECT request without a :scheme pseudo-header. + %% Send an extended CONNECT request with a connection header. {ReqHeadersBlock, _} = cow_hpack:encode([ {<<":method">>, <<"CONNECT">>}, {<<":protocol">>, <<"websocket">>}, @@ -339,7 +342,7 @@ reject_upgrade_header(Config) -> %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. {ok, Socket, Settings} = do_handshake(Config), #{enable_connect_protocol := true} = Settings, - %% Send an extended CONNECT request without a :scheme pseudo-header. + %% Send an extended CONNECT request with a upgrade header. {ReqHeadersBlock, _} = cow_hpack:encode([ {<<":method">>, <<"CONNECT">>}, {<<":protocol">>, <<"websocket">>}, diff --git a/test/rfc9114_SUITE.erl b/test/rfc9114_SUITE.erl new file mode 100644 index 0000000..a03b493 --- /dev/null +++ b/test/rfc9114_SUITE.erl @@ -0,0 +1,2426 @@ +%% Copyright (c) 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..942c41b --- /dev/null +++ b/test/rfc9204_SUITE.erl @@ -0,0 +1,357 @@ +%% Copyright (c) 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..38a59b2 --- /dev/null +++ b/test/rfc9220_SUITE.erl @@ -0,0 +1,485 @@ +%% Copyright (c) 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/3 works. (RFC9220, RFC8441 5.1)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send a CONNECT :protocol request to upgrade the stream to Websocket. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% Receive a 200 response. + {ok, Data} = rfc9114_SUITE:do_receive_data(StreamRef), + {HLenEnc, HLenBits} = rfc9114_SUITE:do_guess_int_encoding(Data), + << + 1, %% HEADERS frame. + HLenEnc:2, HLen:HLenBits, + EncodedResponse:HLen/bytes + >> = Data, + {ok, DecodedResponse, _DecData, _DecSt} + = cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init(decoder)), + #{<<":status">> := <<"200">>} = maps:from_list(DecodedResponse), + %% Masked text hello echoed back clear by the server. + Mask = 16#37fa213d, + MaskedHello = ws_SUITE:do_mask(<<"Hello">>, Mask, <<>>), + {ok, _} = quicer:send(StreamRef, cow_http3:data( + <<1:1, 0:3, 1:4, 1:1, 5:7, Mask:32, MaskedHello/binary>>)), + {ok, WsData} = rfc9114_SUITE:do_receive_data(StreamRef), + << + 0, %% DATA frame. + 0:2, 7:6, %% Length (2 bytes header + "Hello"). + 1:1, 0:3, 1:4, 0:1, 5:7, "Hello" %% Websocket frame. + >> = WsData, + ok. + +%% Closing a Websocket stream. + +% The HTTP/3 stream closure is also analogous to the TCP connection +% closure of [RFC6455]. Orderly TCP-level closures are represented +% as a FIN bit on the stream (Section 4.4 of [HTTP/3]). RST exceptions +% are represented with a stream error (Section 8 of [HTTP/3]) of type +% H3_REQUEST_CANCELLED (Section 8.1 of [HTTP/3]). + +%% @todo client close frame with FIN +%% @todo server close frame with FIN +%% @todo client other frame with FIN +%% @todo server other frame with FIN +%% @todo client close connection diff --git a/test/security_SUITE.erl b/test/security_SUITE.erl index f06cec5..25d5280 100644 --- a/test/security_SUITE.erl +++ b/test/security_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -33,10 +33,12 @@ groups() -> Tests = [nc_rand, nc_zero], H1Tests = [slowloris, slowloris_chunks], H2CTests = [ + http2_cancel_flood, http2_data_dribble, http2_empty_frame_flooding_data, http2_empty_frame_flooding_headers_continuation, http2_empty_frame_flooding_push_promise, + http2_infinite_continuations, http2_ping_flood, http2_reset_flood, http2_settings_flood, @@ -47,10 +49,12 @@ groups() -> {https, [parallel], Tests ++ H1Tests}, {h2, [parallel], Tests}, {h2c, [parallel], Tests ++ H2CTests}, + {h3, [], Tests}, {http_compress, [parallel], Tests ++ H1Tests}, {https_compress, [parallel], Tests ++ H1Tests}, {h2_compress, [parallel], Tests}, - {h2c_compress, [parallel], Tests ++ H2CTests} + {h2c_compress, [parallel], Tests ++ H2CTests}, + {h3_compress, [], Tests} ]. init_per_suite(Config) -> @@ -64,7 +68,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Routes. @@ -72,12 +76,51 @@ init_dispatch(_) -> cowboy_router:compile([{"localhost", [ {"/", hello_h, []}, {"/echo/:key", echo_h, []}, + {"/delay_hello", delay_hello_h, 1000}, {"/long_polling", long_polling_h, []}, {"/resp/:key[/:arg]", resp_h, []} ]}]). %% Tests. +http2_cancel_flood(Config) -> + doc("Confirm that Cowboy detects the rapid reset attack. (CVE-2023-44487)"), + do_http2_cancel_flood(Config, 1, 500), + do_http2_cancel_flood(Config, 10, 50), + do_http2_cancel_flood(Config, 500, 1), + ok. + +do_http2_cancel_flood(Config, NumStreamsPerBatch, NumBatches) -> + {ok, Socket} = rfc7540_SUITE:do_handshake(Config), + {HeadersBlock, _} = cow_hpack:encode([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<":path">>, <<"/delay_hello">>} + ]), + AllStreamIDs = lists:seq(1, NumBatches * NumStreamsPerBatch * 2, 2), + _ = lists:foldl( + fun (_BatchNumber, AvailableStreamIDs) -> + %% Take a bunch of IDs from the available stream IDs. + %% Send HEADERS for all these and then cancel them. + {IDs, RemainingStreamIDs} = lists:split(NumStreamsPerBatch, AvailableStreamIDs), + _ = gen_tcp:send(Socket, [cow_http2:headers(ID, fin, HeadersBlock) || ID <- IDs]), + _ = gen_tcp:send(Socket, [<<4:24, 3:8, 0:8, ID:32, 8:32>> || ID <- IDs]), + RemainingStreamIDs + end, + AllStreamIDs, + lists:seq(1, NumBatches, 1)), + %% When Cowboy detects a flood it must close the connection. + case gen_tcp:recv(Socket, 17, 6000) of + {ok, <<_:24, 7:8, 0:8, 0:32, _LastStreamId:32, 11:32>>} -> + %% GOAWAY with error code 11 = ENHANCE_YOUR_CALM. + ok; + %% We also accept the connection being closed immediately, + %% which may happen because we send the GOAWAY right before closing. + {error, closed} -> + ok + end. + http2_data_dribble(Config) -> doc("Request a very large response then update the window 1 byte at a time. (CVE-2019-9511)"), {ok, Socket} = rfc7540_SUITE:do_handshake(Config), @@ -179,6 +222,38 @@ http2_empty_frame_flooding_push_promise(Config) -> {ok, <<_:24, 7:8, _:72, 1:32>>} = gen_tcp:recv(Socket, 17, 6000), ok. +http2_infinite_continuations(Config) -> + doc("Confirm that Cowboy rejects CONTINUATION frames when the " + "total size of HEADERS + CONTINUATION(s) exceeds the limit. (VU#421644)"), + {ok, Socket} = rfc7540_SUITE:do_handshake(Config), + %% Send a HEADERS frame followed by a large number + %% of continuation frames. + {HeadersBlock, _} = cow_hpack:encode([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<":path">>, <<"/">>} + ]), + HeadersBlockLen = iolist_size(HeadersBlock), + ok = gen_tcp:send(Socket, [ + %% HEADERS frame. + << + HeadersBlockLen:24, 1:8, 0:5, + 0:1, %% END_HEADERS + 0:1, + 1:1, %% END_STREAM + 0:1, + 1:31 %% Stream ID. + >>, + HeadersBlock, + %% CONTINUATION frames. + [<<1024:24, 9:8, 0:8, 0:1, 1:31, 0:1024/unit:8>> + || _ <- lists:seq(1, 100)] + ]), + %% Receive an ENHANCE_YOUR_CALM connection error. + {ok, <<_:24, 7:8, _:72, 11:32>>} = gen_tcp:recv(Socket, 17, 6000), + ok. + %% @todo http2_internal_data_buffering(Config) -> I do not know how to test this. % doc("Request many very large responses, with a larger than necessary window size, " % "but do not attempt to read from the socket. (CVE-2019-9517)"), diff --git a/test/static_handler_SUITE.erl b/test/static_handler_SUITE.erl index 71a9619..6721b48 100644 --- a/test/static_handler_SUITE.erl +++ b/test/static_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2016-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -20,6 +20,12 @@ -import(ct_helper, [doc/1]). -import(cowboy_test, [gun_open/1]). +%% Import useful functions from req_SUITE. +%% @todo Maybe move these functions to cowboy_test. +-import(req_SUITE, [do_get/2]). +-import(req_SUITE, [do_get/3]). +-import(req_SUITE, [do_maybe_h3_error3/1]). + %% ct. all() -> @@ -39,16 +45,22 @@ groups() -> {dir, [parallel], DirTests}, {priv_dir, [parallel], DirTests} ], + GroupTestsNoParallel = OtherTests ++ [ + {dir, [], DirTests}, + {priv_dir, [], DirTests} + ], [ {http, [parallel], GroupTests}, {https, [parallel], GroupTests}, {h2, [parallel], GroupTests}, {h2c, [parallel], GroupTests}, + {h3, [], GroupTestsNoParallel}, %% @todo Enable parallel when it works better. {http_compress, [parallel], GroupTests}, {https_compress, [parallel], GroupTests}, {h2_compress, [parallel], GroupTests}, {h2c_compress, [parallel], GroupTests}, - %% No real need to test sendfile disabled against https or h2. + {h3_compress, [], GroupTestsNoParallel}, %% @todo Enable parallel when it works better. + %% No real need to test sendfile disabled against https, h2 or h3. {http_no_sendfile, [parallel], GroupTests}, {h2c_no_sendfile, [parallel], GroupTests} ]. @@ -65,7 +77,7 @@ init_per_suite(Config) -> %% Add a simple Erlang application archive containing one file %% in its priv directory. true = code:add_pathz(filename:join( - [config(data_dir, Config), "static_files_app", "ebin"])), + [config(data_dir, Config), "static_files_app.ez", "static_files_app", "ebin"])), ok = application:load(static_files_app), %% A special folder contains files of 1 character from 1 to 127 %% excluding / and \ as they are always rejected. @@ -116,6 +128,17 @@ init_per_group(Name=h2c_no_sendfile, Config) -> sendfile => false }, [{flavor, vanilla}|Config]), lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name=h3, Config) -> + cowboy_test:init_http3(Name, #{ + env => #{dispatch => init_dispatch(Config)}, + middlewares => [?MODULE, cowboy_router, cowboy_handler] + }, [{flavor, vanilla}|Config]); +init_per_group(Name=h3_compress, Config) -> + cowboy_test:init_http3(Name, #{ + env => #{dispatch => init_dispatch(Config)}, + middlewares => [?MODULE, cowboy_router, cowboy_handler], + stream_handlers => [cowboy_compress_h, cowboy_stream_h] + }, [{flavor, vanilla}|Config]); init_per_group(Name, Config) -> Config1 = cowboy_test:init_common_groups(Name, Config, ?MODULE), Opts = ranch:get_protocol_options(Name), @@ -129,7 +152,7 @@ end_per_group(dir, _) -> end_per_group(priv_dir, _) -> ok; end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Large file. @@ -207,7 +230,7 @@ execute(Req=#{path := Path}, Env) -> <<"/bad/dir/route">> -> ct_helper:ignore(cowboy_static, escape_reserved, 1); <<"/bad">> -> ct_helper:ignore(cowboy_static, init_opts, 2); <<"/bad/options">> -> ct_helper:ignore(cowboy_static, content_types_provided, 2); - <<"/bad/options/mime">> -> ct_helper:ignore(cowboy_rest, set_content_type, 2); + <<"/bad/options/mime">> -> ct_helper:ignore(cowboy_rest, normalize_content_types, 2); <<"/bad/options/etag">> -> ct_helper:ignore(cowboy_static, generate_etag, 2); <<"/bad/options/charset">> -> ct_helper:ignore(cowboy_static, charsets_provided, 2); _ -> ok @@ -248,25 +271,11 @@ do_mime_custom(Path) -> _ -> {<<"application">>, <<"octet-stream">>, []} end. -do_get(Path, Config) -> - do_get(Path, [], Config). - -do_get(Path, ReqHeaders, Config) -> - ConnPid = gun_open(Config), - Ref = gun:get(ConnPid, Path, [{<<"accept-encoding">>, <<"gzip">>}|ReqHeaders]), - {response, IsFin, Status, RespHeaders} = gun:await(ConnPid, Ref), - {ok, Body} = case IsFin of - nofin -> gun:await_body(ConnPid, Ref); - fin -> {ok, <<>>} - end, - gun:close(ConnPid), - {Status, RespHeaders, Body}. - %% Tests. bad(Config) -> doc("Bad cowboy_static options: not a tuple."), - {500, _, _} = do_get("/bad", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad", Config)), ok. bad_dir_path(Config) -> @@ -276,7 +285,7 @@ bad_dir_path(Config) -> bad_dir_route(Config) -> doc("Bad cowboy_static options: missing [...] in route."), - {500, _, _} = do_get("/bad/dir/route", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/dir/route", Config)), ok. bad_file_in_priv_dir_in_ez_archive(Config) -> @@ -291,27 +300,27 @@ bad_file_path(Config) -> bad_options(Config) -> doc("Bad cowboy_static extra options: not a list."), - {500, _, _} = do_get("/bad/options", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/options", Config)), ok. bad_options_charset(Config) -> doc("Bad cowboy_static extra options: invalid charset option."), - {500, _, _} = do_get("/bad/options/charset", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/options/charset", Config)), ok. bad_options_etag(Config) -> doc("Bad cowboy_static extra options: invalid etag option."), - {500, _, _} = do_get("/bad/options/etag", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/options/etag", Config)), ok. bad_options_mime(Config) -> doc("Bad cowboy_static extra options: invalid mimetypes option."), - {500, _, _} = do_get("/bad/options/mime", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/options/mime", Config)), ok. bad_priv_dir_app(Config) -> doc("Bad cowboy_static options: wrong application name."), - {500, _, _} = do_get("/bad/priv_dir/app/style.css", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/priv_dir/app/style.css", Config)), ok. bad_priv_dir_in_ez_archive(Config) -> @@ -331,12 +340,12 @@ bad_priv_dir_path(Config) -> bad_priv_dir_route(Config) -> doc("Bad cowboy_static options: missing [...] in route."), - {500, _, _} = do_get("/bad/priv_dir/route", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/priv_dir/route", Config)), ok. bad_priv_file_app(Config) -> doc("Bad cowboy_static options: wrong application name."), - {500, _, _} = do_get("/bad/priv_file/app", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/priv_file/app", Config)), ok. bad_priv_file_in_ez_archive(Config) -> @@ -535,7 +544,7 @@ dir_unknown(Config) -> etag_crash(Config) -> doc("Get a file with a crashing etag function."), - {500, _, _} = do_get("/etag/crash", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/etag/crash", Config)), ok. etag_custom(Config) -> @@ -813,7 +822,7 @@ mime_all_uppercase(Config) -> mime_crash(Config) -> doc("Get a file with a crashing mimetype function."), - {500, _, _} = do_get("/mime/crash/style.css", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/mime/crash/style.css", Config)), ok. mime_custom_cowboy(Config) -> @@ -848,7 +857,7 @@ mime_hardcode_tuple(Config) -> charset_crash(Config) -> doc("Get a file with a crashing charset function."), - {500, _, _} = do_get("/charset/crash/style.css", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/charset/crash/style.css", Config)), ok. charset_custom_cowboy(Config) -> @@ -933,7 +942,8 @@ unicode_basic_error(Config) -> %% # and ? indicate fragment and query components %% and are therefore not part of the path. http -> "\r\s#?"; - http2 -> "#?" + http2 -> "#?"; + http3 -> "#?" end, _ = [case do_get("/char/" ++ [C], Config) of {400, _, _} -> ok; diff --git a/test/stream_handler_SUITE.erl b/test/stream_handler_SUITE.erl index 46a05b2..90229c0 100644 --- a/test/stream_handler_SUITE.erl +++ b/test/stream_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -31,50 +31,42 @@ groups() -> %% We set this module as a logger in order to silence expected errors. init_per_group(Name = http, Config) -> - cowboy_test:init_http(Name, #{ - logger => ?MODULE, - stream_handlers => [stream_handler_h] - }, Config); + cowboy_test:init_http(Name, init_plain_opts(), Config); init_per_group(Name = https, Config) -> - cowboy_test:init_https(Name, #{ - logger => ?MODULE, - stream_handlers => [stream_handler_h] - }, Config); + cowboy_test:init_https(Name, init_plain_opts(), Config); init_per_group(Name = h2, Config) -> - cowboy_test:init_http2(Name, #{ - logger => ?MODULE, - stream_handlers => [stream_handler_h] - }, Config); + cowboy_test:init_http2(Name, init_plain_opts(), Config); init_per_group(Name = h2c, Config) -> - Config1 = cowboy_test:init_http(Name, #{ - logger => ?MODULE, - stream_handlers => [stream_handler_h] - }, Config), + Config1 = cowboy_test:init_http(Name, init_plain_opts(), Config), lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3, Config) -> + cowboy_test:init_http3(Name, init_plain_opts(), Config); init_per_group(Name = http_compress, Config) -> - cowboy_test:init_http(Name, #{ - logger => ?MODULE, - stream_handlers => [cowboy_compress_h, stream_handler_h] - }, Config); + cowboy_test:init_http(Name, init_compress_opts(), Config); init_per_group(Name = https_compress, Config) -> - cowboy_test:init_https(Name, #{ - logger => ?MODULE, - stream_handlers => [cowboy_compress_h, stream_handler_h] - }, Config); + cowboy_test:init_https(Name, init_compress_opts(), Config); init_per_group(Name = h2_compress, Config) -> - cowboy_test:init_http2(Name, #{ - logger => ?MODULE, - stream_handlers => [cowboy_compress_h, stream_handler_h] - }, Config); + cowboy_test:init_http2(Name, init_compress_opts(), Config); init_per_group(Name = h2c_compress, Config) -> - Config1 = cowboy_test:init_http(Name, #{ - logger => ?MODULE, - stream_handlers => [cowboy_compress_h, stream_handler_h] - }, Config), - lists:keyreplace(protocol, 1, Config1, {protocol, http2}). + Config1 = cowboy_test:init_http(Name, init_compress_opts(), Config), + lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3_compress, Config) -> + cowboy_test:init_http3(Name, init_compress_opts(), Config). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). + +init_plain_opts() -> + #{ + logger => ?MODULE, + stream_handlers => [stream_handler_h] + }. + +init_compress_opts() -> + #{ + logger => ?MODULE, + stream_handlers => [cowboy_compress_h, stream_handler_h] + }. %% Logger function silencing the expected crashes. @@ -99,15 +91,20 @@ crash_in_init(Config) -> %% Confirm terminate/3 is NOT called. We have no state to give to it. receive {Self, Pid, terminate, _, _, _} -> error(terminate) after 1000 -> ok end, %% Confirm early_error/5 is called in HTTP/1.1's case. - %% HTTP/2 does not send a response back so there is no early_error call. + %% HTTP/2 and HTTP/3 do not send a response back so there is no early_error call. case config(protocol, Config) of http -> receive {Self, Pid, early_error, _, _, _, _, _} -> ok after 1000 -> error(timeout) end; - http2 -> ok + http2 -> ok; + http3 -> ok end, - %% Receive a 500 error response. - case gun:await(ConnPid, Ref) of - {response, fin, 500, _} -> ok; - {error, {stream_error, {stream_error, internal_error, _}}} -> ok + do_await_internal_error(ConnPid, Ref, Config). + +do_await_internal_error(ConnPid, Ref, Config) -> + Protocol = config(protocol, Config), + case {Protocol, gun:await(ConnPid, Ref)} of + {http, {response, fin, 500, _}} -> ok; + {http2, {error, {stream_error, {stream_error, internal_error, _}}}} -> ok; + {http3, {error, {stream_error, {stream_error, h3_internal_error, _}}}} -> ok end. crash_in_data(Config) -> @@ -126,11 +123,7 @@ crash_in_data(Config) -> gun:data(ConnPid, Ref, fin, <<"Hello!">>), %% Confirm terminate/3 is called, indicating the stream ended. receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, - %% Receive a 500 error response. - case gun:await(ConnPid, Ref) of - {response, fin, 500, _} -> ok; - {error, {stream_error, {stream_error, internal_error, _}}} -> ok - end. + do_await_internal_error(ConnPid, Ref, Config). crash_in_info(Config) -> doc("Confirm an error is sent when a stream handler crashes in info/3."), @@ -144,14 +137,14 @@ crash_in_info(Config) -> %% Confirm init/3 is called. Pid = receive {Self, P, init, _, _, _} -> P after 1000 -> error(timeout) end, %% Send a message to make the stream handler crash. - Pid ! {{Pid, 1}, crash}, + StreamID = case config(protocol, Config) of + http3 -> 0; + _ -> 1 + end, + Pid ! {{Pid, StreamID}, crash}, %% Confirm terminate/3 is called, indicating the stream ended. receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, - %% Receive a 500 error response. - case gun:await(ConnPid, Ref) of - {response, fin, 500, _} -> ok; - {error, {stream_error, {stream_error, internal_error, _}}} -> ok - end. + do_await_internal_error(ConnPid, Ref, Config). crash_in_terminate(Config) -> doc("Confirm the state is correct when a stream handler crashes in terminate/3."), @@ -185,10 +178,12 @@ crash_in_terminate(Config) -> {ok, <<"Hello world!">>} = gun:await_body(ConnPid, Ref2), ok. +%% @todo The callbacks ARE used for HTTP/2 and HTTP/3 CONNECT/TRACE requests. crash_in_early_error(Config) -> case config(protocol, Config) of http -> do_crash_in_early_error(Config); - http2 -> doc("The callback early_error/5 is not currently used for HTTP/2.") + http2 -> doc("The callback early_error/5 is not currently used for HTTP/2."); + http3 -> doc("The callback early_error/5 is not currently used for HTTP/3.") end. do_crash_in_early_error(Config) -> @@ -225,10 +220,12 @@ do_crash_in_early_error(Config) -> {response, fin, 500, _} = gun:await(ConnPid, Ref2), ok. +%% @todo The callbacks ARE used for HTTP/2 and HTTP/3 CONNECT/TRACE requests. crash_in_early_error_fatal(Config) -> case config(protocol, Config) of http -> do_crash_in_early_error_fatal(Config); - http2 -> doc("The callback early_error/5 is not currently used for HTTP/2.") + http2 -> doc("The callback early_error/5 is not currently used for HTTP/2."); + http3 -> doc("The callback early_error/5 is not currently used for HTTP/3.") end. do_crash_in_early_error_fatal(Config) -> @@ -262,7 +259,8 @@ early_error_stream_error_reason(Config) -> %% reason in both protocols. {Method, Headers, Status, Error} = case config(protocol, Config) of http -> {<<"GET">>, [{<<"host">>, <<"host:port">>}], 400, protocol_error}; - http2 -> {<<"TRACE">>, [], 501, no_error} + http2 -> {<<"TRACE">>, [], 501, no_error}; + http3 -> {<<"TRACE">>, [], 501, h3_no_error} end, Ref = gun:request(ConnPid, Method, "/long_polling", [ {<<"accept-encoding">>, <<"gzip">>}, @@ -293,7 +291,7 @@ flow_after_body_fully_read(Config) -> %% Receive a 200 response, sent after the second flow command, %% confirming that the flow command was accepted. {response, _, 200, _} = gun:await(ConnPid, Ref), - ok. + gun:close(ConnPid). set_options_ignore_unknown(Config) -> doc("Confirm that unknown options are ignored when using the set_options commands."), @@ -355,11 +353,20 @@ shutdown_on_socket_close(Config) -> Spawn ! {Self, ready}, %% Close the socket. ok = gun:close(ConnPid), - %% Confirm terminate/3 is called, indicating the stream ended. - receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, - %% Confirm we receive a DOWN message for the child process. - receive {'DOWN', MRef, process, Spawn, shutdown} -> ok after 1000 -> error(timeout) end, - ok. + Protocol = config(protocol, Config), + try + %% Confirm terminate/3 is called, indicating the stream ended. + receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, + %% Confirm we receive a DOWN message for the child process. + receive {'DOWN', MRef, process, Spawn, shutdown} -> ok after 1000 -> error(timeout) end, + ok + catch error:timeout when Protocol =:= http3 -> + %% @todo Figure out why this happens. Could be a timing issue + %% or a legitimate bug. I suspect that the server just + %% doesn't receive the GOAWAY frame from Gun because + %% Gun is too quick to close the connection. + shutdown_on_socket_close(Config) + end. shutdown_timeout_on_stream_stop(Config) -> doc("Confirm supervised processes are killed " @@ -406,33 +413,45 @@ shutdown_timeout_on_socket_close(Config) -> Spawn ! {Self, ready}, %% Close the socket. ok = gun:close(ConnPid), - %% Confirm terminate/3 is called, indicating the stream ended. - receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, - %% We should NOT receive a DOWN message immediately. - receive {'DOWN', MRef, process, Spawn, killed} -> error(killed) after 1500 -> ok end, - %% We should received it now. - receive {'DOWN', MRef, process, Spawn, killed} -> ok after 1000 -> error(timeout) end, - ok. + Protocol = config(protocol, Config), + try + %% Confirm terminate/3 is called, indicating the stream ended. + receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, + %% We should NOT receive a DOWN message immediately. + receive {'DOWN', MRef, process, Spawn, killed} -> error(killed) after 1500 -> ok end, + %% We should receive it now. + receive {'DOWN', MRef, process, Spawn, killed} -> ok after 1000 -> error(timeout) end, + ok + catch error:timeout when Protocol =:= http3 -> + %% @todo Figure out why this happens. Could be a timing issue + %% or a legitimate bug. I suspect that the server just + %% doesn't receive the GOAWAY frame from Gun because + %% Gun is too quick to close the connection. + shutdown_timeout_on_socket_close(Config) + end. switch_protocol_after_headers(Config) -> case config(protocol, Config) of http -> do_switch_protocol_after_response( <<"switch_protocol_after_headers">>, Config); - http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.") + http2 -> doc("The switch_protocol command is not currently supported for HTTP/2."); + http3 -> doc("The switch_protocol command is not currently supported for HTTP/3.") end. switch_protocol_after_headers_data(Config) -> case config(protocol, Config) of http -> do_switch_protocol_after_response( <<"switch_protocol_after_headers_data">>, Config); - http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.") + http2 -> doc("The switch_protocol command is not currently supported for HTTP/2."); + http3 -> doc("The switch_protocol command is not currently supported for HTTP/3.") end. switch_protocol_after_response(Config) -> case config(protocol, Config) of http -> do_switch_protocol_after_response( <<"switch_protocol_after_response">>, Config); - http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.") + http2 -> doc("The switch_protocol command is not currently supported for HTTP/2."); + http3 -> doc("The switch_protocol command is not currently supported for HTTP/3.") end. do_switch_protocol_after_response(TestCase, Config) -> @@ -502,7 +521,12 @@ terminate_on_stop(Config) -> {response, fin, 204, _} = gun:await(ConnPid, Ref), %% Confirm the stream is still alive even though we %% received the response fully, and tell it to stop. - Pid ! {{Pid, 1}, please_stop}, + StreamID = case config(protocol, Config) of + http -> 1; + http2 -> 1; + http3 -> 0 + end, + Pid ! {{Pid, StreamID}, please_stop}, receive {Self, Pid, info, _, please_stop, _} -> ok after 1000 -> error(timeout) end, %% Confirm terminate/3 is called. receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, @@ -511,7 +535,8 @@ terminate_on_stop(Config) -> terminate_on_switch_protocol(Config) -> case config(protocol, Config) of http -> do_terminate_on_switch_protocol(Config); - http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.") + http2 -> doc("The switch_protocol command is not currently supported for HTTP/2."); + http3 -> doc("The switch_protocol command is not currently supported for HTTP/3.") end. do_terminate_on_switch_protocol(Config) -> diff --git a/test/sys_SUITE.erl b/test/sys_SUITE.erl index 175219c..3591490 100644 --- a/test/sys_SUITE.erl +++ b/test/sys_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -22,7 +22,6 @@ -import(ct_helper, [get_remote_pid_tcp/1]). -import(ct_helper, [get_remote_pid_tls/1]). -import(ct_helper, [is_process_down/1]). --import(cowboy_test, [gun_open/1]). all() -> [{group, sys}]. @@ -109,7 +108,8 @@ bad_system_from_h1(Config) -> bad_system_from_h2(Config) -> doc("h2: Sending a system message with a bad From value results in a process crash."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), %% Skip the SETTINGS frame. {ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000), timer:sleep(100), @@ -176,7 +176,8 @@ bad_system_message_h1(Config) -> bad_system_message_h2(Config) -> doc("h2: Sending a system message with a bad Request value results in an error."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), %% Skip the SETTINGS frame. {ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000), timer:sleep(100), @@ -252,7 +253,8 @@ good_system_message_h1(Config) -> good_system_message_h2(Config) -> doc("h2: System messages are handled properly."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), %% Skip the SETTINGS frame. {ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000), timer:sleep(100), @@ -336,7 +338,8 @@ trap_exit_parent_exit_h2(Config) -> doc("h2: A process trapping exits must stop when receiving " "an 'EXIT' message from its parent."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), %% Skip the SETTINGS frame. {ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000), timer:sleep(100), @@ -408,7 +411,8 @@ trap_exit_other_exit_h2(Config) -> doc("h2: A process trapping exits must ignore " "'EXIT' messages from unknown processes."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), Pid ! {'EXIT', self(), {shutdown, ?MODULE}}, @@ -526,7 +530,8 @@ sys_change_code_h1(Config) -> sys_change_code_h2(Config) -> doc("h2: The sys:change_code/4 function works as expected."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), %% Suspend the process and try to get a request in. The @@ -609,7 +614,8 @@ sys_get_state_h1(Config) -> sys_get_state_h2(Config) -> doc("h2: The sys:get_state/1 function works as expected."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), %% Skip the SETTINGS frame. {ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000), timer:sleep(100), @@ -653,7 +659,7 @@ sys_get_state_loop(Config) -> timer:sleep(100), SupPid = get_remote_pid_tcp(Socket), [{_, Pid, _, _}] = supervisor:which_children(SupPid), - {Req, Env, long_polling_sys_h, undefined} = sys:get_state(Pid), + {Req, Env, long_polling_sys_h, undefined, infinity} = sys:get_state(Pid), #{pid := _, streamid := _} = Req, #{dispatch := _} = Env, ok. @@ -671,7 +677,8 @@ sys_get_status_h1(Config) -> sys_get_status_h2(Config) -> doc("h2: The sys:get_status/1 function works as expected."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), %% Skip the SETTINGS frame. {ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000), timer:sleep(100), @@ -732,7 +739,8 @@ sys_replace_state_h1(Config) -> sys_replace_state_h2(Config) -> doc("h2: The sys:replace_state/2 function works as expected."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), %% Skip the SETTINGS frame. {ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000), timer:sleep(100), @@ -776,7 +784,7 @@ sys_replace_state_loop(Config) -> timer:sleep(100), SupPid = get_remote_pid_tcp(Socket), [{_, Pid, _, _}] = supervisor:which_children(SupPid), - {Req, Env, long_polling_sys_h, undefined} = sys:replace_state(Pid, fun(S) -> S end), + {Req, Env, long_polling_sys_h, undefined, infinity} = sys:replace_state(Pid, fun(S) -> S end), #{pid := _, streamid := _} = Req, #{dispatch := _} = Env, ok. @@ -801,7 +809,8 @@ sys_suspend_and_resume_h1(Config) -> sys_suspend_and_resume_h2(Config) -> doc("h2: The sys:suspend/1 and sys:resume/1 functions work as expected."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), %% Suspend the process and try to get a request in. The @@ -880,7 +889,8 @@ sys_terminate_h1(Config) -> sys_terminate_h2(Config) -> doc("h2: The sys:terminate/2,3 function works as expected."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), %% Skip the SETTINGS frame. {ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000), timer:sleep(100), @@ -983,7 +993,8 @@ supervisor_count_children_h1(Config) -> supervisor_count_children_h2(Config) -> doc("h2: The function supervisor:count_children/1 must work."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), %% No request was sent so there's no children. @@ -1055,7 +1066,8 @@ supervisor_delete_child_not_found_h1(Config) -> supervisor_delete_child_not_found_h2(Config) -> doc("h2: The function supervisor:delete_child/2 must return {error, not_found}."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), %% When no children exist. @@ -1114,7 +1126,8 @@ supervisor_get_childspec_not_found_h1(Config) -> supervisor_get_childspec_not_found_h2(Config) -> doc("h2: The function supervisor:get_childspec/2 must return {error, not_found}."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), %% When no children exist. @@ -1173,7 +1186,8 @@ supervisor_restart_child_not_found_h1(Config) -> supervisor_restart_child_not_found_h2(Config) -> doc("h2: The function supervisor:restart_child/2 must return {error, not_found}."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), %% When no children exist. @@ -1227,7 +1241,8 @@ supervisor_start_child_not_found_h1(Config) -> supervisor_start_child_not_found_h2(Config) -> doc("h2: The function supervisor:start_child/2 must return {error, start_child_disabled}."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), {error, start_child_disabled} = supervisor:start_child(Pid, #{ @@ -1281,7 +1296,8 @@ supervisor_terminate_child_not_found_h1(Config) -> supervisor_terminate_child_not_found_h2(Config) -> doc("h2: The function supervisor:terminate_child/2 must return {error, not_found}."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), %% When no children exist. @@ -1344,7 +1360,8 @@ supervisor_which_children_h1(Config) -> supervisor_which_children_h2(Config) -> doc("h2: The function supervisor:which_children/1 must work."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), %% No request was sent so there's no children. diff --git a/test/tracer_SUITE.erl b/test/tracer_SUITE.erl index d5683a0..4298b44 100644 --- a/test/tracer_SUITE.erl +++ b/test/tracer_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -29,13 +29,15 @@ suite() -> %% We initialize trace patterns here. Appropriate would be in %% init_per_suite/1, but this works just as well. all() -> - case code:is_module_native(?MODULE) of - true -> - {skip, "The Cowboy tracer is not compatible with native code."}; - false -> - cowboy_tracer_h:set_trace_patterns(), - cowboy_test:common_all() - end. + %% @todo Implement these tests for HTTP/3. + cowboy_test:common_all() -- [{group, h3}, {group, h3_compress}]. + +init_per_suite(Config) -> + cowboy_tracer_h:set_trace_patterns(), + Config. + +end_per_suite(_) -> + ok. %% We want tests for each group to execute sequentially %% because we need to modify the protocol options. Groups diff --git a/test/ws_SUITE.erl b/test/ws_SUITE.erl index 9abeaca..6fa4e61 100644 --- a/test/ws_SUITE.erl +++ b/test/ws_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -68,7 +68,8 @@ init_dispatch() -> {"/ws_timeout_cancel", ws_timeout_cancel, []}, {"/ws_max_frame_size", ws_max_frame_size, []}, {"/ws_deflate_opts", ws_deflate_opts_h, []}, - {"/ws_dont_validate_utf8", ws_dont_validate_utf8_h, []} + {"/ws_dont_validate_utf8", ws_dont_validate_utf8_h, []}, + {"/ws_ping", ws_ping_h, []} ]} ]). @@ -202,6 +203,25 @@ do_ws_version(Socket) -> {error, closed} = gen_tcp:recv(Socket, 0, 6000), ok. +ws_deflate_max_frame_size_close(Config) -> + doc("Server closes connection when decompressed frame size exceeds max_frame_size option"), + %% max_frame_size is set to 8 bytes in ws_max_frame_size. + {ok, Socket, Headers} = do_handshake("/ws_max_frame_size", + "Sec-WebSocket-Extensions: permessage-deflate\r\n", Config), + {_, "permessage-deflate"} = lists:keyfind("sec-websocket-extensions", 1, Headers), + Mask = 16#11223344, + Z = zlib:open(), + zlib:deflateInit(Z, best_compression, deflated, -15, 8, default), + CompressedData0 = iolist_to_binary(zlib:deflate(Z, <<0:800>>, sync)), + CompressedData = binary:part(CompressedData0, 0, byte_size(CompressedData0) - 4), + MaskedData = do_mask(CompressedData, Mask, <<>>), + Len = byte_size(MaskedData), + true = Len < 8, + ok = gen_tcp:send(Socket, << 1:1, 1:1, 0:2, 1:4, 1:1, Len:7, Mask:32, MaskedData/binary >>), + {ok, << 1:1, 0:3, 8:4, 0:1, 2:7, 1009:16 >>} = gen_tcp:recv(Socket, 0, 6000), + {error, closed} = gen_tcp:recv(Socket, 0, 6000), + ok. + ws_deflate_opts_client_context_takeover(Config) -> doc("Handler is configured with client context takeover enabled."), {ok, _, Headers1} = do_handshake("/ws_deflate_opts?client_context_takeover", @@ -247,6 +267,21 @@ ws_deflate_opts_client_max_window_bits_override(Config) -> = lists:keyfind("sec-websocket-extensions", 1, Headers2), ok. +%% @todo This might be better in an rfc7692_SUITE. +%% +%% 7.1.2.2 +%% If a received extension negotiation offer doesn't have the +%% "client_max_window_bits" extension parameter, the corresponding +%% extension negotiation response to the offer MUST NOT include the +%% "client_max_window_bits" extension parameter. +ws_deflate_opts_client_max_window_bits_only_in_server(Config) -> + doc("Handler is configured with non-default client max window bits but " + "client doesn't send the parameter; compression is disabled."), + {ok, _, Headers} = do_handshake("/ws_deflate_opts?client_max_window_bits", + "Sec-WebSocket-Extensions: permessage-deflate\r\n", Config), + false = lists:keyfind("sec-websocket-extensions", 1, Headers), + ok. + ws_deflate_opts_server_context_takeover(Config) -> doc("Handler is configured with server context takeover enabled."), {ok, _, Headers1} = do_handshake("/ws_deflate_opts?server_context_takeover", @@ -341,6 +376,7 @@ ws_first_frame_with_handshake(Config) -> {ok, <<1:1, 0:3, 1:4, 0:1, 5:7, "Hello">>} = gen_tcp:recv(Socket, 0, 6000), ok. +%% @todo Move these tests to ws_handler_SUITE. ws_init_return_ok(Config) -> doc("Handler does nothing."), {ok, Socket, _} = do_handshake("/ws_init?ok", Config), @@ -471,6 +507,17 @@ ws_max_frame_size_intermediate_fragment_close(Config) -> {error, closed} = gen_tcp:recv(Socket, 0, 6000), ok. +ws_ping(Config) -> + doc("Server initiated pings can receive a pong in response."), + {ok, Socket, _} = do_handshake("/ws_ping", Config), + %% Receive a server-sent ping. + {ok, << 1:1, 0:3, 9:4, 0:1, 0:7 >>} = gen_tcp:recv(Socket, 0, 6000), + %% Send a pong back with a 0 mask. + ok = gen_tcp:send(Socket, << 1:1, 0:3, 10:4, 1:1, 0:7, 0:32 >>), + %% Receive a text frame as a result. + {ok, << 1:1, 0:3, 1:4, 0:1, 4:7, "OK!!" >>} = gen_tcp:recv(Socket, 0, 6000), + ok. + ws_send_close(Config) -> doc("Server-initiated close frame ends the connection."), {ok, Socket, _} = do_handshake("/ws_send_close", Config), diff --git a/test/ws_SUITE_data/ws_max_frame_size.erl b/test/ws_SUITE_data/ws_max_frame_size.erl index 3d81497..76df0b0 100644 --- a/test/ws_SUITE_data/ws_max_frame_size.erl +++ b/test/ws_SUITE_data/ws_max_frame_size.erl @@ -5,7 +5,7 @@ -export([websocket_info/2]). init(Req, State) -> - {cowboy_websocket, Req, State, #{max_frame_size => 8}}. + {cowboy_websocket, Req, State, #{max_frame_size => 8, compress => true}}. websocket_handle({text, Data}, State) -> {[{text, Data}], State}; diff --git a/test/ws_autobahn_SUITE.erl b/test/ws_autobahn_SUITE.erl index 24d76e8..58d15fa 100644 --- a/test/ws_autobahn_SUITE.erl +++ b/test/ws_autobahn_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 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 @@ -51,7 +51,7 @@ end_per_group(Listener, _Config) -> init_dispatch() -> cowboy_router:compile([ - {"localhost", [ + {"host.docker.internal", [ {"/ws_echo", ws_echo, []} ]} ]). @@ -73,7 +73,15 @@ autobahn_fuzzingclient(Config) -> end. do_start_port(Config, Pid) -> - Port = open_port({spawn, "wstest -m fuzzingclient -s " ++ config(data_dir, Config) ++ "client.json"}, +% Cmd = "wstest -m fuzzingclient -s " ++ config(data_dir, Config) ++ "client.json", + Cmd = "sudo docker run --rm " + "-v " ++ config(data_dir, Config) ++ "/client.json:/client.json " + "-v " ++ config(priv_dir, Config) ++ "/reports:/reports " + "--add-host=host.docker.internal:host-gateway " + "--name fuzzingclient " + "crossbario/autobahn-testsuite " + "wstest -m fuzzingclient -s client.json", + Port = open_port({spawn, Cmd}, [{line, 10000}, {cd, config(priv_dir, Config)}, binary, eof]), do_receive_infinity(Port, Pid). diff --git a/test/ws_autobahn_SUITE_data/client.json b/test/ws_autobahn_SUITE_data/client.json index 7899503..c729193 100644 --- a/test/ws_autobahn_SUITE_data/client.json +++ b/test/ws_autobahn_SUITE_data/client.json @@ -4,7 +4,7 @@ "servers": [{ "agent": "Cowboy", - "url": "ws://localhost:33080/ws_echo", + "url": "ws://host.docker.internal:33080/ws_echo", "options": {"version": 18} }], diff --git a/test/ws_handler_SUITE.erl b/test/ws_handler_SUITE.erl index 872b152..ab9dbe2 100644 --- a/test/ws_handler_SUITE.erl +++ b/test/ws_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -50,6 +50,7 @@ init_dispatch(Name) -> {"/init", ws_init_commands_h, RunOrHibernate}, {"/handle", ws_handle_commands_h, RunOrHibernate}, {"/info", ws_info_commands_h, RunOrHibernate}, + {"/trap_exit", ws_init_h, RunOrHibernate}, {"/active", ws_active_commands_h, RunOrHibernate}, {"/deflate", ws_deflate_commands_h, RunOrHibernate}, {"/set_options", ws_set_options_commands_h, RunOrHibernate}, @@ -82,9 +83,9 @@ receive_ws(ConnPid, StreamRef) -> {error, timeout} end. -ensure_handle_is_called(ConnPid, "/handle") -> - gun:ws_send(ConnPid, {text, <<"Necessary to trigger websocket_handle/2.">>}); -ensure_handle_is_called(_, _) -> +ensure_handle_is_called(ConnPid, StreamRef, "/handle") -> + gun:ws_send(ConnPid, StreamRef, {text, <<"Necessary to trigger websocket_handle/2.">>}); +ensure_handle_is_called(_, _, _) -> ok. %% Tests. @@ -103,7 +104,7 @@ websocket_info_nothing(Config) -> do_nothing(Config, Path) -> {ok, ConnPid, StreamRef} = gun_open_ws(Config, Path, []), - ensure_handle_is_called(ConnPid, Path), + ensure_handle_is_called(ConnPid, StreamRef, Path), {error, timeout} = receive_ws(ConnPid, StreamRef), ok. @@ -120,8 +121,8 @@ websocket_info_invalid(Config) -> do_invalid(Config, "/info"). do_invalid(Config, Path) -> - {ok, ConnPid, _} = gun_open_ws(Config, Path, bad), - ensure_handle_is_called(ConnPid, Path), + {ok, ConnPid, StreamRef} = gun_open_ws(Config, Path, bad), + ensure_handle_is_called(ConnPid, StreamRef, Path), gun_down(ConnPid). websocket_init_one_frame(Config) -> @@ -140,7 +141,7 @@ do_one_frame(Config, Path) -> {ok, ConnPid, StreamRef} = gun_open_ws(Config, Path, [ {text, <<"One frame!">>} ]), - ensure_handle_is_called(ConnPid, Path), + ensure_handle_is_called(ConnPid, StreamRef, Path), {ok, {text, <<"One frame!">>}} = receive_ws(ConnPid, StreamRef), ok. @@ -161,7 +162,7 @@ do_many_frames(Config, Path) -> {text, <<"One frame!">>}, {binary, <<"Two frames!">>} ]), - ensure_handle_is_called(ConnPid, Path), + ensure_handle_is_called(ConnPid, StreamRef, Path), {ok, {text, <<"One frame!">>}} = receive_ws(ConnPid, StreamRef), {ok, {binary, <<"Two frames!">>}} = receive_ws(ConnPid, StreamRef), ok. @@ -180,7 +181,7 @@ websocket_info_close_frame(Config) -> do_close_frame(Config, Path) -> {ok, ConnPid, StreamRef} = gun_open_ws(Config, Path, [close]), - ensure_handle_is_called(ConnPid, Path), + ensure_handle_is_called(ConnPid, StreamRef, Path), {ok, close} = receive_ws(ConnPid, StreamRef), gun_down(ConnPid). @@ -205,17 +206,24 @@ do_many_frames_then_close_frame(Config, Path) -> {binary, <<"Two frames!">>}, close ]), - ensure_handle_is_called(ConnPid, Path), + ensure_handle_is_called(ConnPid, StreamRef, Path), {ok, {text, <<"One frame!">>}} = receive_ws(ConnPid, StreamRef), {ok, {binary, <<"Two frames!">>}} = receive_ws(ConnPid, StreamRef), {ok, close} = receive_ws(ConnPid, StreamRef), gun_down(ConnPid). +websocket_init_trap_exit_false(Config) -> + doc("The trap_exit process flag must be set back to false before " + "the connection is taken over by Websocket."), + {ok, ConnPid, StreamRef} = gun_open_ws(Config, "/trap_exit?reply_trap_exit", []), + {ok, {text, <<"trap_exit: false">>}} = receive_ws(ConnPid, StreamRef), + ok. + websocket_active_false(Config) -> doc("The {active, false} command stops receiving data from the socket. " "The {active, true} command reenables it."), {ok, ConnPid, StreamRef} = gun_open_ws(Config, "/active", []), - gun:ws_send(ConnPid, {text, <<"Not received until the handler enables active again.">>}), + gun:ws_send(ConnPid, StreamRef, {text, <<"Not received until the handler enables active again.">>}), {error, timeout} = receive_ws(ConnPid, StreamRef), {ok, {text, <<"Not received until the handler enables active again.">>}} = receive_ws(ConnPid, StreamRef), @@ -255,7 +263,7 @@ websocket_deflate_ignore_if_not_negotiated(Config) -> "when compression was not negotiated."), {ok, ConnPid, StreamRef} = gun_open_ws(Config, "/deflate", []), _ = [begin - gun:ws_send(ConnPid, {text, <<"Hello.">>}), + gun:ws_send(ConnPid, StreamRef, {text, <<"Hello.">>}), {ok, {text, <<"Hello.">>}} = receive_ws(ConnPid, StreamRef) end || _ <- lists:seq(1, 10)], ok. @@ -280,7 +288,42 @@ websocket_set_options_idle_timeout(Config) -> {error, timeout} = gun:await(ConnPid, StreamRef, 2000), %% Trigger the change in idle_timeout and confirm that %% the connection gets closed soon after. - gun:ws_send(ConnPid, {text, <<"idle_timeout_short">>}), + gun:ws_send(ConnPid, StreamRef, {text, <<"idle_timeout_short">>}), + receive + {gun_down, ConnPid, _, _, _} -> + ok + after 2000 -> + error(timeout) + end. + +websocket_set_options_max_frame_size(Config) -> + doc("The max_frame_size option can be modified using the " + "command {set_options, Opts} at runtime."), + ConnPid = gun_open(Config), + StreamRef = gun:ws_upgrade(ConnPid, "/set_options"), + receive + {gun_upgrade, ConnPid, StreamRef, [<<"websocket">>], _} -> + ok; + {gun_response, ConnPid, _, _, Status, Headers} -> + exit({ws_upgrade_failed, Status, Headers}); + {gun_error, ConnPid, StreamRef, Reason} -> + exit({ws_upgrade_failed, Reason}) + after 1000 -> + error(timeout) + end, + %% We first send a 1MB frame to confirm that yes, we can + %% send a frame that large. The default max_frame_size is infinity. + gun:ws_send(ConnPid, StreamRef, {binary, <<0:8000000>>}), + {ws, {binary, <<0:8000000>>}} = gun:await(ConnPid, StreamRef), + %% Trigger the change in max_frame_size. From now on we will + %% only allow frames of up to 1000 bytes. + gun:ws_send(ConnPid, StreamRef, {text, <<"max_frame_size_small">>}), + %% Confirm that we can send frames of up to 1000 bytes. + gun:ws_send(ConnPid, StreamRef, {binary, <<0:8000>>}), + {ws, {binary, <<0:8000>>}} = gun:await(ConnPid, StreamRef), + %% Confirm that sending frames larger than 1000 bytes + %% results in the closing of the connection. + gun:ws_send(ConnPid, StreamRef, {binary, <<0:8008>>}), receive {gun_down, ConnPid, _, _, _} -> ok diff --git a/test/ws_perf_SUITE.erl b/test/ws_perf_SUITE.erl new file mode 100644 index 0000000..ff88554 --- /dev/null +++ b/test/ws_perf_SUITE.erl @@ -0,0 +1,308 @@ +%% Copyright (c) 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(ws_perf_SUITE). +-compile(export_all). +-compile(nowarn_export_all). + +-import(ct_helper, [config/2]). +-import(ct_helper, [doc/1]). +-import(cowboy_test, [gun_open/2]). +-import(cowboy_test, [gun_down/1]). + +%% ct. + +all() -> + [{group, binary}, {group, ascii}, {group, mixed}, {group, japanese}]. + +groups() -> + CommonGroups = cowboy_test:common_groups(ct_helper:all(?MODULE), no_parallel), + SubGroups = [G || G = {GN, _, _} <- CommonGroups, + GN =:= http orelse GN =:= h2c orelse GN =:= http_compress orelse GN =:= h2c_compress], + [ + {binary, [], SubGroups}, + {ascii, [], SubGroups}, + {mixed, [], SubGroups}, + {japanese, [], SubGroups} + ]. + +init_per_suite(Config) -> + %% Optionally enable `perf` for the current node. +% spawn(fun() -> ct:pal(os:cmd("perf record -g -F 9999 -o /tmp/ws_perf.data -p " ++ os:getpid() ++ " -- sleep 60")) end), + Config. + +end_per_suite(_Config) -> + ok. + +init_per_group(Name, Config) when Name =:= http; Name =:= http_compress -> + init_info(Name, Config), + cowboy_test:init_common_groups(Name, Config, ?MODULE); +init_per_group(Name, Config) when Name =:= h2c; Name =:= h2c_compress -> + init_info(Name, Config), + {Flavor, Opts} = case Name of + h2c -> {vanilla, #{}}; + h2c_compress -> {compress, #{stream_handlers => [cowboy_compress_h, cowboy_stream_h]}} + end, + Config1 = cowboy_test:init_http(Name, Opts#{ + connection_window_margin_size => 64*1024, + enable_connect_protocol => true, + env => #{dispatch => init_dispatch(Config)}, + max_frame_size_sent => 64*1024, + max_frame_size_received => 16384 * 1024 - 1, + max_received_frame_rate => {10_000_000, 1}, + stream_window_data_threshold => 1024, + stream_window_margin_size => 64*1024 + }, [{flavor, Flavor}|Config]), + lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(ascii, Config) -> + init_text_data("ascii.txt", Config); +init_per_group(mixed, Config) -> + init_text_data("grok_segond.txt", Config); +init_per_group(japanese, Config) -> + init_text_data("japanese.txt", Config); +init_per_group(binary, Config) -> + [{frame_type, binary}|Config]. + +init_info(Name, Config) -> + DataInfo = case config(frame_type, Config) of + text -> config(text_data_filename, Config); + binary -> binary + end, + ConnInfo = case Name of + http -> "cleartext HTTP/1.1"; + http_compress -> "cleartext HTTP/1.1 with compression"; + h2c -> "cleartext HTTP/2"; + h2c_compress -> "cleartext HTTP/2 with compression" + end, + ct:pal("Websocket over ~s (~s)", [ConnInfo, DataInfo]). + +init_text_data(Filename, Config) -> + {ok, Text} = file:read_file(filename:join(config(data_dir, Config), Filename)), + [ + {frame_type, text}, + {text_data, Text}, + {text_data_filename, Filename} + |Config]. + +end_per_group(Name, _Config) -> + cowboy_test:stop_group(Name). + +%% Dispatch configuration. + +init_dispatch(_Config) -> + cowboy_router:compile([ + {"localhost", [ + {"/ws_echo", ws_echo, []}, + {"/ws_ignore", ws_ignore, []} + ]} + ]). + +%% Support functions for testing using Gun. + +do_gun_open_ws(Path, Config) -> + ConnPid = gun_open(Config, #{ + http2_opts => #{ + connection_window_margin_size => 64*1024, + max_frame_size_sent => 64*1024, + max_frame_size_received => 16384 * 1024 - 1, + notify_settings_changed => true, + stream_window_data_threshold => 1024, + stream_window_margin_size => 64*1024 + }, + tcp_opts => [{nodelay, true}], + ws_opts => #{compress => config(flavor, Config) =:= compress} + }), + case config(protocol, Config) of + http -> ok; + http2 -> + {notify, settings_changed, #{enable_connect_protocol := true}} + = gun:await(ConnPid, undefined) %% @todo Maybe have a gun:await/1? + end, + StreamRef = gun:ws_upgrade(ConnPid, Path), + receive + {gun_upgrade, ConnPid, StreamRef, [<<"websocket">>], _} -> + {ok, ConnPid, StreamRef}; + {gun_response, ConnPid, _, _, Status, Headers} -> + exit({ws_upgrade_failed, Status, Headers}); + {gun_error, ConnPid, StreamRef, Reason} -> + exit({ws_upgrade_failed, Reason}) + after 1000 -> + error(timeout) + end. + +receive_ws(ConnPid, StreamRef) -> + receive + {gun_ws, ConnPid, StreamRef, Frame} -> + {ok, Frame} + after 30000 -> + {error, timeout} + end. + +%% Tests. + +echo_1_00064KiB(Config) -> + doc("Send and receive a 64KiB frame."), + do_echo(Config, echo_1, 1, 64 * 1024). + +echo_1_00256KiB(Config) -> + doc("Send and receive a 256KiB frame."), + do_echo(Config, echo_1, 1, 256 * 1024). + +echo_1_01024KiB(Config) -> + doc("Send and receive a 1024KiB frame."), + do_echo(Config, echo_1, 1, 1024 * 1024). + +echo_1_04096KiB(Config) -> + doc("Send and receive a 4096KiB frame."), + do_echo(Config, echo_1, 1, 4096 * 1024). + +%% Minus one because frames can only get so big. +echo_1_16384KiB(Config) -> + doc("Send and receive a 16384KiB - 1 frame."), + do_echo(Config, echo_1, 1, 16384 * 1024 - 1). + +echo_N_00000B(Config) -> + doc("Send and receive a 0B frame 1000 times."), + do_echo(Config, echo_N, 1000, 0). + +echo_N_00256B(Config) -> + doc("Send and receive a 256B frame 1000 times."), + do_echo(Config, echo_N, 1000, 256). + +echo_N_01024B(Config) -> + doc("Send and receive a 1024B frame 1000 times."), + do_echo(Config, echo_N, 1000, 1024). + +echo_N_04096B(Config) -> + doc("Send and receive a 4096B frame 1000 times."), + do_echo(Config, echo_N, 1000, 4096). + +echo_N_16384B(Config) -> + doc("Send and receive a 16384B frame 1000 times."), + do_echo(Config, echo_N, 1000, 16384). + +%echo_N_16384B_10K(Config) -> +% doc("Send and receive a 16384B frame 10000 times."), +% do_echo(Config, echo_N, 10000, 16384). + +do_echo(Config, What, Num, FrameSize) -> + {ok, ConnPid, StreamRef} = do_gun_open_ws("/ws_echo", Config), + FrameType = config(frame_type, Config), + FrameData = case FrameType of + text -> do_text_data(Config, FrameSize); + binary -> rand:bytes(FrameSize) + end, + %% Heat up the processes before doing the real run. +% do_echo_loop(ConnPid, StreamRef, Num, FrameType, FrameData), + {Time, _} = timer:tc(?MODULE, do_echo_loop, [ConnPid, StreamRef, Num, FrameType, FrameData]), + do_log("~-6s ~-6s ~6s: ~8bµs", [What, FrameType, do_format_size(FrameSize), Time]), + gun:ws_send(ConnPid, StreamRef, close), + {ok, close} = receive_ws(ConnPid, StreamRef), + gun_down(ConnPid). + +do_echo_loop(_, _, 0, _, _) -> + ok; +do_echo_loop(ConnPid, StreamRef, Num, FrameType, FrameData) -> + gun:ws_send(ConnPid, StreamRef, {FrameType, FrameData}), + {ok, {FrameType, FrameData}} = receive_ws(ConnPid, StreamRef), + do_echo_loop(ConnPid, StreamRef, Num - 1, FrameType, FrameData). + +send_1_00064KiB(Config) -> + doc("Send a 64KiB frame."), + do_send(Config, send_1, 1, 64 * 1024). + +send_1_00256KiB(Config) -> + doc("Send a 256KiB frame."), + do_send(Config, send_1, 1, 256 * 1024). + +send_1_01024KiB(Config) -> + doc("Send a 1024KiB frame."), + do_send(Config, send_1, 1, 1024 * 1024). + +send_1_04096KiB(Config) -> + doc("Send a 4096KiB frame."), + do_send(Config, send_1, 1, 4096 * 1024). + +%% Minus one because frames can only get so big. +send_1_16384KiB(Config) -> + doc("Send a 16384KiB - 1 frame."), + do_send(Config, send_1, 1, 16384 * 1024 - 1). + +send_N_00000B(Config) -> + doc("Send a 0B frame 10000 times."), + do_send(Config, send_N, 10000, 0). + +send_N_00256B(Config) -> + doc("Send a 256B frame 10000 times."), + do_send(Config, send_N, 10000, 256). + +send_N_01024B(Config) -> + doc("Send a 1024B frame 10000 times."), + do_send(Config, send_N, 10000, 1024). + +send_N_04096B(Config) -> + doc("Send a 4096B frame 10000 times."), + do_send(Config, send_N, 10000, 4096). + +send_N_16384B(Config) -> + doc("Send a 16384B frame 10000 times."), + do_send(Config, send_N, 10000, 16384). + +%send_N_16384B_10K(Config) -> +% doc("Send and receive a 16384B frame 10000 times."), +% do_send(Config, send_N, 10000, 16384). + +do_send(Config, What, Num, FrameSize) -> + {ok, ConnPid, StreamRef} = do_gun_open_ws("/ws_ignore", Config), + FrameType = config(frame_type, Config), + FrameData = case FrameType of + text -> do_text_data(Config, FrameSize); + binary -> rand:bytes(FrameSize) + end, + %% Heat up the processes before doing the real run. +% do_send_loop(ConnPid, StreamRef, Num, FrameType, FrameData), + {Time, _} = timer:tc(?MODULE, do_send_loop, [ConnPid, StreamRef, Num, FrameType, FrameData]), + do_log("~-6s ~-6s ~6s: ~8bµs", [What, FrameType, do_format_size(FrameSize), Time]), + gun:ws_send(ConnPid, StreamRef, close), + {ok, close} = receive_ws(ConnPid, StreamRef), + gun_down(ConnPid). + +do_send_loop(ConnPid, StreamRef, 0, _, _) -> + gun:ws_send(ConnPid, StreamRef, {text, <<"CHECK">>}), + {ok, {text, <<"CHECK">>}} = receive_ws(ConnPid, StreamRef), + ok; +do_send_loop(ConnPid, StreamRef, Num, FrameType, FrameData) -> + gun:ws_send(ConnPid, StreamRef, {FrameType, FrameData}), + do_send_loop(ConnPid, StreamRef, Num - 1, FrameType, FrameData). + +%% Internal. + +do_text_data(Config, FrameSize) -> + do_text_data1(config(text_data, Config), FrameSize). + +do_text_data1(LargeText, FrameSize) when byte_size(LargeText) >= FrameSize -> + binary:part(LargeText, 0, FrameSize); +do_text_data1(LargeText, FrameSize) -> + do_text_data1(<<LargeText/binary, LargeText/binary>>, FrameSize). + +do_format_size(Size) when Size < 1024 -> + integer_to_list(Size) ++ "B"; +do_format_size(Size) when Size < (1024*1024) -> + integer_to_list(Size div 1024) ++ "KiB"; +do_format_size(Size) -> + integer_to_list(Size div (1024*1024)) ++ "MiB". + +do_log(Str, Args) -> + ct:log(Str, Args), + io:format(ct_default_gl, Str ++ "~n", Args). diff --git a/test/ws_perf_SUITE_data/ascii.txt b/test/ws_perf_SUITE_data/ascii.txt new file mode 100644 index 0000000..a9101d2 --- /dev/null +++ b/test/ws_perf_SUITE_data/ascii.txt @@ -0,0 +1,5 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue. Praesent egestas leo in pede. Praesent blandit odio eu enim. Pellentesque sed dui ut augue blandit sodales. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Aliquam nibh. Mauris ac mauris sed pede pellentesque fermentum. Maecenas adipiscing ante non diam sodales hendrerit. + +Ut velit mauris, egestas sed, gravida nec, ornare ut, mi. Aenean ut orci vel massa suscipit pulvinar. Nulla sollicitudin. Fusce varius, ligula non tempus aliquam, nunc turpis ullamcorper nibh, in tempus sapien eros vitae ligula. Pellentesque rhoncus nunc et augue. Integer id felis. Curabitur aliquet pellentesque diam. Integer quis metus vitae elit lobortis egestas. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Morbi vel erat non mauris convallis vehicula. Nulla et sapien. Integer tortor tellus, aliquam faucibus, convallis id, congue eu, quam. Mauris ullamcorper felis vitae erat. Proin feugiat, augue non elementum posuere, metus purus iaculis lectus, et tristique ligula justo vitae magna. + +Aliquam convallis sollicitudin purus. Praesent aliquam, enim at fermentum mollis, ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum augue. Nulla tincidunt tincidunt mi. Curabitur iaculis, lorem vel rhoncus faucibus, felis magna fermentum augue, et ultricies lacus lorem varius purus. Curabitur eu amet. diff --git a/test/ws_perf_SUITE_data/grok_segond.txt b/test/ws_perf_SUITE_data/grok_segond.txt new file mode 100644 index 0000000..614c689 --- /dev/null +++ b/test/ws_perf_SUITE_data/grok_segond.txt @@ -0,0 +1,963 @@ +Some filler content to ensure the split doesn't happen in the middle of a multi-byte character. + +- Extrait de session Grok 2 (license MIT). + +Question : Merci de générer un texte de 32000 caractères dont le but sera de tester un algorithme UTF-8 + +Je vais générer un texte de 32000 caractères qui inclura une variété de caractères pour tester un algorithme UTF-8. Ce texte contiendra des caractères de différents scripts, des symboles spéciaux, des emojis, et des caractères combinés pour couvrir une large gamme de points de code : + +À mesure que la technologie évolue, les systèmes d'encodage des caractères deviennent de plus en plus sophistiqués. L'encodage UTF-8, par exemple, est conçu pour représenter chaque caractère Unicode en utilisant une séquence d'octets. Cela permet de supporter des milliers de langues et symboles. Voici quelques exemples de ce que l'UTF-8 peut encoder : + +Latin : Bonjour le monde! Hello world! +Cyrillique : Привет, мир! +Grec : Γεια σου κόσμε! +Chinois : 你好世界! +Japonais : こんにちは世界! +Coréen : 안녕 세계! +Arabe : مرحبًا بالعالم! +Hébreu : שלום עולם! +Indien (Devanagari) : नमस्ते दुनिया! +Thaï : สวัสดีโลก! + +En plus des scripts de base, UTF-8 peut également encoder des symboles mathématiques, des signes de ponctuation spécifiques, des lettres accentuées, et même des emojis : + +Symboles mathématiques : ∫∑∏×÷√∞ +Emojis : 😀👍🌍🎉🔥 +Caractères combinés : ä, ê, ï, õ, ú (a avec tréma, e avec accent circonflexe, etc.) + +Pour tester l'encodage, on peut utiliser des combinaisons complexes : + +Combinaison de symboles : こんにちは! 😃😄😁🤣👌👍👏🙌🇫🇷🇺🇸🌟✨ +Textes multilingues : La vie est belle en français, 美しい日本の桜, Der Sommer ist schön in Deutschland. + +Pour tester la gestion des caractères spéciaux, voici quelques exemples supplémentaires : + +Ponctuation : .,;:!?¿¡…—–„“«» +Symboles divers : $¢€£¥₩₽₹¤§¶†‡ +Lettres accentuées : àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ +Ligatures : fifl + +Enfin, pour s'assurer que les séquences d'échappement et les caractères de contrôle sont bien gérés : + +Caractères de contrôle : \n\t\r\f\v (nouvelle ligne, tabulation, retour chariot, nouvelle page, tabulation verticale) +Séries de répétition : `abcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabc` + +Ce texte devrait fournir un bon test pour un algorithme UTF-8 en raison de sa diversité et de sa longueur. + +Ce texte atteint environ 32000 caractères, incluant une variété de cas pour tester un algorithme d'encodage UTF-8. + +- Extrait de La Bible en français, version Louis Segond 1910 (domaine publique). + +Romains 1 + +1.1 +Paul, serviteur de Jésus Christ, appelé à être apôtre, mis à part pour annoncer l'Évangile de Dieu, - +1.2 +qui avait été promis auparavant de la part de Dieu par ses prophètes dans les saintes Écritures, +1.3 +et qui concerne son Fils (né de la postérité de David, selon la chair, +1.4 +et déclaré Fils de Dieu avec puissance, selon l'Esprit de sainteté, par sa résurrection d'entre les morts), Jésus Christ notre Seigneur, +1.5 +par qui nous avons reçu la grâce et l'apostolat, pour amener en son nom à l'obéissance de la foi tous les païens, +1.6 +parmi lesquels vous êtes aussi, vous qui avez été appelés par Jésus Christ- +1.7 +à tous ceux qui, à Rome, sont bien-aimés de Dieu, appelés à être saints: que la grâce et la paix vous soient données de la part de Dieu notre Père et du Seigneur Jésus Christ! +1.8 +Je rends d'abord grâces à mon Dieu par Jésus Christ, au sujet de vous tous, de ce que votre foi est renommée dans le monde entier. +1.9 +Dieu, que je sers en mon esprit dans l'Évangile de son Fils, m'est témoin que je fais sans cesse mention de vous, +1.10 +demandant continuellement dans mes prières d'avoir enfin, par sa volonté, le bonheur d'aller vers vous. +1.11 +Car je désire vous voir, pour vous communiquer quelque don spirituel, afin que vous soyez affermis, +1.12 +ou plutôt, afin que nous soyons encouragés ensemble au milieu de vous par la foi qui nous est commune, à vous et à moi. +1.13 +Je ne veux pas vous laisser ignorer, frères, que j'ai souvent formé le projet d'aller vous voir, afin de recueillir quelque fruit parmi vous, comme parmi les autres nations; mais j'en ai été empêché jusqu'ici. +1.14 +Je me dois aux Grecs et aux barbares, aux savants et aux ignorants. +1.15 +Ainsi j'ai un vif désir de vous annoncer aussi l'Évangile, à vous qui êtes à Rome. +1.16 +Car je n'ai point honte de l'Évangile: c'est une puissance de Dieu pour le salut de quiconque croit, du Juif premièrement, puis du Grec, +1.17 +parce qu'en lui est révélée la justice de Dieu par la foi et pour la foi, selon qu'il est écrit: Le juste vivra par la foi. +1.18 +La colère de Dieu se révèle du ciel contre toute impiété et toute injustice des hommes qui retiennent injustement la vérité captive, +1.19 +car ce qu'on peut connaître de Dieu est manifeste pour eux, Dieu le leur ayant fait connaître. +1.20 +En effet, les perfections invisibles de Dieu, sa puissance éternelle et sa divinité, se voient comme à l'oeil, depuis la création du monde, quand on les considère dans ses ouvrages. Ils sont donc inexcusables, +1.21 +puisque ayant connu Dieu, ils ne l'ont point glorifié comme Dieu, et ne lui ont point rendu grâces; mais ils se sont égarés dans leurs pensées, et leur coeur sans intelligence a été plongé dans les ténèbres. +1.22 +Se vantant d'être sages, ils sont devenus fous; +1.23 +et ils ont changé la gloire du Dieu incorruptible en images représentant l'homme corruptible, des oiseaux, des quadrupèdes, et des reptiles. +1.24 +C'est pourquoi Dieu les a livrés à l'impureté, selon les convoitises de leurs coeurs; en sorte qu'ils déshonorent eux-mêmes leurs propres corps; +1.25 +eux qui ont changé la vérité de Dieu en mensonge, et qui ont adoré et servi la créature au lieu du Créateur, qui est béni éternellement. Amen! +1.26 +C'est pourquoi Dieu les a livrés à des passions infâmes: car leurs femmes ont changé l'usage naturel en celui qui est contre nature; +1.27 +et de même les hommes, abandonnant l'usage naturel de la femme, se sont enflammés dans leurs désirs les uns pour les autres, commettant homme avec homme des choses infâmes, et recevant en eux-mêmes le salaire que méritait leur égarement. +1.28 +Comme ils ne se sont pas souciés de connaître Dieu, Dieu les a livrés à leur sens réprouvé, pour commettre des choses indignes, +1.29 +étant remplis de toute espèce d'injustice, de méchanceté, de cupidité, de malice; pleins d'envie, de meurtre, de querelle, de ruse, de malignité; +1.30 +rapporteurs, médisants, impies, arrogants, hautains, fanfarons, ingénieux au mal, rebelles à leurs parents, dépourvus d'intelligence, +1.31 +de loyauté, d'affection naturelle, de miséricorde. +1.32 +Et, bien qu'ils connaissent le jugement de Dieu, déclarant dignes de mort ceux qui commettent de telles choses, non seulement ils les font, mais ils approuvent ceux qui les font. + +Romains 2 + +2.1 +O homme, qui que tu sois, toi qui juges, tu es donc inexcusable; car, en jugeant les autres, tu te condamnes toi-même, puisque toi qui juges, tu fais les mêmes choses. +2.2 +Nous savons, en effet, que le jugement de Dieu contre ceux qui commettent de telles choses est selon la vérité. +2.3 +Et penses-tu, ô homme, qui juges ceux qui commettent de telles choses, et qui les fais, que tu échapperas au jugement de Dieu? +2.4 +Ou méprises-tu les richesses de sa bonté, de sa patience et de sa longanimité, ne reconnaissant pas que la bonté de Dieu te pousse à la repentance? +2.5 +Mais, par ton endurcissement et par ton coeur impénitent, tu t'amasses un trésor de colère pour le jour de la colère et de la manifestation du juste jugement de Dieu, +2.6 +qui rendra à chacun selon ses oeuvres; +2.7 +réservant la vie éternelle à ceux qui, par la persévérance à bien faire, cherchent l'honneur, la gloire et l'immortalité; +2.8 +mais l'irritation et la colère à ceux qui, par esprit de dispute, sont rebelles à la vérité et obéissent à l'injustice. +2.9 +Tribulation et angoisse sur toute âme d'homme qui fait le mal, sur le Juif premièrement, puis sur le Grec! +2.10 +Gloire, honneur et paix pour quiconque fait le bien, pour le Juif premièrement, puis pour le Grec! +2.11 +Car devant Dieu il n'y a point d'acception de personnes. +2.12 +Tous ceux qui ont péché sans la loi périront aussi sans la loi, et tous ceux qui ont péché avec la loi seront jugés par la loi. +2.13 +Ce ne sont pas, en effet, ceux qui écoutent la loi qui sont justes devant Dieu, mais ce sont ceux qui la mettent en pratique qui seront justifiés. +2.14 +Quand les païens, qui n'ont point la loi, font naturellement ce que prescrit la loi, ils sont, eux qui n'ont point la loi, une loi pour eux-mêmes; +2.15 +ils montrent que l'oeuvre de la loi est écrite dans leurs coeurs, leur conscience en rendant témoignage, et leurs pensées s'accusant ou se défendant tour à tour. +2.16 +C'est ce qui paraîtra au jour où, selon mon Évangile, Dieu jugera par Jésus Christ les actions secrètes des hommes. +2.17 +Toi qui te donnes le nom de Juif, qui te reposes sur la loi, qui te glorifies de Dieu, +2.18 +qui connais sa volonté, qui apprécies la différence des choses, étant instruit par la loi; +2.19 +toi qui te flattes d'être le conducteur des aveugles, la lumière de ceux qui sont dans les ténèbres, +2.20 +le docteur des insensés, le maître des ignorants, parce que tu as dans la loi la règle de la science et de la vérité; +2.21 +toi donc, qui enseignes les autres, tu ne t'enseignes pas toi-même! Toi qui prêches de ne pas dérober, tu dérobes! +2.22 +Toi qui dis de ne pas commettre d'adultère, tu commets l'adultère! Toi qui as en abomination les idoles, tu commets des sacrilèges! +2.23 +Toi qui te fais une gloire de la loi, tu déshonores Dieu par la transgression de la loi! +2.24 +Car le nom de Dieu est à cause de vous blasphémé parmi les païens, comme cela est écrit. +2.25 +La circoncision est utile, si tu mets en pratique la loi; mais si tu transgresses la loi, ta circoncision devient incirconcision. +2.26 +Si donc l'incirconcis observe les ordonnances de la loi, son incirconcision ne sera-t-elle pas tenue pour circoncision? +2.27 +L'incirconcis de nature, qui accomplit la loi, ne te condamnera-t-il pas, toi qui la transgresses, tout en ayant la lettre de la loi et la circoncision? +2.28 +Le Juif, ce n'est pas celui qui en a les dehors; et la circoncision, ce n'est pas celle qui est visible dans la chair. +2.29 +Mais le Juif, c'est celui qui l'est intérieurement; et la circoncision, c'est celle du coeur, selon l'esprit et non selon la lettre. La louange de ce Juif ne vient pas des hommes, mais de Dieu. + +Romains 3 + +3.1 +Quel est donc l'avantage des Juifs, ou quelle est l'utilité de la circoncision? +3.2 +Il est grand de toute manière, et tout d'abord en ce que les oracles de Dieu leur ont été confiés. +3.3 +Eh quoi! si quelques-uns n'ont pas cru, leur incrédulité anéantira-t-elle la fidélité de Dieu? +3.4 +Loin de là! Que Dieu, au contraire, soit reconnu pour vrai, et tout homme pour menteur, selon qu'il est écrit: Afin que tu sois trouvé juste dans tes paroles, Et que tu triomphes lorsqu'on te juge. +3.5 +Mais si notre injustice établit la justice de Dieu, que dirons-nous? Dieu est-il injuste quand il déchaîne sa colère? (Je parle à la manière des hommes.) +3.6 +Loin de là! Autrement, comment Dieu jugerait-il le monde? +3.7 +Et si, par mon mensonge, la vérité de Dieu éclate davantage pour sa gloire, pourquoi suis-je moi-même encore jugé comme pécheur? +3.8 +Et pourquoi ne ferions-nous pas le mal afin qu'il en arrive du bien, comme quelques-uns, qui nous calomnient, prétendent que nous le disons? La condamnation de ces gens est juste. +3.9 +Quoi donc! sommes-nous plus excellents? Nullement. Car nous avons déjà prouvé que tous, Juifs et Grecs, sont sous l'empire du péché, +3.10 +selon qu'il est écrit: Il n'y a point de juste, Pas même un seul; +3.11 +Nul n'est intelligent, Nul ne cherche Dieu; Tous sont égarés, tous sont pervertis; +3.12 +Il n'en est aucun qui fasse le bien, Pas même un seul; +3.13 +Leur gosier est un sépulcre ouvert; Ils se servent de leurs langues pour tromper; Ils ont sous leurs lèvres un venin d'aspic; +3.14 +Leur bouche est pleine de malédiction et d'amertume; +3.15 +Ils ont les pieds légers pour répandre le sang; +3.16 +La destruction et le malheur sont sur leur route; +3.17 +Ils ne connaissent pas le chemin de la paix; +3.18 +La crainte de Dieu n'est pas devant leurs yeux. +3.19 +Or, nous savons que tout ce que dit la loi, elle le dit à ceux qui sont sous la loi, afin que toute bouche soit fermée, et que tout le monde soit reconnu coupable devant Dieu. +3.20 +Car nul ne sera justifié devant lui par les oeuvres de la loi, puisque c'est par la loi que vient la connaissance du péché. +3.21 +Mais maintenant, sans la loi est manifestée la justice de Dieu, à laquelle rendent témoignage la loi et les prophètes, +3.22 +justice de Dieu par la foi en Jésus Christ pour tous ceux qui croient. Il n'y a point de distinction. +3.23 +Car tous ont péché et sont privés de la gloire de Dieu; +3.24 +et ils sont gratuitement justifiés par sa grâce, par le moyen de la rédemption qui est en Jésus Christ. +3.25 +C'est lui que Dieu a destiné, par son sang, à être, pour ceux qui croiraient victime propitiatoire, afin de montrer sa justice, parce qu'il avait laissé impunis les péchés commis auparavant, au temps de sa patience, afin, dis-je, +3.26 +de montrer sa justice dans le temps présent, de manière à être juste tout en justifiant celui qui a la foi en Jésus. +3.27 +Où donc est le sujet de se glorifier? Il est exclu. Par quelle loi? Par la loi des oeuvres? Non, mais par la loi de la foi. +3.28 +Car nous pensons que l'homme est justifié par la foi, sans les oeuvres de la loi. +3.29 +Ou bien Dieu est-il seulement le Dieu des Juifs? Ne l'est-il pas aussi des païens? Oui, il l'est aussi des païens, +3.30 +puisqu'il y a un seul Dieu, qui justifiera par la foi les circoncis, et par la foi les incirconcis. +3.31 +Anéantissons-nous donc la loi par la foi? Loin de là! Au contraire, nous confirmons la loi. + +Romains 4 + +4.1 +Que dirons-nous donc qu'Abraham, notre père, a obtenu selon la chair? +4.2 +Si Abraham a été justifié par les oeuvres, il a sujet de se glorifier, mais non devant Dieu. +4.3 +Car que dit l'Écriture? Abraham crut à Dieu, et cela lui fut imputé à justice. +4.4 +Or, à celui qui fait une oeuvre, le salaire est imputé, non comme une grâce, mais comme une chose due; +4.5 +et à celui qui ne fait point d'oeuvre, mais qui croit en celui qui justifie l'impie, sa foi lui est imputée à justice. +4.6 +De même David exprime le bonheur de l'homme à qui Dieu impute la justice sans les oeuvres: +4.7 +Heureux ceux dont les iniquités sont pardonnées, Et dont les péchés sont couverts! +4.8 +Heureux l'homme à qui le Seigneur n'impute pas son péché! +4.9 +Ce bonheur n'est-il que pour les circoncis, ou est-il également pour les incirconcis? Car nous disons que la foi fut imputée à justice à Abraham. +4.10 +Comment donc lui fut-elle imputée? Était-ce après, ou avant sa circoncision? Il n'était pas encore circoncis, il était incirconcis. +4.11 +Et il reçut le signe de la circoncision, comme sceau de la justice qu'il avait obtenue par la foi quand il était incirconcis, afin d'être le père de tous les incirconcis qui croient, pour que la justice leur fût aussi imputée, +4.12 +et le père des circoncis, qui ne sont pas seulement circoncis, mais encore qui marchent sur les traces de la foi de notre père Abraham quand il était incirconcis. +4.13 +En effet, ce n'est pas par la loi que l'héritage du monde a été promis à Abraham ou à sa postérité, c'est par la justice de la foi. +4.14 +Car, si les héritiers le sont par la loi, la foi est vaine, et la promesse est anéantie, +4.15 +parce que la loi produit la colère, et que là où il n'y a point de loi il n'y a point non plus de transgression. +4.16 +C'est pourquoi les héritiers le sont par la foi, pour que ce soit par grâce, afin que la promesse soit assurée à toute la postérité, non seulement à celle qui est sous la loi, mais aussi à celle qui a la foi d'Abraham, notre père à tous, selon qu'il est écrit: +4.17 +Je t'ai établi père d'un grand nombre de nations. Il est notre père devant celui auquel il a cru, Dieu, qui donne la vie aux morts, et qui appelle les choses qui ne sont point comme si elles étaient. +4.18 +Espérant contre toute espérance, il crut, en sorte qu'il devint père d'un grand nombre de nations, selon ce qui lui avait été dit: Telle sera ta postérité. +4.19 +Et, sans faiblir dans la foi, il ne considéra point que son corps était déjà usé, puisqu'il avait près de cent ans, et que Sara n'était plus en état d'avoir des enfants. +4.20 +Il ne douta point, par incrédulité, au sujet de la promesse de Dieu; mais il fut fortifié par la foi, donnant gloire à Dieu, +4.21 +et ayant la pleine conviction que ce qu'il promet il peut aussi l'accomplir. +4.22 +C'est pourquoi cela lui fut imputé à justice. +4.23 +Mais ce n'est pas à cause de lui seul qu'il est écrit que cela lui fut imputé; +4.24 +c'est encore à cause de nous, à qui cela sera imputé, à nous qui croyons en celui qui a ressuscité des morts Jésus notre Seigneur, +4.25 +lequel a été livré pour nos offenses, et est ressuscité pour notre justification. + +Romains 5 + +5.1 +Étant donc justifiés par la foi, nous avons la paix avec Dieu par notre Seigneur Jésus Christ, +5.2 +à qui nous devons d'avoir eu par la foi accès à cette grâce, dans laquelle nous demeurons fermes, et nous nous glorifions dans l'espérance de la gloire de Dieu. +5.3 +Bien plus, nous nous glorifions même des afflictions, sachant que l'affliction produit la persévérance, +5.4 +la persévérance la victoire dans l'épreuve, et cette victoire l'espérance. +5.5 +Or, l'espérance ne trompe point, parce que l'amour de Dieu est répandu dans nos coeurs par le Saint Esprit qui nous a été donné. +5.6 +Car, lorsque nous étions encore sans force, Christ, au temps marqué, est mort pour des impies. +5.7 +A peine mourrait-on pour un juste; quelqu'un peut-être mourrait-il pour un homme de bien. +5.8 +Mais Dieu prouve son amour envers nous, en ce que, lorsque nous étions encore des pécheurs, Christ est mort pour nous. +5.9 +A plus forte raison donc, maintenant que nous sommes justifiés par son sang, serons-nous sauvés par lui de la colère. +5.10 +Car si, lorsque nous étions ennemis, nous avons été réconciliés avec Dieu par la mort de son Fils, à plus forte raison, étant réconciliés, serons-nous sauvés par sa vie. +5.11 +Et non seulement cela, mais encore nous nous glorifions en Dieu par notre Seigneur Jésus Christ, par qui maintenant nous avons obtenu la réconciliation. +5.12 +C'est pourquoi, comme par un seul homme le péché est entré dans le monde, et par le péché la mort, et qu'ainsi la mort s'est étendue sur tous les hommes, parce que tous ont péché,... +5.13 +car jusqu'à la loi le péché était dans le monde. Or, le péché n'est pas imputé, quand il n'y a point de loi. +5.14 +Cependant la mort a régné depuis Adam jusqu'à Moïse, même sur ceux qui n'avaient pas péché par une transgression semblable à celle d'Adam, lequel est la figure de celui qui devait venir. +5.15 +Mais il n'en est pas du don gratuit comme de l'offense; car, si par l'offense d'un seul il en est beaucoup qui sont morts, à plus forte raison la grâce de Dieu et le don de la grâce venant d'un seul homme, Jésus Christ, ont-ils été abondamment répandus sur beaucoup. +5.16 +Et il n'en est pas du don comme de ce qui est arrivé par un seul qui a péché; car c'est après une seule offense que le jugement est devenu condamnation, tandis que le don gratuit devient justification après plusieurs offenses. +5.17 +Si par l'offense d'un seul la mort a régné par lui seul, à plus forte raison ceux qui reçoivent l'abondance de la grâce et du don de la justice régneront-ils dans la vie par Jésus Christ lui seul. +5.18 +Ainsi donc, comme par une seule offense la condamnation a atteint tous les hommes, de même par un seul acte de justice la justification qui donne la vie s'étend à tous les hommes. +5.19 +Car, comme par la désobéissance d'un seul homme beaucoup ont été rendus pécheurs, de même par l'obéissance d'un seul beaucoup seront rendus justes. +5.20 +Or, la loi est intervenue pour que l'offense abondât, mais là où le péché a abondé, la grâce a surabondé, +5.21 +afin que, comme le péché a régné par la mort, ainsi la grâce régnât par la justice pour la vie éternelle, par Jésus Christ notre Seigneur. + +Romains 6 + +6.1 +Que dirons-nous donc? Demeurerions-nous dans le péché, afin que la grâce abonde? +6.2 +Loin de là! Nous qui sommes morts au péché, comment vivrions-nous encore dans le péché? +6.3 +Ignorez-vous que nous tous qui avons été baptisés en Jésus Christ, c'est en sa mort que nous avons été baptisés? +6.4 +Nous avons donc été ensevelis avec lui par le baptême en sa mort, afin que, comme Christ est ressuscité des morts par la gloire du Père, de même nous aussi nous marchions en nouveauté de vie. +6.5 +En effet, si nous sommes devenus une même plante avec lui par la conformité à sa mort, nous le serons aussi par la conformité à sa résurrection, +6.6 +sachant que notre vieil homme a été crucifié avec lui, afin que le corps du péché fût détruit, pour que nous ne soyons plus esclaves du péché; +6.7 +car celui qui est mort est libre du péché. +6.8 +Or, si nous sommes morts avec Christ, nous croyons que nous vivrons aussi avec lui, +6.9 +sachant que Christ ressuscité des morts ne meurt plus; la mort n'a plus de pouvoir sur lui. +6.10 +Car il est mort, et c'est pour le péché qu'il est mort une fois pour toutes; il est revenu à la vie, et c'est pour Dieu qu'il vit. +6.11 +Ainsi vous-mêmes, regardez-vous comme morts au péché, et comme vivants pour Dieu en Jésus Christ. +6.12 +Que le péché ne règne donc point dans votre corps mortel, et n'obéissez pas à ses convoitises. +6.13 +Ne livrez pas vos membres au péché, comme des instruments d'iniquité; mais donnez-vous vous-mêmes à Dieu, comme étant vivants de morts que vous étiez, et offrez à Dieu vos membres, comme des instruments de justice. +6.14 +Car le péché n'aura point de pouvoir sur vous, puisque vous êtes, non sous la loi, mais sous la grâce. +6.15 +Quoi donc! Pécherions-nous, parce que nous sommes, non sous la loi, mais sous la grâce? Loin de là! +6.16 +Ne savez-vous pas qu'en vous livrant à quelqu'un comme esclaves pour lui obéir, vous êtes esclaves de celui à qui vous obéissez, soit du péché qui conduit à la mort, soit de l'obéissance qui conduit à la justice? +6.17 +Mais grâces soient rendues à Dieu de ce que, après avoir été esclaves du péché, vous avez obéi de coeur à la règle de doctrine dans laquelle vous avez été instruits. +6.18 +Ayant été affranchis du péché, vous êtes devenus esclaves de la justice. - +6.19 +Je parle à la manière des hommes, à cause de la faiblesse de votre chair. -De même donc que vous avez livré vos membres comme esclaves à l'impureté et à l'iniquité, pour arriver à l'iniquité, ainsi maintenant livrez vos membres comme esclaves à la justice, pour arriver à la sainteté. +6.20 +Car, lorsque vous étiez esclaves du péché, vous étiez libres à l'égard de la justice. +6.21 +Quels fruits portiez-vous alors? Des fruits dont vous rougissez aujourd'hui. Car la fin de ces choses, c'est la mort. +6.22 +Mais maintenant, étant affranchis du péché et devenus esclaves de Dieu, vous avez pour fruit la sainteté et pour fin la vie éternelle. +6.23 +Car le salaire du péché, c'est la mort; mais le don gratuit de Dieu, c'est la vie éternelle en Jésus Christ notre Seigneur. + +Romains 7 + +7.1 +Ignorez-vous, frères, -car je parle à des gens qui connaissent la loi, -que la loi exerce son pouvoir sur l'homme aussi longtemps qu'il vit? +7.2 +Ainsi, une femme mariée est liée par la loi à son mari tant qu'il est vivant; mais si le mari meurt, elle est dégagée de la loi qui la liait à son mari. +7.3 +Si donc, du vivant de son mari, elle devient la femme d'un autre homme, elle sera appelée adultère; mais si le mari meurt, elle est affranchie de la loi, de sorte qu'elle n'est point adultère en devenant la femme d'un autre. +7.4 +De même, mes frères, vous aussi vous avez été, par le corps de Christ, mis à mort en ce qui concerne la loi, pour que vous apparteniez à un autre, à celui qui est ressuscité des morts, afin que nous portions des fruits pour Dieu. +7.5 +Car, lorsque nous étions dans la chair, les passions des péchés provoquées par la loi agissaient dans nos membres, de sorte que nous portions des fruits pour la mort. +7.6 +Mais maintenant, nous avons été dégagés de la loi, étant morts à cette loi sous laquelle nous étions retenus, de sorte que nous servons dans un esprit nouveau, et non selon la lettre qui a vieilli. +7.7 +Que dirons-nous donc? La loi est-elle péché? Loin de là! Mais je n'ai connu le péché que par la loi. Car je n'aurais pas connu la convoitise, si la loi n'eût dit: Tu ne convoiteras point. +7.8 +Et le péché, saisissant l'occasion, produisit en moi par le commandement toutes sortes de convoitises; car sans loi le péché est mort. +7.9 +Pour moi, étant autrefois sans loi, je vivais; mais quand le commandement vint, le péché reprit vie, et moi je mourus. +7.10 +Ainsi, le commandement qui conduit à la vie se trouva pour moi conduire à la mort. +7.11 +Car le péché saisissant l'occasion, me séduisit par le commandement, et par lui me fit mourir. +7.12 +La loi donc est sainte, et le commandement est saint, juste et bon. +7.13 +Ce qui est bon a-t-il donc été pour moi une cause de mort? Loin de là! Mais c'est le péché, afin qu'il se manifestât comme péché en me donnant la mort par ce qui est bon, et que, par le commandement, il devînt condamnable au plus haut point. +7.14 +Nous savons, en effet, que la loi est spirituelle; mais moi, je suis charnel, vendu au péché. +7.15 +Car je ne sais pas ce que je fais: je ne fais point ce que je veux, et je fais ce que je hais. +7.16 +Or, si je fais ce que je ne veux pas, je reconnais par là que la loi est bonne. +7.17 +Et maintenant ce n'est plus moi qui le fais, mais c'est le péché qui habite en moi. +7.18 +Ce qui est bon, je le sais, n'habite pas en moi, c'est-à-dire dans ma chair: j'ai la volonté, mais non le pouvoir de faire le bien. +7.19 +Car je ne fais pas le bien que je veux, et je fais le mal que je ne veux pas. +7.20 +Et si je fais ce que je ne veux pas, ce n'est plus moi qui le fais, c'est le péché qui habite en moi. +7.21 +Je trouve donc en moi cette loi: quand je veux faire le bien, le mal est attaché à moi. +7.22 +Car je prends plaisir à la loi de Dieu, selon l'homme intérieur; +7.23 +mais je vois dans mes membres une autre loi, qui lutte contre la loi de mon entendement, et qui me rend captif de la loi du péché, qui est dans mes membres. +7.24 +Misérable que je suis! Qui me délivrera du corps de cette mort?... +7.25 +Grâces soient rendues à Dieu par Jésus Christ notre Seigneur!... Ainsi donc, moi-même, je suis par l'entendement esclave de la loi de Dieu, et je suis par la chair esclave de la loi du péché. + +Romains 8 + +8.1 +Il n'y a donc maintenant aucune condamnation pour ceux qui sont en Jésus Christ. +8.2 +En effet, la loi de l'esprit de vie en Jésus Christ m'a affranchi de la loi du péché et de la mort. +8.3 +Car-chose impossible à la loi, parce que la chair la rendait sans force, -Dieu a condamné le péché dans la chair, en envoyant, à cause du péché, son propre Fils dans une chair semblable à celle du péché, +8.4 +et cela afin que la justice de la loi fût accomplie en nous, qui marchons, non selon la chair, mais selon l'esprit. +8.5 +Ceux, en effet, qui vivent selon la chair, s'affectionnent aux choses de la chair, tandis que ceux qui vivent selon l'esprit s'affectionnent aux choses de l'esprit. +8.6 +Et l'affection de la chair, c'est la mort, tandis que l'affection de l'esprit, c'est la vie et la paix; +8.7 +car l'affection de la chair est inimitié contre Dieu, parce qu'elle ne se soumet pas à la loi de Dieu, et qu'elle ne le peut même pas. +8.8 +Or ceux qui vivent selon la chair ne sauraient plaire à Dieu. +8.9 +Pour vous, vous ne vivez pas selon la chair, mais selon l'esprit, si du moins l'Esprit de Dieu habite en vous. Si quelqu'un n'a pas l'Esprit de Christ, il ne lui appartient pas. +8.10 +Et si Christ est en vous, le corps, il est vrai, est mort à cause du péché, mais l'esprit est vie à cause de la justice. +8.11 +Et si l'Esprit de celui qui a ressuscité Jésus d'entre les morts habite en vous, celui qui a ressuscité Christ d'entre les morts rendra aussi la vie à vos corps mortels par son Esprit qui habite en vous. +8.12 +Ainsi donc, frères, nous ne sommes point redevables à la chair, pour vivre selon la chair. +8.13 +Si vous vivez selon la chair, vous mourrez; mais si par l'Esprit vous faites mourir les actions du corps, vous vivrez, +8.14 +car tous ceux qui sont conduits par l'Esprit de Dieu sont fils de Dieu. +8.15 +Et vous n'avez point reçu un esprit de servitude, pour être encore dans la crainte; mais vous avez reçu un Esprit d'adoption, par lequel nous crions: Abba! Père! +8.16 +L'Esprit lui-même rend témoignage à notre esprit que nous sommes enfants de Dieu. +8.17 +Or, si nous sommes enfants, nous sommes aussi héritiers: héritiers de Dieu, et cohéritiers de Christ, si toutefois nous souffrons avec lui, afin d'être glorifiés avec lui. +8.18 +J'estime que les souffrances du temps présent ne sauraient être comparées à la gloire à venir qui sera révélée pour nous. +8.19 +Aussi la création attend-elle avec un ardent désir la révélation des fils de Dieu. +8.20 +Car la création a été soumise à la vanité, -non de son gré, mais à cause de celui qui l'y a soumise, - +8.21 +avec l'espérance qu'elle aussi sera affranchie de la servitude de la corruption, pour avoir part à la liberté de la gloire des enfants de Dieu. +8.22 +Or, nous savons que, jusqu'à ce jour, la création tout entière soupire et souffre les douleurs de l'enfantement. +8.23 +Et ce n'est pas elle seulement; mais nous aussi, qui avons les prémices de l'Esprit, nous aussi nous soupirons en nous-mêmes, en attendant l'adoption, la rédemption de notre corps. +8.24 +Car c'est en espérance que nous sommes sauvés. Or, l'espérance qu'on voit n'est plus espérance: ce qu'on voit, peut-on l'espérer encore? +8.25 +Mais si nous espérons ce que nous ne voyons pas, nous l'attendons avec persévérance. +8.26 +De même aussi l'Esprit nous aide dans notre faiblesse, car nous ne savons pas ce qu'il nous convient de demander dans nos prières. Mais l'Esprit lui-même intercède par des soupirs inexprimables; +8.27 +et celui qui sonde les coeurs connaît quelle est la pensée de l'Esprit, parce que c'est selon Dieu qu'il intercède en faveur des saints. +8.28 +Nous savons, du reste, que toutes choses concourent au bien de ceux qui aiment Dieu, de ceux qui sont appelés selon son dessein. +8.29 +Car ceux qu'il a connus d'avance, il les a aussi prédestinés à être semblables à l'image de son Fils, afin que son Fils fût le premier-né entre plusieurs frères. +8.30 +Et ceux qu'il a prédestinés, il les a aussi appelés; et ceux qu'il a appelés, il les a aussi justifiés; et ceux qu'il a justifiés, il les a aussi glorifiés. +8.31 +Que dirons-nous donc à l'égard de ces choses? Si Dieu est pour nous, qui sera contre nous? +8.32 +Lui, qui n'a point épargné son propre Fils, mais qui l'a livré pour nous tous, comment ne nous donnera-t-il pas aussi toutes choses avec lui? +8.33 +Qui accusera les élus de Dieu? C'est Dieu qui justifie! +8.34 +Qui les condamnera? Christ est mort; bien plus, il est ressuscité, il est à la droite de Dieu, et il intercède pour nous! +8.35 +Qui nous séparera de l'amour de Christ? Sera-ce la tribulation, ou l'angoisse, ou la persécution, ou la faim, ou la nudité, ou le péril, ou l'épée? +8.36 +selon qu'il est écrit: C'est à cause de toi qu'on nous met à mort tout le jour, Qu'on nous regarde comme des brebis destinées à la boucherie. +8.37 +Mais dans toutes ces choses nous sommes plus que vainqueurs par celui qui nous a aimés. +8.38 +Car j'ai l'assurance que ni la mort ni la vie, ni les anges ni les dominations, ni les choses présentes ni les choses à venir, +8.39 +ni les puissances, ni la hauteur, ni la profondeur, ni aucune autre créature ne pourra nous séparer de l'amour de Dieu manifesté en Jésus Christ notre Seigneur. + +Romains 9 + +9.1 +Je dis la vérité en Christ, je ne mens point, ma conscience m'en rend témoignage par le Saint Esprit: +9.2 +J'éprouve une grande tristesse, et j'ai dans le coeur un chagrin continuel. +9.3 +Car je voudrais moi-même être anathème et séparé de Christ pour mes frères, mes parents selon la chair, +9.4 +qui sont Israélites, à qui appartiennent l'adoption, et la gloire, et les alliances, et la loi, et le culte, +9.5 +et les promesses, et les patriarches, et de qui est issu, selon la chair, le Christ, qui est au-dessus de toutes choses, Dieu béni éternellement. Amen! +9.6 +Ce n'est point à dire que la parole de Dieu soit restée sans effet. Car tous ceux qui descendent d'Israël ne sont pas Israël, +9.7 +et, pour être la postérité d'Abraham, ils ne sont pas tous ses enfants; mais il est dit: En Isaac sera nommée pour toi une postérité, +9.8 +c'est-à-dire que ce ne sont pas les enfants de la chair qui sont enfants de Dieu, mais que ce sont les enfants de la promesse qui sont regardés comme la postérité. +9.9 +Voici, en effet, la parole de la promesse: Je reviendrai à cette même époque, et Sara aura un fils. +9.10 +Et, de plus, il en fut ainsi de Rébecca, qui conçut du seul Isaac notre père; +9.11 +car, quoique les enfants ne fussent pas encore nés et ils n'eussent fait ni bien ni mal, -afin que le dessein d'élection de Dieu subsistât, sans dépendre des oeuvres, et par la seule volonté de celui qui appelle, - +9.12 +il fut dit à Rébecca: L'aîné sera assujetti au plus jeune; selon qu'il est écrit: +9.13 +J'ai aimé Jacob Et j'ai haï Ésaü. +9.14 +Que dirons-nous donc? Y a-t-il en Dieu de l'injustice? Loin de là! +9.15 +Car il dit à Moïse: Je ferai miséricorde à qui je fais miséricorde, et j'aurai compassion de qui j'ai compassion. +9.16 +Ainsi donc, cela ne dépend ni de celui qui veut, ni de celui qui court, mais de Dieu qui fait miséricorde. +9.17 +Car l'Écriture dit à Pharaon: Je t'ai suscité à dessein pour montrer en toi ma puissance, et afin que mon nom soit publié par toute la terre. +9.18 +Ainsi, il fait miséricorde à qui il veut, et il endurcit qui il veut. +9.19 +Tu me diras: Pourquoi blâme-t-il encore? Car qui est-ce qui résiste à sa volonté? +9.20 +O homme, toi plutôt, qui es-tu pour contester avec Dieu? Le vase d'argile dira-t-il à celui qui l'a formé: Pourquoi m'as-tu fait ainsi? +9.21 +Le potier n'est-il pas maître de l'argile, pour faire avec la même masse un vase d'honneur et un vase d'un usage vil? +9.22 +Et que dire, si Dieu, voulant montrer sa colère et faire connaître sa puissance, a supporté avec une grande patience des vases de colère formés pour la perdition, +9.23 +et s'il a voulu faire connaître la richesse de sa gloire envers des vases de miséricorde qu'il a d'avance préparés pour la gloire? +9.24 +Ainsi nous a-t-il appelés, non seulement d'entre les Juifs, mais encore d'entre les païens, +9.25 +selon qu'il le dit dans Osée: J'appellerai mon peuple celui qui n'était pas mon peuple, et bien-aimée celle qui n'était pas la bien-aimée; +9.26 +et là où on leur disait: Vous n'êtes pas mon peuple! ils seront appelés fils du Dieu vivant. +9.27 +Ésaïe, de son côté, s'écrie au sujet d'Israël: Quand le nombre des fils d'Israël serait comme le sable de la mer, Un reste seulement sera sauvé. +9.28 +Car le Seigneur exécutera pleinement et promptement sur la terre ce qu'il a résolu. +9.29 +Et, comme Ésaïe l'avait dit auparavant: Si le Seigneur des armées Ne nous eût laissé une postérité, Nous serions devenus comme Sodome, Nous aurions été semblables à Gomorrhe. +9.30 +Que dirons-nous donc? Les païens, qui ne cherchaient pas la justice, ont obtenu la justice, la justice qui vient de la foi, +9.31 +tandis qu'Israël, qui cherchait une loi de justice, n'est pas parvenu à cette loi. +9.32 +Pourquoi? Parce qu'Israël l'a cherchée, non par la foi, mais comme provenant des oeuvres. Ils se sont heurtés contre la pierre d'achoppement, +9.33 +selon qu'il est écrit: Voici, je mets en Sion une pierre d'achoppement Et un rocher de scandale, Et celui qui croit en lui ne sera point confus. + +Romains 10 + +10.1 +Frères, le voeu de mon coeur et ma prière à Dieu pour eux, c'est qu'ils soient sauvés. +10.2 +Je leur rends le témoignage qu'ils ont du zèle pour Dieu, mais sans intelligence: +10.3 +ne connaissant pas la justice de Dieu, et cherchant à établir leur propre justice, ils ne se sont pas soumis à la justice de Dieu; +10.4 +car Christ est la fin de la loi, pour la justification de tous ceux qui croient. +10.5 +En effet, Moïse définit ainsi la justice qui vient de la loi: L'homme qui mettra ces choses en pratique vivra par elles. +10.6 +Mais voici comment parle la justice qui vient de la foi: Ne dis pas en ton coeur: Qui montera au ciel? c'est en faire descendre Christ; +10.7 +ou: Qui descendra dans l'abîme? c'est faire remonter Christ d'entre les morts. +10.8 +Que dit-elle donc? La parole est près de toi, dans ta bouche et dans ton coeur. Or, c'est la parole de la foi, que nous prêchons. +10.9 +Si tu confesses de ta bouche le Seigneur Jésus, et si tu crois dans ton coeur que Dieu l'a ressuscité des morts, tu seras sauvé. +10.10 +Car c'est en croyant du coeur qu'on parvient à la justice, et c'est en confessant de la bouche qu'on parvient au salut, selon ce que dit l'Écriture: +10.11 +Quiconque croit en lui ne sera point confus. +10.12 +Il n'y a aucune différence, en effet, entre le Juif et le Grec, puisqu'ils ont tous un même Seigneur, qui est riche pour tous ceux qui l'invoquent. +10.13 +Car quiconque invoquera le nom du Seigneur sera sauvé. +10.14 +Comment donc invoqueront-ils celui en qui ils n'ont pas cru? Et comment croiront-ils en celui dont ils n'ont pas entendu parler? Et comment en entendront-ils parler, s'il n'y a personne qui prêche? +10.15 +Et comment y aura-t-il des prédicateurs, s'ils ne sont pas envoyés? selon qu'il est écrit: Qu'ils sont beaux Les pieds de ceux qui annoncent la paix, De ceux qui annoncent de bonnes nouvelles! +10.16 +Mais tous n'ont pas obéi à la bonne nouvelle. Aussi Ésaïe dit-il: Seigneur, Qui a cru à notre prédication? +10.17 +Ainsi la foi vient de ce qu'on entend, et ce qu'on entend vient de la parole de Christ. +10.18 +Mais je dis: N'ont-ils pas entendu? Au contraire! Leur voix est allée par toute la terre, Et leurs paroles jusqu'aux extrémités du monde. +10.19 +Mais je dis: Israël ne l'a-t-il pas su? Moïse le premier dit: J'exciterai votre jalousie par ce qui n'est point une nation, je provoquerai votre colère par une nation sans intelligence. +10.20 +Et Ésaïe pousse la hardiesse jusqu'à dire: J'ai été trouvé par ceux qui ne me cherchaient pas, Je me suis manifesté à ceux qui ne me demandaient pas. +10.21 +Mais au sujet d'Israël, il dit: J'ai tendu mes mains tout le jour vers un peuple rebelle Et contredisant. + +Romains 11 + +11.1 +Je dis donc: Dieu a-t-il rejeté son peuple? Loin de là! Car moi aussi je suis Israélite, de la postérité d'Abraham, de la tribu de Benjamin. +11.2 +Dieu n'a point rejeté son peuple, qu'il a connu d'avance. Ne savez-vous pas ce que l'Écriture rapporte d'Élie, comment il adresse à Dieu cette plainte contre Israël: +11.3 +Seigneur, ils ont tué tes prophètes, ils ont renversé tes autels; je suis resté moi seul, et ils cherchent à m'ôter la vie? +11.4 +Mais quelle réponse Dieu lui fait-il? Je me suis réservé sept mille hommes, qui n'ont point fléchi le genou devant Baal. +11.5 +De même aussi dans le temps présent il y un reste, selon l'élection de la grâce. +11.6 +Or, si c'est par grâce, ce n'est plus par les oeuvres; autrement la grâce n'est plus une grâce. Et si c'est par les oeuvres, ce n'est plus une grâce; autrement l'oeuvre n'est plus une oeuvre. +11.7 +Quoi donc? Ce qu'Israël cherche, il ne l'a pas obtenu, mais l'élection l'a obtenu, tandis que les autres ont été endurcis, +11.8 +selon qu'il est écrit: Dieu leur a donné un esprit d'assoupissement, Des yeux pour ne point voir, Et des oreilles pour ne point entendre, Jusqu'à ce jour. Et David dit: +11.9 +Que leur table soit pour eux un piège, Un filet, une occasion de chute, et une rétribution! +11.10 +Que leurs yeux soient obscurcis pour ne point voir, Et tiens leur dos continuellement courbé! +11.11 +Je dis donc: Est-ce pour tomber qu'ils ont bronché? Loin de là! Mais, par leur chute, le salut est devenu accessible aux païens, afin qu'ils fussent excités à la jalousie. +11.12 +Or, si leur chute a été la richesse du monde, et leur amoindrissement la richesse des païens, combien plus en sera-t-il ainsi quand ils se convertiront tous. +11.13 +Je vous le dis à vous, païens: en tant que je suis apôtre des païens, je glorifie mon ministère, +11.14 +afin, s'il est possible, d'exciter la jalousie de ceux de ma race, et d'en sauver quelques-uns. +11.15 +Car si leur rejet a été la réconciliation du monde, que sera leur réintégration, sinon une vie d'entre les morts? +11.16 +Or, si les prémices sont saintes, la masse l'est aussi; et si la racine est sainte, les branches le sont aussi. +11.17 +Mais si quelques-unes des branches ont été retranchées, et si toi, qui était un olivier sauvage, tu as été enté à leur place, et rendu participant de la racine et de la graisse de l'olivier, +11.18 +ne te glorifie pas aux dépens de ces branches. Si tu te glorifies, sache que ce n'est pas toi qui portes la racine, mais que c'est la racine qui te porte. +11.19 +Tu diras donc: Les branches ont été retranchées, afin que moi je fusse enté. +11.20 +Cela est vrai; elles ont été retranchées pour cause d'incrédulité, et toi, tu subsistes par la foi. Ne t'abandonne pas à l'orgueil, mais crains; +11.21 +car si Dieu n'a pas épargné les branches naturelles, il ne t'épargnera pas non plus. +11.22 +Considère donc la bonté et la sévérité de Dieu: sévérité envers ceux qui sont tombés, et bonté de Dieu envers toi, si tu demeures ferme dans cette bonté; autrement, tu seras aussi retranché. +11.23 +Eux de même, s'ils ne persistent pas dans l'incrédulité, ils seront entés; car Dieu est puissant pour les enter de nouveau. +11.24 +Si toi, tu as été coupé de l'olivier naturellement sauvage, et enté contrairement à ta nature sur l'olivier franc, à plus forte raison eux seront-ils entés selon leur nature sur leur propre olivier. +11.25 +Car je ne veux pas, frères, que vous ignoriez ce mystère, afin que vous ne vous regardiez point comme sages, c'est qu'une partie d'Israël est tombée dans l'endurcissement, jusqu'à ce que la totalité des païens soit entrée. +11.26 +Et ainsi tout Israël sera sauvé, selon qu'il est écrit: Le libérateur viendra de Sion, Et il détournera de Jacob les impiétés; +11.27 +Et ce sera mon alliance avec eux, Lorsque j'ôterai leurs péchés. +11.28 +En ce qui concerne l'Évangile, ils sont ennemis à cause de vous; mais en ce qui concerne l'élection, ils sont aimés à cause de leurs pères. +11.29 +Car Dieu ne se repent pas de ses dons et de son appel. +11.30 +De même que vous avez autrefois désobéi à Dieu et que par leur désobéissance vous avez maintenant obtenu miséricorde, +11.31 +de même ils ont maintenant désobéi, afin que, par la miséricorde qui vous a été faite, ils obtiennent aussi miséricorde. +11.32 +Car Dieu a renfermé tous les hommes dans la désobéissance, pour faire miséricorde à tous. +11.33 +O profondeur de la richesse, de la sagesse et de la science de Dieu! Que ses jugements sont insondables, et ses voies incompréhensibles! Car +11.34 +Qui a connu la pensée du Seigneur, Ou qui a été son conseiller? +11.35 +Qui lui a donné le premier, pour qu'il ait à recevoir en retour? +11.36 +C'est de lui, par lui, et pour lui que sont toutes choses. A lui la gloire dans tous les siècles! Amen! + +Romains 12 + +12.1 +Je vous exhorte donc, frères, par les compassions de Dieu, à offrir vos corps comme un sacrifice vivant, saint, agréable à Dieu, ce qui sera de votre part un culte raisonnable. +12.2 +Ne vous conformez pas au siècle présent, mais soyez transformés par le renouvellement de l'intelligence, afin que vous discerniez quelle est la volonté de Dieu, ce qui est bon, agréable et parfait. +12.3 +Par la grâce qui m'a été donnée, je dis à chacun de vous de n'avoir pas de lui-même une trop haute opinion, mais de revêtir des sentiments modestes, selon la mesure de foi que Dieu a départie à chacun. +12.4 +Car, comme nous avons plusieurs membres dans un seul corps, et que tous les membres n'ont pas la même fonction, +12.5 +ainsi, nous qui sommes plusieurs, nous formons un seul corps en Christ, et nous sommes tous membres les uns des autres. +12.6 +Puisque nous avons des dons différents, selon la grâce qui nous a été accordée, que celui qui a le don de prophétie l'exerce selon l'analogie de la foi; +12.7 +que celui qui est appelé au ministère s'attache à son ministère; que celui qui enseigne s'attache à son enseignement, +12.8 +et celui qui exhorte à l'exhortation. Que celui qui donne le fasse avec libéralité; que celui qui préside le fasse avec zèle; que celui qui pratique la miséricorde le fasse avec joie. +12.9 +Que la charité soit sans hypocrisie. Ayez le mal en horreur; attachez-vous fortement au bien. +12.10 +Par amour fraternel, soyez pleins d'affection les uns pour les autres; par honneur, usez de prévenances réciproques. +12.11 +Ayez du zèle, et non de la paresse. Soyez fervents d'esprit. Servez le Seigneur. +12.12 +Réjouissez-vous en espérance. Soyez patients dans l'affliction. Persévérez dans la prière. +12.13 +Pourvoyez aux besoins des saints. Exercez l'hospitalité. +12.14 +Bénissez ceux qui vous persécutent, bénissez et ne maudissez pas. +12.15 +Réjouissez-vous avec ceux qui se réjouissent; pleurez avec ceux qui pleurent. +12.16 +Ayez les mêmes sentiments les uns envers les autres. N'aspirez pas à ce qui est élevé, mais laissez-vous attirer par ce qui est humble. Ne soyez point sages à vos propres yeux. +12.17 +Ne rendez à personne le mal pour le mal. Recherchez ce qui est bien devant tous les hommes. +12.18 +S'il est possible, autant que cela dépend de vous, soyez en paix avec tous les hommes. +12.19 +Ne vous vengez point vous-mêmes, bien-aimés, mais laissez agir la colère; car il est écrit: A moi la vengeance, à moi la rétribution, dit le Seigneur. +12.20 +Mais si ton ennemi a faim, donne-lui à manger; s'il a soif, donne-lui à boire; car en agissant ainsi, ce sont des charbons ardents que tu amasseras sur sa tête. +12.21 +Ne te laisse pas vaincre par le mal, mais surmonte le mal par le bien. + +Romains 13 + +13.1 +Que toute personne soit soumise aux autorités supérieures; car il n'y a point d'autorité qui ne vienne de Dieu, et les autorités qui existent ont été instituées de Dieu. +13.2 +C'est pourquoi celui qui s'oppose à l'autorité résiste à l'ordre que Dieu a établi, et ceux qui résistent attireront une condamnation sur eux-mêmes. +13.3 +Ce n'est pas pour une bonne action, c'est pour une mauvaise, que les magistrats sont à redouter. Veux-tu ne pas craindre l'autorité? Fais-le bien, et tu auras son approbation. +13.4 +Le magistrat est serviteur de Dieu pour ton bien. Mais si tu fais le mal, crains; car ce n'est pas en vain qu'il porte l'épée, étant serviteur de Dieu pour exercer la vengeance et punir celui qui fait le mal. +13.5 +Il est donc nécessaire d'être soumis, non seulement par crainte de la punition, mais encore par motif de conscience. +13.6 +C'est aussi pour cela que vous payez les impôts. Car les magistrats sont des ministres de Dieu entièrement appliqués à cette fonction. +13.7 +Rendez à tous ce qui leur est dû: l'impôt à qui vous devez l'impôt, le tribut à qui vous devez le tribut, la crainte à qui vous devez la crainte, l'honneur à qui vous devez l'honneur. +13.8 +Ne devez rien à personne, si ce n'est de vous aimer les uns les autres; car celui qui aime les autres a accompli la loi. +13.9 +En effet, les commandements: Tu ne commettras point d'adultère, tu ne tueras point, tu ne déroberas point, tu ne convoiteras point, et ceux qu'il peut encore y avoir, se résument dans cette parole: Tu aimeras ton prochain comme toi-même. +13.10 +L'amour ne fait point de mal au prochain: l'amour est donc l'accomplissement de la loi. +13.11 +Cela importe d'autant plus que vous savez en quel temps nous sommes: c'est l'heure de vous réveiller enfin du sommeil, car maintenant le salut est plus près de nous que lorsque nous avons cru. +13.12 +La nuit est avancée, le jour approche. Dépouillons-nous donc des oeuvres des ténèbres, et revêtons les armes de la lumière. +13.13 +Marchons honnêtement, comme en plein jour, loin des excès et de l'ivrognerie, de la luxure et de l'impudicité, des querelles et des jalousies. +13.14 +Mais revêtez-vous du Seigneur Jésus Christ, et n'ayez pas soin de la chair pour en satisfaire les convoitises. + +Romains 14 + +14.1 +Faites accueil à celui qui est faible dans la foi, et ne discutez pas sur les opinions. +14.2 +Tel croit pouvoir manger de tout: tel autre, qui est faible, ne mange que des légumes. +14.3 +Que celui qui mange ne méprise point celui qui ne mange pas, et que celui qui ne mange pas ne juge point celui qui mange, car Dieu l'a accueilli. +14.4 +Qui es-tu, toi qui juges un serviteur d'autrui? S'il se tient debout, ou s'il tombe, cela regarde son maître. Mais il se tiendra debout, car le Seigneur a le pouvoir de l'affermir. +14.5 +Tel fait une distinction entre les jours; tel autre les estime tous égaux. Que chacun ait en son esprit une pleine conviction. +14.6 +Celui qui distingue entre les jours agit ainsi pour le Seigneur. Celui qui mange, c'est pour le Seigneur qu'il mange, car il rend grâces à Dieu; celui qui ne mange pas, c'est pour le Seigneur qu'il ne mange pas, et il rend grâces à Dieu. +14.7 +En effet, nul de nous ne vit pour lui-même, et nul ne meurt pour lui-même. +14.8 +Car si nous vivons, nous vivons pour le Seigneur; et si nous mourons, nous mourons pour le Seigneur. Soit donc que nous vivions, soit que nous mourions, nous sommes au Seigneur. +14.9 +Car Christ est mort et il a vécu, afin de dominer sur les morts et sur les vivants. +14.10 +Mais toi, pourquoi juges-tu ton frère? ou toi, pourquoi méprises-tu ton frère? puisque nous comparaîtrons tous devant le tribunal de Dieu. +14.11 +Car il est écrit: Je suis vivant, dit le Seigneur, Tout genou fléchira devant moi, Et toute langue donnera gloire à Dieu. +14.12 +Ainsi chacun de nous rendra compte à Dieu pour lui-même. +14.13 +Ne nous jugeons donc plus les uns les autres; mais pensez plutôt à ne rien faire qui soit pour votre frère une pierre d'achoppement ou une occasion de chute. +14.14 +Je sais et je suis persuadé par le Seigneur Jésus que rien n'est impur en soi, et qu'une chose n'est impure que pour celui qui la croit impure. +14.15 +Mais si, pour un aliment, ton frère est attristé, tu ne marches plus selon l'amour: ne cause pas, par ton aliment, la perte de celui pour lequel Christ est mort. +14.16 +Que votre privilège ne soit pas un sujet de calomnie. +14.17 +Car le royaume de Dieu, ce n'est pas le manger et le boire, mais la justice, la paix et la joie, par le Saint Esprit. +14.18 +Celui qui sert Christ de cette manière est agréable à Dieu et approuvé des hommes. +14.19 +Ainsi donc, recherchons ce qui contribue à la paix et à l'édification mutuelle. +14.20 +Pour un aliment, ne détruis pas l'oeuvre de Dieu. A la vérité toutes choses sont pures; mais il est mal à l'homme, quand il mange, de devenir une pierre d'achoppement. +14.21 +Il est bien de ne pas manger de viande, de ne pas boire de vin, et de s'abstenir de ce qui peut être pour ton frère une occasion de chute, de scandale ou de faiblesse. +14.22 +Cette foi que tu as, garde-la pour toi devant Dieu. Heureux celui qui ne se condamne pas lui-même dans ce qu'il approuve! +14.23 +Mais celui qui a des doutes au sujet de ce qu'il mange est condamné, parce qu'il n'agit pas par conviction. Tout ce qui n'est pas le produit d'une conviction est péché. + +Romains 15 + +15.1 +Nous qui sommes forts, nous devons supporter les faiblesses de ceux qui ne le sont pas, et ne pas nous complaire en nous-mêmes. +15.2 +Que chacun de nous complaise au prochain pour ce qui est bien en vue de l'édification. +15.3 +Car Christ ne s'est point complu en lui-même, mais, selon qu'il est écrit: Les outrages de ceux qui t'insultent sont tombés sur moi. +15.4 +Or, tout ce qui a été écrit d'avance l'a été pour notre instruction, afin que, par la patience, et par la consolation que donnent les Écritures, nous possédions l'espérance. +15.5 +Que le Dieu de la persévérance et de la consolation vous donne d'avoir les mêmes sentiments les uns envers les autres selon Jésus Christ, +15.6 +afin que tous ensemble, d'une seule bouche, vous glorifiiez le Dieu et Père de notre Seigneur Jésus Christ. +15.7 +Accueillez-vous donc les uns les autres, comme Christ vous a accueillis, pour la gloire de Dieu. +15.8 +Je dis, en effet, que Christ a été serviteur des circoncis, pour prouver la véracité de Dieu en confirmant les promesses faites aux pères, +15.9 +tandis que les païens glorifient Dieu à cause de sa miséricorde, selon qu'il est écrit: C'est pourquoi je te louerai parmi les nations, Et je chanterai à la gloire de ton nom. Il est dit encore: +15.10 +Nations, réjouissez-vous avec son peuple! +15.11 +Et encore: Louez le Seigneur, vous toutes les nations, Célébrez-le, vous tous les peuples! +15.12 +Ésaïe dit aussi: Il sortira d'Isaï un rejeton, Qui se lèvera pour régner sur les nations; Les nations espéreront en lui. +15.13 +Que le Dieu de l'espérance vous remplisse de toute joie et de toute paix dans la foi, pour que vous abondiez en espérance, par la puissance du Saint Esprit! +15.14 +Pour ce qui vous concerne, mes frères, je suis moi-même persuadé que vous êtes pleins de bonnes dispositions, remplis de toute connaissance, et capables de vous exhorter les uns les autres. +15.15 +Cependant, à certains égards, je vous ai écrit avec une sorte de hardiesse, comme pour réveiller vos souvenirs, à cause de la grâce que Dieu m'a faite +15.16 +d'être ministre de Jésus Christ parmi les païens, m'acquittant du divin service de l'Évangile de Dieu, afin que les païens lui soient une offrande agréable, étant sanctifiée par l'Esprit Saint. +15.17 +J'ai donc sujet de me glorifier en Jésus Christ, pour ce qui regarde les choses de Dieu. +15.18 +Car je n'oserais mentionner aucune chose que Christ n'ait pas faite par moi pour amener les païens à l'obéissance, par la parole et par les actes, +15.19 +par la puissance des miracles et des prodiges, par la puissance de l'Esprit de Dieu, en sorte que, depuis Jérusalem et les pays voisins jusqu'en Illyrie, j'ai abondamment répandu l'Évangile de Christ. +15.20 +Et je me suis fait honneur d'annoncer l'Évangile là où Christ n'avait point été nommé, afin de ne pas bâtir sur le fondement d'autrui, selon qu'il est écrit: +15.21 +Ceux à qui il n'avait point été annoncé verront, Et ceux qui n'en avaient point entendu parler comprendront. +15.22 +C'est ce qui m'a souvent empêché d'aller vers vous. +15.23 +Mais maintenant, n'ayant plus rien qui me retienne dans ces contrées, et ayant depuis plusieurs années le désir d'aller vers vous, +15.24 +j'espère vous voir en passant, quand je me rendrai en Espagne, et y être accompagné par vous, après que j'aurai satisfait en partie mon désir de me trouver chez vous. +15.25 +Présentement je vais à Jérusalem, pour le service des saints. +15.26 +Car la Macédoine et l'Achaïe ont bien voulu s'imposer une contribution en faveur des pauvres parmi les saints de Jérusalem. +15.27 +Elles l'ont bien voulu, et elles le leur devaient; car si les païens ont eu part à leurs avantages spirituels, ils doivent aussi les assister dans les choses temporelles. +15.28 +Dès que j'aurai terminé cette affaire et que je leur aurai remis ces dons, je partirai pour l'Espagne et passerai chez vous. +15.29 +Je sais qu'en allant vers vous, c'est avec une pleine bénédiction de Christ que j'irai. +15.30 +Je vous exhorte, frères, par notre Seigneur Jésus Christ et par l'amour de l'Esprit, à combattre avec moi, en adressant à Dieu des prières en ma faveur, +15.31 +afin que je sois délivré des incrédules de la Judée, et que les dons que je porte à Jérusalem soient agréés des saints, +15.32 +en sorte que j'arrive chez vous avec joie, si c'est la volonté de Dieu, et que je jouisse au milieu de vous de quelque repos. +15.33 +Que le Dieu de paix soit avec vous tous! Amen! + +Romains 16 + +16.1 +Je vous recommande Phoebé, notre soeur, qui est diaconesse de l'Église de Cenchrées, +16.2 +afin que vous la receviez en notre Seigneur d'une manière digne des saints, et que vous l'assistiez dans les choses où elle aurait besoin de vous, car elle en a donné aide à plusieurs et à moi-même. +16.3 +Saluez Prisca et Aquilas, mes compagnons d'oeuvre en Jésus Christ, +16.4 +qui ont exposé leur tête pour sauver ma vie; ce n'est pas moi seul qui leur rends grâces, ce sont encore toutes les Églises des païens. +16.5 +Saluez aussi l'Église qui est dans leur maison. Saluez Épaïnète, mon bien-aimé, qui a été pour Christ les prémices de l'Asie. +16.6 +Saluez Marie, qui a pris beaucoup de peine pour vous. +16.7 +Saluez Andronicus et Junias, mes parents et mes compagnons de captivité, qui jouissent d'une grande considération parmi les apôtres, et qui même ont été en Christ avant moi. +16.8 +Saluez Amplias, mon bien-aimé dans le Seigneur. +16.9 +Saluez Urbain, notre compagnon d'oeuvre en Christ, et Stachys, mon bien-aimé. +16.10 +Saluez Apellès, qui est éprouvé en Christ. Saluez ceux de la maison d'Aristobule. +16.11 +Saluez Hérodion, mon parent. Saluez ceux de la maison de Narcisse qui sont dans le Seigneur. +16.12 +Saluez Tryphène et Tryphose, qui travaillent pour le Seigneur. Saluez Perside, la bien-aimée, qui a beaucoup travaillé pour le Seigneur. +16.13 +Saluez Rufus, l'élu du Seigneur, et sa mère, qui est aussi la mienne. +16.14 +Saluez Asyncrite, Phlégon, Hermès, Patrobas, Hermas, et les frères qui sont avec eux. +16.15 +Saluez Philologue et Julie, Nérée et sa soeur, et Olympe, et tous les saints qui sont avec eux. +16.16 +Saluez-vous les uns les autres par un saint baiser. Toutes les Églises de Christ vous saluent. +16.17 +Je vous exhorte, frères, à prendre garde à ceux qui causent des divisions et des scandales, au préjudice de l'enseignement que vous avez reçu. Éloignez-vous d'eux. +16.18 +Car de tels hommes ne servent point Christ notre Seigneur, mais leur propre ventre; et, par des paroles douces et flatteuses, ils séduisent les coeurs des simples. +16.19 +Pour vous, votre obéissance est connue de tous; je me réjouis donc à votre sujet, et je désire que vous soyez sages en ce qui concerne le bien et purs en ce qui concerne le mal. +16.20 +Le Dieu de paix écrasera bientôt Satan sous vos pieds. Que la grâce de notre Seigneur Jésus Christ soit avec vous! +16.21 +Timothée, mon compagnon d'oeuvre, vous salue, ainsi que Lucius, Jason et Sosipater, mes parents. +16.22 +Je vous salue dans le Seigneur, moi Tertius, qui ai écrit cette lettre. +16.23 +Gaïus, mon hôte et celui de toute l'Église, vous salue. Éraste, le trésorier de la ville, vous salue, ainsi que le frère Quartus. +16.24 +Que la grâce de notre Seigneur Jésus Christ soit avec vous tous! Amen! +16.25 +A celui qui peut vous affermir selon mon Évangile et la prédication de Jésus Christ, conformément à la révélation du mystère caché pendant des siècles, +16.26 +mais manifesté maintenant par les écrits des prophètes, d'après l'ordre du Dieu éternel, et porté à la connaissance de toutes les nations, afin qu'elles obéissent à la foi, +16.27 +à Dieu, seul sage, soit la gloire aux siècles des siècles, par Jésus Christ! Amen! diff --git a/test/ws_perf_SUITE_data/japanese.txt b/test/ws_perf_SUITE_data/japanese.txt new file mode 100644 index 0000000..780764f --- /dev/null +++ b/test/ws_perf_SUITE_data/japanese.txt @@ -0,0 +1,41 @@ +JAP + +1 + +天と地の創造 + +1まだ何もなかった時、神は天と地を造りました。 2地は形も定まらず、闇に包まれた水の上を、さらに神の霊が覆っていました。 + +3「光よ、輝き出よ。」神が言われると、光がさっとさしてきました。 4-5それを見て、神は大いに満足し、光と闇とを区別しました。しばらくの間、光は輝き続け、やがて、もう一度闇に覆われました。神は光を「昼」、闇を「夜」と名づけました。こうして昼と夜ができて、一日目が終わりました。 + + +6「もやは上下に分かれ、空と海になれ」と神が言われると、 7-8そのとおり水蒸気が二つに分かれ、空ができました。こうして二日目も終わりました。 + +9-10「空の下の水は集まって海となり、乾いた地が現れ出よ。」こう神が言われると、そのとおりになりました。神は乾いた地を「陸地」、水の部分を「海」と名づけました。それを見て満足すると、 11-12神はまた言われました。「陸地には、あらゆる種類の草、種のある植物、実のなる木が生えよ。それぞれの種から同じ種類の草や木が生えるようになれ。」すると、そのとおりになり、神は満足しました。 13これが三日目です。 + +14-15神のことばはさらに続きます。「空に光が輝き、地を照らせ。その光で、昼と夜の区別、季節の変化、一日や一年の区切りをつけよ。」すると、そのとおりになりました。 16こうして、地を照らす太陽と月ができました。太陽は大きく明るいので昼を、月は夜を治めました。このほかにも、星々が造られました。 17神はそれをみな空にちりばめ、地を照らすようにしました。 18こうして昼と夜を分け終えると、神は満足しました。 19ここまでが四日目の出来事です。 + +20神は再び言われました。「海は魚やその他の生き物であふれ、空はあらゆる種類の鳥で満ちよ。」 21-22神は海に住む大きな生き物をはじめ、あらゆる種類の魚と鳥を造りました。みなすばらしいものばかりです。神はそれを見て、「海いっぱいに満ちよ。鳥たちは地を覆うまでに増えよ」と祝福しました。 23これが五日目です。 + +24次に神は言われました。「地は、家畜や地をはうもの、野の獣など、あらゆる種類の生き物を生み出せ。」そのとおりになりました。 25神が造った生き物は、どれも満足のいくものばかりでした。 + +26そして最後に、神はこう言われました。「さあ、人間を造ろう。地と空と海のあらゆる生き物を治めさせるために、われわれに最も近い、われわれのかたちに似せて人間を造ろう。」 27このように人間は、天地を造った神の特性を持つ者として、男と女とに創造されました。 + +28神は人間を祝福して言われました。「地に増え広がり、大地を治めよ。あなたがたは、魚と鳥とすべての動物の主人なのだ。 29全地に生える種のある植物を見てみなさい。みなあなたがたのものだ。実のなる木もすべて与えるから、好きなだけ食べるがいい。 30また、動物や鳥にも、あらゆる草と植物を彼らの食物として与える。」 31神はでき上がった世界を隅から隅まで見渡しました。とてもすばらしい世界が広がっていました。こうして六日目が終わりました。 + + +2 + +1ついに全世界が完成しました。 2すべてを創造し終えると、神は七日目には休まれ、 3この日を祝福して、聖なる日と定めました。この日、天地創造の働きが完了したからです。 + +人間の創造 + +人間の創造 + +人間の創造 + +人間の創造 + +人間の創造 + +人間の創造. |