Skip to content

Commit 0db3fb4

Browse files
authored
Merge pull request #831 from ycastorium/fix_off_by_one_error
fix: Fixes off-by-one error in HPACK
2 parents 2c7e638 + dd35c3e commit 0db3fb4

2 files changed

Lines changed: 107 additions & 2 deletions

File tree

src/hackney_hpack.erl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,7 @@ find_in_dynamic_field(Header, #state{field_index = FieldIndex, insert_count = In
566566
{ok, AbsIndex} when InsertCount - AbsIndex < Count ->
567567
%% Convert absolute index to relative index
568568
%% Relative index = insert_count - abs_index + STATIC_TABLE_SIZE
569-
{ok, ?STATIC_TABLE_SIZE + (InsertCount - AbsIndex)};
569+
{ok, ?STATIC_TABLE_SIZE + (InsertCount - AbsIndex) + 1};
570570
_ ->
571571
error
572572
end.
@@ -575,7 +575,7 @@ find_in_dynamic_field(Header, #state{field_index = FieldIndex, insert_count = In
575575
find_in_dynamic_name(Name, #state{name_index = NameIndex, insert_count = InsertCount, count = Count}) ->
576576
case maps:find(Name, NameIndex) of
577577
{ok, AbsIndex} when InsertCount - AbsIndex < Count ->
578-
{ok, ?STATIC_TABLE_SIZE + (InsertCount - AbsIndex)};
578+
{ok, ?STATIC_TABLE_SIZE + (InsertCount - AbsIndex) + 1};
579579
_ ->
580580
error
581581
end.

test/hackney_hpack_tests.erl

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
-module(hackney_hpack_tests).
2+
-include_lib("eunit/include/eunit.hrl").
3+
4+
-define(STATIC_TABLE_SIZE, 61).
5+
6+
encode_decode_roundtrip_test_() ->
7+
{"HPACK encode/decode round-trip", [
8+
{"single encode/decode cycle preserves headers",
9+
fun encode_decode_single/0},
10+
{"second encode uses dynamic table and still decodes correctly",
11+
fun encode_decode_reuses_dynamic_table/0},
12+
{"multiple sequential requests round-trip correctly",
13+
fun encode_decode_multiple_sequential/0},
14+
{"dynamic table indices start at STATIC_TABLE_SIZE + 1",
15+
fun dynamic_table_index_starts_at_62/0}
16+
]}.
17+
18+
encode_decode_single() ->
19+
Headers = [
20+
{<<":method">>, <<"GET">>},
21+
{<<":path">>, <<"/">>},
22+
{<<":scheme">>, <<"https">>},
23+
{<<":authority">>, <<"example.com">>}
24+
],
25+
{Encoded, EncState} = hackney_hpack:encode(Headers),
26+
EncodedBin = iolist_to_binary(Encoded),
27+
{Decoded, _DecState} = hackney_hpack:decode(EncodedBin),
28+
?assertEqual(Headers, Decoded),
29+
%% Verify encoder state is usable for next request
30+
?assertNotEqual(hackney_hpack:init(), EncState).
31+
32+
encode_decode_reuses_dynamic_table() ->
33+
Headers = [
34+
{<<":method">>, <<"GET">>},
35+
{<<":path">>, <<"/api/test">>},
36+
{<<":scheme">>, <<"https">>},
37+
{<<":authority">>, <<"example.com">>},
38+
{<<"accept">>, <<"application/json">>}
39+
],
40+
%% First encode populates the dynamic table
41+
{Encoded1, EncState1} = hackney_hpack:encode(Headers),
42+
Encoded1Bin = iolist_to_binary(Encoded1),
43+
{Decoded1, DecState1} = hackney_hpack:decode(Encoded1Bin),
44+
?assertEqual(Headers, Decoded1),
45+
46+
%% Second encode should use indexed references from dynamic table
47+
{Encoded2, _EncState2} = hackney_hpack:encode(Headers, EncState1),
48+
Encoded2Bin = iolist_to_binary(Encoded2),
49+
50+
%% Second encoding should be smaller (using indexed references)
51+
?assert(byte_size(Encoded2Bin) < byte_size(Encoded1Bin)),
52+
53+
%% Must still decode to the same headers
54+
{Decoded2, _DecState2} = hackney_hpack:decode(Encoded2Bin, DecState1),
55+
?assertEqual(Headers, Decoded2).
56+
57+
encode_decode_multiple_sequential() ->
58+
EncState0 = hackney_hpack:init(),
59+
DecState0 = hackney_hpack:init(),
60+
Requests = [
61+
[{<<":method">>, <<"GET">>},
62+
{<<":path">>, <<"/">>},
63+
{<<":scheme">>, <<"https">>},
64+
{<<":authority">>, <<"example.com">>}],
65+
[{<<":method">>, <<"GET">>},
66+
{<<":path">>, <<"/page2">>},
67+
{<<":scheme">>, <<"https">>},
68+
{<<":authority">>, <<"example.com">>}],
69+
[{<<":method">>, <<"POST">>},
70+
{<<":path">>, <<"/api/data">>},
71+
{<<":scheme">>, <<"https">>},
72+
{<<":authority">>, <<"example.com">>},
73+
{<<"content-type">>, <<"application/json">>}],
74+
[{<<":method">>, <<"GET">>},
75+
{<<":path">>, <<"/">>},
76+
{<<":scheme">>, <<"https">>},
77+
{<<":authority">>, <<"example.com">>}]
78+
],
79+
lists:foldl(fun(Headers, {ES, DS}) ->
80+
{Encoded, ES2} = hackney_hpack:encode(Headers, ES),
81+
EncodedBin = iolist_to_binary(Encoded),
82+
{Decoded, DS2} = hackney_hpack:decode(EncodedBin, DS),
83+
?assertEqual(Headers, Decoded),
84+
{ES2, DS2}
85+
end, {EncState0, DecState0}, Requests).
86+
87+
dynamic_table_index_starts_at_62() ->
88+
Headers = [{<<"x-custom">>, <<"value">>}],
89+
{Encoded1, EncState1} = hackney_hpack:encode(Headers),
90+
Encoded1Bin = iolist_to_binary(Encoded1),
91+
{_, DecState1} = hackney_hpack:decode(Encoded1Bin),
92+
93+
%% Second encode should produce an indexed header field representation
94+
%% The index must be >= 62 (STATIC_TABLE_SIZE + 1)
95+
{Encoded2, _} = hackney_hpack:encode(Headers, EncState1),
96+
Encoded2Bin = iolist_to_binary(Encoded2),
97+
98+
%% Indexed header field starts with 1-bit prefix: 1xxxxxxx
99+
%% Extract the index from the 7-bit prefix integer
100+
<<1:1, IndexBits:7, _/binary>> = Encoded2Bin,
101+
?assert(IndexBits >= ?STATIC_TABLE_SIZE + 1),
102+
103+
%% Verify it decodes correctly
104+
{Decoded2, _} = hackney_hpack:decode(Encoded2Bin, DecState1),
105+
?assertEqual(Headers, Decoded2).

0 commit comments

Comments
 (0)