wsv 0.8.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.
data/lib/wsv/request.rb CHANGED
@@ -1,21 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "request/too_large"
4
+ require_relative "request/parser"
5
+
3
6
  module Wsv
4
7
  class Request
5
- REQUEST_LINE_LIMIT = 8192
6
- HEADER_LINE_LIMIT = 8192
7
- HEADER_COUNT_LIMIT = 100
8
- HEADER_TOTAL_LIMIT = 16384
9
-
10
- class TooLarge < StandardError
11
- attr_reader :status_code
12
-
13
- def initialize(status_code)
14
- super("request exceeded size limit (#{status_code})")
15
- @status_code = status_code
16
- end
17
- end
18
-
19
8
  attr_reader :method, :target, :version, :headers
20
9
 
21
10
  def initialize(method:, target:, version:, headers:)
@@ -30,39 +19,7 @@ module Wsv
30
19
  end
31
20
 
32
21
  def self.parse(io)
33
- line = io.gets(REQUEST_LINE_LIMIT)
34
- return :empty unless line
35
- raise TooLarge, 414 if line.bytesize >= REQUEST_LINE_LIMIT && !line.end_with?("\n")
36
-
37
- method, target, version = line.split(/\s+/, 3)
38
- version = version&.strip
39
- return :malformed unless method && target && version&.start_with?("HTTP/")
40
-
41
- headers = read_headers(io)
42
- new(method: method, target: target, version: version, headers: headers)
43
- end
44
-
45
- def self.read_headers(io)
46
- headers = {}
47
- total = 0
48
- count = 0
49
- while (line = io.gets(HEADER_LINE_LIMIT))
50
- raise TooLarge, 431 if line.bytesize >= HEADER_LINE_LIMIT && !line.end_with?("\n")
51
-
52
- stripped = line.delete_suffix("\r\n").delete_suffix("\n").delete_suffix("\r")
53
- break if stripped.empty?
54
-
55
- count += 1
56
- raise TooLarge, 431 if count > HEADER_COUNT_LIMIT
57
-
58
- total += line.bytesize
59
- raise TooLarge, 431 if total > HEADER_TOTAL_LIMIT
60
-
61
- name, value = stripped.split(":", 2)
62
- headers[name.downcase] = value.strip if name && value
63
- end
64
- headers
22
+ Parser.new(io).parse
65
23
  end
66
- private_class_method :read_headers
67
24
  end
68
25
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Response
5
+ class FileBody
6
+ def initialize(path, offset: 0, length: nil)
7
+ @path = path
8
+ @offset = offset
9
+ @length = length || (File.size(path) - offset)
10
+ end
11
+
12
+ def to_s
13
+ File.open(@path, "rb") do |f|
14
+ f.seek(@offset)
15
+ f.read(@length)
16
+ end
17
+ end
18
+
19
+ def bytesize
20
+ @length
21
+ end
22
+
23
+ def write_to(io)
24
+ File.open(@path, "rb") do |f|
25
+ f.seek(@offset)
26
+ IO.copy_stream(f, io, @length)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../mime_types"
5
+
6
+ module Wsv
7
+ class Response
8
+ class FileBuilder
9
+ def initialize(path, head: false, range: nil, status: 200)
10
+ @path = path
11
+ @head = head
12
+ @range = range
13
+ @status = status
14
+ end
15
+
16
+ def build
17
+ if @range
18
+ Response.new(status: 206, headers: range_headers, body: range_body)
19
+ else
20
+ Response.new(status: @status, headers: full_headers, body: full_body)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def size
27
+ @size ||= File.size(@path)
28
+ end
29
+
30
+ def base_headers
31
+ {
32
+ "Content-Type" => MimeTypes.for_file(@path),
33
+ "Last-Modified" => File.mtime(@path).httpdate,
34
+ "Cache-Control" => "no-cache",
35
+ "Accept-Ranges" => "bytes"
36
+ }
37
+ end
38
+
39
+ def range_headers
40
+ base_headers.merge(
41
+ "Content-Length" => @range.size.to_s,
42
+ "Content-Range" => "bytes #{@range.begin}-#{@range.end}/#{size}"
43
+ )
44
+ end
45
+
46
+ def full_headers
47
+ base_headers.merge("Content-Length" => size.to_s)
48
+ end
49
+
50
+ def range_body
51
+ return StringBody.new("") if @head
52
+
53
+ FileBody.new(@path, offset: @range.begin, length: @range.size)
54
+ end
55
+
56
+ def full_body
57
+ return StringBody.new("") if @head
58
+
59
+ FileBody.new(@path)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Response
5
+ class StringBody
6
+ def initialize(string)
7
+ @string = string
8
+ end
9
+
10
+ def to_s
11
+ @string
12
+ end
13
+
14
+ def bytesize
15
+ @string.bytesize
16
+ end
17
+
18
+ def write_to(io)
19
+ io.write(@string) unless @string.empty?
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Response
5
+ class TextBuilder
6
+ def initialize(status, head: false, headers: {})
7
+ @status = status
8
+ @head = head
9
+ @extra_headers = headers
10
+ end
11
+
12
+ def build
13
+ Response.new(status: @status, headers: response_headers, body: response_body)
14
+ end
15
+
16
+ private
17
+
18
+ def response_body
19
+ @head ? "" : message
20
+ end
21
+
22
+ def message
23
+ @message ||= "#{@status} #{Status.reason(@status)}\n"
24
+ end
25
+
26
+ def response_headers
27
+ {
28
+ "Content-Type" => "text/plain; charset=utf-8",
29
+ "Content-Length" => message.bytesize.to_s,
30
+ "Cache-Control" => "no-cache"
31
+ }.merge(@extra_headers)
32
+ end
33
+ end
34
+ end
35
+ end
data/lib/wsv/response.rb CHANGED
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "time"
4
- require_relative "mime_types"
5
3
  require_relative "status"
