From 5f1708077c3aa7ca8e9c261e7b4ce2575ffc27ef Mon Sep 17 00:00:00 2001 From: Thomas Arts Date: Fri, 15 May 2026 08:43:25 +0200 Subject: [PATCH 1/3] First model --- rebar.config | 4 +- src/riak_api_web_acceptor.erl | 4 ++ test/uri_eqc.erl | 128 ++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 test/uri_eqc.erl diff --git a/rebar.config b/rebar.config index f3a4aab..0fbdc3f 100644 --- a/rebar.config +++ b/rebar.config @@ -19,13 +19,15 @@ "test/riak_api_web_get_random.erl", "test/riak_api_web_ets_store.erl", "test/riak_api_web_trigger.erl", + "test/uri_eqc.erl", "rebar.config" ]}, {exclude_files, []} ]}. {project_plugins, [ - {erlfmt, {git, "https://github.com/OpenRiak/erlfmt.git", {branch, "main"}}} + {erlfmt, {git, "https://github.com/OpenRiak/erlfmt.git", {branch, "main"}}}, + {eqc_rebar, {git, "https://github.com/Quviq/eqc-rebar", {branch, "master"}}} ]}. {eunit_opts, [verbose]}. diff --git a/src/riak_api_web_acceptor.erl b/src/riak_api_web_acceptor.erl index e031df1..286af96 100644 --- a/src/riak_api_web_acceptor.erl +++ b/src/riak_api_web_acceptor.erl @@ -32,6 +32,10 @@ -export([extend_buffer/4]). +-ifdef(TEST). +-export([split_path/1]). +-endif. + -include_lib("kernel/include/logger.hrl"). -define(ACCEPT_TIMEOUT, 10000). diff --git a/test/uri_eqc.erl b/test/uri_eqc.erl new file mode 100644 index 0000000..a068719 --- /dev/null +++ b/test/uri_eqc.erl @@ -0,0 +1,128 @@ +-module(uri_eqc). +-include_lib("eqc/include/eqc.hrl"). + +-export([prop_uri_gen/0, + prop_split_path/0, + prop_uri_string_roundtrip/0]). + +-doc """ +Test the generator for URIs, ensuring that it produces valid URIs that can be parsed by `uri_string:recompose/1`. +""". +-spec prop_uri_gen() -> eqc:property(). +prop_uri_gen() -> + ?FORALL(URIMap, fault_rate(2, 20, uri_map()), + try recompose(URIMap) of + {error, Error, _} -> + ?WHENFAIL(eqc:format("URIMap: ~p, Error: ~p\n", [URIMap, Error]), + collect(Error, false)); + _ -> + collect(uri, true) + catch _:Reason -> + ?WHENFAIL(eqc:format("URIMap: ~p, Error: ~p\n", [URIMap, Reason]), + collect(crash, false)) + end). + +-doc """ +Verify that for a given URI, the path and query parameters are correctly extracted by `riak_api_web_acceptor:split_path/1`. +""". +-spec prop_split_path() -> eqc:property(). +prop_split_path() -> + ?SETUP( + fun() -> + %% TODO remove this when we have onload for the detectors + riak_api_web_acceptor:compile_detectors(), + fun() -> ok end + end, + fault_rate(1, 100, + ?FORALL(URIMap, uri_map(), + begin + URI = recompose(URIMap), + ?FORALL({BinURI, Fault}, fault_inject(iolist_to_binary(URI)), + case riak_api_web_acceptor:split_path(BinURI) of + {ok, {Path, DecodedPath, Params}} -> + StartWithSlash = starts_with_slash(Path), + ?WHENFAIL(eqc:format("BinURI: ~p, Path: ~p, StartWithSlash: ~p, DecodedPath: ~p, Params: ~p\n", + [BinURI, Path, StartWithSlash, DecodedPath, Params]), + conjunction( + [{path, equals(Path, mk_path(StartWithSlash, DecodedPath))} + || not ends_with_slash(Path)] ++ + [{slash_path, equals(string:trim(Path, trailing, "/"), mk_path(StartWithSlash, DecodedPath))} + || ends_with_slash(Path) ] ++ + [{params, equals(Params, maps:get(query, URIMap, []))} || Fault == none] + )); + {halt, Status, _, _Msg, _} -> + ?WHENFAIL(eqc:format("BinURI: ~p, Status: ~p\n", [BinURI, Status]), + Fault /= none) + end) + end))). + + +fault_inject(BinURI) -> + fault(oneof([{utf8(), arbitrary}]), {BinURI, none}). + +-doc """ +When a URI is parsed, the normalization of the resulting binary cannot return an error. +This means that +""". +prop_uri_string_roundtrip() -> + ?FORALL(String, utf8(), + try uri_string:normalize(String) of + URI when is_binary(URI) -> + collect(uri, true); + {error, Error, _} -> + ?WHENFAIL(eqc:format("String: ~p, Error: ~p\n", [String, Error]), + collect(Error, false)) + catch _:_ -> + collect(crash, true) + end). + +uri_map() -> + ?LET({Path, Params}, {list(valid_path_element()), list(query_param())}, + maps:without([query || Params == []], + #{path => filename:join(["/" | Path]), + scheme => oneof(["http", "https"]), + host => oneof(["example.com", "localhost", "127.0.0.1"]), + port => oneof([80, 443, 8080, 8443]), + query => Params + })). + +valid_chars() -> + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:?/#[]@!$&'()*+,;=". + +valid_path_element() -> + list(oneof([elements(valid_chars() -- "/"), 32, 61, 62, choose(128, 255)])). + +query_param() -> + %% {~"company", ~"Bausch & Lomb Canada Inc."}. + {non_empty(unicode()), oneof([non_empty(unicode()), + ?LET(N, int(), integer_to_binary(N)), + true])}. + + +unicode() -> + ?LET(Chars, list(choose(32, 16#D7FF)), + unicode:characters_to_nfc_binary(Chars)). + +%% helper functions + +ends_with_slash(String) -> + case string:find(String, "/", trailing) of + nomatch -> false; + Found -> string:equal(Found, "/") + end. + +starts_with_slash(String) -> + string:prefix(String, "/") /= nomatch. + +mk_path(_, []) -> ~""; +mk_path(true, DecodedPath) -> filename:join(["/" | DecodedPath]); +mk_path(false, DecodedPath) -> filename:join(DecodedPath). + +-doc """ +A recomposition of a generated URI string even if it contains certain injected faults. +""". +recompose(URIMap) -> + case maps:get(query, URIMap, []) of + [] -> uri_string:recompose(URIMap); + Q -> uri_string:recompose(URIMap#{query => uri_string:compose_query(Q)}) + end. \ No newline at end of file From 0604f851ca5147e54927da2e05dc0771b452f03d Mon Sep 17 00:00:00 2001 From: Thomas Arts Date: Fri, 15 May 2026 15:59:30 +0200 Subject: [PATCH 2/3] Error injection with right weight --- test/uri_eqc.erl | 186 ++++++++++++++++++++++++++++------------------- 1 file changed, 110 insertions(+), 76 deletions(-) diff --git a/test/uri_eqc.erl b/test/uri_eqc.erl index a068719..c34619e 100644 --- a/test/uri_eqc.erl +++ b/test/uri_eqc.erl @@ -2,105 +2,119 @@ -include_lib("eqc/include/eqc.hrl"). -export([prop_uri_gen/0, - prop_split_path/0, - prop_uri_string_roundtrip/0]). + prop_uri_gen/1, + prop_split_path/0]). -doc """ -Test the generator for URIs, ensuring that it produces valid URIs that can be parsed by `uri_string:recompose/1`. +Test the generator for URIs, ensuring that it produces both valid URIs that can be normalized +as well as URIs on which normalization fails. """. -spec prop_uri_gen() -> eqc:property(). prop_uri_gen() -> - ?FORALL(URIMap, fault_rate(2, 20, uri_map()), - try recompose(URIMap) of - {error, Error, _} -> - ?WHENFAIL(eqc:format("URIMap: ~p, Error: ~p\n", [URIMap, Error]), - collect(Error, false)); - _ -> - collect(uri, true) - catch _:Reason -> - ?WHENFAIL(eqc:format("URIMap: ~p, Error: ~p\n", [URIMap, Reason]), - collect(crash, false)) - end). + prop_uri_gen(3). + +%% Check that distribution is within 90% of given fault rate. +prop_uri_gen(N) -> + in_parallel( + ?FORALL({_URIMap, URIBin, Fault}, fault_rate(N, 100, uri_pair()), + begin + Normalize = + case uri_string:normalize(URIBin) of + {error, Error, _} -> Error; + _ -> uri + end, + collect(with_title(path_types), + if Normalize /= uri -> invalid_uri; + true -> {dot_segment, string:find(URIBin, "..") /= nomatch} + end, + collect(with_title(fault_injection), Normalize, + check_distribution(positive, 9*(100-N)/1000, Normalize == uri, + check_distribution(negative, 9*N/1000, Normalize /= uri, + Normalize == uri orelse Fault /= none)))) + end)). + -doc """ Verify that for a given URI, the path and query parameters are correctly extracted by `riak_api_web_acceptor:split_path/1`. """. -spec prop_split_path() -> eqc:property(). prop_split_path() -> - ?SETUP( - fun() -> - %% TODO remove this when we have onload for the detectors - riak_api_web_acceptor:compile_detectors(), - fun() -> ok end - end, - fault_rate(1, 100, - ?FORALL(URIMap, uri_map(), - begin - URI = recompose(URIMap), - ?FORALL({BinURI, Fault}, fault_inject(iolist_to_binary(URI)), - case riak_api_web_acceptor:split_path(BinURI) of - {ok, {Path, DecodedPath, Params}} -> - StartWithSlash = starts_with_slash(Path), - ?WHENFAIL(eqc:format("BinURI: ~p, Path: ~p, StartWithSlash: ~p, DecodedPath: ~p, Params: ~p\n", - [BinURI, Path, StartWithSlash, DecodedPath, Params]), - conjunction( - [{path, equals(Path, mk_path(StartWithSlash, DecodedPath))} - || not ends_with_slash(Path)] ++ - [{slash_path, equals(string:trim(Path, trailing, "/"), mk_path(StartWithSlash, DecodedPath))} - || ends_with_slash(Path) ] ++ - [{params, equals(Params, maps:get(query, URIMap, []))} || Fault == none] - )); - {halt, Status, _, _Msg, _} -> - ?WHENFAIL(eqc:format("BinURI: ~p, Status: ~p\n", [BinURI, Status]), - Fault /= none) - end) + fault_rate(3, 100, + ?FORALL({URIMap, URIBin, Fault}, uri_pair(), + collect(with_title(fault_injection), Fault, + case riak_api_web_acceptor:split_path(URIBin) of + {ok, {Path, DecodedPath, Params}} -> + StartWithSlash = starts_with_slash(Path), + ?WHENFAIL(eqc:format("BinURI: ~p, Path: ~p, StartWithSlash: ~p, DecodedPath: ~p, Params: ~p\n", + [URIBin, Path, StartWithSlash, DecodedPath, Params]), + conjunction( + [{path, equal_path(Path, mk_path(StartWithSlash, DecodedPath))} + || not ends_with_slash(Path)] ++ + [{slash_path, equal_path(string:trim(Path, trailing, "/"), mk_path(StartWithSlash, DecodedPath))} + || ends_with_slash(Path) ] ++ + [{params, equals(Params, maps:get(query, URIMap, []))} || maps:get(query, URIMap, []) /= [{~"", true}] ] + )); + {halt, Status, _, _Msg, _} -> + ?WHENFAIL(eqc:format("BinURI: ~p, Status: ~p\n", [URIBin, Status]), + Fault /= none) end))). +equal_path(Path, DecodedPath) -> + try uri_string:unquote(Path) of + UnquotedPath -> + equals(UnquotedPath, DecodedPath) + catch _:_ -> + {Path, '/=', DecodedPath} + end. -fault_inject(BinURI) -> - fault(oneof([{utf8(), arbitrary}]), {BinURI, none}). - --doc """ -When a URI is parsed, the normalization of the resulting binary cannot return an error. -This means that -""". -prop_uri_string_roundtrip() -> - ?FORALL(String, utf8(), - try uri_string:normalize(String) of - URI when is_binary(URI) -> - collect(uri, true); - {error, Error, _} -> - ?WHENFAIL(eqc:format("String: ~p, Error: ~p\n", [String, Error]), - collect(Error, false)) - catch _:_ -> - collect(crash, true) +%% This should return a URIMap and its string representation +%% but the string representation might be an invalid URI due to fault injection. +%% The uri_string module itself does not guarantee a round trip from +%% recompose to normalize (i.e. there are maps that one can recompose, +%% but fail normalization). However, if uri_string can be round tripped, +%% then there is guaranteed no failure in the URI. +uri_pair() -> + ?LET(URIMap, uri_map(), + try uri_string:recompose(recompose_query(URIMap)) of + {error, _, _} -> + {URIMap, recompose(URIMap), fault}; + URI -> + try uri_string:normalize(URI) of + {error, Error, _} -> + {URIMap, unicode:characters_to_nfc_binary(URI), Error}; + _ -> + {URIMap, unicode:characters_to_nfc_binary(URI), none} + catch _:_ -> + {URIMap, unicode:characters_to_nfc_binary(URI), non_normalizable} + end + catch _:_ -> + {URIMap, recompose(URIMap), crash} end). uri_map() -> - ?LET({Path, Params}, {list(valid_path_element()), list(query_param())}, + ?LET({Path, Params}, {less_faulty(10, list(valid_path_element())), list(query_param())}, maps:without([query || Params == []], #{path => filename:join(["/" | Path]), - scheme => oneof(["http", "https"]), - host => oneof(["example.com", "localhost", "127.0.0.1"]), - port => oneof([80, 443, 8080, 8443]), + scheme => less_faulty(10, fault("http", oneof(["http", "https"]))), + host => fault("2001::bd:1", oneof(["example.com", "localhost", "127.0.0.1"])), + port => fault("twelve", oneof([80, 443, 8080, 8443])), query => Params })). -valid_chars() -> - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:?/#[]@!$&'()*+,;=". +valid_chars(Kind) -> + proplists:get_value(Kind, uri_string:allowed_characters(), []). valid_path_element() -> - list(oneof([elements(valid_chars() -- "/"), 32, 61, 62, choose(128, 255)])). + frequency([{5, list(elements(valid_chars(path) -- [$%]))}, %% percent is valid, but only to encode + {1, fault(oneof([[0], " ", ">", unicode(), "%x"]), + oneof([".", ".."]))} + ]). query_param() -> - %% {~"company", ~"Bausch & Lomb Canada Inc."}. - {non_empty(unicode()), oneof([non_empty(unicode()), - ?LET(N, int(), integer_to_binary(N)), - true])}. - + {unicode(), frequency([{9, unicode()}, {1, true}])}. unicode() -> - ?LET(Chars, list(choose(32, 16#D7FF)), + ?LET(Chars, list(choose(26, 16#D7FF)), unicode:characters_to_nfc_binary(Chars)). %% helper functions @@ -116,13 +130,33 @@ starts_with_slash(String) -> mk_path(_, []) -> ~""; mk_path(true, DecodedPath) -> filename:join(["/" | DecodedPath]); -mk_path(false, DecodedPath) -> filename:join(DecodedPath). +mk_path(false, DecodedPath) -> filename:join(DecodedPath). -doc """ A recomposition of a generated URI string even if it contains certain injected faults. """. recompose(URIMap) -> - case maps:get(query, URIMap, []) of - [] -> uri_string:recompose(URIMap); - Q -> uri_string:recompose(URIMap#{query => uri_string:compose_query(Q)}) - end. \ No newline at end of file + %% Build Query map with potential injected faults in it + URI = + [maps:get(scheme, URIMap), + "://", + maps:get(host, URIMap), + ":", + case maps:get(port, URIMap) of + P when is_integer(P) -> integer_to_list(P); + P -> P + end, + maps:get(path, URIMap), + case maps:get(query, URIMap, []) of + [] -> ""; + Q -> "?" ++ lists:join("&", [ if V == true -> K; true -> [K, "=", V] end || {K, V} <- Q ]) + end], + unicode:characters_to_nfc_binary(URI). + +recompose_query(URIMap) -> + maps:put(query, + case maps:get(query, URIMap, []) of + [] -> ""; + Q -> uri_string:compose_query(Q) + end, + URIMap). From 4edd33ceab7ce43ad1b8015ed4548c8d35cda6da Mon Sep 17 00:00:00 2001 From: Thomas Arts Date: Thu, 21 May 2026 09:58:11 +0200 Subject: [PATCH 3/3] All unicode parameters are handled correctly --- test/uri_eqc.erl | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/uri_eqc.erl b/test/uri_eqc.erl index c34619e..d6161c4 100644 --- a/test/uri_eqc.erl +++ b/test/uri_eqc.erl @@ -27,7 +27,7 @@ prop_uri_gen(N) -> if Normalize /= uri -> invalid_uri; true -> {dot_segment, string:find(URIBin, "..") /= nomatch} end, - collect(with_title(fault_injection), Normalize, + collect(with_title(fault_injection), Fault, check_distribution(positive, 9*(100-N)/1000, Normalize == uri, check_distribution(negative, 9*N/1000, Normalize /= uri, Normalize == uri orelse Fault /= none)))) @@ -45,6 +45,7 @@ prop_split_path() -> case riak_api_web_acceptor:split_path(URIBin) of {ok, {Path, DecodedPath, Params}} -> StartWithSlash = starts_with_slash(Path), + collect(with_title(params), length(Params), ?WHENFAIL(eqc:format("BinURI: ~p, Path: ~p, StartWithSlash: ~p, DecodedPath: ~p, Params: ~p\n", [URIBin, Path, StartWithSlash, DecodedPath, Params]), conjunction( @@ -53,7 +54,7 @@ prop_split_path() -> [{slash_path, equal_path(string:trim(Path, trailing, "/"), mk_path(StartWithSlash, DecodedPath))} || ends_with_slash(Path) ] ++ [{params, equals(Params, maps:get(query, URIMap, []))} || maps:get(query, URIMap, []) /= [{~"", true}] ] - )); + ))); {halt, Status, _, _Msg, _} -> ?WHENFAIL(eqc:format("BinURI: ~p, Status: ~p\n", [URIBin, Status]), Fault /= none) @@ -88,7 +89,7 @@ uri_pair() -> {URIMap, unicode:characters_to_nfc_binary(URI), non_normalizable} end catch _:_ -> - {URIMap, recompose(URIMap), crash} + {URIMap, recompose(URIMap), non_recomposable} end). uri_map() -> @@ -111,12 +112,16 @@ valid_path_element() -> ]). query_param() -> - {unicode(), frequency([{9, unicode()}, {1, true}])}. + {unicode(), frequency([{1, digits()}, {9, unicode()}, {1, true}])}. unicode() -> ?LET(Chars, list(choose(26, 16#D7FF)), unicode:characters_to_nfc_binary(Chars)). +digits() -> + ?LET(Chars, list(choose($0, $9)), + unicode:characters_to_nfc_binary(Chars)). + %% helper functions ends_with_slash(String) ->