tp2 0.2 → 0.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c717e4955f9761fb51c8f8604323c887745282b31ff8f3aaae7aad300af35f3
4
- data.tar.gz: 1969ba78997432a60f26b2103f8a5c3abaf460e0dbd3b0b4e5893baa3fd9c75a
3
+ metadata.gz: 4d8c8ee2edd1c72a68b21c3ea27dc208c0dbfcbb2a6d78d76abb3ec11f115977
4
+ data.tar.gz: 5fb43258443827cf5fa44fc1ddd4960740d8694d5469fac70875c46c54558c15
5
5
  SHA512:
6
- metadata.gz: 772ba38e32667e97f0531d877b3ddecb93d3863b4342d462e29e623dc5bb0cdd8b7979352150b13d662ec2a2871185a360154eb0b0928e6e1836f82ff28a2b08
7
- data.tar.gz: 31cd057a87151dc1ee622cf5511c43ac4a98e21228ff9aa9fc5311223f3f08155e920142e4c6f1f553a1f9b81428ce36c2674c4d87b277bd1b1d86e67b6c4421
6
+ metadata.gz: e6c54083c10368f84bd23a0bfd235f01aa74690f7d02667a54c539921e9dc601e7f513858d3a85eee17229d4f4f12a6482d219feb1c1820cd2622c7e70e0dfa9
7
+ data.tar.gz: 134a0b072495b5d23f02d1d19e86db66f4f78ffea81ce1e20b48660d77c61966bf83e4876f4870b33f8d63102274d3f3e3555e3166d02cfcaa0086aacdff2520
data/Gemfile.lock CHANGED
@@ -1,25 +1,23 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- tp2 (0.2)
5
- http_parser.rb (= 0.8.0)
4
+ tp2 (0.4)
6
5
  qeweney (= 0.21)
7
- uringmachine (= 0.5.1)
6
+ uringmachine (= 0.7)
8
7
 
9
8
  GEM
10
9
  remote: https://rubygems.org/
11
10
  specs:
12
11
  docile (1.4.1)
13
12
  escape_utils (1.3.0)
14
- ffi (1.17.1-aarch64-linux-gnu)
15
- ffi (1.17.1-aarch64-linux-musl)
16
- ffi (1.17.1-arm-linux-gnu)
17
- ffi (1.17.1-arm-linux-musl)
18
- ffi (1.17.1-arm64-darwin)
19
- ffi (1.17.1-x86_64-darwin)
20
- ffi (1.17.1-x86_64-linux-gnu)
21
- ffi (1.17.1-x86_64-linux-musl)
22
- http_parser.rb (0.8.0)
13
+ ffi (1.17.2-aarch64-linux-gnu)
14
+ ffi (1.17.2-aarch64-linux-musl)
15
+ ffi (1.17.2-arm-linux-gnu)
16
+ ffi (1.17.2-arm-linux-musl)
17
+ ffi (1.17.2-arm64-darwin)
18
+ ffi (1.17.2-x86_64-darwin)
19
+ ffi (1.17.2-x86_64-linux-gnu)
20
+ ffi (1.17.2-x86_64-linux-musl)
23
21
  listen (3.9.0)
24
22
  rb-fsevent (~> 0.10, >= 0.10.3)
25
23
  rb-inotify (~> 0.9, >= 0.9.10)
@@ -38,7 +36,7 @@ GEM
38
36
  simplecov_json_formatter (~> 0.1)
39
37
  simplecov-html (0.13.1)
40
38
  simplecov_json_formatter (0.1.4)
41
- uringmachine (0.5.1)
39
+ uringmachine (0.7)
42
40
  yard (0.9.37)
43
41
 
44
42
  PLATFORMS
