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