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
- .announce(stdout, server, dir) ⇒ Object
- .drain_headers(client) ⇒ Object
- .error(stderr, message) ⇒ Object
-
.handle_connection(client, root) ⇒ Object
Reads one HTTP request line, drains headers, serves the file or writes a status response.
- .inside?(path, root) ⇒ Boolean
- .parse(args) ⇒ Object
-
.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”.
- .respond(client, status, body = "", content_type = "text/plain") ⇒ Object
- .run(args, stdout:, stderr:) ⇒ Object
- .serve_loop(server, dir, stdout) ⇒ Object
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, ) stderr.puts("simplecov serve: #{}") 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
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.(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 |