inquirex 0.7.0 → 0.9.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 +48 -18
- data/README.md +115 -48
- data/lib/inquirex/accumulator.rb +48 -6
- data/lib/inquirex/answers.rb +0 -10
- data/lib/inquirex/definition.rb +11 -53
- data/lib/inquirex/dsl/flow_builder.rb +52 -45
- data/lib/inquirex/dsl/send_email_builder.rb +88 -0
- data/lib/inquirex/engine.rb +94 -13
- data/lib/inquirex/errors.rb +20 -3
- data/lib/inquirex/node.rb +29 -0
- data/lib/inquirex/safe_source/call_spec.rb +42 -0
- data/lib/inquirex/safe_source/validator.rb +559 -0
- data/lib/inquirex/safe_source/vocabulary.rb +322 -0
- data/lib/inquirex/safe_source.rb +111 -0
- data/lib/inquirex/send_email.rb +206 -0
- data/lib/inquirex/template.rb +93 -0
- data/lib/inquirex/transcript.rb +93 -0
- data/lib/inquirex/version.rb +1 -1
- data/lib/inquirex.rb +48 -19
- metadata +12 -13
- data/lib/inquirex/actions/action.rb +0 -68
- data/lib/inquirex/actions/base.rb +0 -41
- data/lib/inquirex/actions/custom.rb +0 -31
- data/lib/inquirex/actions/outbox.rb +0 -57
- data/lib/inquirex/actions/runner.rb +0 -52
- data/lib/inquirex/actions/send_email.rb +0 -174
- data/lib/inquirex/actions/template.rb +0 -95
- data/lib/inquirex/actions/webhook.rb +0 -139
- data/lib/inquirex/actions.rb +0 -57
- data/lib/inquirex/dsl/action_builder.rb +0 -53
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module SafeSource
|
|
5
|
+
# Default-deny AST allowlist for the Inquirex flow DSL.
|
|
6
|
+
#
|
|
7
|
+
# The validator parses the source with Prism and walks it by *recursive
|
|
8
|
+
# descent against an expected shape* rather than by visiting every node and
|
|
9
|
+
# asking "is this one forbidden?". At each position — top-level statement,
|
|
10
|
+
# flow-block statement, step-block statement, argument, keyword value, Hash
|
|
11
|
+
# element — only the handful of node types the real DSL produces there are
|
|
12
|
+
# accepted, and anything else is a violation. A node type nobody thought
|
|
13
|
+
# about is therefore rejected by construction, which is the whole point: a
|
|
14
|
+
# blocklist of forbidden methods would be defeated by the first construct
|
|
15
|
+
# that was overlooked.
|
|
16
|
+
#
|
|
17
|
+
# What that rules out, without needing to name any of it: `system`,
|
|
18
|
+
# backticks and `%x`, `exec`/`spawn`/`fork`, `require`/`load`, `eval` and
|
|
19
|
+
# the `*_eval` family, `send`/`__send__`/`public_send`/`method`, every
|
|
20
|
+
# constant reference other than the `Inquirex` entry point (so no `File`,
|
|
21
|
+
# `IO`, `Dir`, `Kernel`, `ENV`, `ObjectSpace`, `Process`,
|
|
22
|
+
# `Object.const_get`), instance, class and global variables,
|
|
23
|
+
# `def`/`class`/`module`, `begin`/`rescue`, `at_exit`, `BEGIN`/`END`,
|
|
24
|
+
# singleton definitions, `__FILE__`-style magic, assignments, conditionals,
|
|
25
|
+
# loops, string/symbol/regexp interpolation (including inside heredocs),
|
|
26
|
+
# splats and block-pass arguments.
|
|
27
|
+
#
|
|
28
|
+
# Ruby blocks are accepted only where the DSL itself opens a nested scope
|
|
29
|
+
# ({Vocabulary} `block:`), and those blocks are validated statement by
|
|
30
|
+
# statement in turn. Every other block is rejected, which is why `compute`,
|
|
31
|
+
# a block-form `default`, `fallback` and an action's `run` cannot be used in
|
|
32
|
+
# safe mode: a `compute` block is indistinguishable from a payload.
|
|
33
|
+
#
|
|
34
|
+
# @example
|
|
35
|
+
# Validator.new("Inquirex.define { start :a }").violations # => []
|
|
36
|
+
# Validator.new("system('id')").violations
|
|
37
|
+
# # => ["line 1: the DSL must be a single `Inquirex.define` block"]
|
|
38
|
+
class Validator
|
|
39
|
+
# Cap on reported violations, so a hostile 64 KiB payload cannot turn a
|
|
40
|
+
# validation error into a megabyte of flash message.
|
|
41
|
+
MAX_REPORTED = 10
|
|
42
|
+
|
|
43
|
+
# Appended to violations an author could legitimately have meant.
|
|
44
|
+
UNSAFE_HINT = " — evaluate source you wrote yourself with `Inquirex.load_dsl(text, unsafe: true)`"
|
|
45
|
+
|
|
46
|
+
# Node types worth naming explicitly in a violation message, because the
|
|
47
|
+
# humanized class name would not tell the author what they actually wrote.
|
|
48
|
+
NODE_LABELS = {
|
|
49
|
+
Prism::XStringNode => "a backtick or %x command",
|
|
50
|
+
Prism::InterpolatedXStringNode => "a backtick or %x command",
|
|
51
|
+
Prism::InterpolatedStringNode => "an interpolated string (interpolation is never allowed, not even in a heredoc)",
|
|
52
|
+
Prism::InterpolatedSymbolNode => "an interpolated symbol",
|
|
53
|
+
Prism::InterpolatedRegularExpressionNode => "an interpolated regular expression",
|
|
54
|
+
Prism::RegularExpressionNode => "a regular expression",
|
|
55
|
+
Prism::ConstantReadNode => "a constant reference",
|
|
56
|
+
Prism::ConstantPathNode => "a constant reference",
|
|
57
|
+
Prism::InstanceVariableReadNode => "an instance variable",
|
|
58
|
+
Prism::ClassVariableReadNode => "a class variable",
|
|
59
|
+
Prism::GlobalVariableReadNode => "a global variable",
|
|
60
|
+
Prism::DefNode => "a method definition",
|
|
61
|
+
Prism::ClassNode => "a class definition",
|
|
62
|
+
Prism::ModuleNode => "a module definition",
|
|
63
|
+
Prism::SingletonClassNode => "a singleton class definition",
|
|
64
|
+
Prism::BeginNode => "a begin/rescue block",
|
|
65
|
+
Prism::LambdaNode => "a lambda",
|
|
66
|
+
Prism::BlockNode => "a block",
|
|
67
|
+
Prism::BlockArgumentNode => "a block-pass (&) argument",
|
|
68
|
+
Prism::SplatNode => "a splat (*) argument",
|
|
69
|
+
Prism::AssocSplatNode => "a double-splat (**) argument",
|
|
70
|
+
Prism::SourceFileNode => "__FILE__",
|
|
71
|
+
Prism::SourceLineNode => "__LINE__",
|
|
72
|
+
Prism::SourceEncodingNode => "__ENCODING__",
|
|
73
|
+
Prism::SelfNode => "self",
|
|
74
|
+
Prism::PreExecutionNode => "a BEGIN block",
|
|
75
|
+
Prism::PostExecutionNode => "an END block"
|
|
76
|
+
}.freeze
|
|
77
|
+
|
|
78
|
+
# @param source [String, nil] Inquirex DSL source to validate
|
|
79
|
+
# @param max_bytes [Integer] ceiling on source size
|
|
80
|
+
# @param max_depth [Integer] ceiling on AST nesting depth
|
|
81
|
+
def initialize(source,
|
|
82
|
+
max_bytes: SafeSource::DEFAULT_MAX_SOURCE_BYTES,
|
|
83
|
+
max_depth: SafeSource::DEFAULT_MAX_DEPTH)
|
|
84
|
+
@source = source
|
|
85
|
+
@max_bytes = max_bytes
|
|
86
|
+
@max_depth = max_depth
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Every reason this source falls outside the allowlist.
|
|
90
|
+
#
|
|
91
|
+
# @return [Array<String>] `"line N: reason"` messages, empty when safe
|
|
92
|
+
def violations
|
|
93
|
+
@violations ||= analyze.uniq.first(MAX_REPORTED)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# @return [Array<String>]
|
|
99
|
+
def analyze
|
|
100
|
+
@found = []
|
|
101
|
+
return ["the DSL is blank"] if @source.nil? || @source.to_s.strip.empty?
|
|
102
|
+
return ["the DSL must be a String, got #{@source.class}"] unless @source.is_a?(String)
|
|
103
|
+
|
|
104
|
+
size = @source.bytesize
|
|
105
|
+
return ["the DSL is #{size} bytes, over the #{@max_bytes}-byte limit"] if size > @max_bytes
|
|
106
|
+
|
|
107
|
+
result = Prism.parse(@source)
|
|
108
|
+
return syntax_violations(result) if result.failure?
|
|
109
|
+
# A __END__ data section is inert under eval, but nothing that generates
|
|
110
|
+
# flow DSL emits one, and a payload parked below the marker is exactly
|
|
111
|
+
# the sort of thing a future refactor could start feeding somewhere else.
|
|
112
|
+
return ["line #{result.data_loc.start_line}: the DSL must not have a __END__ data section"] if result.data_loc
|
|
113
|
+
if (comment = foreign_encoding(result))
|
|
114
|
+
return ["the DSL must not declare a source encoding (found #{comment.value.inspect}); write UTF-8"]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
check_program(result.value)
|
|
118
|
+
@found
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# An `# encoding:` magic comment naming anything other than UTF-8 or
|
|
122
|
+
# US-ASCII. Validation and evaluation must agree on where one token ends
|
|
123
|
+
# and the next begins; in encodings whose multi-byte sequences may contain
|
|
124
|
+
# ASCII bytes (Shift_JIS and friends) that agreement depends on both
|
|
125
|
+
# readers applying the same encoding, and the comment is the only lever a
|
|
126
|
+
# payload has over it. Nothing that generates flow DSL emits one.
|
|
127
|
+
#
|
|
128
|
+
# @param result [Prism::ParseResult]
|
|
129
|
+
# @return [Prism::MagicComment, nil]
|
|
130
|
+
def foreign_encoding(result)
|
|
131
|
+
result.magic_comments.find do |comment|
|
|
132
|
+
%w[encoding coding].include?(comment.key.downcase) &&
|
|
133
|
+
!%w[utf-8 utf8 us-ascii ascii binary ascii-8bit].include?(comment.value.downcase)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @param result [Prism::ParseResult]
|
|
138
|
+
# @return [Array<String>]
|
|
139
|
+
def syntax_violations(result)
|
|
140
|
+
result.errors.first(MAX_REPORTED).map do |error|
|
|
141
|
+
"line #{error.location.start_line}: syntax error — #{error.message}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Records a violation against a node's line.
|
|
146
|
+
#
|
|
147
|
+
# @param node [Prism::Node, nil]
|
|
148
|
+
# @param message [String]
|
|
149
|
+
# @return [nil]
|
|
150
|
+
def reject(node, message)
|
|
151
|
+
line = node&.location&.start_line || 1
|
|
152
|
+
@found << "line #{line}: #{message}"
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The whole program must be exactly one `Inquirex.define` block.
|
|
157
|
+
#
|
|
158
|
+
# @param program [Prism::ProgramNode]
|
|
159
|
+
# @return [void]
|
|
160
|
+
def check_program(program)
|
|
161
|
+
body = program.statements.body
|
|
162
|
+
if body.length != 1
|
|
163
|
+
return reject(program,
|
|
164
|
+
"the DSL must be a single `#{Vocabulary::ENTRY_CONSTANT}.#{Vocabulary::ENTRY_METHOD}` block " \
|
|
165
|
+
"(found #{body.length} top-level statements)")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
check_entry(body.first)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @param node [Prism::Node] the single top-level statement
|
|
172
|
+
# @return [void]
|
|
173
|
+
def check_entry(node)
|
|
174
|
+
unless entry_call?(node)
|
|
175
|
+
return reject(node,
|
|
176
|
+
"the DSL must be a single `#{Vocabulary::ENTRY_CONSTANT}.#{Vocabulary::ENTRY_METHOD}` block")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
spec = Vocabulary.entry_spec
|
|
180
|
+
check_block_for(node, spec, depth: 1)
|
|
181
|
+
check_arguments(node, spec, depth: 1)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# @param node [Prism::Node]
|
|
185
|
+
# @return [Boolean] whether this is literally `Inquirex.define`
|
|
186
|
+
def entry_call?(node)
|
|
187
|
+
node.is_a?(Prism::CallNode) &&
|
|
188
|
+
node.name == Vocabulary::ENTRY_METHOD &&
|
|
189
|
+
!node.safe_navigation? &&
|
|
190
|
+
node.receiver.is_a?(Prism::ConstantReadNode) &&
|
|
191
|
+
node.receiver.name == Vocabulary::ENTRY_CONSTANT
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Validates every statement in a block body against a scope's table.
|
|
195
|
+
#
|
|
196
|
+
# @param block [Prism::BlockNode]
|
|
197
|
+
# @param scope [Symbol] a scope registered with {Vocabulary.register_scope}
|
|
198
|
+
# @param depth [Integer]
|
|
199
|
+
# @return [void]
|
|
200
|
+
def check_block(block, scope, depth:)
|
|
201
|
+
return reject(block, too_deep_message) if too_deep?(depth)
|
|
202
|
+
|
|
203
|
+
reject(block.parameters, "#{scope_label(scope)} block takes no parameters") if block.parameters
|
|
204
|
+
|
|
205
|
+
body = block.body
|
|
206
|
+
return if body.nil?
|
|
207
|
+
unless body.is_a?(Prism::StatementsNode)
|
|
208
|
+
return reject(body, "#{describe(body)} is not allowed inside #{scope_label(scope)} block")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
body.body.each { |statement| check_call(statement, scope, depth: depth + 1) }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# @param node [Prism::Node] one statement from a block body
|
|
215
|
+
# @param scope [Symbol]
|
|
216
|
+
# @param depth [Integer]
|
|
217
|
+
# @return [void]
|
|
218
|
+
def check_call(node, scope, depth:)
|
|
219
|
+
return reject(node, too_deep_message) if too_deep?(depth)
|
|
220
|
+
|
|
221
|
+
unless plain_self_call?(node)
|
|
222
|
+
return reject(node, "#{describe(node)} is not allowed inside #{scope_label(scope)} block")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
spec = Vocabulary.spec_for(scope, node.name)
|
|
226
|
+
return reject_unknown_call(node, scope) if spec.nil?
|
|
227
|
+
|
|
228
|
+
# Blocks are checked first so that `default { ... }` reports the Ruby
|
|
229
|
+
# block, not the argument count it happens to also get wrong.
|
|
230
|
+
check_block_for(node, spec, depth: depth)
|
|
231
|
+
check_arguments(node, spec, depth: depth)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Explains a call the scope's table does not contain — quoting the
|
|
235
|
+
# recorded reason when the word exists but was deliberately excluded.
|
|
236
|
+
#
|
|
237
|
+
# @param node [Prism::CallNode]
|
|
238
|
+
# @param scope [Symbol]
|
|
239
|
+
# @return [nil]
|
|
240
|
+
def reject_unknown_call(node, scope)
|
|
241
|
+
reason = Vocabulary.exclusion_for(scope, node.name)
|
|
242
|
+
return reject(node, "`#{node.name}` is not available in safe mode: #{reason}#{UNSAFE_HINT}") if reason
|
|
243
|
+
|
|
244
|
+
reject(node, "`#{node.name}` is not allowed inside #{scope_label(scope)} block")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# A bare `keyword ...` call on implicit self — no receiver, no `&.`.
|
|
248
|
+
#
|
|
249
|
+
# @param node [Prism::Node]
|
|
250
|
+
# @return [Boolean]
|
|
251
|
+
def plain_self_call?(node)
|
|
252
|
+
node.is_a?(Prism::CallNode) &&
|
|
253
|
+
node.receiver.nil? &&
|
|
254
|
+
!node.safe_navigation? &&
|
|
255
|
+
node.call_operator_loc.nil?
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# @param node [Prism::CallNode]
|
|
259
|
+
# @param spec [CallSpec]
|
|
260
|
+
# @param depth [Integer]
|
|
261
|
+
# @return [void]
|
|
262
|
+
def check_arguments(node, spec, depth:)
|
|
263
|
+
args = node.arguments&.arguments&.dup || []
|
|
264
|
+
|
|
265
|
+
# A trailing keyword hash is only peeled off when the call actually
|
|
266
|
+
# takes keywords. For `options single: "Single"` the hash IS the one
|
|
267
|
+
# positional argument, which is exactly how Ruby passes it.
|
|
268
|
+
keywords = args.pop if spec.keywords && args.last.is_a?(Prism::KeywordHashNode)
|
|
269
|
+
|
|
270
|
+
check_positional(node, args, spec.positional, depth: depth)
|
|
271
|
+
check_keywords(node, keywords, spec.keywords, depth: depth)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# @param node [Prism::CallNode] for the error message and line number
|
|
275
|
+
# @param args [Array<Prism::Node>] positional arguments
|
|
276
|
+
# @param descriptor [Array<Symbol>, Hash] see {CallSpec#positional}
|
|
277
|
+
# @param depth [Integer]
|
|
278
|
+
# @return [void]
|
|
279
|
+
def check_positional(node, args, descriptor, depth:)
|
|
280
|
+
case descriptor
|
|
281
|
+
when Array
|
|
282
|
+
if args.length != descriptor.length
|
|
283
|
+
return reject(node, "`#{node.name}` takes #{descriptor.length} argument(s), got #{args.length}")
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
descriptor.each_with_index do |kind, index|
|
|
287
|
+
check_value(args[index], kind, depth: depth + 1, context: "`#{node.name}` argument #{index + 1}")
|
|
288
|
+
end
|
|
289
|
+
when Hash
|
|
290
|
+
check_variadic(node, args, descriptor, depth: depth)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# @param node [Prism::CallNode]
|
|
295
|
+
# @param args [Array<Prism::Node>]
|
|
296
|
+
# @param descriptor [Hash] `{ repeat:, min: }` or `{ optional: }`
|
|
297
|
+
# @param depth [Integer]
|
|
298
|
+
# @return [void]
|
|
299
|
+
def check_variadic(node, args, descriptor, depth:)
|
|
300
|
+
if (kind = descriptor[:repeat])
|
|
301
|
+
minimum = descriptor.fetch(:min, 0)
|
|
302
|
+
return reject(node, "`#{node.name}` needs at least #{minimum} argument(s)") if args.length < minimum
|
|
303
|
+
else
|
|
304
|
+
kind = descriptor.fetch(:optional)
|
|
305
|
+
return reject(node, "`#{node.name}` takes at most 1 argument") if args.length > 1
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
args.each_with_index do |arg, index|
|
|
309
|
+
check_value(arg, kind, depth: depth + 1, context: "`#{node.name}` argument #{index + 1}")
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# @param node [Prism::CallNode]
|
|
314
|
+
# @param keywords [Prism::KeywordHashNode, nil]
|
|
315
|
+
# @param descriptor [nil, Hash] see {CallSpec#keywords}
|
|
316
|
+
# @param depth [Integer]
|
|
317
|
+
# @return [void]
|
|
318
|
+
def check_keywords(node, keywords, descriptor, depth:)
|
|
319
|
+
return if keywords.nil?
|
|
320
|
+
return reject(keywords, "`#{node.name}` takes no keyword arguments") if descriptor.nil?
|
|
321
|
+
|
|
322
|
+
keywords.elements.each do |element|
|
|
323
|
+
unless element.is_a?(Prism::AssocNode)
|
|
324
|
+
next reject(element, "`#{node.name}`: #{describe(element)} is not allowed in a keyword list")
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
name = literal_key(element.key)
|
|
328
|
+
next reject(element.key, "`#{node.name}`: keyword names must be plain symbols") if name.nil?
|
|
329
|
+
|
|
330
|
+
kind = descriptor[name] || descriptor[Vocabulary::ANY_OTHER]
|
|
331
|
+
next reject(element, "`#{node.name}` does not accept `#{name}:`") if kind.nil?
|
|
332
|
+
|
|
333
|
+
check_value(element.value, kind, depth: depth + 1, context: "`#{node.name}` #{name}:")
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# @param node [Prism::Node, nil]
|
|
338
|
+
# @param kind [Symbol] :literal, :string, :symbol, :type_name or :rule
|
|
339
|
+
# @param depth [Integer]
|
|
340
|
+
# @param context [String] what this value belongs to, for the message
|
|
341
|
+
# @return [void]
|
|
342
|
+
def check_value(node, kind, depth:, context:)
|
|
343
|
+
return reject(node, too_deep_message) if too_deep?(depth)
|
|
344
|
+
|
|
345
|
+
case kind
|
|
346
|
+
when :literal then check_literal(node, depth: depth, context: context)
|
|
347
|
+
when :string then check_static(node, :static_string?, "a plain string", context: context)
|
|
348
|
+
when :symbol then check_static(node, :static_symbol?, "a plain symbol", context: context)
|
|
349
|
+
when :type_name then check_type_name(node, context: context)
|
|
350
|
+
when :rule then check_rule(node, depth: depth, context: context)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# @param node [Prism::Node, nil]
|
|
355
|
+
# @param predicate [Symbol] {#static_string?} or {#static_symbol?}
|
|
356
|
+
# @param label [String] how to describe the accepted shape to the author
|
|
357
|
+
# @param context [String]
|
|
358
|
+
# @return [void]
|
|
359
|
+
def check_static(node, predicate, label, context:)
|
|
360
|
+
return if send(predicate, node)
|
|
361
|
+
|
|
362
|
+
reject(node, "#{context} must be #{label}, found #{describe(node)}")
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# One of the gem's own data types. Bound to {Node::TYPES} rather than
|
|
366
|
+
# accepting any symbol, so a typo is a validation error instead of a step
|
|
367
|
+
# that silently renders as free text.
|
|
368
|
+
#
|
|
369
|
+
# @param node [Prism::Node, nil]
|
|
370
|
+
# @param context [String]
|
|
371
|
+
# @return [void]
|
|
372
|
+
def check_type_name(node, context:)
|
|
373
|
+
return reject(node, "#{context} must be a plain symbol, found #{describe(node)}") unless static_name?(node)
|
|
374
|
+
return if Node::TYPES.include?(node.unescaped.to_sym)
|
|
375
|
+
|
|
376
|
+
reject(node, "#{context} must be one of #{Node::TYPES.join(", ")}, found #{node.unescaped}")
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# A string with no embedded expressions.
|
|
380
|
+
#
|
|
381
|
+
# Prism represents a dedented heredoc — and adjacent literal
|
|
382
|
+
# concatenation — as an InterpolatedStringNode whose parts happen to be
|
|
383
|
+
# plain StringNodes. Those are static text, and multi-line `send_email`
|
|
384
|
+
# bodies rely on them, so they are accepted; the moment any part is an
|
|
385
|
+
# embedded expression (or a nested interpolated string) the node is
|
|
386
|
+
# rejected.
|
|
387
|
+
#
|
|
388
|
+
# @param node [Prism::Node, nil]
|
|
389
|
+
# @return [Boolean]
|
|
390
|
+
def static_string?(node)
|
|
391
|
+
case node
|
|
392
|
+
when Prism::StringNode then true
|
|
393
|
+
when Prism::InterpolatedStringNode then node.parts.all?(Prism::StringNode)
|
|
394
|
+
else false
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# A symbol with no embedded expressions.
|
|
399
|
+
#
|
|
400
|
+
# @param node [Prism::Node, nil]
|
|
401
|
+
# @return [Boolean]
|
|
402
|
+
def static_symbol?(node)
|
|
403
|
+
case node
|
|
404
|
+
when Prism::SymbolNode then true
|
|
405
|
+
when Prism::InterpolatedSymbolNode then node.parts.all?(Prism::StringNode)
|
|
406
|
+
else false
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# A single-part symbol or string, i.e. a node whose text is knowable
|
|
411
|
+
# without evaluating anything.
|
|
412
|
+
#
|
|
413
|
+
# @param node [Prism::Node, nil]
|
|
414
|
+
# @return [Boolean]
|
|
415
|
+
def static_name?(node)
|
|
416
|
+
node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Literals, and Arrays/Hashes built only out of literals.
|
|
420
|
+
#
|
|
421
|
+
# @param node [Prism::Node, nil]
|
|
422
|
+
# @param depth [Integer]
|
|
423
|
+
# @param context [String]
|
|
424
|
+
# @return [void]
|
|
425
|
+
def check_literal(node, depth:, context:)
|
|
426
|
+
return reject(node, too_deep_message) if too_deep?(depth)
|
|
427
|
+
|
|
428
|
+
case node
|
|
429
|
+
when Prism::IntegerNode, Prism::FloatNode, Prism::TrueNode, Prism::FalseNode, Prism::NilNode
|
|
430
|
+
nil
|
|
431
|
+
when Prism::ArrayNode
|
|
432
|
+
node.elements.each { |element| check_literal(element, depth: depth + 1, context: context) }
|
|
433
|
+
when Prism::HashNode, Prism::KeywordHashNode
|
|
434
|
+
check_hash_literal(node, depth: depth, context: context)
|
|
435
|
+
else
|
|
436
|
+
return if static_string?(node) || static_symbol?(node)
|
|
437
|
+
|
|
438
|
+
reject(node, "#{context} must be a literal value, found #{describe(node)}")
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# @param node [Prism::HashNode, Prism::KeywordHashNode]
|
|
443
|
+
# @param depth [Integer]
|
|
444
|
+
# @param context [String]
|
|
445
|
+
# @return [void]
|
|
446
|
+
def check_hash_literal(node, depth:, context:)
|
|
447
|
+
node.elements.each do |element|
|
|
448
|
+
unless element.is_a?(Prism::AssocNode)
|
|
449
|
+
next reject(element, "#{context}: #{describe(element)} is not allowed in a Hash literal")
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
if literal_key(element.key).nil?
|
|
453
|
+
next reject(element.key, "#{context}: Hash keys must be plain symbols or strings")
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
check_literal(element.value, depth: depth + 1, context: context)
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# @param node [Prism::Node, nil]
|
|
461
|
+
# @param depth [Integer]
|
|
462
|
+
# @param context [String]
|
|
463
|
+
# @return [void]
|
|
464
|
+
def check_rule(node, depth:, context:)
|
|
465
|
+
return reject(node, too_deep_message) if too_deep?(depth)
|
|
466
|
+
|
|
467
|
+
spec = plain_self_call?(node) ? Vocabulary.spec_for(:rule, node.name) : nil
|
|
468
|
+
if spec.nil?
|
|
469
|
+
return reject(node,
|
|
470
|
+
"#{context} must be one of #{Vocabulary.allowed_names(:rule).join(", ")}, found #{describe(node)}")
|
|
471
|
+
end
|
|
472
|
+
return reject(node.block, "rule `#{node.name}` takes no block") if node.block
|
|
473
|
+
|
|
474
|
+
check_arguments(node, spec, depth: depth)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# @param node [Prism::CallNode]
|
|
478
|
+
# @param spec [CallSpec]
|
|
479
|
+
# @param depth [Integer]
|
|
480
|
+
# @return [void]
|
|
481
|
+
def check_block_for(node, spec, depth:)
|
|
482
|
+
block = node.block
|
|
483
|
+
|
|
484
|
+
if spec.block == :forbidden
|
|
485
|
+
return if block.nil?
|
|
486
|
+
|
|
487
|
+
return reject(block,
|
|
488
|
+
"`#{node.name}` takes no block in safe mode — a Ruby block is arbitrary server-side " \
|
|
489
|
+
"code that cannot be validated#{UNSAFE_HINT}")
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# `{ optional: scope }` — the call accepts the block but does not need
|
|
493
|
+
# it, because the same fields can be given as keywords.
|
|
494
|
+
scope = spec.block_scope
|
|
495
|
+
|
|
496
|
+
return if block.nil? && spec.block.is_a?(Hash)
|
|
497
|
+
return reject(node, "`#{node.name}` requires a block") if block.nil?
|
|
498
|
+
unless block.is_a?(Prism::BlockNode)
|
|
499
|
+
return reject(block, "`#{node.name}` requires a literal block, found #{describe(block)}")
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
check_block(block, scope, depth: depth + 1)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# @param depth [Integer]
|
|
506
|
+
# @return [Boolean]
|
|
507
|
+
def too_deep?(depth)
|
|
508
|
+
depth > @max_depth
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# @return [String]
|
|
512
|
+
def too_deep_message
|
|
513
|
+
"the DSL nests deeper than #{@max_depth} levels"
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# The Symbol a Hash/keyword key denotes, or nil when the key is not a
|
|
517
|
+
# plain (uninterpolated) symbol or string.
|
|
518
|
+
#
|
|
519
|
+
# @param node [Prism::Node]
|
|
520
|
+
# @return [Symbol, nil]
|
|
521
|
+
def literal_key(node)
|
|
522
|
+
node.unescaped.to_sym if static_name?(node)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# @param scope [Symbol]
|
|
526
|
+
# @return [String] human-readable scope name, article included
|
|
527
|
+
def scope_label(scope)
|
|
528
|
+
Vocabulary.label_for(scope)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# A short, author-facing description of a node the allowlist rejected.
|
|
532
|
+
#
|
|
533
|
+
# @param node [Prism::Node, nil]
|
|
534
|
+
# @return [String]
|
|
535
|
+
def describe(node)
|
|
536
|
+
return "nothing" if node.nil?
|
|
537
|
+
return describe_call(node) if node.is_a?(Prism::CallNode)
|
|
538
|
+
|
|
539
|
+
NODE_LABELS.fetch(node.class) { humanize(node.class) }
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# @param node [Prism::CallNode]
|
|
543
|
+
# @return [String]
|
|
544
|
+
def describe_call(node)
|
|
545
|
+
return "`#{node.name}`" if node.receiver.nil?
|
|
546
|
+
|
|
547
|
+
"a method call `#{node.name}` on #{describe(node.receiver)}"
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# @param klass [Class] a Prism node class
|
|
551
|
+
# @return [String] e.g. "a local variable write"
|
|
552
|
+
def humanize(klass)
|
|
553
|
+
words = klass.name.delete_prefix("Prism::").delete_suffix("Node")
|
|
554
|
+
.gsub(/([a-z\d])([A-Z])/, '\1 \2').downcase
|
|
555
|
+
"#{words.start_with?(/[aeiou]/) ? "an" : "a"} #{words}"
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
end
|