rails-profiler 0.30.8 → 0.30.10

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7edf1c383b05c79e4013d76c96fa57077b376f3a312c27aa355927ca1bb7e362
4
- data.tar.gz: b1b9b6bd25f7f739c14d2afe72309fac7455e8f2c62b5e7d81e206a09b8ba4fa
3
+ metadata.gz: 9d1f1618012f3a987e45ec2f18c565acc7cda82685806ca78801e6fb892012b3
4
+ data.tar.gz: 0f8e433032ac46e974c6a4162afa9c44610f5fd424afdcaf7f12a601f983cfab
5
5
  SHA512:
6
- metadata.gz: ca0ca79568606603601b5b33aa3ee69fb54d6497e8c8e2087f45cb8d78092ad0264ed8a8794472955e6b5608f743d198de31fcdf63493e40b7f538fd4c4d8298
7
- data.tar.gz: fd3f7cb5326209b731f11abbca7e7e0b3b226ce019ea62f32636a137d1d402c26f692dc11754e27d3144bae0814524ea7be282031d8adacfe3bda5a97d3d4cc9
6
+ metadata.gz: 30be96f28f9d20d285b9105725cb0704ed97af7ea8e34d425f7bf3fb8b4ef2f739cded05ab68b1c2931a8424058c18245e72eddb797bbee15c8d2fc1754175e7
7
+ data.tar.gz: 70021a953b7a36fdff2287d9e46242761e02ea114aa6ad16344860e04cb1973d6a87095d120216b4c60b48e9187130ca7a8461b0627c35a90f683931f3ac632c
data/CHANGELOG.md CHANGED
@@ -19,6 +19,69 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.30.10] - 2026-10-04
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Security
27
+
28
+ - **Test runner:** The test runner (the dashboard page and the MCP tool `run_tests`) only runs
29
+ the test files it discovers, `spec/**/*_spec.rb` and `test/**/*_test.rb` under the Rails root,
30
+ optionally with a line number (`spec/models/user_spec.rb:12`). Paths are compared after resolving
31
+ symbolic links. Any other path answers `422` over HTTP, or a tool error over MCP, and nothing is
32
+ started. The HTTP endpoint used to run any file under the Rails root, and `run_tests` any path at all.
33
+ - **Test runner:** A selection sent with the wrong framework (a `test/**/*_test.rb` file for
34
+ `rspec`, or a spec for `minitest`) now answers `422` too, and so does a path holding a null byte,
35
+ which used to raise an error.
36
+ - **Test runner:** The test process starts from the environment the shell gave Rails, copied when
37
+ the gem is loaded, plus the environment overrides set from the profiler, instead of the current
38
+ environment of the application. Overrides that make the test process, or the rbenv or asdf shim
39
+ that starts it, load or run other code are left out, with a warning naming them once: names
40
+ starting with `RUBY`, `GEM_`, `BUNDLE_`, `LD_`, `BASH_`, `RBENV_` and others, `NODE_OPTIONS`,
41
+ `PATH`, `SPEC_OPTS`, `PS4`, `SHELLOPTS`, any name holding a character other than a letter, a
42
+ digit or `_`, and the rest of the list in the README. Those keep their shell value, and so do
43
+ `DATABASE_URL` and `SECRET_KEY_BASE`, whose overrides were skipped but still reached the test
44
+ process, since an override is also written into the environment of the running application. A
45
+ variable deleted from the profiler is now unset in the test process, where it used to hold the
46
+ text `__profiler_deleted__`. Variables the application writes into `ENV` once loaded (`dotenv`
47
+ for instance) no longer reach the test process, which loads them itself when your test setup does.
48
+ The test process, which boots the same application, no longer replays the overrides on its own:
49
+ the runner marks it with `PROFILER_TEST_RUNNER_CHILD=1`, a name the env tools and the env vars
50
+ endpoint now refuse to set or delete (`422`, or a tool error over MCP).
51
+ - **Test runner:** Test files are found when the path of the application holds `[` or `{`.
52
+ - **Upgrading:** Nothing to do when you run the specs the profiler lists. To run any file under the
53
+ Rails root again, set `config.test_runner_allow_undiscovered_files = true`. The environment
54
+ overrides left out have no option, the list being a deny list that cannot be complete: set those
55
+ variables in the shell that starts Rails instead, the test process inherits them.
56
+
57
+ ## [0.30.9] - 2026-10-04
58
+
59
+ <!-- stamped -->
60
+
61
+ ### Security
62
+
63
+ - **Database:** Explain only read-only queries. The **Explain** button, `POST /_profiler/api/explain`
64
+ and the MCP `explain_query` tool ran `EXPLAIN ANALYZE` on any stored query, and PostgreSQL's
65
+ `EXPLAIN ANALYZE` runs the statement it explains: explaining a `DELETE`, `UPDATE` or `INSERT` the
66
+ application had just run ran it a second time, for real. Only statements starting with `SELECT`,
67
+ `WITH`, `TABLE` or `VALUES` are explained now, and a writing CTE, `SELECT ... INTO`, `FOR UPDATE`
68
+ or a second statement is refused too, with a 422 from the endpoint and an error from the MCP tool.
69
+ The probe also runs in a transaction that is always rolled back, read-only on PostgreSQL.
70
+ Upgrading: nothing to set, and no setting brings back the explaining of writes, since the old
71
+ behaviour was to run them again; to see the plan of a write, run it yourself in `psql` between
72
+ `BEGIN` and `ROLLBACK`, as the README shows.
73
+ - **Database:** Run the Explain probe on a connection of its own, opened outside the pool and closed
74
+ afterwards, so that nothing it does to its session outlives it: a `SELECT pg_try_advisory_lock(...)`
75
+ explained from a profile left the lock held by a connection of the pool. On PostgreSQL the probe
76
+ is also limited to 30 seconds (`statement_timeout`), and its EXPLAIN goes by the extended
77
+ protocol, which refuses a second statement.
78
+
79
+ ### Fixed
80
+
81
+ - **Database:** Put bind values back correctly when explaining a query with ten binds or more
82
+ (`$10` was read as `$1` followed by `0`), and leave alone a `?` or `$1` inside a string literal or
83
+ inside a value already put back.
84
+
22
85
  ## [0.30.8] - 2026-10-04
