wsv 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/wsv/server.rb CHANGED
@@ -3,17 +3,16 @@
3
3
  require "openssl"
4
4
  require "socket"
5
5
  require_relative "app"
6
- require_relative "request"
7
- require_relative "response"
6
+ require_relative "cors"
8
7
  require_relative "server/banner"
9
8
  require_relative "server/browser_launcher"
10
- require_relative "server/deadline_reader"
9
+ require_relative "server/connection"
10
+ require_relative "server/connection_throttle"
11
11
 
12
12
  module Wsv
13
13
  class Server
14
14
  DEFAULT_READ_TIMEOUT = 10
15
15
  DEFAULT_MAX_CONNECTIONS = 8
16
- DRAIN_TIMEOUT = 5
17
16
 
18
17
  attr_reader :host, :port, :root
19
18
 
@@ -36,14 +35,13 @@ module Wsv
36
35
  @out = out
37
36
  @err = err
38
37
  @read_timeout = read_timeout
39
- @max_connections = max_connections
40
38
  @tls = tls
41
39
  @ssl_context = tls&.to_ssl_context
42
40
  @open = open
43
- @app = App.new(@root, spa: spa, cors: cors)
41
+ @cors = Cors.new if cors
42
+ @app = App.new(@root, spa: spa, cors: @cors)
43
+ @throttle = ConnectionThrottle.new(max: max_connections, err: err)
44
44
  @running = false
45
- @mutex = Mutex.new
46
- @active = 0
47
45
  end
48
46
 
49
47
  def start
@@ -62,74 +60,8 @@ module Wsv
62
60
  close
63
61
  end
64
62
 
65
- def handle(client)
66
- reader = DeadlineReader.new(client, Time.now + @read_timeout)
67
- request = Request.parse(reader)
68
- case request
69
- when :empty
70
- nil
71
- when :malformed
72
- write_response(client, Response.text(400))
73
- else
74
- write_response(client, @app.call(request))
75
- end
76
- rescue Request::TooLarge => e
77
- write_response(client, Response.text(e.status_code))
78
- rescue IO::TimeoutError
79
- write_response(client, Response.text(408))
80
- rescue StandardError => e
81
- # Treat unmapped failures as connection-scoped and close with 400 rather
82
- # than letting one bad request path bring down the server.
83
- @err.puts "wsv: #{e.class}: #{e.message}"
84
- write_response(client, Response.text(400))
85
- ensure
86
- graceful_close(client)
87
- end
88
-
89
63
  private
90
64
 
91
- def write_response(client, response)
92
- return if client.closed?
93
-
94
- response.write_to(client)
95
- rescue Errno::EPIPE, Errno::ECONNRESET, IOError
96
- nil
97
- end
98
-
99
- def graceful_close(client)
100
- return if client.closed?
101
-
102
- drain_recv(client)
103
- rescue StandardError
104
- nil
105
- ensure
106
- begin
107
- client.close unless client.closed?
108
- rescue StandardError
109
- nil
110
- end
111
- end
112
-
113
- def drain_recv(client)
114
- deadline = Time.now + DRAIN_TIMEOUT
115
- loop do
116
- return if Time.now >= deadline
117
-
118
- chunk = client.read_nonblock(8192, exception: false)
119
- case chunk
120
- when nil, :wait_writable
121
- # nil = EOF. :wait_writable can come back from SSLSocket during a
122
- # renegotiation (read needs an underlying write). Either way,
123
- # there's nothing more we can usefully drain right now.
124
- return
125
- when :wait_readable
126
- remaining = deadline - Time.now
127
- return if remaining <= 0
128
- return unless client.wait_readable([remaining, 0.2].min)
129
- end
130
- end
131
- end
132
-
133
65
  def accept_loop
134
66
  while @running
135
67
  client = nil
@@ -157,38 +89,25 @@ module Wsv
157
89
  end
158
90
 
159
91
  def spawn_handler(client)
