Module: SimpleCov::CLI::Serve

Defined in:
lib/simplecov/cli/serve.rb

Overview

‘simplecov serve [–port N] [–host HOST]` — serve the coverage report over HTTP. A 30-line static file server backed by stdlib `socket`, so there’s no extra dependency just for “view a local report on a CI box where ‘file://` doesn’t work.”

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

Class Method Summary collapse

Class Method Details

.announce(stdout, server, dir) ⇒ Object



56
57
58
59
60
61
# File 'lib/simplecov/cli/serve.rb', line 56

def announce(stdout, server, dir)
  port = server.addr[1]
  host = server.addr[3]
  stdout.puts("simplecov serve: serving #{dir} at http://#{host}:#{port}/")
  stdout.puts("Press Ctrl-C to stop.")
end

.drain_headers(client) ⇒ Object



92
93
94
# File 'lib/simplecov/cli/serve.rb', line 92

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

.error(stderr, message) ⇒ Object



133
134
135
136
# File 'lib/simplecov/cli/serve.rb', line 133

def error(stderr, message)
  stderr.puts("simplecov serve: #{message}")
  1
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.



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/simplecov/cli/serve.rb', line 72

def handle_connection(client, root)
  method, path = client.readline.split
  drain_headers(client)
  return respond(client, 405) unless method == "GET"

  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])
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)


121
122
123
# File 'lib/simplecov/cli/serve.rb', line 121

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

.parse(args) ⇒ Object



47
48
49
50
51
52
53
54
# File 'lib/simplecov/cli/serve.rb', line 47

def parse(args)
  opts = {port: 0, host: "127.0.0.1"}
  OptionParser.new do |o|
    o.on("--port N", Integer) { |v| opts[:port] = v }
    o.on("--host HOST")       { |v| opts[:host] = v }
  end.parse(args)
  opts
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”.



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/simplecov/cli/serve.rb', line 99

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



125
126
127
128
129
130
131
# File 'lib/simplecov/cli/serve.rb', line 125

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

.run(args, stdout:, stderr:) ⇒ Object



33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/simplecov/cli/serve.rb', line 33

def run(args, stdout:, stderr:, **)
  opts = parse(args)
  dir = SimpleCov::CLI.coverage_dir
  return error(stderr, "#{dir} doesn't exist; run your test suite first") unless File.directory?(dir)

  require "socket"
  server = TCPServer.new(opts[:host], opts[:port])
  announce(stdout, server, dir)
  serve_loop(server, dir, stdout)
  0
ensure
  server&.close
end

.serve_loop(server, dir, stdout) ⇒ Object



63
64
65
66
67
# File 'lib/simplecov/cli/serve.rb', line 63

def serve_loop(server, dir, stdout)
  loop { handle_connection(server.accept, dir) }
rescue Interrupt
  stdout.puts("\nsimplecov serve: stopping")
end