wsv 0.10.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 782b303e2d6eeb0f45f6b3a012523b94611a4991d59ef6669768b21936f47a8e
4
- data.tar.gz: 25b99b347007975bac86ccd93c212f5fb0bc85522054c36badac601bae5f3638
3
+ metadata.gz: adfd569857554409cef36ce05205eb50f833a58a27e654e1da80646afa2ec40e
4
+ data.tar.gz: 8d5c75289dd59dc65412ebff24bf0cf4d9aa9933befb17354f9b2fb3320b5ca3
5
5
  SHA512:
6
- metadata.gz: b3619a62d69f29a3498081d8174a0a01c61484ede73f9875de9867b4a4dd451f639dcbe0bee99fb3bea00b78c0ba289618ab7eeec3b066705d8ca437a9cea134
7
- data.tar.gz: c58379c2eab12176f492d993d1e194dc0f3a33c16ba7d8736134498e4d13ce8031816a5d22688f1639c0ea3127c5787ad4c235874b20929490eac52881c3e9b0
6
+ metadata.gz: b4514041977a6c9a599ea9f2a3735f7b4668ab18172b974453293c444c4441221e068f9374cbdd6c5058b99ecb776edbe47bda3bff4fd77731dbeb9b45cd9446
7
+ data.tar.gz: f1d06286b164b0f70dce52d7fd86b9fdf29c64f6cbc0515e3d26558afb4498d23277fc23b66709298afffb593e321a21498cc55dea2ab8b58e31186b48aaa021
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0
4
+
5
+ - Extract `Wsv::RangeRequest` from `App`. RFC 7233 range parsing
6
+ (suffix / open-ended / bounded ranges, unsatisfiability) now lives
7
+ in its own class with a `Result` value object (`#full?` /
8
+ `#partial?` / `#unsatisfiable?` predicates plus `#bounds`),
9
+ matching the `PathResolver::Result` pattern. Behavior unchanged.
10
+ - Make `Server::Connection` the sole place that adds CORS overlay
11
+ headers (`Access-Control-Allow-Origin`, `Vary`) on outgoing
12
+ responses. Previously `App` applied the overlay on its return
13
+ value, but rescue-path responses (408 / 414 / 431 / 503 / unmapped
14
+ 400) bypassed `App` and therefore lacked CORS headers. Now every
15
+ response — `App`, parser errors, timeouts, and the 503 rejection —
16
+ gets the overlay uniformly. `Cors#preflight` no longer includes
17
+ ACAO/Vary itself, since Connection adds them.
18
+ - `Wsv::App.new(... cors:)` now expects a `Wsv::Cors` instance (or
19
+ `nil`) instead of a Boolean. `Wsv::Server.new(... cors: true/false)`
20
+ is unchanged. Per the README's stability statement, the Ruby API
21
+ under `lib/wsv/` is implementation-only and may change in any
22
+ release.
23
+
3
24
  ## 0.10.1
4
25
 
5
26
  - Update gem description and README tagline to position wsv as
data/lib/wsv/app.rb CHANGED
@@ -4,24 +4,23 @@ require "time"
4
4
  require "uri"
5
5
  require_relative "cors"
6
6
  require_relative "path_resolver"
7
+ require_relative "range_request"
7
8
  require_relative "response"
8
9
 
9
10
  module Wsv
10
11
  class App
11
12
  ALLOWED_METHODS = %w[GET HEAD].freeze
12
- RANGE_PATTERN = /\Abytes=(\d+)?-(\d+)?\z/
13
13
 
14
- def initialize(root, spa: false, cors: false)
14
+ def initialize(root, spa: false, cors: nil)
15
15
  @resolver = PathResolver.new(root)
16
16
  @spa = spa
17
- @cors = Cors.new if cors
17
+ @cors = cors
18
18
  end
19
19
 
20
20
  def call(request)
21
21
  return @cors.preflight(request) if @cors && request.method == "OPTIONS"
22
22
 
23
- response = build_response(request)
24
- @cors ? @cors.overlay(response) : response
23
+ build_response(request)
25
24
  end
26
25
 
27
26
  private
@@ -30,7 +29,7 @@ module Wsv
30
29
  head = request.head?
