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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE.txt +21 -0
- data/README.md +97 -0
- data/bin/wsv +6 -0
- data/lib/wsv/app.rb +38 -0
- data/lib/wsv/cli.rb +88 -0
- data/lib/wsv/mime_types.rb +32 -0
- data/lib/wsv/path_resolver.rb +111 -0
- data/lib/wsv/request.rb +68 -0
- data/lib/wsv/response.rb +67 -0
- data/lib/wsv/server.rb +217 -0
- data/lib/wsv/status.rb +22 -0
- data/lib/wsv/version.rb +5 -0
- data/lib/wsv.rb +14 -0
- data/test/app_test.rb +77 -0
- data/test/cli_test.rb +71 -0
- data/test/path_resolver_test.rb +183 -0
- data/test/request_test.rb +61 -0
- data/test/response_test.rb +48 -0
- data/test/server_test.rb +310 -0
- data/test/test_helper.rb +9 -0
- data/wsv.gemspec +36 -0
- metadata +67 -0
data/test/server_test.rb
ADDED
|
@@ -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
|
data/test/test_helper.rb
ADDED
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: []
|