Module: SimpleCov::CLI::Serve::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 (option parsing, binding, the accept loop) 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

Class Method Summary collapse

Class Method Details

.drain_headers(client) ⇒ Object



73
74
75
# File 'lib/simplecov/cli/serve/static_file_handler.rb', line 73

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

.handle_connection(client, root) ⇒ 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. The read timeout keeps idle connections from pinning their threads forever.



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

def handle_connection(client, root)
  # simplecov:disable branch — support for IO#timeout= is fixed by the engine
  # JRuby and TruffleRuby don't implement IO#timeout=. Without
  # the guard the NoMethodError lands in the wide rescue below
  # and every connection closes with an empty response. On those
  # engines an idle connection 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=)
  # simplecov:enable branch
  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 defined below.
  return respond(client, 400) if path.nil?
  return respond(client, 405) unless method == "GET"

  serve_file(client, path, root)
rescue StandardError
  # Misbehaving clients (truncated requests, connection resets,
  # invalid encoding) shouldn't take the whole server down.
  nil
ensure
  # simplecov:disable — `client` is the parameter, never nil here;
  # the `&.` is purely defensive in case of future refactors
  client&.close
  # simplecov:enable
end

.inside?(path, root) ⇒ Boolean

Returns:

  • (Boolean)


108
109
110
# File 'lib/simplecov/cli/serve/static_file_handler.rb', line 108

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

.resolve(request_path, root) ⇒ Object

Returns the absolute path of the file to serve, :forbidden for a traversal attempt (including symlinks that escape root), 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 below.



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/simplecov/cli/serve/static_file_handler.rb', line 86

def resolve(request_path, root)
  path = request_path.split("?", 2).first.to_s.sub(%r{^/}, "")
  absolute_root = File.realpath(root)
  candidate = File.expand_path(path.empty? ? "index.html" : path, absolute_root)
  # Reject `..` traversal and absolute-path attempts before
  # touching disk so they're 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)

  # Resolve symlinks last and re-check: a file inside root could
  # be a symlink pointing outside (e.g. /etc/passwd).
  real = File.realpath(candidate)
  inside?(real, absolute_root) ? real : :forbidden
rescue Errno::ENOENT
  # simplecov:disable — TOCTOU: candidate vanished between
  # File.file? and File.realpath. Treat as "not found".
  nil
  # simplecov:enable
end

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



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

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



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

def serve_file(client, path, root)
  file = resolve(path, root)
  return respond(client, file == :forbidden ? 403 : 404) unless file.is_a?(String)

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