rails-profiler 0.30.9 → 0.30.11

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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -0
  3. data/app/controllers/profiler/api/cluster_controller.rb +37 -1
  4. data/app/controllers/profiler/api/env_vars_controller.rb +6 -0
  5. data/app/controllers/profiler/api/slave_proxy_controller.rb +4 -2
  6. data/app/controllers/profiler/api/test_runner_controller.rb +3 -9
  7. data/app/controllers/profiler/application_controller.rb +12 -1
  8. data/app/controllers/profiler/profiles_controller.rb +2 -0
  9. data/config/routes.rb +15 -8
  10. data/lib/profiler/boot_env.rb +8 -0
  11. data/lib/profiler/cluster/master_client.rb +24 -6
  12. data/lib/profiler/cluster/security.rb +179 -0
  13. data/lib/profiler/cluster/slave_proxy.rb +24 -1
  14. data/lib/profiler/collectors/base_collector.rb +2 -0
  15. data/lib/profiler/collectors/console_collector.rb +2 -2
  16. data/lib/profiler/collectors/flamegraph_collector.rb +2 -2
  17. data/lib/profiler/collectors/i18n_collector.rb +1 -1
  18. data/lib/profiler/collectors/mailer_collector.rb +5 -5
  19. data/lib/profiler/configuration.rb +25 -2
  20. data/lib/profiler/console_profiler.rb +1 -1
  21. data/lib/profiler/env_override_store.rb +11 -0
  22. data/lib/profiler/instrumentation/net_http_instrumentation.rb +2 -1
  23. data/lib/profiler/job_profiler.rb +2 -2
  24. data/lib/profiler/mcp/http_guard.rb +73 -0
  25. data/lib/profiler/mcp/server.rb +5 -2
  26. data/lib/profiler/mcp/tools/delete_env_var.rb +7 -0
  27. data/lib/profiler/mcp/tools/run_tests.rb +7 -1
  28. data/lib/profiler/mcp/tools/set_env_var.rb +7 -0
  29. data/lib/profiler/models/profile.rb +14 -4
  30. data/lib/profiler/railtie.rb +5 -1
  31. data/lib/profiler/redaction.rb +98 -0
  32. data/lib/profiler/test_runner/discovery.rb +2 -2
  33. data/lib/profiler/test_runner/run_store.rb +39 -2
  34. data/lib/profiler/test_runner/runner.rb +144 -10
  35. data/lib/profiler/version.rb +1 -1
  36. data/lib/profiler.rb +2 -0
  37. metadata +6 -3
@@ -86,7 +86,7 @@ module Profiler
86
86
 
87
87
  sql = payload[:sql].to_s