23
86
 
24
87
  <!-- stamped -->
@@ -17,6 +17,12 @@ module Profiler
17
17
  return
18
18
  end
19
19
 
20
+ if Profiler::EnvOverrideStore.reserved_key?(key)
21
+ render json: { error: "#{Profiler::EnvOverrideStore::RESERVED_KEY_ERROR}; #{key} was left unchanged" },
22
+ status: :unprocessable_entity
23
+ return
24
+ end
25
+
20
26
  value = params[:value]
21
27
 
22
28
  if Profiler::Redaction.mask?(value)
@@ -27,17 +27,11 @@ module Profiler
27
27
  return render json: { error: "No files selected" }, status: :unprocessable_entity
28
28
  end
29
29
 
30
- # Validate paths are within Rails root (prevent path traversal)
31
- root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
32
- files.each do |f|
33
- expanded = File.expand_path(File.join(root, f))
34
- unless expanded.start_with?(root)
35
- return render json: { error: "Invalid file path: #{f}" }, status: :unprocessable_entity
36
- end
37
- end
38
-
30
+ # Runner.start accepts only discovered test files
39
31
  run = Profiler::TestRunner::Runner.start(files: files, framework: framework)
40
32
  render json: run.to_h, status: :created
33
+ rescue Profiler::TestRunner::InvalidFileError => e
34
+ render json: { error: e.message }, status: :unprocessable_entity
41
35
  end
42
36
 
43
37
  def show
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ # ENV as the shell gave it to the process, captured when the gem is loaded, before
5
+ # EnvOverrideStore#apply! or any override set from the profiler writes into ENV. The test
6
+ # runner builds the environment of the processes it starts from this copy, not from ENV.
7
+ BOOT_ENV = ENV.to_h.freeze unless const_defined?(:BOOT_ENV)
8
+ end
@@ -36,7 +36,7 @@ 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,
@@ -80,6 +80,9 @@ module Profiler
80
80
  @track_console = true
81
81
  @apply_env_overrides_when_disabled = false
82
82
  @track_tests = false
83
+ # The test runner only runs the files listed by its discovery. true restores the
84
+ # previous behavior: any file under the Rails root.
85
+ @test_runner_allow_undiscovered_files = false
83
86
  @track_mailers = true
84
87
  @capture_mail_body = false
85
88
  @sanitize_mailer_recipients = false
@@ -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
@@ -1,9 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "profiler/sql_statement"
4
+
3
5
  module Profiler
4
6
  # Shared service for running EXPLAIN ANALYZE on a stored query.
5
7
  # Used by both the HTTP API controller and the MCP explain_query tool.
8
+ #
9
+ # Only read-only statements are explained: PostgreSQL's EXPLAIN ANALYZE runs the
10
+ # statement, so explaining a write would replay it. The probe itself runs, for
11
+ # what no reading of the statement can rule out (a function with a side effect),
12
+ # on a connection of its own that is closed afterwards, in a transaction that is
13
+ # always rolled back, read-only and time-limited on PostgreSQL.
6
14
  module ExplainRunner
15
+ # An ArgumentError, so callers answer it as a bad request (422, MCP error).
16
+ class UnsafeStatementError < ArgumentError; end
17
+
18
+ # PostgreSQL's EXPLAIN ANALYZE runs the query: a slow one is stopped after this.
19
+ PROBE_STATEMENT_TIMEOUT = "30s"
20
+
7
21
  # @param profile_token [String]
8
22
  # @param query_index [Integer]
9
23
  # @return [Hash] { result:, format: "json"|"text", adapter: String }
@@ -35,11 +49,14 @@ module Profiler
35
49
 
36
50
  conn = ActiveRecord::Base.connection
37
51
  adapter = conn.adapter_name.downcase
52
+ refusal = SqlStatement.read_only_refusal(sql, dialect: dialect_for(adapter))
53
+ raise UnsafeStatementError, refusal if refusal
54
+
38
55
  full_sql = reconstruct_sql(sql, binds, conn, adapter)
39
56
 
40
57
  explain_sql, format = build_explain_statement(full_sql, adapter)
41
58
 
42
- rows = conn.exec_query(explain_sql, "EXPLAIN").to_a
59
+ rows = probe(explain_sql, adapter)
43
60
 
44
61
  result = if format == "json"
45
62
  # PostgreSQL / MySQL return JSON in rows[0]["QUERY PLAN"] or rows[0]["EXPLAIN"]
@@ -55,21 +72,51 @@ module Profiler
55
72
  private
56
73
 
57
74
  def self.reconstruct_sql(sql, binds, conn, adapter)
58
- return sql if binds.empty?
75
+ SqlStatement.substitute(sql, binds, dialect: dialect_for(adapter)) { |value| conn.quote(value) }
76
+ end
59
77
 
60
- if adapter.include?("postgresql")
61
- result = sql.dup
62
- binds.each_with_index do |value, i|
63
- result = result.gsub("$#{i + 1}", conn.quote(value))
78
+ def self.dialect_for(adapter)
79
+ if adapter.include?("postgresql") then :postgresql
80
+ elsif adapter.include?("mysql") || adapter.include?("trilogy") then :mysql
81
+ else :sqlite
82
+ end
83
+ end
84
+
85
+ # Runs on a connection of its own, opened outside the pool and closed
86
+ # afterwards, so that nothing the probe does to its session outlives it (an
87
+ # advisory lock, a setting, a LISTEN), the application's own transaction is
88
+ # never touched, and a full pool does not keep Explain waiting.
89
+ # The transaction is always rolled back. On PostgreSQL it is also read-only and
90
+ # time-limited, and the EXPLAIN goes by the extended protocol, which refuses
91
+ # more than one statement: a COMMIT slipped in could not end the read-only
92
+ # transaction. Neither MySQL's EXPLAIN nor SQLite's EXPLAIN QUERY PLAN runs the
93
+ # statement.
94
+ def self.probe(explain_sql, adapter)
95
+ conn = open_probe_connection
96
+ begin
97
+ rows = nil
98
+ conn.transaction do
99
+ if adapter.include?("postgresql")
100
+ conn.execute("SET TRANSACTION READ ONLY")
101
+ conn.execute("SET LOCAL statement_timeout = #{conn.quote(PROBE_STATEMENT_TIMEOUT)}")
102
+ rows = conn.raw_connection.exec_params(explain_sql, []).to_a
103
+ else
104
+ rows = conn.exec_query(explain_sql, "EXPLAIN").to_a
105
+ end
106
+ raise ActiveRecord::Rollback
64
107
  end
65
- result
108
+ rows
109
+ ensure
110
+ conn.disconnect!
111
+ end
112
+ end
113
+
114
+ def self.open_probe_connection
115
+ db_config = ActiveRecord::Base.connection_pool.db_config
116
+ if db_config.respond_to?(:new_connection) # Active Record 7.2 and later
117
+ db_config.new_connection
66
118
  else
67
- # MySQL / SQLite: replace ? sequentially
68
- result = sql.dup
69
- binds.each do |value|
70
- result = result.sub("?", conn.quote(value))
71
- end
72
- result
119
+ ActiveRecord::Base.public_send(db_config.adapter_method, db_config.configuration_hash)
73
120
  end
74
121
  end
75
122
 
@@ -139,7 +139,7 @@ module Profiler
139
139
  ),
140
140
  define_tool(
141
141
  name: "explain_query",
142
- description: "Run EXPLAIN ANALYZE on a specific query from a profile. Returns the query execution plan with cost and row estimates. Only available in development/test environments.",
142
+ description: "Run EXPLAIN ANALYZE on a specific query from a profile. Returns the query execution plan with cost and row estimates. Only read-only statements (SELECT, WITH ... SELECT, TABLE, VALUES) are explained, in a transaction always rolled back; other queries return an error. Only available in development/test environments.",
143
143
  input_schema: {
144
144
  properties: {
145
145
  token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
@@ -389,7 +389,7 @@ module Profiler
389
389
  def define_tool(name:, description:, input_schema:, handler:)
390
390
  ::MCP::Tool.define(name: name, description: description, input_schema: input_schema) do |server_context: nil, **args|
391
391
  result = handler.call(args.transform_keys(&:to_s))
392
- ::MCP::Tool::Response.new(result)
392
+ result.is_a?(::MCP::Tool::Response) ? result : ::MCP::Tool::Response.new(result)
393
393
  end
394
394
  end
395
395
 
@@ -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." }]
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Profiler
6
+ # Reads a stored SQL statement the way the database would, one token at a time:
7
+ # comments, string literals and quoted identifiers are never mistaken for keywords
8
+ # or placeholders. Used by ExplainRunner to refuse anything but a read, and to put
9
+ # the bind values back into the statement.
10
+ module SqlStatement
11
+ # The first keyword of a statement EXPLAIN may run.
12
+ READ_ONLY_VERBS = %w[SELECT WITH TABLE VALUES].freeze
13
+
14
+ # Keywords that make a read-only verb write or lock: a CTE that writes,
15
+ # SELECT ... INTO (and MySQL's INTO OUTFILE), FOR UPDATE / FOR SHARE,
16
+ # LOCK IN SHARE MODE. Matched as whole words outside literals, so a column
17
+ # named `updated_at` is fine and an unquoted column named `share` is refused.
18
+ WRITE_KEYWORDS = %w[INSERT UPDATE DELETE MERGE TRUNCATE DROP ALTER CREATE INTO LOCK SHARE].freeze
19
+
20
+ Token = Struct.new(:type, :text)
21
+
22
+ # 'it''s' everywhere; 'it\'s' too where a backslash escapes. An unterminated
23
+ # literal runs to the end: the database refuses such a statement anyway.
24
+ PLAIN_LITERAL = /[Nn]?'(?:[^']|'')*'?/m
25
+ BACKSLASH_LITERAL = /[Nn]?'(?:[^'\\]|''|\\.)*'?/m
26
+ ESCAPE_LITERAL = /[Ee]'(?:[^'\\]|''|\\.)*'?/m
27
+
28
+ # Dialects differ in what a literal or a comment is: a backslash escapes a quote
29
+ # in every MySQL string but only in PostgreSQL's E'...' strings, dollar quoting
30
+ # is PostgreSQL's, backticks quote identifiers in MySQL and SQLite, MySQL starts
31
+ # a comment with `#` and needs a space after `--`, and only PostgreSQL nests
32
+ # block comments.
33
+ DIALECTS = %i[postgresql mysql sqlite].freeze
34
+
35
+ # @param dialect [Symbol] one of DIALECTS
36
+ # @return [String, nil] why the statement must not be explained, or nil when it reads only
37
+ def self.read_only_refusal(sql, dialect:)
38
+ all = tokenize(sql, dialect)
39
+ if dialect == :mysql && all.any? { |t| t.type == :comment && t.text.start_with?("/*!") }
40
+ return refusal("it holds a MySQL executable comment (/*! ... */)")
41
+ end
42
+
43
+ tokens = all.reject { |t| %i[space comment].include?(t.type) }
44
+ first = tokens.find { |t| t.type != :punct || t.text != "(" }
45
+ return refusal("it is empty") if first.nil?
46
+
47
+ verb = first.text.upcase
48
+ return refusal("it starts with #{verb}") unless first.type == :word && READ_ONLY_VERBS.include?(verb)
49
+
50
+ tokens.each_with_index do |token, i|
51
+ if token.type == :word && WRITE_KEYWORDS.include?(token.text.upcase)
52
+ return refusal("it contains #{token.text.upcase}")
53
+ end
54
+ if token.type == :punct && token.text == ";" && i != tokens.size - 1
55
+ return refusal("it holds more than one statement")
56
+ end
57
+ end
58
+ nil
59
+ end
60
+
61
+ # Replaces each placeholder by its quoted value in a single pass: `$1`, `$10`...
62
+ # by number (PostgreSQL), or `?` in order (MySQL, SQLite). Placeholders inside
63
+ # literals or comments stay as they are, and a substituted value is never read
64
+ # again. A placeholder without a value is left in place.
65
+ def self.substitute(sql, binds, dialect:, &quote)
66
+ return sql if binds.empty?
67
+
68
+ position = 0
69
+ tokenize(sql, dialect).map do |token|
70
+ index = case token.type
71
+ when :numbered then token.text[1..].to_i - 1
72
+ when :question then (position += 1) - 1
73
+ end
74
+ index && index >= 0 && index < binds.size ? quote.call(binds[index]) : token.text
75
+ end.join
76
+ end
77
+
78
+ def self.refusal(reason)
79
+ "EXPLAIN refused: only read-only statements (SELECT, WITH ... SELECT, TABLE, VALUES) " \
80
+ "are explained, and #{reason}. PostgreSQL's EXPLAIN ANALYZE runs the statement it explains."
81
+ end
82
+
83
+ # Splits the statement into tokens whose texts, joined, give it back unchanged.
84
+ def self.tokenize(sql, dialect)
85
+ raise ArgumentError, "unknown SQL dialect: #{dialect.inspect}" unless DIALECTS.include?(dialect)
86
+
87
+ s = StringScanner.new(sql.to_s)
88
+ tokens = []
89
+ until s.eos?
90
+ tokens << if s.scan(/\s+/)
91
+ Token.new(:space, s.matched)
92
+ elsif s.scan(dialect == :mysql ? /(?:--(?=\s|\z)|#)[^\n]*/ : /--[^\n]*/)
93
+ Token.new(:comment, s.matched)
94
+ elsif s.check(%r{/\*})
95
+ Token.new(:comment, scan_block_comment(s, nested: dialect == :postgresql))
96
+ elsif s.scan(dialect == :mysql ? BACKSLASH_LITERAL : PLAIN_LITERAL) ||
97
+ (dialect == :postgresql && s.scan(ESCAPE_LITERAL))
98
+ Token.new(:literal, s.matched)
99
+ elsif s.scan(/"(?:[^"]|"")*"?/m) || (dialect != :postgresql && s.scan(/`(?:[^`]|``)*`?/m))
100
+ Token.new(:identifier, s.matched)
101
+ elsif dialect == :postgresql && s.scan(/\$\d+/)
102
+ Token.new(:numbered, s.matched)
103
+ elsif dialect == :postgresql && s.scan(/\$([A-Za-z_][A-Za-z0-9_]*)?\$/)
104
+ tag = s.matched
105
+ body = s.scan_until(/#{Regexp.escape(tag)}/) || s.rest.tap { s.terminate }
106
+ Token.new(:literal, tag + body)
107
+ elsif dialect != :postgresql && s.scan(/\?/)
108
+ Token.new(:question, s.matched)
109
+ elsif s.scan(/[A-Za-z_][A-Za-z0-9_$]*/)
110
+ Token.new(:word, s.matched)
111
+ else
112
+ Token.new(:punct, s.getch)
113
+ end
114
+ end
115
+ tokens
116
+ end
117
+
118
+ # An unterminated block comment runs to the end.
119
+ def self.scan_block_comment(s, nested:)
120
+ text = +""
121
+ depth = 0
122
+ until s.eos?
123
+ if s.scan(%r{/\*})
124
+ depth += 1 if nested || depth.zero?
125
+ elsif s.scan(%r{\*/})
126
+ depth -= 1
127
+ else
128
+ s.getch
129
+ end
130
+ text << s.matched
131
+ break if depth.zero?
132
+ end
133
+ text
134
+ end
135
+
136
+ private_class_method :refusal, :tokenize, :scan_block_comment
137
+ end
138
+ end
@@ -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] ||= []
@@ -1,12 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+ require_relative "../boot_env"
5
+ require_relative "../env_override_store"
3
6
  require_relative "run_store"
4
7
  require_relative "discovery"
5
8
 
6
9
  module Profiler
7
10
  module TestRunner
11
+ # Raised by Runner.start when the selection holds a file that is not a discovered test.
12
+ class InvalidFileError < ArgumentError; end
13
+
8
14
  class Runner
15
+ # "spec/models/user_spec.rb:12" or ":12:30": the line selection rspec and minitest accept.
16
+ LINE_SUFFIX = /\A(.+?)((?::\d+)+)\z/
17
+
18
+ @warn_mutex = Mutex.new
19
+ @warned = {}
20
+
21
+ # Every caller (the HTTP controller, the MCP run_tests tool) goes through here, so the
22
+ # selection is checked once, before any run is recorded or any process is started.
9
23
  def self.start(files:, framework:)
24
+ validate_files!(files, framework)
10
25
  run = Profiler::TestRunner.run_store.create(files: files, framework: framework)
11
26
  spawn_async(run)
12
27
  run
@@ -26,6 +41,77 @@ module Profiler
26
41
  end
27
42
  end
28
43
 
44
+ # Accepts only files listed by Discovery.files for the framework, compared by their
45
+ # canonical path (File.realpath, symbolic links resolved), so that a test run cannot be
46
+ # used to execute any other Ruby file of the application.
47
+ def self.validate_files!(files, framework)
48
+ files = Array(files)
49
+ raise InvalidFileError, "No files selected" if files.empty?
50
+
51
+ root = project_root
52
+ allowed = if Profiler.configuration.test_runner_allow_undiscovered_files
53
+ warn_once(:undiscovered,
54
+ "[Profiler] config.test_runner_allow_undiscovered_files is enabled: the test runner " \
55
+ "runs any file under the Rails root, not only the discovered tests.")
56
+ nil
57
+ else
58
+ discovered_real_paths(root, framework)
59
+ end
60
+
61
+ refused = files.reject do |entry|
62
+ next false unless entry.is_a?(String) && !entry.empty? && !entry.include?("\0")
63
+
64
+ path = entry[LINE_SUFFIX, 1] || entry
65
+ allowed ? allowed.include?(real_path(File.join(root, path))) : under_root?(root, File.join(root, path))
66
+ end
67
+ return if refused.empty?
68
+
69
+ message = allowed ? "Not a discovered test file" : "Not a file under the Rails root"
70
+ raise InvalidFileError, "#{message}: #{refused.map(&:to_s).join(", ")}"
71
+ end
72
+
73
+ def self.discovered_real_paths(root, framework)
74
+ real_root = real_path(root)
75
+ Discovery.files(framework: framework).flat_map { |dir| dir[:files] }.filter_map do |file|
76
+ path = real_path(File.join(root, file[:path]))
77
+ # A discovered symbolic link whose target leaves the project is not a project test.
78
+ path if path && real_root && path.start_with?("#{real_root}/")
79
+ end.to_set
80
+ end
81
+
82
+ def self.real_path(path)
83
+ File.realpath(path)
84
+ rescue SystemCallError, ArgumentError
85
+ nil
86
+ end
87
+
88
+ def self.under_root?(root, path)
89
+ expanded = File.expand_path(path)
90
+ expanded == root || expanded.start_with?("#{root}/")
91
+ end
92
+
93
+ def self.project_root
94
+ defined?(Rails) ? Rails.root.to_s : Dir.pwd
95
+ end
96
+
97
+ def self.warn_once(key, message)
98
+ @warn_mutex.synchronize do
99
+ return if @warned[key]
100
+
101
+ @warned[key] = true
102
+ end
103
+
104
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
105
+ Rails.logger.warn(message)
106
+ else
107
+ warn(message)
108
+ end
109
+ end
110
+
111
+ def self.reset_warnings!
112
+ @warn_mutex.synchronize { @warned = {} }
113
+ end
114
+
29
115
  private
30
116
 
31
117
  def self.spawn_async(run)
@@ -47,7 +133,8 @@ module Profiler
47
133
 
48
134
  Profiler::TestRunner.run_store.update(run.id, status: "running", started_at: Time.now)
49
135
 
50
- IO.popen([env, *cmd, err: [:child, :out]], "r") do |io|
136
+ # unsetenv_others: the child gets exactly env, not the ENV of this process
137
+ IO.popen([env, *cmd, unsetenv_others: true, err: [:child, :out]], "r") do |io|
51
138
  Profiler::TestRunner.run_store.update(run.id, pid: io.pid)
52
139
 
53
140
  while (chunk = io.read(256))
@@ -69,7 +156,7 @@ module Profiler
69
156
  end
70
157
 
71
158
  def self.build_command(files, framework)
72
- root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
159
+ root = project_root
73
160
  absolute_files = files.map { |f| File.join(root, f) }
74
161
 
75
162
  case framework.to_sym
@@ -82,22 +169,63 @@ module Profiler
82
169
  end
83
170
  end
84
171
 
85
- BLOCKED_ENV_KEYS = %w[RAILS_ENV RACK_ENV DATABASE_URL SECRET_KEY_BASE].freeze
172
+ BLOCKED_ENV_KEYS = %w[RAILS_ENV RACK_ENV DATABASE_URL SECRET_KEY_BASE PROFILER_TEST_RUNNER_CHILD].freeze
173
+
174
+ # Overrides of these variables make the test process, or the shell shims (rbenv, asdf) that
175
+ # start it, load or run code other than the selected tests. It is a deny list, so it cannot
176
+ # be complete; a variable needed by the tests can be set in the shell that starts Rails.
177
+ CODE_LOADING_ENV_KEYS = %w[
178
+ SPEC_OPTS TESTOPTS TEST PATH HOME XDG_CONFIG_HOME GEMRC NODE_OPTIONS NODE_PATH
179
+ SHELLOPTS BASHOPTS PS4 ENV CDPATH IFS
180
+ ].freeze
181
+ CODE_LOADING_ENV_PREFIXES = %w[
182
+ RUBY GEM_ BUNDLE_ BUNDLER_ LD_ DYLD_ BASH_ RBENV_ ASDF_ RVM_ CHRUBY GIT_ BOOTSNAP_
183
+ PYTHON PERL5
184
+ ].freeze
185
+ ENV_NAME = /\A[A-Z_][A-Z0-9_]*\z/i
186
+
187
+ def self.code_loading_env_key?(key)
188
+ name = key.to_s.upcase
189
+ return true unless ENV_NAME.match?(name)
190
+
191
+ CODE_LOADING_ENV_KEYS.include?(name) || CODE_LOADING_ENV_PREFIXES.any? { |prefix| name.start_with?(prefix) }
192
+ end
86
193
 
194
+ # Starts from the copy of ENV taken when the gem was loaded, not from ENV: the overrides are
195
+ # also written into ENV (by the env vars endpoint, the MCP tools and EnvOverrideStore#apply!),
196
+ # and one whose entry the store lost would pass unnoticed. Only the overrides read from the
197
+ # store and admitted are applied on top; a left-out or blocked key keeps its shell value.
87
198
  def self.build_env
88
- base = ENV.to_h
199
+ base = Profiler::BOOT_ENV.dup
200
+ left_out = []
89
201
 
90
- # Inject env var overrides configured in the profiler — skip blocked keys
91
202
  overrides = Profiler.env_override_store.all_overrides
92
203
  overrides.each do |key, entry|
93
- next if BLOCKED_ENV_KEYS.include?(key.upcase)
204
+ blocked = BLOCKED_ENV_KEYS.include?(key.upcase)
205
+ if blocked || code_loading_env_key?(key)
206
+ left_out << key unless blocked
207
+ next
208
+ end
94
209
  value = entry.is_a?(Hash) ? entry["value"] : entry
95
- base[key] = value
210
+ if value == EnvOverrideStore::DELETED_SENTINEL
211
+ base.delete(key)
212
+ else
213
+ base[key] = value
214
+ end
215
+ end
216
+
217
+ unless left_out.empty?
218
+ warn_once([:env, left_out.sort],
219
+ "[Profiler] Environment overrides not passed to the test runner, because they make it " \
220
+ "load other code: #{left_out.sort.join(", ")}.")
96
221
  end
97
222
 
98
223
  # Ensure test environment regardless of overrides
99
224
  base["RAILS_ENV"] = "test"
100
225
  base["RACK_ENV"] = "test"
226
+ # The test process boots the same application: its EnvOverrideStore#apply! must not
227
+ # replay the overrides left out above
228
+ base[EnvOverrideStore::TEST_RUNNER_CHILD_ENV] = "1"
101
229
 
102
230
  base
103
231
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.30.8"
4
+ VERSION = "0.30.10"
5
5
  end
data/lib/profiler.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # First, so that the copy of ENV is taken before anything writes into it
4
+ require_relative "profiler/boot_env"
3
5
  require_relative "profiler/version"
4
6
  require_relative "profiler/configuration"
5
7
  require_relative "profiler/redaction"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.8
4
+ version: 0.30.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
@@ -58,14 +58,14 @@ dependencies:
58
58
  requirements:
59
59
  - - ">="
60
60
  - !ruby/object:Gem::Version
61
- version: '0'
61
+ version: '0.3'
62
62
  type: :runtime
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - ">="
67
67
  - !ruby/object:Gem::Version
68
- version: '0'
68
+ version: '0.3'
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: webmock
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -133,6 +133,7 @@ files:
133
133
  - config/routes.rb
134
134
  - exe/profiler-mcp
135
135
  - lib/profiler.rb
136
+ - lib/profiler/boot_env.rb
136
137
  - lib/profiler/cluster/master_client.rb
137
138
  - lib/profiler/cluster/slave_proxy.rb
138
139
  - lib/profiler/cluster/slave_registry.rb
@@ -210,6 +211,7 @@ files:
210
211
  - lib/profiler/models/timeline_event.rb
211
212
  - lib/profiler/railtie.rb
212
213
  - lib/profiler/redaction.rb
214
+ - lib/profiler/sql_statement.rb
213
215
  - lib/profiler/sse/bus.rb
214
216
  - lib/profiler/sse/event_bus.rb
215
217
  - lib/profiler/sse/redis_event_bus.rb