160
- accepted = @mutex.synchronize do
161
- next false if @active >= @max_connections
162
-
163
- @active += 1
164
- true
165
- end
166
-
167
- return spawn_rejection(client) unless accepted
168
-
169
- begin
170
- Thread.new do
171
- Thread.current.report_on_exception = false
172
- handle(maybe_wrap_tls(client))
173
- ensure
174
- @mutex.synchronize { @active -= 1 }
175
- end
176
- rescue ThreadError => e
177
- @err.puts "wsv: thread error: #{e.message}"
178
- @mutex.synchronize { @active -= 1 }
179
- spawn_rejection(client)
92
+ accepted = @throttle.try_spawn do
93
+ Connection.new(maybe_wrap_tls(client), err: @err, cors: @cors).serve(@app, read_timeout: @read_timeout)
180
94
  end
95
+ spawn_rejection(client) unless accepted
181
96
  end
182
97
 
183
98
  # Reject in a separate thread so a slow client cannot block accept_loop
184
- # via graceful_close's drain_recv (up to DRAIN_TIMEOUT seconds).
99
+ # via Connection#graceful_close (up to Connection::DRAIN_TIMEOUT seconds).
100
+ # In TLS mode `client` is the raw TCPSocket before any handshake; writing
101
+ # a plaintext 503 would corrupt the TLS handshake the client is about to
102
+ # start, so suppress the reply in that case.
185
103
  def spawn_rejection(client)
104
+ reply = !@ssl_context
186
105
  Thread.new do
187
106
  Thread.current.report_on_exception = false
188
- reject(client)
107
+ Connection.new(client, err: @err, cors: @cors).reject(reply: reply)
189
108
  end
190
109
  rescue ThreadError
191
- reject(client)
110
+ Connection.new(client, err: @err, cors: @cors).reject(reply: reply)
192
111
  end
193
112
 
194
113
  def maybe_wrap_tls(client)
@@ -200,7 +119,7 @@ module Wsv
200
119
  ssl.accept
201
120
  ssl
202
121
  rescue StandardError
203
- # If wrapping or the handshake failed, `handle` is never called and
122
+ # If wrapping or the handshake failed, `serve` is never called and
204
123
  # its ensure does not get a chance to close the underlying socket.
205
124
  # Close it here so we do not leak a TCPSocket per failed handshake.
206
125
  begin
@@ -211,15 +130,6 @@ module Wsv
211
130
  raise
212
131
  end
213
132
 
214
- def reject(client)
215
- # In TLS mode `client` is the raw TCPSocket before any handshake.
216
- # Writing a plaintext 503 over it would corrupt the TLS handshake
217
- # the client is about to start, so just close in that case.
218
- write_response(client, Response.text(503)) unless @ssl_context
219
- ensure
220
- graceful_close(client)
221
- end
222
-
223
133
  def close
224
134
  @server&.close unless @server&.closed?
225
135
  end
data/lib/wsv/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wsv
4
- VERSION = "0.10.0"
4
+ VERSION = "0.11.0"
5
5
  end
data/lib/wsv.rb CHANGED
@@ -4,6 +4,7 @@ require_relative "wsv/version"
4
4
  require_relative "wsv/status"
5
5
  require_relative "wsv/mime_types"
6
6
  require_relative "wsv/path_resolver"
7
+ require_relative "wsv/range_request"
7
8
  require_relative "wsv/request"
8
9
  require_relative "wsv/response"
9
10
  require_relative "wsv/cors"
data/test/app_test.rb CHANGED
@@ -337,50 +337,34 @@ class AppTest < Minitest::Test
337
337
  assert_equal "bytes", response.headers["Accept-Ranges"]
338
338
  end
339
339
 
340
- def test_cors_disabled_omits_acao_header
341
- File.write(File.join(@dir, "x.txt"), "hi")
342
-
343
- response = @app.call(req("GET", "/x.txt"))
340
+ # CORS overlay (Access-Control-Allow-Origin / Vary) is applied by
341
+ # Server::Connection, not by App. App only short-circuits OPTIONS preflight
342
+ # and adds OPTIONS to the Allow header. End-to-end CORS behavior is covered
343
+ # by server_test.rb.
344
344
 
