Module: SimpleCov::CLI::Serve::StaticFileHandler

Extended by:
StaticFileHandler
Included in:
StaticFileHandler
Defined in:
lib/simplecov/cli/serve/static_file_handler.rb

Overview

The HTTP mechanics behind simplecov serve: reads one request per connection and answers it from the report directory, with traversal-safe path resolution. Kept apart from the CLI wiring in serve.rb.

Constant Summary collapse

MIME =
{
  ".html" => "text/html; charset=utf-8", ".htm" => "text/html; charset=utf-8",
  ".css" => "text/css",
  ".js" => "application/javascript",
  ".json" => "application/json",
  ".svg" => "image/svg+xml",
  ".png" => "image/png",
  ".gif" => "image/gif",
  ".jpg" => "image/jpeg",
  ".jpeg" => "image/jpeg",
  ".ico" => "image/x-icon",
  ".txt" => "text/plain; charset=utf-8"
}.freeze
STATUS_TEXT =
{200 => "OK", 400 => "Bad Request", 403 => "Forbidden",
404 => "Not Found", 405 => "Method Not Allowed"}.freeze
READ_TIMEOUT =

Seconds a connection may sit idle mid-request before its reads raise IO::TimeoutError and the connection is dropped.

5

Instance Method Summary collapse

Instance Method Details

#dispatch(client, method, path, root, routes) ⇒ Object



64
65
66
67
68
69
70
71
# File 'lib/simplecov/cli/serve/static_file_handler.rb', line 64

def dispatch(client, method, path, root, routes)
  return respond(client, 405) unless method.eql?("GET")

  route = routes[path.split("?").first]
  return route.call(client) if route

  serve_file(client, path, root)
end

#drain_headers(client) ⇒ Object



82
83
84
# File 'lib/simplecov/cli/serve/static_file_handler.rb', line 82

def drain_headers(client)
  loop { break if client.readline.rstrip.empty? }
end

#handle_connection(client, root, routes = {}) ⇒ Object

Reads one HTTP request line, drains headers, serves the file or writes a status response. Wide rescue so a misbehaving client can't crash the server.

routes maps exact request paths (query string excluded) to callables that take over the connection, the seam simplecov watch mounts its /events stream on. A route may hold the socket for as long as it likes; the ensure below closes it when the route returns.



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/simplecov/cli/serve/static_file_handler.rb', line 41

def handle_connection(client, root, routes = {})
  # JRuby doesn't implement IO#timeout=. Without the guard the
  # NoMethodError lands in the wide rescue below and every connection
  # closes with an empty response. An idle connection then pins its
  # thread instead of timing out, which only leaks a thread in an
  # interactive dev server.
  client.timeout = READ_TIMEOUT if client.respond_to?(:timeout=)
  method, path = client.readline.split
  drain_headers(client)
  # A request line without both tokens used to raise on `path.split` inside
  # the wide rescue, closing the connection with an empty response
  # instead of the 400 below.
  return respond(client, 400) if path.nil?

  dispatch(client, method, path, root, routes)
rescue
  # Misbehaving clients (truncated requests, connection resets, invalid
  # encoding) shouldn't take the whole server down.
  nil
ensure
  client.close
end

#inside?(path, root) ⇒ Boolean

Returns:

  • (Boolean)


114
115
116
# File 'lib/simplecov/cli/serve/static_file_handler.rb', line 114

def inside?(path, root)
  path.eql?(root) || path.start_with?(root + File::SEPARATOR)
end

#resolve(request_path, root) ⇒ Object

Answers the absolute path of the file to serve, :forbidden for a traversal attempt (symlinks that escape root included), or nil for "not found".

The request path is deliberately NOT percent-decoded: filenames needing escapes don't occur in generated reports, and keeping %2e%2e%2f as literal bytes is part of the traversal defense. If decoding is ever added, it must happen BEFORE the inside? check.



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/simplecov/cli/serve/static_file_handler.rb', line 94

def resolve(request_path, root)
  path = request_path.split("?").first.to_s.delete_prefix("/")
  absolute_root = File.realpath(root)
  candidate = File.expand_path(path, absolute_root)
  # Rejected before touching disk, so traversal and absolute-path attempts
  # are 403, not 404.
  return :forbidden unless inside?(candidate, absolute_root)

  candidate = File.join(candidate, "index.html") if File.directory?(candidate)
  return nil unless File.file?(candidate)

  # Symlinks are resolved last and re-checked: a file inside root could be
  # a symlink pointing outside.
  real = File.realpath(candidate)
  inside?(real, absolute_root) ? real : :forbidden
rescue Errno::ENOENT
  # TOCTOU: candidate vanished between File.file? and File.realpath.
  nil
end

#respond(client, status, body = "", content_type = "text/plain") ⇒ Object



118
119
120
121
122
123
124
# File 'lib/simplecov/cli/serve/static_file_handler.rb', line 118

def respond(client, status, body = "", content_type = "text/plain")
  client.write("HTTP/1.1 #{status} #{STATUS_TEXT[status] || "Error"}\r\n",
    "Content-Type: #{content_type || "application/octet-stream"}\r\n",
    "Content-Length: #{body.bytesize}\r\n",
    "Connection: close\r\n\r\n")
  client.write(body)
end

#serve_file(client, path, root) ⇒ Object



73
74
75
76
77
78
79
80
# File 'lib/simplecov/cli/serve/static_file_handler.rb', line 73

def serve_file(client, path, root)
  file = resolve(path, root)
  # `resolve` answers a path, nothing for a file that is not there, or the
  # refusal itself.
  return respond(client, file ? 403 : 404) unless file.instance_of?(String)

  respond(client, 200, File.binread(file), MIME[File.extname(file).downcase])
end