6
4
  require_relative "version"
5
+ require_relative "response/string_body"
6
+ require_relative "response/file_body"
7
+ require_relative "response/text_builder"
8
+ require_relative "response/file_builder"
7
9
 
8
10
  module Wsv
9
11
  class Response
@@ -12,56 +14,68 @@ module Wsv
12
14
  INVALID_HEADER_NAME = /[\s:]/
13
15
  INVALID_HEADER_VALUE = /[\r\n]/
14
16
 
15
- attr_reader :status, :headers, :body
17
+ attr_reader :status, :headers
16
18
 
17
19
  def initialize(status:, headers: {}, body: "")
18
- headers.each do |name, value|
19
- raise ArgumentError, "invalid header name: #{name.inspect}" if name.to_s.match?(INVALID_HEADER_NAME)
20
- raise ArgumentError, "invalid header value: #{value.inspect}" if value.to_s.match?(INVALID_HEADER_VALUE)
21
- end
20
+ validate_headers(headers)
22
21
  @status = status
23
22
  @headers = headers
24
- @body = body
23
+ @body = body.is_a?(String) ? StringBody.new(body) : body
24
+ end
25
+
26
+ def body
27
+ @body.to_s
25
28
  end
26
29
 
27
30
  def reason
28
31
  Status.reason(status)
29
32
  end
30
33
 
34
+ # Returns a new Response with `extra` merged into the headers, sharing the
35
+ # same body object so streaming (FileBody) is preserved.
36
+ def with_headers(extra)
37
+ self.class.new(status: @status, headers: @headers.merge(extra), body: @body)
38
+ end
39
+
31
40
  def write_to(io)
32
41
  io.write "HTTP/1.1 #{status} #{reason}\r\n"
33
42
  io.write "Server: #{SERVER_NAME}\r\n"
34
43
  io.write "Connection: close\r\n"
44
+ unless headers.any? { |name, _value| name.to_s.casecmp?("X-Content-Type-Options") }
45
+ io.write "X-Content-Type-Options: nosniff\r\n"
46
+ end
35
47
  headers.each { |name, value| io.write "#{name}: #{value}\r\n" }
36
48
  io.write "\r\n"
37
- io.write body
49
+ @body.write_to(io)
38
50
  end
39
51
 
40
- def self.text(status, headers: {}, head: false)
41
- body = "#{status} #{Status.reason(status)}\n"
42
- base = {
43
- "Content-Type" => "text/plain; charset=utf-8",
44
- "Content-Length" => body.bytesize.to_s,
45
- "Cache-Control" => "no-cache"
46
- }
47
- new(status: status, headers: base.merge(headers), body: head ? "" : body)
52
+ def self.text(status, **)
53
+ TextBuilder.new(status, **).build
48
54
  end