345
- refute response.headers.key?("Access-Control-Allow-Origin")
346
- end
347
-
348
- def test_cors_adds_acao_to_successful_response
345
+ def test_app_does_not_add_cors_headers_directly
349
346
  File.write(File.join(@dir, "x.txt"), "hi")
350
- cors_app = Wsv::App.new(File.realpath(@dir), cors: true)
347
+ cors_app = Wsv::App.new(File.realpath(@dir), cors: Wsv::Cors.new)
351
348
 
352
349
  response = cors_app.call(req("GET", "/x.txt"))
353
350
 
354
- assert_equal 200, response.status
355
- assert_equal "hi", response.body
356
- assert_equal "*", response.headers["Access-Control-Allow-Origin"]
357
- assert_equal "Origin", response.headers["Vary"]
358
- end
359
-
360
- def test_cors_adds_acao_to_error_response
361
- cors_app = Wsv::App.new(File.realpath(@dir), cors: true)
362
-
363
- response = cors_app.call(req("GET", "/missing.txt"))
364
-
365
- assert_equal 404, response.status
366
- assert_equal "*", response.headers["Access-Control-Allow-Origin"]
351
+ refute response.headers.key?("Access-Control-Allow-Origin")
352
+ refute response.headers.key?("Vary")
367
353
  end
368
354
 
369
355
  def test_cors_preflight_returns_204_with_methods_and_max_age
370
- cors_app = Wsv::App.new(File.realpath(@dir), cors: true)
356
+ cors_app = Wsv::App.new(File.realpath(@dir), cors: Wsv::Cors.new)
371
357
 
372
358
  response = cors_app.call(req("OPTIONS", "/x.txt"))
373
359
 
374
360
  assert_equal 204, response.status
375
- assert_equal "*", response.headers["Access-Control-Allow-Origin"]
376
361
  assert_equal "GET, HEAD, OPTIONS", response.headers["Access-Control-Allow-Methods"]
377
362
  assert_equal "86400", response.headers["Access-Control-Max-Age"]
378
- assert_equal "Origin", response.headers["Vary"]
379
363
  assert_equal "", response.body
380
364
  end
381
365
 
382
366
  def test_cors_preflight_echoes_requested_headers
383
- cors_app = Wsv::App.new(File.realpath(@dir), cors: true)
367
+ cors_app = Wsv::App.new(File.realpath(@dir), cors: Wsv::Cors.new)
384
368
 
385
369
  response = cors_app.call(req("OPTIONS", "/x.txt", "access-control-request-headers" => "X-Custom, Authorization"))
386
370
 
@@ -389,7 +373,7 @@ class AppTest < Minitest::Test
389
373
  end
390
374
 
391
375
  def test_cors_preflight_omits_allow_headers_when_not_requested
392
- cors_app = Wsv::App.new(File.realpath(@dir), cors: true)
376
+ cors_app = Wsv::App.new(File.realpath(@dir), cors: Wsv::Cors.new)
393
377
 
394
378
  response = cors_app.call(req("OPTIONS", "/x.txt"))
395
379
 
@@ -404,13 +388,12 @@ class AppTest < Minitest::Test
404
388
  end
405
389
 
406
390
  def test_cors_405_advertises_options_in_allow
407
- cors_app = Wsv::App.new(File.realpath(@dir), cors: true)
391
+ cors_app = Wsv::App.new(File.realpath(@dir), cors: Wsv::Cors.new)
408
392
 
409
393
  response = cors_app.call(req("POST", "/x.txt"))
410
394
 
411
395
  assert_equal 405, response.status
412
396
  assert_equal "GET, HEAD, OPTIONS", response.headers["Allow"]
413
- assert_equal "*", response.headers["Access-Control-Allow-Origin"]
414
397
  end
415
398
 
