tipi 0.38 → 0.42

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +5 -1
  3. data/.gitignore +5 -0
  4. data/CHANGELOG.md +34 -0
  5. data/Gemfile +5 -1
  6. data/Gemfile.lock +58 -16
  7. data/Rakefile +7 -3
  8. data/TODO.md +77 -1
  9. data/benchmarks/bm_http1_parser.rb +61 -0
  10. data/bin/benchmark +37 -0
  11. data/bin/h1pd +6 -0
  12. data/bin/tipi +3 -21
  13. data/df/sample_agent.rb +1 -1
  14. data/df/server.rb +16 -47
  15. data/df/server_utils.rb +178 -0
  16. data/examples/full_service.rb +13 -0
  17. data/examples/http1_parser.rb +55 -0
  18. data/examples/http_server.rb +15 -3
  19. data/examples/http_server_forked.rb +5 -1
  20. data/examples/http_server_routes.rb +29 -0
  21. data/examples/http_server_static.rb +26 -0
  22. data/examples/http_server_throttled.rb +3 -2
  23. data/examples/https_server.rb +6 -4
  24. data/examples/https_wss_server.rb +2 -1
  25. data/examples/rack_server.rb +5 -0
  26. data/examples/rack_server_https.rb +1 -1
  27. data/examples/rack_server_https_forked.rb +4 -3
  28. data/examples/routing_server.rb +5 -4
  29. data/examples/servername_cb.rb +37 -0
  30. data/examples/websocket_demo.rb +2 -8
  31. data/examples/ws_page.html +2 -2
  32. data/ext/tipi/extconf.rb +13 -0
  33. data/ext/tipi/http1_parser.c +823 -0
  34. data/ext/tipi/http1_parser.h +18 -0
  35. data/ext/tipi/tipi_ext.c +5 -0
  36. data/lib/tipi.rb +89 -1
  37. data/lib/tipi/acme.rb +308 -0
  38. data/lib/tipi/cli.rb +30 -0
  39. data/lib/tipi/digital_fabric/agent.rb +22 -17
  40. data/lib/tipi/digital_fabric/agent_proxy.rb +95 -40
  41. data/lib/tipi/digital_fabric/executive.rb +6 -2
  42. data/lib/tipi/digital_fabric/protocol.rb +87 -15
  43. data/lib/tipi/digital_fabric/request_adapter.rb +6 -10
  44. data/lib/tipi/digital_fabric/service.rb +77 -51
  45. data/lib/tipi/http1_adapter.rb +116 -117
  46. data/lib/tipi/http2_adapter.rb +56 -10
  47. data/lib/tipi/http2_stream.rb +106 -53
  48. data/lib/tipi/rack_adapter.rb +2 -53
  49. data/lib/tipi/response_extensions.rb +17 -0
  50. data/lib/tipi/version.rb +1 -1
  51. data/security/http1.rb +12 -0
  52. data/test/helper.rb +60 -11
  53. data/test/test_http1_parser.rb +586 -0
  54. data/test/test_http_server.rb +0 -27
  55. data/test/test_request.rb +1 -28
  56. data/tipi.gemspec +11 -5
  57. metadata +96 -22
  58. data/e +0 -0
data/security/http1.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tipi
4
+ HTTP1_LIMITS = {
5
+ max_method_length: 16,
6
+ max_path_length: 4096,
7
+ max_header_key_length: 128,
8
+ max_header_value_length: 2048,
9
+ max_header_count: 256,
10
+ max_chunked_encoding_chunk_size_length: 16,
11
+ }
12
+ end
data/test/helper.rb CHANGED
@@ -14,26 +14,24 @@ require 'polyphony'
14
14
 
15
15
  ::Exception.__disable_sanitized_backtrace__ = true
16
16
 
17
- Minitest::Reporters.use! [
18
- Minitest::Reporters::SpecReporter.new
19
- ]
20
-
21
17
  class MiniTest::Test
22
18
  def setup
23
- # puts "* setup #{self.name}"
24
- if Fiber.current.children.size > 0
25
- puts "Children left: #{Fiber.current.children.inspect}"
26
- exit!
27
- end
19
+ # trace "* setup #{self.name}"
28
20
  Fiber.current.setup_main_fiber
29
21
  Fiber.current.instance_variable_set(:@auto_watcher, nil)
