rails_vitals 0.6.0 → 0.6.1
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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ce21f0b9edbbef2e18452c51755bf980c067c92afb651a86ee99b78aac0cbe6d
|
|
4
|
+
data.tar.gz: 0ee7cddd129757762d01cdce2f049622bb6f449e44de05ff5d592bfd4933136d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 201b26e862cafbde3dfe07f1011ceb3d8241f362ff5386cbae8edbe5e0a250aa99ac800fd4c3d57d9984beec6f54348c78bd7a13f5265cae6a390d137494348e
|
|
7
|
+
data.tar.gz: de6f0ff8c15da14e155850368884cd14830dce374505ad098f8c3a902865d16ad7d42bd17a38ba51561b3e2fb829e73b03abf2530f16873f6249b1782da4ccc5
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
require "strscan"
|
|
2
|
+
|
|
3
|
+
module RailsVitals
|
|
4
|
+
module Playground
|
|
5
|
+
class SafeChainBuilder
|
|
6
|
+
ALLOWED_METHODS = %w[
|
|
7
|
+
all where select limit offset order group
|
|
8
|
+
includes preload eager_load joins left_joins
|
|
9
|
+
find find_by first last count sum average
|
|
10
|
+
pluck distinct having references unscoped not
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
DISALLOWED_CLASS_METHODS = %w[
|
|
14
|
+
connection execute exec system eval send public_send __send__
|
|
15
|
+
instance_eval class_eval module_eval define_method method_missing
|
|
16
|
+
delete destroy delete_all destroy_all update_all
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
ParseError = Class.new(StandardError)
|
|
20
|
+
|
|
21
|
+
def self.build(chain_str, model)
|
|
22
|
+
relation = model.all
|
|
23
|
+
parse_chain(chain_str).each do |method_name, args|
|
|
24
|
+
if DISALLOWED_CLASS_METHODS.include?(method_name)
|
|
25
|
+
raise ParseError, "Method '#{method_name}' is not allowed for security reasons"
|
|
26
|
+
end
|
|
27
|
+
unless ALLOWED_METHODS.include?(method_name)
|
|
28
|
+
raise ParseError, "Method '#{method_name}' is not allowed. Allowed: #{ALLOWED_METHODS.join(', ')}"
|
|
29
|
+
end
|
|
30
|
+
relation = relation.public_send(method_name, *args)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
raise ParseError, "Expression must return an ActiveRecord::Relation" unless relation.is_a?(ActiveRecord::Relation)
|
|
34
|
+
relation
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def self.parse_chain(str)
|
|
40
|
+
scanner = StringScanner.new(str)
|
|
41
|
+
calls = []
|
|
42
|
+
scanner.skip(/\s+/)
|
|
43
|
+
|
|
44
|
+
until scanner.eos?
|
|
45
|
+
scanner.skip(/\.\s*/)
|
|
46
|
+
|
|
47
|
+
name = scanner.scan(/[a-z_][a-zA-Z0-9_!?]*/)
|
|
48
|
+
raise ParseError, "Expected method name at position #{scanner.pos}" unless name
|
|
49
|
+
|
|
50
|
+
scanner.skip(/\s+/)
|
|
51
|
+
if scanner.scan(/\(/)
|
|
52
|
+
args = parse_args(scanner)
|
|
53
|
+
scanner.skip(/\s*\)/)
|
|
54
|
+
calls << [ name, args ]
|
|
55
|
+
else
|
|
56
|
+
calls << [ name, [] ]
|
|
57
|
+
end
|
|
58
|
+
scanner.skip(/\s+/)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
calls
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.parse_args(scanner)
|
|
65
|
+
args = []
|
|
66
|
+
scanner.skip(/\s+/)
|
|
67
|
+
return args if scanner.eos? || scanner.peek(1) == ")"
|
|
68
|
+
|
|
69
|
+
loop do
|
|
70
|
+
scanner.skip(/\s+/)
|
|
71
|
+
break if scanner.eos? || scanner.peek(1) == ")"
|
|
72
|
+
|
|
73
|
+
if keyword_hash_start?(scanner)
|
|
74
|
+
args << scan_keyword_hash(scanner)
|
|
75
|
+
else
|
|
76
|
+
args << scan_value(scanner)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
scanner.skip(/\s+/)
|
|
80
|
+
break unless scanner.scan(/,/)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
args
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.keyword_hash_start?(scanner)
|
|
87
|
+
pos = scanner.pos
|
|
88
|
+
ident = scanner.scan(/[a-zA-Z_][a-zA-Z0-9_]*/)
|
|
89
|
+
return false unless ident
|
|
90
|
+
|
|
91
|
+
scanner.skip(/\s*/)
|
|
92
|
+
|
|
93
|
+
if scanner.scan(/:/) && !scanner.scan(/:/)
|
|
94
|
+
scanner.pos = pos
|
|
95
|
+
return true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
scanner.pos = pos
|
|
99
|
+
false
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.scan_keyword_hash(scanner)
|
|
103
|
+
hash = {}
|
|
104
|
+
loop do
|
|
105
|
+
scanner.skip(/\s+/)
|
|
106
|
+
break if scanner.eos? || scanner.peek(1) == ")" || scanner.peek(1) == "}"
|
|
107
|
+
|
|
108
|
+
key = scanner.scan(/[a-zA-Z_][a-zA-Z0-9_]*/)
|
|
109
|
+
raise ParseError, "Expected hash key" unless key
|
|
110
|
+
scanner.skip(/\s*:\s*/)
|
|
111
|
+
value = scan_value(scanner)
|
|
112
|
+
hash[key.to_sym] = value
|
|
113
|
+
scanner.skip(/\s+/)
|
|
114
|
+
break unless scanner.scan(/,/)
|
|
115
|
+
end
|
|
116
|
+
hash
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.scan_value(scanner)
|
|
120
|
+
scanner.skip(/\s+/)
|
|
121
|
+
ch = scanner.peek(1)
|
|
122
|
+
raise ParseError, "Unexpected end of expression" unless ch
|
|
123
|
+
|
|
124
|
+
case ch
|
|
125
|
+
when "'" then scan_single_quoted_string(scanner)
|
|
126
|
+
when '"' then scan_double_quoted_string(scanner)
|
|
127
|
+
when ":" then scan_symbol(scanner)
|
|
128
|
+
when "t"
|
|
129
|
+
if scanner.scan(/true\b/)
|
|
130
|
+
true
|
|
131
|
+
else
|
|
132
|
+
raise ParseError, "Unexpected token at position #{scanner.pos}"
|
|
133
|
+
end
|
|
134
|
+
when "f"
|
|
135
|
+
if scanner.scan(/false\b/)
|
|
136
|
+
false
|
|
137
|
+
else
|
|
138
|
+
raise ParseError, "Unexpected token at position #{scanner.pos}"
|
|
139
|
+
end
|
|
140
|
+
when "n"
|
|
141
|
+
if scanner.scan(/nil\b/)
|
|
142
|
+
nil
|
|
143
|
+
else
|
|
144
|
+
raise ParseError, "Unexpected token at position #{scanner.pos}"
|
|
145
|
+
end
|
|
146
|
+
when "[" then scan_array(scanner)
|
|
147
|
+
when "{" then scan_hash_literal(scanner)
|
|
148
|
+
when "-", "+", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9" then scan_number(scanner)
|
|
149
|
+
else
|
|
150
|
+
raise ParseError, "Unexpected token '#{ch}' at position #{scanner.pos}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def self.scan_single_quoted_string(scanner)
|
|
155
|
+
scanner.pos += 1
|
|
156
|
+
result = +""
|
|
157
|
+
until scanner.eos?
|
|
158
|
+
case scanner.peek(1)
|
|
159
|
+
when "'"
|
|
160
|
+
scanner.pos += 1
|
|
161
|
+
return result
|
|
162
|
+
when "\\"
|
|
163
|
+
scanner.pos += 1
|
|
164
|
+
escaped = scanner.getch
|
|
165
|
+
case escaped
|
|
166
|
+
when "'" then result << "'"
|
|
167
|
+
when "\\" then result << "\\"
|
|
168
|
+
else result << "\\#{escaped}"
|
|
169
|
+
end
|
|
170
|
+
else
|
|
171
|
+
result << scanner.getch
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
raise ParseError, "Unterminated single-quoted string"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def self.scan_double_quoted_string(scanner)
|
|
178
|
+
scanner.pos += 1
|
|
179
|
+
result = +""
|
|
180
|
+
until scanner.eos?
|
|
181
|
+
case scanner.peek(1)
|
|
182
|
+
when '"'
|
|
183
|
+
scanner.pos += 1
|
|
184
|
+
return result
|
|
185
|
+
when "\\"
|
|
186
|
+
scanner.pos += 1
|
|
187
|
+
escaped = scanner.getch
|
|
188
|
+
case escaped
|
|
189
|
+
when '"' then result << '"'
|
|
190
|
+
when "\\" then result << "\\"
|
|
191
|
+
when "n" then result << "\n"
|
|
192
|
+
when "t" then result << "\t"
|
|
193
|
+
when "r" then result << "\r"
|
|
194
|
+
when "#" then result << "#"
|
|
195
|
+
else result << "\\#{escaped}"
|
|
196
|
+
end
|
|
197
|
+
else
|
|
198
|
+
result << scanner.getch
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
raise ParseError, "Unterminated double-quoted string"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def self.scan_symbol(scanner)
|
|
205
|
+
scanner.pos += 1
|
|
206
|
+
if scanner.scan(/[a-zA-Z_][a-zA-Z0-9_]*/)
|
|
207
|
+
scanner.matched.to_sym
|
|
208
|
+
elsif (str = scanner.scan(/"[^"]*"/) || scanner.scan(/'[^']*'/))
|
|
209
|
+
str[1..-2].to_sym
|
|
210
|
+
else
|
|
211
|
+
raise ParseError, "Invalid symbol at position #{scanner.pos}"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def self.scan_number(scanner)
|
|
216
|
+
num_str = scanner.scan(/-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?/)
|
|
217
|
+
raise ParseError, "Invalid number at position #{scanner.pos}" unless num_str
|
|
218
|
+
if num_str.include?(".") || num_str.match?(/[eE]/)
|
|
219
|
+
num_str.to_f
|
|
220
|
+
else
|
|
221
|
+
num_str.to_i
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def self.scan_array(scanner)
|
|
226
|
+
scanner.pos += 1
|
|
227
|
+
arr = []
|
|
228
|
+
scanner.skip(/\s+/)
|
|
229
|
+
unless scanner.peek(1) == "]"
|
|
230
|
+
loop do
|
|
231
|
+
arr << scan_value(scanner)
|
|
232
|
+
scanner.skip(/\s+/)
|
|
233
|
+
break unless scanner.scan(/,/)
|
|
234
|
+
scanner.skip(/\s+/)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
scanner.skip(/\s*\]/)
|
|
238
|
+
raise ParseError, "Unterminated array" unless scanner.matched
|
|
239
|
+
arr
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def self.scan_hash_literal(scanner)
|
|
243
|
+
scanner.pos += 1
|
|
244
|
+
hash = {}
|
|
245
|
+
scanner.skip(/\s+/)
|
|
246
|
+
unless scanner.eos? || scanner.peek(1) == "}"
|
|
247
|
+
loop do
|
|
248
|
+
scanner.skip(/\s+/)
|
|
249
|
+
break if scanner.eos? || scanner.peek(1) == "}"
|
|
250
|
+
|
|
251
|
+
key = parse_hash_key(scanner)
|
|
252
|
+
scanner.skip(/\s+/)
|
|
253
|
+
|
|
254
|
+
if scanner.scan(/=>/)
|
|
255
|
+
scanner.skip(/\s+/)
|
|
256
|
+
hash[key] = scan_value(scanner)
|
|
257
|
+
elsif scanner.scan(/:\s*/)
|
|
258
|
+
hash[key.to_sym] = scan_value(scanner)
|
|
259
|
+
else
|
|
260
|
+
raise ParseError, "Expected '=>' or ':' after hash key"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
scanner.skip(/\s+/)
|
|
264
|
+
break unless scanner.scan(/,/)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
scanner.skip(/\s*}/)
|
|
268
|
+
raise ParseError, "Unterminated hash" unless scanner.matched
|
|
269
|
+
hash
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def self.parse_hash_key(scanner)
|
|
273
|
+
case scanner.peek(1)
|
|
274
|
+
when '"', "'" then scan_string(scanner)
|
|
275
|
+
when ":" then scan_symbol(scanner)
|
|
276
|
+
else
|
|
277
|
+
ident = scanner.scan(/[a-zA-Z_][a-zA-Z0-9_]*/)
|
|
278
|
+
raise ParseError, "Expected hash key" unless ident
|
|
279
|
+
ident
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def self.scan_string(scanner)
|
|
284
|
+
case scanner.peek(1)
|
|
285
|
+
when "'" then scan_single_quoted_string(scanner)
|
|
286
|
+
when '"' then scan_double_quoted_string(scanner)
|
|
287
|
+
else raise ParseError, "Expected string"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
module RailsVitals
|
|
2
2
|
module Playground
|
|
3
3
|
class Sandbox
|
|
4
|
-
ALLOWED_METHODS = %w[
|
|
5
|
-
all where select limit offset order group
|
|
6
|
-
includes preload eager_load joins left_joins
|
|
7
|
-
find find_by first last count sum average
|
|
8
|
-
pluck distinct having references unscoped
|
|
9
|
-
].freeze
|
|
10
|
-
|
|
11
4
|
BLOCKED_PATTERNS = [
|
|
12
5
|
/\b(insert|update|delete|destroy|drop|truncate|create|alter)\b/i,
|
|
13
6
|
/\.save/i, /\.save!/i, /\.update/i, /\.delete/i,
|
|
14
7
|
/\.destroy/i, /`/
|
|
15
8
|
].freeze
|
|
16
9
|
|
|
10
|
+
SAFE_EXPRESSION_PATTERN = /\A[a-zA-Z0-9_\.\s\(\),:\[\]{}'"!?=<>|&*+\-\/\\%]+\z/
|
|
11
|
+
|
|
12
|
+
ASSOCIATION_NAME_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
|
|
13
|
+
|
|
17
14
|
DEFAULT_LIMIT = 100
|
|
18
15
|
|
|
19
16
|
Result = Struct.new(
|
|
@@ -26,6 +23,13 @@ module RailsVitals
|
|
|
26
23
|
def self.run(expression, access_associations: [])
|
|
27
24
|
return blocked_result("No expression provided") if expression.blank?
|
|
28
25
|
|
|
26
|
+
expression = expression.gsub(/#[^\n]*/, "").strip
|
|
27
|
+
return blocked_result("No expression provided") if expression.blank?
|
|
28
|
+
|
|
29
|
+
return blocked_result(
|
|
30
|
+
"Expression contains invalid characters."
|
|
31
|
+
) unless expression.match?(SAFE_EXPRESSION_PATTERN)
|
|
32
|
+
|
|
29
33
|
BLOCKED_PATTERNS.each do |pattern|
|
|
30
34
|
return blocked_result(
|
|
31
35
|
"Expression contains blocked operation. " \
|
|
@@ -33,6 +37,10 @@ module RailsVitals
|
|
|
33
37
|
) if expression.match?(pattern)
|
|
34
38
|
end
|
|
35
39
|
|
|
40
|
+
access_associations = access_associations.select do |name|
|
|
41
|
+
name.to_s.match?(ASSOCIATION_NAME_PATTERN)
|
|
42
|
+
end
|
|
43
|
+
|
|
36
44
|
model_name = extract_model_name(expression)
|
|
37
45
|
return blocked_result(
|
|
38
46
|
"Could not detect model from expression. " \
|
|
@@ -111,10 +119,7 @@ module RailsVitals
|
|
|
111
119
|
end
|
|
112
120
|
|
|
113
121
|
def self.extract_model_name(expression)
|
|
114
|
-
|
|
115
|
-
clean = expression.gsub(/#[^\n]*/, "").strip
|
|
116
|
-
# First word before a dot or whitespace — must look like a constant (CamelCase)
|
|
117
|
-
match = clean.match(/\A([A-Z][A-Za-z0-9]*)/)
|
|
122
|
+
match = expression.match(/\A([A-Z][A-Za-z0-9]*)/)
|
|
118
123
|
match ? match[1] : nil
|
|
119
124
|
end
|
|
120
125
|
|
|
@@ -132,31 +137,15 @@ module RailsVitals
|
|
|
132
137
|
end
|
|
133
138
|
|
|
134
139
|
def self.build_relation(expression, model)
|
|
135
|
-
# Parse "Post.includes(:likes).where(published: true).limit(10)"
|
|
136
|
-
# Strip the model name prefix if present
|
|
137
140
|
chain_str = expression
|
|
138
141
|
.sub(/\A#{Regexp.escape(model.name)}\s*\.?\s*/, "")
|
|
139
142
|
.strip
|
|
140
143
|
|
|
141
144
|
return model.all if chain_str.blank?
|
|
142
145
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
relation = eval(chain_str, sandbox_binding) # rubocop:disable Security/Eval
|
|
147
|
-
|
|
148
|
-
unless relation.is_a?(ActiveRecord::Relation)
|
|
149
|
-
raise "Expression must return an ActiveRecord::Relation"
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
relation
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def self.build_binding(model)
|
|
156
|
-
# Create a minimal binding with only the model exposed
|
|
157
|
-
ctx = Object.new
|
|
158
|
-
ctx.define_singleton_method(:relation) { model.all }
|
|
159
|
-
ctx.instance_eval { binding }
|
|
146
|
+
SafeChainBuilder.build(chain_str, model)
|
|
147
|
+
rescue SafeChainBuilder::ParseError => e
|
|
148
|
+
raise "Expression error: #{e.message}"
|
|
160
149
|
end
|
|
161
150
|
|
|
162
151
|
def self.apply_limit(relation)
|
data/lib/rails_vitals/version.rb
CHANGED
data/lib/rails_vitals.rb
CHANGED
|
@@ -14,6 +14,7 @@ require "rails_vitals/scorers/base_scorer"
|
|
|
14
14
|
require "rails_vitals/scorers/query_scorer"
|
|
15
15
|
require "rails_vitals/scorers/n_plus_one_scorer"
|
|
16
16
|
require "rails_vitals/scorers/composite_scorer"
|
|
17
|
+
require "rails_vitals/playground/safe_chain_builder"
|
|
17
18
|
require "rails_vitals/playground/sandbox"
|
|
18
19
|
require "rails_vitals/panel_renderer"
|
|
19
20
|
require "rails_vitals/middleware/panel_injector"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_vitals
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.
|
|
4
|
+
version: 0.6.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Sanchez
|
|
@@ -95,6 +95,7 @@ files:
|
|
|
95
95
|
- lib/rails_vitals/middleware/panel_injector.rb
|
|
96
96
|
- lib/rails_vitals/notifications/subscriber.rb
|
|
97
97
|
- lib/rails_vitals/panel_renderer.rb
|
|
98
|
+
- lib/rails_vitals/playground/safe_chain_builder.rb
|
|
98
99
|
- lib/rails_vitals/playground/sandbox.rb
|
|
99
100
|
- lib/rails_vitals/request_record.rb
|
|
100
101
|
- lib/rails_vitals/scorers/base_scorer.rb
|