31
30
 
32
31
  unless ALLOWED_METHODS.include?(request.method)
33
- return Response.text(405, headers: { "Allow" => allow_methods }, head: head)
32
+ return Response.text(405, headers: { "Allow" => allow_header }, head: head)
34
33
  end
35
34
 
36
35
  raw_path, query = request.target.split("?", 2)
@@ -51,8 +50,8 @@ module Wsv
51
50
  file_response(result.file, request, head: head)
52
51
  end
53
52
 
54
- def allow_methods
55
- @cors ? Cors::ALLOW_METHODS : "GET, HEAD"
53
+ def allow_header
54
+ (@cors&.allow_methods || ALLOWED_METHODS).join(", ")
56
55
  end
57
56
 
58
57
  def error_response(status, head:)
@@ -69,15 +68,11 @@ module Wsv
69
68
  return Response.not_modified if not_modified?(file, request.headers["if-modified-since"])
70
69
 
71
70
  size = File.size(file)
72
- range = parse_range(request.headers["range"], size)
73
- case range
74
- when :unsatisfiable
75
- Response.range_not_satisfiable(size, head: head)
76
- when nil
77
- Response.file(file, head: head)
78
- else
79
- Response.file(file, head: head, range: range)
80
- end
71
+ range = RangeRequest.parse(request.headers["range"], size)
72
+ return Response.range_not_satisfiable(size, head: head) if range.unsatisfiable?
73
+ return Response.file(file, head: head) if range.full?
74
+
75
+ Response.file(file, head: head, range: range.bounds)
81
76
  end
82
77
 
83
78
  def not_modified?(file, header_value)
@@ -89,45 +84,6 @@ module Wsv
89
84
  false
90
85
  end
91
86
 
92
- def parse_range(header_value, file_size)
93
- return nil if header_value.nil? || header_value.empty?
94
-
95
- match = header_value.match(RANGE_PATTERN)
96
- # Per RFC 7233, an unparseable Range is treated as if absent: fall
97
- # through as nil so the caller serves a normal 200 instead of 416.
98
- return nil unless match
99
-
100
- first, last = match.captures
101
- if first.nil? && last.nil?
102
- nil
103
- elsif first.nil?
104
- suffix_range(last.to_i, file_size)
105
- elsif last.nil?
106
- open_range(first.to_i, file_size)
107
- else
108
- bounded_range(first.to_i, last.to_i, file_size)
109
- end
110
- end
111
-
112
- def suffix_range(suffix, file_size)
113
- return :unsatisfiable if suffix.zero? || file_size.zero?
114
-
115
- [file_size - suffix, 0].max..(file_size - 1)
116
- end
117
-
118
- def open_range(first, file_size)
119
- return :unsatisfiable if first >= file_size
120
-
121
- first..(file_size - 1)
122
- end
123
-
124
- def bounded_range(first, last, file_size)
125
- return :unsatisfiable if first > last || first >= file_size
126
-
127
- last = file_size - 1 if last >= file_size
128
- first..last
129
- end
130
-
131
87
  def redirect_location(raw_path, query)
132
88
  path = URI(raw_path.to_s).path
133
89
  path = "/" if path.empty?
data/lib/wsv/cors.rb CHANGED
@@ -5,15 +5,17 @@ require_relative "response"
5
5
  module Wsv
6
6
  class Cors
7
7
  ALLOW_ORIGIN = "*"
8
- ALLOW_METHODS = "GET, HEAD, OPTIONS"
8
+ ALLOW_METHODS = %w[GET HEAD OPTIONS].freeze
9
9
  MAX_AGE = "86400"
10
10
 
11
+ def allow_methods
12
+ ALLOW_METHODS
13
+ end
14
+
11
15
  def preflight(request)
12
16
  headers = {
13
- "Access-Control-Allow-Origin" => ALLOW_ORIGIN,
14
- "Access-Control-Allow-Methods" => ALLOW_METHODS,
15
- "Access-Control-Max-Age" => MAX_AGE,
16
- "Vary" => "Origin"
17
+ "Access-Control-Allow-Methods" => ALLOW_METHODS.join(", "),
18
+ "Access-Control-Max-Age" => MAX_AGE
17
19
  }
