wsv 0.8.0 → 0.10.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.
@@ -127,6 +127,41 @@ class PathResolverTest < Minitest::Test
127
127
  assert_equal 400, result.status
128
128
  end
129
129
 
130
+ def test_returns_400_for_nul_byte_in_path
131
+ result = @resolver.resolve("/foo%00bar")
132
+
133
+ assert_predicate result, :error?
134
+ assert_equal 400, result.status
135
+ end
136
+
137
+ def test_returns_400_for_cr_in_path
138
+ result = @resolver.resolve("/foo%0Dbar")
139
+
140
+ assert_predicate result, :error?
141
+ assert_equal 400, result.status
142
+ end
143
+
144
+ def test_returns_400_for_lf_in_path
145
+ result = @resolver.resolve("/foo%0Abar")
146
+
147
+ assert_predicate result, :error?
148
+ assert_equal 400, result.status
149
+ end
150
+
151
+ def test_returns_400_for_tab_in_path
152
+ result = @resolver.resolve("/foo%09bar")
153
+
154
+ assert_predicate result, :error?
155
+ assert_equal 400, result.status
156
+ end
157
+
158
+ def test_returns_400_for_del_in_path
159
+ result = @resolver.resolve("/foo%7Fbar")
160
+
161
+ assert_predicate result, :error?
162
+ assert_equal 400, result.status
163
+ end
164
+
130
165
  def test_rejects_symlink_to_dotfile
131
166
  File.write(File.join(@dir, ".env"), "secret")
132
167
  File.symlink(".env", File.join(@dir, "config"))
@@ -45,4 +45,53 @@ class ResponseTest < Minitest::Test
45
45
 
46
46
  assert_includes io.string, "HTTP/1.1 404 Not Found"
47
47
  end
48
+
49
+ def test_write_to_emits_nosniff_header
50
+ response = Wsv::Response.text(200)
51
+ io = StringIO.new
52
+ response.write_to(io)
53
+
54
+ assert_includes io.string, "X-Content-Type-Options: nosniff"
55
+ end
56
+
57
+ def test_file_response_streams_via_io_copy_stream
58
+ Dir.mktmpdir do |dir|
59
+ path = File.join(dir, "data.bin")
60
+ File.binwrite(path, "abcdefghij")
61
+
62
+ response = Wsv::Response.file(path)
63
+ io = StringIO.new
64
+ response.write_to(io)
65
+
66
+ assert_includes io.string, "HTTP/1.1 200 OK"
67
+ assert io.string.end_with?("abcdefghij"), "expected file bytes at end of response"
68
+ end
69
+ end
70
+
71
+ def test_file_response_does_not_eagerly_read_file
72
+ Dir.mktmpdir do |dir|
73
+ path = File.join(dir, "data.bin")
74
+ File.binwrite(path, "abcdefghij")
75
+
76
+ response = Wsv::Response.file(path)
77
+ File.delete(path)
78
+
79
+ assert_equal 200, response.status
80
+ assert_equal "10", response.headers["Content-Length"]
81
+ end
82
+ end
83
+
84
+ def test_file_response_streams_byte_range
85
+ Dir.mktmpdir do |dir|
86
+ path = File.join(dir, "data.bin")
87
+ File.binwrite(path, "abcdefghij")
88
+
89
+ response = Wsv::Response.file(path, range: 2..5)
90
+ io = StringIO.new
91
+ response.write_to(io)
92
+
93
+ assert_includes io.string, "HTTP/1.1 206 Partial Content"
94
+ assert io.string.end_with?("cdef"), "expected only the requested range"
95
+ end
96
+ end
48
97
  end
data/test/server_test.rb CHANGED
@@ -198,6 +198,23 @@ class ServerTest < Minitest::Test
198
198
  refute_includes err.string, "WARNING"
199
199
  end
200
200
 
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)
205
+
206
+ assert_includes out.string, "http://[::1]:8000/"
207
+ refute_includes out.string, "http://::1:8000/"
208
+ end
209
+
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)
214
+
215
+ assert_includes out.string, "http://[fe80::1%25eth0]:8000/"
216
+ end
217
+
201
218
  def test_accept_loop_survives_transient_accept_error
