wsv 0.9.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.
@@ -46,6 +46,14 @@ class ResponseTest < Minitest::Test
46
46
  assert_includes io.string, "HTTP/1.1 404 Not Found"
47
47
  end
48
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
+
49
57
  def test_file_response_streams_via_io_copy_stream
50
58
  Dir.mktmpdir do |dir|
51
59
  path = File.join(dir, "data.bin")
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
@@ -253,6 +270,37 @@ class ServerTest < Minitest::Test
253
270
  assert_equal "304", response.code
254
271
  end
255
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
+
256
304
  def test_unsupported_method
257
305
  start_server
258
306
 
@@ -264,14 +312,15 @@ class ServerTest < Minitest::Test
264
312
 
265
313
  private
266
314
 
267
- def start_server(read_timeout: Wsv::Server::DEFAULT_READ_TIMEOUT)
315
+ def start_server(read_timeout: Wsv::Server::DEFAULT_READ_TIMEOUT, tls: nil)
268
316
  @server = Wsv::Server.new(
269
317
  host: "127.0.0.1",
270
318
  port: free_port,
271
319
  root: @dir,
272
320
  out: StringIO.new,
273
321
  err: StringIO.new,
274
- read_timeout: read_timeout
322
+ read_timeout: read_timeout,
323
+ tls: tls
275
324
  )
276
325
  @thread = Thread.new { @server.start }
277
326
  wait_until_ready
@@ -298,6 +347,12 @@ class ServerTest < Minitest::Test
298
347
  end
299
348
  end
300
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
+
301
356
  def free_port
302
357
  server = TCPServer.new("127.0.0.1", 0)
303
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.9.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,25 +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
30
31
  - lib/wsv/request/parser.rb
32
+ - lib/wsv/request/too_large.rb
31
33
  - lib/wsv/response.rb
32
34
  - lib/wsv/response/file_body.rb
33
35
  - lib/wsv/response/file_builder.rb
34
36
  - lib/wsv/response/string_body.rb
35
37
  - lib/wsv/response/text_builder.rb
36
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
37
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
38
46
  - lib/wsv/version.rb
39
47
  - test/app_test.rb
48
+ - test/browser_launcher_test.rb
40
49
  - test/cli_test.rb
41
50
  - test/path_resolver_test.rb
42
51
  - test/request_test.rb
43
52
  - test/response_test.rb
44
53
  - test/server_test.rb
45
54
  - test/test_helper.rb
55
+ - test/tls_context_test.rb
46
56
  - wsv.gemspec
47
57
  homepage: https://rubygems.org/gems/wsv
48
58
  licenses: