From 563292a0d5bc74b58a248f09de199325fc36245b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 21 Sep 2026 00:45:21 +0200 Subject: [PATCH 1/2] Add HPAX.protocol_resize/2 for decoding tables resize/2 sets both the protocol maximum and the table's own maximum size. That's right for an encoding context, which takes the whole size the protocol permits and declares it, but a decoding table's maximum size is chosen by the peer's encoder and declared with a dynamic table size update instruction (RFC 7541 4.2). Raising the protocol maximum on a decoding table kept entries the peer's encoder had evicted, so an indexed reference to one of them decoded to a stale header instead of failing. protocol_resize/2 changes only the maximum the peer is permitted to use and follows it down when it drops below the maximum the encoder declared. The encoder then has to declare a size of at most the new maximum at the start of its next block, which RFC 9113 4.3.1 requires the receiver to enforce with a COMPRESSION_ERROR, so decode/2 returns {:error, :missing_size_update} for a block that doesn't start with one. When the maximum changes more than once before that block, the smallest of them is the one that has to be declared. --- lib/hpax.ex | 78 +++++++++++++++++++++++++++++-------- lib/hpax/table.ex | 36 ++++++++++++++++- test/hpax_test.exs | 96 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 17 deletions(-) diff --git a/lib/hpax.ex b/lib/hpax.ex index bc271f9..70b359d 100644 --- a/lib/hpax.ex +++ b/lib/hpax.ex @@ -82,30 +82,62 @@ defmodule HPAX do end @doc """ - Resizes the given table to the given maximum size. + Resizes the given encoding 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. + This is intended for use on an encoding context where the overlying protocol has signaled a + change to the maximum size the encoder is permitted to use, such as when an HTTP/2 `SETTINGS` + frame is received. The table takes the whole size it is permitted, which + [RFC7541§4.2](https://datatracker.ietf.org/doc/html/rfc7541#section-4.2) requires it to declare + to the decoder, so a "dynamic table size update" command is prefixed to the next block encoded + with this table. 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). + maximum size will be decreased to the specified value. 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. + Use `protocol_resize/2` for a decoding context, whose maximum size is chosen by the peer's + encoder rather than by the protocol. + ## Examples - decoding_context = HPAX.new(4096) - HPAX.resize(decoding_context, 8192) + encoding_context = HPAX.new(4096) + HPAX.resize(encoding_context, 8192) """ @spec resize(table(), non_neg_integer()) :: table() defdelegate resize(table, new_max_size), to: Table + @doc """ + Changes the maximum size the peer's encoder is permitted to use for the given decoding table. + + This is intended for use on a decoding context when the overlying protocol communicates a new + maximum to the peer, such as when an HTTP/2 client sends `SETTINGS_HEADER_TABLE_SIZE` and the + server acknowledges it. + [RFC7541§4.2](https://datatracker.ietf.org/doc/html/rfc7541#section-4.2) leaves the table's own + maximum size to the encoder, which declares it with a "dynamic table size update" command, so + raising the permitted maximum doesn't change the size of the table. + + If the new maximum is below the maximum the encoder declared, the table is resized to it and + entries are evicted as needed to fit. The encoder then has to declare a size of at most the new + maximum at the start of its next block, so `decode/2` returns `{:error, :missing_size_update}` + for a block that doesn't start with one, per + [RFC9113§4.3.1](https://www.rfc-editor.org/rfc/rfc9113.html#section-4.3.1). When the maximum + changes more than once before that block, the smallest of them is the one that has to be + declared. + + ## Examples + + decoding_context = HPAX.new(4096) + HPAX.protocol_resize(decoding_context, 8192) + + """ + @doc since: "1.1.0" + @spec protocol_resize(table(), non_neg_integer()) :: table() + defdelegate protocol_resize(table, new_protocol_max_size), to: Table + @doc """ Decodes a header block fragment (HBF) through a given table. @@ -129,15 +161,31 @@ defmodule HPAX do 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} + cond do + # Dynamic resizes must be less than protocol max table size + # https://datatracker.ietf.org/doc/html/rfc7541#section-6.3 + new_max_size > table.protocol_max_table_size -> + {:error, :protocol_error} + + # The smallest maximum size the table was reduced to has to be signalled first + # https://datatracker.ietf.org/doc/html/rfc7541#section-4.2 + is_integer(table.required_minimum_resize) and new_max_size > table.required_minimum_resize -> + {:error, :missing_size_update} + + true -> + table = %{table | required_minimum_resize: nil} + decode(rest, Table.dynamic_resize(table, new_max_size)) end end + # A reduction of the maximum size below the size of the table has to be acknowledged with a + # dynamic table size update at the start of the next block + # https://www.rfc-editor.org/rfc/rfc9113.html#section-4.3.1 + def decode(block, %Table{required_minimum_resize: required}) + when is_binary(block) and is_integer(required) do + {:error, :missing_size_update} + end + def decode(block, %Table{} = table) when is_binary(block) do decode_headers(block, table, _acc = []) catch diff --git a/lib/hpax/table.ex b/lib/hpax/table.ex index 1299955..ff630f0 100644 --- a/lib/hpax/table.ex +++ b/lib/hpax/table.ex @@ -9,7 +9,8 @@ defmodule HPAX.Table do entries: [], size: 0, length: 0, - pending_minimum_resize: nil + pending_minimum_resize: nil, + required_minimum_resize: nil ] @type huffman_encoding() :: :always | :never @@ -21,7 +22,8 @@ defmodule HPAX.Table do entries: [{binary(), binary()}], size: non_neg_integer(), length: non_neg_integer(), - pending_minimum_resize: non_neg_integer() | nil + pending_minimum_resize: non_neg_integer() | nil, + required_minimum_resize: non_neg_integer() | nil } @static_table [ @@ -279,6 +281,36 @@ defmodule HPAX.Table do } end + @doc """ + Changes the maximum size the peer's encoder is permitted to use for this decoding table. + + The table's own maximum size is chosen by that encoder and signalled with dynamic table size + update instructions, so it only follows the protocol maximum down. When the new protocol + maximum is below the maximum the encoder declared, entries are evicted to fit it and the + encoder is required to start its next block with a dynamic table size update of at most that + size, per RFC7541§4.2. `required_minimum_resize` holds the smallest such size until the update + arrives, since that is the one the encoder has to signal. + """ + @spec protocol_resize(t(), non_neg_integer()) :: t() + def protocol_resize(%__MODULE__{} = table, new_protocol_max_table_size) do + table = %{table | protocol_max_table_size: new_protocol_max_table_size} + + if new_protocol_max_table_size < table.max_table_size do + required_minimum_resize = + case table.required_minimum_resize do + nil -> new_protocol_max_table_size + current -> min(current, new_protocol_max_table_size) + end + + %{ + dynamic_resize(table, new_protocol_max_table_size) + | required_minimum_resize: required_minimum_resize + } + else + table + end + end + def dynamic_resize(%__MODULE__{} = table, new_max_table_size) do %{ evict_to_size(table, new_max_table_size) diff --git a/test/hpax_test.exs b/test/hpax_test.exs index 07f7d57..42884ea 100644 --- a/test/hpax_test.exs +++ b/test/hpax_test.exs @@ -259,6 +259,102 @@ defmodule HPAXTest do end end + describe "protocol_resize/2" do + # ":status: 404" stored with incremental indexing, 42 bytes in the dynamic table. + @store_404 <<0x48, 3, "404">> + # ":status: 500" stored the same way. + @store_500 <<0x48, 3, "500">> + # Indexed references to the first and second entries of the dynamic table. + @indexed_62 <<0xBE>> + @indexed_63 <<0xBF>> + # Indexed reference to ":status: 200" in the static table. + @indexed_200 <<0x88>> + + test "raising the maximum leaves the size of the table to the encoder" do + table = HPAX.new(4096) + + # The encoder picks a 64 byte table, which fits one of the entries below. + assert {:ok, [{":status", "404"}], table} = + HPAX.decode(size_update(64) <> @store_404, table) + + table = HPAX.protocol_resize(table, 8192) + assert table.max_table_size == 64 + + # The encoder didn't ask for more room, so storing the second entry evicts the first. + assert {:ok, [{":status", "500"}], table} = HPAX.decode(@store_500, table) + assert {:ok, [{":status", "500"}], table} = HPAX.decode(@indexed_62, table) + assert {:error, {:index_not_found, 63}} = HPAX.decode(@indexed_63, table) + end + + test "lowering the maximum evicts entries and requires a size update" do + assert {:ok, _headers, table} = HPAX.decode(@store_404, HPAX.new(4096)) + assert table.size == 42 + + table = HPAX.protocol_resize(table, 0) + assert table.max_table_size == 0 + assert table.size == 0 + + assert {:error, :missing_size_update} = HPAX.decode(@indexed_200, table) + assert {:error, :missing_size_update} = HPAX.decode(<<>>, table) + + assert {:ok, [{":status", "200"}], table} = + HPAX.decode(size_update(0) <> @indexed_200, table) + + assert {:ok, [{":status", "200"}], _table} = HPAX.decode(@indexed_200, table) + end + + test "a size update is required even when the entries fit the new maximum" do + assert {:ok, _headers, table} = HPAX.decode(@store_404, HPAX.new(4096)) + + table = HPAX.protocol_resize(table, 100) + assert table.max_table_size == 100 + assert table.size == 42 + + assert {:error, :missing_size_update} = HPAX.decode(@indexed_200, table) + + assert {:ok, [{":status", "200"}], _table} = + HPAX.decode(size_update(100) <> @indexed_200, table) + end + + test "no size update is required while the encoder stays below the new maximum" do + assert {:ok, _headers, table} = HPAX.decode(size_update(64) <> @store_404, HPAX.new(4096)) + + table = HPAX.protocol_resize(table, 100) + assert table.max_table_size == 64 + + assert {:ok, [{":status", "200"}], _table} = HPAX.decode(@indexed_200, table) + end + + test "the smallest maximum has to be signalled when it changes more than once" do + assert {:ok, _headers, table} = HPAX.decode(@store_404, HPAX.new(4096)) + + table = + table + |> HPAX.protocol_resize(40) + |> HPAX.protocol_resize(0) + |> HPAX.protocol_resize(4096) + + assert {:error, :missing_size_update} = + HPAX.decode(size_update(40) <> @indexed_200, table) + + assert {:ok, [{":status", "200"}], table} = + HPAX.decode(size_update(0) <> size_update(4096) <> @indexed_200, table) + + assert table.max_table_size == 4096 + end + + test "a size update above the maximum is rejected" do + table = HPAX.protocol_resize(HPAX.new(4096), 100) + + assert {:error, :protocol_error} = HPAX.decode(size_update(101), table) + assert {:ok, [], _table} = HPAX.decode(size_update(100), table) + end + + defp size_update(size) do + <<0b001::3, HPAX.Types.encode_integer(size, 5)::bitstring>> + end + end + property "encoding then decoding headers is circular" do table = HPAX.new(500) From 325b3573f86b3b4226e431e6d08e130c45f02ee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 21 Sep 2026 01:02:52 +0200 Subject: [PATCH 2/2] Return an error tuple for a malformed dynamic table size update decode/2 parses the size update's integer before the clause that catches decoding errors, so a block whose integer never ends, such as <<0x3F>>, threw {:hpax, :bad_integer_encoding} at the caller instead of returning {:error, :bad_integer_encoding}. Callers that follow the documented contract don't catch it, so the throw takes down the process decoding the block. --- lib/hpax.ex | 2 ++ test/hpax_test.exs | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/lib/hpax.ex b/lib/hpax.ex index 70b359d..309929e 100644 --- a/lib/hpax.ex +++ b/lib/hpax.ex @@ -176,6 +176,8 @@ defmodule HPAX do table = %{table | required_minimum_resize: nil} decode(rest, Table.dynamic_resize(table, new_max_size)) end + catch + :throw, {:hpax, error} -> {:error, error} end # A reduction of the maximum size below the size of the table has to be acknowledged with a diff --git a/test/hpax_test.exs b/test/hpax_test.exs index 42884ea..517a562 100644 --- a/test/hpax_test.exs +++ b/test/hpax_test.exs @@ -355,6 +355,15 @@ defmodule HPAXTest do end end + describe "decode/2 of a dynamic table size update" do + test "returns an error for an integer that never ends" do + assert {:error, :bad_integer_encoding} = HPAX.decode(<<0x3F>>, HPAX.new(4096)) + + assert {:error, :bad_integer_encoding} = + HPAX.decode(<<0x20, 0x3F>>, HPAX.new(4096)) + end + end + property "encoding then decoding headers is circular" do table = HPAX.new(500)