Current section
Files
Jump to
Current section
Files
lib/http/h3/web_transport.ex
defmodule HTTP.H3.WebTransport do
@moduledoc false
alias HTTP.H3.Settings
@protocol "webtransport-h3"
@required_connect_pseudo_headers [":method", ":protocol", ":scheme", ":authority", ":path"]
@type headers :: [{String.t(), String.t()}] | map()
@spec protocol() :: String.t()
def protocol, do: @protocol
@spec client_settings(keyword()) :: Settings.settings()
def client_settings(opts \\ []) do
[
{Settings.wt_enabled(), 1},
{Settings.enable_connect_protocol(), 1},
{Settings.h3_datagram(), 1}
]
|> maybe_setting(
Settings.wt_initial_max_streams_uni(),
Keyword.get(opts, :initial_max_streams_uni, 0)
)
|> maybe_setting(
Settings.wt_initial_max_streams_bidi(),
Keyword.get(opts, :initial_max_streams_bidi, 0)
)
|> maybe_setting(Settings.wt_initial_max_data(), Keyword.get(opts, :initial_max_data, 0))
end
@spec validate_client_settings(Settings.settings()) :: :ok | {:error, term()}
def validate_client_settings(settings) do
with :ok <- Settings.validate(settings),
{:ok, normalized} <- Settings.normalize(settings),
:ok <-
require_setting(
normalized,
Settings.wt_enabled(),
&(&1 == 1),
:webtransport_not_enabled
),
:ok <-
require_setting(
normalized,
Settings.enable_connect_protocol(),
&(&1 == 1),
:extended_connect_disabled
) do
require_setting(normalized, Settings.h3_datagram(), &(&1 == 1), :h3_datagram_disabled)
end
end
@spec validate_server_settings(Settings.settings()) :: :ok | {:error, term()}
def validate_server_settings(settings) do
with :ok <- Settings.validate(settings),
{:ok, normalized} <- Settings.normalize(settings),
:ok <-
require_setting(
normalized,
Settings.wt_enabled(),
&(&1 == 1),
:webtransport_not_enabled
),
:ok <-
require_setting(
normalized,
Settings.enable_connect_protocol(),
&(&1 == 1),
:extended_connect_disabled
) do
require_setting(normalized, Settings.h3_datagram(), &(&1 == 1), :h3_datagram_disabled)
end
end
@spec connect_pseudo_headers(String.t() | URI.t()) ::
{:ok, [{String.t(), String.t()}]} | {:error, term()}
def connect_pseudo_headers(url) do
with {:ok, uri} <- normalize_uri(url),
{:ok, authority} <- authority(uri),
{:ok, path} <- request_path(uri) do
{:ok,
[
{":method", "CONNECT"},
{":protocol", @protocol},
{":scheme", "https"},
{":authority", authority},
{":path", path}
]}
end
end
@spec validate_connect_pseudo_headers(headers()) :: {:ok, map()} | {:error, term()}
def validate_connect_pseudo_headers(headers) do
with {:ok, headers} <- normalize_headers(headers),
:ok <- validate_pseudo_header_order(headers),
{:ok, pseudo_headers} <- collect_pseudo_headers(headers),
:ok <- require_connect_pseudo_headers(pseudo_headers),
:ok <- validate_connect_pseudo_header_values(pseudo_headers) do
{:ok, pseudo_headers}
end
end
defp maybe_setting(settings, _setting_id, 0), do: settings
defp maybe_setting(settings, setting_id, value), do: settings ++ [{setting_id, value}]
defp require_setting(settings, setting_id, predicate, reason) do
case List.keyfind(settings, setting_id, 0) do
{^setting_id, value} ->
if predicate.(value), do: :ok, else: {:error, reason}
nil ->
{:error, reason}
end
end
defp normalize_uri(%URI{} = uri), do: validate_uri(uri)
defp normalize_uri(url) when is_binary(url) do
url
|> URI.parse()
|> validate_uri()
end
defp normalize_uri(_url), do: {:error, :invalid_url}
defp validate_uri(%URI{fragment: fragment}) when is_binary(fragment),
do: {:error, :fragment_not_allowed}
defp validate_uri(%URI{scheme: "https", host: host} = uri) when is_binary(host),
do: {:ok, uri}
defp validate_uri(%URI{scheme: scheme}), do: {:error, {:unsupported_scheme, scheme}}
defp authority(%URI{host: host, port: port}) do
authority =
host
|> bracket_ipv6_host()
|> maybe_append_port(port)
validate_header_value(authority, :invalid_authority)
end
defp bracket_ipv6_host(host) do
if String.contains?(host, ":") and not String.starts_with?(host, "[") do
"[" <> host <> "]"
else
host
end
end
defp maybe_append_port(host, nil), do: host
defp maybe_append_port(host, 443), do: host
defp maybe_append_port(host, port), do: host <> ":" <> Integer.to_string(port)
defp request_path(%URI{path: path, query: query}) do
path =
case path do
nil -> "/"
"" -> "/"
path -> path
end
path =
if is_binary(query) do
path <> "?" <> query
else
path
end
with {:ok, path} <- validate_header_value(path, :invalid_path) do
if String.starts_with?(path, "/") do
{:ok, path}
else
{:error, :invalid_path}
end
end
end
defp normalize_headers(%HTTP.Headers{headers: headers}), do: normalize_headers(headers)
defp normalize_headers(headers) when is_map(headers),
do: headers |> Map.to_list() |> normalize_headers()
defp normalize_headers(headers) when is_list(headers) do
if Enum.all?(headers, &valid_header?/1) do
{:ok, headers}
else
{:error, :invalid_headers}
end
end
defp normalize_headers(_headers), do: {:error, :invalid_headers}
defp valid_header?({name, value}), do: is_binary(name) and is_binary(value)
defp valid_header?(_header), do: false
defp validate_pseudo_header_order(headers) do
headers
|> Enum.reduce_while(false, fn {name, _value}, seen_regular_header? ->
cond do
pseudo_header?(name) and seen_regular_header? ->
{:halt, {:error, :pseudo_header_after_regular_header}}
pseudo_header?(name) ->
{:cont, false}
true ->
{:cont, true}
end
end)
|> case do
{:error, reason} -> {:error, reason}
_seen_regular_header? -> :ok
end
end
defp collect_pseudo_headers(headers) do
Enum.reduce_while(headers, {:ok, %{}}, fn {name, value}, {:ok, acc} ->
if pseudo_header?(name) do
cond do
name not in @required_connect_pseudo_headers ->
{:halt, {:error, {:unknown_pseudo_header, name}}}
Map.has_key?(acc, name) ->
{:halt, {:error, {:duplicate_pseudo_header, name}}}
true ->
{:cont, {:ok, Map.put(acc, name, value)}}
end
else
{:cont, {:ok, acc}}
end
end)
end
defp require_connect_pseudo_headers(pseudo_headers) do
case Enum.find(@required_connect_pseudo_headers, &(not Map.has_key?(pseudo_headers, &1))) do
nil -> :ok
missing -> {:error, {:missing_pseudo_header, missing}}
end
end
defp validate_connect_pseudo_header_values(pseudo_headers) do
with :ok <-
validate_exact_pseudo_header(
pseudo_headers,
":method",
"CONNECT",
:invalid_connect_method
),
:ok <-
validate_exact_pseudo_header(
pseudo_headers,
":protocol",
@protocol,
:invalid_connect_protocol
),
:ok <-
validate_exact_pseudo_header(
pseudo_headers,
":scheme",
"https",
:invalid_connect_scheme
),
{:ok, _authority} <-
validate_header_value(Map.fetch!(pseudo_headers, ":authority"), :invalid_authority),
{:ok, path} <- validate_header_value(Map.fetch!(pseudo_headers, ":path"), :invalid_path) do
if String.starts_with?(path, "/") do
:ok
else
{:error, :invalid_path}
end
end
end
defp validate_exact_pseudo_header(pseudo_headers, name, value, error) do
if Map.fetch!(pseudo_headers, name) == value do
:ok
else
{:error, error}
end
end
defp validate_header_value("", reason), do: {:error, reason}
defp validate_header_value(value, reason) when is_binary(value) do
if String.match?(value, ~r/[\x00-\x20\x7f]/) do
{:error, reason}
else
{:ok, value}
end
end
defp pseudo_header?(name), do: String.starts_with?(name, ":")
end