wsv 0.8.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.
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "socket"
5
+ require_relative "test_helper"
6
+
7
+ class ServerTest < Minitest::Test
8
+ def setup
9
+ @dir = Dir.mktmpdir
10
+ @server = nil
11
+ @thread = nil
12
+ end
13
+
14
+ def teardown
15
+ @server&.stop
16
+ @thread&.join(2)
17
+ FileUtils.remove_entry(@dir)
18
+ end
19
+
20
+ def test_serves_index
21
+ File.write(File.join(@dir, "index.html"), "<h1>Hello</h1>")
22
+ start_server
23
+
24
+ response = get("/")
25
+
26
+ assert_equal "200", response.code
27
+ assert_equal "<h1>Hello</h1>", response.body
28
+ assert_equal "text/html; charset=utf-8", response["content-type"]
29
+ assert_equal "14", response["content-length"]
30
+ assert_equal "no-cache", response["cache-control"]
31
+ end
32
+
33
+ def test_serves_file_with_mime_type
34
+ File.write(File.join(@dir, "app.js"), "console.log('ok');")
35
+ start_server
36
+
37
+ response = get("/app.js")
38
+
39
+ assert_equal "200", response.code
40
+ assert_equal "console.log('ok');", response.body
41
+ assert_equal "text/javascript; charset=utf-8", response["content-type"]
42
+ assert_equal "18", response["content-length"]
43
+ end
44
+
45
+ def test_head_has_headers_without_body
46
+ File.write(File.join(@dir, "style.css"), "body{}")
47
+ start_server
48
+
49
+ response = head("/style.css")
50
+
51
+ assert_equal "200", response.code
52
+ assert_nil response.body
53
+ assert_equal "6", response["content-length"]
54
+ end
55
+
56
+ def test_directory_without_index_is_not_listed
57
+ FileUtils.mkdir_p(File.join(@dir, "assets"))
58
+ start_server
59
+
60
+ response = get("/assets/")
61
+
62
+ assert_equal "404", response.code
63
+ assert_includes response.body, "404 Not Found"
64
+ end
65
+
66
+ def test_redirects_directory_without_trailing_slash
67
+ FileUtils.mkdir_p(File.join(@dir, "docs"))
68
+ File.write(File.join(@dir, "docs", "index.html"), "docs")
69
+ start_server
70
+
71
+ response = get("/docs")
72
+
73
+ assert_equal "301", response.code
74
+ assert_equal "/docs/", response["location"]
75
+ end
76
+
77
+ def test_rejects_path_traversal
78
+ secret = File.join(File.dirname(@dir), "wsv-secret-#{$$}.txt")
79
+ File.write(secret, "secret")
80
+ start_server
81
+
82
+ response = get("/../#{File.basename(secret)}")
83
+
84
+ assert_equal "403", response.code
85
+ ensure
86
+ FileUtils.rm_f(secret) if secret
87
+ end
88
+
89
+ def test_rejects_dotfile_at_root
90
+ File.write(File.join(@dir, ".env"), "API_KEY=secret")
91
+ start_server
92
+
93
+ response = get("/.env")
94
+
95
+ assert_equal "403", response.code
96
+ end
97
+
98
+ def test_rejects_dotfile_in_subdir
99
+ FileUtils.mkdir_p(File.join(@dir, "sub"))
100
+ File.write(File.join(@dir, "sub", ".secret"), "secret")
101
+ start_server
102
+
103
+ response = get("/sub/.secret")
104
+
105
+ assert_equal "403", response.code
106
+ end
107
+
108
+ def test_rejects_dot_directory
109
+ FileUtils.mkdir_p(File.join(@dir, ".git"))
110
+ File.write(File.join(@dir, ".git", "config"), "[remote]")
111
+ start_server
112
+
113
+ response = get("/.git/config")
114
+
115
+ assert_equal "403", response.code
116
+ end
117
+
118
+ def test_rejects_url_encoded_traversal
119
+ secret = File.join(File.dirname(@dir), "wsv-secret-#{$$}.txt")
120
+ File.write(secret, "secret")
121
+ start_server
122
+
123
+ response = get("/%2e%2e/#{File.basename(secret)}")
124
+
125
+ assert_equal "403", response.code
126
+ ensure
127
+ FileUtils.rm_f(secret) if secret
128
+ end
129
+
130
+ def test_returns_414_for_too_long_request_line
131
+ start_server
132
+
133
+ long_path = "/" + ("a" * 9000)
134
+ socket = TCPSocket.open("127.0.0.1", @server.port)
135
+ socket.write("GET #{long_path} HTTP/1.1\r\nHost: localhost\r\n\r\n")
136
+ response = socket.read
137
+
138
+ assert_includes response, "HTTP/1.1 414"
139
+ ensure
140
+ socket&.close
141
+ end
142
+
143
+ def test_returns_431_for_too_large_headers
144
+ start_server
145
+
146
+ socket = TCPSocket.open("127.0.0.1", @server.port)
147
+ big = "X-Long: " + ("z" * 9000) + "\r\n"
148
+ socket.write("GET / HTTP/1.1\r\n#{big}\r\n")
149
+ response = socket.read
150
+
151
+ assert_includes response, "HTTP/1.1 431"
152
+ ensure
153
+ socket&.close
154
+ end
155
+
156
+ def test_returns_408_for_idle_client
157
+ start_server(read_timeout: 0.1)
158
+
159
+ socket = TCPSocket.open("127.0.0.1", @server.port)
160
+ response = socket.read
161
+
162
+ assert_includes response, "HTTP/1.1 408"
163
+ ensure
164
+ socket&.close
165
+ end
166
+
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)
172
+
173
+ started = Time.now
174
+ response = get("/x.txt")
175
+ elapsed = Time.now - started
176
+
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"
180
+ ensure
181
+ slow_socket&.close
182
+ end
183
+
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)
188
+
189
+ assert_includes err.string, "WARNING"
190
+ assert_includes err.string, "0.0.0.0"
191
+ end
192
+
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)
197
+
198
+ refute_includes err.string, "WARNING"
199
+ end
200
+
201
+ def test_accept_loop_survives_transient_accept_error
202
+ File.write(File.join(@dir, "x.txt"), "ok")
203
+ err = StringIO.new
204
+ @server = Wsv::Server.new(host: "127.0.0.1", port: free_port, root: @dir, out: StringIO.new, err: err)
205
+ inject_one_accept_error(@server, Errno::ECONNABORTED)
206
+ @thread = Thread.new { @server.start }
207
+ wait_until_ready
208
+
209
+ response = get("/x.txt")
210
+
211
+ assert_equal "200", response.code
212
+ assert_includes err.string, "accept error"
213
+ assert_includes err.string, "ECONNABORTED"
214
+ end
215
+
216
+ def test_drains_request_body_for_unsupported_method
217
+ start_server
218
+
219
+ body = "X" * 10_000
220
+ socket = TCPSocket.open("127.0.0.1", @server.port)
221
+ socket.write("POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
222
+ response = socket.read
223
+
224
+ assert_includes response, "HTTP/1.1 405"
225
+ ensure
226
+ socket&.close
227
+ end
228
+
229
+ def test_unsupported_method
230
+ start_server
231
+
232
+ response = raw_request("POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n")
233
+
234
+ assert_includes response, "HTTP/1.1 405 Method Not Allowed"
235
+ assert_includes response, "Allow: GET, HEAD"
236
+ end
237
+
238
+ private
239
+
240
+ def start_server(read_timeout: Wsv::Server::DEFAULT_READ_TIMEOUT)
241
+ @server = Wsv::Server.new(
242
+ host: "127.0.0.1",
243
+ port: free_port,
244
+ root: @dir,
245
+ out: StringIO.new,
246
+ err: StringIO.new,
247
+ read_timeout: read_timeout
248
+ )
249
+ @thread = Thread.new { @server.start }
250
+ wait_until_ready
251
+ end
252
+
253
+ def inject_one_accept_error(server, error_class)
254
+ fired = false
255
+ server.define_singleton_method(:start) do
256
+ @server = TCPServer.new(host, port)
257
+ original = @server.method(:accept)
258
+ @server.define_singleton_method(:accept) do
259
+ unless fired
260
+ fired = true
261
+ raise error_class, "injected"
262
+ end
263
+ original.call
264
+ end
265
+ @running = true
266
+ log_startup
267
+ trap_signals
268
+ accept_loop
269
+ ensure
270
+ close
271
+ end
272
+ end
273
+
274
+ def free_port
275
+ server = TCPServer.new("127.0.0.1", 0)
276
+ server.addr[1]
277
+ ensure
278
+ server&.close
279
+ end
280
+
281
+ def wait_until_ready
282
+ deadline = Time.now + 2
283
+ loop do
284
+ TCPSocket.open("127.0.0.1", @server.port).close
285
+ break
286
+ rescue Errno::ECONNREFUSED
287
+ raise if Time.now >= deadline
288
+
289
+ sleep 0.01
290
+ end
291
+ end
292
+
293
+ def get(path)
294
+ Net::HTTP.get_response(URI("http://127.0.0.1:#{@server.port}#{path}"))
295
+ end
296
+
297
+ def head(path)
298
+ Net::HTTP.start("127.0.0.1", @server.port) do |http|
299
+ http.head(path)
300
+ end
301
+ end
302
+
303
+ def raw_request(request)
304
+ socket = TCPSocket.open("127.0.0.1", @server.port)
305
+ socket.write(request)
306
+ socket.read
307
+ ensure
308
+ socket&.close
309
+ end
310
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+
5
+ require "minitest/autorun"
6
+ require "fileutils"
7
+ require "stringio"
8
+ require "tmpdir"
9
+ require "wsv"
data/wsv.gemspec ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/wsv/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "wsv"
7
+ spec.version = Wsv::VERSION
8
+ spec.authors = ["takahashim"]
9
+ spec.email = ["takahashimm@gmail.com"]
10
+
11
+ spec.summary = "A tiny static web server for local previews."
12
+ spec.description = "wsv serves a local directory over HTTP from a zero-config CLI."
13
+ spec.homepage = "https://rubygems.org/gems/wsv"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.2"
16
+
17
+ spec.metadata = {
18
+ "homepage_uri" => spec.homepage,
19
+ "source_code_uri" => "https://github.com/takahashim/wsv",
20
+ "changelog_uri" => "https://github.com/takahashim/wsv/blob/main/CHANGELOG.md",
21
+ "rubygems_mfa_required" => "true"
22
+ }
23
+
24
+ spec.files = Dir[
25
+ "CHANGELOG.md",
26
+ "LICENSE.txt",
27
+ "README.md",
28
+ "bin/wsv",
29
+ "lib/**/*.rb",
30
+ "test/**/*.rb",
31
+ "wsv.gemspec"
32
+ ]
33
+ spec.bindir = "bin"
34
+ spec.executables = ["wsv"]
35
+ spec.require_paths = ["lib"]
36
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wsv
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.8.0
5
+ platform: ruby
6
+ authors:
7
+ - takahashim
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-05-06 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: wsv serves a local directory over HTTP from a zero-config CLI.
13
+ email:
14
+ - takahashimm@gmail.com
15
+ executables:
16
+ - wsv
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE.txt
22
+ - README.md
23
+ - bin/wsv
24
+ - lib/wsv.rb
25
+ - lib/wsv/app.rb
26
+ - lib/wsv/cli.rb
27
+ - lib/wsv/mime_types.rb
28
+ - lib/wsv/path_resolver.rb
29
+ - lib/wsv/request.rb
30
+ - lib/wsv/response.rb
31
+ - lib/wsv/server.rb
32
+ - lib/wsv/status.rb
33
+ - lib/wsv/version.rb
34
+ - test/app_test.rb
35
+ - test/cli_test.rb
36
+ - test/path_resolver_test.rb
37
+ - test/request_test.rb
38
+ - test/response_test.rb
39
+ - test/server_test.rb
40
+ - test/test_helper.rb
41
+ - wsv.gemspec
42
+ homepage: https://rubygems.org/gems/wsv
43
+ licenses:
44
+ - MIT
45
+ metadata:
46
+ homepage_uri: https://rubygems.org/gems/wsv
47
+ source_code_uri: https://github.com/takahashim/wsv
48
+ changelog_uri: https://github.com/takahashim/wsv/blob/main/CHANGELOG.md
49
+ rubygems_mfa_required: 'true'
50
+ rdoc_options: []
51
+ require_paths:
52
+ - lib
53
+ required_ruby_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '3.2'
58
+ required_rubygems_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ requirements: []
64
+ rubygems_version: 3.6.2
65
+ specification_version: 4
66
+ summary: A tiny static web server for local previews.
67
+ test_files: []