http-2 0.6.1
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.
- checksums.yaml +7 -0
- data/.autotest +19 -0
- data/.gitignore +17 -0
- data/.rspec +3 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/README.md +280 -0
- data/Rakefile +11 -0
- data/example/client.rb +46 -0
- data/example/helper.rb +14 -0
- data/example/server.rb +50 -0
- data/http-2.gemspec +24 -0
- data/lib/http/2/buffer.rb +21 -0
- data/lib/http/2/compressor.rb +493 -0
- data/lib/http/2/connection.rb +516 -0
- data/lib/http/2/emitter.rb +47 -0
- data/lib/http/2/error.rb +45 -0
- data/lib/http/2/flow_buffer.rb +64 -0
- data/lib/http/2/framer.rb +302 -0
- data/lib/http/2/stream.rb +474 -0
- data/lib/http/2/version.rb +3 -0
- data/lib/http/2.rb +9 -0
- data/spec/compressor_spec.rb +384 -0
- data/spec/connection_spec.rb +448 -0
- data/spec/emitter_spec.rb +46 -0
- data/spec/framer_spec.rb +325 -0
- data/spec/helper.rb +98 -0
- data/spec/stream_spec.rb +683 -0
- metadata +120 -0
@@ -0,0 +1,493 @@
|
|
1
|
+
require "stringio"
|
2
|
+
|
3
|
+
module HTTP2
|
4
|
+
|
5
|
+
# Implementation of header compression for HTTP 2.0 (HPACK) format adapted
|
6
|
+
# to efficiently represent HTTP headers in the context of HTTP 2.0.
|
7
|
+
#
|
8
|
+
# - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression
|
9
|
+
module Header
|
10
|
+
|
11
|
+
# The set of components used to encode or decode a header set form an
|
12
|
+
# encoding context: an encoding context contains a header table and a
|
13
|
+
# reference set - there is one encoding context for each direction.
|
14
|
+
#
|
15
|
+
class CompressionContext
|
16
|
+
include Error
|
17
|
+
|
18
|
+
# TODO: replace StringIO with Buffer...
|
19
|
+
|
20
|
+
# Default request working set as defined by the spec.
|
21
|
+
REQ_DEFAULTS = [
|
22
|
+
[':scheme' ,'http' ],
|
23
|
+
[':scheme' ,'https'],
|
24
|
+
[':host' ,'' ],
|
25
|
+
[':path' ,'/' ],
|
26
|
+
[':method' ,'get' ],
|
27
|
+
['accept' ,'' ],
|
28
|
+
['accept-charset' ,'' ],
|
29
|
+
['accept-encoding' ,'' ],
|
30
|
+
['accept-language' ,'' ],
|
31
|
+
['cookie' ,'' ],
|
32
|
+
['if-modified-since' ,'' ],
|
33
|
+
['keep-alive' ,'' ],
|
34
|
+
['user-agent' ,'' ],
|
35
|
+
['proxy-connection' ,'' ],
|
36
|
+
['referer' ,'' ],
|
37
|
+
['accept-datetime' ,'' ],
|
38
|
+
['authorization' ,'' ],
|
39
|
+
['allow' ,'' ],
|
40
|
+
['cache-control' ,'' ],
|
41
|
+
['connection' ,'' ],
|
42
|
+
['content-length' ,'' ],
|
43
|
+
['content-md5' ,'' ],
|
44
|
+
['content-type' ,'' ],
|
45
|
+
['date' ,'' ],
|
46
|
+
['expect' ,'' ],
|
47
|
+
['from' ,'' ],
|
48
|
+
['if-match' ,'' ],
|
49
|
+
['if-none-match' ,'' ],
|
50
|
+
['if-range' ,'' ],
|
51
|
+
['if-unmodified-since','' ],
|
52
|
+
['max-forwards' ,'' ],
|
53
|
+
['pragma' ,'' ],
|
54
|
+
['proxy-authorization','' ],
|
55
|
+
['range' ,'' ],
|
56
|
+
['te' ,'' ],
|
57
|
+
['upgrade' ,'' ],
|
58
|
+
['via' ,'' ],
|
59
|
+
['warning' ,'' ]
|
60
|
+
];
|
61
|
+
|
62
|
+
# Default response working set as defined by the spec.
|
63
|
+
RESP_DEFAULTS = [
|
64
|
+
[':status' ,'200'],
|
65
|
+
['age' ,'' ],
|
66
|
+
['cache-control' ,'' ],
|
67
|
+
['content-length' ,'' ],
|
68
|
+
['content-type' ,'' ],
|
69
|
+
['date' ,'' ],
|
70
|
+
['etag' ,'' ],
|
71
|
+
['expires' ,'' ],
|
72
|
+
['last-modified' ,'' ],
|
73
|
+
['server' ,'' ],
|
74
|
+
['set-cookie' ,'' ],
|
75
|
+
['vary' ,'' ],
|
76
|
+
['via' ,'' ],
|
77
|
+
['access-control-allow-origin','' ],
|
78
|
+
['accept-ranges' ,'' ],
|
79
|
+
['allow' ,'' ],
|
80
|
+
['connection' ,'' ],
|
81
|
+
['content-disposition' ,'' ],
|
82
|
+
['content-encoding' ,'' ],
|
83
|
+
['content-language' ,'' ],
|
84
|
+
['content-location' ,'' ],
|
85
|
+
['content-md5' ,'' ],
|
86
|
+
['content-range' ,'' ],
|
87
|
+
['link' ,'' ],
|
88
|
+
['location' ,'' ],
|
89
|
+
['p3p' ,'' ],
|
90
|
+
['pragma' ,'' ],
|
91
|
+
['proxy-authenticate' ,'' ],
|
92
|
+
['refresh' ,'' ],
|
93
|
+
['retry-after' ,'' ],
|
94
|
+
['strict-transport-security' ,'' ],
|
95
|
+
['trailer' ,'' ],
|
96
|
+
['transfer-encoding' ,'' ],
|
97
|
+
['warning' ,'' ],
|
98
|
+
['www-authenticate' ,'' ]
|
99
|
+
];
|
100
|
+
|
101
|
+
# Current table of header key-value pairs.
|
102
|
+
attr_reader :table
|
103
|
+
|
104
|
+
# Current working set of header key-value pairs.
|
105
|
+
attr_reader :workset
|
106
|
+
|
107
|
+
# Initializes compression context with appropriate client/server
|
108
|
+
# defaults and maximum size of the header table.
|
109
|
+
#
|
110
|
+
# @param type [Symbol] either :request or :response
|
111
|
+
# @param limit [Integer] maximum header table size in bytes
|
112
|
+
def initialize(type, limit = 4096)
|
113
|
+
@type = type
|
114
|
+
@table = (type == :request) ? REQ_DEFAULTS.dup : RESP_DEFAULTS.dup
|
115
|
+
@limit = limit
|
116
|
+
@workset = []
|
117
|
+
end
|
118
|
+
|
119
|
+
# Performs differential coding based on provided command type.
|
120
|
+
# - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-01#section-3.1
|
121
|
+
#
|
122
|
+
# @param cmd [Hash]
|
123
|
+
def process(cmd)
|
124
|
+
# indexed representation
|
125
|
+
if cmd[:type] == :indexed
|
126
|
+
# For an indexed representation, the decoder checks whether the index
|
127
|
+
# is present in the working set. If true, the corresponding entry is
|
128
|
+
# removed from the working set. If several entries correspond to this
|
129
|
+
# encoded index, all these entries are removed from the working set.
|
130
|
+
# If the index is not present in the working set, it is used to
|
131
|
+
# retrieve the corresponding header from the header table, and a new
|
132
|
+
# entry is added to the working set representing this header.
|
133
|
+
cur = @workset.find_index {|(i,v)| i == cmd[:name]}
|
134
|
+
|
135
|
+
if cur
|
136
|
+
@workset.delete_at(cur)
|
137
|
+
else
|
138
|
+
@workset.push [cmd[:name], @table[cmd[:name]]]
|
139
|
+
end
|
140
|
+
|
141
|
+
else
|
142
|
+
# For a literal representation, a new entry is added to the working
|
143
|
+
# set representing this header. If the literal representation specifies
|
144
|
+
# that the header is to be indexed, the header is added accordingly to
|
145
|
+
# the header table, and its index is included in the entry in the working
|
146
|
+
# set. Otherwise, the entry in the working set contains an undefined index.
|
147
|
+
if cmd[:name].is_a? Integer
|
148
|
+
k,v = @table[cmd[:name]]
|
149
|
+
|
150
|
+
cmd[:index] ||= cmd[:name]
|
151
|
+
cmd[:value] ||= v
|
152
|
+
cmd[:name] = k
|
153
|
+
end
|
154
|
+
|
155
|
+
newval = [cmd[:name], cmd[:value]]
|
156
|
+
|
157
|
+
if cmd[:type] != :noindex
|
158
|
+
size_check cmd
|
159
|
+
|
160
|
+
case cmd[:type]
|
161
|
+
when :incremental
|
162
|
+
cmd[:index] = @table.size
|
163
|
+
when :substitution
|
164
|
+
if @table[cmd[:index]].nil?
|
165
|
+
raise HeaderException.new("invalid index")
|
166
|
+
end
|
167
|
+
when :prepend
|
168
|
+
@table = [newval] + @table
|
169
|
+
end
|
170
|
+
|
171
|
+
@table[cmd[:index]] = newval
|
172
|
+
end
|
173
|
+
|
174
|
+
@workset.push [cmd[:index], newval]
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
# First, upon starting the decoding of a new set of headers, the
|
179
|
+
# reference set of headers is interpreted into the working set of
|
180
|
+
# headers: for each header in the reference set, an entry is added to
|
181
|
+
# the working set, containing the header name, its value, and its
|
182
|
+
# current index in the header table.
|
183
|
+
#
|
184
|
+
# @return [Array] current working set
|
185
|
+
def update_sets
|
186
|
+
# new refset is the the workset sans headers not in header table
|
187
|
+
refset = @workset.reject {|(i,h)| !@table.include? h}
|
188
|
+
|
189
|
+
# new workset is the refset with index of each header in header table
|
190
|
+
@workset = refset.collect {|(i,h)| [@table.find_index(h), h]}
|
191
|
+
end
|
192
|
+
|
193
|
+
# Emits best available command to encode provided header.
|
194
|
+
#
|
195
|
+
# @param header [Hash]
|
196
|
+
def addcmd(header)
|
197
|
+
# check if we have an exact match in header table
|
198
|
+
if idx = @table.index(header)
|
199
|
+
if !active? idx
|
200
|
+
return { name: idx, type: :indexed }
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
# check if we have a partial match on header name
|
205
|
+
if idx = @table.index {|(k,_)| k == header.first}
|
206
|
+
# default to incremental indexing
|
207
|
+
cmd = { name: idx, value: header.last, type: :incremental}
|
208
|
+
|
209
|
+
# TODO: implement literal without indexing strategy
|
210
|
+
# TODO: implement substitution strategy (if it makes sense)
|
211
|
+
# if default? idx
|
212
|
+
# cmd[:type] = :incremental
|
213
|
+
# else
|
214
|
+
# cmd[:type] = :substitution
|
215
|
+
# cmd[:index] = idx
|
216
|
+
# end
|
217
|
+
|
218
|
+
return cmd
|
219
|
+
end
|
220
|
+
|
221
|
+
return { name: header.first, value: header.last, type: :incremental }
|
222
|
+
end
|
223
|
+
|
224
|
+
# Emits command to remove current index from working set.
|
225
|
+
#
|
226
|
+
# @param idx [Integer]
|
227
|
+
def removecmd(idx)
|
228
|
+
{name: idx, type: :indexed}
|
229
|
+
end
|
230
|
+
|
231
|
+
private
|
232
|
+
|
233
|
+
# Before adding a new entry to the header table or changing an existing
|
234
|
+
# one, a check has to be performed to ensure that the change will not
|
235
|
+
# cause the table to grow in size beyond the SETTINGS_MAX_BUFFER_SIZE
|
236
|
+
# limit. If necessary, one or more items from the beginning of the
|
237
|
+
# table are removed until there is enough free space available to make
|
238
|
+
# the modification. Dropping an entry from the beginning of the table
|
239
|
+
# causes the index positions of the remaining entries in the table to
|
240
|
+
# be decremented by 1.
|
241
|
+
#
|
242
|
+
# @param cmd [Hash]
|
243
|
+
def size_check(cmd)
|
244
|
+
cursize = @table.join.bytesize + @table.size * 32
|
245
|
+
cmdsize = cmd[:name].bytesize + cmd[:value].bytesize + 32
|
246
|
+
|
247
|
+
cur = 0
|
248
|
+
while (cursize + cmdsize) > @limit do
|
249
|
+
e = @table.shift
|
250
|
+
|
251
|
+
# When using substitution indexing, it is possible that the existing
|
252
|
+
# item being replaced might be one of the items removed when performing
|
253
|
+
# the necessary size adjustment. In such cases, the substituted value
|
254
|
+
# being added to the header table is inserted at the beginning of the
|
255
|
+
# header table (at index position #0) and the index positions of the
|
256
|
+
# other remaining entries in the table are incremented by 1.
|
257
|
+
if cmd[:type] == :substitution && cur == cmd[:index]
|
258
|
+
cmd[:type] = :prepend
|
259
|
+
end
|
260
|
+
|
261
|
+
cursize -= (e.join.bytesize + 32)
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
def active?(idx)
|
266
|
+
!@workset.find {|i,_| i == idx }.nil?
|
267
|
+
end
|
268
|
+
|
269
|
+
def default?(idx)
|
270
|
+
t = (@type == :request) ? REQ_DEFAULTS : RESP_DEFAULTS
|
271
|
+
idx < t.size
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
# Header representation as defined by the spec.
|
276
|
+
HEADREP = {
|
277
|
+
indexed: {prefix: 7, pattern: 0x80},
|
278
|
+
noindex: {prefix: 5, pattern: 0x60},
|
279
|
+
incremental: {prefix: 5, pattern: 0x40},
|
280
|
+
substitution: {prefix: 6, pattern: 0x00}
|
281
|
+
}
|
282
|
+
|
283
|
+
# Responsible for encoding header key-value pairs using HPACK algorithm.
|
284
|
+
# Compressor must be initialized with appropriate starting context based
|
285
|
+
# on local role: client or server.
|
286
|
+
#
|
287
|
+
# @example
|
288
|
+
# client_role = Compressor.new(:request)
|
289
|
+
# server_role = Compressor.new(:response)
|
290
|
+
class Compressor
|
291
|
+
def initialize(type)
|
292
|
+
@cc = CompressionContext.new(type)
|
293
|
+
end
|
294
|
+
|
295
|
+
# Encodes provided value via integer representation.
|
296
|
+
# - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-01#section-4.2.1
|
297
|
+
#
|
298
|
+
# If I < 2^N - 1, encode I on N bits
|
299
|
+
# Else, encode 2^N - 1 on N bits and do the following steps:
|
300
|
+
# Set I to (I - (2^N - 1)) and Q to 1
|
301
|
+
# While Q > 0
|
302
|
+
# Compute Q and R, quotient and remainder of I divided by 2^7
|
303
|
+
# If Q is strictly greater than 0, write one 1 bit; otherwise, write one 0 bit
|
304
|
+
# Encode R on the next 7 bits
|
305
|
+
# I = Q
|
306
|
+
#
|
307
|
+
# @param i [Integer] value to encode
|
308
|
+
# @param n [Integer] number of available bits
|
309
|
+
# @return [String] binary string
|
310
|
+
def integer(i, n)
|
311
|
+
limit = 2**n - 1
|
312
|
+
return [i].pack('C') if (i < limit)
|
313
|
+
|
314
|
+
bytes = []
|
315
|
+
bytes.push limit if !n.zero?
|
316
|
+
|
317
|
+
i -= limit
|
318
|
+
q = 1
|
319
|
+
|
320
|
+
while (q > 0) do
|
321
|
+
q, r = i.divmod(128)
|
322
|
+
r += 128 if (q > 0)
|
323
|
+
i = q
|
324
|
+
|
325
|
+
bytes.push(r)
|
326
|
+
end
|
327
|
+
|
328
|
+
bytes.pack('C*')
|
329
|
+
end
|
330
|
+
|
331
|
+
# Encodes provided value via string literal representation.
|
332
|
+
# - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-01#section-4.2.2
|
333
|
+
#
|
334
|
+
# * The string length, defined as the number of bytes needed to store
|
335
|
+
# its UTF-8 representation, is represented as an integer with a zero
|
336
|
+
# bits prefix. If the string length is strictly less than 128, it is
|
337
|
+
# represented as one byte.
|
338
|
+
# * The string value represented as a list of UTF-8 character
|
339
|
+
#
|
340
|
+
# @param str [String]
|
341
|
+
# @return [String] binary string
|
342
|
+
def string(str)
|
343
|
+
integer(str.bytesize, 0) + str.dup.force_encoding('binary')
|
344
|
+
end
|
345
|
+
|
346
|
+
# Encodes header command with appropriate header representation.
|
347
|
+
# - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-01#section-4.3
|
348
|
+
#
|
349
|
+
# @param h [Hash] header command
|
350
|
+
# @param buffer [String]
|
351
|
+
def header(h, buffer = "")
|
352
|
+
rep = HEADREP[h[:type]]
|
353
|
+
|
354
|
+
if h[:type] == :indexed
|
355
|
+
buffer << integer(h[:name], rep[:prefix])
|
356
|
+
|
357
|
+
else
|
358
|
+
if h[:name].is_a? Integer
|
359
|
+
buffer << integer(h[:name]+1, rep[:prefix])
|
360
|
+
else
|
361
|
+
buffer << integer(0, rep[:prefix])
|
362
|
+
buffer << string(h[:name])
|
363
|
+
end
|
364
|
+
|
365
|
+
if h[:type] == :substitution
|
366
|
+
buffer << integer(h[:index], 0)
|
367
|
+
end
|
368
|
+
|
369
|
+
if h[:value].is_a? Integer
|
370
|
+
buffer << integer(h[:value], 0)
|
371
|
+
else
|
372
|
+
buffer << string(h[:value])
|
373
|
+
end
|
374
|
+
end
|
375
|
+
|
376
|
+
# set header representation pattern on first byte
|
377
|
+
fb = buffer[0].unpack("C").first | rep[:pattern]
|
378
|
+
buffer.setbyte(0, fb)
|
379
|
+
|
380
|
+
buffer
|
381
|
+
end
|
382
|
+
|
383
|
+
# Encodes provided list of HTTP headers.
|
384
|
+
#
|
385
|
+
# @param headers [Hash]
|
386
|
+
# @return [String] binary string
|
387
|
+
def encode(headers)
|
388
|
+
commands = []
|
389
|
+
@cc.update_sets
|
390
|
+
|
391
|
+
# Remove missing headers from the working set
|
392
|
+
@cc.workset.each do |idx, (wk,wv)|
|
393
|
+
if headers.find {|(hk,hv)| hk == wk && hv == wv }.nil?
|
394
|
+
commands.push @cc.removecmd idx
|
395
|
+
end
|
396
|
+
end
|
397
|
+
|
398
|
+
# Add missing headers to the working set
|
399
|
+
headers.each do |(hk,hv)|
|
400
|
+
if @cc.workset.find {|i,(wk,wv)| hk == wk && hv == wv}.nil?
|
401
|
+
commands.push @cc.addcmd [hk, hv]
|
402
|
+
end
|
403
|
+
end
|
404
|
+
|
405
|
+
commands.map do |cmd|
|
406
|
+
@cc.process cmd.dup
|
407
|
+
header cmd
|
408
|
+
end.join
|
409
|
+
end
|
410
|
+
end
|
411
|
+
|
412
|
+
# Responsible for decoding received headers and maintaining compression
|
413
|
+
# context of the opposing peer. Decompressor must be initialized with
|
414
|
+
# appropriate starting context based on local role: client or server.
|
415
|
+
#
|
416
|
+
# @example
|
417
|
+
# server_role = Decompressor.new(:request)
|
418
|
+
# client_role = Decompressor.new(:response)
|
419
|
+
class Decompressor
|
420
|
+
def initialize(type)
|
421
|
+
@cc = CompressionContext.new(type)
|
422
|
+
end
|
423
|
+
|
424
|
+
# Decodes integer value from provided buffer.
|
425
|
+
#
|
426
|
+
# @param buf [String]
|
427
|
+
# @param n [Integer] number of available bits
|
428
|
+
def integer(buf, n)
|
429
|
+
limit = 2**n - 1
|
430
|
+
i = !n.zero? ? (buf.getbyte & limit) : 0
|
431
|
+
|
432
|
+
m = 0
|
433
|
+
buf.each_byte do |byte|
|
434
|
+
i += ((byte & 127) << m)
|
435
|
+
m += 7
|
436
|
+
|
437
|
+
break if (byte & 128).zero?
|
438
|
+
end if (i == limit)
|
439
|
+
|
440
|
+
i
|
441
|
+
end
|
442
|
+
|
443
|
+
# Decodes string value from provided buffer.
|
444
|
+
#
|
445
|
+
# @param buf [String]
|
446
|
+
# @return [String] UTF-8 encoded string
|
447
|
+
def string(buf)
|
448
|
+
buf.read(integer(buf, 0)).force_encoding('utf-8')
|
449
|
+
end
|
450
|
+
|
451
|
+
# Decodes header command from provided buffer.
|
452
|
+
#
|
453
|
+
# @param buf [String]
|
454
|
+
def header(buf)
|
455
|
+
peek = buf.getbyte
|
456
|
+
buf.seek(-1, IO::SEEK_CUR)
|
457
|
+
|
458
|
+
header = {}
|
459
|
+
header[:type], type = HEADREP.select do |t, desc|
|
460
|
+
mask = (peek >> desc[:prefix]) << desc[:prefix]
|
461
|
+
mask == desc[:pattern]
|
462
|
+
end.first
|
463
|
+
|
464
|
+
header[:name] = integer(buf, type[:prefix])
|
465
|
+
if header[:type] != :indexed
|
466
|
+
header[:name] -= 1
|
467
|
+
|
468
|
+
if header[:name] == -1
|
469
|
+
header[:name] = string(buf)
|
470
|
+
end
|
471
|
+
|
472
|
+
if header[:type] == :substitution
|
473
|
+
header[:index] = integer(buf, 0)
|
474
|
+
end
|
475
|
+
|
476
|
+
header[:value] = string(buf)
|
477
|
+
end
|
478
|
+
|
479
|
+
header
|
480
|
+
end
|
481
|
+
|
482
|
+
# Decodes and processes header commands within provided buffer.
|
483
|
+
#
|
484
|
+
# @param buf [String]
|
485
|
+
def decode(buf)
|
486
|
+
@cc.update_sets
|
487
|
+
@cc.process(header(buf)) while !buf.eof?
|
488
|
+
@cc.workset.map {|i,header| header}
|
489
|
+
end
|
490
|
+
end
|
491
|
+
|
492
|
+
end
|
493
|
+
end
|