Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 65 additions & 15 deletions lib/hpax.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -129,13 +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
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
# 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
Expand Down
36 changes: 34 additions & 2 deletions lib/hpax/table.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 [
Expand Down Expand Up @@ -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)
Expand Down
105 changes: 105 additions & 0 deletions test/hpax_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,111 @@ 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

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)

Expand Down
Loading