18
20
  requested = request.headers["access-control-request-headers"]
19
21
  headers["Access-Control-Allow-Headers"] = requested if requested
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ # Parses an HTTP `Range` header (RFC 7233) against a known file size.
5
+ class RangeRequest
6
+ PATTERN = /\Abytes=(\d+)?-(\d+)?\z/
7
+
8
+ class Result
9
+ attr_reader :bounds
10
+
11
+ def initialize(kind:, bounds: nil)
12
+ @kind = kind
13
+ @bounds = bounds
14
+ end
15
+
16
+ def full?
17
+ @kind == :full
18
+ end
19
+
20
+ def partial?
21
+ @kind == :partial
22
+ end
23
+
24
+ def unsatisfiable?
25
+ @kind == :unsatisfiable
26
+ end
27
+
28
+ def self.full
29
+ new(kind: :full)
30
+ end
31
+
32
+ def self.partial(bounds)
33
+ new(kind: :partial, bounds: bounds)
34
+ end
35
+
36
+ def self.unsatisfiable
37
+ new(kind: :unsatisfiable)
38
+ end
39
+ end
40
+
41
+ def self.parse(header_value, file_size)
42
+ new(header_value, file_size).parse
43
+ end
44
+
45
+ def initialize(header_value, file_size)
46
+ @header_value = header_value
47
+ @file_size = file_size
48
+ end
49
+
50
+ def parse
51
+ return Result.full if @header_value.nil? || @header_value.empty?
52
+
53
+ match = @header_value.match(PATTERN)
54
+ # Per RFC 7233, an unparseable Range is treated as if absent: return
55
+ # full so the caller serves a normal 200 instead of 416.
56
+ return Result.full unless match
57
+
58
+ first, last = match.captures
59
+ if first.nil? && last.nil?
60
+ Result.full
61
+ elsif first.nil?
62
+ suffix_range(last.to_i)
63
+ elsif last.nil?
64
+ open_range(first.to_i)
65
+ else
66
+ bounded_range(first.to_i, last.to_i)
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def suffix_range(suffix)
73
+ return Result.unsatisfiable if suffix.zero? || @file_size.zero?
74
+
75
+ Result.partial([@file_size - suffix, 0].max..(@file_size - 1))
76
+ end
77
+
78
+ def open_range(first)
79
+ return Result.unsatisfiable if first >= @file_size
80
+
81
+ Result.partial(first..(@file_size - 1))
82
+ end
83
+
84
+ def bounded_range(first, last)
85
+ return Result.unsatisfiable if first > last || first >= @file_size
86
+
87
+ last = @file_size - 1 if last >= @file_size
88
+ Result.partial(first..last)
89
+ end
90
+ end
91
+ end
@@ -13,9 +13,10 @@ module Wsv
13
13
  class Connection
14
14
  DRAIN_TIMEOUT = 5
15
15
 
16
- def initialize(client, err:)
16
+ def initialize(client, err:, cors: nil)
17
17
  @client = client
18
18
  @err = err
19
+ @cors = cors
19
20
  end
20
21
 
21
22
  def serve(app, read_timeout:)
@@ -50,14 +51,21 @@ module Wsv
50
51
 
51
52
  private
52
53
 
54
+ # Connection is the sole place that adds ACAO / Vary headers, so every
55
+ # response (App, parser errors, timeouts, the 503 rejection) gets them
56
+ # uniformly when CORS is enabled.
53
57
  def write(response)
54
58
  return if @client.closed?
55
59
 
56
- response.write_to(@client)
60
+ finalize(response).write_to(@client)
57
61
  rescue Errno::EPIPE, Errno::ECONNRESET, IOError
58
62
  nil
59
63
  end
60
64
 
65
+ def finalize(response)
66
+ @cors ? @cors.overlay(response) : response
67
+ end
68
+
61
69
  def graceful_close
62
70
  return if @client.closed?
63
71
 
data/lib/wsv/server.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "openssl"
4
4
  require "socket"
5
5
  require_relative "app"
6
+ require_relative "cors"
6
7
  require_relative "server/banner"
7
8
  require_relative "server/browser_launcher"