416
399
  def test_multipart_range_falls_through_to_200
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class BannerTest < Minitest::Test
6
+ include TlsTestHelpers
7
+
8
+ ROOT = Dir.tmpdir
9
+
10
+ def test_warns_when_binding_to_non_loopback
11
+ err = StringIO.new
12
+ build(host: "0.0.0.0", err: err).emit
13
+
14
+ assert_includes err.string, "WARNING"
15
+ assert_includes err.string, "0.0.0.0"
16
+ end
17
+
18
+ def test_no_warning_for_loopback_bind
19
+ err = StringIO.new
20
+ build(host: "127.0.0.1", err: err).emit
21
+
22
+ refute_includes err.string, "WARNING"
23
+ end
24
+
25
+ def test_brackets_ipv6_address_in_url
26
+ out = StringIO.new
27
+ build(host: "::1", port: 8000, out: out).emit
28
+
29
+ assert_includes out.string, "http://[::1]:8000/"
30
+ refute_includes out.string, "http://::1:8000/"
31
+ end
32
+
33
+ def test_percent_encodes_ipv6_zone_identifier
34
+ out = StringIO.new
35
+ build(host: "fe80::1%eth0", port: 8000, out: out).emit
36
+
37
+ assert_includes out.string, "http://[fe80::1%25eth0]:8000/"
38
+ end
39
+
40
+ def test_logs_https_scheme_when_tls_enabled
41
+ out = StringIO.new
42
+ build(host: "127.0.0.1", port: 8000, out: out, tls: ephemeral_tls).emit
43
+
44
+ assert_includes out.string, "https://"
45
+ end
46
+
47
+ def test_warns_about_self_signed_cert
48
+ err = StringIO.new
49
+ build(host: "127.0.0.1", err: err, tls: ephemeral_tls).emit
50
+
51
+ assert_includes err.string, "self-signed"
52
+ end
53
+
54
+ private
55
+
56
+ def build(host:, port: 0, root: ROOT, out: StringIO.new, err: StringIO.new, tls: nil)
57
+ Wsv::Server::Banner.new(host: host, port: port, root: root, out: out, err: err, tls: tls)
58
+ end
59
+ end
data/test/cors_test.rb ADDED
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class CorsTest < Minitest::Test
6
+ def setup
7
+ @cors = Wsv::Cors.new
8
+ end
9
+
10
+ def test_allow_methods_returns_array
11
+ assert_equal %w[GET HEAD OPTIONS], @cors.allow_methods
12
+ end
13
+
14
+ # `Cors#preflight` returns the preflight-specific headers only;
15
+ # Server::Connection adds ACAO / Vary on top, uniformly.
16
+
17
+ def test_preflight_returns_204
18
+ response = @cors.preflight(req)
19
+
20
+ assert_equal 204, response.status
21
+ end
22
+
23
+ def test_preflight_includes_allow_methods_and_max_age
24
+ response = @cors.preflight(req)
25
+
26
+ assert_equal "GET, HEAD, OPTIONS", response.headers["Access-Control-Allow-Methods"]
27
+ assert_equal "86400", response.headers["Access-Control-Max-Age"]
28
+ end
29
+
30
+ def test_preflight_omits_acao_and_vary
31
+ response = @cors.preflight(req)
32
+
33
+ refute response.headers.key?("Access-Control-Allow-Origin")
34
+ refute response.headers.key?("Vary")
35
+ end
36
+
37
+ def test_preflight_echoes_requested_headers
38
+ response = @cors.preflight(req("access-control-request-headers" => "X-Custom, Authorization"))
39
+
40
+ assert_equal "X-Custom, Authorization", response.headers["Access-Control-Allow-Headers"]
41
+ end
42
+
43
+ def test_preflight_omits_allow_headers_when_not_requested
44
+ response = @cors.preflight(req)
45
+
46
+ refute response.headers.key?("Access-Control-Allow-Headers")
47
+ end
48
+
49
+ def test_overlay_adds_acao_and_vary
50
+ base = Wsv::Response.text(200)
51
+ overlaid = @cors.overlay(base)
52
+
53
+ assert_equal "*", overlaid.headers["Access-Control-Allow-Origin"]
54
+ assert_equal "Origin", overlaid.headers["Vary"]
55
+ end
56
+
57
+ def test_overlay_preserves_existing_headers
58
+ base = Wsv::Response.text(404)
59
+ overlaid = @cors.overlay(base)
60
+
61
+ assert_equal "text/plain; charset=utf-8", overlaid.headers["Content-Type"]
62
+ end
63
+
64
+ def test_overlay_returns_a_new_response_keeping_status
65
+ base = Wsv::Response.text(404)
66
+ overlaid = @cors.overlay(base)
67
+
68
+ assert_equal 404, overlaid.status
69
+ end
70
+
71
+ private
72
+
73
+ def req(headers = {})
74
+ Wsv::Request.new(method: "OPTIONS", target: "/", version: "HTTP/1.1", headers: headers)
75
+ end
76
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class RangeRequestTest < Minitest::Test
6
+ def test_nil_header_is_full
7
+ result = Wsv::RangeRequest.parse(nil, 100)
8
+
9
+ assert_predicate result, :full?
10
+ end
11
+
12
+ def test_empty_header_is_full
13
+ result = Wsv::RangeRequest.parse("", 100)
14
+
15
+ assert_predicate result, :full?
16
+ end
17
+
18
+ def test_unparseable_syntax_is_full
19
+ # Per RFC 7233 an unparseable Range is treated as if absent.
20
+ result = Wsv::RangeRequest.parse("garbage", 100)
21
+
22
+ assert_predicate result, :full?
23
+ end
24
+
25
+ def test_empty_range_is_full
26
+ # `bytes=-` matches the regex but yields no bounds; treat as absent.
27
+ result = Wsv::RangeRequest.parse("bytes=-", 100)
28
+
29
+ assert_predicate result, :full?
30
+ end
31
+
32
+ def test_bounded_range
33
+ result = Wsv::RangeRequest.parse("bytes=2-5", 100)
34
+
35
+ assert_predicate result, :partial?
36
+ assert_equal 2..5, result.bounds
37
+ end
38
+
39
+ def test_open_range
40
+ result = Wsv::RangeRequest.parse("bytes=5-", 10)
41
+
42
+ assert_predicate result, :partial?
43
+ assert_equal 5..9, result.bounds
44
+ end
45
+
46
+ def test_suffix_range
47
+ result = Wsv::RangeRequest.parse("bytes=-3", 10)
48
+
49
+ assert_predicate result, :partial?
50
+ assert_equal 7..9, result.bounds
51
+ end
52
+
53
+ def test_suffix_larger_than_file_clamps_to_zero
54
+ result = Wsv::RangeRequest.parse("bytes=-99", 10)
55
+
56
+ assert_predicate result, :partial?
57
+ assert_equal 0..9, result.bounds
58
+ end
59
+
60
+ def test_bounded_last_past_file_clamps_to_end
61
+ result = Wsv::RangeRequest.parse("bytes=5-99", 10)
62
+
63
+ assert_predicate result, :partial?
64
+ assert_equal 5..9, result.bounds
65
+ end
66
+
67
+ def test_zero_byte_suffix_is_unsatisfiable
68
+ result = Wsv::RangeRequest.parse("bytes=-0", 10)
69
+
70
+ assert_predicate result, :unsatisfiable?
71
+ end
72
+
73
+ def test_suffix_against_empty_file_is_unsatisfiable
74
+ result = Wsv::RangeRequest.parse("bytes=-3", 0)
75
+
76
+ assert_predicate result, :unsatisfiable?
77
+ end
78
+
79
+ def test_open_range_past_file_is_unsatisfiable
80
+ result = Wsv::RangeRequest.parse("bytes=10-", 5)
81
+
82
+ assert_predicate result, :unsatisfiable?
83
+ end
84
+
85
+ def test_bounded_first_past_file_is_unsatisfiable
86
+ result = Wsv::RangeRequest.parse("bytes=10-20", 5)
87
+
88
+ assert_predicate result, :unsatisfiable?
89
+ end
90
+
91
+ def test_inverted_bounded_range_is_unsatisfiable
92
+ result = Wsv::RangeRequest.parse("bytes=5-3", 100)
93
+
94
+ assert_predicate result, :unsatisfiable?
95
+ end
96
+
97
+ def test_multipart_range_is_full
98
+ # `bytes=0-2,5-7` doesn't match the single-range regex; treat as absent.
99
+ result = Wsv::RangeRequest.parse("bytes=0-2,5-7", 100)
100
+
101
+ assert_predicate result, :full?
102
+ end
103
+ end