diff options
-rw-r--r-- | doc/src/guide/static_files.asciidoc | 4 | ||||
-rw-r--r-- | src/cowboy.erl | 2 | ||||
-rw-r--r-- | src/cowboy_http.erl | 12 | ||||
-rw-r--r-- | src/cowboy_http2.erl | 49 | ||||
-rw-r--r-- | src/cowboy_rest.erl | 31 | ||||
-rw-r--r-- | src/cowboy_router.erl | 25 | ||||
-rw-r--r-- | src/cowboy_static.erl | 19 | ||||
-rw-r--r-- | src/cowboy_stream_h.erl | 66 | ||||
-rw-r--r-- | test/cowboy_test.erl | 19 | ||||
-rw-r--r-- | test/static_handler_SUITE.erl | 761 |
10 files changed, 950 insertions, 38 deletions
diff --git a/doc/src/guide/static_files.asciidoc b/doc/src/guide/static_files.asciidoc index 39197a8..ef27119 100644 --- a/doc/src/guide/static_files.asciidoc +++ b/doc/src/guide/static_files.asciidoc @@ -124,8 +124,8 @@ a binary string is also allowed (but will require extra processing). If the function can't figure out the mimetype, then it should return `{<<"application">>, <<"octet-stream">>, []}`. -When the static handler fails to find the extension in the -list, it will send the file as `application/octet-stream`. +When the static handler fails to find the extension, +it will send the file as `application/octet-stream`. A browser receiving such file will attempt to download it directly to disk. diff --git a/src/cowboy.erl b/src/cowboy.erl index d9c14ea..3387224 100644 --- a/src/cowboy.erl +++ b/src/cowboy.erl @@ -44,7 +44,7 @@ cowboy_protocol:opts()) -> {ok, pid()} | {error, any()}. start_clear(Ref, NbAcceptors, TransOpts0, ProtoOpts) when is_integer(NbAcceptors), NbAcceptors > 0 -> - TransOpts = TransOpts0,%[connection_type(ProtoOpts)|TransOpts0], + TransOpts = [connection_type(ProtoOpts)|TransOpts0], ranch:start_listener(Ref, NbAcceptors, ranch_tcp, TransOpts, cowboy_clear, ProtoOpts). -spec start_tls(ranch:ref(), non_neg_integer(), ranch_ssl:opts(), opts()) -> {ok, pid()} | {error, any()}. diff --git a/src/cowboy_http.erl b/src/cowboy_http.erl index 4608839..e93045d 100644 --- a/src/cowboy_http.erl +++ b/src/cowboy_http.erl @@ -900,15 +900,15 @@ maybe_terminate(State=#state{last_streamid=StreamID}, StreamID, _Tail, fin) -> maybe_terminate(State, StreamID, _Tail, fin) -> stream_terminate(State, StreamID, normal). -stream_reset(State=#state{socket=Socket, transport=Transport}, StreamID, - StreamError={internal_error, _, _}) -> +stream_reset(State, StreamID, StreamError={internal_error, _, _}) -> %% @todo headers %% @todo Don't send this if there are no streams left. - Transport:send(Socket, cow_http:response(500, 'HTTP/1.1', [ - {<<"content-length">>, <<"0">>} - ])), +% Transport:send(Socket, cow_http:response(500, 'HTTP/1.1', [ +% {<<"content-length">>, <<"0">>} +% ])), %% @todo update IsFin local - stream_terminate(State#state{out_state=done}, StreamID, StreamError). +% stream_terminate(State#state{out_state=done}, StreamID, StreamError). + stream_terminate(State, StreamID, StreamError). stream_terminate(State=#state{socket=Socket, transport=Transport, handler=Handler, out_streamid=OutStreamID, out_state=OutState, diff --git a/src/cowboy_http2.erl b/src/cowboy_http2.erl index c5c7e0c..94581ac 100644 --- a/src/cowboy_http2.erl +++ b/src/cowboy_http2.erl @@ -365,18 +365,50 @@ commands(State=#state{socket=Socket, transport=Transport, encode_state=EncodeSta [{response, StatusCode, Headers0, Body}|Tail]) -> Headers = Headers0#{<<":status">> => integer_to_binary(StatusCode)}, {HeaderBlock, EncodeState} = headers_encode(Headers, EncodeState0), - Transport:send(Socket, [ - cow_http2:headers(StreamID, nofin, HeaderBlock), - cow_http2:data(StreamID, fin, Body) - ]), - commands(State#state{encode_state=EncodeState}, StreamID, Tail); + Response = cow_http2:headers(StreamID, nofin, HeaderBlock), + case Body of + {sendfile, O, B, P} -> + Transport:send(Socket, Response), + commands(State#state{encode_state=EncodeState}, StreamID, + [{sendfile, fin, O, B, P}|Tail]); + _ -> + Transport:send(Socket, [ + Response, + cow_http2:data(StreamID, fin, Body) + ]), + commands(State#state{encode_state=EncodeState}, StreamID, Tail) + end; %% Send a response body chunk. %% %% @todo WINDOW_UPDATE stuff require us to buffer some data. +%% +%% When the body is sent using sendfile, the current solution is not +%% very good. The body could be too large, blocking the connection. +%% Also sendfile technically only works over TCP, so it's not that +%% useful for HTTP/2. At the very least the sendfile call should be +%% split into multiple calls and flow control should be used to make +%% sure we only send as fast as the client can receive and don't block +%% anything. commands(State=#state{socket=Socket, transport=Transport}, StreamID, [{data, IsFin, Data}|Tail]) -> Transport:send(Socket, cow_http2:data(StreamID, IsFin, Data)), commands(State, StreamID, Tail); +%% Send a file. +%% +%% @todo This implementation is terrible. A good implementation would +%% need to check that Bytes is exact (or we need to document that we +%% trust it to be exact), and would need to send the file asynchronously +%% in many data frames. Perhaps a sendfile call should result in a +%% process being created specifically for this purpose. Or perhaps +%% the protocol should be "dumb" and the stream handler be the one +%% to ensure the file is sent in chunks (which would require a better +%% flow control at the stream handler level). One thing for sure, the +%% implementation necessarily varies between HTTP/1.1 and HTTP/2. +commands(State=#state{socket=Socket, transport=Transport}, StreamID, + [{sendfile, IsFin, Offset, Bytes, Path}|Tail]) -> + Transport:send(Socket, cow_http2:data_header(StreamID, IsFin, Bytes)), + Transport:sendfile(Socket, Path, Offset, Bytes), + commands(State, StreamID, Tail); %% Send a push promise. %% %% @todo We need to keep track of what promises we made so that we don't @@ -400,6 +432,10 @@ commands(State, StreamID, [{flow, _Size}|Tail]) -> %% Supervise a child process. commands(State=#state{children=Children}, StreamID, [{spawn, Pid, _Shutdown}|Tail]) -> %% @todo Shutdown commands(State#state{children=[{Pid, StreamID}|Children]}, StreamID, Tail); +%% Error handling. +commands(State, StreamID, [Error = {internal_error, _, _}|Tail]) -> + %% @todo Only reset when the stream still exists. + commands(stream_reset(State, StreamID, Error), StreamID, Tail); %% Upgrade to a new protocol. %% %% @todo Implementation. @@ -447,8 +483,7 @@ stream_init(State0=#state{ref=Ref, socket=Socket, transport=Transport, decode_st Host = Authority, %% @todo Port = todo, %% @todo - Path = PathWithQs, %% @todo - Qs = todo, %% @todo + {Path, Qs} = cow_http:parse_fullpath(PathWithQs), Req = #{ ref => Ref, diff --git a/src/cowboy_rest.erl b/src/cowboy_rest.erl index 914b273..55b4e22 100644 --- a/src/cowboy_rest.erl +++ b/src/cowboy_rest.erl @@ -198,7 +198,6 @@ %% End of REST callbacks. Whew! -record(state, { - env :: cowboy_middleware:env(), method = undefined :: binary(), %% Handler. @@ -235,10 +234,11 @@ -spec upgrade(Req, Env, module(), any(), infinity, run) -> {ok, Req, Env} when Req::cowboy_req:req(), Env::cowboy_middleware:env(). -upgrade(Req, Env, Handler, HandlerState, infinity, run) -> - Method = cowboy_req:method(Req), - service_available(Req, #state{env=Env, method=Method, - handler=Handler, handler_state=HandlerState}). +upgrade(Req0, Env, Handler, HandlerState, infinity, run) -> + Method = cowboy_req:method(Req0), + {ok, Req, Result} = service_available(Req0, #state{method=Method, + handler=Handler, handler_state=HandlerState}), + {ok, Req, [{result, Result}|Env]}. service_available(Req, State) -> expect(Req, State, service_available, true, fun known_methods/2, 503). @@ -683,9 +683,12 @@ if_match_exists(Req, State) -> if_match(Req, State, EtagsList) -> try generate_etag(Req, State) of + %% Strong Etag comparison: weak Etag never matches. + {{weak, _}, Req2, State2} -> + precondition_failed(Req2, State2); {Etag, Req2, State2} -> case lists:member(Etag, EtagsList) of - true -> if_unmodified_since_exists(Req2, State2); + true -> if_none_match_exists(Req2, State2); %% Etag may be `undefined' which cannot be a member. false -> precondition_failed(Req2, State2) end @@ -738,15 +741,23 @@ if_none_match(Req, State, EtagsList) -> undefined -> precondition_failed(Req2, State2); Etag -> - case lists:member(Etag, EtagsList) of + case is_weak_match(Etag, EtagsList) of true -> precondition_is_head_get(Req2, State2); - false -> if_modified_since_exists(Req2, State2) + false -> method(Req2, State2) end end catch Class:Reason -> error_terminate(Req, State, Class, Reason, generate_etag) end. +%% Weak Etag comparison: only check the opaque tag. +is_weak_match(_, []) -> + false; +is_weak_match({_, Tag}, [{_, Tag}|_]) -> + true; +is_weak_match(Etag, [_|Tail]) -> + is_weak_match(Etag, Tail). + precondition_is_head_get(Req, State=#state{method=Method}) when Method =:= <<"HEAD">>; Method =:= <<"GET">> -> not_modified(Req, State); @@ -1156,6 +1167,6 @@ error_terminate(Req, #state{handler=Handler, handler_state=HandlerState}, {state, HandlerState} ]}). -terminate(Req, #state{env=Env, handler=Handler, handler_state=HandlerState}) -> +terminate(Req, #state{handler=Handler, handler_state=HandlerState}) -> Result = cowboy_handler:terminate(normal, Req, HandlerState, Handler), - {ok, Req, [{result, Result}|Env]}. + {ok, Req, Result}. diff --git a/src/cowboy_router.erl b/src/cowboy_router.erl index f07b307..f2f8fb4 100644 --- a/src/cowboy_router.erl +++ b/src/cowboy_router.erl @@ -322,9 +322,9 @@ split_path(Path, Acc) -> try case binary:match(Path, <<"/">>) of nomatch when Path =:= <<>> -> - lists:reverse([cow_qs:urldecode(S) || S <- Acc]); + remove_dot_segments(lists:reverse([cow_uri:urldecode(S) || S <- Acc]), []); nomatch -> - lists:reverse([cow_qs:urldecode(S) || S <- [Path|Acc]]); + remove_dot_segments(lists:reverse([cow_uri:urldecode(S) || S <- [Path|Acc]]), []); {Pos, _} -> << Segment:Pos/binary, _:8, Rest/bits >> = Path, split_path(Rest, [Segment|Acc]) @@ -334,6 +334,27 @@ split_path(Path, Acc) -> badrequest end. +remove_dot_segments([], Acc) -> + lists:reverse(Acc); +remove_dot_segments([<<".">>|Segments], Acc) -> + remove_dot_segments(Segments, Acc); +remove_dot_segments([<<"..">>|Segments], Acc=[]) -> + remove_dot_segments(Segments, Acc); +remove_dot_segments([<<"..">>|Segments], [_|Acc]) -> + remove_dot_segments(Segments, Acc); +remove_dot_segments([S|Segments], Acc) -> + remove_dot_segments(Segments, [S|Acc]). + +-ifdef(TEST). +remove_dot_segments_test_() -> + Tests = [ + {[<<"a">>, <<"b">>, <<"c">>, <<".">>, <<"..">>, <<"..">>, <<"g">>], [<<"a">>, <<"g">>]}, + {[<<"mid">>, <<"content=5">>, <<"..">>, <<"6">>], [<<"mid">>, <<"6">>]}, + {[<<"..">>, <<"a">>], [<<"a">>]} + ], + [fun() -> R = remove_dot_segments(S, []) end || {S, R} <- Tests]. +-endif. + -spec list_match(tokens(), dispatch_match(), bindings()) -> {true, bindings(), undefined | tokens()} | false. %% Atom '...' matches any trailing path, stop right now. diff --git a/src/cowboy_static.erl b/src/cowboy_static.erl index c771771..d13db62 100644 --- a/src/cowboy_static.erl +++ b/src/cowboy_static.erl @@ -81,15 +81,32 @@ init_dir(Req, Path, Extra) when is_list(Path) -> init_dir(Req, Path, Extra) -> Dir = fullpath(filename:absname(Path)), PathInfo = cowboy_req:path_info(Req), - Filepath = filename:join([Dir|PathInfo]), + Filepath = filename:join([Dir|[escape_reserved(P, <<>>) || P <- PathInfo]]), Len = byte_size(Dir), case fullpath(Filepath) of << Dir:Len/binary, $/, _/binary >> -> init_info(Req, Filepath, Extra); + << Dir:Len/binary >> -> + init_info(Req, Filepath, Extra); _ -> {cowboy_rest, Req, error} end. +%% We escape the slash found in path segments because +%% a segment corresponds to a directory entry, and +%% therefore those slashes are expected to be part of +%% the directory name. +%% +%% Note that on most systems the slash is prohibited +%% and cannot appear in filenames, which means the +%% requested file will end up being not found. +escape_reserved(<<>>, Acc) -> + Acc; +escape_reserved(<< $/, Rest/bits >>, Acc) -> + escape_reserved(Rest, << Acc/binary, $\\, $/ >>); +escape_reserved(<< C, Rest/bits >>, Acc) -> + escape_reserved(Rest, << Acc/binary, C >>). + fullpath(Path) -> fullpath(filename:split(Path), []). fullpath([], Acc) -> diff --git a/src/cowboy_stream_h.erl b/src/cowboy_stream_h.erl index f924e28..b834c17 100644 --- a/src/cowboy_stream_h.erl +++ b/src/cowboy_stream_h.erl @@ -21,10 +21,12 @@ -export([info/3]). -export([terminate/3]). +-export([proc_lib_hack/3]). -export([execute/3]). -export([resume/5]). -record(state, { + ref = undefined :: ranch:ref(), pid = undefined :: pid(), read_body_ref = undefined :: reference(), read_body_length = 0 :: non_neg_integer(), @@ -38,12 +40,12 @@ %% @todo proper specs -spec init(_,_,_) -> _. -init(_StreamID, Req, Opts) -> +init(_StreamID, Req=#{ref := Ref}, Opts) -> Env = maps:get(env, Opts, #{}), Middlewares = maps:get(middlewares, Opts, [cowboy_router, cowboy_handler]), Shutdown = maps:get(shutdown, Opts, 5000), - Pid = proc_lib:spawn_link(?MODULE, execute, [Req, Env, Middlewares]), - {[{spawn, Pid, Shutdown}], #state{pid=Pid}}. + Pid = proc_lib:spawn_link(?MODULE, proc_lib_hack, [Req, Env, Middlewares]), + {[{spawn, Pid, Shutdown}], #state{ref=Ref, pid=Pid}}. %% If we receive data and stream is waiting for data: %% If we accumulated enough data or IsFin=fin, send it. @@ -64,8 +66,29 @@ data(_StreamID, IsFin, Data, State=#state{pid=Pid, read_body_ref=Ref, read_body_ -spec info(_,_,_) -> _. info(_StreamID, {'EXIT', Pid, normal}, State=#state{pid=Pid}) -> {[stop], State}; -info(_StreamID, Reason = {'EXIT', Pid, _}, State=#state{pid=Pid}) -> - {[{internal_error, Reason, 'Stream process crashed.'}], State}; +%% @todo Transition. +%% In the future it would be better to simplify things +%% and only catch this at the stream level. +%% +%% Maybe we don't need specific error messages +%% for every single callbacks anymore? +info(_StreamID, Exit = {'EXIT', Pid, {cowboy_handler, _}}, State=#state{pid=Pid}) -> + %% No crash report; one has already been sent. + {[ + {response, 500, #{<<"content-length">> => <<"0">>}, <<>>}, + {internal_error, Exit, 'Stream process crashed.'} + ], State}; +info(_StreamID, {'EXIT', Pid, {_Reason, [_, {cow_http_hd, _, _, _}|_]}}, State=#state{pid=Pid}) -> + %% @todo Have an option to enable/disable this specific crash report? + %%report_crash(Ref, StreamID, Pid, Reason, Stacktrace), + %% @todo Headers? Details in body? More stuff in debug only? + {[{response, 400, #{}, <<>>}, stop], State}; +info(StreamID, Exit = {'EXIT', Pid, {Reason, Stacktrace}}, State=#state{ref=Ref, pid=Pid}) -> + report_crash(Ref, StreamID, Pid, Reason, Stacktrace), + {[ + {response, 500, #{<<"content-length">> => <<"0">>}, <<>>}, + {internal_error, Exit, 'Stream process crashed.'} + ], State}; %% Request body, no body buffer but IsFin=fin. info(_StreamID, {read_body, Ref, _}, State=#state{pid=Pid, read_body_is_fin=fin, read_body_buffer= <<>>}) -> Pid ! {request_body, Ref, fin, <<>>}, @@ -89,6 +112,7 @@ info(_StreamID, SwitchProtocol = {switch_protocol, _, _, _}, State) -> {[SwitchProtocol], State}; %% Stray message. info(_StreamID, _Msg, State) -> + %% @todo Error report. %% @todo Cleanup if no reply was sent when stream ends. {[], State}. @@ -97,8 +121,40 @@ info(_StreamID, _Msg, State) -> terminate(_StreamID, _Reason, _State) -> ok. +%% We use ~999999p here instead of ~w because the latter doesn't +%% support printable strings. +report_crash(_, _, _, normal, _) -> + ok; +report_crash(_, _, _, shutdown, _) -> + ok; +report_crash(_, _, _, {shutdown, _}, _) -> + ok; +report_crash(Ref, StreamID, Pid, Reason, Stacktrace) -> + error_logger:error_msg( + "Ranch listener ~p, connection process ~p, stream ~p " + "had its request process ~p exit with reason " + "~999999p and stacktrace ~999999p~n", + [Ref, self(), StreamID, Pid, Reason, Stacktrace]). + %% Request process. +%% This hack is necessary because proc_lib does not propagate +%% stacktraces by default. This is ugly because we end up +%% having two try/catch instead of one (the one in proc_lib), +%% just to add the stacktrace information. +%% +%% @todo Remove whenever proc_lib propagates stacktraces. +-spec proc_lib_hack(_, _, _) -> _. +proc_lib_hack(Req, Env, Middlewares) -> + try + execute(Req, Env, Middlewares) + catch + _:Reason when element(1, Reason) =:= cowboy_handler -> + exit(Reason); + _:Reason -> + exit({Reason, erlang:get_stacktrace()}) + end. + %% @todo %-spec execute(cowboy_req:req(), #state{}, cowboy_middleware:env(), [module()]) % -> ok. diff --git a/test/cowboy_test.erl b/test/cowboy_test.erl index 44ffdf8..5b223d1 100644 --- a/test/cowboy_test.erl +++ b/test/cowboy_test.erl @@ -42,22 +42,33 @@ common_all() -> [ {group, http}, {group, https}, - {group, http_compress}, - {group, https_compress} + {group, h2}, + {group, h2c}%, +%% @todo +% {group, http_compress}, +% {group, https_compress} ]. common_groups(Tests) -> [ {http, [parallel], Tests}, {https, [parallel], Tests}, - {http_compress, [parallel], Tests}, - {https_compress, [parallel], Tests} + {h2, [parallel], Tests}, + {h2c, [parallel], Tests}%, +%% @todo +% {http_compress, [parallel], Tests}, +% {https_compress, [parallel], Tests} ]. init_common_groups(Name = http, Config, Mod) -> init_http(Name, #{env => #{dispatch => Mod:init_dispatch(Config)}}, Config); init_common_groups(Name = https, Config, Mod) -> init_https(Name, #{env => #{dispatch => Mod:init_dispatch(Config)}}, Config); +init_common_groups(Name = h2, Config, Mod) -> + init_http2(Name, #{env => #{dispatch => Mod:init_dispatch(Config)}}, Config); +init_common_groups(Name = h2c, Config, Mod) -> + Config1 = init_http(Name, #{env => #{dispatch => Mod:init_dispatch(Config)}}, Config), + lists:keyreplace(protocol, 1, Config1, {protocol, http2}); init_common_groups(Name = http_compress, Config, Mod) -> init_http(Name, #{ env => #{dispatch => Mod:init_dispatch(Config)}, diff --git a/test/static_handler_SUITE.erl b/test/static_handler_SUITE.erl new file mode 100644 index 0000000..fdeae10 --- /dev/null +++ b/test/static_handler_SUITE.erl @@ -0,0 +1,761 @@ +%% Copyright (c) 2016, 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(static_handler_SUITE). +-compile(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() -> + AllTests = ct_helper:all(?MODULE), + %% The directory tests are shared between dir and priv_dir options. + DirTests = lists:usort([F || {F, 1} <- ?MODULE:module_info(exports), + string:substr(atom_to_list(F), 1, 4) =:= "dir_" + ]), + OtherTests = AllTests -- DirTests, + GroupTests = OtherTests ++ [ + {dir, [parallel], DirTests}, + {priv_dir, [parallel], DirTests} + ], + [ + {http, [parallel], GroupTests}, + {https, [parallel], GroupTests}, + {h2, [parallel], GroupTests}, + {h2c, [parallel], GroupTests} + %% @todo With compression enabled. + ]. + +init_per_suite(Config) -> + %% @todo When we can chain stream handlers, write one + %% to hide these expected errors. + ct:print("This test suite will produce error reports. " + "The path for these expected errors begins with '/bad' or '/char'."), + %% Two static folders are created: one in ct_helper's private directory, + %% and one in the test run private directory. + PrivDir = code:priv_dir(ct_helper) ++ "/static", + StaticDir = config(priv_dir, Config) ++ "/static", + ct_helper:create_static_dir(PrivDir), + ct_helper:create_static_dir(StaticDir), + init_large_file(PrivDir ++ "/large.bin"), + init_large_file(StaticDir ++ "/large.bin"), + %% A special folder contains files of 1 character from 0 to 127. + CharDir = config(priv_dir, Config) ++ "/char", + ok = filelib:ensure_dir(CharDir ++ "/file"), + Chars = lists:flatten([case file:write_file(CharDir ++ [$/, C], [C]) of + ok -> C; + {error, _} -> [] + end || C <- lists:seq(0, 127)]), + [{static_dir, StaticDir}, {char_dir, CharDir}, {chars, Chars}|Config]. + +end_per_suite(Config) -> + ct:print("This test suite produced error reports. " + "The path for these expected errors begins with '/bad' or '/char'."), + %% Special directory. + CharDir = config(char_dir, Config), + _ = [file:delete(CharDir ++ [$/, C]) || C <- lists:seq(0, 127)], + file:del_dir(CharDir), + %% Static directories. + StaticDir = config(static_dir, Config), + PrivDir = code:priv_dir(ct_helper) ++ "/static", + ok = file:delete(StaticDir ++ "/large.bin"), + ok = file:delete(PrivDir ++ "/large.bin"), + ct_helper:delete_static_dir(StaticDir), + ct_helper:delete_static_dir(PrivDir). + +init_per_group(dir, Config) -> + [{prefix, "/dir"}|Config]; +init_per_group(priv_dir, Config) -> + [{prefix, "/priv_dir"}|Config]; +init_per_group(tttt, Config) -> + Config; +init_per_group(Name, Config) -> + cowboy_test:init_common_groups(Name, Config, ?MODULE). + +end_per_group(Name, _) -> + cowboy:stop_listener(Name). + +%% Large file. + +init_large_file(Filename) -> + case os:type() of + {unix, _} -> + "" = os:cmd("truncate -s 512M " ++ Filename), + ok; + {win32, _} -> + ok + end. + +%% Routes. + +init_dispatch(Config) -> + cowboy_router:compile([{'_', [ + {"/priv_dir/[...]", cowboy_static, {priv_dir, ct_helper, "static"}}, + {"/dir/[...]", cowboy_static, {dir, config(static_dir, Config)}}, + {"/priv_file/style.css", cowboy_static, {priv_file, ct_helper, "static/style.css"}}, + {"/file/style.css", cowboy_static, {file, config(static_dir, Config) ++ "/style.css"}}, + {"/index", cowboy_static, {file, config(static_dir, Config) ++ "/index.html"}}, + {"/mime/all/[...]", cowboy_static, {priv_dir, ct_helper, "static", + [{mimetypes, cow_mimetypes, all}]}}, + {"/mime/custom/[...]", cowboy_static, {priv_dir, ct_helper, "static", + [{mimetypes, ?MODULE, do_mime_custom}]}}, + {"/mime/crash/[...]", cowboy_static, {priv_dir, ct_helper, "static", + [{mimetypes, ?MODULE, do_mime_crash}]}}, + {"/mime/hardcode/binary-form", cowboy_static, {priv_file, ct_helper, "static/file.cowboy", + [{mimetypes, <<"application/vnd.ninenines.cowboy+xml;v=1">>}]}}, + {"/mime/hardcode/tuple-form", cowboy_static, {priv_file, ct_helper, "static/file.cowboy", + [{mimetypes, {<<"application">>, <<"vnd.ninenines.cowboy+xml">>, [{<<"v">>, <<"1">>}]}}]}}, + {"/etag/custom", cowboy_static, {file, config(static_dir, Config) ++ "/style.css", + [{etag, ?MODULE, do_etag_custom}]}}, + {"/etag/crash", cowboy_static, {file, config(static_dir, Config) ++ "/style.css", + [{etag, ?MODULE, do_etag_crash}]}}, + {"/etag/disable", cowboy_static, {file, config(static_dir, Config) ++ "/style.css", + [{etag, false}]}}, + {"/bad", cowboy_static, bad}, + {"/bad/priv_dir/app/[...]", cowboy_static, {priv_dir, bad_app, "static"}}, + {"/bad/priv_dir/no-priv/[...]", cowboy_static, {priv_dir, cowboy, "static"}}, + {"/bad/priv_dir/path/[...]", cowboy_static, {priv_dir, ct_helper, "bad"}}, + {"/bad/priv_dir/route", cowboy_static, {priv_dir, ct_helper, "static"}}, + {"/bad/dir/path/[...]", cowboy_static, {dir, "/bad/path"}}, + {"/bad/dir/route", cowboy_static, {dir, config(static_dir, Config)}}, + {"/bad/priv_file/app", cowboy_static, {priv_file, bad_app, "static/style.css"}}, + {"/bad/priv_file/no-priv", cowboy_static, {priv_file, cowboy, "static/style.css"}}, + {"/bad/priv_file/path", cowboy_static, {priv_file, ct_helper, "bad/style.css"}}, + {"/bad/file/path", cowboy_static, {file, "/bad/path/style.css"}}, + {"/bad/options", cowboy_static, {priv_file, ct_helper, "static/style.css", bad}}, + {"/bad/options/mime", cowboy_static, {priv_file, ct_helper, "static/style.css", [{mimetypes, bad}]}}, + {"/bad/options/etag", cowboy_static, {priv_file, ct_helper, "static/style.css", [{etag, true}]}}, + {"/unknown/option", cowboy_static, {priv_file, ct_helper, "static/style.css", [{bad, option}]}}, + {"/char/[...]", cowboy_static, {dir, config(char_dir, Config)}} + ]}]). + +%% Internal functions. + +do_etag_crash(_, _, _) -> + ct_helper_error_h:ignore(?MODULE, do_etag_crash, 3), + exit(crash). + +do_etag_custom(_, _, _) -> + {strong, <<"etag">>}. + +do_mime_crash(_) -> + ct_helper_error_h:ignore(?MODULE, do_mime_crash, 1), + exit(crash). + +do_mime_custom(Path) -> + case filename:extension(Path) of + <<".cowboy">> -> <<"application/vnd.ninenines.cowboy+xml;v=1">>; + <<".txt">> -> <<"text/plain">>; + _ -> {<<"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, 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), + ok. + +bad_dir_path(Config) -> + doc("Bad cowboy_static options: wrong path."), + {404, _, _} = do_get("/bad/dir/path/style.css", Config), + ok. + +bad_dir_route(Config) -> + doc("Bad cowboy_static options: missing [...] in route."), + {500, _, _} = do_get("/bad/dir/route", Config), + ok. + +bad_file_path(Config) -> + doc("Bad cowboy_static options: wrong path."), + {404, _, _} = do_get("/bad/file/path", Config), + ok. + +bad_options(Config) -> + doc("Bad cowboy_static extra options: not a list."), + {500, _, _} = do_get("/bad/options", Config), + ok. + +bad_options_etag(Config) -> + doc("Bad cowboy_static extra options: invalid etag option."), + {500, _, _} = 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), + ok. + +bad_priv_dir_app(Config) -> + doc("Bad cowboy_static options: wrong application name."), + {500, _, _} = do_get("/bad/priv_dir/app/style.css", Config), + ok. + +bad_priv_dir_no_priv(Config) -> + doc("Bad cowboy_static options: application has no priv directory."), + {404, _, _} = do_get("/bad/priv_dir/no-priv/style.css", Config), + ok. + +bad_priv_dir_path(Config) -> + doc("Bad cowboy_static options: wrong path."), + {404, _, _} = do_get("/bad/priv_dir/path/style.css", Config), + ok. + +bad_priv_dir_route(Config) -> + doc("Bad cowboy_static options: missing [...] in route."), + {500, _, _} = 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), + ok. + +bad_priv_file_no_priv(Config) -> + doc("Bad cowboy_static options: application has no priv directory."), + {404, _, _} = do_get("/bad/priv_file/no-priv", Config), + ok. + +bad_priv_file_path(Config) -> + doc("Bad cowboy_static options: wrong path."), + {404, _, _} = do_get("/bad/priv_file/path", Config), + ok. + +dir_cowboy(Config) -> + doc("Get a .cowboy file."), + {200, Headers, <<"File with custom extension.\n">>} + = do_get(config(prefix, Config) ++ "/file.cowboy", Config), + {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +dir_css(Config) -> + doc("Get a .css file."), + {200, Headers, <<"body{color:red}\n">>} + = do_get(config(prefix, Config) ++ "/style.css", Config), + {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +dir_css_urlencoded(Config) -> + doc("Get a .css file with the extension dot urlencoded."), + {200, Headers, <<"body{color:red}\n">>} + = do_get(config(prefix, Config) ++ "/style%2ecss", Config), + {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +dir_dot_file(Config) -> + doc("Get a file with extra dot segments in the path."), + %% All these are equivalent. + {200, _, _} = do_get(config(prefix, Config) ++ "/./style.css", Config), + {200, _, _} = do_get(config(prefix, Config) ++ "/././style.css", Config), + {200, _, _} = do_get(config(prefix, Config) ++ "/./././style.css", Config), + {200, _, _} = do_get("/./priv_dir/style.css", Config), + {200, _, _} = do_get("/././priv_dir/style.css", Config), + {200, _, _} = do_get("/./././priv_dir/style.css", Config), + ok. + +dir_dotdot_file(Config) -> + doc("Get a file with extra dotdot segments in the path."), + %% All these are equivalent. + {200, _, _} = do_get("/../priv_dir/style.css", Config), + {200, _, _} = do_get("/../../priv_dir/style.css", Config), + {200, _, _} = do_get("/../../../priv_dir/style.css", Config), + {200, _, _} = do_get(config(prefix, Config) ++ "/../priv_dir/style.css", Config), + {200, _, _} = do_get(config(prefix, Config) ++ "/../../priv_dir/style.css", Config), + {200, _, _} = do_get(config(prefix, Config) ++ "/../../../priv_dir/style.css", Config), + {200, _, _} = do_get("/../priv_dir/../priv_dir/style.css", Config), + {200, _, _} = do_get("/../../priv_dir/../../priv_dir/style.css", Config), + {200, _, _} = do_get("/../../../priv_dir/../../../priv_dir/style.css", Config), + %% Try with non-existing segments, which may correspond to real folders. + {200, _, _} = do_get("/anything/../priv_dir/style.css", Config), + {200, _, _} = do_get(config(prefix, Config) ++ "/anything/../style.css", Config), + {200, _, _} = do_get(config(prefix, Config) ++ "/directory/../style.css", Config), + {200, _, _} = do_get(config(prefix, Config) ++ "/static/../style.css", Config), + %% Try with segments corresponding to real files. It works because + %% URI normalization happens before looking at the filesystem. + {200, _, _} = do_get(config(prefix, Config) ++ "/style.css/../style.css", Config), + {200, _, _} = do_get(config(prefix, Config) ++ "/style.css/../../priv_dir/style.css", Config), + %% Try to fool the server to accept segments corresponding to real folders. + {404, _, _} = do_get(config(prefix, Config) ++ "/../static/style.css", Config), + {404, _, _} = do_get(config(prefix, Config) ++ "/directory/../../static/style.css", Config), + ok. + +dir_error_directory(Config) -> + doc("Try to get a directory."), + {403, _, _} = do_get(config(prefix, Config) ++ "/directory", Config), + ok. + +dir_error_directory_slash(Config) -> + doc("Try to get a directory with an extra slash in the path."), + {403, _, _} = do_get(config(prefix, Config) ++ "/directory/", Config), + ok. + +dir_error_doesnt_exist(Config) -> + doc("Try to get a file that does not exist."), + {404, _, _} = do_get(config(prefix, Config) ++ "/not.found", Config), + ok. + +dir_error_dot(Config) -> + doc("Try to get a file named '.'."), + {403, _, _} = do_get(config(prefix, Config) ++ "/.", Config), + ok. + +dir_error_dot_urlencoded(Config) -> + doc("Try to get a file named '.' percent encoded."), + {403, _, _} = do_get(config(prefix, Config) ++ "/%2e", Config), + ok. + +dir_error_dotdot(Config) -> + doc("Try to get a file named '..'."), + {404, _, _} = do_get(config(prefix, Config) ++ "/..", Config), + ok. + +dir_error_dotdot_urlencoded(Config) -> + doc("Try to get a file named '..' percent encoded."), + {404, _, _} = do_get(config(prefix, Config) ++ "/%2e%2e", Config), + ok. + +dir_error_empty(Config) -> + doc("Try to get the configured directory."), + {403, _, _} = do_get(config(prefix, Config) ++ "", Config), + ok. + +dir_error_slash(Config) -> + %% I know the description isn't that good considering / has a meaning in URIs. + doc("Try to get a file named '/'."), + {403, _, _} = do_get(config(prefix, Config) ++ "//", Config), + ok. + +dir_error_slash_urlencoded(Config) -> + doc("Try to get a file named '/' percent encoded."), + {404, _, _} = do_get(config(prefix, Config) ++ "/%2f", Config), + ok. + +dir_error_slash_urlencoded_dotdot_file(Config) -> + doc("Try to use a percent encoded slash to access an existing file."), + {200, _, _} = do_get(config(prefix, Config) ++ "/directory/../style.css", Config), + {404, _, _} = do_get(config(prefix, Config) ++ "/directory%2f../style.css", Config), + ok. + +dir_error_unreadable(Config) -> + doc("Try to get a file that can't be read."), + {403, _, _} = do_get(config(prefix, Config) ++ "/unreadable", Config), + ok. + +dir_html(Config) -> + doc("Get a .html file."), + {200, Headers, <<"<html><body>Hello!</body></html>\n">>} + = do_get(config(prefix, Config) ++ "/index.html", Config), + {_, <<"text/html">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +%% @todo This test results in a crash dump. +%dir_large_file(Config) -> +% doc("Get a large file."), +% {200, Headers, _} = do_get(config(prefix, Config) ++ "/large.bin", Config), +% {_, <<"text/html">>} = lists:keyfind(<<"content-type">>, 1, Headers), +%% @todo Receive body. +% ok. + +dir_text(Config) -> + doc("Get a .txt file. The extension is unknown by default."), + {200, Headers, <<"Timeless space.\n">>} + = do_get(config(prefix, Config) ++ "/plain.txt", Config), + {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +dir_unknown(Config) -> + doc("Get a file with no extension."), + {200, Headers, <<"File with no extension.\n">>} + = do_get(config(prefix, Config) ++ "/unknown", Config), + {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +etag_crash(Config) -> + doc("Get a file with a crashing etag function."), + {500, _, _} = do_get("/etag/crash", Config), + ok. + +etag_custom(Config) -> + doc("Get a file with custom Etag function and make sure it is used."), + {200, Headers, _} = do_get("/etag/custom", Config), + {_, <<"\"etag\"">>} = lists:keyfind(<<"etag">>, 1, Headers), + ok. + +etag_default(Config) -> + doc("Get a file twice and make sure the Etag matches."), + {200, Headers1, _} = do_get("/dir/style.css", Config), + {200, Headers2, _} = do_get("/dir/style.css", Config), + {_, Etag} = lists:keyfind(<<"etag">>, 1, Headers1), + {_, Etag} = lists:keyfind(<<"etag">>, 1, Headers2), + ok. + +etag_default_change(Config) -> + doc("Get a file, modify it, get it again and make sure the Etag doesn't match."), + {200, Headers1, _} = do_get("/dir/index.html", Config), + {_, Etag1} = lists:keyfind(<<"etag">>, 1, Headers1), + ok = file:change_time(config(static_dir, Config) ++ "/index.html", + {{config(port, Config), 1, 1}, {1, 1, 1}}), + {200, Headers2, _} = do_get("/dir/index.html", Config), + {_, Etag2} = lists:keyfind(<<"etag">>, 1, Headers2), + true = Etag1 =/= Etag2, + ok. + +etag_disable(Config) -> + doc("Get a file with disabled Etag and make sure no Etag is provided."), + {200, Headers, _} = do_get("/etag/disable", Config), + false = lists:keyfind(<<"etag">>, 1, Headers), + ok. + +file(Config) -> + doc("Get a file with hardcoded route."), + {200, Headers, <<"body{color:red}\n">>} = do_get("/file/style.css", Config), + {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +if_match(Config) -> + doc("Get a file with If-Match matching."), + {200, _, _} = do_get("/etag/custom", [ + {<<"if-match">>, <<"\"etag\"">>} + ], Config), + ok. + +if_match_fail(Config) -> + doc("Get a file with If-Match not matching."), + {412, _, _} = do_get("/etag/custom", [ + {<<"if-match">>, <<"\"invalid\"">>} + ], Config), + ok. + +if_match_invalid(Config) -> + doc("Try to get a file with an invalid If-Match header."), + {400, _, _} = do_get("/etag/custom", [ + {<<"if-match">>, <<"bad input">>} + ], Config), + ok. + +if_match_list(Config) -> + doc("Get a file with If-Match matching."), + {200, _, _} = do_get("/etag/custom", [ + {<<"if-match">>, <<"\"invalid\", \"etag\", \"cowboy\"">>} + ], Config), + ok. + +if_match_list_fail(Config) -> + doc("Get a file with If-Match not matching."), + {412, _, _} = do_get("/etag/custom", [ + {<<"if-match">>, <<"\"invalid\", W/\"etag\", \"cowboy\"">>} + ], Config), + ok. + +if_match_weak(Config) -> + doc("Try to get a file with a weak If-Match header."), + {412, _, _} = do_get("/etag/custom", [ + {<<"if-match">>, <<"W/\"etag\"">>} + ], Config), + ok. + +if_match_wildcard(Config) -> + doc("Get a file with a wildcard If-Match."), + {200, _, _} = do_get("/etag/custom", [ + {<<"if-match">>, <<"*">>} + ], Config), + ok. + +if_modified_since(Config) -> + doc("Get a file with If-Modified-Since in the past."), + {200, _, _} = do_get("/etag/custom", [ + {<<"if-modified-since">>, <<"Sat, 29 Oct 1994 19:43:31 GMT">>} + ], Config), + ok. + +if_modified_since_fail(Config) -> + doc("Get a file with If-Modified-Since equal to file modification time."), + LastModified = filelib:last_modified(config(static_dir, Config) ++ "/style.css"), + {304, _, _} = do_get("/etag/custom", [ + {<<"if-modified-since">>, httpd_util:rfc1123_date(LastModified)} + ], Config), + ok. + +if_modified_since_future(Config) -> + doc("Get a file with If-Modified-Since in the future."), + {{Year, _, _}, {_, _, _}} = calendar:universal_time(), + {200, _, _} = do_get("/etag/custom", [ + {<<"if-modified-since">>, [ + <<"Sat, 29 Oct ">>, + integer_to_binary(Year + 1), + <<" 19:43:31 GMT">>]} + ], Config), + ok. + +if_modified_since_if_none_match(Config) -> + doc("Get a file with both If-Modified-Since and If-None-Match headers." + "If-None-Match takes precedence and If-Modified-Since is ignored. (RFC7232 3.3)"), + LastModified = filelib:last_modified(config(static_dir, Config) ++ "/style.css"), + {200, _, _} = do_get("/etag/custom", [ + {<<"if-modified-since">>, httpd_util:rfc1123_date(LastModified)}, + {<<"if-none-match">>, <<"\"not-etag\"">>} + ], Config), + ok. + +if_modified_since_invalid(Config) -> + doc("Get a file with an invalid If-Modified-Since header."), + {200, _, _} = do_get("/etag/custom", [ + {<<"if-modified-since">>, <<"\"not a date\"">>} + ], Config), + ok. + +if_none_match(Config) -> + doc("Get a file with If-None-Match not matching."), + {200, _, _} = do_get("/etag/custom", [ + {<<"if-none-match">>, <<"\"not-etag\"">>} + ], Config), + ok. + +if_none_match_fail(Config) -> + doc("Get a file with If-None-Match matching."), + {304, _, _} = do_get("/etag/custom", [ + {<<"if-none-match">>, <<"\"etag\"">>} + ], Config), + ok. + +if_none_match_invalid(Config) -> + doc("Try to get a file with an invalid If-None-Match header."), + {400, _, _} = do_get("/etag/custom", [ + {<<"if-none-match">>, <<"bad input">>} + ], Config), + ok. + +if_none_match_list(Config) -> + doc("Get a file with If-None-Match not matching."), + {200, _, _} = do_get("/etag/custom", [ + {<<"if-none-match">>, <<"\"invalid\", W/\"not-etag\", \"cowboy\"">>} + ], Config), + ok. + +if_none_match_list_fail(Config) -> + doc("Get a file with If-None-Match matching."), + {304, _, _} = do_get("/etag/custom", [ + {<<"if-none-match">>, <<"\"invalid\", \"etag\", \"cowboy\"">>} + ], Config), + ok. + +if_none_match_weak(Config) -> + doc("Try to get a file with a weak If-None-Match header matching."), + {304, _, _} = do_get("/etag/custom", [ + {<<"if-none-match">>, <<"W/\"etag\"">>} + ], Config), + ok. + +if_none_match_wildcard(Config) -> + doc("Try to get a file with a wildcard If-None-Match."), + {304, _, _} = do_get("/etag/custom", [ + {<<"if-none-match">>, <<"*">>} + ], Config), + ok. + +if_unmodified_since(Config) -> + doc("Get a file with If-Unmodified-Since equal to file modification time."), + LastModified = filelib:last_modified(config(static_dir, Config) ++ "/style.css"), + {200, _, _} = do_get("/etag/custom", [ + {<<"if-unmodified-since">>, httpd_util:rfc1123_date(LastModified)} + ], Config), + ok. + +if_unmodified_since_fail(Config) -> + doc("Get a file with If-Unmodified-Since in the past."), + {412, _, _} = do_get("/etag/custom", [ + {<<"if-unmodified-since">>, <<"Sat, 29 Oct 1994 19:43:31 GMT">>} + ], Config), + ok. + +if_unmodified_since_future(Config) -> + doc("Get a file with If-Unmodified-Since in the future."), + {{Year, _, _}, {_, _, _}} = calendar:universal_time(), + {200, _, _} = do_get("/etag/custom", [ + {<<"if-unmodified-since">>, [ + <<"Sat, 29 Oct ">>, + integer_to_binary(Year + 1), + <<" 19:43:31 GMT">>]} + ], Config), + ok. + +if_unmodified_since_if_match(Config) -> + doc("Get a file with both If-Unmodified-Since and If-Match headers." + "If-Match takes precedence and If-Unmodified-Since is ignored. (RFC7232 3.4)"), + {200, _, _} = do_get("/etag/custom", [ + {<<"if-unmodified-since">>, <<"Sat, 29 Oct 1994 19:43:31 GMT">>}, + {<<"if-match">>, <<"\"etag\"">>} + ], Config), + ok. + +if_unmodified_since_invalid(Config) -> + doc("Get a file with an invalid If-Unmodified-Since header."), + {200, _, _} = do_get("/etag/custom", [ + {<<"if-unmodified-since">>, <<"\"not a date\"">>} + ], Config), + ok. + +index_file(Config) -> + doc("Get an index file."), + {200, Headers, <<"<html><body>Hello!</body></html>\n">>} = do_get("/index", Config), + {_, <<"text/html">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +index_file_slash(Config) -> + doc("Get an index file with extra slash."), + {200, Headers, <<"<html><body>Hello!</body></html>\n">>} = do_get("/index/", Config), + {_, <<"text/html">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +last_modified(Config) -> + doc("Get a file, modify it, get it again and make sure Last-Modified changes."), + {200, Headers1, _} = do_get("/dir/file.cowboy", Config), + {_, LastModified1} = lists:keyfind(<<"last-modified">>, 1, Headers1), + ok = file:change_time(config(static_dir, Config) ++ "/file.cowboy", + {{config(port, Config), 1, 1}, {1, 1, 1}}), + {200, Headers2, _} = do_get("/dir/file.cowboy", Config), + {_, LastModified2} = lists:keyfind(<<"last-modified">>, 1, Headers2), + true = LastModified1 =/= LastModified2, + ok. + +mime_all_cowboy(Config) -> + doc("Get a .cowboy file. The extension is unknown."), + {200, Headers, _} = do_get("/mime/all/file.cowboy", Config), + {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +mime_all_css(Config) -> + doc("Get a .css file."), + {200, Headers, _} = do_get("/mime/all/style.css", Config), + {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +mime_all_txt(Config) -> + doc("Get a .txt file."), + {200, Headers, _} = do_get("/mime/all/plain.txt", Config), + {_, <<"text/plain">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +mime_crash(Config) -> + doc("Get a file with a crashing mimetype function."), + {500, _, _} = do_get("/mime/crash/style.css", Config), + ok. + +mime_custom_cowboy(Config) -> + doc("Get a .cowboy file."), + {200, Headers, _} = do_get("/mime/custom/file.cowboy", Config), + {_, <<"application/vnd.ninenines.cowboy+xml;v=1">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +mime_custom_css(Config) -> + doc("Get a .css file. The extension is unknown."), + {200, Headers, _} = do_get("/mime/custom/style.css", Config), + {_, <<"application/octet-stream">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +mime_custom_txt(Config) -> + doc("Get a .txt file."), + {200, Headers, _} = do_get("/mime/custom/plain.txt", Config), + {_, <<"text/plain">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +mime_hardcode_binary(Config) -> + doc("Get a .cowboy file with hardcoded route."), + {200, Headers, _} = do_get("/mime/hardcode/binary-form", Config), + {_, <<"application/vnd.ninenines.cowboy+xml;v=1">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +mime_hardcode_tuple(Config) -> + doc("Get a .cowboy file with hardcoded route."), + {200, Headers, _} = do_get("/mime/hardcode/tuple-form", Config), + {_, <<"application/vnd.ninenines.cowboy+xml;v=1">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +priv_file(Config) -> + doc("Get a file with hardcoded route."), + {200, Headers, <<"body{color:red}\n">>} = do_get("/priv_file/style.css", Config), + {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. + +unicode_basic_latin(Config) -> + doc("Get a file with non-urlencoded characters from Unicode Basic Latin block."), + _ = [case do_get("/char/" ++ [C], Config) of + {200, _, << C >>} -> ok; + Error -> exit({error, C, Error}) + end || C <- + %% Excluding the dot which has a special meaning in URLs + %% when they are the only content in a path segment, + %% and is tested as part of filenames in other test cases. + "abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "0123456789" + ":@-_~!$&'()*+,;=" + ], + ok. + +unicode_basic_error(Config) -> + doc("Try to get a file with invalid non-urlencoded characters from Unicode Basic Latin block."), + Exclude = case config(protocol, Config) of + %% Some characters trigger different errors in HTTP/1.1 + %% because they are used for the protocol. + %% + %% # and ? indicate fragment and query components + %% and are therefore not part of the path. + http -> "\r\s#?"; + http2 -> "#?" + end, + _ = [case do_get("/char/" ++ [C], Config) of + {500, _, _} -> ok; + Error -> exit({error, C, Error}) + end || C <- (config(chars, Config) -- Exclude) -- + "abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "0123456789" + ":@-_~!$&'()*+,;=" + ], + ok. + +unicode_basic_latin_urlencoded(Config) -> + doc("Get a file with urlencoded characters from Unicode Basic Latin block."), + _ = [case do_get(lists:flatten(["/char/%", io_lib:format("~2.16.0b", [C])]), Config) of + {200, _, << C >>} -> ok; + Error -> exit({error, C, Error}) + end || C <- config(chars, Config)], + ok. + +unknown_option(Config) -> + doc("Get a file configured with unknown extra options."), + {200, Headers, <<"body{color:red}\n">>} = do_get("/unknown/option", Config), + {_, <<"text/css">>} = lists:keyfind(<<"content-type">>, 1, Headers), + ok. |