361 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Elixir
		
	
	
	
	
	
			
		
		
	
	
			361 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Elixir
		
	
	
	
	
	
defmodule HPAX do
 | 
						|
  @moduledoc """
 | 
						|
  Support for the HPACK header compression algorithm.
 | 
						|
 | 
						|
  This module provides support for the HPACK header compression algorithm used mainly in HTTP/2.
 | 
						|
 | 
						|
  ## Encoding and decoding contexts
 | 
						|
 | 
						|
  The HPACK algorithm requires both
 | 
						|
 | 
						|
    * an encoding context on the encoder side
 | 
						|
    * a decoding context on the decoder side
 | 
						|
 | 
						|
  These contexts are semantically different but structurally the same. In HPACK they are
 | 
						|
  implemented as **HPACK tables**. This library uses the name "tables" everywhere internally
 | 
						|
 | 
						|
  HPACK tables can be created through the `new/1` function.
 | 
						|
  """
 | 
						|
 | 
						|
  alias HPAX.{Table, Types}
 | 
						|
 | 
						|
  @typedoc """
 | 
						|
  An HPACK table.
 | 
						|
 | 
						|
  This can be used for encoding or decoding.
 | 
						|
  """
 | 
						|
  @typedoc since: "0.2.0"
 | 
						|
  @opaque table() :: Table.t()
 | 
						|
 | 
						|
  @typedoc """
 | 
						|
  An HPACK header name.
 | 
						|
  """
 | 
						|
  @type header_name() :: binary()
 | 
						|
 | 
						|
  @typedoc """
 | 
						|
  An HPACK header value.
 | 
						|
  """
 | 
						|
  @type header_value() :: binary()
 | 
						|
 | 
						|
  @valid_header_actions [:store, :store_name, :no_store, :never_store]
 | 
						|
 | 
						|
  @doc """
 | 
						|
  Creates a new HPACK table.
 | 
						|
 | 
						|
  Same as `new/2` with default options.
 | 
						|
  """
 | 
						|
  @spec new(non_neg_integer()) :: table()
 | 
						|
  def new(max_table_size), do: new(max_table_size, [])
 | 
						|
 | 
						|
  @doc """
 | 
						|
  Create a new HPACK table that can be used as encoding or decoding context.
 | 
						|
 | 
						|
  See the "Encoding and decoding contexts" section in the module documentation.
 | 
						|
 | 
						|
  `max_table_size` is the maximum table size (in bytes) for the newly created table.
 | 
						|
 | 
						|
  ## Options
 | 
						|
 | 
						|
  This function accepts the following `options`:
 | 
						|
 | 
						|
    * `:huffman_encoding` - (since 0.2.0) `:always` or `:never`. If `:always`,
 | 
						|
      then HPAX will always encode headers using Huffman encoding. If `:never`,
 | 
						|
      HPAX will not use any Huffman encoding. Defaults to `:never`.
 | 
						|
 | 
						|
  ## Examples
 | 
						|
 | 
						|
      encoding_context = HPAX.new(4096)
 | 
						|
 | 
						|
  """
 | 
						|
  @doc since: "0.2.0"
 | 
						|
  @spec new(non_neg_integer(), [keyword()]) :: table()
 | 
						|
  def new(max_table_size, options)
 | 
						|
      when is_integer(max_table_size) and max_table_size >= 0 and is_list(options) do
 | 
						|
    options = Keyword.put_new(options, :huffman_encoding, :never)
 | 
						|
 | 
						|
    Enum.each(options, fn
 | 
						|
      {:huffman_encoding, _huffman_encoding} -> :ok
 | 
						|
      {key, _value} -> raise ArgumentError, "unknown option: #{inspect(key)}"
 | 
						|
    end)
 | 
						|
 | 
						|
    Table.new(max_table_size, Keyword.fetch!(options, :huffman_encoding))
 | 
						|
  end
 | 
						|
 | 
						|
  @doc """
 | 
						|
  Resizes the given table to the given maximum size.
 | 
						|
 | 
						|
  This is intended for use where the overlying protocol has signaled a change to the table's
 | 
						|
  maximum size, such as when an HTTP/2 `SETTINGS` frame is received.
 | 
						|
 | 
						|
  If the indicated size is less than the table's current size, entries
 | 
						|
  will be evicted as needed to fit within the specified size, and the table's
 | 
						|
  maximum size will be decreased to the specified value. A flag will also be
 | 
						|
  set which will enqueue a "dynamic table size update" command to be prefixed
 | 
						|
  to the next block encoded with this table, per
 | 
						|
  [RFC9113§4.3.1](https://www.rfc-editor.org/rfc/rfc9113.html#section-4.3.1).
 | 
						|
 | 
						|
  If the indicated size is greater than or equal to the table's current max size, no entries are evicted
 | 
						|
  and the table's maximum size changes to the specified value.
 | 
						|
 | 
						|
  ## Examples
 | 
						|
 | 
						|
      decoding_context = HPAX.new(4096)
 | 
						|
      HPAX.resize(decoding_context, 8192)
 | 
						|
 | 
						|
  """
 | 
						|
  @spec resize(table(), non_neg_integer()) :: table()
 | 
						|
  defdelegate resize(table, new_max_size), to: Table
 | 
						|
 | 
						|
  @doc """
 | 
						|
  Decodes a header block fragment (HBF) through a given table.
 | 
						|
 | 
						|
  If decoding is successful, this function returns a `{:ok, headers, updated_table}` tuple where
 | 
						|
  `headers` is a list of decoded headers, and `updated_table` is the updated table. If there's
 | 
						|
  an error in decoding, this function returns `{:error, reason}`.
 | 
						|
 | 
						|
  ## Examples
 | 
						|
 | 
						|
      decoding_context = HPAX.new(1000)
 | 
						|
      hbf = get_hbf_from_somewhere()
 | 
						|
      HPAX.decode(hbf, decoding_context)
 | 
						|
      #=> {:ok, [{":method", "GET"}], decoding_context}
 | 
						|
 | 
						|
  """
 | 
						|
  @spec decode(binary(), table()) ::
 | 
						|
          {:ok, [{header_name(), header_value()}], table()} | {:error, term()}
 | 
						|
 | 
						|
  # Dynamic resizes must occur only at the start of a block
 | 
						|
  # https://datatracker.ietf.org/doc/html/rfc7541#section-4.2
 | 
						|
  def decode(<<0b001::3, rest::bitstring>>, %Table{} = table) do
 | 
						|
    {new_max_size, rest} = decode_integer(rest, 5)
 | 
						|
 | 
						|
    # Dynamic resizes must be less than protocol max table size
 | 
						|
    # https://datatracker.ietf.org/doc/html/rfc7541#section-6.3
 | 
						|
    if new_max_size <= table.protocol_max_table_size do
 | 
						|
      decode(rest, Table.dynamic_resize(table, new_max_size))
 | 
						|
    else
 | 
						|
      {:error, :protocol_error}
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def decode(block, %Table{} = table) when is_binary(block) do
 | 
						|
    decode_headers(block, table, _acc = [])
 | 
						|
  catch
 | 
						|
    :throw, {:hpax, error} -> {:error, error}
 | 
						|
  end
 | 
						|
 | 
						|
  @doc """
 | 
						|
  Encodes a list of headers through the given table.
 | 
						|
 | 
						|
  Returns a two-element tuple where the first element is a binary representing the encoded headers
 | 
						|
  and the second element is an updated table.
 | 
						|
 | 
						|
  ## Examples
 | 
						|
 | 
						|
      headers = [{:store, ":authority", "https://example.com"}]
 | 
						|
      encoding_context = HPAX.new(1000)
 | 
						|
      HPAX.encode(headers, encoding_context)
 | 
						|
      #=> {iodata, updated_encoding_context}
 | 
						|
 | 
						|
  """
 | 
						|
  @spec encode([header], table()) :: {iodata(), table()}
 | 
						|
        when header: {action, header_name(), header_value()},
 | 
						|
             action: :store | :store_name | :no_store | :never_store
 | 
						|
  def encode(headers, %Table{} = table) when is_list(headers) do
 | 
						|
    {table, pending_resizes} = Table.pop_pending_resizes(table)
 | 
						|
    acc = Enum.map(pending_resizes, &[<<0b001::3, Types.encode_integer(&1, 5)::bitstring>>])
 | 
						|
    encode_headers(headers, table, acc)
 | 
						|
  end
 | 
						|
 | 
						|
  @doc """
 | 
						|
  Encodes a list of headers through the given table, applying the same `action` to all of them.
 | 
						|
 | 
						|
  This function is the similar to `encode/2`, but `headers` are `{name, value}` tuples instead,
 | 
						|
  and the same `action` is applied to all headers.
 | 
						|
 | 
						|
    ## Examples
 | 
						|
 | 
						|
      headers = [{":authority", "https://example.com"}]
 | 
						|
      encoding_context = HPAX.new(1000)
 | 
						|
      HPAX.encode(:store, headers, encoding_context)
 | 
						|
      #=> {iodata, updated_encoding_context}
 | 
						|
 | 
						|
  """
 | 
						|
  @doc since: "0.2.0"
 | 
						|
  @spec encode(action, [header], table()) :: {iodata(), table()}
 | 
						|
        when action: :store | :store_name | :no_store | :never_store,
 | 
						|
             header: {header_name(), header_value()}
 | 
						|
  def encode(action, headers, %Table{} = table)
 | 
						|
      when is_list(headers) and action in [:store, :store_name, :no_store, :never_store] do
 | 
						|
    headers
 | 
						|
    |> Enum.map(fn {name, value} -> {action, name, value} end)
 | 
						|
    |> encode(table)
 | 
						|
  end
 | 
						|
 | 
						|
  ## Helpers
 | 
						|
 | 
						|
  defp decode_headers(<<>>, table, acc) do
 | 
						|
    {:ok, Enum.reverse(acc), table}
 | 
						|
  end
 | 
						|
 | 
						|
  # Indexed header field
 | 
						|
  # http://httpwg.org/specs/rfc7541.html#rfc.section.6.1
 | 
						|
  defp decode_headers(<<0b1::1, rest::bitstring>>, table, acc) do
 | 
						|
    {index, rest} = decode_integer(rest, 7)
 | 
						|
    decode_headers(rest, table, [lookup_by_index!(table, index) | acc])
 | 
						|
  end
 | 
						|
 | 
						|
  # Literal header field with incremental indexing
 | 
						|
  # http://httpwg.org/specs/rfc7541.html#rfc.section.6.2.1
 | 
						|
  defp decode_headers(<<0b01::2, rest::bitstring>>, table, acc) do
 | 
						|
    {name, value, rest} =
 | 
						|
      case rest do
 | 
						|
        # The header name is a string.
 | 
						|
        <<0::6, rest::binary>> ->
 | 
						|
          {name, rest} = decode_binary(rest)
 | 
						|
          {value, rest} = decode_binary(rest)
 | 
						|
          {name, value, rest}
 | 
						|
 | 
						|
        # The header name is an index to be looked up in the table.
 | 
						|
        _other ->
 | 
						|
          {index, rest} = decode_integer(rest, 6)
 | 
						|
          {value, rest} = decode_binary(rest)
 | 
						|
          {name, _value} = lookup_by_index!(table, index)
 | 
						|
          {name, value, rest}
 | 
						|
      end
 | 
						|
 | 
						|
    decode_headers(rest, Table.add(table, name, value), [{name, value} | acc])
 | 
						|
  end
 | 
						|
 | 
						|
  # Literal header field without indexing
 | 
						|
  # http://httpwg.org/specs/rfc7541.html#rfc.section.6.2.2
 | 
						|
  defp decode_headers(<<0b0000::4, rest::bitstring>>, table, acc) do
 | 
						|
    {name, value, rest} =
 | 
						|
      case rest do
 | 
						|
        <<0::4, rest::binary>> ->
 | 
						|
          {name, rest} = decode_binary(rest)
 | 
						|
          {value, rest} = decode_binary(rest)
 | 
						|
          {name, value, rest}
 | 
						|
 | 
						|
        _other ->
 | 
						|
          {index, rest} = decode_integer(rest, 4)
 | 
						|
          {value, rest} = decode_binary(rest)
 | 
						|
          {name, _value} = lookup_by_index!(table, index)
 | 
						|
          {name, value, rest}
 | 
						|
      end
 | 
						|
 | 
						|
    decode_headers(rest, table, [{name, value} | acc])
 | 
						|
  end
 | 
						|
 | 
						|
  # Literal header field never indexed
 | 
						|
  # http://httpwg.org/specs/rfc7541.html#rfc.section.6.2.3
 | 
						|
  defp decode_headers(<<0b0001::4, rest::bitstring>>, table, acc) do
 | 
						|
    {name, value, rest} =
 | 
						|
      case rest do
 | 
						|
        <<0::4, rest::binary>> ->
 | 
						|
          {name, rest} = decode_binary(rest)
 | 
						|
          {value, rest} = decode_binary(rest)
 | 
						|
          {name, value, rest}
 | 
						|
 | 
						|
        _other ->
 | 
						|
          {index, rest} = decode_integer(rest, 4)
 | 
						|
          {value, rest} = decode_binary(rest)
 | 
						|
          {name, _value} = lookup_by_index!(table, index)
 | 
						|
          {name, value, rest}
 | 
						|
      end
 | 
						|
 | 
						|
    # TODO: enforce the "never indexed" part somehow.
 | 
						|
    decode_headers(rest, table, [{name, value} | acc])
 | 
						|
  end
 | 
						|
 | 
						|
  defp decode_headers(_other, _table, _acc) do
 | 
						|
    throw({:hpax, :protocol_error})
 | 
						|
  end
 | 
						|
 | 
						|
  defp lookup_by_index!(table, index) do
 | 
						|
    case Table.lookup_by_index(table, index) do
 | 
						|
      {:ok, header} -> header
 | 
						|
      :error -> throw({:hpax, {:index_not_found, index}})
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  defp decode_integer(bitstring, prefix) do
 | 
						|
    case Types.decode_integer(bitstring, prefix) do
 | 
						|
      {:ok, int, rest} -> {int, rest}
 | 
						|
      :error -> throw({:hpax, :bad_integer_encoding})
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  defp decode_binary(binary) do
 | 
						|
    case Types.decode_binary(binary) do
 | 
						|
      {:ok, binary, rest} -> {binary, rest}
 | 
						|
      :error -> throw({:hpax, :bad_binary_encoding})
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  defp encode_headers([], table, acc) do
 | 
						|
    {acc, table}
 | 
						|
  end
 | 
						|
 | 
						|
  defp encode_headers([{action, name, value} | rest], table, acc)
 | 
						|
       when action in @valid_header_actions and is_binary(name) and is_binary(value) do
 | 
						|
    huffman? = table.huffman_encoding == :always
 | 
						|
 | 
						|
    {encoded, table} =
 | 
						|
      case Table.lookup_by_header(table, name, value) do
 | 
						|
        {:full, index} ->
 | 
						|
          {encode_indexed_header(index), table}
 | 
						|
 | 
						|
        {:name, index} when action == :store ->
 | 
						|
          {encode_literal_header_with_indexing(index, value, huffman?),
 | 
						|
           Table.add(table, name, value)}
 | 
						|
 | 
						|
        {:name, index} when action in [:store_name, :no_store] ->
 | 
						|
          {encode_literal_header_without_indexing(index, value, huffman?), table}
 | 
						|
 | 
						|
        {:name, index} when action == :never_store ->
 | 
						|
          {encode_literal_header_never_indexed(index, value, huffman?), table}
 | 
						|
 | 
						|
        :not_found when action in [:store, :store_name] ->
 | 
						|
          {encode_literal_header_with_indexing(name, value, huffman?),
 | 
						|
           Table.add(table, name, value)}
 | 
						|
 | 
						|
        :not_found when action == :no_store ->
 | 
						|
          {encode_literal_header_without_indexing(name, value, huffman?), table}
 | 
						|
 | 
						|
        :not_found when action == :never_store ->
 | 
						|
          {encode_literal_header_never_indexed(name, value, huffman?), table}
 | 
						|
      end
 | 
						|
 | 
						|
    encode_headers(rest, table, [acc, encoded])
 | 
						|
  end
 | 
						|
 | 
						|
  defp encode_indexed_header(index) do
 | 
						|
    <<1::1, Types.encode_integer(index, 7)::bitstring>>
 | 
						|
  end
 | 
						|
 | 
						|
  defp encode_literal_header_with_indexing(index, value, huffman?) when is_integer(index) do
 | 
						|
    [<<1::2, Types.encode_integer(index, 6)::bitstring>>, Types.encode_binary(value, huffman?)]
 | 
						|
  end
 | 
						|
 | 
						|
  defp encode_literal_header_with_indexing(name, value, huffman?) when is_binary(name) do
 | 
						|
    [<<1::2, 0::6>>, Types.encode_binary(name, huffman?), Types.encode_binary(value, huffman?)]
 | 
						|
  end
 | 
						|
 | 
						|
  defp encode_literal_header_without_indexing(index, value, huffman?) when is_integer(index) do
 | 
						|
    [<<0::4, Types.encode_integer(index, 4)::bitstring>>, Types.encode_binary(value, huffman?)]
 | 
						|
  end
 | 
						|
 | 
						|
  defp encode_literal_header_without_indexing(name, value, huffman?) when is_binary(name) do
 | 
						|
    [<<0::4, 0::4>>, Types.encode_binary(name, huffman?), Types.encode_binary(value, huffman?)]
 | 
						|
  end
 | 
						|
 | 
						|
  defp encode_literal_header_never_indexed(index, value, huffman?) when is_integer(index) do
 | 
						|
    [<<1::4, Types.encode_integer(index, 4)::bitstring>>, Types.encode_binary(value, huffman?)]
 | 
						|
  end
 | 
						|
 | 
						|
  defp encode_literal_header_never_indexed(name, value, huffman?) when is_binary(name) do
 | 
						|
    [<<1::4, 0::4>>, Types.encode_binary(name, huffman?), Types.encode_binary(value, huffman?)]
 | 
						|
  end
 | 
						|
end
 |