49
55
 
50
- def self.file(path, head: false)
51
- new(
52
- status: 200,
53
- headers: {
54
- "Content-Type" => MimeTypes.for_file(path),
55
- "Content-Length" => File.size(path).to_s,
56
- "Last-Modified" => File.mtime(path).httpdate,
57
- "Cache-Control" => "no-cache"
58
- },
59
- body: head ? "" : File.binread(path)
60
- )
56
+ def self.file(path, **)
57
+ FileBuilder.new(path, **).build
61
58
  end
62
59
 
63
60
  def self.redirect(location, head: false)
64
- text(301, headers: { "Location" => location }, head: head)
61
+ TextBuilder.new(301, head: head, headers: { "Location" => location }).build
62
+ end
63
+
64
+ def self.not_modified
65
+ new(status: 304, headers: { "Cache-Control" => "no-cache" }, body: "")
66
+ end
67
+
68
+ def self.range_not_satisfiable(file_size, head: false)
69
+ TextBuilder.new(416, head: head, headers: { "Content-Range" => "bytes */#{file_size}" }).build
70
+ end
71
+
72
+ private
73
+
74
+ def validate_headers(headers)
75
+ headers.each do |name, value|
76
+ raise ArgumentError, "invalid header name: #{name.inspect}" if name.to_s.match?(INVALID_HEADER_NAME)
77
+ raise ArgumentError, "invalid header value: #{value.inspect}" if value.to_s.match?(INVALID_HEADER_VALUE)
78
+ end
65
79
  end
66
80
  end
67
81
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Server
5
+ # Renders the startup announcement (the "Serving / Bind / Local / Stop"
6
+ # block plus warnings about non-loopback binds and self-signed certs).
7
+ class Banner
8
+ def initialize(host:, port:, root:, out:, err:, tls:)
9
+ @host = host
10
+ @port = port
11
+ @root = root
12
+ @out = out
13
+ @err = err
14
+ @tls = tls
15
+ end
16
+
17
+ def emit
18
+ @out.puts "Serving: #{@root}"
19
+ @out.puts "Bind: #{url_for(@host)}"
20
+ @out.puts "Local: #{url_for('127.0.0.1')}" unless localhost?(@host)
21
+ @out.puts "Stop: Ctrl-C"
22
+ warn_public_bind unless localhost?(@host)
23
+ warn_ephemeral_cert if @tls&.ephemeral?
24
+ end
25
+
26
+ private
27
+
28
+ def warn_public_bind
29
+ @err.puts "WARNING: binding to #{@host} exposes #{@root} on your network."
30
+ @err.puts " Pass --host 127.0.0.1 (or omit --host) for local-only access."
31
+ end
32
+
33
+ def warn_ephemeral_cert
34
+ @err.puts "WARNING: serving with a self-signed certificate. Browsers will"
35
+ @err.puts " show a security warning. Pass --cert / --key for a real cert."
36
+ end
37
+
38
+ def url_for(display_host)
39
+ "#{scheme}://#{format_host(display_host)}:#{@port}/"
40
+ end
41
+
42
+ def format_host(host)
43
+ # Bracket IPv6 literals per RFC 3986; zone IDs (`%eth0` etc.) need %25 per RFC 6874.
44
+ host.include?(":") ? "[#{host.gsub('%', '%25')}]" : host
45
+ end
46
+
47
+ def scheme
48
+ @tls ? "https" : "http"
49
+ end
50
+
51
+ def localhost?(display_host)
52
+ ["127.0.0.1", "localhost", "::1"].include?(display_host)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Wsv
6
+ class Server
7
+ # Launches the OS default browser at the served URL when `--open` is set.
8
+ # Best-effort: unsupported platforms or spawn failures are logged but
9
+ # never abort the server.
10
+ class BrowserLauncher
11
+ def initialize(host:, port:, tls:, err:)
12
+ @host = host
13
+ @port = port
14
+ @tls = tls
15
+ @err = err
16
+ end
17
+
18
+ def launch
19
+ command = platform_command
20
+ unless command
21
+ @err.puts "wsv: --open is not supported on this platform; skipping."
22
+ return
23
+ end
24
+
25
+ pid = Process.spawn(*command, url, in: :close, out: File::NULL, err: File::NULL)
26
+ Process.detach(pid)
27
+ rescue StandardError => e
28
+ @err.puts "wsv: failed to open browser: #{e.message}"
29
+ end
30
+
31
+ private
32
+
33
+ def url
34
+ scheme = @tls ? "https" : "http"
35
+ "#{scheme}://#{url_host}:#{@port}/"
36
+ end
37
+
38
+ def url_host
39
+ host = display_host
40
+ # IPv6 literals must be bracketed in URLs per RFC 3986. Scoped IPv6
41
+ # zone identifiers use `%`, which must be percent-encoded in URLs.
42
+ host.include?(":") ? "[#{host.gsub('%', '%25')}]" : host
43
+ end
44
+
45
+ def display_host
46
+ # Wildcard binds aren't reachable; redirect to the matching loopback.
47
+ case @host
48
+ when "0.0.0.0" then "127.0.0.1"
49
+ when "::" then "::1"
50
+ else @host
51
+ end
52
+ end
53
+
54
+ def platform_command
55
+ case RbConfig::CONFIG["host_os"]
56
+ when /darwin/ then ["open"]
57
+ when /linux|bsd/ then ["xdg-open"]
58
+ when /mswin|mingw|cygwin/ then ["cmd.exe", "/c", "start", ""]
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Server
5
+ # Wraps an IO with a shared deadline so each subsequent read is bounded by
6
+ # the time remaining until the deadline. Used to enforce a single budget
7
+ # across the request line and all header lines.
8
+ class DeadlineReader
9
+ def initialize(io, deadline)
10
+ @io = io
11
+ @deadline = deadline
12
+ end
13
+
14
+ def gets(eol, limit)
15
+ remaining = @deadline - Time.now
16
+ raise IO::TimeoutError if remaining <= 0
17
+
18
+ @io.to_io.timeout = remaining
19
+ @io.gets(eol, limit)
20
+ end
21
+ end
22
+ end
23
+ end
data/lib/wsv/server.rb CHANGED
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "openssl"
3
4
  require "socket"
