%%% @doc URI template handling for RFC 9298 CONNECT-UDP and the %%% CONNECT-TCP draft. %%% %%% RFC 9298 §3 defines the request path as the expansion of a URI %%% template with two variables - `target_host` and `target_port`. %%% `target_host` may be an IPv4 literal, an IPv6 literal, or a %%% registered name; colons and any non-unreserved characters are %%% percent-encoded on the wire. %%% %%% The template engine now lives in `masque_uri_template'; this %%% module is a thin UDP/TCP facade that keeps its historical public %%% API shape and does the UDP-specific validation (`target_host' %%% reg-name / IP literal rules, `target_port' integer range). -module(masque_uri). -export([expand/2, match/2, to_path/1, valid_host/1]). -export([build_authority/2, parse_authority_form/1]). -export_type([template/0, vars/0]). -type template() :: binary(). -type vars() :: #{target_host := binary() | string(), target_port := 1..65535}. %%==================================================================== %% API %%==================================================================== %% @doc Expand a URI template using `Vars'. Returns the absolute path %% to place in the `:path' pseudo-header. Absolute `http(s)://...' %% templates are accepted - only the path-and-onwards portion is %% expanded, mirroring what servers actually match at runtime. -spec expand(template(), vars()) -> binary(). expand(Template, Vars) when is_binary(Template), is_map(Vars) -> PathTpl = to_path(Template), case masque_uri_template:parse_pattern(PathTpl) of {ok, T} -> masque_uri_template:expand(T, Vars); {error, _} = Err -> error({bad_template, Err}) end. %% @doc Match a request path against a template. %% %% Returns `{ok, #{target_host := Host, target_port := Port}}' on %% success with `Host' as a binary (percent-decoded) and `Port' as an %% integer in `1..65535'. Returns `{error, Reason}' otherwise. -spec match(template(), binary()) -> {ok, #{target_host := binary(), target_port := 1..65535}} | {error, no_match | bad_port | bad_host | bad_template}. match(Template, Path) when is_binary(Template), is_binary(Path) -> case masque_uri_template:parse_pattern(to_path(Template)) of {ok, T} -> match_with(T, Path); {error, _} -> {error, bad_template} end. match_with(T, Path) -> case masque_uri_template:match(T, Path) of {ok, #{target_host := Host, target_port := Port}} when byte_size(Host) > 0 -> case {valid_host(Host), parse_port(Port)} of {true, {ok, PortInt}} -> {ok, #{target_host => Host, target_port => PortInt}}; {false, _} -> {error, bad_host}; {_, error} -> {error, bad_port} end; {ok, _} -> {error, bad_host}; {error, no_match} -> {error, no_match}; {error, bad_pct} -> {error, bad_host} end. %% @doc Strip an absolute `http(s)://…' template to its path portion. %% Path-shaped templates pass through unchanged. -spec to_path(binary()) -> binary(). to_path(<<"http://", Rest/binary>>) -> drop_authority(Rest); to_path(<<"https://", Rest/binary>>) -> drop_authority(Rest); to_path(Path) -> Path. drop_authority(Rest) -> case binary:match(Rest, <<"/">>) of {Pos, 1} -> <<_:Pos/binary, Tail/binary>> = Rest, Tail; nomatch -> <<"/">> end. %% @doc Validate `Host' as an IPv4 literal, IPv6 literal, or LDH %% registered name. Rejects IPv6 zone identifiers (RFC 3986 excludes %% the `%zone' suffix from URI host syntax). -spec valid_host(binary()) -> boolean(). valid_host(<<>>) -> false; valid_host(Host) when is_binary(Host) -> S = binary_to_list(Host), case inet:parse_address(S) of {ok, _} -> not has_zone_id(Host); {error, _} -> valid_reg_name(Host) end. has_zone_id(Host) -> binary:match(Host, <<"%">>) =/= nomatch. %% reg-name per RFC 3986: one or more labels joined by dots, each label %% a non-empty run of alphanumerics / `-' with no leading or trailing %% hyphen. valid_reg_name(Host) -> Labels = binary:split(Host, <<".">>, [global]), Labels =/= [] andalso lists:all(fun valid_label/1, Labels). valid_label(<<>>) -> false; valid_label(L) -> Bytes = binary_to_list(L), lists:all(fun is_ldh/1, Bytes) andalso hd(Bytes) =/= $- andalso lists:last(Bytes) =/= $-. is_ldh(C) when C >= $a, C =< $z -> true; is_ldh(C) when C >= $A, C =< $Z -> true; is_ldh(C) when C >= $0, C =< $9 -> true; is_ldh($-) -> true; is_ldh(_) -> false. %%==================================================================== %% Internal %%==================================================================== parse_port(Bin) when is_binary(Bin) -> try binary_to_integer(Bin) of P when is_integer(P), P >= 1, P =< 65535 -> {ok, P}; _ -> error catch _:_ -> error end; parse_port(Int) when is_integer(Int), Int >= 1, Int =< 65535 -> {ok, Int}; parse_port(_) -> error. %%==================================================================== %% Authority helpers (for CONNECT-TCP request-target + Host header) %%==================================================================== %% @doc Format a `host:port' authority. IPv6 literals are wrapped in %% square brackets per RFC 3986 §3.2.2. Used on the client side to %% build the CONNECT request-target and `Host' header. -spec build_authority(binary(), inet:port_number()) -> binary(). build_authority(Host, Port) when is_binary(Host), is_integer(Port) -> HostPart = case is_ipv6_literal(Host) of true -> <<"[", Host/binary, "]">>; false -> Host end, iolist_to_binary([HostPart, ":", integer_to_binary(Port)]). %% @doc Parse the authority-form of a request-target used by classic %% CONNECT (RFC 9112 §3.2.3): `host:port' or `[ipv6]:port'. Strips the %% brackets from the IPv6 literal on the way out. Rejects malformed %% inputs (missing port, non-numeric port, empty host). -spec parse_authority_form(binary()) -> {ok, binary(), inet:port_number()} | {error, term()}. parse_authority_form(<<"[", Rest/binary>>) -> case binary:split(Rest, <<"]:">>) of [Host, PortBin] when Host =/= <<>> -> case parse_port(PortBin) of {ok, Port} -> {ok, Host, Port}; error -> {error, bad_port} end; _ -> {error, bad_authority} end; parse_authority_form(Bin) when is_binary(Bin) -> case binary:matches(Bin, <<":">>) of [{Pos, 1}] -> <> = Bin, case Host of <<>> -> {error, bad_host}; _ -> case parse_port(PortBin) of {ok, Port} -> {ok, Host, Port}; error -> {error, bad_port} end end; _ -> {error, bad_authority} end; parse_authority_form(_) -> {error, bad_authority}. is_ipv6_literal(Host) when is_binary(Host) -> case inet:parse_address(binary_to_list(Host)) of {ok, {_, _, _, _, _, _, _, _}} -> true; _ -> false end.