22
+ Thread.current.backend.finalize
30
23
  Thread.current.backend = Polyphony::Backend.new
31
24
  sleep 0
32
25
  end
33
26
 
34
27
  def teardown
35
- # puts "* teardown #{self.name.inspect} Fiber.current: #{Fiber.current.inspect}"
28
+ # trace "* teardown #{self.name}"
36
29
  Fiber.current.shutdown_all_children
30
+ if Fiber.current.children.size > 0
31
+ puts "Children left after #{self.name}: #{Fiber.current.children.inspect}"
32
+ exit!
33
+ end
34
+ Fiber.current.instance_variable_set(:@auto_watcher, nil)
37
35
  rescue => e
38
36
  puts e
39
37
  puts e.backtrace.join("\n")
@@ -47,4 +45,55 @@ module Kernel
47
45
  rescue Exception => e
48
46
  e
49
47
  end
50
- end
48
+
49
+ def trace(*args)
50
+ STDOUT.orig_write(format_trace(args))
51
+ end
52
+
53
+ def format_trace(args)
54
+ if args.first.is_a?(String)
55
+ if args.size > 1
56
+ format("%s: %p\n", args.shift, args)
57
+ else
58
+ format("%s\n", args.first)
59
+ end
60
+ else
61
+ format("%p\n", args.size == 1 ? args.first : args)
62
+ end
63
+ end
64
+ end
65
+
66
+ class IO
67
+ # Creates two mockup sockets for simulating server-client communication
68
+ def self.server_client_mockup
69
+ server_in, client_out = IO.pipe
70
+ client_in, server_out = IO.pipe
71
+
72
+ server_connection = mockup_connection(server_in, server_out, client_out)
73
+ client_connection = mockup_connection(client_in, client_out, server_out)
74
+
75
+ [server_connection, client_connection]
76
+ end
77
+
78
+ def self.mockup_connection(input, output, output2)
79
+ eg(
80
+ __parser_read_method__: ->() { :readpartial },
81
+ read: ->(*args) { input.read(*args) },
82
+ read_loop: ->(*args, &block) { input.read_loop(*args, &block) },
83
+ recv_loop: ->(*args, &block) { input.read_loop(*args, &block) },
84
+ readpartial: ->(*args) { input.readpartial(*args) },
85
+ recv: ->(*args) { input.readpartial(*args) },
86
+ '<<': ->(*args) { output.write(*args) },
87
+ write: ->(*args) { output.write(*args) },
88
+ close: -> { output.close },
89
+ eof?: -> { output2.closed? }
90
+ )
91
+ end
92
+ end
93
+
94
+ module Minitest::Assertions
95
+ def assert_in_range exp_range, act
96
+ msg = message(msg) { "Expected #{mu_pp(act)} to be in range #{mu_pp(exp_range)}" }
97
+ assert exp_range.include?(act), msg
98
+ end
99
+ end
@@ -0,0 +1,586 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+ require 'tipi_ext'
5
+ require_relative '../security/http1.rb'
6
+
7
+ class HTTP1ParserTest < MiniTest::Test
8
+ Error = Tipi::HTTP1Parser::Error
9
+
10
+ def setup
11
+ super
12
+ @i, @o = IO.pipe
13
+ @parser = Tipi::HTTP1Parser.new(@i)
14
+ end
15
+ alias_method :reset_parser, :setup
16
+
17
+ def test_request_line
18
+ msg = "GET / HTTP/1.1\r\n\r\n"
19
+ @o << msg
20
+ headers = @parser.parse_headers
21
+
22
+ assert_equal(
23
+ {
24
+ ':method' => 'get',
25
+ ':path' => '/',
26
+ ':protocol' => 'http/1.1',
27
+ ':rx' => msg.bytesize
28
+ },
29
+ headers
30
+ )
31
+ end
32
+
33
+ def test_request_line_whitespace
34
+ msg = "GET / HTTP/1.1\r\n\r\n"
35
+ @o << msg
36
+ headers = @parser.parse_headers
37
+
38
+ assert_equal(
39
+ {
40
+ ':method' => 'get',
41
+ ':path' => '/',
42
+ ':protocol' => 'http/1.1',
43
+ ':rx' => msg.bytesize
44
+ },
45
+ headers
46
+ )
47
+ end
48
+
49
+ def test_eof
50
+ @o << "GET / HTTP/1.1"
51
+ @o.close
52
+
53
+ assert_nil @parser.parse_headers
54
+ end
55
+
56
+ def test_method_case
57
+ @o << "GET / HTTP/1.1\r\n\r\n"
58
+ headers = @parser.parse_headers
59
+ assert_equal 'get', headers[':method']
60
+
61
+ reset_parser
62
+ @o << "post / HTTP/1.1\r\n\r\n"
63
+ headers = @parser.parse_headers
64
+ assert_equal 'post', headers[':method']
65
+
66
+ reset_parser
67
+ @o << "PoST / HTTP/1.1\r\n\r\n"
68
+ headers = @parser.parse_headers
69
+ assert_equal 'post', headers[':method']
70
+ end
71
+
72
+ def test_bad_method
73
+ @o << " / HTTP/1.1\r\n\r\n"
74
+ @o.close
75
+
76
+ assert_raises(Error) { @parser.parse_headers }
77
+
78
+ max_length = Tipi::HTTP1_LIMITS[:max_method_length]
79
+
80
+ reset_parser
81
+ @o << "#{'a' * max_length} / HTTP/1.1\r\n\r\n"
82
+ assert_equal 'a' * max_length, @parser.parse_headers[':method']
83
+
84
+ reset_parser
85
+ @o << "#{'a' * (max_length + 1)} / HTTP/1.1\r\n\r\n"
86
+ assert_raises(Error) { @parser.parse_headers }
87
+ end
88
+
89
+ def test_path_characters
90
+ @o << "GET /äBçDé¤23~{@€ HTTP/1.1\r\n\r\n"
91
+ headers = @parser.parse_headers
92
+ assert_equal '/äBçDé¤23~{@€', headers[':path']
93
+ end
94
+
95
+ def test_bad_path
96
+ @o << "GET HTTP/1.1\r\n\r\n"
97
+ assert_raises(Error) { @parser.parse_headers }
98
+
99
+ max_length = Tipi::HTTP1_LIMITS[:max_path_length]
100
+
101
+ reset_parser
102
+ @o << "get #{'a' * max_length} HTTP/1.1\r\n\r\n"
103
+ assert_equal 'a' * max_length, @parser.parse_headers[':path']
104
+
105
+ reset_parser
106
+ @o << "get #{'a' * (max_length + 1)} HTTP/1.1\r\n\r\n"
107
+ assert_raises(Error) { @parser.parse_headers }
108
+ end
109
+
110
+ def test_protocol
111
+ @o << "GET / http/1\r\n\r\n"
112
+ headers = @parser.parse_headers
113
+ assert_equal 'http/1', headers[':protocol']
114
+
115
+ reset_parser
116
+ @o << "GET / HTTP/1\r\n\r\n"
117
+ headers = @parser.parse_headers
118
+ assert_equal 'http/1', headers[':protocol']
119
+
120
+ reset_parser
121
+ @o << "GET / HTTP/1.0\r\n\r\n"
122
+ headers = @parser.parse_headers
123
+ assert_equal 'http/1.0', headers[':protocol']
124
+
125
+ @o << "GET / HttP/1.1\r\n\r\n"
126
+ headers = @parser.parse_headers
127
+ assert_equal 'http/1.1', headers[':protocol']
128
+ end
129
+
130
+ def test_bad_protocol
131
+ @o << "GET / blah\r\n\r\n"
132
+ assert_raises(Error) { @parser.parse_headers }
133
+
134
+ reset_parser
135
+ @o << "GET / http\r\n\r\n"
136
+ assert_raises(Error) { @parser.parse_headers }
137
+
138
+ reset_parser
139
+ @o << "GET / http/2\r\n\r\n"
140
+ assert_raises(Error) { @parser.parse_headers }
141
+
142
+ reset_parser
143
+ @o << "GET / http/1.\r\n\r\n"
144
+ assert_raises(Error) { @parser.parse_headers }
145
+
146
+ reset_parser
147
+ @o << "GET / http/a.1\r\n\r\n"
148
+ assert_raises(Error) { @parser.parse_headers }
149
+
150
+ reset_parser
151
+ @o << "GET / http/1.1.1\r\n\r\n"
152
+ assert_raises(Error) { @parser.parse_headers }
153
+ end
154
+
155
+ def test_headers_eof
156
+ @o << "GET / HTTP/1.1\r\na"
157
+ @o.close
158
+
159
+ assert_nil @parser.parse_headers
160
+
161
+ reset_parser
162
+ @o << "GET / HTTP/1.1\r\na:"
163
+ @o.close
164
+
165
+ assert_nil @parser.parse_headers
166
+
167
+ reset_parser
168
+ @o << "GET / HTTP/1.1\r\na: "
169
+ @o.close
170
+
171
+ assert_nil @parser.parse_headers
172
+ end
173
+
174
+ def test_headers
175
+ @o << "GET / HTTP/1.1\r\nFoo: Bar\r\n\r\n"
176
+ headers = @parser.parse_headers
177
+ assert_equal [':method', ':path', ':protocol', 'foo', ':rx'], headers.keys
178
+ assert_equal 'Bar', headers['foo']
179
+
180
+ reset_parser
181
+ @o << "GET / HTTP/1.1\r\nFOO: baR\r\n\r\n"
182
+ headers = @parser.parse_headers
183
+ assert_equal 'baR', headers['foo']
184
+
185
+ reset_parser
186
+ @o << "GET / HTTP/1.1\r\na: bbb\r\nc: ddd\r\n\r\n"
187
+ headers = @parser.parse_headers
188
+ assert_equal 'bbb', headers['a']
189
+ assert_equal 'ddd', headers['c']
190
+ end
191
+
192
+ def test_headers_multiple_values
193
+ @o << "GET / HTTP/1.1\r\nFoo: Bar\r\nfoo: baz\r\n\r\n"
194
+ headers = @parser.parse_headers
195
+ assert_equal ['Bar', 'baz'], headers['foo']
196
+ end
197
+
198
+ def test_bad_headers
199
+ @o << "GET / http/1.1\r\n a: b\r\n\r\n"
200
+ assert_raises(Error) { @parser.parse_headers }
201
+
202
+ reset_parser
203
+ @o << "GET / http/1.1\r\na b\r\n\r\n"
204
+ assert_raises(Error) { @parser.parse_headers }
205
+
206
+ max_key_length = Tipi::HTTP1_LIMITS[:max_header_key_length]
207
+
208
+ reset_parser
209
+ @o << "GET / http/1.1\r\n#{'a' * max_key_length}: b\r\n\r\n"
210
+ headers = @parser.parse_headers
211
+ assert_equal 'b', headers['a' * max_key_length]
212
+
213
+ reset_parser
214
+ @o << "GET / http/1.1\r\n#{'a' * (max_key_length + 1)}: b\r\n\r\n"
215
+ assert_raises(Error) { @parser.parse_headers }
216
+
217
+ max_value_length = Tipi::HTTP1_LIMITS[:max_header_value_length]
218
+
219
+ reset_parser
220
+ @o << "GET / http/1.1\r\nfoo: #{'a' * max_value_length}\r\n\r\n"
221
+ headers = @parser.parse_headers
222
+ assert_equal 'a' * max_value_length, headers['foo']
223
+
224
+ reset_parser
225
+ @o << "GET / http/1.1\r\nfoo: #{'a' * (max_value_length + 1)}\r\n\r\n"
226
+ assert_raises(Error) { @parser.parse_headers }
227
+
228
+ max_header_count = Tipi::HTTP1_LIMITS[:max_header_count]
229
+
230
+ reset_parser
231
+ hdrs = (1..max_header_count).map { |i| "foo#{i}: bar\r\n" }.join
232
+ @o << "GET / http/1.1\r\n#{hdrs}\r\n"
233
+ headers = @parser.parse_headers
234
+ assert_equal (max_header_count + 4), headers.size
235
+
236
+ reset_parser
237
+ hdrs = (1..(max_header_count + 1)).map { |i| "foo#{i}: bar\r\n" }.join
238
+ @o << "GET / http/1.1\r\n#{hdrs}\r\n"
239
+ assert_raises(Error) { @parser.parse_headers }
240
+ end
241
+
242
+ def test_request_without_cr
243
+ msg = "GET /foo HTTP/1.1\nBar: baz\n\n"
244
+ @o << msg
245
+ headers = @parser.parse_headers
246
+ assert_equal({
247
+ ':method' => 'get',
248
+ ':path' => '/foo',
249
+ ':protocol' => 'http/1.1',
250
+ 'bar' => 'baz',
251
+ ':rx' => msg.bytesize
252
+ }, headers)
253
+ end
254
+
255
+ def test_read_body_with_content_length
256
+ 10.times do
257
+ data = ' ' * rand(20..60000)
258
+ msg = "POST /foo HTTP/1.1\r\nContent-Length: #{data.bytesize}\r\n\r\n#{data}"
259
+ spin do
260
+ @o << msg
261
+ end
262
+ headers = @parser.parse_headers
263
+ assert_equal data.bytesize.to_s, headers['content-length']
264
+
265
+ body = @parser.read_body
266
+ assert_equal data, body
267
+ assert_equal msg.bytesize, headers[':rx']
268
+ end
269
+ end
270
+
271
+ def test_read_body_chunk_with_content_length
272
+ data = 'abc' * 20000
273
+ msg = "POST /foo HTTP/1.1\r\nContent-Length: #{data.bytesize}\r\n\r\n#{data}"
274
+ spin { @o << msg }
275
+ headers = @parser.parse_headers
276
+ assert_equal data.bytesize.to_s, headers['content-length']
277
+
278
+ buf = +''
279
+ count = 0
280
+ while (chunk = @parser.read_body_chunk(false))
281
+ count += 1
282
+ buf += chunk
283
+ end
284
+ assert_equal data.bytesize, data.bytesize
285
+ assert_equal data, buf
286
+ assert_in_range 1..3, count
287
+ assert_equal msg.bytesize, headers[':rx']
288
+ end
289
+
290
+ def test_read_body_with_content_length_incomplete
291
+ data = ' ' * rand(20..60000)
292
+ spin do
293
+ @o << "POST /foo HTTP/1.1\r\nContent-Length: #{data.bytesize + 1}\r\n\r\n#{data}"
294
+ @o.close # !!! otherwise the parser will keep waiting
295
+ end
296
+ headers = @parser.parse_headers
297
+
298
+ assert_raises(Tipi::HTTP1Parser::Error) { @parser.read_body }
299
+ end
300
+
301
+ def test_read_body_chunk_with_content_length_incomplete
302
+ data = 'abc' * 50
303
+ @o << "POST /foo HTTP/1.1\r\nContent-Length: #{data.bytesize + 1}\r\n\r\n#{data}"
304
+ @o.close
305
+ headers = @parser.parse_headers
306
+
307
+ assert_raises(Tipi::HTTP1Parser::Error) { @parser.read_body_chunk(false) }
308
+ end
309
+
310
+ def test_read_body_with_chunked_encoding
311
+ chunks = []
312
+ total_sent = 0
313
+ spin do
314
+ msg = "POST /foo HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n"
315
+ @o << msg
316
+ total_sent += msg.bytesize
317
+ rand(8..16).times do |i|
318
+ chunk = i.to_s * rand(200..360000)
319
+ msg = "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n"
320
+ @o << msg
321
+ chunks << chunk
322
+ total_sent += msg.bytesize
323
+ end
324
+ msg = "0\r\n\r\n"
325
+ @o << msg
326
+ total_sent += msg.bytesize
327
+ end
328
+ headers = @parser.parse_headers
329
+ assert_equal 'chunked', headers['transfer-encoding']
330
+
331
+ body = @parser.read_body
332
+ assert_equal chunks.join, body
333
+ assert_equal total_sent, headers[':rx']
334
+ end
335
+
336
+ def test_read_body_chunk_with_chunked_encoding
337
+ chunks = []
338
+ total_sent = 0
339
+ spin do
340
+ msg = "POST /foo HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n"
341
+ @o << msg
342
+ total_sent += msg.bytesize
343
+ rand(8..16).times do |i|
344
+ chunk = i.to_s * rand(40000..360000)
345
+ msg = "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n"
346
+ @o << msg
347
+ total_sent += msg.bytesize
348
+ chunks << chunk
349
+ end
350
+ msg = "0\r\n\r\n"
351
+ @o << msg
352
+ total_sent += msg.bytesize
353
+ end
354
+ headers = @parser.parse_headers
355
+ assert_equal 'chunked', headers['transfer-encoding']
356
+
357
+ received = []
358
+ while (chunk = @parser.read_body_chunk(false))
359
+ received << chunk
360
+ end
361
+ assert_equal chunks, received
362
+ assert_equal total_sent, headers[':rx']
363
+ end
364
+
365
+ def test_read_body_with_chunked_encoding_malformed
366
+ spin do
367
+ @o << "POST /foo HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n"
368
+ chunk = ' '.to_s * rand(40000..360000)
369
+ @o << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n3"
370
+ @o << "0\r\n\r\n"
371
+ @o.close
372
+ end
373
+ headers = @parser.parse_headers
374
+ assert_raises(Tipi::HTTP1Parser::Error) { @parser.read_body }
375
+
376
+ reset_parser
377
+ # missing last empty chunk
378
+ spin do
379
+ @o << "POST /foo HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n"
380
+ chunk = ' '.to_s * rand(40000..360000)
381
+ @o << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n"
382
+ @o.close
383
+ end
384
+ headers = @parser.parse_headers
385
+ assert_raises(Tipi::HTTP1Parser::Error) { @parser.read_body }
386
+
387
+ reset_parser
388
+ # bad chunk size
389
+ spin do
390
+ @o << "POST /foo HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n"
391
+ chunk = ' '.to_s * rand(40000..360000)
392
+ @o << "-#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n"
393
+ @o.close
394
+ end
395
+ headers = @parser.parse_headers
396
+ assert_raises(Tipi::HTTP1Parser::Error) { @parser.read_body }
397
+ end
398
+
399
+ def test_read_body_chunk_with_chunked_encoding_malformed
400
+ chunk = nil
401
+ spin do
402
+ @o << "POST /foo HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n"
403
+ chunk = ' ' * rand(40000..360000)
404
+ @o << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n3"
405
+ @o << "0\r\n\r\n"
406
+ @o.close
407
+ end
408
+ headers = @parser.parse_headers
409
+ read = @parser.read_body_chunk(false)
410
+ assert_equal chunk, read
411
+ assert_raises(Tipi::HTTP1Parser::Error) { @parser.read_body_chunk(false) }
412
+
413
+ reset_parser
414
+ Fiber.current.shutdown_all_children
415
+ # missing last empty chunk
416
+ chunk = nil
417
+ spin do
418
+ @o << "POST /foo HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n"
419
+ chunk = ' '.to_s * rand(20..1600)
420
+ @o << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n"
421
+ @o.close
422
+ end
423
+ headers = @parser.parse_headers
424
+ read = @parser.read_body_chunk(false)
425
+ assert_equal chunk, read
426
+ assert_raises(Tipi::HTTP1Parser::Error) { @parser.read_body_chunk(false) }
427
+
428
+ reset_parser
429
+ Fiber.current.shutdown_all_children
430
+ # bad chunk size
431
+ spin do
432
+ @o << "POST /foo HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n"
433
+ chunk = ' '.to_s * rand(20..1600)
434
+ @o << "-#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n"
435
+ @o.close
436
+ end
437
+ headers = @parser.parse_headers
438
+ assert_raises(Tipi::HTTP1Parser::Error) { @parser.read_body_chunk(false) }
439
+
440
+ reset_parser
441
+ Fiber.current.shutdown_all_children
442
+ # missing body
443
+ @o << "POST /foo HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n"
444
+ @o.close
445
+ headers = @parser.parse_headers
446
+ assert_raises(Tipi::HTTP1Parser::Error) { @parser.read_body_chunk(false) }
447
+ ensure
448
+ Fiber.current.shutdown_all_children
449
+ end
450
+
451
+ def test_complete?
452
+ @o << "GET / HTTP/1.1\r\n\r\n"
453
+ headers = @parser.parse_headers
454
+ assert_equal true, @parser.complete?
455
+
456
+ reset_parser
457
+ @o << "GET / HTTP/1.1\r\nContent-Length: 3\r\n\r\n"
458
+ headers = @parser.parse_headers
459
+ assert_equal false, @parser.complete?
460
+ @o << 'foo'
461
+ body = @parser.read_body
462
+ assert_equal 'foo', body
463
+ assert_equal true, @parser.complete?
464
+
465
+ reset_parser
466
+ @o << "POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n"
467
+ headers = @parser.parse_headers
468
+ assert_equal false, @parser.complete?
469
+ @o << "3\r\nfoo\r\n"
470
+ chunk = @parser.read_body_chunk(false)
471
+ assert_equal 'foo', chunk
472
+ assert_equal false, @parser.complete?
473
+ @o << "0\r\n\r\n"
474
+ chunk = @parser.read_body_chunk(false)
475
+ assert_nil chunk
476
+ assert_equal true, @parser.complete?
477
+ end
478
+
479
+ def test_buffered_body_chunk
480
+ @o << "GET / HTTP/1.1\r\nContent-Length: 3\r\n\r\nfoo"
481
+ headers = @parser.parse_headers
482
+ assert_equal false, @parser.complete?
483
+
484
+ chunk = @parser.read_body_chunk(true)
485
+ assert_equal 'foo', chunk
486
+ assert_equal true, @parser.complete?
487
+ chunk = @parser.read_body_chunk(false)
488
+ assert_nil chunk
489
+ assert_equal true, @parser.complete?
490
+
491
+ reset_parser
492
+ @o << "GET / HTTP/1.1\r\nContent-Length: 6\r\n\r\nfoo"
493
+ headers = @parser.parse_headers
494
+ assert_equal false, @parser.complete?
495
+
496
+ chunk = @parser.read_body_chunk(true)
497
+ assert_equal 'foo', chunk
498
+ assert_equal false, @parser.complete?
499
+ @o << 'bar'
500
+ chunk = @parser.read_body_chunk(false)
501
+ assert_equal 'bar', chunk
502
+ assert_equal true, @parser.complete?
503
+
504
+ reset_parser
505
+ @o << "GET / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nfoo\r\n"
506
+ headers = @parser.parse_headers
507
+ assert_equal false, @parser.complete?
508
+
509
+ chunk = @parser.read_body_chunk(true)
510
+ assert_equal 'foo', chunk
511
+ assert_equal false, @parser.complete?
512
+ @o << "0\r\n\r\n"
513
+ chunk = @parser.read_body_chunk(true)
514
+ assert_nil chunk
515
+ assert_equal true, @parser.complete?
516
+ end
517
+
518
+ def test_parser_with_tcp_socket
519
+ port = rand(1234..5678)
520
+ server = TCPServer.new('127.0.0.1', port)
521
+ server_fiber = spin do
522
+ while (socket = server.accept)
523
+ spin do
524
+ parser = Tipi::HTTP1Parser.new(socket)
525
+ headers = parser.parse_headers
526
+ socket << headers.inspect
527
+ socket.shutdown
528
+ socket.close
529
+ end
530
+ end
531
+ end
532
+
533
+ snooze
534
+ client = TCPSocket.new('127.0.0.1', port)
535
+ msg = "get /foo HTTP/1.1\r\nCookie: abc=def\r\n\r\n"
536
+ client << msg
537
+ reply = client.read
538
+ assert_equal({
539
+ ':method' => 'get',
540
+ ':path' => '/foo',
541
+ ':protocol' => 'http/1.1',
542
+ 'cookie' => 'abc=def',
543
+ ':rx' => msg.bytesize,
544
+ }, eval(reply))
545
+ ensure
546
+ client.shutdown rescue nil
547
+ client&.close
548
+ server_fiber&.stop
549
+ server_fiber&.await
550
+ server&.close
551
+ end
552
+
553
+ def test_parser_with_callable
554
+ buf = []
555
+ request = +"GET /foo HTTP/1.1\r\nHost: bar\r\n\r\n"
556
+ callable = proc do |len|
557
+ buf << {len: len}
558
+ request
559
+ end
560
+
561
+ parser = Tipi::HTTP1Parser.new(callable)
562
+
563
+ headers = parser.parse_headers
564
+ assert_equal({
565
+ ':method' => 'get',
566
+ ':path' => '/foo',
567
+ ':protocol' => 'http/1.1',
568
+ 'host' => 'bar',
569
+ ':rx' => request.bytesize,
570
+
571
+ }, headers)
572
+ assert_equal [{len: 4096}], buf
573
+
574
+ request = +"GET /bar HTTP/1.1\r\nHost: baz\r\n\r\n"
575
+ headers = parser.parse_headers
576
+ assert_equal({
577
+ ':method' => 'get',
578
+ ':path' => '/bar',
579
+ ':protocol' => 'http/1.1',
580
+ 'host' => 'baz',
581
+ ':rx' => request.bytesize,
582
+
583
+ }, headers)
584
+ assert_equal [{len: 4096}, {len: 4096}], buf
585
+ end
586
+ end