4
5
  require_relative "app"
5
6
  require_relative "request"
6
7
  require_relative "response"
8
+ require_relative "server/banner"
9
+ require_relative "server/browser_launcher"
10
+ require_relative "server/deadline_reader"
7
11
 
8
12
  module Wsv
9
13
  class Server
@@ -20,7 +24,11 @@ module Wsv
20
24
  out: $stdout,
21
25
  err: $stderr,
22
26
  read_timeout: DEFAULT_READ_TIMEOUT,
23
- max_connections: DEFAULT_MAX_CONNECTIONS
27
+ max_connections: DEFAULT_MAX_CONNECTIONS,
28
+ tls: nil,
29
+ spa: false,
30
+ open: false,
31
+ cors: false
24
32
  )
25
33
  @host = host
26
34
  @port = port
@@ -29,7 +37,10 @@ module Wsv
29
37
  @err = err
30
38
  @read_timeout = read_timeout
31
39
  @max_connections = max_connections
32
- @app = App.new(@root)
40
+ @tls = tls
41
+ @ssl_context = tls&.to_ssl_context
42
+ @open = open
43
+ @app = App.new(@root, spa: spa, cors: cors)
33
44
  @running = false
34
45
  @mutex = Mutex.new
35
46
  @active = 0
@@ -40,6 +51,7 @@ module Wsv
40
51
  @running = true
41
52
  log_startup
42
53
  trap_signals
54
+ open_in_browser if @open
43
55
  accept_loop
44
56
  ensure
45
57
  close
@@ -66,6 +78,8 @@ module Wsv
66
78
  rescue IO::TimeoutError
67
79
  write_response(client, Response.text(408))
68
80
  rescue StandardError => e
81
+ # Treat unmapped failures as connection-scoped and close with 400 rather
82
+ # than letting one bad request path bring down the server.
69
83
  @err.puts "wsv: #{e.class}: #{e.message}"
70
84
  write_response(client, Response.text(400))
71
85
  ensure
@@ -104,6 +118,9 @@ module Wsv
104
118
  chunk = client.read_nonblock(8192, exception: false)
105
119
  case chunk
106
120
  when nil, :wait_writable
121
+ # nil = EOF. :wait_writable can come back from SSLSocket during a
122
+ # renegotiation (read needs an underlying write). Either way,
123
+ # there's nothing more we can usefully drain right now.
107
124
  return