data/TODO.md CHANGED
@@ -1 +1,14 @@
1
- - Write some tests.
1
+ - Add failing tests for request bombs (requests with lots of bytes)
2
+ - Add limits for:
3
+ - total request size (pre body)
4
+ - line size
5
+ - request method size
6
+ - request path size
7
+ - header size
8
+ - limits expressed inside regexps done as follows:
9
+
10
+ ```ruby
11
+ MAX_HEADER_KEY_BYTES = 256
12
+
13
+ HEADER_RE = /^\s([^\s^\:]{1, #{MAX_HEADER_KEY_BYTES}}).../
14
+ ```
data/examples/simple.rb CHANGED
@@ -14,6 +14,7 @@ app = ->(req) {
14
14
 
15
15
  machine = UM.new
16
16
  server = TP2::Server.new(machine, '0.0.0.0', 1234, &app)
17
+ puts "Listening on port 1234..."
17
18
 
18
19
  machine.spin { server.run }
19
20
 
@@ -1,69 +1,79 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'http/parser'
4
3
  require 'qeweney'
4
+ require 'stringio'
5
5
 
6
6
  module TP2
7
- # Encapsulates a HTTP/1 connection
8
- class HTTP1Connection # rubocop:disable Metrics/ClassLength
9
- SEND_FLAGS = UM::MSG_NOSIGNAL | UM::MSG_WAITALL
7
+ class HTTP1Adapter
8
+ attr_reader :fd
10
9
 
11
- def initialize(machine, fd, bgid, &app) # rubocop:disable Naming/MethodParameterName
10
+ def initialize(machine, fd, &app)
12
11
  @machine = machine
13
12
  @fd = fd
14
- @bgid = bgid
15
- @parser = Http::Parser.new(self)
13
+ @buffer = String.new('', capacity: 4096)
14
+ @sio = StringIO.new(@buffer)
16
15
  @app = app
17
16
  end
18
17
 
19
- def on_headers_complete(headers) # rubocop:disable Metrics/MethodLength
20
- headers = normalize_headers(headers)
21
- headers[':path'] = @parser.request_url
22
- headers[':method'] = @parser.http_method.downcase
23
- headers[':scheme'] = get_scheme_from_headers(headers)
24
- headers[':protocol'] = "http/#{@parser.http_major}.#{@parser.http_minor}"
25
- @request = Qeweney::Request.new(headers, self)
26
- end
27
-
28
- def get_scheme_from_headers(headers)
29
- if (proto = headers['x-forwarded-proto'])
30
- proto.downcase
31
- else
32
- 'http'
18
+ def run
19
+ while true
20
+ persist = serve_request
21
+ break if !persist
33
22
  end
23
+ rescue => e
24
+ puts '*' * 40
25
+ p e
26
+ puts e.backtrace.join("\n")
27
+ exit!
28
+ ensure
29
+ @machine.close(@fd)
34
30
  end
35
31
 
36
- def normalize_headers(headers)
37
- headers.each_with_object({}) do |(k, v), h|
38
- k = k.downcase
39
- hk = h[k]
40
- if hk
41
- hk = h[k] = [hk] unless hk.is_a?(Array)
42
- v.is_a?(Array) ? hk.concat(v) : hk << v
43
- else
44
- h[k] = v
45
- end
46
- end
47
- end
32
+ def serve_request
33
+ headers = parse_headers
34
+ return false if !headers
48
35
 
49
- def on_body(chunk)
50
- @request.buffer_body_chunk(chunk)
36
+ request = Qeweney::Request.new(headers, self)
37
+ @app.call(request)
38
+ persist_connection?(headers)
51
39
  end
52
40
 
53
- def on_message_complete
54
- @done = @request.headers[':protocol'] != 'http/1.1'
55
- @app.call(@request)
41
+ def get_body(req)
42
+ headers = req.headers
43
+ content_length = headers['content-length']
44
+ return read(content_length.to_i) if content_length
45
+
46
+ chunked_encoding = headers['transfer-encoding']&.downcase == 'chunked'
47
+ return get_body_chunked_encoding(headers) if chunked_encoding
48
+
49
+ # if content-length is not specified, we read to EOF, up to max 1MB size
50
+ read(1 << 20, nil, false)
56
51
  end
57
52
 
58
- def run
59
- @machine.recv_each(@fd, @bgid, 0) do |buf|
60
- @parser << buf
61
- break if @done
53
+ def get_body_chunk(req, buffered_only = false)
54
+ headers = req.headers
55
+ content_length = headers['content-length']
56
+ if content_length
57
+ return nil if headers[':body-done-reading']
58
+
59
+ chunk = read(content_length.to_i)
60
+ headers[':body-done-reading'] = true
61
+ return chunk
62
62
  end
63
- rescue SystemCallError, HTTP::Parser::Error
64
- # ignore error, just close the port silently
65
- ensure
66
- @machine.close(@fd)
63
+
64
+ chunked_encoding = headers['transfer-encoding']&.downcase == 'chunked'
65
+ return read_chunk(headers, nil) if chunked_encoding
66
+
67
+ return nil if headers[':body-done-reading']
68
+
69
+ # if content-length is not specified, we read to EOF, up to max 1MB size
70
+ chunk = read(1 << 20, nil, false)
71
+ headers[':body-done-reading'] = true
72
+ chunk
73
+ end
74
+
75
+ def complete?(req)
76
+ req.headers[':body-done-reading']
67
77
  end
68
78
 
69
79
  # response API
@@ -72,6 +82,8 @@ module TP2
72
82
  ZERO_CRLF_CRLF = "0\r\n\r\n"
73
83
  CRLF_ZERO_CRLF_CRLF = "\r\n0\r\n\r\n"
74
84
 
85
+ SEND_FLAGS = UM::MSG_NOSIGNAL | UM::MSG_WAITALL
86
+
75
87
  # Sends response including headers and body. Waits for the request to complete
76
88
  # if not yet completed. The body is sent using chunked transfer encoding.
77
89
  # @param request [Qeweney::Request] HTTP request
@@ -128,6 +140,117 @@ module TP2
128
140
  @machine.send(@fd, ZERO_CRLF_CRLF, ZERO_CRLF_CRLF.bytesize, SEND_FLAGS)
129
141
  end
130
142
 
143
+ private
144
+
145
+ RE_REQUEST_LINE = /^([a-z]+)\s+([^\s]+)\s+(http\/[0-9\.]{1,3})/i
146
+ RE_HEADER_LINE = /^([a-z0-9\-]+)\:\s+(.+)/i
147
+
148
+ class ProtocolError < StandardError
149
+ end
150
+
151
+ def persist_connection?(headers)
152
+ connection = headers['connection']&.downcase
153
+ if headers[':protocol'] == 'http/1.1'
154
+ return connection != 'close'
155
+ else
156
+ return connection && connection != 'close'
157
+ end
158
+ end
159
+
160
+ def parse_headers
161
+ headers = get_request_line
162
+ return nil if !headers
163
+
164
+ while true
165
+ line = get_line
166
+ break if line.nil? || line.empty?
167
+
168
+ m = line.match(RE_HEADER_LINE)
169
+ raise ProtocolError, 'Invalid header' if !m
170
+
171
+ headers[m[1].downcase] = m[2]
172
+ end
173
+
174
+ headers
175
+ end
176
+
177
+ def get_line
178
+ while true
179
+ line = @sio.gets(chomp: true)
180
+ return line if line
181
+
182
+ res = @machine.read(@fd, @buffer, 65536, -1)
183
+ return nil if res == 0
184
+ end
185
+ end
186
+
187
+ def get_request_line
188
+ line = get_line
189
+ return nil if !line
190
+
191
+ m = line.match(RE_REQUEST_LINE)
192
+ raise ProtocolError, 'Invalid request line' if !m
193
+
194
+ {
195
+ ':method' => m[1].downcase,
196
+ ':path' => m[2],
197
+ ':protocol' => m[3].downcase
198
+ }
199
+ end
200
+
201
+ def get_body_chunked_encoding(headers)
202
+ buf = String.new(capacity: 65536)
203
+ while read_chunk(headers, buf)
204
+ end
205
+
206
+ buf
207
+ end
208
+
209
+ def read(len, buf = nil, raise_on_eof = true)
210
+ from_sio = @sio.read(len)
211
+ if from_sio
212
+ left = len - from_sio&.bytesize
213
+ if buf
214
+ buf << from_sio
215
+ else
216
+ buf = +from_sio
217
+ end
218
+ else
219
+ left = len
220
+ buf ||= +''
221
+ end
222
+
223
+ while left > 0
224
+ res = @machine.read(@fd, buf, left, -1)
225
+ if res == 0
226
+ raise ProtocolError, "Incomplete body" if raise_on_eof
227
+
228
+ return buf
229
+ end
230
+
231
+
232
+ left -= res
233
+ end
234
+ buf
235
+ end
236
+
237
+ def read_chunk(headers, buf)
238
+ chunk_size = get_line
239
+ return nil if !chunk_size
240
+
241
+ chunk_size = chunk_size.to_i(16)
242
+ if chunk_size == 0
243
+ headers[':body-done-reading'] = true
244
+ get_line
245
+ return nil
246
+ end
247
+
248
+ chunk = read(chunk_size, buf)
249
+ get_line
250
+
251
+ chunk
252
+ end
253
+
131
254
  def http1_1?(request)
132
255
  request.headers[':protocol'] == 'http/1.1'
133
256
  end
data/lib/tp2/server.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'tp2/http'
3
+ require 'tp2/http1_adapter'
4
4
  require 'tp2/request_extensions'
5
5
 
6
6
  module TP2
@@ -18,14 +18,13 @@ module TP2
18
18
  @machine.bind(@server_fd, @hostname, @port)
19
19
  @machine.listen(@server_fd, UM::SOMAXCONN)
20
20
 
21
- @bgid = @machine.setup_buffer_ring(4096, 1024)
22
- puts "Listening on #{@hostname}:#{@port}"
21
+ # puts "Listening on #{@hostname}:#{@port}"
23
22
  end
24
23
 
25
24
  def run
26
25
  setup
27
26
  @machine.accept_each(@server_fd) do |fd|
28
- conn = HTTP1Connection.new(@machine, fd, @bgid, &@app)
27
+ conn = HTTP1Adapter.new(@machine, fd, &@app)
29
28
  @machine.spin(conn) { it.run }
30
29
  end
31
30
  end
data/lib/tp2/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module TP2
2
- VERSION = '0.2'
2
+ VERSION = '0.4'
3
3
  end
data/test/helper.rb ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'minitest/autorun'
5
+ require 'tp2'
6
+
7
+ require_relative './coverage' if ENV['COVERAGE']
8
+
9
+ module Kernel
10
+ def capture_exception
11
+ yield
12
+ rescue Exception => e
13
+ e
14
+ end
15
+
16
+ def trace(*args)
17
+ STDOUT.orig_write(format_trace(args))
18
+ end
19
+
20
+ def format_trace(args)
21
+ if args.first.is_a?(String)
22
+ if args.size > 1
23
+ format("%s: %p\n", args.shift, args)
24
+ else
25
+ format("%s\n", args.first)
26
+ end
27
+ else
28
+ format("%p\n", args.size == 1 ? args.first : args)
29
+ end
30
+ end
31
+ end
32
+
33
+ module Minitest::Assertions
34
+ def assert_in_range exp_range, act
35
+ msg = message(msg) { "Expected #{mu_pp(act)} to be in range #{mu_pp(exp_range)}" }
36
+ assert exp_range.include?(act), msg
37
+ end
38
+ end
data/test/run.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir.glob("#{__dir__}/test_*.rb").each do |path|
4
+ require(path)
5
+ end
@@ -0,0 +1,532 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './helper'
4
+
5
+ class String
6
+ def crlf_lines
7
+ chomp.gsub("\n", "\r\n").chomp
8
+ end
9
+ end
10
+
11
+ class HTTP1AdapterTest < Minitest::Test
12
+ def make_socket_pair
13
+ port = 10000 + rand(30000)
14
+ server_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
15
+ @machine.setsockopt(server_fd, UM::SOL_SOCKET, UM::SO_REUSEADDR, true)
16
+ @machine.bind(server_fd, '127.0.0.1', port)
17
+ @machine.listen(server_fd, UM::SOMAXCONN)
18
+
19
+ client_conn_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
20
+ @machine.connect(client_conn_fd, '127.0.0.1', port)
21
+
22
+ server_conn_fd = @machine.accept(server_fd)
23
+
24
+ @machine.close(server_fd)
25
+ [client_conn_fd, server_conn_fd]
26
+ end
27
+
28
+ def setup
29
+ @machine = UM.new
30
+ @c_fd, @s_fd = make_socket_pair
31
+ @reqs = []
32
+ @hook = nil
33
+ @app = ->(req) { @hook&.call(req); @reqs << req }
34
+ @adapter = TP2::HTTP1Adapter.new(@machine, @s_fd, &@app)
35
+ end
36
+
37
+ def teardown
38
+ @machine.close(@c_fd) rescue nil
39
+ @machine.close(@s_fd) rescue nil
40
+ end
41
+
42
+ def write_http_request(msg, shutdown_wr = true)
43
+ @machine.send(@c_fd, msg, msg.bytesize, UM::MSG_WAITALL)
44
+ @machine.shutdown(@c_fd, UM::SHUT_WR) if shutdown_wr
45
+ end
46
+
47
+ def write_client_side(msg)
48
+ @machine.send(@c_fd, msg, msg.bytesize, UM::MSG_WAITALL)
49
+ end
50
+
51
+ def read_client_side(len = 65536)
52
+ buf = +''
53
+ res = @machine.recv(@c_fd, buf, len, 0)
54
+ res == 0 ? nil : buf
55
+ end
56
+
57
+ def test_basic_request_parsing
58
+ write_http_request "GET / HTTP/1.0\r\n\r\n"
59
+
60
+ @adapter.serve_request
61
+ assert_equal 1, @reqs.size
62
+ req = @reqs.shift
63
+ headers = req.headers
64
+ assert_equal({
65
+ ':method' => 'get',
66
+ ':path' => '/',
67
+ ':protocol' => 'http/1.0'
68
+ }, headers)
69
+ end
70
+
71
+ def test_pipelined_requests
72
+ write_http_request <<~HTTP.crlf_lines
73
+ GET /foo HTTP/1.1
74
+ Server: foo.com
75
+
76
+ SCHMET /bar HTTP/1.1
77
+
78
+ HTTP
79
+
80
+ @adapter.run
81
+ assert_equal 2, @reqs.size
82
+ req0 = @reqs.shift
83
+ headers = req0.headers
84
+ assert_equal({
85
+ ':method' => 'get',
86
+ ':path' => '/foo',
87
+ ':protocol' => 'http/1.1',
88
+ 'server' => 'foo.com'
89
+ }, headers)
90
+
91
+ req1 = @reqs.shift
92
+ headers = req1.headers
93
+ assert_equal({
94
+ ':method' => 'schmet',
95
+ ':path' => '/bar',
96
+ ':protocol' => 'http/1.1'
97
+ }, headers)
98
+ end
99
+
100
+ def test_pipelined_requests_with_body
101
+ write_http_request <<~HTTP.crlf_lines
102
+ POST /foo HTTP/1.1
103
+ Server: foo.com
104
+ Content-Length: 3
105
+
106
+ abcSCHMOST /bar HTTP/1.1
107
+ Server: bar.com
108
+ Content-Length: 6
109
+
110
+ defghi
111
+ HTTP
112
+
113
+ @bodies = []
114
+ @hook = ->(req) { @bodies << req.read }
115
+
116
+ @adapter.run
117
+ assert_equal 2, @reqs.size
118
+
119
+ req0 = @reqs.shift
120
+ headers = req0.headers
121
+ assert_equal({
122
+ ':method' => 'post',
123
+ ':path' => '/foo',
124
+ ':protocol' => 'http/1.1',
125
+ 'server' => 'foo.com',
126
+ 'content-length' => '3'
127
+ }, headers)
128
+ body = @bodies.shift
129
+ assert_equal 'abc', body
130
+
131
+ req1 = @reqs.shift
132
+ headers = req1.headers
133
+ assert_equal({
134
+ ':method' => 'schmost',
135
+ ':path' => '/bar',
136
+ ':protocol' => 'http/1.1',
137
+ 'server' => 'bar.com',
138
+ 'content-length' => '6'
139
+ }, headers)
140
+ body = @bodies.shift
141
+ assert_equal 'defghi', body
142
+ end
143
+
144
+ def test_pipelined_requests_with_body_chunked
145
+ write_http_request <<~HTTP.crlf_lines
146
+ POST /foo HTTP/1.1
147
+ Server: foo.com
148
+ Transfer-Encoding: chunked
149
+
150
+ 3
151
+ abc
152
+ 2
153
+ de
154
+ 0
155
+
156
+ SCHMOST /bar HTTP/1.1
157
+ Server: bar.com
158
+ Transfer-Encoding: chunked
159
+
160
+ 1f
161
+ 123456789abcdefghijklmnopqrstuv
162
+ 0
163
+
164
+ HTTP
165
+
166
+ @bodies = []
167
+ @hook = ->(req) { @bodies << req.read }
168
+
169
+ @adapter.run
170
+ assert_equal 2, @reqs.size
171
+
172
+ req0 = @reqs.shift
173
+ headers = req0.headers
174
+ assert_equal({
175
+ ':method' => 'post',
176
+ ':path' => '/foo',
177
+ ':protocol' => 'http/1.1',
178
+ 'server' => 'foo.com',
179
+ 'transfer-encoding' => 'chunked',
180
+ ':body-done-reading' => true
181
+ }, headers)
182
+ body = @bodies.shift
183
+ assert_equal 'abcde', body
184
+
185
+ req1 = @reqs.shift
186
+ headers = req1.headers
187
+ assert_equal({
188
+ ':method' => 'schmost',
189
+ ':path' => '/bar',
190
+ ':protocol' => 'http/1.1',
191
+ 'server' => 'bar.com',
192
+ 'transfer-encoding' => 'chunked',
193
+ ':body-done-reading' => true
194
+ }, headers)
195
+ body = @bodies.shift
196
+ assert_equal '123456789abcdefghijklmnopqrstuv', body
197
+ end
198
+
199
+ def test_body_to_eof
200
+ write_http_request <<~HTTP.crlf_lines
201
+ POST /foo HTTP/1.1
202
+ Server: foo.com
203
+
204
+ barbaz
205
+ HTTP
206
+
207
+ @bodies = []
208
+ @hook = ->(req) { @bodies << req.read }
209
+
210
+ @adapter.run
211
+ assert_equal 1, @reqs.size
212
+
213
+ req0 = @reqs.shift
214
+ headers = req0.headers
215
+ assert_equal({
216
+ ':method' => 'post',
217
+ ':path' => '/foo',
218
+ ':protocol' => 'http/1.1',
219
+ 'server' => 'foo.com'
220
+ }, headers)
221
+ body = @bodies.shift
222
+ assert_equal 'barbaz', body
223
+ end
224
+
225
+ def test_each_chunk
226
+ write_http_request <<~HTTP.crlf_lines
227
+ POST /foo HTTP/1.1
228
+ Server: foo.com
229
+ Transfer-Encoding: chunked
230
+
231
+ 3
232
+ abc
233
+ 2
234
+ de
235
+ 0
236
+
237
+ SCHMOST /bar HTTP/1.1
238
+ Server: bar.com
239
+ Content-Length: 31
240
+
241
+ 123456789abcdefghijklmnopqrstuv
242
+ HTTP
243
+
244
+ chunks = []
245
+ @hook = ->(req) { req.each_chunk { chunks << it } }
246
+
247
+ @adapter.serve_request
248
+ assert_equal 1, @reqs.size
249
+
250
+ req0 = @reqs.shift
251
+ headers = req0.headers
252
+ assert_equal({
253
+ ':method' => 'post',
254
+ ':path' => '/foo',
255
+ ':protocol' => 'http/1.1',
256
+ 'server' => 'foo.com',
257
+ 'transfer-encoding' => 'chunked',
258
+ ':body-done-reading' => true
259
+ }, headers)
260
+ assert_equal ['abc', 'de'], chunks
261
+
262
+ chunks.clear
263
+ @adapter.serve_request
264
+ assert_equal 1, @reqs.size
265
+
266
+ req1 = @reqs.shift
267
+ headers = req1.headers
268
+ assert_equal({
269
+ ':method' => 'schmost',
270
+ ':path' => '/bar',
271
+ ':protocol' => 'http/1.1',
272
+ 'server' => 'bar.com',
273
+ 'content-length' => '31',
274
+ ':body-done-reading' => true
275
+ }, headers)
276
+ assert_equal ['123456789abcdefghijklmnopqrstuv'], chunks
277
+ end
278
+
279
+ def test_that_server_uses_content_length_in_http_1_0
280
+ @hook = ->(req) {
281
+ req.respond('Hello, world!', {})
282
+ }
283
+
284
+ write_http_request "GET / HTTP/1.0\r\n\r\n"
285
+ @adapter.run
286
+ response = read_client_side
287
+
288
+ expected = <<~HTTP.crlf_lines
289
+ HTTP/1.1 200
290
+ Content-Length: 13
291
+
292
+ Hello, world!
293
+ HTTP
294
+ assert_equal(expected, response)
295
+ end
296
+
297
+ def test_204_status_on_empty_response
298
+ @hook = ->(req) {
299
+ req.respond(nil, {})
300
+ }
301
+
302
+ write_http_request "GET / HTTP/1.0\r\n\r\n"
303
+ @adapter.run
304
+ response = read_client_side
305
+
306
+ expected = <<~HTTP.crlf_lines
307
+ HTTP/1.1 204
308
+
309
+
310
+
311
+ HTTP
312
+ assert_equal(expected, response)
313
+
314
+ end
315
+
316
+ def test_that_server_uses_chunked_encoding_in_http_1_1
317
+ @hook = ->(req) {
318
+ req.respond('Hello, world!')
319
+ }
320
+
321
+ # using HTTP 1.0, server should close connection after responding
322
+ write_http_request "GET / HTTP/1.1\r\n\r\n"
323
+ @adapter.run
324
+
325
+ response = read_client_side
326
+ expected = <<~HTTP.crlf_lines.chomp
327
+ HTTP/1.1 200
328
+ Content-Length: 13
329
+
330
+ Hello, world!
331
+ HTTP
332
+ assert_equal(expected, response)
333
+ end
334
+
335
+ def test_that_server_maintains_connection_when_using_keep_alives
336
+ @hook = ->(req) {
337
+ req.respond('Hi', {})
338
+ }
339
+
340
+ write_http_request "GET / HTTP/1.0\r\nConnection: keep-alive\r\n\r\n", false
341
+ res = @adapter.serve_request
342
+ assert_equal true, res
343
+
344
+ response = read_client_side
345
+ assert_equal("HTTP/1.1 200\r\nContent-Length: 2\r\n\r\nHi", response)
346
+
347
+ write_http_request "GET / HTTP/1.1\r\n\r\n", false
348
+ res = @adapter.serve_request
349
+ assert_equal true, res
350
+
351
+ response = read_client_side
352
+ expected = <<~HTTP.crlf_lines
353
+ HTTP/1.1 200
354
+ Content-Length: 2
355
+
356
+ Hi
357
+ HTTP
358
+ assert_equal(expected, response)
359
+
360
+ write_http_request "GET / HTTP/1.0\r\n\r\n"
361
+ res = @adapter.serve_request
362
+ assert_equal false, !!res
363
+
364
+ response = read_client_side
365
+ assert_equal("HTTP/1.1 200\r\nContent-Length: 2\r\n\r\nHi", response)
366
+ end
367
+
368
+ def test_pipelining_client
369
+ @hook = ->(req) {
370
+ if req.headers['foo'] == 'bar'
371
+ req.respond('Hello, foobar!', {})
372
+ else
373
+ req.respond('Hello, world!', {})
374
+ end
375
+
376
+ }
377
+
378
+ write_http_request "GET / HTTP/1.1\r\n\r\nGET / HTTP/1.1\r\nFoo: bar\r\n\r\n"
379
+ @adapter.run
380
+ response = read_client_side
381
+
382
+ expected = <<~HTTP.crlf_lines.chomp
383
+ HTTP/1.1 200
384
+ Content-Length: 13
385
+
386
+ Hello, world!HTTP/1.1 200
387
+ Content-Length: 14
388
+
389
+ Hello, foobar!
390
+ HTTP
391
+ assert_equal(expected, response)
392
+ end
393
+
394
+ def test_body_chunks
395
+ chunks = []
396
+ request = nil
397
+
398
+ @hook = ->(req) {
399
+ request = req
400
+ req.send_headers
401
+ req.each_chunk do |c|
402
+ chunks << c
403
+ req << c.upcase
404
+ end
405
+ req.finish
406
+ }
407
+
408
+ msg = "POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n6\r\nfoobar\r\n"
409
+ write_http_request msg, false
410
+ @machine.spin { @adapter.serve_request rescue nil }
411
+ @machine.sleep(0.01)
412
+
413
+ assert request
414
+ assert_equal %w[foobar], chunks
415
+ assert !request.complete?
416
+
417
+ write_http_request "6\r\nbazbud\r\n", false
418
+ @machine.sleep(0.01)
419
+ assert_equal %w[foobar bazbud], chunks
420
+ assert !request.complete?
421
+
422
+ write_http_request "0\r\n\r\n"
423
+ @machine.sleep(0.01)
424
+ assert_equal %w[foobar bazbud], chunks
425
+ assert request.complete?
426
+
427
+ @machine.sleep(0.01)
428
+ response = read_client_side
429
+
430
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n6\r\nFOOBAR\r\n6\r\nBAZBUD\r\n0\r\n\r\n"
431
+ assert_equal(expected, response)
432
+ end
433
+
434
+ def test_upgrade
435
+ done = nil
436
+
437
+ @hook = ->(req) do
438
+ return if req.upgrade_protocol != 'echo'
439
+
440
+ req.upgrade(:echo)
441
+ @machine.sleep(0.01)
442
+ buf = +''
443
+ while true
444
+ res = @machine.recv(@s_fd, buf, 4096, 0)
445
+ break if res == 0
446
+
447
+ res = @machine.send(@s_fd, buf, res, 0)
448
+ end
449
+ done = true
450
+ rescue Exception > e
451
+ p e
452
+ p e.backtrace.join("\n")
453
+ end
454
+
455
+ opts = {
456
+ upgrade: {
457
+ echo: lambda do |adapter, _headers|
458
+ conn = adapter.conn
459
+ conn << <<~HTTP.crlf_lines
460
+ HTTP/1.1 101 Switching Protocols
461
+ Upgrade: echo
462
+ Connection: Upgrade
463
+
464
+ HTTP
465
+
466
+ conn.read_loop { |data| conn << data }
467
+ done = true
468
+ end
469
+ }
470
+ }
471
+
472
+ msg = "GET / HTTP/1.1\r\nUpgrade: echo\r\nConnection: upgrade\r\n\r\n"
473
+ write_http_request(msg, false)
474
+ @machine.spin { @adapter.serve_request rescue nil }
475
+ @machine.sleep(0.01)
476
+
477
+ response = read_client_side
478
+ expected = "HTTP/1.1 101\r\nContent-Length: 0\r\nUpgrade: echo\r\nConnection: upgrade\r\n\r\n"
479
+ assert_equal(expected, response)
480
+
481
+ assert !done
482
+
483
+ write_client_side 'foo'
484
+ assert_equal 'foo', read_client_side
485
+
486
+ write_client_side 'bar'
487
+ assert_equal 'bar', read_client_side
488
+
489
+ @machine.close(@c_fd)
490
+ assert !done
491
+
492
+ @machine.sleep(0.01)
493
+ assert done
494
+ end
495
+
496
+ def test_big_download
497
+ chunk_size = 1000
498
+ chunk_count = 1000
499
+ chunk = '*' * chunk_size
500
+
501
+ @hook = ->(req) do
502
+ req.send_headers
503
+ chunk_count.times do |i|
504
+ req << chunk
505
+ @machine.snooze
506
+ end
507
+ req.finish
508
+ @machine.close(@s_fd)
509
+ rescue Exception => e
510
+ p e
511
+ p e.backtrace.join("\n")
512
+ end
513
+
514
+ response = +''
515
+ count = 0
516
+
517
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
518
+ @machine.spin { @adapter.serve_request }
519
+
520
+ while (data = read_client_side(chunk_size))
521
+ response << data
522
+ count += 1
523
+ @machine.snooze
524
+ end
525
+
526
+ chunks = "#{chunk_size.to_s(16)}\r\n#{'*' * chunk_size}\r\n" * chunk_count
527
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n#{chunks}0\r\n\r\n"
528
+
529
+ assert_equal expected, response
530
+ assert count >= chunk_count
531
+ end
532
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './helper'
4
+
5
+ class String
6
+ end
7
+
8
+ class ServerTest < Minitest::Test
9
+ def make_socket_pair
10
+ port = 10000 + rand(30000)
11
+ server_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
12
+ @machine.setsockopt(server_fd, UM::SOL_SOCKET, UM::SO_REUSEADDR, true)
13
+ @machine.bind(server_fd, '127.0.0.1', port)
14
+ @machine.listen(server_fd, UM::SOMAXCONN)
15
+
16
+ client_conn_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
17
+ @machine.connect(client_conn_fd, '127.0.0.1', port)
18
+
19
+ server_conn_fd = @machine.accept(server_fd)
20
+
21
+ @machine.close(server_fd)
22
+ [client_conn_fd, server_conn_fd]
23
+ end
24
+
25
+ class STOP < StandardError
26
+ end
27
+
28
+ def setup
29
+ @machine = UM.new
30
+ @port = 10000 + rand(30000)
31
+ @server = TP2::Server.new(@machine, '127.0.0.1', @port) { @app&.call(it) }
32
+ @f_server = @machine.spin do
33
+ @server.run
34
+ rescue STOP
35
+ ensure
36
+ @server_done = true
37
+ end
38
+
39
+ @machine.sleep(0.01)
40
+ @client_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
41
+ @machine.connect(@client_fd, '127.0.0.1', @port)
42
+ end
43
+
44
+ def teardown
45
+ @machine.close(@client_fd) rescue nil
46
+ @machine.schedule(@f_server, STOP.new)
47
+ @machine.snooze until @server_done
48
+ end
49
+
50
+ def write_http_request(msg, shutdown_wr = true)
51
+ @machine.send(@client_fd, msg, msg.bytesize, UM::MSG_WAITALL)
52
+ @machine.shutdown(@client_fd, UM::SHUT_WR) if shutdown_wr
53
+ end
54
+
55
+ def write_client_side(msg)
56
+ @machine.send(@client_fd, msg, msg.bytesize, UM::MSG_WAITALL)
57
+ end
58
+
59
+ def read_client_side(len = 65536)
60
+ buf = +''
61
+ res = @machine.recv(@client_fd, buf, len, 0)
62
+ res == 0 ? nil : buf
63
+ end
64
+
65
+ def test_basic_app_response
66
+ @app = ->(req) {
67
+ req.respond('Hello, world!', {})
68
+ }
69
+
70
+ write_http_request "GET / HTTP/1.0\r\n\r\n"
71
+ response = read_client_side
72
+ expected = "HTTP/1.1 200\r\nContent-Length: 13\r\n\r\nHello, world!"
73
+ assert_equal(expected, response)
74
+ end
75
+
76
+ def test_pipelined_requests
77
+ @app = ->(req) {
78
+ req.respond("method: #{req.method}")
79
+ }
80
+
81
+ write_http_request "GET /foo HTTP/1.1\r\nServer: foo.com\r\n\r\nSCHMET /bar HTTP/1.1\r\n\r\n"
82
+
83
+ response = read_client_side
84
+ expected = "HTTP/1.1 200\r\nContent-Length: 11\r\n\r\nmethod: getHTTP/1.1 200\r\nContent-Length: 14\r\n\r\nmethod: schmet"
85
+ assert_equal(expected, response)
86
+ end
87
+
88
+ def test_pipelined_requests_with_body
89
+ skip
90
+
91
+ write_http_request <<~HTTP.crlf_lines
92
+ POST /foo HTTP/1.1
93
+ Server: foo.com
94
+ Content-Length: 3
95
+
96
+ abcSCHMOST /bar HTTP/1.1
97
+ Server: bar.com
98
+ Content-Length: 6
99
+
100
+ defghi
101
+ HTTP
102
+
103
+ @bodies = []
104
+ @hook = ->(req) { @bodies << req.read }
105
+
106
+ @adapter.run
107
+ assert_equal 2, @reqs.size
108
+
109
+ req0 = @reqs.shift
110
+ headers = req0.headers
111
+ assert_equal({
112
+ ':method' => 'post',
113
+ ':path' => '/foo',
114
+ ':protocol' => 'http/1.1',
115
+ 'server' => 'foo.com',
116
+ 'content-length' => '3'
117
+ }, headers)
118
+ body = @bodies.shift
119
+ assert_equal 'abc', body
120
+
121
+ req1 = @reqs.shift
122
+ headers = req1.headers
123
+ assert_equal({
124
+ ':method' => 'schmost',
125
+ ':path' => '/bar',
126
+ ':protocol' => 'http/1.1',
127
+ 'server' => 'bar.com',
128
+ 'content-length' => '6'
129
+ }, headers)
130
+ body = @bodies.shift
131
+ assert_equal 'defghi', body
132
+ end
133
+
134
+ def test_pipelined_requests_with_body_chunked
135
+ skip
136
+
137
+ write_http_request <<~HTTP.crlf_lines
138
+ POST /foo HTTP/1.1
139
+ Server: foo.com
140
+ Transfer-Encoding: chunked
141
+
142
+ 3
143
+ abc
144
+ 2
145
+ de
146
+ 0
147
+
148
+ SCHMOST /bar HTTP/1.1
149
+ Server: bar.com
150
+ Transfer-Encoding: chunked
151
+
152
+ 1f
153
+ 123456789abcdefghijklmnopqrstuv
154
+ 0
155
+
156
+ HTTP
157
+
158
+ @bodies = []
159
+ @hook = ->(req) { @bodies << req.read }
160
+
161
+ @adapter.run
162
+ assert_equal 2, @reqs.size
163
+
164
+ req0 = @reqs.shift
165
+ headers = req0.headers
166
+ assert_equal({
167
+ ':method' => 'post',
168
+ ':path' => '/foo',
169
+ ':protocol' => 'http/1.1',
170
+ 'server' => 'foo.com',
171
+ 'transfer-encoding' => 'chunked',
172
+ ':body-done-reading' => true
173
+ }, headers)
174
+ body = @bodies.shift
175
+ assert_equal 'abcde', body
176
+
177
+ req1 = @reqs.shift
178
+ headers = req1.headers
179
+ assert_equal({
180
+ ':method' => 'schmost',
181
+ ':path' => '/bar',
182
+ ':protocol' => 'http/1.1',
183
+ 'server' => 'bar.com',
184
+ 'transfer-encoding' => 'chunked',
185
+ ':body-done-reading' => true
186
+ }, headers)
187
+ body = @bodies.shift
188
+ assert_equal '123456789abcdefghijklmnopqrstuv', body
189
+ end
190
+
191
+ def test_body_to_eof
192
+ skip
193
+
194
+ write_http_request <<~HTTP.crlf_lines
195
+ POST /foo HTTP/1.1
196
+ Server: foo.com
197
+
198
+ barbaz
199
+ HTTP
200
+
201
+ @bodies = []
202
+ @hook = ->(req) { @bodies << req.read }
203
+
204
+ @adapter.run
205
+ assert_equal 1, @reqs.size
206
+
207
+ req0 = @reqs.shift
208
+ headers = req0.headers
209
+ assert_equal({
210
+ ':method' => 'post',
211
+ ':path' => '/foo',
212
+ ':protocol' => 'http/1.1',
213
+ 'server' => 'foo.com'
214
+ }, headers)
215
+ body = @bodies.shift
216
+ assert_equal 'barbaz', body
217
+ end
218
+ end
data/tp2.gemspec CHANGED
@@ -2,7 +2,7 @@ require_relative './lib/tp2/version'
2
2
 
3
3
  Gem::Specification.new do |s|
4
4
  s.name = 'tp2'
5
- s.summary = ''
5
+ s.summary = 'Experimental HTTP/1 server for UringMachine'
6
6
  s.version = TP2::VERSION
7
7
  s.licenses = ['MIT']
8
8
  s.author = 'Sharon Rosner'
@@ -19,7 +19,6 @@ Gem::Specification.new do |s|
19
19
  s.require_paths = ['lib']
20
20
  s.required_ruby_version = '>= 3.4'
21
21
 
22
- s.add_dependency 'http_parser.rb', '0.8.0'
23
- s.add_dependency 'uringmachine', '0.5.1'
22
+ s.add_dependency 'uringmachine', '0.7'
24
23
  s.add_dependency 'qeweney', '0.21'
25
24
  end
metadata CHANGED
@@ -1,42 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tp2
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.2'
4
+ version: '0.4'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sharon Rosner
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-03-01 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: http_parser.rb
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - '='
17
- - !ruby/object:Gem::Version
18
- version: 0.8.0
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - '='
24
- - !ruby/object:Gem::Version
25
- version: 0.8.0
26
12
  - !ruby/object:Gem::Dependency
27
13
  name: uringmachine
28
14
  requirement: !ruby/object:Gem::Requirement
29
15
  requirements:
30
16
  - - '='
31
17
  - !ruby/object:Gem::Version
32
- version: 0.5.1
18
+ version: '0.7'
33
19
  type: :runtime
34
20
  prerelease: false
35
21
  version_requirements: !ruby/object:Gem::Requirement
36
22
  requirements:
37
23
  - - '='
38
24
  - !ruby/object:Gem::Version
39
- version: 0.5.1
25
+ version: '0.7'
40
26
  - !ruby/object:Gem::Dependency
41
27
  name: qeweney
42
28
  requirement: !ruby/object:Gem::Requirement
@@ -65,10 +51,14 @@ files:
65
51
  - TODO.md
66
52
  - examples/simple.rb
67
53
  - lib/tp2.rb
68
- - lib/tp2/http.rb
54
+ - lib/tp2/http1_adapter.rb
69
55
  - lib/tp2/request_extensions.rb
70
56
  - lib/tp2/server.rb
71
57
  - lib/tp2/version.rb
58
+ - test/helper.rb
59
+ - test/run.rb
60
+ - test/test_http1_adapter.rb
61
+ - test/test_server.rb
72
62
  - tp2.gemspec
73
63
  homepage: https://github.com/noteflakes/tp2
74
64
  licenses:
@@ -95,7 +85,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
95
85
  - !ruby/object:Gem::Version
96
86
  version: '0'
97
87
  requirements: []
98
- rubygems_version: 3.6.2
88
+ rubygems_version: 3.6.8
99
89
  specification_version: 4
100
- summary: ''
90
+ summary: Experimental HTTP/1 server for UringMachine
101
91
  test_files: []