http-2-next 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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