| 1 | module http |
| 2 | |
| 3 | // Tests for the HPACK implementation (RFC 7541). The byte sequences below are |
| 4 | // the worked examples from RFC 7541 Appendix C; decoding them is the canonical |
| 5 | // correctness check for an HPACK decoder. |
| 6 | |
| 7 | // hexb parses a hex string (spaces allowed) into bytes. |
| 8 | fn hexb(s string) []u8 { |
| 9 | clean := s.replace(' ', '').replace('\n', '') |
| 10 | mut out := []u8{cap: clean.len / 2} |
| 11 | for i := 0; i + 1 < clean.len; i += 2 { |
| 12 | hi := hex_nibble(clean[i]) |
| 13 | lo := hex_nibble(clean[i + 1]) |
| 14 | out << u8((hi << 4) | lo) |
| 15 | } |
| 16 | return out |
| 17 | } |
| 18 | |
| 19 | fn hex_nibble(c u8) u8 { |
| 20 | return match c { |
| 21 | `0`...`9` { c - `0` } |
| 22 | `a`...`f` { c - `a` + 10 } |
| 23 | `A`...`F` { c - `A` + 10 } |
| 24 | else { 0 } |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | fn assert_fields(got []H2HeaderField, want [][]string) { |
| 29 | assert got.len == want.len, 'field count: got ${got.len}, want ${want.len}' |
| 30 | for i, w in want { |
| 31 | assert got[i].name == w[0], 'field ${i} name: got "${got[i].name}", want "${w[0]}"' |
| 32 | assert got[i].value == w[1], 'field ${i} value: got "${got[i].value}", want "${w[1]}"' |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | // --- Integer representation (RFC 7541 Section 5.1) --- |
| 37 | |
| 38 | fn test_hpack_integer_examples() { |
| 39 | // C.1.1: encode 10 with a 5-bit prefix -> single byte 0x0a. |
| 40 | mut b := []u8{} |
| 41 | h2_hpack_write_int(mut b, 10, 5, 0) |
| 42 | assert b == [u8(0x0a)] |
| 43 | mut r := H2HpackReader{ |
| 44 | buf: b |
| 45 | } |
| 46 | assert r.read_int(5)! == 10 |
| 47 | |
| 48 | // C.1.2: encode 1337 with a 5-bit prefix -> 0x1f 0x9a 0x0a. |
| 49 | b = []u8{} |
| 50 | h2_hpack_write_int(mut b, 1337, 5, 0) |
| 51 | assert b == [u8(0x1f), 0x9a, 0x0a] |
| 52 | r = H2HpackReader{ |
| 53 | buf: b |
| 54 | } |
| 55 | assert r.read_int(5)! == 1337 |
| 56 | |
| 57 | // C.1.3: encode 42 with an 8-bit prefix -> single byte 0x2a. |
| 58 | b = []u8{} |
| 59 | h2_hpack_write_int(mut b, 42, 8, 0) |
| 60 | assert b == [u8(0x2a)] |
| 61 | r = H2HpackReader{ |
| 62 | buf: b |
| 63 | } |
| 64 | assert r.read_int(8)! == 42 |
| 65 | } |
| 66 | |
| 67 | fn test_hpack_integer_overflow_rejected() { |
| 68 | // A run of continuation bytes with the high bit always set must not loop |
| 69 | // forever or overflow; it should error. |
| 70 | bad := [u8(0x1f), 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80] |
| 71 | mut r := H2HpackReader{ |
| 72 | buf: bad |
| 73 | } |
| 74 | r.read_int(5) or { return } |
| 75 | assert false, 'expected integer overflow error' |
| 76 | } |
| 77 | |
| 78 | // --- Huffman coding (RFC 7541 Section 5.2) --- |
| 79 | |
| 80 | fn test_huffman_roundtrip() { |
| 81 | samples := ['', 'www.example.com', 'no-cache', 'custom-value', '/sample/path', |
| 82 | 'Mon, 21 Oct 2013 20:13:21 GMT', 'https://www.example.com', 'private'] |
| 83 | for s in samples { |
| 84 | enc := h2_huffman_encode(s.bytes()) |
| 85 | dec := h2_huffman_decode(enc)! |
| 86 | assert dec.bytestr() == s, 'huffman roundtrip failed for "${s}"' |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | fn test_huffman_known_vector() { |
| 91 | // "www.example.com" Huffman-encoded, from RFC 7541 C.4.1. |
| 92 | enc := hexb('f1e3c2e5f23a6ba0ab90f4ff') |
| 93 | dec := h2_huffman_decode(enc)! |
| 94 | assert dec.bytestr() == 'www.example.com' |
| 95 | } |
| 96 | |
| 97 | fn test_huffman_codes_rebuilt_from_lengths() { |
| 98 | // The HPACK table now ships only the bit lengths; the canonical codes are |
| 99 | // rebuilt at startup via hash.huffman. Pin a few known codes from RFC 7541 |
| 100 | // Appendix B so a bad rebuild (or a future builder change) is caught. |
| 101 | assert h2_huffman_table.codes.len == 257 |
| 102 | assert h2_huffman_table.lengths.len == 257 |
| 103 | // symbol 0 (NUL): 0x1ff8 / 13 bits |
| 104 | assert h2_huffman_table.codes[0] == 0x1ff8 |
| 105 | assert h2_huffman_table.lengths[0] == 13 |
| 106 | // '0' (0x30): 0x0 / 5 bits, '1': 0x1 / 5 bits, 'a' (0x61): 0x3 / 5 bits |
| 107 | assert h2_huffman_table.codes[0x30] == 0x0 |
| 108 | assert h2_huffman_table.lengths[0x30] == 5 |
| 109 | assert h2_huffman_table.codes[0x31] == 0x1 |
| 110 | assert h2_huffman_table.codes[0x61] == 0x3 |
| 111 | // EOS (256): 0x3fffffff / 30 bits |
| 112 | assert h2_huffman_table.codes[256] == 0x3fffffff |
| 113 | assert h2_huffman_table.lengths[256] == 30 |
| 114 | } |
| 115 | |
| 116 | fn test_huffman_rejects_padding_not_all_ones() { |
| 117 | // Valid encoding of "0" is 5 bits (00000); pad the rest of the byte with |
| 118 | // zeros instead of ones -> invalid per RFC 7541 Section 5.2. |
| 119 | bad := [u8(0x00)] // '0' code is 00000, then 000 padding (not all ones) |
| 120 | h2_huffman_decode(bad) or { return } |
| 121 | assert false, 'expected invalid huffman padding error' |
| 122 | } |
| 123 | |
| 124 | // --- C.2: Header field representations --- |
| 125 | |
| 126 | fn test_hpack_c_2_1_literal_incremental() { |
| 127 | mut d := H2HpackDecoder{} |
| 128 | fields := d.decode(hexb('400a 6375 7374 6f6d 2d6b 6579 0d63 7573 746f 6d2d 6865 6164 6572'))! |
| 129 | assert_fields(fields, [['custom-key', 'custom-header']]) |
| 130 | // Added to the dynamic table: size = 10 + 13 + 32 = 55. |
| 131 | assert d.dyn_table.entries.len == 1 |
| 132 | assert d.dyn_table.cur_size == 55 |
| 133 | assert d.dyn_table.entries[0].name == 'custom-key' |
| 134 | } |
| 135 | |
| 136 | fn test_hpack_c_2_2_literal_without_indexing() { |
| 137 | mut d := H2HpackDecoder{} |
| 138 | fields := d.decode(hexb('040c 2f73 616d 706c 652f 7061 7468'))! |
| 139 | assert_fields(fields, [[':path', '/sample/path']]) |
| 140 | assert d.dyn_table.entries.len == 0 |
| 141 | } |
| 142 | |
| 143 | fn test_hpack_c_2_3_never_indexed() { |
| 144 | mut d := H2HpackDecoder{} |
| 145 | fields := d.decode(hexb('1008 7061 7373 776f 7264 0673 6563 7265 74'))! |
| 146 | assert_fields(fields, [['password', 'secret']]) |
| 147 | assert d.dyn_table.entries.len == 0 |
| 148 | } |
| 149 | |
| 150 | fn test_hpack_c_2_4_indexed() { |
| 151 | mut d := H2HpackDecoder{} |
| 152 | fields := d.decode(hexb('82'))! |
| 153 | assert_fields(fields, [[':method', 'GET']]) |
| 154 | } |
| 155 | |
| 156 | // --- C.3: Request sequence without Huffman, shared decoder --- |
| 157 | |
| 158 | fn test_hpack_c_3_request_sequence() { |
| 159 | mut d := H2HpackDecoder{} |
| 160 | |
| 161 | f1 := d.decode(hexb('8286 8441 0f77 7777 2e65 7861 6d70 6c65 2e63 6f6d'))! |
| 162 | assert_fields(f1, [[':method', 'GET'], [':scheme', 'http'], |
| 163 | [':path', '/'], [':authority', 'www.example.com']]) |
| 164 | assert d.dyn_table.cur_size == 57 |
| 165 | |
| 166 | f2 := d.decode(hexb('8286 84be 5808 6e6f 2d63 6163 6865'))! |
| 167 | assert_fields(f2, [[':method', 'GET'], [':scheme', 'http'], |
| 168 | [':path', '/'], [':authority', 'www.example.com'], ['cache-control', 'no-cache']]) |
| 169 | |
| 170 | f3 := |
| 171 | d.decode(hexb('8287 85bf 400a 6375 7374 6f6d 2d6b 6579 0c63 7573 746f 6d2d 7661 6c75 65'))! |
| 172 | assert_fields(f3, [[':method', 'GET'], [':scheme', 'https'], |
| 173 | [':path', '/index.html'], [':authority', 'www.example.com'], |
| 174 | ['custom-key', 'custom-value']]) |
| 175 | } |
| 176 | |
| 177 | // --- C.4: Request sequence with Huffman, shared decoder --- |
| 178 | |
| 179 | fn test_hpack_c_4_request_sequence_huffman() { |
| 180 | mut d := H2HpackDecoder{} |
| 181 | |
| 182 | f1 := d.decode(hexb('8286 8441 8cf1 e3c2 e5f2 3a6b a0ab 90f4 ff'))! |
| 183 | assert_fields(f1, [[':method', 'GET'], [':scheme', 'http'], |
| 184 | [':path', '/'], [':authority', 'www.example.com']]) |
| 185 | |
| 186 | f2 := d.decode(hexb('8286 84be 5886 a8eb 1064 9cbf'))! |
| 187 | assert_fields(f2, [[':method', 'GET'], [':scheme', 'http'], |
| 188 | [':path', '/'], [':authority', 'www.example.com'], ['cache-control', 'no-cache']]) |
| 189 | |
| 190 | f3 := d.decode(hexb('8287 85bf 4088 25a8 49e9 5ba9 7d7f 8925 a849 e95b b8e8 b4bf'))! |
| 191 | assert_fields(f3, [[':method', 'GET'], [':scheme', 'https'], |
| 192 | [':path', '/index.html'], [':authority', 'www.example.com'], |
| 193 | ['custom-key', 'custom-value']]) |
| 194 | } |
| 195 | |
| 196 | // --- Dynamic table eviction (RFC 7541 Sections 4.3, 4.4) --- |
| 197 | |
| 198 | fn test_dyn_table_eviction_on_add() { |
| 199 | // Mirrors python-hpack's eviction test: a 66-byte table holds only one of |
| 200 | // these two entries at a time. |
| 201 | mut t := H2DynTable{ |
| 202 | max_size: 66 |
| 203 | } |
| 204 | t.add('a', 'b') // size = 1 + 1 + 32 = 34 |
| 205 | assert t.entries.len == 1 |
| 206 | assert t.cur_size == 34 |
| 207 | t.add('long-custom-header', 'longish value') // size = 18 + 13 + 32 = 63 |
| 208 | assert t.entries.len == 1 |
| 209 | assert t.entries[0].name == 'long-custom-header' |
| 210 | assert t.cur_size == 63 |
| 211 | } |
| 212 | |
| 213 | fn test_dyn_table_oversized_entry_empties_table() { |
| 214 | mut t := H2DynTable{ |
| 215 | max_size: 64 |
| 216 | } |
| 217 | t.add('a', 'b') |
| 218 | assert t.entries.len == 1 |
| 219 | // An entry larger than the whole table empties it and is not added. |
| 220 | t.add('x'.repeat(100), '') |
| 221 | assert t.entries.len == 0 |
| 222 | assert t.cur_size == 0 |
| 223 | } |
| 224 | |
| 225 | fn test_dyn_table_resize_evicts() { |
| 226 | mut t := H2DynTable{} |
| 227 | t.add('a', 'b') |
| 228 | t.add('c', 'd') |
| 229 | assert t.entries.len == 2 |
| 230 | t.set_max_size(34) // room for exactly one 34-byte entry (the newest) |
| 231 | assert t.entries.len == 1 |
| 232 | assert t.entries[0].name == 'c' |
| 233 | t.set_max_size(0) |
| 234 | assert t.entries.len == 0 |
| 235 | assert t.cur_size == 0 |
| 236 | } |
| 237 | |
| 238 | // A "size update then re-add" sequence exercised through the decoder: an |
| 239 | // indexed reference to an evicted entry must fail. |
| 240 | fn test_decoder_dynamic_indexing_and_eviction() { |
| 241 | mut d := H2HpackDecoder{} |
| 242 | // Literal incremental indexing of custom-key: custom-header (size 55). |
| 243 | _ := d.decode(hexb('400a 6375 7374 6f6d 2d6b 6579 0d63 7573 746f 6d2d 6865 6164 6572'))! |
| 244 | assert d.dyn_table.entries.len == 1 |
| 245 | // Index 62 now refers to that entry. |
| 246 | f := d.decode([u8(0xbe)])! |
| 247 | assert_fields(f, [['custom-key', 'custom-header']]) |
| 248 | // Shrinking the table to 0 evicts it; index 62 is then out of range. |
| 249 | d.decode([u8(0x20)])! // dynamic table size update to 0 |
| 250 | assert d.dyn_table.entries.len == 0 |
| 251 | d.decode([u8(0xbe)]) or { return } |
| 252 | assert false, 'expected out-of-range error after eviction' |
| 253 | } |
| 254 | |
| 255 | // --- Encoder + round-trip --- |
| 256 | |
| 257 | fn test_hpack_encode_indexed_static() { |
| 258 | mut e := H2HpackEncoder{} |
| 259 | // :method GET is static index 2 -> single byte 0x82. |
| 260 | out := e.encode([H2HeaderField{':method', 'GET'}]) |
| 261 | assert out == [u8(0x82)] |
| 262 | } |
| 263 | |
| 264 | fn test_hpack_roundtrip() { |
| 265 | fields := [ |
| 266 | H2HeaderField{':method', 'GET'}, |
| 267 | H2HeaderField{':scheme', 'https'}, |
| 268 | H2HeaderField{':authority', 'example.com'}, |
| 269 | H2HeaderField{':path', '/index.html'}, |
| 270 | H2HeaderField{'user-agent', 'v.http/0.1'}, |
| 271 | H2HeaderField{'accept', '*/*'}, |
| 272 | H2HeaderField{'cookie', 'session=abc123'}, |
| 273 | ] |
| 274 | mut e := H2HpackEncoder{} |
| 275 | mut d := H2HpackDecoder{} |
| 276 | encoded := e.encode(fields) |
| 277 | decoded := d.decode(encoded)! |
| 278 | assert_fields(decoded, [[':method', 'GET'], [':scheme', 'https'], |
| 279 | [':authority', 'example.com'], [':path', '/index.html'], |
| 280 | ['user-agent', 'v.http/0.1'], ['accept', '*/*'], ['cookie', 'session=abc123']]) |
| 281 | } |
| 282 | |
| 283 | // --- Decoder error handling --- |
| 284 | |
| 285 | fn test_hpack_rejects_zero_index() { |
| 286 | mut d := H2HpackDecoder{} |
| 287 | d.decode([u8(0x80)]) or { return } // indexed header field, index 0 |
| 288 | assert false, 'expected error for index 0' |
| 289 | } |
| 290 | |
| 291 | fn test_hpack_rejects_out_of_range_index() { |
| 292 | mut d := H2HpackDecoder{} |
| 293 | d.decode([u8(0xff), 0x00]) or { return } // index 62, dynamic table empty |
| 294 | assert false, 'expected error for out-of-range index' |
| 295 | } |
| 296 | |
| 297 | fn test_hpack_rejects_size_update_after_field() { |
| 298 | mut d := H2HpackDecoder{} |
| 299 | // Indexed field (0x82) followed by a dynamic table size update (0x20). |
| 300 | d.decode([u8(0x82), 0x20]) or { return } |
| 301 | assert false, 'expected error for size update after field' |
| 302 | } |
| 303 | |
| 304 | fn test_hpack_rejects_size_update_over_limit() { |
| 305 | mut d := H2HpackDecoder{} |
| 306 | d.set_max_dynamic_size(4096) |
| 307 | // 0x3f e0 0f = dynamic table size update to 4096+... well over 4096. |
| 308 | // Dynamic table size update to 8192 (> 4096 limit). |
| 309 | d.decode([u8(0x3f), 0xe1, 0x3f]) or { return } |
| 310 | assert false, 'expected error for size update over limit' |
| 311 | } |
| 312 | |
| 313 | fn test_hpack_rejects_truncating_index() { |
| 314 | mut d := H2HpackDecoder{} |
| 315 | // Insert one dynamic entry, so dynamic index 1 (HPACK index 62) is valid. |
| 316 | _ := d.decode(hexb('400a 6375 7374 6f6d 2d6b 6579 0d63 7573 746f 6d2d 6865 6164 6572'))! |
| 317 | // Indexed representation with idx = 2^32 + 62: it truncates to 62 (a valid |
| 318 | // dynamic index) when narrowed to a 32-bit int, but must be rejected. |
| 319 | mut block := []u8{} |
| 320 | h2_hpack_write_int(mut block, u64(0x1_0000_0000) + 62, 7, 0x80) |
| 321 | d.decode(block) or { return } |
| 322 | assert false, 'expected out-of-range error for truncating index' |
| 323 | } |
| 324 | |
| 325 | fn test_hpack_rejects_truncating_string_length() { |
| 326 | mut d := H2HpackDecoder{} |
| 327 | mut block := []u8{} |
| 328 | block << 0x00 // literal without indexing, name index 0 |
| 329 | block << 0x00 // empty name (H=0, length 0) |
| 330 | // Value string length = 2^32 + 5, which truncates to 5 in a 32-bit int. |
| 331 | h2_hpack_write_int(mut block, u64(0x1_0000_0000) + 5, 7, 0x00) |
| 332 | // No value bytes follow; the oversized length must be rejected cleanly. |
| 333 | d.decode(block) or { return } |
| 334 | assert false, 'expected length-exceeds-buffer error for truncating string length' |
| 335 | } |
| 336 | |