88
88
  add_event Models::TimelineEvent.new(
89
- name: sql.length > 80 ? "#{sql[0, 80]}..." : sql,
89
+ name: Profiler::Redaction.truncate(sql, 80),
90
90
  started_at: started,
91
91
  finished_at: finished,
92
92
  category: "sql",
@@ -100,7 +100,7 @@ module Profiler
100
100
  op = name.split(".").first.sub("cache_", "")
101
101
  key = payload[:key].to_s
102
102
  add_event Models::TimelineEvent.new(
103
- name: "cache_#{op}: #{key.length > 60 ? "#{key[0, 60]}..." : key}",
103
+ name: "cache_#{op}: #{Profiler::Redaction.truncate(key, 60)}",
104
104
  started_at: started,
105
105
  finished_at: finished,
106
106
  category: "cache",
@@ -93,7 +93,7 @@ module Profiler
93
93
  private
94
94
 
95
95
  def truncate(str, max = 100)
96
- str.length > max ? "#{str[0, max]}…" : str
96
+ Profiler::Redaction.truncate(str, max, "…")
97
97
  end
98
98
  end
99
99
  end
@@ -224,12 +224,12 @@ module Profiler
224
224
  if mail.multipart?
225
225
  html_part = mail.parts.find { |p| p.content_type.to_s.start_with?("text/html") }
226
226
  text_part = mail.parts.find { |p| p.content_type.to_s.start_with?("text/plain") }
227
- html = html_part&.body&.decoded&.then { |b| b[0, MAX_BODY_SIZE] }
228
- text = text_part&.body&.decoded&.then { |b| b[0, MAX_BODY_SIZE] }
227
+ html = html_part&.body&.decoded&.then { |b| Profiler::Redaction.truncate(b, MAX_BODY_SIZE, "") }
228
+ text = text_part&.body&.decoded&.then { |b| Profiler::Redaction.truncate(b, MAX_BODY_SIZE, "") }
229
229
  elsif mail.content_type.to_s.start_with?("text/html")
230
- html = mail.body.decoded[0, MAX_BODY_SIZE]
230
+ html = Profiler::Redaction.truncate(mail.body.decoded, MAX_BODY_SIZE, "")
231
231
  else
232
- text = mail.body.decoded[0, MAX_BODY_SIZE]
232
+ text = Profiler::Redaction.truncate(mail.body.decoded, MAX_BODY_SIZE, "")
233
233
  end
234
234
  [html.nil? || html.empty? ? nil : html, text.nil? || text.empty? ? nil : text]
235
235
  rescue StandardError
@@ -240,7 +240,7 @@ module Profiler
240
240
  return "nil" if value.nil?
241
241
 
242
242
  inspected = value.inspect
243
- inspected.length > 300 ? "#{inspected[0, 300]}…" : inspected
243
+ Profiler::Redaction.truncate(inspected, 300, "…")
244
244
  rescue StandardError
245
245
  value.to_s
246
246
  end
@@ -36,12 +36,14 @@ module Profiler
36
36
  :track_jobs,
37
37
  :track_console,
38
38
  :apply_env_overrides_when_disabled,
39
- :track_tests,
39
+ :track_tests, :test_runner_allow_undiscovered_files,
40
40
  :track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
41
41
  :compress_bodies, :compress_body_threshold,
42
42
  :redact_sensitive_data, :filter_parameters, :env_allowlist,
43
43
  :name, :master_url, :self_url,
44
- :cluster_heartbeat_interval, :cluster_offline_threshold
44
+ :cluster_heartbeat_interval, :cluster_offline_threshold,
45
+ :cluster_master, :cluster_secret, :cluster_require_secret,
46
+ :cluster_allowed_slave_urls, :cluster_allow_insecure_http
45
47
 
46
48
  attr_writer :tmp_path
47
49
 
@@ -80,6 +82,9 @@ module Profiler
80
82
  @track_console = true
81
83
  @apply_env_overrides_when_disabled = false
82
84
  @track_tests = false
85
+ # The test runner only runs the files listed by its discovery. true restores the
86
+ # previous behavior: any file under the Rails root.
87
+ @test_runner_allow_undiscovered_files = false
83
88
  @track_mailers = true
84
89
  @capture_mail_body = false
85
90
  @sanitize_mailer_recipients = false
@@ -95,6 +100,12 @@ module Profiler
95
100
  @self_url = nil
96
101
  @cluster_heartbeat_interval = 15
97
102
  @cluster_offline_threshold = 60
103
+ # Cluster security: see Profiler::Cluster::Security and the Cluster section of the README.
104
+ @cluster_master = false
105
+ @cluster_secret = nil
106
+ @cluster_require_secret = true
107
+ @cluster_allowed_slave_urls = []
108
+ @cluster_allow_insecure_http = false
98
109
  end
99
110
 
100
111
  def tmp_path
@@ -109,6 +120,18 @@ module Profiler
109
120
  !slave?
110
121
  end
111
122
 
123
+ # Whether this node serves the master-side cluster routes (register, heartbeat, slaves and
124
+ # the slave proxy). Read on each request by the route constraints.
125
+ def cluster_master?
126
+ cluster_master ? true : false
127
+ end
128
+
129
+ # Whether /_profiler/mcp is routed. The stdio transport (rake profiler:mcp) does not depend
130
+ # on it.
131
+ def mcp_http_enabled?
132
+ mcp_enabled && mcp_transport.to_s == "http" ? true : false
133
+ end
134
+
112
135
  def resolved_name
113
136
  @name || (defined?(Rails) ? Rails.application.class.module_parent_name.underscore : "profiler")
114
137
  end
@@ -39,7 +39,7 @@ module Profiler
39
39
  profile = Models::Profile.new
40
40
  profile.profile_type = "console"
41
41
  profile.gem_version = Profiler::VERSION
42
- profile.path = @expression.length > 200 ? "#{@expression[0, 200]}..." : @expression
42
+ profile.path = Redaction.truncate(@expression, 200)
43
43
  profile.method = "CONSOLE"
44
44
 
45
45
  console_collector = Collectors::ConsoleCollector.new(profile, expression: @expression)
@@ -107,7 +107,18 @@ module Profiler
107
107
  warn "[Profiler] EnvOverrideStore: failed to check overrides at boot: #{e.message}"
108
108
  end
109
109
 
110
+ # Set by the test runner in the environment of the test process, to which it already gave
111
+ # the admitted overrides only (TestRunner::Runner.build_env).
112
+ TEST_RUNNER_CHILD_ENV = "PROFILER_TEST_RUNNER_CHILD"
113
+ RESERVED_KEY_ERROR = "#{TEST_RUNNER_CHILD_ENV} is reserved for the test runner"
114
+
115
+ # The env tools may not set or delete the test runner marker, whatever its case.
116
+ def self.reserved_key?(key)
117
+ key.to_s.upcase == TEST_RUNNER_CHILD_ENV
118
+ end
119
+
110
120
  def apply!
121
+ return if ENV[TEST_RUNNER_CHILD_ENV] == "1"
111
122
  return if blocked_reason
112
123
 
113
124
  overrides = load_overrides
@@ -150,7 +150,8 @@ module Profiler
150
150
  mime = content_type.split(";").first.to_s.strip
151
151
 
152
152
  if mime.match?(BINARY_CONTENT_TYPES)
153
- { body: Base64.strict_encode64(body.b), encoding: "base64" }
153
+ # Masked on the raw bytes, before the encoding hides them from any later search.
154
+ { body: Base64.strict_encode64(Redaction.hide_credentials(body.b)), encoding: "base64" }
154
155
  else
155
156
  text = Redaction.filter_body(body, content_type)
156
157
  .encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
@@ -118,11 +118,11 @@ module Profiler
118
118
 
119
119
  Redaction.filter_value(args).map do |arg|
120
120
  case arg
121
- when String then arg.length > 200 ? "#{arg[0, 200]}..." : arg
121
+ when String then Redaction.truncate(arg, 200)
122
122
  when Numeric, TrueClass, FalseClass, NilClass then arg
123
123
  else
124
124
  inspected = arg.inspect
125
- inspected.length > 200 ? "#{inspected[0, 200]}..." : inspected
125
+ Redaction.truncate(inspected, 200)
126
126
  end
127
127
  rescue
128
128
  arg.to_s
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Profiler
7
+ module MCP
8
+ # Checks a request to the MCP HTTP transport before the MCP server sees it, with the same
9
+ # rules as the profiler's controllers: the profiler must be enabled, the request authorized,
10
+ # and a POST must not be one that another site could forge from a browser.
11
+ #
12
+ # Forgery protection does not use the X-Profiler-Request header, which MCP clients do not
13
+ # send. A POST must carry Content-Type: application/json instead: a cross-site form or
14
+ # "simple" fetch can only send text/plain, form-urlencoded or multipart, and JSON needs a
15
+ # CORS preflight that the profiler grants to configured origins only. A browser also sends
16
+ # its Origin, which has to be the profiler's own or one of cors_allowed_origins. GET (the
17
+ # SSE stream) changes nothing, and DELETE always needs a preflight.
18
+ module HttpGuard
19
+ JSON_MEDIA_TYPE = "application/json"
20
+
21
+ class << self
22
+ # Returns nil when the request may proceed, otherwise a Rack response.
23
+ def call(env)
24
+ config = Profiler.configuration
25
+ return deny("Profiler is disabled") unless config.enabled
26
+
27
+ request = ActionDispatch::Request.new(env)
28
+ return deny("Not authorized to access the profiler") unless config.authorized?(request)
29
+ return nil unless config.api_forgery_protection
30
+
31
+ forgery_denial(request, config)
32
+ end
33
+
34
+ private
35
+
36
+ def forgery_denial(request, config)
37
+ origin = request.get_header("HTTP_ORIGIN")
38
+ if origin && !origin.empty? && !allowed_origin?(origin, request, config)
39
+ return deny("Cross-origin request refused: Origin #{origin} is not allowed")
40
+ end
41
+
42
+ if request.post? && media_type(request) != JSON_MEDIA_TYPE
43
+ return deny("A POST to the MCP endpoint needs Content-Type: #{JSON_MEDIA_TYPE}")
44
+ end
45
+
46
+ nil
47
+ end
48
+
49
+ def media_type(request)
50
+ request.get_header("CONTENT_TYPE").to_s.split(";").first.to_s.strip.downcase
51
+ end
52
+
53
+ def allowed_origin?(origin, request, config)
54
+ return true if Array(config.cors_allowed_origins).include?(origin)
55
+
56
+ same_origin?(origin, request.base_url)
57
+ end
58
+
59
+ def same_origin?(origin, base_url)
60
+ a = URI.parse(origin)
61
+ b = URI.parse(base_url)
62
+ a.scheme.to_s.casecmp?(b.scheme.to_s) && a.host.to_s.casecmp?(b.host.to_s) && a.port == b.port
63
+ rescue URI::InvalidURIError
64
+ false
65
+ end
66
+
67
+ def deny(message)
68
+ [403, { "content-type" => "application/json" }, [{ error: message }.to_json]]
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "mcp"
4
+ require_relative "http_guard"
4
5
 
5
6
  module Profiler
6
7
  module MCP
@@ -12,7 +13,9 @@ module Profiler
12
13
 
13
14
  def rack_app
14
15
  # Memoized at class level — survives route reloads in development
15
- @rack_app ||= ->(env) { instance.http_transport.handle_request(Rack::Request.new(env)) }
16
+ @rack_app ||= lambda do |env|
17
+ HttpGuard.call(env) || instance.http_transport.handle_request(Rack::Request.new(env))
18
+ end
16
19
  end
17
20
  end
18
21
 
@@ -389,7 +392,7 @@ module Profiler
389
392
  def define_tool(name:, description:, input_schema:, handler:)
390
393
  ::MCP::Tool.define(name: name, description: description, input_schema: input_schema) do |server_context: nil, **args|
391
394
  result = handler.call(args.transform_keys(&:to_s))
392
- ::MCP::Tool::Response.new(result)
395
+ result.is_a?(::MCP::Tool::Response) ? result : ::MCP::Tool::Response.new(result)
393
396
  end
394
397
  end
395
398
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "mcp"
3
4
  require_relative "../slave_support"
4
5
 
5
6
  module Profiler
@@ -10,6 +11,12 @@ module Profiler
10
11
  key = params["key"].to_s.strip
11
12
 
12
13
  return [{ type: "text", text: "Error: key cannot be blank." }] if key.empty?
14
+ if Profiler::EnvOverrideStore.reserved_key?(key)
15
+ return ::MCP::Tool::Response.new(
16
+ [{ type: "text", text: "Error: #{Profiler::EnvOverrideStore::RESERVED_KEY_ERROR}; #{key} was left unchanged." }],
17
+ error: true
18
+ )
19
+ end
13
20
 
14
21
  if (proxy = MCP::SlaveSupport.with_slave_proxy(params))
15
22
  proxy.patch_json("/_profiler/api/env_vars", { key: key, value: "" })
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "mcp"
4
+
3
5
  require_relative "../slave_support"
4
6
 
5
7
  require_relative "../../test_runner/discovery"
@@ -41,7 +43,11 @@ module Profiler
41
43
  end
42
44
 
43
45
  run_started_at = Time.now
44
- run = Profiler::TestRunner::Runner.start(files: files, framework: framework)
46
+ begin
47
+ run = Profiler::TestRunner::Runner.start(files: files, framework: framework)
48
+ rescue Profiler::TestRunner::InvalidFileError => e
49
+ return ::MCP::Tool::Response.new([{ type: "text", text: e.message }], error: true)
50
+ end
45
51
 
46
52
  output_pos = 0
47
53
  deadline = Time.now + timeout_secs
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "mcp"
3
4
  require_relative "../slave_support"
4
5
  require_relative "../../redaction"
5
6
 
@@ -12,6 +13,12 @@ module Profiler
12
13
  value = params["value"].to_s
13
14
 
14
15
  return [{ type: "text", text: "Error: key cannot be blank." }] if key.empty?
16
+ if Profiler::EnvOverrideStore.reserved_key?(key)
17
+ return ::MCP::Tool::Response.new(
18
+ [{ type: "text", text: "Error: #{Profiler::EnvOverrideStore::RESERVED_KEY_ERROR}; #{key} was left unchanged." }],
19
+ error: true
20
+ )
21
+ end
15
22
  if Profiler::Redaction.mask?(value)
16
23
  return [{ type: "text", text: "Error: #{Profiler::Redaction::MASK} is the mask the profiler shows " \
17
24
  "in place of a hidden value, not a value; #{key} was left unchanged." }]
@@ -9,7 +9,8 @@ require_relative "../redaction"
9
9
  module Profiler
10
10
  module Models
11
11
  class Profile
12
- attr_accessor :token, :path, :method, :status, :duration, :memory,
12
+ attr_reader :path
13
+ attr_accessor :token, :method, :status, :duration, :memory,
13
14
  :started_at, :finished_at, :params, :headers,
14
15
  :response_headers, :collectors_data, :collectors_metadata,
15
16
  :parent_token, :is_ajax, :profile_type,
@@ -50,8 +51,15 @@ module Profiler
50
51
  @response_headers = Redaction.filter_headers(response_headers)
51
52
  end
52
53
 
54
+ # Every collector stores its data here: logs, exception messages, dumps and SQL text are free
55
+ # text with no name to filter on, so the profiler's own credentials are masked by value.
53
56
  def add_collector_data(name, data)
54
- @collectors_data[name.to_s] = data
57
+ @collectors_data[name.to_s] = Redaction.hide_credentials(data)
58
+ end
59
+
60
+ # The path is free text for a console profile: the expression typed.
61
+ def path=(value)
62
+ @path = Redaction.hide_credentials(value)
55
63
  end
56
64
 
57
65
  def collector_data(name)
@@ -160,7 +168,9 @@ module Profiler
160
168
  return { body: nil, encoding: "text" } if raw.nil? || raw.empty?
161
169
 
162
170
  if binary_content_type?(content_type)
163
- { body: Base64.strict_encode64(raw.b), encoding: "base64" }
171
+ # Masked on the raw bytes, before the encoding hides them from any later search. A
172
+ # compressed format (zip, png, gzip...) does not hold the secret as it is.
173
+ { body: Base64.strict_encode64(Redaction.hide_credentials(raw.b)), encoding: "base64" }
164
174
  else
165
175
  text = Redaction.filter_body(raw, content_type).encode("UTF-8", invalid: :replace, undef: :replace)
166
176
  if compress_body?(text)
@@ -195,7 +205,7 @@ module Profiler
195
205
  def sanitize_params(params)
196
206
  return {} unless params
197
207
 
198
- return params.to_h.except(*LEGACY_FILTERED_PARAMS) unless Redaction.enabled?
208
+ return Redaction.hide_credentials(params.to_h.except(*LEGACY_FILTERED_PARAMS)) unless Redaction.enabled?
199
209
 
200
210
  Redaction.filter_hash(params.to_h)
201
211
  end
@@ -92,7 +92,11 @@ module Profiler
92
92
  # Check slave? inside on_load — the app's own initializers (config/initializers/profiler.rb)
93
93
  # set master_url AFTER railtie initializers run, so the check must happen after_initialize.
94
94
  ActiveSupport.on_load(:after_initialize) do
95
- next unless Profiler.configuration.enabled && Profiler.configuration.slave?
95
+ next unless Profiler.configuration.enabled
96
+
97
+ require_relative "cluster/security"
98
+ Profiler::Cluster::Security.warn_about_configuration(Rails.logger)
99
+ next unless Profiler.configuration.slave?
96
100
 
97
101
  require_relative "cluster/master_client"
98
102
  Profiler::Cluster::MasterClient.new.start
@@ -4,6 +4,7 @@ require "json"
4
4
  require "set"
5
5
  require "rack/utils"
6
6
  require "active_support/parameter_filter"
7
+ require_relative "cluster/security"
7
8
 
8
9
  module Profiler
9
10
  # The single filter applied to everything the profiler captures: request
@@ -44,6 +45,18 @@ module Profiler
44
45
  # fail on the same key inside the text.
45
46
  CONTEXT_SENSITIVE_REGEXP = /\\[AzZbBG]|[\^$]|\(\?<?[=!]/
46
47
 
48
+ # The profiler's own credentials are masked wherever they appear, by value,
49
+ # whatever name they travel under, and even with redact_sensitive_data
50
+ # off: the cluster secret opens every slave from the network, and a
51
+ # profile can be read on any node. Wraps every public filter, so that each
52
+ # capture point gets it.
53
+ module CredentialMasking
54
+ %i[filter_hash filter_value filter_named filter_headers filter_body filter_query filter_url
55
+ env_snapshot env_value env_overrides].each do |name|
56
+ define_method(name) { |*args| hide_credentials(super(*args)) }
57
+ end
58
+ end
59
+
47
60
  # Bound on the memo of names already tested against the filter.
48
61
  KEY_CACHE_LIMIT = 10_000
49
62
 
@@ -52,6 +65,41 @@ module Profiler
52
65
  PROBE = "x"
53
66
 
54
67
  class << self
68
+ prepend CredentialMasking
69
+
70
+ # Masks every occurrence of the profiler's own credentials in a value:
71
+ # a string equal to one becomes MASK, a string holding one has it
72
+ # replaced, hashes and arrays are walked. Only what changes is copied:
73
+ # a value holding no credential is returned as it is.
74
+ def hide_credentials(value)
75
+ credentials = credential_values
76
+ credentials.empty? ? value : hide_in(value, credentials)
77
+ rescue StandardError => e
78
+ report(e, "masking a profiler credential")
79
+ MASK
80
+ end
81
+
82
+ # Text cut to +max+ characters, with +ellipsis+ when cut, once the
83
+ # credentials are masked: cutting first would leave a prefix of one, or
84
+ # all of it once the pieces are put back together. Every capture point
85
+ # that shortens text goes through here.
86
+ def truncate(text, max, ellipsis = "...")
87
+ text = hide_credentials(text.to_s)
88
+ text.length > max ? "#{text[0, max]}#{ellipsis}" : text
89
+ end
90
+
91
+ # How many trailing bytes of +text+, a stream read in pieces, could be
92
+ # the start of a credential and have to wait for the next piece: the
93
+ # longest end of the text that is also the beginning of a credential,
94
+ # 0 when there is none, so that other output is shown as it comes.
95
+ def held_back_bytes(text)
96
+ bytes = text.to_s.b
97
+ credential_values.map do |credential|
98
+ start = credential.b
99
+ [start.bytesize - 1, bytes.bytesize].min.downto(1).find { |k| bytes.end_with?(start.byteslice(0, k)) } || 0
100
+ end.max || 0
101
+ end
102
+
55
103
  def enabled?
56
104
  Profiler.configuration.redact_sensitive_data != false
57
105
  end
@@ -231,6 +279,56 @@ module Profiler
231
279
 
232
280
  private
233
281
 
282
+ # Only a secret the cluster would use (Cluster::Security.secret_problem
283
+ # nil: 32 characters or more): a shorter one is ignored by the cluster,
284
+ # and masking "true" or "/" everywhere would hide the very data profiled.
285
+ def credential_values
286
+ return [] unless Cluster::Security.configured_secret?
287
+
288
+ [Profiler.configuration.cluster_secret.to_s.strip]
289
+ end
290
+
291
+ def hide_in(value, credentials)
292
+ case value
293
+ when String then hide_in_string(value, credentials)
294
+ when Hash
295
+ changes = nil
296
+ value.each do |key, v|
297
+ masked = hide_in(v, credentials)
298
+ (changes ||= {})[key] = masked unless masked.equal?(v)
299
+ end
300
+ changes ? value.merge(changes) : value
301
+ when Array
302
+ copy = nil
303
+ value.each_with_index do |v, i|
304
+ masked = hide_in(v, credentials)
305
+ next if masked.equal?(v)
306
+
307
+ (copy ||= value.dup)[i] = masked
308
+ end
309
+ copy || value
310
+ else value
311
+ end
312
+ end
313
+
314
+ # The string itself when it holds no credential, which is the common
315
+ # case and costs one search; otherwise a copy, compared as bytes so that
316
+ # a binary body never meets an incompatible encoding.
317
+ def hide_in_string(string, credentials)
318
+ credentials.reduce(string) do |text, credential|
319
+ next text unless contains?(text, credential)
320
+ next MASK if text.b.strip == credential.b
321
+
322
+ text.b.gsub(credential.b, MASK).force_encoding(text.encoding)
323
+ end
324
+ end
325
+
326
+ def contains?(text, credential)
327
+ text.include?(credential)
328
+ rescue Encoding::CompatibilityError, ArgumentError
329
+ text.b.include?(credential.b)
330
+ end
331
+
234
332
  def compiled
235
333
  filters = current_filters
236
334
  state = @compiled
@@ -24,8 +24,8 @@ module Profiler
24
24
  end
25
25
 
26
26
  globs.each do |glob|
27
- Dir.glob(File.join(root, glob)).each do |path|
28
- relative = path.sub("#{root}/", "")
27
+ # base: keeps glob metacharacters of the root ([, {, *) out of the pattern
28
+ Dir.glob(glob, base: root).each do |relative|
29
29
  parts = relative.split("/")
30
30
  dir = parts[0..-2].join("/")
31
31
  result[dir] ||= []
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "concurrent"
4
4
  require "securerandom"
5
+ require_relative "../redaction"
5
6
 
6
7
  module Profiler
7
8
  module TestRunner
@@ -24,6 +25,9 @@ module Profiler
24
25
  def initialize
25
26
  @runs = Concurrent::Hash.new
26
27
  @locks = Concurrent::Hash.new
28
+ @held = Concurrent::Hash.new
29
+ @output_finished = Concurrent::Hash.new
30
+ @held_lock = Mutex.new
27
31
  end
28
32
 
29
33
  def create(files:, framework:)
@@ -58,11 +62,38 @@ module Profiler
58
62
  run
59
63
  end
60
64
 
65
+ # Output is free text read back by the API, the SSE stream and run_tests, in the pieces it
66
+ # arrives in: the profiler's own credentials are masked by value, as in a profile. They
67
+ # are masked on the text joined with what was held back from the previous piece, and the
68
+ # last bytes that could start a credential wait for the next piece, so that none is ever
69
+ # shown cut in two. What is held back is released by finish_output, once the process has
70
+ # nothing more to print: a killed run still prints its summary after its status changed.
61
71
  def append_output(id, chunk)
62
72
  run = @runs[id]
63
73
  return unless run
64
74
 
65
- run.output_lines.push(chunk)
75
+ @held_lock.synchronize do
76
+ text = Profiler::Redaction.hide_credentials(@held.fetch(id, "".b) + chunk.to_s.b)
77
+ holdback = @output_finished[id] ? 0 : Profiler::Redaction.held_back_bytes(text)
78
+ cut = text.bytesize - holdback
79
+ @held[id] = text.byteslice(cut, text.bytesize - cut)
80
+ shown = text.byteslice(0, cut).force_encoding(chunk.to_s.encoding)
81
+ run.output_lines.push(shown) unless shown.empty?
82
+ end
83
+ signal(id)
84
+ end
85
+
86
+ # The end of the output, told by the reader of the process once it read everything: the
87
+ # bytes held back are shown, and anything appended later is shown as it comes.
88
+ def finish_output(id)
89
+ run = @runs[id]
90
+ return unless run
91
+
92
+ @held_lock.synchronize do
93
+ @output_finished[id] = true
94
+ rest = @held.delete(id)
95
+ run.output_lines.push(rest) if rest && !rest.empty?
96
+ end
66
97
  signal(id)
67
98
  end
68
99
 
@@ -97,6 +128,12 @@ module Profiler
97
128
  lock[:mutex].synchronize { lock[:cond].broadcast }
98
129
  end
99
130
 
131
+ def forget_output(id)
132
+ @held.delete(id)
133
+ @output_finished.delete(id)
134
+ true
135
+ end
136
+
100
137
  def snapshot(run, position)
101
138
  lines = run.output_lines.dup
102
139
  {
@@ -109,7 +146,7 @@ module Profiler
109
146
 
110
147
  def cleanup_old_runs
111
148
  cutoff = Time.now - TTL
112
- @runs.delete_if { |id, r| r.finished_at && r.finished_at < cutoff && @locks.delete(id) }
149
+ @runs.delete_if { |id, r| r.finished_at && r.finished_at < cutoff && @locks.delete(id) && forget_output(id) }
113
150
  end
114
151
  end
115
152