Skip to content

Commit 56db437

Browse files
authored
Bring over HPACK implementation from Mint (#1)
1 parent 9ca8468 commit 56db437

14 files changed

+1333
-23
lines changed

.formatter.exs

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Used by "mix format"
22
[
3-
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
3+
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
4+
import_deps: [:stream_data]
45
]

.github/workflows/main.yml

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: CI
2+
3+
on: [push, pull_request]
4+
5+
jobs:
6+
test:
7+
name: Test
8+
runs-on: ubuntu-18.04
9+
strategy:
10+
fail-fast: false
11+
matrix:
12+
include:
13+
- erlang: "23.3.1"
14+
elixir: "1.11.4"
15+
- erlang: "23.0"
16+
elixir: "1.11.2"
17+
lint: true
18+
- erlang: "23.0"
19+
elixir: "1.10.3"
20+
- erlang: "22.3"
21+
elixir: "1.9.4"
22+
- erlang: "21.3"
23+
elixir: "1.8.2"
24+
- erlang: "20.3.1"
25+
elixir: "1.7.4"
26+
- erlang: "19.3"
27+
elixir: "1.6.6"
28+
- erlang: "18.3"
29+
elixir: "1.5.3"
30+
steps:
31+
- uses: actions/checkout@v1
32+
33+
- name: Install OTP and Elixir
34+
uses: erlef/setup-elixir@v1
35+
with:
36+
otp-version: ${{matrix.erlang}}
37+
elixir-version: ${{matrix.elixir}}
38+
39+
- name: Install dependencies
40+
run: mix deps.get
41+
42+
- name: Check for unused dependencies
43+
run: mix deps.unlock --check-unused
44+
if: ${{matrix.lint}}
45+
46+
- name: Compile with --warnings-as-errors
47+
run: mix compile --warnings-as-errors
48+
if: ${{matrix.lint}}
49+
50+
- name: Check mix format
51+
run: mix format --check-formatted
52+
if: ${{matrix.lint}}
53+
54+
- name: Run tests
55+
run: mix test --trace

README.md

+59-8
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,72 @@
1-
# Hpax
1+
# HPax
22

3-
**TODO: Add description**
3+
[![Build Status](https://travis-ci.org/elixir-mint/hpax.svg?branch=master)](https://travis-ci.org/elixir-mint/hpax)
4+
[![Docs](https://img.shields.io/badge/api-docs-green.svg?style=flat)](https://hexdocs.pm/hpax)
5+
[![Hex.pm Version](http://img.shields.io/hexpm/v/hpax.svg?style=flat)](https://hex.pm/packages/hpax)
6+
7+
HPax is an Elixir implementation of the HPACK header compression algorithm as used in HTTP/2 and
8+
defined in RFC 7541. HPax is (or will soon be) used by several Elixir projects, including the
9+
[Mint](https://github.com/elixir-mint/mint) HTTP client and
10+
[bandit](https://github.com/mtrudel/bandit) HTTP server projects.
411

512
## Installation
613

7-
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
8-
by adding `hpax` to your list of dependencies in `mix.exs`:
14+
To install HPax, add it to your `mix.exs` file.
915

1016
```elixir
11-
def deps do
17+
defp deps do
1218
[
1319
{:hpax, "~> 0.1.0"}
1420
]
1521
end
1622
```
1723

18-
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
19-
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
20-
be found at [https://hexdocs.pm/hpax](https://hexdocs.pm/hpax).
24+
Then, run `$ mix deps.get`.
25+
26+
## Usage
27+
28+
HPax is designed to be used in both encoding and decoding scenarios. In both cases, a context is
29+
used to maintain state internal to the HPACK algorithm. In the common use case of using HPax
30+
within HTTP/2, this context must be shared between any subsequent encoding/decoding calls within
31+
an endpoint. Note that the contexts used for encoding and decoding within HTTP/2 are completely
32+
distinct from one another, even though they are structurally identical.
33+
34+
To encode a set of headers into a binary with HPax:
35+
36+
```elixir
37+
ctx = HPax.new(4096)
38+
headers = [{:store, ":status", "201"}, {:store, "location", "http://example.com"}]
39+
{encoded_headers, ctx} = HPax.encode(headers, ctx)
40+
#=> {iodata, updated_context}
41+
```
42+
43+
To decode a binary into a set of headers with HPax:
44+
45+
```elixir
46+
ctx = HPax.new(4096)
47+
encoded_headers = <<...>>
48+
{:ok, headers, ctx} = HPax.decode(encoded_headers, ctx)
49+
#=> {:ok, [{:store, ":status", "201"}, {:store, "location", "http://example.com"}], updated_context}
50+
```
51+
52+
For complete usage information, please see the HPax [documentation](https://hex.pm/packages/hpax).
53+
54+
## Contributing
55+
56+
If you wish to contribute check out the [issue list](https://github.com/elixir-mint/hpax/issues) and let us know what you want to work on so we can discuss it and reduce duplicate work.
57+
58+
## License
59+
60+
Copyright 2021 Eric Meadows-Jönsson and Andrea Leopardi
61+
62+
Licensed under the Apache License, Version 2.0 (the "License");
63+
you may not use this file except in compliance with the License.
64+
You may obtain a copy of the License at
65+
66+
http://www.apache.org/licenses/LICENSE-2.0
2167

68+
Unless required by applicable law or agreed to in writing, software
69+
distributed under the License is distributed on an "AS IS" BASIS,
70+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
71+
See the License for the specific language governing permissions and
72+
limitations under the License.

lib/hpax.ex

+235-7
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,246 @@
1-
defmodule Hpax do
1+
defmodule HPax do
22
@moduledoc """
3-
Documentation for `Hpax`.
3+
Support for the HPACK header compression algorithm.
4+
5+
This module provides support for the HPACK header compression algorithm used mainly in HTTP/2.
6+
The HPACK algorithm requires an encoding context on the encoder side and a decoding context on
7+
the decoder side. These contexts are semantically different but structurally the same and they
8+
can both be created through `new/1`.
9+
"""
10+
11+
alias HPax.{Table, Types}
12+
13+
@type header_name() :: binary()
14+
@type header_value() :: binary()
15+
16+
@valid_header_actions [:store, :store_name, :no_store, :never_store]
17+
18+
@doc """
19+
Create a new context.
20+
21+
`max_table_size` is the maximum table size (in bytes) for the newly created context.
422
"""
23+
@spec new(non_neg_integer()) :: Table.t()
24+
def new(max_table_size) when is_integer(max_table_size) and max_table_size >= 0 do
25+
Table.new(max_table_size)
26+
end
527

628
@doc """
7-
Hello world.
29+
Resizes the given table to the given size.
30+
"""
31+
@spec resize(Table.t(), non_neg_integer()) :: Table.t()
32+
defdelegate resize(table, new_size), to: Table
33+
34+
## Decoding
35+
36+
@doc """
37+
Decodes a header block fragment (HBF) through a given context.
38+
39+
If decoding is successful, this function returns a `{:ok, headers, updated_context}` tuple where
40+
`headers` is a list of decoded headers, and `updated_context` is the updated context. If there's
41+
an error in decoding, this function returns `{:error, reason}`.
842
943
## Examples
1044
11-
iex> Hpax.hello()
12-
:world
45+
context = HPax.new(1000)
46+
hbf = get_hbf_from_somewhere()
47+
HPax.decode(hbf, context)
48+
#=> {:ok, [{":method", "GET"}], updated_context}
1349
1450
"""
15-
def hello do
16-
:world
51+
@spec decode(binary(), Table.t()) :: {:ok, [{binary(), binary()}], Table.t()} | {:error, term()}
52+
def decode(block, %Table{} = table) when is_binary(block) do
53+
decode_headers(block, table, _acc = [])
54+
catch
55+
:throw, {:mint, error} -> {:error, error}
56+
end
57+
58+
defp decode_headers(<<>>, table, acc) do
59+
{:ok, Enum.reverse(acc), table}
60+
end
61+
62+
# Indexed header field
63+
# http://httpwg.org/specs/rfc7541.html#rfc.section.6.1
64+
defp decode_headers(<<0b1::1, rest::bitstring>>, table, acc) do
65+
{index, rest} = decode_integer(rest, 7)
66+
decode_headers(rest, table, [lookup_by_index!(table, index) | acc])
67+
end
68+
69+
# Literal header field with incremental indexing
70+
# http://httpwg.org/specs/rfc7541.html#rfc.section.6.2.1
71+
defp decode_headers(<<0b01::2, rest::bitstring>>, table, acc) do
72+
{name, value, rest} =
73+
case rest do
74+
# The header name is a string.
75+
<<0::6, rest::binary>> ->
76+
{name, rest} = decode_binary(rest)
77+
{value, rest} = decode_binary(rest)
78+
{name, value, rest}
79+
80+
# The header name is an index to be looked up in the table.
81+
_other ->
82+
{index, rest} = decode_integer(rest, 6)
83+
{value, rest} = decode_binary(rest)
84+
{name, _value} = lookup_by_index!(table, index)
85+
{name, value, rest}
86+
end
87+
88+
decode_headers(rest, Table.add(table, name, value), [{name, value} | acc])
89+
end
90+
91+
# Literal header field without indexing
92+
# http://httpwg.org/specs/rfc7541.html#rfc.section.6.2.2
93+
defp decode_headers(<<0b0000::4, rest::bitstring>>, table, acc) do
94+
{name, value, rest} =
95+
case rest do
96+
<<0::4, rest::binary>> ->
97+
{name, rest} = decode_binary(rest)
98+
{value, rest} = decode_binary(rest)
99+
{name, value, rest}
100+
101+
_other ->
102+
{index, rest} = decode_integer(rest, 4)
103+
{value, rest} = decode_binary(rest)
104+
{name, _value} = lookup_by_index!(table, index)
105+
{name, value, rest}
106+
end
107+
108+
decode_headers(rest, table, [{name, value} | acc])
109+
end
110+
111+
# Literal header field never indexed
112+
# http://httpwg.org/specs/rfc7541.html#rfc.section.6.2.3
113+
defp decode_headers(<<0b0001::4, rest::bitstring>>, table, acc) do
114+
{name, value, rest} =
115+
case rest do
116+
<<0::4, rest::binary>> ->
117+
{name, rest} = decode_binary(rest)
118+
{value, rest} = decode_binary(rest)
119+
{name, value, rest}
120+
121+
_other ->
122+
{index, rest} = decode_integer(rest, 4)
123+
{value, rest} = decode_binary(rest)
124+
{name, _value} = lookup_by_index!(table, index)
125+
{name, value, rest}
126+
end
127+
128+
# TODO: enforce the "never indexed" part somehow.
129+
decode_headers(rest, table, [{name, value} | acc])
130+
end
131+
132+
# Dynamic table size update
133+
defp decode_headers(<<0b001::3, rest::bitstring>>, table, acc) do
134+
{new_size, rest} = decode_integer(rest, 5)
135+
decode_headers(rest, Table.resize(table, new_size), acc)
136+
end
137+
138+
defp decode_headers(_other, _table, _acc) do
139+
throw({:mint, :protocol_error})
140+
end
141+
142+
defp lookup_by_index!(table, index) do
143+
case Table.lookup_by_index(table, index) do
144+
{:ok, header} -> header
145+
:error -> throw({:mint, {:index_not_found, index}})
146+
end
147+
end
148+
149+
defp decode_integer(bitstring, prefix) do
150+
case Types.decode_integer(bitstring, prefix) do
151+
{:ok, int, rest} -> {int, rest}
152+
:error -> throw({:mint, :bad_integer_encoding})
153+
end
154+
end
155+
156+
defp decode_binary(binary) do
157+
case Types.decode_binary(binary) do
158+
{:ok, binary, rest} -> {binary, rest}
159+
:error -> throw({:mint, :bad_binary_encoding})
160+
end
161+
end
162+
163+
## Encoding
164+
165+
@doc """
166+
Encodes a list of headers through the given context.
167+
168+
Returns a two-element tuple where the first element is a binary representing the encoded headers
169+
and the second element is an updated context.
170+
171+
## Examples
172+
173+
headers = [{:store, ":authority", "https://example.com"}]
174+
context = HPax.new(1000)
175+
HPax.encode(headers, context)
176+
#=> {<<...>>, updated_context}
177+
178+
"""
179+
@spec encode([header], Table.t()) :: {iodata(), Table.t()}
180+
when header: {action, header_name(), header_value()},
181+
action: :store | :store_name | :no_store | :never_store
182+
def encode(headers, %Table{} = table) when is_list(headers) do
183+
encode_headers(headers, table, _acc = [])
184+
end
185+
186+
defp encode_headers([], table, acc) do
187+
{acc, table}
188+
end
189+
190+
defp encode_headers([{action, name, value} | rest], table, acc)
191+
when action in @valid_header_actions and is_binary(name) and is_binary(value) do
192+
{encoded, table} =
193+
case Table.lookup_by_header(table, name, value) do
194+
{:full, index} ->
195+
{encode_indexed_header(index), table}
196+
197+
{:name, index} when action == :store ->
198+
{encode_literal_header_with_indexing(index, value), Table.add(table, name, value)}
199+
200+
{:name, index} when action in [:store_name, :no_store] ->
201+
{encode_literal_header_without_indexing(index, value), table}
202+
203+
{:name, index} when action == :never_store ->
204+
{encode_literal_header_never_indexed(index, value), table}
205+
206+
:not_found when action in [:store, :store_name] ->
207+
{encode_literal_header_with_indexing(name, value), Table.add(table, name, value)}
208+
209+
:not_found when action == :no_store ->
210+
{encode_literal_header_without_indexing(name, value), table}
211+
212+
:not_found when action == :never_store ->
213+
{encode_literal_header_never_indexed(name, value), table}
214+
end
215+
216+
encode_headers(rest, table, [acc, encoded])
217+
end
218+
219+
defp encode_indexed_header(index) do
220+
<<1::1, Types.encode_integer(index, 7)::bitstring>>
221+
end
222+
223+
defp encode_literal_header_with_indexing(index, value) when is_integer(index) do
224+
[<<1::2, Types.encode_integer(index, 6)::bitstring>>, Types.encode_binary(value, false)]
225+
end
226+
227+
defp encode_literal_header_with_indexing(name, value) when is_binary(name) do
228+
[<<1::2, 0::6>>, Types.encode_binary(name, false), Types.encode_binary(value, false)]
229+
end
230+
231+
defp encode_literal_header_without_indexing(index, value) when is_integer(index) do
232+
[<<0::4, Types.encode_integer(index, 4)::bitstring>>, Types.encode_binary(value, false)]
233+
end
234+
235+
defp encode_literal_header_without_indexing(name, value) when is_binary(name) do
236+
[<<0::4, 0::4>>, Types.encode_binary(name, false), Types.encode_binary(value, false)]
237+
end
238+
239+
defp encode_literal_header_never_indexed(index, value) when is_integer(index) do
240+
[<<1::4, Types.encode_integer(index, 4)::bitstring>>, Types.encode_binary(value, false)]
241+
end
242+
243+
defp encode_literal_header_never_indexed(name, value) when is_binary(name) do
244+
[<<1::4, 0::4>>, Types.encode_binary(name, false), Types.encode_binary(value, false)]
17245
end
18246
end

0 commit comments

Comments
 (0)