8
9
  require_relative "server/connection"
@@ -37,7 +38,8 @@ module Wsv
37
38
  @tls = tls
38
39
  @ssl_context = tls&.to_ssl_context
39
40
  @open = open
40
- @app = App.new(@root, spa: spa, cors: cors)
41
+ @cors = Cors.new if cors
42
+ @app = App.new(@root, spa: spa, cors: @cors)
41
43
  @throttle = ConnectionThrottle.new(max: max_connections, err: err)
42
44
  @running = false
43
45
  end
@@ -88,7 +90,7 @@ module Wsv
88
90
 
89
91
  def spawn_handler(client)
90
92
  accepted = @throttle.try_spawn do
91
- Connection.new(maybe_wrap_tls(client), err: @err).serve(@app, read_timeout: @read_timeout)
93
+ Connection.new(maybe_wrap_tls(client), err: @err, cors: @cors).serve(@app, read_timeout: @read_timeout)
92
94
  end
93
95
  spawn_rejection(client) unless accepted
94
96
  end
@@ -102,10 +104,10 @@ module Wsv
102
104
  reply = !@ssl_context
103
105
  Thread.new do
104
106
  Thread.current.report_on_exception = false
105
- Connection.new(client, err: @err).reject(reply: reply)
107
+ Connection.new(client, err: @err, cors: @cors).reject(reply: reply)
106
108
  end
107
109
  rescue ThreadError
108
- Connection.new(client, err: @err).reject(reply: reply)
110
+ Connection.new(client, err: @err, cors: @cors).reject(reply: reply)
109
111
  end
110
112
 
111
113
  def maybe_wrap_tls(client)
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.1"
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
data/test/server_test.rb CHANGED
@@ -5,6 +5,8 @@ require "socket"
5
5
  require_relative "test_helper"
6
6
 
7
7
  class ServerTest < Minitest::Test
8
+ include TlsTestHelpers
9
+
8
10
  def setup
9
11
  @dir = Dir.mktmpdir
10
12
  @server = nil
@@ -164,55 +166,74 @@ class ServerTest < Minitest::Test
164
166
  socket&.close
165
167
  end
166
168
 
167
- def test_slow_client_does_not_block_other_clients
168
- File.write(File.join(@dir, "x.txt"), "ok")
169
- start_server(read_timeout: 5)
170
-
171
- slow_socket = TCPSocket.open("127.0.0.1", @server.port)
169
+ def test_408_carries_cors_header_when_cors_enabled
170
+ start_server(read_timeout: 0.1, cors: true)
172
171
 
173
- started = Time.now
174
- response = get("/x.txt")
175
- elapsed = Time.now - started
172
+ socket = TCPSocket.open("127.0.0.1", @server.port)
173
+ response = socket.read
176
174
 
177
- assert_equal "200", response.code
178
- assert_equal "ok", response.body
179
- assert_operator elapsed, :<, 1.0, "request should not be serialized behind slow client"
175
+ assert_includes response, "HTTP/1.1 408"
176
+ assert_includes response, "Access-Control-Allow-Origin: *"
180
177
  ensure
181
- slow_socket&.close
178
+ socket&.close
182
179
  end
183
180
 
184
- def test_warns_when_binding_to_non_loopback
185
- err = StringIO.new
186
- server = Wsv::Server.new(host: "0.0.0.0", port: 0, root: @dir, out: StringIO.new, err: err)
187
- server.send(:log_startup)
181
+ def test_414_carries_cors_header_when_cors_enabled
182
+ start_server(cors: true)
183
+
184
+ long_path = "/" + ("a" * 9000)
185
+ socket = TCPSocket.open("127.0.0.1", @server.port)
186
+ socket.write("GET #{long_path} HTTP/1.1\r\nHost: localhost\r\n\r\n")
187
+ response = socket.read
188
188
 
189
- assert_includes err.string, "WARNING"
190
- assert_includes err.string, "0.0.0.0"
189
+ assert_includes response, "HTTP/1.1 414"
190
+ assert_includes response, "Access-Control-Allow-Origin: *"
191
+ ensure
192
+ socket&.close
191
193
  end
192
194
 
