woods 1.6.2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/CONTRIBUTING.md +19 -11
- data/README.md +1 -1
- data/exe/woods-console +1 -1
- data/exe/woods-mcp-http +17 -4
- data/lib/woods/cache/cache_middleware.rb +11 -11
- data/lib/woods/console/adapter_family.rb +39 -0
- data/lib/woods/console/credential_index.rb +33 -3
- data/lib/woods/console/credential_scanner.rb +2 -1
- data/lib/woods/console/dispatch_pipeline.rb +22 -4
- data/lib/woods/console/embedded_executor.rb +139 -31
- data/lib/woods/console/model_validator.rb +11 -1
- data/lib/woods/console/rack_middleware.rb +27 -6
- data/lib/woods/console/redactor.rb +46 -14
- data/lib/woods/console/safe_context.rb +56 -8
- data/lib/woods/console/server.rb +2 -1
- data/lib/woods/console/sql_noise_stripper.rb +165 -25
- data/lib/woods/console/sql_output_policy.rb +535 -0
- data/lib/woods/console/sql_table_scanner.rb +163 -52
- data/lib/woods/console/sql_validator.rb +70 -10
- data/lib/woods/console/sqlite_read_guard.rb +46 -0
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/mcp/bearer_auth.rb +29 -10
- data/lib/woods/mcp/http_transport_options.rb +2 -11
- data/lib/woods/mcp/origin_guard.rb +25 -51
- data/lib/woods/mcp/origin_policy.rb +113 -0
- data/lib/woods/railtie.rb +11 -4
- data/lib/woods/version.rb +1 -1
- metadata +9 -5
|
@@ -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
|
-
|
|
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
|
|
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`
|
|
245
|
-
#
|
|
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
|
|
249
|
-
connection
|
|
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
|
|
data/lib/woods/console/server.rb
CHANGED
|
@@ -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],
|
|
@@ -24,14 +24,14 @@ module Woods
|
|
|
24
24
|
# SqlNoiseStripper.strip_literals("SELECT 'it\\'s ok' FROM t", dialect: :mysql)
|
|
25
25
|
# # => "SELECT '' FROM t"
|
|
26
26
|
#
|
|
27
|
-
module SqlNoiseStripper
|
|
27
|
+
module SqlNoiseStripper # rubocop:disable Metrics/ModuleLength
|
|
28
28
|
# Strips SQL line comments (`-- ...`) and block comments (`/* ... */`).
|
|
29
29
|
# Line comments are stripped to (but not including) the newline so that
|
|
30
30
|
# newline-separated statement structure is preserved for callers that
|
|
31
31
|
# check for multiple statements.
|
|
32
32
|
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
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
|
|
@@ -40,7 +40,7 @@ module Woods
|
|
|
40
40
|
|
|
41
41
|
def self.strip_comments(sql)
|
|
42
42
|
out = sql.gsub(LINE_COMMENT, '')
|
|
43
|
-
out.gsub(BLOCK_COMMENT, '')
|
|
43
|
+
out.gsub(BLOCK_COMMENT, ' ')
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
# Strips single-quoted string literals and (for the `:postgres` dialect)
|
|
@@ -53,21 +53,21 @@ module Woods
|
|
|
53
53
|
# single-quote scanner.
|
|
54
54
|
#
|
|
55
55
|
# @param sql [String] the SQL string to process
|
|
56
|
-
# @param dialect [Symbol] `:postgres` (default) or `:
|
|
56
|
+
# @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite`.
|
|
57
57
|
# - `:postgres` — single-quoted strings support `''` as an apostrophe
|
|
58
58
|
# escape. Backslash is treated literally and does not escape quotes.
|
|
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
|
|
63
|
-
#
|
|
62
|
+
# are not recognized by the combined MySQL security scanner.
|
|
63
|
+
# - `:sqlite` — doubled apostrophes escape strings; backslashes and dollar signs are literal.
|
|
64
64
|
# @return [String] a new string with all string literals replaced by `''`
|
|
65
65
|
# @raise [ArgumentError] if an unsupported dialect is provided
|
|
66
66
|
DOLLAR_QUOTED = /\$(\w*)\$.*?\$\1\$/m
|
|
67
67
|
SINGLE_QUOTED_POSTGRES = /'(?:''|[^'])*'/m
|
|
68
68
|
SINGLE_QUOTED_MYSQL = /'(?:\\.|''|[^'])*'/m
|
|
69
69
|
|
|
70
|
-
SUPPORTED_DIALECTS = %i[postgres mysql].freeze
|
|
70
|
+
SUPPORTED_DIALECTS = %i[postgres mysql sqlite].freeze
|
|
71
71
|
private_constant :SUPPORTED_DIALECTS
|
|
72
72
|
|
|
73
73
|
def self.strip_literals(sql, dialect: :postgres)
|
|
@@ -77,7 +77,7 @@ module Woods
|
|
|
77
77
|
|
|
78
78
|
# Strip dollar-quoted strings first so stray apostrophes inside them
|
|
79
79
|
# do not interfere with the single-quote scanner.
|
|
80
|
-
out = sql.gsub(DOLLAR_QUOTED, "''")
|
|
80
|
+
out = dialect == :sqlite ? sql : sql.gsub(DOLLAR_QUOTED, "''")
|
|
81
81
|
|
|
82
82
|
pattern = dialect == :mysql ? SINGLE_QUOTED_MYSQL : SINGLE_QUOTED_POSTGRES
|
|
83
83
|
out.gsub(pattern, "''")
|
|
@@ -97,13 +97,30 @@ module Woods
|
|
|
97
97
|
# never under-detect: an unterminated literal is treated as an ordinary
|
|
98
98
|
# character rather than swallowing the rest of the statement.
|
|
99
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
|
+
#
|
|
100
116
|
# @param sql [String] the SQL string to process
|
|
101
|
-
# @param dialect [Symbol] `:postgres` (default) or `:
|
|
102
|
-
# single-quote escape rules (see {.strip_literals})
|
|
117
|
+
# @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite` — controls
|
|
118
|
+
# single-quote escape rules (see {.strip_literals}) and whether `#`
|
|
119
|
+
# opens a line comment. MySQL quote flags reflect session sql_mode.
|
|
103
120
|
# @return [String] a new string with comments removed and every string
|
|
104
121
|
# literal replaced by `''`
|
|
105
122
|
# @raise [ArgumentError] if an unsupported dialect is provided
|
|
106
|
-
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
|
|
107
124
|
unless SUPPORTED_DIALECTS.include?(dialect)
|
|
108
125
|
raise ArgumentError, "Unknown dialect #{dialect.inspect}. Supported: #{SUPPORTED_DIALECTS.inspect}"
|
|
109
126
|
end
|
|
@@ -117,7 +134,11 @@ module Woods
|
|
|
117
134
|
ch = sql[i]
|
|
118
135
|
|
|
119
136
|
if ch == "'"
|
|
120
|
-
close = single_quote_end(
|
|
137
|
+
close = single_quote_end(
|
|
138
|
+
sql, i,
|
|
139
|
+
backslash_escapes: (mysql && !no_backslash_escapes) ||
|
|
140
|
+
(dialect == :postgres && postgres_escape_string?(sql, i))
|
|
141
|
+
)
|
|
121
142
|
if close
|
|
122
143
|
out << "''"
|
|
123
144
|
i = close
|
|
@@ -126,7 +147,33 @@ module Woods
|
|
|
126
147
|
out << ch
|
|
127
148
|
i += 1
|
|
128
149
|
end
|
|
129
|
-
elsif ch == '
|
|
150
|
+
elsif ch == '"'
|
|
151
|
+
close = quoted_span_end(sql, i, quote: '"',
|
|
152
|
+
backslash_escapes: mysql && !ansi_quotes && !no_backslash_escapes)
|
|
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.
|
|
170
|
+
out << sql[i...close]
|
|
171
|
+
i = close
|
|
172
|
+
else
|
|
173
|
+
out << ch
|
|
174
|
+
i += 1
|
|
175
|
+
end
|
|
176
|
+
elsif dialect == :postgres && ch == '$' && !preceded_by_word_char?(sql, i) && (tag = dollar_tag_at(sql, i))
|
|
130
177
|
close = sql.index(tag, i + tag.length)
|
|
131
178
|
if close
|
|
132
179
|
out << "''"
|
|
@@ -135,13 +182,33 @@ module Woods
|
|
|
135
182
|
out << ch
|
|
136
183
|
i += 1
|
|
137
184
|
end
|
|
138
|
-
elsif
|
|
185
|
+
elsif dash_comment?(sql, i, mysql: mysql) || (mysql && ch == '#')
|
|
139
186
|
nl = sql.index("\n", i)
|
|
140
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
|
|
141
202
|
elsif ch == '/' && sql[i + 1] == '*'
|
|
142
|
-
close = sql
|
|
203
|
+
close = block_comment_end(sql, i, nested: dialect == :postgres)
|
|
143
204
|
if close
|
|
144
|
-
|
|
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
|
|
145
212
|
else
|
|
146
213
|
# Unterminated block comment: never under-detect. Leave it in
|
|
147
214
|
# place (over-detection is safe; the old regex also required a
|
|
@@ -158,9 +225,42 @@ module Woods
|
|
|
158
225
|
out
|
|
159
226
|
end
|
|
160
227
|
|
|
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?
|
|
235
|
+
|
|
236
|
+
position = match.end(0)
|
|
237
|
+
end
|
|
238
|
+
nil
|
|
239
|
+
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?
|
|
260
|
+
|
|
161
261
|
# Regexp matching a PostgreSQL dollar-quote opening tag (`$$` or
|
|
162
262
|
# `$tag$`) at the start of the given slice.
|
|
163
|
-
DOLLAR_TAG = /\A
|
|
263
|
+
DOLLAR_TAG = /\A\$(?:[A-Za-z_\u0080-\u{10ffff}][A-Za-z0-9_\u0080-\u{10ffff}]*)?\$/u
|
|
164
264
|
private_constant :DOLLAR_TAG
|
|
165
265
|
|
|
166
266
|
# Return the dollar-quote tag opening at +index+, or nil.
|
|
@@ -172,29 +272,69 @@ module Woods
|
|
|
172
272
|
end
|
|
173
273
|
private_class_method :dollar_tag_at
|
|
174
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
|
+
|
|
175
288
|
# Return the index just past the closing quote of the single-quoted
|
|
176
289
|
# literal that opens at +start+, honoring `''` (both dialects) and `\'`
|
|
177
290
|
# (MySQL only) escapes. Returns nil when the literal is unterminated.
|
|
178
291
|
#
|
|
179
292
|
# @api private
|
|
180
|
-
def self.single_quote_end(sql, start,
|
|
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:)
|
|
181
309
|
i = start + 1
|
|
182
310
|
len = sql.length
|
|
183
311
|
while i < len
|
|
184
312
|
c = sql[i]
|
|
185
|
-
if
|
|
313
|
+
if backslash_escapes && c == '\\'
|
|
186
314
|
i += 2
|
|
187
|
-
elsif c ==
|
|
188
|
-
return i + 1 unless sql[i + 1] ==
|
|
315
|
+
elsif c == quote
|
|
316
|
+
return i + 1 unless sql[i + 1] == quote # closing delimiter
|
|
189
317
|
|
|
190
|
-
i += 2 # doubled-
|
|
318
|
+
i += 2 # doubled-delimiter escape — span continues
|
|
191
319
|
else
|
|
192
320
|
i += 1
|
|
193
321
|
end
|
|
194
322
|
end
|
|
195
323
|
nil
|
|
196
324
|
end
|
|
197
|
-
private_class_method :
|
|
198
|
-
|
|
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
|
|
199
339
|
end
|
|
200
340
|
end
|