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.
data/lib/wsv/server.rb ADDED
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require_relative "app"
5
+ require_relative "request"
6
+ require_relative "response"
7
+
8
+ module Wsv
9
+ class Server
10
+ DEFAULT_READ_TIMEOUT = 10
11
+ DEFAULT_MAX_CONNECTIONS = 8
12
+ DRAIN_TIMEOUT = 5
13
+
14
+ attr_reader :host, :port, :root
15
+
16
+ def initialize(
17
+ host:,
18
+ port:,
19
+ root:,
20
+ out: $stdout,
21
+ err: $stderr,
22
+ read_timeout: DEFAULT_READ_TIMEOUT,
23
+ max_connections: DEFAULT_MAX_CONNECTIONS
24
+ )
25
+ @host = host
26
+ @port = port
27
+ @root = File.realpath(root)
28
+ @out = out
29
+ @err = err
30
+ @read_timeout = read_timeout
31
+ @max_connections = max_connections
32
+ @app = App.new(@root)
33
+ @running = false
34
+ @mutex = Mutex.new
35
+ @active = 0
36
+ end
37
+
38
+ def start
39
+ @server = TCPServer.new(host, port)
40
+ @running = true
41
+ log_startup
42
+ trap_signals
43
+ accept_loop
44
+ ensure
45
+ close
46
+ end
47
+
48
+ def stop
49
+ @running = false
50
+ close
51
+ end
52
+
53
+ def handle(client)
54
+ reader = DeadlineReader.new(client, Time.now + @read_timeout)
55
+ request = Request.parse(reader)
56
+ case request
57
+ when :empty
58
+ nil
59
+ when :malformed
60
+ write_response(client, Response.text(400))
61
+ else
62
+ write_response(client, @app.call(request))
63
+ end
64
+ rescue Request::TooLarge => e
65
+ write_response(client, Response.text(e.status_code))
66
+ rescue IO::TimeoutError
67
+ write_response(client, Response.text(408))
68
+ rescue StandardError => e
69
+ @err.puts "wsv: #{e.class}: #{e.message}"
70
+ write_response(client, Response.text(400))
71
+ ensure
72
+ graceful_close(client)
73
+ end
74
+
75
+ private
76
+
77
+ def write_response(client, response)
78
+ return if client.closed?
79
+
80
+ response.write_to(client)
81
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
82
+ nil
83
+ end
84
+
85
+ def graceful_close(client)
86
+ return if client.closed?
87
+
88
+ drain_recv(client)
89
+ rescue StandardError
90
+ nil
91
+ ensure
92
+ begin
93
+ client.close unless client.closed?
94
+ rescue StandardError
95
+ nil
96
+ end
97
+ end
98
+
99
+ def drain_recv(client)
100
+ deadline = Time.now + DRAIN_TIMEOUT
101
+ loop do
102
+ return if Time.now >= deadline
103
+
104
+ chunk = client.read_nonblock(8192, exception: false)
105
+ case chunk
106
+ when nil, :wait_writable
107
+ return
108
+ when :wait_readable
109
+ remaining = deadline - Time.now
110
+ return if remaining <= 0
111
+ return unless client.wait_readable([remaining, 0.2].min)
112
+ end
113
+ end
114
+ end
115
+
116
+ class DeadlineReader
117
+ def initialize(io, deadline)
118
+ @io = io
119
+ @deadline = deadline
120
+ end
121
+
122
+ def gets(limit)
123
+ remaining = @deadline - Time.now
124
+ raise IO::TimeoutError if remaining <= 0
125
+
126
+ @io.timeout = remaining
127
+ @io.gets(limit)
128
+ end
129
+ end
130
+
131
+ def accept_loop
132
+ while @running
133
+ client = nil
134
+ begin
135
+ client = @server.accept
136
+ rescue IOError, Errno::EBADF
137
+ break
138
+ rescue StandardError => e
139
+ @err.puts "wsv: accept error: #{e.class}: #{e.message}"
140
+ sleep 0.05
141
+ next
142
+ end
143
+
144
+ begin
145
+ spawn_handler(client)
146
+ rescue StandardError => e
147
+ @err.puts "wsv: dispatch error: #{e.class}: #{e.message}"
148
+ begin
149
+ client.close
150
+ rescue StandardError
151
+ nil
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ def spawn_handler(client)
158
+ accepted = @mutex.synchronize do
159
+ next false if @active >= @max_connections
160
+
161
+ @active += 1
162
+ true
163
+ end
164
+
165
+ return reject(client) unless accepted
166
+
167
+ Thread.new do
168
+ Thread.current.report_on_exception = false
169
+ handle(client)
170
+ ensure
171
+ @mutex.synchronize { @active -= 1 }
172
+ end
173
+ end
174
+
175
+ def reject(client)
176
+ write_response(client, Response.text(503))
177
+ ensure
178
+ graceful_close(client)
179
+ end
180
+
181
+ def close
182
+ @server&.close unless @server&.closed?
183
+ end
184
+
185
+ def trap_signals
186
+ %w[INT TERM].each do |signal|
187
+ Signal.trap(signal) do
188
+ @out.puts "\nStopping wsv."
189
+ stop
190
+ end
191
+ end
192
+ rescue ArgumentError
193
+ nil
194
+ end
195
+
196
+ def log_startup
197
+ @out.puts "Serving: #{root}"
198
+ @out.puts "Bind: #{url_for(host)}"
199
+ @out.puts "Local: #{url_for('127.0.0.1')}" unless localhost?(host)
200
+ @out.puts "Stop: Ctrl-C"
201
+ warn_public_bind unless localhost?(host)
202
+ end
203
+
204
+ def warn_public_bind
205
+ @err.puts "WARNING: binding to #{host} exposes #{root} on your network."
206
+ @err.puts " Pass --host 127.0.0.1 (or omit --host) for local-only access."
207
+ end
208
+
209
+ def url_for(display_host)
210
+ "http://#{display_host}:#{port}/"
211
+ end
212
+
213
+ def localhost?(display_host)
214
+ ["127.0.0.1", "localhost", "::1"].include?(display_host)
215
+ end
216
+ end
217
+ end
data/lib/wsv/status.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ module Status
5
+ REASONS = {
6
+ 200 => "OK",
7
+ 301 => "Moved Permanently",
8
+ 400 => "Bad Request",
9
+ 403 => "Forbidden",
10
+ 404 => "Not Found",
11
+ 405 => "Method Not Allowed",
12
+ 408 => "Request Timeout",
13
+ 414 => "URI Too Long",
14
+ 431 => "Request Header Fields Too Large",
15
+ 503 => "Service Unavailable"
16
+ }.freeze
17
+
18
+ def self.reason(code)
19
+ REASONS.fetch(code)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ VERSION = "0.8.0"
5
+ end
data/lib/wsv.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "wsv/version"
4
+ require_relative "wsv/status"
5
+ require_relative "wsv/mime_types"
6
+ require_relative "wsv/path_resolver"
7
+ require_relative "wsv/request"
8
+ require_relative "wsv/response"
9
+ require_relative "wsv/app"
10
+ require_relative "wsv/server"
11
+ require_relative "wsv/cli"
12
+
13
+ module Wsv
14
+ end
data/test/app_test.rb ADDED
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class AppTest < Minitest::Test
6
+ def setup
7
+ @dir = Dir.mktmpdir
8
+ @app = Wsv::App.new(File.realpath(@dir))
9
+ end
10
+
11
+ def teardown
12
+ FileUtils.remove_entry(@dir)
13
+ end
14
+
15
+ def test_returns_file_response
16
+ File.write(File.join(@dir, "hello.txt"), "hi")
17
+
18
+ response = @app.call(req("GET", "/hello.txt"))
19
+
20
+ assert_equal 200, response.status
21
+ assert_equal "text/plain; charset=utf-8", response.headers["Content-Type"]
22
+ assert_equal "2", response.headers["Content-Length"]
23
+ assert_equal "hi", response.body
24
+ end
25
+
26
+ def test_method_not_allowed
27
+ response = @app.call(req("POST", "/"))
28
+
29
+ assert_equal 405, response.status
30
+ assert_equal "GET, HEAD", response.headers["Allow"]
31
+ end
32
+
33
+ def test_dotfile_forbidden
34
+ File.write(File.join(@dir, ".env"), "secret")
35
+
36
+ response = @app.call(req("GET", "/.env"))
37
+
38
+ assert_equal 403, response.status
39
+ end
40
+
41
+ def test_path_traversal_forbidden
42
+ response = @app.call(req("GET", "/../etc/passwd"))
43
+
44
+ assert_equal 403, response.status
45
+ end
46
+
47
+ def test_url_encoded_traversal_forbidden
48
+ response = @app.call(req("GET", "/%2e%2e/passwd"))
49
+
50
+ assert_equal 403, response.status
51
+ end
52
+
53
+ def test_redirect_preserves_query
54
+ FileUtils.mkdir_p(File.join(@dir, "docs"))
55
+ File.write(File.join(@dir, "docs", "index.html"), "x")
56
+
57
+ response = @app.call(req("GET", "/docs?q=1"))
58
+
59
+ assert_equal 301, response.status
60
+ assert_equal "/docs/?q=1", response.headers["Location"]
61
+ end
62
+
63
+ def test_head_omits_body_but_keeps_content_length
64
+ File.write(File.join(@dir, "x.txt"), "hi")
65
+
66
+ response = @app.call(req("HEAD", "/x.txt"))
67
+
68
+ assert_equal "", response.body
69
+ assert_equal "2", response.headers["Content-Length"]
70
+ end
71
+
72
+ private
73
+
74
+ def req(method, target)
75
+ Wsv::Request.new(method: method, target: target, version: "HTTP/1.1", headers: {})
76
+ end
77
+ end
data/test/cli_test.rb ADDED
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class CLITest < Minitest::Test
6
+ def test_defaults
7
+ Dir.mktmpdir do |dir|
8
+ Dir.chdir(dir) do
9
+ options = Wsv::CLI.new([]).parse_options([])
10
+
11
+ assert_equal "127.0.0.1", options[:host]
12
+ assert_equal 8000, options[:port]
13
+ assert_equal File.realpath(dir), options[:directory]
14
+ end
15
+ end
16
+ end
17
+
18
+ def test_host_port_and_directory
19
+ Dir.mktmpdir do |dir|
20
+ options = Wsv::CLI.new([]).parse_options(["-h", "127.0.0.1", "-p", "3000", dir])
21
+
22
+ assert_equal "127.0.0.1", options[:host]
23
+ assert_equal 3000, options[:port]
24
+ assert_equal dir, options[:directory]
25
+ end
26
+ end
27
+
28
+ def test_long_host_port_and_directory
29
+ Dir.mktmpdir do |dir|
30
+ options = Wsv::CLI.new([]).parse_options(["--host", "localhost", "--port", "4567", dir])
31
+
32
+ assert_equal "localhost", options[:host]
33
+ assert_equal 4567, options[:port]
34
+ assert_equal dir, options[:directory]
35
+ end
36
+ end
37
+
38
+ def test_help
39
+ out = StringIO.new
40
+ code = Wsv::CLI.new(["--help"], out: out).run
41
+
42
+ assert_equal 0, code
43
+ assert_includes out.string, "Usage: wsv"
44
+ assert_includes out.string, "--host HOST"
45
+ end
46
+
47
+ def test_version
48
+ out = StringIO.new
49
+ code = Wsv::CLI.new(["--version"], out: out).run
50
+
51
+ assert_equal 0, code
52
+ assert_equal "#{Wsv::VERSION}\n", out.string
53
+ end
54
+
55
+ def test_invalid_port
56
+ err = StringIO.new
57
+ code = Wsv::CLI.new(["-p", "0"], err: err).run
58
+
59
+ assert_equal 1, code
60
+ assert_includes err.string, "port must be between 1 and 65535"
61
+ end
62
+
63
+ def test_missing_directory
64
+ err = StringIO.new
65
+ missing = File.join(Dir.tmpdir, "wsv-missing-#{Time.now.to_i}-#{$$}")
66
+ code = Wsv::CLI.new([missing], err: err).run
67
+
68
+ assert_equal 1, code
69
+ assert_includes err.string, "directory does not exist"
70
+ end
71
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class PathResolverTest < Minitest::Test
6
+ def setup
7
+ @dir = Dir.mktmpdir
8
+ @root = File.realpath(@dir)
9
+ @resolver = Wsv::PathResolver.new(@root)
10
+ end
11
+
12
+ def teardown
13
+ FileUtils.remove_entry(@dir)
14
+ end
15
+
16
+ def test_resolves_existing_file
17
+ path = File.join(@dir, "hello.txt")
18
+ File.write(path, "hi")
19
+
20
+ result = @resolver.resolve("/hello.txt")
21
+
22
+ assert_predicate result, :file?
23
+ assert_equal File.realpath(path), result.file
24
+ end
25
+
26
+ def test_returns_404_for_missing_file
27
+ result = @resolver.resolve("/nope.txt")
28
+
29
+ assert_predicate result, :error?
30
+ assert_equal 404, result.status
31
+ end
32
+
33
+ def test_redirects_directory_without_trailing_slash
34
+ FileUtils.mkdir_p(File.join(@dir, "docs"))
35
+ File.write(File.join(@dir, "docs", "index.html"), "x")
36
+
37
+ result = @resolver.resolve("/docs")
38
+
39
+ assert_predicate result, :redirect?
40
+ end
41
+
42
+ def test_serves_index_for_directory_with_trailing_slash
43
+ FileUtils.mkdir_p(File.join(@dir, "docs"))
44
+ index = File.join(@dir, "docs", "index.html")
45
+ File.write(index, "x")
46
+
47
+ result = @resolver.resolve("/docs/")
48
+
49
+ assert_predicate result, :file?
50
+ assert_equal File.realpath(index), result.file
51
+ end
52
+
53
+ def test_directory_without_index_is_404
54
+ FileUtils.mkdir_p(File.join(@dir, "assets"))
55
+
56
+ result = @resolver.resolve("/assets/")
57
+
58
+ assert_predicate result, :error?
59
+ assert_equal 404, result.status
60
+ end
61
+
62
+ def test_rejects_path_traversal
63
+ result = @resolver.resolve("/../etc/passwd")
64
+
65
+ assert_predicate result, :error?
66
+ assert_equal 403, result.status
67
+ end
68
+
69
+ def test_rejects_dotfile_at_root
70
+ File.write(File.join(@dir, ".env"), "secret")
71
+
72
+ result = @resolver.resolve("/.env")
73
+
74
+ assert_predicate result, :error?
75
+ assert_equal 403, result.status
76
+ end
77
+
78
+ def test_rejects_dot_directory
79
+ FileUtils.mkdir_p(File.join(@dir, ".git"))
80
+ File.write(File.join(@dir, ".git", "config"), "x")
81
+
82
+ result = @resolver.resolve("/.git/config")
83
+
84
+ assert_predicate result, :error?
85
+ assert_equal 403, result.status
86
+ end
87
+
88
+ def test_rejects_dotfile_in_subdir
89
+ FileUtils.mkdir_p(File.join(@dir, "sub"))
90
+ File.write(File.join(@dir, "sub", ".secret"), "x")
91
+
92
+ result = @resolver.resolve("/sub/.secret")
93
+
94
+ assert_predicate result, :error?
95
+ assert_equal 403, result.status
96
+ end
97
+
98
+ def test_rejects_url_encoded_traversal
99
+ result = @resolver.resolve("/%2e%2e/etc/passwd")
100
+
101
+ assert_predicate result, :error?
102
+ assert_equal 403, result.status
103
+ end
104
+
105
+ def test_rejects_url_encoded_dotfile
106
+ File.write(File.join(@dir, ".env"), "secret")
107
+
108
+ result = @resolver.resolve("/%2eenv")
109
+
110
+ assert_predicate result, :error?
111
+ assert_equal 403, result.status
112
+ end
113
+
114
+ def test_preserves_literal_plus_in_path
115
+ File.write(File.join(@dir, "foo+bar.txt"), "x")
116
+
117
+ result = @resolver.resolve("/foo+bar.txt")
118
+
119
+ assert_predicate result, :file?
120
+ assert_equal File.realpath(File.join(@dir, "foo+bar.txt")), result.file
121
+ end
122
+
123
+ def test_returns_400_for_invalid_uri
124
+ result = @resolver.resolve("http://[invalid")
125
+
126
+ assert_predicate result, :error?
127
+ assert_equal 400, result.status
128
+ end
129
+
130
+ def test_rejects_symlink_to_dotfile
131
+ File.write(File.join(@dir, ".env"), "secret")
132
+ File.symlink(".env", File.join(@dir, "config"))
133
+
134
+ result = @resolver.resolve("/config")
135
+
136
+ assert_predicate result, :error?
137
+ assert_equal 403, result.status
138
+ end
139
+
140
+ def test_rejects_symlink_to_dot_directory
141
+ FileUtils.mkdir_p(File.join(@dir, ".git"))
142
+ File.write(File.join(@dir, ".git", "HEAD"), "ref")
143
+ File.symlink(".git", File.join(@dir, "gitstuff"))
144
+
145
+ result = @resolver.resolve("/gitstuff/HEAD")
146
+
147
+ assert_predicate result, :error?
148
+ assert_equal 403, result.status
149
+ end
150
+
151
+ def test_allows_internal_symlink_to_regular_file
152
+ File.write(File.join(@dir, "real.txt"), "data")
153
+ File.symlink("real.txt", File.join(@dir, "alias.txt"))
154
+
155
+ result = @resolver.resolve("/alias.txt")
156
+
157
+ assert_predicate result, :file?
158
+ assert_equal File.realpath(File.join(@dir, "real.txt")), result.file
159
+ end
160
+
161
+ def test_handles_symlink_loop
162
+ File.symlink("b", File.join(@dir, "a"))
163
+ File.symlink("a", File.join(@dir, "b"))
164
+
165
+ result = @resolver.resolve("/a")
166
+
167
+ assert_predicate result, :error?
168
+ assert_equal 404, result.status
169
+ end
170
+
171
+ def test_rejects_symlink_outside_root
172
+ outside = File.join(File.dirname(@dir), "wsv-outside-#{$$}")
173
+ File.write(outside, "leaked")
174
+ File.symlink(outside, File.join(@dir, "link"))
175
+
176
+ result = @resolver.resolve("/link")
177
+
178
+ assert_predicate result, :error?
179
+ assert_equal 403, result.status
180
+ ensure
181
+ FileUtils.rm_f(outside) if outside
182
+ end
183
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class RequestTest < Minitest::Test
6
+ def test_parse_simple_get
7
+ io = StringIO.new("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")
8
+
9
+ request = Wsv::Request.parse(io)
10
+
11
+ assert_equal "GET", request.method
12
+ assert_equal "/", request.target
13
+ assert_equal "HTTP/1.1", request.version
14
+ assert_equal "localhost", request.headers["host"]
15
+ end
16
+
17
+ def test_parse_empty_returns_symbol
18
+ assert_equal :empty, Wsv::Request.parse(StringIO.new(""))
19
+ end
20
+
21
+ def test_parse_malformed_returns_symbol
22
+ assert_equal :malformed, Wsv::Request.parse(StringIO.new("garbage line\r\n"))
23
+ end
24
+
25
+ def test_request_line_too_long_raises_414
26
+ long_path = "/#{'a' * 9000}"
27
+ io = StringIO.new("GET #{long_path} HTTP/1.1\r\nHost: x\r\n\r\n")
28
+
29
+ error = assert_raises(Wsv::Request::TooLarge) { Wsv::Request.parse(io) }
30
+
31
+ assert_equal 414, error.status_code
32
+ end
33
+
34
+ def test_too_many_headers_raises_431
35
+ headers = (1..200).map { |i| "X-Custom-#{i}: x\r\n" }.join
36
+ io = StringIO.new("GET / HTTP/1.1\r\n#{headers}\r\n")
37
+
38
+ error = assert_raises(Wsv::Request::TooLarge) { Wsv::Request.parse(io) }
39
+
40
+ assert_equal 431, error.status_code
41
+ end
42
+
43
+ def test_header_total_too_large_raises_431
44
+ big_value = "a" * 5000
45
+ headers = (1..5).map { |i| "X-Big-#{i}: #{big_value}\r\n" }.join
46
+ io = StringIO.new("GET / HTTP/1.1\r\n#{headers}\r\n")
47
+
48
+ error = assert_raises(Wsv::Request::TooLarge) { Wsv::Request.parse(io) }
49
+
50
+ assert_equal 431, error.status_code
51
+ end
52
+
53
+ def test_single_header_line_too_long_raises_431
54
+ big_header = "X-Long: #{'z' * 9000}\r\n"
55
+ io = StringIO.new("GET / HTTP/1.1\r\n#{big_header}\r\n")
56
+
57
+ error = assert_raises(Wsv::Request::TooLarge) { Wsv::Request.parse(io) }
58
+
59
+ assert_equal 431, error.status_code
60
+ end
61
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class ResponseTest < Minitest::Test
6
+ def test_rejects_crlf_in_header_value
7
+ assert_raises(ArgumentError) do
8
+ Wsv::Response.new(status: 200, headers: { "X-Foo" => "bad\r\nInjected: yes" })
9
+ end
10
+ end
11
+
12
+ def test_rejects_lf_in_header_value
13
+ assert_raises(ArgumentError) do
14
+ Wsv::Response.new(status: 200, headers: { "X-Foo" => "bad\nthing" })
15
+ end
16
+ end
17
+
18
+ def test_rejects_crlf_in_header_name
19
+ assert_raises(ArgumentError) do
20
+ Wsv::Response.new(status: 200, headers: { "X-Bad\r\n" => "value" })
21
+ end
22
+ end
23
+
24
+ def test_rejects_colon_in_header_name
25
+ assert_raises(ArgumentError) do
26
+ Wsv::Response.new(status: 200, headers: { "X-Foo: extra" => "value" })
27
+ end
28
+ end
29
+
30
+ def test_accepts_normal_headers
31
+ Wsv::Response.new(
32
+ status: 200,
33
+ headers: {
34
+ "Content-Type" => "text/html; charset=utf-8",
35
+ "Allow" => "GET, HEAD",
36
+ "Location" => "/docs/?q=1"
37
+ }
38
+ )
39
+ end
40
+
41
+ def test_text_factory_produces_writable_response
42
+ response = Wsv::Response.text(404)
43
+ io = StringIO.new
44
+ response.write_to(io)
45
+
46
+ assert_includes io.string, "HTTP/1.1 404 Not Found"
47
+ end
48
+ end