193
- def test_no_warning_for_loopback_bind
194
- err = StringIO.new
195
- server = Wsv::Server.new(host: "127.0.0.1", port: 0, root: @dir, out: StringIO.new, err: err)
196
- server.send(:log_startup)
195
+ def test_200_carries_cors_header_when_cors_enabled
196
+ File.write(File.join(@dir, "x.txt"), "hi")
197
+ start_server(cors: true)
198
+
199
+ response = get("/x.txt")
197
200
 
198
- refute_includes err.string, "WARNING"
201
+ assert_equal "200", response.code
202
+ assert_equal "*", response["access-control-allow-origin"]
203
+ assert_equal "Origin", response["vary"]
199
204
  end
200
205
 
201
- def test_banner_brackets_ipv6_address_in_url
202
- out = StringIO.new
203
- server = Wsv::Server.new(host: "::1", port: 8000, root: @dir, out: out, err: StringIO.new)
204
- server.send(:log_startup)
206
+ def test_options_preflight_carries_cors_headers_when_cors_enabled
207
+ start_server(cors: true)
208
+
209
+ socket = TCPSocket.open("127.0.0.1", @server.port)
210
+ socket.write("OPTIONS /x.txt HTTP/1.1\r\nHost: localhost\r\n" \
211
+ "Access-Control-Request-Method: GET\r\n\r\n")
212
+ response = socket.read
205
213
 
206
- assert_includes out.string, "http://[::1]:8000/"
207
- refute_includes out.string, "http://::1:8000/"
214
+ assert_includes response, "HTTP/1.1 204"
215
+ assert_includes response, "Access-Control-Allow-Origin: *"
216
+ assert_includes response, "Access-Control-Allow-Methods: GET, HEAD, OPTIONS"
217
+ assert_includes response, "Vary: Origin"
218
+ ensure
219
+ socket&.close
208
220
  end
209
221
 
210
- def test_banner_percent_encodes_ipv6_zone_identifier
211
- out = StringIO.new
212
- server = Wsv::Server.new(host: "fe80::1%eth0", port: 8000, root: @dir, out: out, err: StringIO.new)
213
- server.send(:log_startup)
222
+ def test_slow_client_does_not_block_other_clients
223
+ File.write(File.join(@dir, "x.txt"), "ok")
224
+ start_server(read_timeout: 5)
225
+
226
+ slow_socket = TCPSocket.open("127.0.0.1", @server.port)
227
+
228
+ started = Time.now
229
+ response = get("/x.txt")
230
+ elapsed = Time.now - started
214
231
 
215
- assert_includes out.string, "http://[fe80::1%25eth0]:8000/"
232
+ assert_equal "200", response.code
233
+ assert_equal "ok", response.body
234
+ assert_operator elapsed, :<, 1.0, "request should not be serialized behind slow client"
235
+ ensure
236
+ slow_socket&.close
216
237
  end
217
238
 
218
239
  def test_accept_loop_survives_transient_accept_error
@@ -272,7 +293,7 @@ class ServerTest < Minitest::Test
272
293
 
273
294
  def test_serves_over_tls
274
295
  File.write(File.join(@dir, "x.txt"), "secret")
275
- start_server(tls: build_ephemeral_tls)
296
+ start_server(tls: ephemeral_tls)
276
297
 
277
298
  response = Net::HTTP.start("127.0.0.1", @server.port, use_ssl: true,
278
299
  verify_mode: OpenSSL::SSL::VERIFY_NONE) do |http|
@@ -283,24 +304,6 @@ class ServerTest < Minitest::Test
283
304
  assert_equal "secret", response.body
284
305
  end
285
306
 
286
- def test_logs_https_scheme_when_tls_enabled
287
- out = StringIO.new
288
- @server = Wsv::Server.new(host: "127.0.0.1", port: 0, root: @dir,
289
- out: out, err: StringIO.new, tls: build_ephemeral_tls)
290
- @server.send(:log_startup)
291
-
292
- assert_includes out.string, "https://"
293
- end
294
-
295
- def test_warns_about_self_signed_cert
296
- err = StringIO.new
297
- @server = Wsv::Server.new(host: "127.0.0.1", port: 0, root: @dir,
298
- out: StringIO.new, err: err, tls: build_ephemeral_tls)
299
- @server.send(:log_startup)
300
-
301
- assert_includes err.string, "self-signed"
302
- end
303
-
304
307
  def test_unsupported_method
