http-2-next 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,619 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTP2Next
4
+ # Implementation of header compression for HTTP 2.0 (HPACK) format adapted
5
+ # to efficiently represent HTTP headers in the context of HTTP 2.0.
6
+ #
7
+ # - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-10
8
+ module Header
9
+ # To decompress header blocks, a decoder only needs to maintain a
10
+ # dynamic table as a decoding context.
11
+ # No other state information is needed.
12
+ class EncodingContext
13
+ include Error
14
+
15
+ # @private
16
+ # Static table
17
+ # - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-10#appendix-A
18
+ STATIC_TABLE = [
19
+ [":authority", ""],
20
+ [":method", "GET"],
21
+ [":method", "POST"],
22
+ [":path", "/"],
23
+ [":path", "/index.html"],
24
+ [":scheme", "http"],
25
+ [":scheme", "https"],
26
+ [":status", "200"],
27
+ [":status", "204"],
28
+ [":status", "206"],
29
+ [":status", "304"],
30
+ [":status", "400"],
31
+ [":status", "404"],
32
+ [":status", "500"],
33
+ ["accept-charset", ""],
34
+ ["accept-encoding", "gzip, deflate"],
35
+ ["accept-language", ""],
36
+ ["accept-ranges", ""],
37
+ ["accept", ""],
38
+ ["access-control-allow-origin", ""],
39
+ ["age", ""],
40
+ ["allow", ""],
41
+ ["authorization", ""],
42
+ ["cache-control", ""],
43
+ ["content-disposition", ""],
44
+ ["content-encoding", ""],
45
+ ["content-language", ""],
46
+ ["content-length", ""],
47
+ ["content-location", ""],
48
+ ["content-range", ""],
49
+ ["content-type", ""],
50
+ ["cookie", ""],
51
+ ["date", ""],
52
+ ["etag", ""],
53
+ ["expect", ""],
54
+ ["expires", ""],
55
+ ["from", ""],
56
+ ["host", ""],
57
+ ["if-match", ""],
58
+ ["if-modified-since", ""],
59
+ ["if-none-match", ""],
60
+ ["if-range", ""],
61
+ ["if-unmodified-since", ""],
62
+ ["last-modified", ""],
63
+ ["link", ""],
64
+ ["location", ""],
65
+ ["max-forwards", ""],
66
+ ["proxy-authenticate", ""],
67
+ ["proxy-authorization", ""],
68
+ ["range", ""],
69
+ ["referer", ""],
70
+ ["refresh", ""],
71
+ ["retry-after", ""],
72
+ ["server", ""],
73
+ ["set-cookie", ""],
74
+ ["strict-transport-security", ""],
75
+ ["transfer-encoding", ""],
76
+ ["user-agent", ""],
77
+ ["vary", ""],
78
+ ["via", ""],
79
+ ["www-authenticate", ""]
80
+ ].each { |pair| pair.each(&:freeze).freeze }.freeze
81
+
82
+ # Current table of header key-value pairs.
83
+ attr_reader :table
84
+
85
+ # Current encoding options
86
+ #
87
+ # :table_size Integer maximum dynamic table size in bytes
88
+ # :huffman Symbol :always, :never, :shorter
89
+ # :index Symbol :all, :static, :never
90
+ attr_reader :options
91
+
92
+ # Initializes compression context with appropriate client/server
93
+ # defaults and maximum size of the dynamic table.
94
+ #
95
+ # @param options [Hash] encoding options
96
+ # :table_size Integer maximum dynamic table size in bytes
97
+ # :huffman Symbol :always, :never, :shorter
98
+ # :index Symbol :all, :static, :never
99
+ def initialize(**options)
100
+ default_options = {
101
+ huffman: :shorter,
102
+ index: :all,
103
+ table_size: 4096
104
+ }
105
+ @table = []
106
+ @options = default_options.merge(options)
107
+ @limit = @options[:table_size]
108
+ @_table_updated = false
109
+ end
110
+
111
+ # Duplicates current compression context
112
+ # @return [EncodingContext]
113
+ def dup
114
+ other = EncodingContext.new(@options)
115
+ t = @table
116
+ l = @limit
117
+ other.instance_eval do
118
+ @table = t.dup # shallow copy
119
+ @limit = l
120
+ end
121
+ other
122
+ end
123
+
124
+ # Finds an entry in current dynamic table by index.
125
+ # Note that index is zero-based in this module.
126
+ #
127
+ # If the index is greater than the last index in the static table,
128
+ # an entry in the dynamic table is dereferenced.
129
+ #
130
+ # If the index is greater than the last header index, an error is raised.
131
+ #
132
+ # @param index [Integer] zero-based index in the dynamic table.
133
+ # @return [Array] +[key, value]+
134
+ def dereference(index)
135
+ # NOTE: index is zero-based in this module.
136
+ value = STATIC_TABLE[index] || @table[index - STATIC_TABLE.size]
137
+ raise CompressionError, "Index too large" unless value
138
+
139
+ value
140
+ end
141
+
142
+ # Header Block Processing
143
+ # - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-10#section-4.1
144
+ #
145
+ # @param cmd [Hash] { type:, name:, value:, index: }
146
+ # @return [Array, nil] +[name, value]+ header field that is added to the decoded header list,
147
+ # or nil if +cmd[:type]+ is +:changetablesize+
148
+ def process(cmd)
149
+ emit = nil
150
+
151
+ case cmd[:type]
152
+ when :changetablesize
153
+ raise CompressionError, "tried to change table size after adding elements to table" if @_table_updated
154
+
155
+ # we can receive multiple table size change commands inside a header frame. However,
156
+ # we should blow up if we receive another frame where the new table size is bigger.
157
+ table_size_updated = @limit != @options[:table_size]
158
+
159
+ raise CompressionError, "dynamic table size update exceed limit" if !table_size_updated && cmd[:value] > @limit
160
+
161
+ self.table_size = cmd[:value]
162
+
163
+ when :indexed
164
+ # Indexed Representation
165
+ # An _indexed representation_ entails the following actions:
166
+ # o The header field corresponding to the referenced entry in either
167
+ # the static table or dynamic table is added to the decoded header
168
+ # list.
169
+ idx = cmd[:name]
170
+
171
+ k, v = dereference(idx)
172
+ emit = [k, v]
173
+
174
+ when :incremental, :noindex, :neverindexed
175
+ # A _literal representation_ that is _not added_ to the dynamic table
176
+ # entails the following action:
177
+ # o The header field is added to the decoded header list.
178
+
179
+ # A _literal representation_ that is _added_ to the dynamic table
180
+ # entails the following actions:
181
+ # o The header field is added to the decoded header list.
182
+ # o The header field is inserted at the beginning of the dynamic table.
183
+
184
+ if cmd[:name].is_a? Integer
185
+ k, v = dereference(cmd[:name])
186
+
187
+ cmd = cmd.dup
188
+ cmd[:index] ||= cmd[:name]
189
+ cmd[:value] ||= v
190
+ cmd[:name] = k
191
+ elsif cmd[:name] != cmd[:name].downcase
192
+ raise ProtocolError, "Invalid uppercase key: #{cmd[:name]}"
193
+ end
194
+
195
+ emit = [cmd[:name], cmd[:value]]
196
+
197
+ add_to_table(emit) if cmd[:type] == :incremental
198
+
199
+ else
200
+ raise CompressionError, "Invalid type: #{cmd[:type]}"
201
+ end
202
+
203
+ emit
204
+ end
205
+
206
+ # Plan header compression according to +@options [:index]+
207
+ # :never Do not use dynamic table or static table reference at all.
208
+ # :static Use static table only.
209
+ # :all Use all of them.
210
+ #
211
+ # @param headers [Array] +[[name, value], ...]+
212
+ # @return [Array] array of commands
213
+ def encode(headers)
214
+ commands = []
215
+ # Literals commands are marked with :noindex when index is not used
216
+ noindex = %i[static never].include?(@options[:index])
217
+ headers.each do |field, value|
218
+ # Literal header names MUST be translated to lowercase before
219
+ # encoding and transmission.
220
+ field = field.downcase
221
+ value = "/" if field == ":path" && value.empty?
222
+ cmd = addcmd(field, value)
223
+ cmd[:type] = :noindex if noindex && cmd[:type] == :incremental
224
+ commands << cmd
225
+ process(cmd)
226
+ end
227
+ commands
228
+ end
229
+
230
+ # Emits command for a header.
231
+ # Prefer static table over dynamic table.
232
+ # Prefer exact match over name-only match.
233
+ #
234
+ # +@options [:index]+ controls whether to use the dynamic table,
235
+ # static table, or both.
236
+ # :never Do not use dynamic table or static table reference at all.
237
+ # :static Use static table only.
238
+ # :all Use all of them.
239
+ #
240
+ # @param header [Array] +[name, value]+
241
+ # @return [Hash] command
242
+ def addcmd(*header)
243
+ exact = nil
244
+ name_only = nil
245
+
246
+ if %i[all static].include?(@options[:index])
247
+ STATIC_TABLE.each_index do |i|
248
+ if STATIC_TABLE[i] == header
249
+ exact ||= i
250
+ break
251
+ elsif STATIC_TABLE[i].first == header.first
252
+ name_only ||= i
253
+ end
254
+ end
255
+ end
256
+ if [:all].include?(@options[:index]) && !exact
257
+ @table.each_index do |i|
258
+ if @table[i] == header
259
+ exact ||= i + STATIC_TABLE.size
260
+ break
261
+ elsif @table[i].first == header.first
262
+ name_only ||= i + STATIC_TABLE.size
263
+ end
264
+ end
265
+ end
266
+
267
+ if exact
268
+ { name: exact, type: :indexed }
269
+ elsif name_only
270
+ { name: name_only, value: header.last, type: :incremental }
271
+ else
272
+ { name: header.first, value: header.last, type: :incremental }
273
+ end
274
+ end
275
+
276
+ # Alter dynamic table size.
277
+ # When the size is reduced, some headers might be evicted.
278
+ def table_size=(size)
279
+ @limit = size
280
+ size_check(nil)
281
+ end
282
+
283
+ # Returns current table size in octets
284
+ # @return [Integer]
285
+ def current_table_size
286
+ @table.inject(0) { |r, (k, v)| r + k.bytesize + v.bytesize + 32 }
287
+ end
288
+
289
+ def listen_on_table
290
+ yield
291
+ ensure
292
+ @_table_updated = false
293
+ end
294
+
295
+ private
296
+
297
+ # Add a name-value pair to the dynamic table.
298
+ # Older entries might have been evicted so that
299
+ # the new entry fits in the dynamic table.
300
+ #
301
+ # @param cmd [Array] +[name, value]+
302
+ def add_to_table(cmd)
303
+ return unless size_check(cmd)
304
+
305
+ @table.unshift(cmd)
306
+ @_table_updated = true
307
+ end
308
+
309
+ # To keep the dynamic table size lower than or equal to @limit,
310
+ # remove one or more entries at the end of the dynamic table.
311
+ #
312
+ # @param cmd [Hash]
313
+ # @return [Boolean] whether +cmd+ fits in the dynamic table.
314
+ def size_check(cmd)
315
+ cursize = current_table_size
316
+ cmdsize = cmd.nil? ? 0 : cmd[0].bytesize + cmd[1].bytesize + 32
317
+
318
+ while cursize + cmdsize > @limit
319
+ break if @table.empty?
320
+
321
+ e = @table.pop
322
+ cursize -= e[0].bytesize + e[1].bytesize + 32
323
+ end
324
+
325
+ cmdsize <= @limit
326
+ end
327
+ end
328
+
329
+ # Header representation as defined by the spec.
330
+ HEADREP = {
331
+ indexed: { prefix: 7, pattern: 0x80 },
332
+ incremental: { prefix: 6, pattern: 0x40 },
333
+ noindex: { prefix: 4, pattern: 0x00 },
334
+ neverindexed: { prefix: 4, pattern: 0x10 },
335
+ changetablesize: { prefix: 5, pattern: 0x20 }
336
+ }.each_value(&:freeze).freeze
337
+
338
+ # Predefined options set for Compressor
339
+ # http://mew.org/~kazu/material/2014-hpack.pdf
340
+ NAIVE = { index: :never, huffman: :never }.freeze
341
+ LINEAR = { index: :all, huffman: :never }.freeze
342
+ STATIC = { index: :static, huffman: :never }.freeze
343
+ SHORTER = { index: :all, huffman: :never }.freeze
344
+ NAIVEH = { index: :never, huffman: :always }.freeze
345
+ LINEARH = { index: :all, huffman: :always }.freeze
346
+ STATICH = { index: :static, huffman: :always }.freeze
347
+ SHORTERH = { index: :all, huffman: :shorter }.freeze
348
+
349
+ # Responsible for encoding header key-value pairs using HPACK algorithm.
350
+ class Compressor
351
+ # @param options [Hash] encoding options
352
+ def initialize(**options)
353
+ @cc = EncodingContext.new(options)
354
+ end
355
+
356
+ # Set dynamic table size in EncodingContext
357
+ # @param size [Integer] new dynamic table size
358
+ def table_size=(size)
359
+ @cc.table_size = size
360
+ end
361
+
362
+ # Encodes provided value via integer representation.
363
+ # - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-10#section-5.1
364
+ #
365
+ # If I < 2^N - 1, encode I on N bits
366
+ # Else
367
+ # encode 2^N - 1 on N bits
368
+ # I = I - (2^N - 1)
369
+ # While I >= 128
370
+ # Encode (I % 128 + 128) on 8 bits
371
+ # I = I / 128
372
+ # encode (I) on 8 bits
373
+ #
374
+ # @param i [Integer] value to encode
375
+ # @param n [Integer] number of available bits
376
+ # @return [String] binary string
377
+ def integer(i, n)
378
+ limit = 2**n - 1
379
+ return [i].pack("C") if i < limit
380
+
381
+ bytes = []
382
+ bytes.push limit unless n.zero?
383
+
384
+ i -= limit
385
+ while i >= 128
386
+ bytes.push((i % 128) + 128)
387
+ i /= 128
388
+ end
389
+
390
+ bytes.push i
391
+ bytes.pack("C*")
392
+ end
393
+
394
+ # Encodes provided value via string literal representation.
395
+ # - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-10#section-5.2
396
+ #
397
+ # * The string length, defined as the number of bytes needed to store
398
+ # its UTF-8 representation, is represented as an integer with a seven
399
+ # bits prefix. If the string length is strictly less than 127, it is
400
+ # represented as one byte.
401
+ # * If the bit 7 of the first byte is 1, the string value is represented
402
+ # as a list of Huffman encoded octets
403
+ # (padded with bit 1's until next octet boundary).
404
+ # * If the bit 7 of the first byte is 0, the string value is
405
+ # represented as a list of UTF-8 encoded octets.
406
+ #
407
+ # +@options [:huffman]+ controls whether to use Huffman encoding:
408
+ # :never Do not use Huffman encoding
409
+ # :always Always use Huffman encoding
410
+ # :shorter Use Huffman when the result is strictly shorter
411
+ #
412
+ # @param str [String]
413
+ # @return [String] binary string
414
+ def string(str)
415
+ plain = nil
416
+ huffman = nil
417
+ unless @cc.options[:huffman] == :always
418
+ plain = integer(str.bytesize, 7) << str.dup.force_encoding(Encoding::BINARY)
419
+ end
420
+ unless @cc.options[:huffman] == :never
421
+ huffman = Huffman.new.encode(str)
422
+ huffman = integer(huffman.bytesize, 7) << huffman
423
+ huffman.setbyte(0, huffman.ord | 0x80)
424
+ end
425
+ case @cc.options[:huffman]
426
+ when :always
427
+ huffman
428
+ when :never
429
+ plain
430
+ else
431
+ huffman.bytesize < plain.bytesize ? huffman : plain
432
+ end
433
+ end
434
+
435
+ # Encodes header command with appropriate header representation.
436
+ #
437
+ # @param h [Hash] header command
438
+ # @param buffer [String]
439
+ # @return [Buffer]
440
+ def header(h, buffer = Buffer.new)
441
+ rep = HEADREP[h[:type]]
442
+
443
+ case h[:type]
444
+ when :indexed
445
+ buffer << integer(h[:name] + 1, rep[:prefix])
446
+ when :changetablesize
447
+ buffer << integer(h[:value], rep[:prefix])
448
+ else
449
+ if h[:name].is_a? Integer
450
+ buffer << integer(h[:name] + 1, rep[:prefix])
451
+ else
452
+ buffer << integer(0, rep[:prefix])
453
+ buffer << string(h[:name])
454
+ end
455
+
456
+ buffer << string(h[:value])
457
+ end
458
+
459
+ # set header representation pattern on first byte
460
+ fb = buffer.ord | rep[:pattern]
461
+ buffer.setbyte(0, fb)
462
+
463
+ buffer
464
+ end
465
+
466
+ # Encodes provided list of HTTP headers.
467
+ #
468
+ # @param headers [Array] +[[name, value], ...]+
469
+ # @return [Buffer]
470
+ def encode(headers)
471
+ buffer = Buffer.new
472
+ pseudo_headers, regular_headers = headers.partition { |f, _| f.start_with? ":" }
473
+ headers = [*pseudo_headers, *regular_headers]
474
+ commands = @cc.encode(headers)
475
+ commands.each do |cmd|
476
+ buffer << header(cmd)
477
+ end
478
+
479
+ buffer
480
+ end
481
+ end
482
+
483
+ # Responsible for decoding received headers and maintaining compression
484
+ # context of the opposing peer. Decompressor must be initialized with
485
+ # appropriate starting context based on local role: client or server.
486
+ #
487
+ # @example
488
+ # server_role = Decompressor.new(:request)
489
+ # client_role = Decompressor.new(:response)
490
+ class Decompressor
491
+ include Error
492
+
493
+ # @param options [Hash] decoding options. Only :table_size is effective.
494
+ def initialize(**options)
495
+ @cc = EncodingContext.new(options)
496
+ end
497
+
498
+ # Set dynamic table size in EncodingContext
499
+ # @param size [Integer] new dynamic table size
500
+ def table_size=(size)
501
+ @cc.table_size = size
502
+ end
503
+
504
+ # Decodes integer value from provided buffer.
505
+ #
506
+ # @param buf [String]
507
+ # @param n [Integer] number of available bits
508
+ # @return [Integer]
509
+ def integer(buf, n)
510
+ limit = 2**n - 1
511
+ i = !n.zero? ? (buf.getbyte & limit) : 0
512
+
513
+ m = 0
514
+ if i == limit
515
+ while (byte = buf.getbyte)
516
+ i += ((byte & 127) << m)
517
+ m += 7
518
+
519
+ break if (byte & 128).zero?
520
+ end
521
+ end
522
+
523
+ i
524
+ end
525
+
526
+ # Decodes string value from provided buffer.
527
+ #
528
+ # @param buf [String]
529
+ # @return [String] UTF-8 encoded string
530
+ # @raise [CompressionError] when input is malformed
531
+ def string(buf)
532
+ raise CompressionError, "invalid header block fragment" if buf.empty?
533
+
534
+ huffman = (buf.readbyte(0) & 0x80) == 0x80
535
+ len = integer(buf, 7)
536
+ str = buf.read(len)
537
+ raise CompressionError, "string too short" unless str.bytesize == len
538
+
539
+ str = Huffman.new.decode(Buffer.new(str)) if huffman
540
+ str.force_encoding(Encoding::UTF_8)
541
+ end
542
+
543
+ # Decodes header command from provided buffer.
544
+ #
545
+ # @param buf [Buffer]
546
+ # @return [Hash] command
547
+ def header(buf)
548
+ peek = buf.readbyte(0)
549
+
550
+ header = {}
551
+ header[:type], type = HEADREP.find do |_t, desc|
552
+ mask = (peek >> desc[:prefix]) << desc[:prefix]
553
+ mask == desc[:pattern]
554
+ end
555
+
556
+ raise CompressionError unless header[:type]
557
+
558
+ header[:name] = integer(buf, type[:prefix])
559
+
560
+ case header[:type]
561
+ when :indexed
562
+ raise CompressionError if (header[:name]).zero?
563
+
564
+ header[:name] -= 1
565
+ when :changetablesize
566
+ header[:value] = header[:name]
567
+ else
568
+ if (header[:name]).zero?
569
+ header[:name] = string(buf)
570
+ else
571
+ header[:name] -= 1
572
+ end
573
+ header[:value] = string(buf)
574
+ end
575
+
576
+ header
577
+ end
578
+
579
+ FORBIDDEN_HEADERS = %w[connection te].freeze
580
+
581
+ # Decodes and processes header commands within provided buffer.
582
+ #
583
+ # @param buf [Buffer]
584
+ # @param frame [HTTP2Next::Frame, nil]
585
+ # @return [Array] +[[name, value], ...]
586
+ def decode(buf, frame = nil)
587
+ list = []
588
+ decoding_pseudo_headers = true
589
+ @cc.listen_on_table do
590
+ until buf.empty?
591
+ field, value = @cc.process(header(buf))
592
+ next if field.nil?
593
+
594
+ is_pseudo_header = field.start_with? ":"
595
+ if !decoding_pseudo_headers && is_pseudo_header
596
+ raise ProtocolError, "one or more pseudo headers encountered after regular headers"
597
+ end
598
+
599
+ decoding_pseudo_headers = is_pseudo_header
600
+ raise ProtocolError, "invalid header received: #{field}" if FORBIDDEN_HEADERS.include?(field)
601
+
602
+ if frame
603
+ case field
604
+ when ":method"
605
+ frame[:method] = value
606
+ when "content-length"
607
+ frame[:content_length] = Integer(value)
608
+ when "trailer"
609
+ (frame[:trailer] ||= []) << value
610
+ end
611
+ end
612
+ list << [field, value]
613
+ end
614
+ end
615
+ list
616
+ end
617
+ end
618
+ end
619
+ end