202
219
  File.write(File.join(@dir, "x.txt"), "ok")
203
220
  err = StringIO.new
@@ -226,6 +243,64 @@ class ServerTest < Minitest::Test
226
243
  socket&.close
227
244
  end
228
245
 
246
+ def test_serves_byte_range_e2e
247
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
248
+ start_server
249
+
250
+ uri = URI("http://127.0.0.1:#{@server.port}/data.bin")
251
+ response = Net::HTTP.start(uri.host, uri.port) do |http|
252
+ http.get(uri.path, "Range" => "bytes=2-5")
253
+ end
254
+
255
+ assert_equal "206", response.code
256
+ assert_equal "cdef", response.body
257
+ assert_equal "bytes 2-5/10", response["content-range"]
258
+ end
259
+
260
+ def test_returns_304_when_if_modified_since_matches_e2e
261
+ path = File.join(@dir, "x.txt")
262
+ File.write(path, "hi")
263
+ start_server
264
+
265
+ uri = URI("http://127.0.0.1:#{@server.port}/x.txt")
266
+ response = Net::HTTP.start(uri.host, uri.port) do |http|
267
+ http.get(uri.path, "If-Modified-Since" => File.mtime(path).httpdate)
268
+ end
269
+
270
+ assert_equal "304", response.code
271
+ end
272
+
273
+ def test_serves_over_tls
274
+ File.write(File.join(@dir, "x.txt"), "secret")
275
+ start_server(tls: build_ephemeral_tls)
276
+
277
+ response = Net::HTTP.start("127.0.0.1", @server.port, use_ssl: true,
278
+ verify_mode: OpenSSL::SSL::VERIFY_NONE) do |http|
279
+ http.get("/x.txt")
280
+ end
281
+
282
+ assert_equal "200", response.code
283
+ assert_equal "secret", response.body
284
+ end
285
+
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
+
229
304
  def test_unsupported_method
230
305
  start_server
231
306
 
@@ -237,14 +312,15 @@ class ServerTest < Minitest::Test
237
312
 
238
313
  private
239
314
 
240
- def start_server(read_timeout: Wsv::Server::DEFAULT_READ_TIMEOUT)
315
+ def start_server(read_timeout: Wsv::Server::DEFAULT_READ_TIMEOUT, tls: nil)
241
316
  @server = Wsv::Server.new(
242
317
  host: "127.0.0.1",
243
318
  port: free_port,
244
319
  root: @dir,
245
320
  out: StringIO.new,
246
321
  err: StringIO.new,
247
- read_timeout: read_timeout
322
+ read_timeout: read_timeout,
323
+ tls: tls
248
324
  )
249
325
  @thread = Thread.new { @server.start }
250
326
  wait_until_ready
@@ -271,6 +347,12 @@ class ServerTest < Minitest::Test
271
347
  end
272
348
  end
273
349
 
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
+
274
356
  def free_port
275
357
  server = TCPServer.new("127.0.0.1", 0)