305
308
  start_server
306
309
 
@@ -312,7 +315,7 @@ class ServerTest < Minitest::Test
312
315
 
313
316
  private
314
317
 
315
- def start_server(read_timeout: Wsv::Server::DEFAULT_READ_TIMEOUT, tls: nil)
318
+ def start_server(read_timeout: Wsv::Server::DEFAULT_READ_TIMEOUT, tls: nil, cors: false)
316
319
  @server = Wsv::Server.new(
317
320
  host: "127.0.0.1",
318
321
  port: free_port,
@@ -320,39 +323,32 @@ class ServerTest < Minitest::Test
320
323
  out: StringIO.new,
321
324
  err: StringIO.new,
322
325
  read_timeout: read_timeout,
323
- tls: tls
326
+ tls: tls,
327
+ cors: cors
324
328
  )
325
329
  @thread = Thread.new { @server.start }
326
330
  wait_until_ready
327
331
  end
328
332
 
333
+ # Wrap accept_loop so the very first call to @server.accept raises a
334
+ # transient error. Avoids redefining Server#start, which would silently
335
+ # drift if start grew new steps (open_in_browser, etc.).
329
336
  def inject_one_accept_error(server, error_class)
337
+ original_accept_loop = server.method(:accept_loop)
330
338
  fired = false
331
- server.define_singleton_method(:start) do
332
- @server = TCPServer.new(host, port)
333
- original = @server.method(:accept)
339
+ server.define_singleton_method(:accept_loop) do
340
+ original_accept = @server.method(:accept)
334
341
  @server.define_singleton_method(:accept) do
335
342
  unless fired
336
343
  fired = true
337
344
  raise error_class, "injected"
338
345
  end
339
- original.call
346
+ original_accept.call
340
347
  end
341
- @running = true
342
- log_startup
343
- trap_signals
344
- accept_loop
345
- ensure
346
- close
348
+ original_accept_loop.call
347
349
  end
348
350
  end
349
351
 
350
- def build_ephemeral_tls
351
- key = OpenSSL::PKey::RSA.new(2048)
352
- cert = Wsv::TlsContext::SelfSignedCert.build(key)
353
- Wsv::TlsContext.new(cert: cert, key: key, ephemeral: true)
354
- end
355
-
356
352
  def free_port
357
353
  server = TCPServer.new("127.0.0.1", 0)
358
354
  server.addr[1]
data/test/test_helper.rb CHANGED
@@ -7,3 +7,11 @@ require "fileutils"
7
7
  require "stringio"
8
8
  require "tmpdir"
9
9
  require "wsv"
10
+
11
+ module TlsTestHelpers
12
+ def ephemeral_tls
13
+ key = OpenSSL::PKey::RSA.new(2048)
14
+ cert = Wsv::TlsContext::SelfSignedCert.build(key)
15
+ Wsv::TlsContext.new(cert: cert, key: key, ephemeral: true)
16
+ end
17
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wsv
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.1
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - takahashim
@@ -29,6 +29,7 @@ files:
29
29
  - lib/wsv/cors.rb
30
30
  - lib/wsv/mime_types.rb
31
31
  - lib/wsv/path_resolver.rb
32
+ - lib/wsv/range_request.rb
32
33
  - lib/wsv/request.rb
33
34
  - lib/wsv/request/parser.rb
34
35
  - lib/wsv/request/too_large.rb
@@ -50,9 +51,12 @@ files:
50
51
  - lib/wsv/tls_context/self_signed_cert.rb
51
52
  - lib/wsv/version.rb
52
53
  - test/app_test.rb
54
+ - test/banner_test.rb
53
55
  - test/browser_launcher_test.rb
54
56
  - test/cli_test.rb
57
+ - test/cors_test.rb
55
58
  - test/path_resolver_test.rb
59
+ - test/range_request_test.rb
56
60
  - test/request_test.rb
57
61
  - test/response_test.rb
58
62
  - test/server_test.rb