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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +19 -13
- data/lib/wsv/app.rb +12 -56
- data/lib/wsv/cors.rb +7 -5
- data/lib/wsv/range_request.rb +91 -0
- data/lib/wsv/response/file_builder.rb +4 -9
- data/lib/wsv/server/banner.rb +3 -6
- data/lib/wsv/server/browser_launcher.rb +2 -8
- data/lib/wsv/server/connection.rb +104 -0
- data/lib/wsv/server/connection_throttle.rb +50 -0
- data/lib/wsv/server/url_host.rb +16 -0
- data/lib/wsv/server.rb +17 -107
- data/lib/wsv/version.rb +1 -1
- data/lib/wsv.rb +1 -0
- data/test/app_test.rb +12 -29
- data/test/banner_test.rb +59 -0
- data/test/cors_test.rb +76 -0
- data/test/range_request_test.rb +103 -0
- data/test/server_test.rb +67 -71
- data/test/test_helper.rb +8 -0
- data/wsv.gemspec +7 -6
- metadata +16 -8
- /data/{bin → exe}/wsv +0 -0
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 "
|
|
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/
|
|
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
|
-
@
|
|
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 = @
|
|
161
|
-
|
|
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
|
|
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
|
-
|
|
107
|
+
Connection.new(client, err: @err, cors: @cors).reject(reply: reply)
|
|
189
108
|
end
|
|
190
109
|
rescue ThreadError
|
|
191
|
-
|
|
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, `
|
|
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
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
355
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
data/test/banner_test.rb
ADDED
|
@@ -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
|