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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +65 -11
- data/lib/wsv/app.rb +39 -4
- data/lib/wsv/cli.rb +49 -3
- data/lib/wsv/cors.rb +30 -0
- data/lib/wsv/path_resolver.rb +6 -0
- data/lib/wsv/request/parser.rb +2 -2
- data/lib/wsv/request/too_large.rb +18 -0
- data/lib/wsv/request.rb +1 -9
- data/lib/wsv/response/file_builder.rb +3 -2
- data/lib/wsv/response.rb +9 -0
- data/lib/wsv/server/banner.rb +56 -0
- data/lib/wsv/server/browser_launcher.rb +63 -0
- data/lib/wsv/server/deadline_reader.rb +23 -0
- data/lib/wsv/server.rb +62 -36
- data/lib/wsv/status.rb +1 -0
- data/lib/wsv/tls_context/resolver.rb +71 -0
- data/lib/wsv/tls_context/self_signed_cert.rb +38 -0
- data/lib/wsv/tls_context.rb +29 -0
- data/lib/wsv/version.rb +1 -1
- data/lib/wsv.rb +2 -0
- data/test/app_test.rb +194 -0
- data/test/browser_launcher_test.rb +57 -0
- data/test/cli_test.rb +64 -0
- data/test/response_test.rb +8 -0
- data/test/server_test.rb +57 -2
- data/test/tls_context_test.rb +169 -0
- metadata +12 -2
data/test/response_test.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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:
|