276
358
  server.addr[1]
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class TlsContextTest < Minitest::Test
6
+ def test_to_ssl_context_returns_configured_ssl_context
7
+ key = OpenSSL::PKey::RSA.new(2048)
8
+ cert = Wsv::TlsContext::SelfSignedCert.build(key)
9
+ tls = Wsv::TlsContext.new(cert: cert, key: key)
10
+
11
+ ssl = tls.to_ssl_context
12
+
13
+ assert_kind_of OpenSSL::SSL::SSLContext, ssl
14
+ assert_equal cert, ssl.cert
15
+ assert_equal key, ssl.key
16
+ end
17
+
18
+ def test_ephemeral_predicate
19
+ key = OpenSSL::PKey::RSA.new(2048)
20
+ cert = Wsv::TlsContext::SelfSignedCert.build(key)
21
+
22
+ refute_predicate Wsv::TlsContext.new(cert: cert, key: key), :ephemeral?
23
+ assert_predicate Wsv::TlsContext.new(cert: cert, key: key, ephemeral: true), :ephemeral?
24
+ end
25
+ end
26
+
27
+ class SelfSignedCertTest < Minitest::Test
28
+ def test_subject_is_localhost
29
+ cert = build
30
+
31
+ assert_equal "/CN=localhost", cert.subject.to_s
32
+ end
33
+
34
+ def test_san_includes_localhost_and_loopback
35
+ san = build.extensions.find { |ext| ext.oid == "subjectAltName" }.value
36
+
37
+ assert_includes san, "DNS:localhost"
38
+ assert_includes san, "IP Address:127.0.0.1"
39
+ assert_includes san, "IP Address:0:0:0:0:0:0:0:1"
40
+ end
41
+
42
+ def test_signed_with_provided_key
43
+ key = OpenSSL::PKey::RSA.new(2048)
44
+ cert = Wsv::TlsContext::SelfSignedCert.build(key)
45
+
46
+ assert cert.verify(key.public_key)
47
+ end
48
+
49
+ private
50
+
51
+ def build
52
+ Wsv::TlsContext::SelfSignedCert.build(OpenSSL::PKey::RSA.new(2048))
53
+ end
54
+ end
55
+
56
+ class TlsContextResolverTest < Minitest::Test
57
+ def test_resolves_explicit_paths
58
+ Dir.mktmpdir do |dir|
59
+ cert_path, key_path = write_self_signed(dir)
60
+
61
+ tls = Wsv::TlsContext::Resolver.resolve(cert_path: cert_path, key_path: key_path)
62
+
63
+ refute_predicate tls, :ephemeral?
64
+ end
65
+ end
66
+
67
+ def test_requires_both_cert_and_key
68
+ assert_raises(ArgumentError) do
69
+ Wsv::TlsContext::Resolver.resolve(cert_path: "/some/cert.pem", key_path: nil)
70
+ end
71
+
72
+ assert_raises(ArgumentError) do
73
+ Wsv::TlsContext::Resolver.resolve(cert_path: nil, key_path: "/some/key.pem")
74
+ end
75
+ end
76
+
77
+ def test_uses_xdg_when_present
78
+ Dir.mktmpdir do |xdg|
79
+ wsv_dir = File.join(xdg, "wsv")
80
+ FileUtils.mkdir_p(wsv_dir)
81
+ write_self_signed(wsv_dir, cert_name: "cert.pem", key_name: "key.pem")
82
+
83
+ with_env("XDG_CONFIG_HOME" => xdg) do
84
+ tls = Wsv::TlsContext::Resolver.resolve
85
+
86
+ refute_predicate tls, :ephemeral?
87
+ end
88
+ end
89
+ end
90
+
91
+ def test_falls_back_to_ephemeral_when_no_xdg
92
+ Dir.mktmpdir do |xdg|
93
+ with_env("XDG_CONFIG_HOME" => xdg) do
94
+ tls = Wsv::TlsContext::Resolver.resolve
95
+
96
+ assert_predicate tls, :ephemeral?
97
+ end
98
+ end
99
+ end
100
+
101
+ def test_errors_when_only_one_xdg_file_present
102
+ Dir.mktmpdir do |xdg|
103
+ wsv_dir = File.join(xdg, "wsv")
104
+ FileUtils.mkdir_p(wsv_dir)
105
+ File.write(File.join(wsv_dir, "cert.pem"), "stub")
106
+
107
+ with_env("XDG_CONFIG_HOME" => xdg) do
108
+ assert_raises(ArgumentError) { Wsv::TlsContext::Resolver.resolve }
109
+ end
110
+ end
111
+ end
112
+
113
+ def test_raises_openssl_error_for_malformed_cert
114
+ Dir.mktmpdir do |dir|
115
+ _cert_path, key_path = write_self_signed(dir)
116
+ bad_cert = File.join(dir, "bad-cert.pem")
117
+ File.write(bad_cert, "this is not a PEM\n")
118
+
119
+ assert_raises(OpenSSL::X509::CertificateError) do
120
+ Wsv::TlsContext::Resolver.resolve(cert_path: bad_cert, key_path: key_path)
121
+ end
122
+ end
123
+ end
124
+
125
+ def test_raises_openssl_error_for_malformed_key
126
+ Dir.mktmpdir do |dir|
127
+ cert_path, _key_path = write_self_signed(dir)
128
+ bad_key = File.join(dir, "bad-key.pem")
129
+ File.write(bad_key, "this is not a PEM\n")
130
+
131
+ assert_raises(OpenSSL::PKey::PKeyError) do
132
+ Wsv::TlsContext::Resolver.resolve(cert_path: cert_path, key_path: bad_key)
133
+ end
134
+ end
135
+ end
136
+
137
+ def test_raises_argument_error_when_cert_and_key_do_not_match
138
+ Dir.mktmpdir do |dir|
139
+ cert_path, = write_self_signed(dir, cert_name: "a.pem", key_name: "a.key")
140
+ _, foreign_key = write_self_signed(dir, cert_name: "b.pem", key_name: "b.key")
141
+
142
+ err = assert_raises(ArgumentError) do
143
+ Wsv::TlsContext::Resolver.resolve(cert_path: cert_path, key_path: foreign_key)
144
+ end
145
+
146
+ assert_includes err.message, "does not match"
147
+ end
148
+ end
149
+
150
+ private
151
+
152
+ def write_self_signed(dir, cert_name: "test-cert.pem", key_name: "test-key.pem")
153
+ key = OpenSSL::PKey::RSA.new(2048)
154
+ cert = Wsv::TlsContext::SelfSignedCert.build(key)
155
+ cert_path = File.join(dir, cert_name)
156
+ key_path = File.join(dir, key_name)
157
+ File.write(cert_path, cert.to_pem)
158
+ File.write(key_path, key.to_pem)
159
+ [cert_path, key_path]
160
+ end
161
+
162
+ def with_env(overrides)
163
+ original = ENV.to_h
164
+ overrides.each { |k, v| ENV[k] = v }
165
+ yield
166
+ ensure
167
+ ENV.replace(original)
168
+ end
169
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wsv
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - takahashim
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-05-06 00:00:00.000000000 Z
10
+ date: 2026-05-07 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: wsv serves a local directory over HTTP from a zero-config CLI.
13
13
  email:
@@ -24,20 +24,35 @@ files:
24
24
  - lib/wsv.rb
25
25
  - lib/wsv/app.rb
26
26
  - lib/wsv/cli.rb
27
+ - lib/wsv/cors.rb
27
28
  - lib/wsv/mime_types.rb
28
29
  - lib/wsv/path_resolver.rb
29
30
  - lib/wsv/request.rb
31
+ - lib/wsv/request/parser.rb
32
+ - lib/wsv/request/too_large.rb
30
33
  - lib/wsv/response.rb
34
+ - lib/wsv/response/file_body.rb
35
+ - lib/wsv/response/file_builder.rb
36
+ - lib/wsv/response/string_body.rb
37
+ - lib/wsv/response/text_builder.rb
31
38
  - lib/wsv/server.rb
39
+ - lib/wsv/server/banner.rb
40
+ - lib/wsv/server/browser_launcher.rb
41
+ - lib/wsv/server/deadline_reader.rb
32
42
  - lib/wsv/status.rb
43
+ - lib/wsv/tls_context.rb
44
+ - lib/wsv/tls_context/resolver.rb
45
+ - lib/wsv/tls_context/self_signed_cert.rb
33
46
  - lib/wsv/version.rb
34
47
  - test/app_test.rb
48
+ - test/browser_launcher_test.rb
35
49
  - test/cli_test.rb
36
50
  - test/path_resolver_test.rb
37
51
  - test/request_test.rb
38
52
  - test/response_test.rb
39
53
  - test/server_test.rb
40
54
  - test/test_helper.rb
55
+ - test/tls_context_test.rb
41
56
  - wsv.gemspec
42
57
  homepage: https://rubygems.org/gems/wsv
43
58
  licenses: