woods 1.6.3 → 1.6.4

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.
@@ -3,6 +3,9 @@
3
3
  require 'json'
4
4
  require 'woods/mcp/http_transport_options'
5
5
  require 'woods/observability/structured_logger'
6
+ require 'woods/mcp/bearer_auth'
7
+ require 'woods/mcp/origin_guard'
8
+ require 'rack/request'
6
9
 
7
10
  module Woods
8
11
  module Console
@@ -66,7 +69,7 @@ module Woods
66
69
  # If your threat model requires stricter isolation, use the bridge mode instead
67
70
  # (docs/CONSOLE_MCP_SETUP.md) which runs the executor in a separate process.
68
71
  #
69
- class RackMiddleware
72
+ class RackMiddleware # rubocop:disable Metrics/ClassLength -- mount-local authentication precedes legacy construction
70
73
  # @param app [#call] The next Rack app in the middleware stack
71
74
  # @param path [String] URL path to mount the MCP endpoint (default: '/mcp/console')
72
75
  # @param embedded_read_tools [Boolean] Enable sql/query tools in embedded mode (default: false)
@@ -77,8 +80,14 @@ module Woods
77
80
  # @param unsafe_eval_audit_log_path [String, Pathname, nil] JSONL audit log
78
81
  # path for every `console_eval` run. Required on the opt-in path. Takes
79
82
  # precedence over `config.console_unsafe_eval_audit_log_path`.
80
- def initialize(app, path: '/mcp/console', embedded_read_tools: false,
81
- unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil)
83
+ def initialize(app, options = {}, **keywords)
84
+ raise TypeError, 'middleware options must be a Hash' unless options.is_a?(Hash)
85
+
86
+ initialize_options(app, **options, **keywords)
87
+ end
88
+
89
+ def initialize_options(app, path: '/mcp/console', embedded_read_tools: false, origin_policy: nil, # rubocop:disable Metrics/ParameterLists -- preserves legacy eval options alongside the shared HTTP policy
90
+ unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil)
82
91
  @app = app
83
92
  @path = path
84
93
  @embedded_read_tools = embedded_read_tools
@@ -86,7 +95,15 @@ module Woods
86
95
  @unsafe_eval_audit_log_path = unsafe_eval_audit_log_path
87
96
  @mutex = Mutex.new
88
97
  @transport = nil
98
+ @origin_policy = origin_policy || Woods::MCP::OriginPolicy.new(
99
+ allowed_origins: Woods.configuration&.console_mcp_allowed_origins
100
+ )
101
+ authenticated = Woods::MCP::BearerAuth.new(
102
+ method(:handle_request), token: -> { Woods.configuration.console_mcp_token }
103
+ )
104
+ @guarded_request = Woods::MCP::OriginGuard.new(authenticated, policy: @origin_policy)
89
105
  end
106
+ private :initialize_options
90
107
 
91
108
  DISABLED_BODY = JSON.generate(
92
109
  error: 'woods_console_disabled',
@@ -108,11 +125,15 @@ module Woods
108
125
  return @app.call(env) unless env['PATH_INFO'].start_with?(@path)
109
126
  return [410, { 'content-type' => 'application/json' }, [DISABLED_BODY]] unless enabled?
110
127
 
111
- ensure_transport.handle_request(Rack::Request.new(env))
128
+ @guarded_request.call(env)
112
129
  end
113
130
 
114
131
  private
115
132
 
133
+ def handle_request(env)
134
+ ensure_transport.handle_request(Rack::Request.new(env))
135
+ end
136
+
116
137
  def enabled?
117
138
  Woods.configuration.console_mcp_enabled
118
139
  end
@@ -133,7 +154,7 @@ module Woods
133
154
 
134
155
  server = build_embedded_server
135
156
  @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
136
- server, **Woods::MCP::HttpTransportOptions.for(Woods.configuration.console_mcp_allowed_origins)
157
+ server, **@origin_policy.transport_options
137
158
  )
138
159
  server.transport = @transport
139
160
  @transport
@@ -179,7 +200,7 @@ module Woods
179
200
  config = Woods.configuration
180
201
  introspection = build_model_introspection
181
202
  Server.build_embedded(
182
- model_validator: ModelValidator.new(registry: introspection[:registry]),
203
+ model_validator: ModelValidator.new(registry: introspection[:registry], table_names: introspection[:tables]),
183
204
  safe_context: SafeContext.new(pool: ActiveRecord::Base.connection_pool),
184
205
  redacted_columns: Array(config&.console_redacted_columns),
185
206
  redacted_key_values: Array(config&.console_redacted_key_values),
@@ -65,7 +65,8 @@ module Woods
65
65
  case key
66
66
  when 'record' then value.is_a?(Hash) ? ctx.redact(value) : value
67
67
  when 'records' then redact_hash_array(value, ctx)
68
- when 'rows', 'values' then redact_positional(value, plan)
68
+ when 'rows' then redact_positional(value, plan)
69
+ when 'values' then redact_positional(value, plan, single_column: plan[:column_count] == 1)
69
70
  when 'associations' then redact_association_map(value, ctx)
70
71
  else value
71
72
  end
@@ -93,7 +94,8 @@ module Woods
93
94
  # `columns` header: the column-name mask plus any EAV key-value rules
94
95
  # resolved to column indexes.
95
96
  def positional_plan(columns, ctx)
96
- { mask: positional_mask(columns, ctx),
97
+ { column_count: columns.is_a?(Array) ? columns.length : nil,
98
+ mask: positional_mask(columns, ctx),
97
99
  kv_rules: positional_kv_rules(columns, ctx) }
98
100
  end
99
101
 
@@ -112,35 +114,65 @@ module Woods
112
114
  # Resolve EAV patterns against a `columns` header into concrete index
113
115
  # pairs. A rule only fires when both key_column and value_column are
114
116
  # present in the header, and costs nothing per row otherwise.
117
+ #
118
+ # A duplicated key or value header (an `AS` alias can shadow the real
119
+ # column — CON-1) makes index attribution ambiguous: a last-index-wins
120
+ # lookup would let the shadow steal the mask from the secret. The
121
+ # executor refuses those selects up front; here, defense-in-depth
122
+ # fails toward masking — every cell under a value-named header is
123
+ # redacted unconditionally.
115
124
  def positional_kv_rules(columns, ctx)
116
125
  return [] unless columns.is_a?(Array)
117
126
 
118
- index = columns.each_with_index.to_h { |name, idx| [name.to_s, idx] }
119
- ctx.redacted_key_values.filter_map do |pattern|
120
- key_idx = index[pattern['key_column']]
121
- val_idx = index[pattern['value_column']]
122
- next unless key_idx && val_idx
127
+ names = columns.map(&:to_s)
128
+ ctx.redacted_key_values.filter_map { |pattern| positional_kv_rule(names, pattern, ctx) }
129
+ end
123
130
 
124
- { key_idx: key_idx, val_idx: val_idx, sensitive: pattern['sensitive_keys'] }
125
- end
131
+ # One resolved rule for one EAV pattern, or nil when the header lacks
132
+ # either column. Unambiguous headers get the key/value index pair;
133
+ # duplicated headers get the unconditional mask list.
134
+ def positional_kv_rule(names, pattern, ctx)
135
+ key_idxs = names.each_index.select { |i| names[i] == pattern['key_column'] }
136
+ val_idxs = names.each_index.select { |i| names[i] == pattern['value_column'] }
137
+ return nil if key_idxs.empty? || val_idxs.empty?
138
+ return { mask_idxs: val_idxs } unless key_idxs.one? && val_idxs.one?
139
+
140
+ { key_idx: key_idxs.first, val_idx: val_idxs.first,
141
+ key_matches: ->(value) { sensitive_key?(ctx, value, pattern) } }
142
+ end
143
+
144
+ def sensitive_key?(ctx, value, pattern)
145
+ return ctx.sensitive_key?(value, pattern) if ctx.respond_to?(:sensitive_key?)
146
+
147
+ pattern['sensitive_keys'].include?(value.to_s)
126
148
  end
127
149
 
128
150
  # Redact positional row data using a precomputed plan. Handles both
129
- # nested arrays (multi-column pluck, sql/query rows) and flat scalar
130
- # arrays (pluck with a single column — Rails collapses the result).
131
- def redact_positional(rows, plan)
151
+ # nested arrays (multi-column pluck, sql/query rows) and single-column
152
+ # pluck values. Rails collapses the row for single-column pluck, so an
153
+ # Array/Hash value is still one cell and must be redacted as a whole.
154
+ def redact_positional(rows, plan, single_column: false)
132
155
  return rows unless rows.is_a?(Array)
133
156
  return rows if plan[:mask].nil? && plan[:kv_rules].empty?
134
157
 
135
158
  rows.map do |row|
136
- row.is_a?(Array) ? redact_row(row, plan) : redact_scalar(row, plan[:mask])
159
+ if !single_column && row.is_a?(Array)
160
+ redact_row(row, plan)
161
+ else
162
+ redact_scalar(row, plan[:mask])
163
+ end
137
164
  end
138
165
  end
139
166
 
140
167
  def redact_row(row, plan)
141
168
  result = apply_mask(row, plan[:mask])
142
169
  plan[:kv_rules].each do |rule|
143
- result[rule[:val_idx]] = '[REDACTED]' if rule[:sensitive].include?(row[rule[:key_idx]].to_s)
170
+ if rule[:mask_idxs]
171
+ # Ambiguous (duplicated) headers: mask every value-named cell.
172
+ rule[:mask_idxs].each { |idx| result[idx] = '[REDACTED]' }
173
+ elsif rule[:key_matches].call(row[rule[:key_idx]])
174
+ result[rule[:val_idx]] = '[REDACTED]'
175
+ end
144
176
  end
145
177
  result
146
178
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'adapter_family'
4
+
3
5
  # Stub for environments that don't load ActiveRecord
4
6
  unless defined?(ActiveRecord::Rollback)
5
7
  module ActiveRecord
@@ -86,7 +88,7 @@ module Woods
86
88
  # ]
87
89
  # )
88
90
  #
89
- class SafeContext
91
+ class SafeContext # rubocop:disable Metrics/ClassLength
90
92
  # Thread-local key that exposes the connection currently leased for
91
93
  # the in-flight #execute block. Handlers should prefer this over
92
94
  # acquiring their own connection so every request stays on a single
@@ -183,6 +185,36 @@ module Woods
183
185
  apply_key_value_redaction(redacted)
184
186
  end
185
187
 
188
+ # Add runtime key types to a request-local redaction view. The original
189
+ # context and its execution policy are unchanged.
190
+ # @param types [Hash<String, Array<Object>>] Active Record attribute types
191
+ # @param raw [Boolean] whether keys are raw database cells
192
+ # @return [SafeContext]
193
+ def with_key_value_types(types, raw: false)
194
+ dup.tap do |context|
195
+ context.instance_variable_set(:@key_value_types, types)
196
+ context.instance_variable_set(:@raw_key_values, raw)
197
+ end
198
+ end
199
+
200
+ # Match the database spelling and its application representation. If a
201
+ # type cannot safely transform a key, protect the value rather than
202
+ # returning a value whose sensitivity could not be established.
203
+ # @param value [Object] raw or cast EAV key
204
+ # @param pattern [Hash] normalized EAV policy
205
+ # @return [Boolean]
206
+ def sensitive_key?(value, pattern)
207
+ sensitive = pattern['sensitive_keys']
208
+ return true if sensitive.include?(value.to_s)
209
+
210
+ Array(@key_value_types&.fetch(pattern['key_column'], nil)).any? do |type|
211
+ alternate = @raw_key_values ? type.deserialize(value) : type.serialize(value)
212
+ sensitive.include?(alternate.to_s)
213
+ rescue StandardError
214
+ true
215
+ end
216
+ end
217
+
186
218
  private
187
219
 
188
220
  # Wrap one connection in a rolled-back transaction with timeout, and
@@ -194,8 +226,12 @@ module Woods
194
226
  Thread.current[LEASED_CONNECTION_KEY] = connection
195
227
  result = nil
196
228
  connection.transaction do
197
- set_timeout(connection)
198
- result = yield(connection)
229
+ restore_timeout = set_timeout(connection)
230
+ begin
231
+ result = yield(connection)
232
+ ensure
233
+ restore_timeout&.call
234
+ end
199
235
  raise ActiveRecord::Rollback
200
236
  end
201
237
  result
@@ -225,7 +261,7 @@ module Woods
225
261
  key_col = pattern['key_column']
226
262
  val_col = pattern['value_column']
227
263
  next unless hash.key?(key_col) && hash.key?(val_col)
228
- next unless pattern['sensitive_keys'].include?(hash[key_col].to_s)
264
+ next unless sensitive_key?(hash[key_col], pattern)
229
265
 
230
266
  hash[val_col] = '[REDACTED]'
231
267
  end
@@ -241,14 +277,16 @@ module Woods
241
277
  # request, background job, etc.). Safe here because every #execute
242
278
  # is wrapped in a transaction.
243
279
  #
244
- # MySQL uses `SET max_execution_time` (applies to SELECT only — DDL
245
- # and DML statements cannot be time-limited via this variable).
280
+ # MySQL uses session-scoped `SET max_execution_time` for SELECTs.
281
+ # Return a callback that restores the previous value before releasing
282
+ # the pooled connection; rollback alone does not reset this setting.
246
283
  def set_timeout(connection, timeout_ms = @timeout_ms)
247
284
  adapter = connection.adapter_name.downcase
248
- if adapter.include?('mysql')
249
- connection.execute("SET max_execution_time = #{timeout_ms.to_i}")
285
+ if AdapterFamily.for(connection) == :mysql
286
+ set_mysql_timeout(connection, timeout_ms)
250
287
  else
251
288
  connection.execute("SET LOCAL statement_timeout = '#{timeout_ms.to_i}ms'")
289
+ nil
252
290
  end
253
291
  rescue StandardError => e
254
292
  # Unsupported adapter (SQLite, Trilogy on unsupported version, Oracle) —
@@ -259,6 +297,16 @@ module Woods
259
297
  nil
260
298
  end
261
299
 
300
+ def set_mysql_timeout(connection, timeout_ms)
301
+ maria = connection.respond_to?(:mariadb?) && connection.mariadb?
302
+ variable = maria ? 'max_statement_time' : 'max_execution_time'
303
+ timeout = maria ? timeout_ms.to_i / 1000.0 : timeout_ms.to_i
304
+ previous_value = Float(connection.select_value("SELECT @@SESSION.#{variable}"))
305
+ connection.execute("SET #{variable} = #{timeout}")
306
+ previous_value = previous_value.to_i unless maria
307
+ -> { connection.execute("SET #{variable} = #{previous_value}") }
308
+ end
309
+
262
310
  def warn_timeout_unsupported(adapter, error)
263
311
  return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
264
312
 
@@ -176,7 +176,7 @@ module Woods
176
176
  # @param unsafe_eval_audit_log_path [String, Pathname, nil] JSONL audit log
177
177
  # path for `console_eval`. Required when the opt-in is on.
178
178
  # @return [MCP::Server] Configured server ready for transport
179
- def build_embedded(model_validator:, safe_context:, redacted_columns: [], # rubocop:disable Metrics/ParameterLists
179
+ def build_embedded(model_validator:, safe_context:, redacted_columns: [], # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
180
180
  redacted_key_values: [], connection: nil,
181
181
  read_tools_enabled: false, model_tables: {},
182
182
  model_reflections: {},
@@ -201,6 +201,7 @@ module Woods
201
201
  table_gate = ctx&.table_gate
202
202
  executor = EmbeddedExecutor.new(
203
203
  model_validator: model_validator, safe_context: safe_context,
204
+ redaction_context: safe_ctx,
204
205
  connection: connection, read_tools_enabled: read_tools_enabled,
205
206
  table_gate: table_gate,
206
207
  eval_guard: eval_wiring[:eval_guard],
@@ -30,8 +30,8 @@ module Woods
30
30
  # newline-separated statement structure is preserved for callers that
31
31
  # check for multiple statements.
32
32
  #
33
- # Block comments are non-nested — real SQL engines do not support nested
34
- # block comments, and neither does this stripper.
33
+ # This legacy helper is not a security scanner. Use strip_noise for
34
+ # quote-aware and PostgreSQL nested-comment handling.
35
35
  #
36
36
  # @param sql [String] the SQL string to process
37
37
  # @return [String] a new string with all SQL comments removed
@@ -59,9 +59,8 @@ module Woods
59
59
  # Dollar-quoted strings (`$$...$$`, `$tag$...$tag$`) are also stripped.
60
60
  # - `:mysql` — single-quoted strings support both `\'` (backslash-escape)
61
61
  # and `''` (doubled-quote) as apostrophe escapes. Dollar-quoted strings
62
- # are also stripped (MySQL does not use them, but stripping them is
63
- # harmless and keeps the two dialects consistent).
64
- # - `:sqlite` — doubled apostrophes escape strings; dollar signs stay literal.
62
+ # are not recognized by the combined MySQL security scanner.
63
+ # - `:sqlite` — doubled apostrophes escape strings; backslashes and dollar signs are literal.
65
64
  # @return [String] a new string with all string literals replaced by `''`
66
65
  # @raise [ArgumentError] if an unsupported dialect is provided
67
66
  DOLLAR_QUOTED = /\$(\w*)\$.*?\$\1\$/m
@@ -98,13 +97,30 @@ module Woods
98
97
  # never under-detect: an unterminated literal is treated as an ordinary
99
98
  # character rather than swallowing the rest of the statement.
100
99
  #
100
+ # `#` opens a MySQL line comment, mirroring `--`, but only under the
101
+ # `:mysql` dialect — PostgreSQL does not treat `#` as a comment, and
102
+ # collapsing it there would hide SQL that a real PostgreSQL server
103
+ # still executes. A MySQL `/*! ... */` executable comment is left
104
+ # visible as one span, including MariaDB's `/*M! ... */` spelling:
105
+ # its body can execute, so it must stay visible to every downstream
106
+ # scan without changing literal state outside the span. Leaving it
107
+ # visible under `:postgres` too is over-detection at
108
+ # worst, never under-detection, on a server where it really is inert.
109
+ # An ordinary `/* ... */` block comment is replaced by a single
110
+ # newline rather than vanishing outright, mirroring how a `--`/`#`
111
+ # line comment's own trailing newline survives: SqlValidator's
112
+ # statement-leader scan needs a durable marker showing a comment sat
113
+ # here so a comment-hidden statement (`SELECT 1 /*;*/ DELETE ...`)
114
+ # still reads as following a boundary once comments are gone.
115
+ #
101
116
  # @param sql [String] the SQL string to process
102
117
  # @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite` — controls
103
- # single-quote escape rules (see {.strip_literals}).
118
+ # single-quote escape rules (see {.strip_literals}) and whether `#`
119
+ # opens a line comment. MySQL quote flags reflect session sql_mode.
104
120
  # @return [String] a new string with comments removed and every string
105
121
  # literal replaced by `''`
106
122
  # @raise [ArgumentError] if an unsupported dialect is provided
107
- def self.strip_noise(sql, dialect: :postgres) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/AbcSize
123
+ def self.strip_noise(sql, dialect: :postgres, ansi_quotes: false, no_backslash_escapes: false) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/AbcSize
108
124
  unless SUPPORTED_DIALECTS.include?(dialect)
109
125
  raise ArgumentError, "Unknown dialect #{dialect.inspect}. Supported: #{SUPPORTED_DIALECTS.inspect}"
110
126
  end
@@ -118,7 +134,11 @@ module Woods
118
134
  ch = sql[i]
119
135
 
120
136
  if ch == "'"
121
- close = single_quote_end(sql, i, mysql: mysql)
137
+ close = single_quote_end(
138
+ sql, i,
139
+ backslash_escapes: (mysql && !no_backslash_escapes) ||
140
+ (dialect == :postgres && postgres_escape_string?(sql, i))
141
+ )
122
142
  if close
123
143
  out << "''"
124
144
  i = close
@@ -127,16 +147,33 @@ module Woods
127
147
  out << ch
128
148
  i += 1
129
149
  end
130
- elsif ['"', '`'].include?(ch)
131
- close = quoted_identifier_end(sql, i, ch)
150
+ elsif ch == '"'
151
+ close = quoted_span_end(sql, i, quote: '"',
152
+ backslash_escapes: mysql && !ansi_quotes && !no_backslash_escapes)
132
153
  if close
154
+ # MySQL parses double quotes as strings unless ANSI_QUOTES is
155
+ # enabled. Treating them as literals prevents a `#` inside the
156
+ # value from hiding live SQL. PostgreSQL uses them for
157
+ # identifiers, which must remain visible to table/column scans.
158
+ out << double_quote_replacement(sql, i, close, mysql: mysql && !ansi_quotes)
159
+ i = close
160
+ else
161
+ out << ch
162
+ i += 1
163
+ end
164
+ elsif (mysql || dialect == :sqlite) && ch == '`'
165
+ close = quoted_span_end(sql, i, quote: '`', backslash_escapes: false)
166
+ if close
167
+ # Backticks delimit identifiers. Preserve the token for table
168
+ # and protected-column scans while shielding comment markers
169
+ # inside it from the noise scanner.
133
170
  out << sql[i...close]
134
171
  i = close
135
172
  else
136
173
  out << ch
137
174
  i += 1
138
175
  end
139
- elsif dialect != :sqlite && ch == '$' && (tag = dollar_tag_at(sql, i))
176
+ elsif dialect == :postgres && ch == '$' && !preceded_by_word_char?(sql, i) && (tag = dollar_tag_at(sql, i))
140
177
  close = sql.index(tag, i + tag.length)
141
178
  if close
142
179
  out << "''"
@@ -145,14 +182,33 @@ module Woods
145
182
  out << ch
146
183
  i += 1
147
184
  end
148
- elsif ch == '-' && sql[i + 1] == '-'
185
+ elsif dash_comment?(sql, i, mysql: mysql) || (mysql && ch == '#')
149
186
  nl = sql.index("\n", i)
150
187
  i = nl || len
188
+ elsif sql[i, 3] == '/*!' || sql[i, 4] == '/*M!'
189
+ close = block_comment_end(sql, i, nested: dialect == :postgres)
190
+ if close
191
+ comment = sql[i...close]
192
+ yield comment if block_given?
193
+ # An executable comment is a lexical boundary even when its
194
+ # version guard makes its body inert. A quote in that body must
195
+ # never change the scanner's state outside the comment.
196
+ out << comment
197
+ i = close
198
+ else
199
+ out << ch
200
+ i += 1
201
+ end
151
202
  elsif ch == '/' && sql[i + 1] == '*'
152
- close = sql.index('*/', i + 2)
203
+ close = block_comment_end(sql, i, nested: dialect == :postgres)
153
204
  if close
154
- out << ' '
155
- i = close + 2
205
+ # Preserve a newline in place of the removed comment, mirroring
206
+ # line comments (see class docs): callers that check for
207
+ # statement structure (SqlValidator's statement-leader scan)
208
+ # need a survivable marker showing a comment sat here, the
209
+ # same way a `--`/`#` comment's own trailing newline does.
210
+ out << "\n"
211
+ i = close
156
212
  else
157
213
  # Unterminated block comment: never under-detect. Leave it in
158
214
  # place (over-detection is safe; the old regex also required a
@@ -169,26 +225,42 @@ module Woods
169
225
  out
170
226
  end
171
227
 
172
- # Preserve quoted identifiers while shielding their comment markers.
173
- # @api private
174
- def self.quoted_identifier_end(sql, start, quote)
175
- i = start + 1
176
- while i < sql.length
177
- if sql[i] == quote
178
- return i + 1 unless sql[i + 1] == quote
228
+ def self.block_comment_end(sql, start, nested:)
229
+ depth = 1
230
+ position = start + 2
231
+ while (match = %r{/\*|\*/}.match(sql, position))
232
+ depth += 1 if nested && match[0] == '/*'
233
+ depth -= 1 if match[0] == '*/'
234
+ return match.end(0) if depth.zero?
179
235
 
180
- i += 2
181
- else
182
- i += 1
183
- end
236
+ position = match.end(0)
184
237
  end
185
238
  nil
186
239
  end
187
- private_class_method :quoted_identifier_end
240
+ private_class_method :block_comment_end
241
+
242
+ # Session modes change MySQL quoting without changing the adapter name.
243
+ # Security consumers must reject SQL unsafe under any supported combination.
244
+ MYSQL_QUOTE_MODES = [false, true].product([false, true]).map do |ansi, no_backslash|
245
+ { ansi_quotes: ansi, no_backslash_escapes: no_backslash }.freeze
246
+ end.freeze
247
+
248
+ def self.security_views(sql, dialect:, mysql_modes: nil)
249
+ modes = dialect == :mysql && mysql_modes.nil? ? MYSQL_QUOTE_MODES : [mysql_modes || {}]
250
+ modes.map { |mode| strip_noise(sql, dialect: dialect, **mode) }.uniq
251
+ end
252
+
253
+ # MySQL requires whitespace/control after --; otherwise it is subtraction.
254
+ def self.dash_comment?(sql, index, mysql:)
255
+ return false unless sql[index, 2] == '--'
256
+
257
+ !mysql || sql[index + 2].nil? || sql[index + 2].match?(/[[:space:][:cntrl:]]/)
258
+ end
259
+ private_class_method :dash_comment?
188
260
 
189
261
  # Regexp matching a PostgreSQL dollar-quote opening tag (`$$` or
190
262
  # `$tag$`) at the start of the given slice.
191
- DOLLAR_TAG = /\A\$\w*\$/
263
+ DOLLAR_TAG = /\A\$(?:[A-Za-z_\u0080-\u{10ffff}][A-Za-z0-9_\u0080-\u{10ffff}]*)?\$/u
192
264
  private_constant :DOLLAR_TAG
193
265
 
194
266
  # Return the dollar-quote tag opening at +index+, or nil.
@@ -200,29 +272,69 @@ module Woods
200
272
  end
201
273
  private_class_method :dollar_tag_at
202
274
 
275
+ # Whether the character immediately before +index+ is a word character
276
+ # (including high-bit characters). PostgreSQL allows `$` inside identifiers (`x$a$`), so a `$`
277
+ # is only a candidate dollar-quote opener when it does NOT follow an
278
+ # identifier character — otherwise `x$a$ FROM blocked, (SELECT 1 AS
279
+ # z$a$)` gets misread as one dollar-quoted literal spanning the real
280
+ # FROM clause.
281
+ #
282
+ # @api private
283
+ def self.preceded_by_word_char?(sql, index)
284
+ index.positive? && sql[index - 1].match?(/[A-Za-z0-9_$\u0080-\u{10ffff}]/u)
285
+ end
286
+ private_class_method :preceded_by_word_char?
287
+
203
288
  # Return the index just past the closing quote of the single-quoted
204
289
  # literal that opens at +start+, honoring `''` (both dialects) and `\'`
205
290
  # (MySQL only) escapes. Returns nil when the literal is unterminated.
206
291
  #
207
292
  # @api private
208
- def self.single_quote_end(sql, start, mysql:)
293
+ def self.single_quote_end(sql, start, backslash_escapes:)
294
+ quoted_span_end(sql, start, quote: "'", backslash_escapes: backslash_escapes)
295
+ end
296
+ private_class_method :single_quote_end
297
+
298
+ def self.double_quote_replacement(sql, start, close, mysql:)
299
+ mysql ? "''" : sql[start...close]
300
+ end
301
+ private_class_method :double_quote_replacement
302
+
303
+ # Return the index just past a quoted span. Doubled delimiters escape
304
+ # themselves in every supported dialect; MySQL strings/identifiers and
305
+ # PostgreSQL E-strings additionally honor backslash escapes.
306
+ #
307
+ # @api private
308
+ def self.quoted_span_end(sql, start, quote:, backslash_escapes:)
209
309
  i = start + 1
210
310
  len = sql.length
211
311
  while i < len
212
312
  c = sql[i]
213
- if mysql && c == '\\'
313
+ if backslash_escapes && c == '\\'
214
314
  i += 2
215
- elsif c == "'"
216
- return i + 1 unless sql[i + 1] == "'" # closing quote
315
+ elsif c == quote
316
+ return i + 1 unless sql[i + 1] == quote # closing delimiter
217
317
 
218
- i += 2 # doubled-quote escape — literal continues
318
+ i += 2 # doubled-delimiter escape — span continues
219
319
  else
220
320
  i += 1
221
321
  end
222
322
  end
223
323
  nil
224
324
  end
225
- private_class_method :single_quote_end
226
- end
325
+ private_class_method :quoted_span_end
326
+
327
+ # PostgreSQL E'...' strings opt into C-style backslash escapes. The E
328
+ # must begin a token; an identifier ending in e immediately before a
329
+ # quote is not an escape-string prefix.
330
+ #
331
+ # @api private
332
+ def self.postgres_escape_string?(sql, quote_index)
333
+ return false unless quote_index.positive? && sql[quote_index - 1].match?(/[eE]/)
334
+
335
+ quote_index < 2 || !sql[quote_index - 2].match?(/[A-Za-z0-9_$\u0080-\u{10ffff}]/u)
336
+ end
337
+ private_class_method :postgres_escape_string?
338
+ end # rubocop:enable Metrics/ModuleLength
227
339
  end
228
340
  end