108
125
  when :wait_readable
109
126
  remaining = deadline - Time.now
@@ -113,21 +130,6 @@ module Wsv
113
130
  end
114
131
  end
115
132
 
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
133
  def accept_loop
132
134
  while @running
133
135
  client = nil
@@ -162,18 +164,58 @@ module Wsv
162
164
  true
163
165
  end
164
166
 
165
- return reject(client) unless accepted
167
+ return spawn_rejection(client) unless accepted
166
168
 
169
+ begin
170
+ Thread.new do
171
+ Thread.current.report_on_exception = false
172
+ handle(maybe_wrap_tls(client))
173
+ ensure
174
+ @mutex.synchronize { @active -= 1 }
175
+ end
176
+ rescue ThreadError => e
177
+ @err.puts "wsv: thread error: #{e.message}"
178
+ @mutex.synchronize { @active -= 1 }
179
+ spawn_rejection(client)
180
+ end
181
+ end
182
+
183
+ # Reject in a separate thread so a slow client cannot block accept_loop
184
+ # via graceful_close's drain_recv (up to DRAIN_TIMEOUT seconds).
185
+ def spawn_rejection(client)
167
186
  Thread.new do
168
187
  Thread.current.report_on_exception = false
169
- handle(client)
170
- ensure
171
- @mutex.synchronize { @active -= 1 }
188
+ reject(client)
172
189
  end
190
+ rescue ThreadError
191
+ reject(client)
192
+ end
193
+
194
+ def maybe_wrap_tls(client)
195
+ return client unless @ssl_context
196
+
197
+ client.timeout = @read_timeout
198
+ ssl = OpenSSL::SSL::SSLSocket.new(client, @ssl_context)
199
+ ssl.sync_close = true
200
+ ssl.accept
201
+ ssl
202
+ rescue StandardError
203
+ # If wrapping or the handshake failed, `handle` is never called and
204
+ # its ensure does not get a chance to close the underlying socket.
205
+ # Close it here so we do not leak a TCPSocket per failed handshake.
206
+ begin
207
+ client.close
208
+ rescue StandardError
209
+ nil
210
+ end
211
+ raise
173
212
  end
174
213
 
175
214
  def reject(client)
176
- write_response(client, Response.text(503))
215
+ # In TLS mode `client` is the raw TCPSocket before any handshake.
216
+ # Writing a plaintext 503 over it would corrupt the TLS handshake
217
+ # the client is about to start, so just close in that case.
218
+ write_response(client, Response.text(503)) unless @ssl_context
177
219
  ensure
178
220
  graceful_close(client)
179
221
  end
@@ -190,28 +232,18 @@ module Wsv
190
232
  end
191
233
  end
192
234
  rescue ArgumentError
235
+ # Signal.trap raises ArgumentError when called from a context that
236
+ # cannot install signal handlers (e.g. embedded in a non-main thread,
237
+ # which is how tests start the server). Skip silently in that case.
193
238
  nil
194
239
  end
195
240
 
196
241
  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}/"
242
+ Banner.new(host: host, port: port, root: root, out: @out, err: @err, tls: @tls).emit
211
243
  end
212
244
 
213
- def localhost?(display_host)
214
- ["127.0.0.1", "localhost", "::1"].include?(display_host)
245
+ def open_in_browser
246
+ BrowserLauncher.new(host: host, port: port, tls: @tls, err: @err).launch
215
247
  end
216
248
  end
217
249
  end
data/lib/wsv/status.rb CHANGED
@@ -4,13 +4,17 @@ module Wsv
4
4
  module Status
5
5
  REASONS = {
6
6
  200 => "OK",
7
+ 204 => "No Content",
8
+ 206 => "Partial Content",
7
9
  301 => "Moved Permanently",
10
+ 304 => "Not Modified",
8
11
  400 => "Bad Request",
9
12
  403 => "Forbidden",
10
13
  404 => "Not Found",
11
14
  405 => "Method Not Allowed",
12
15
  408 => "Request Timeout",
13
16
  414 => "URI Too Long",
17
+ 416 => "Range Not Satisfiable",
14
18
  431 => "Request Header Fields Too Large",
15
19
  503 => "Service Unavailable"
16
20
  }.freeze