ruby-rego 0.1.0
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 +7 -0
- data/.reek.yml +80 -0
- data/.vscode/extensions.json +19 -0
- data/.vscode/launch.json +35 -0
- data/.vscode/settings.json +25 -0
- data/.vscode/tasks.json +117 -0
- data/.yardopts +12 -0
- data/ARCHITECTURE.md +39 -0
- data/CHANGELOG.md +25 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +183 -0
- data/RELEASING.md +37 -0
- data/Rakefile +38 -0
- data/SECURITY.md +26 -0
- data/Steepfile +10 -0
- data/TODO.md +35 -0
- data/benchmark/builtin_calls.rb +29 -0
- data/benchmark/complex_policy.rb +19 -0
- data/benchmark/comprehensions.rb +19 -0
- data/benchmark/simple_rules.rb +20 -0
- data/examples/README.md +27 -0
- data/examples/sample_config.yaml +2 -0
- data/examples/simple_policy.rego +7 -0
- data/examples/validation_policy.rego +11 -0
- data/exe/rego-validate +6 -0
- data/lib/ruby/rego/ast/base.rb +95 -0
- data/lib/ruby/rego/ast/binary_op.rb +64 -0
- data/lib/ruby/rego/ast/call.rb +27 -0
- data/lib/ruby/rego/ast/composite.rb +48 -0
- data/lib/ruby/rego/ast/comprehension.rb +63 -0
- data/lib/ruby/rego/ast/every.rb +37 -0
- data/lib/ruby/rego/ast/import.rb +32 -0
- data/lib/ruby/rego/ast/literal.rb +70 -0
- data/lib/ruby/rego/ast/module.rb +32 -0
- data/lib/ruby/rego/ast/package.rb +22 -0
- data/lib/ruby/rego/ast/query.rb +63 -0
- data/lib/ruby/rego/ast/reference.rb +58 -0
- data/lib/ruby/rego/ast/rule.rb +114 -0
- data/lib/ruby/rego/ast/unary_op.rb +42 -0
- data/lib/ruby/rego/ast/variable.rb +22 -0
- data/lib/ruby/rego/ast.rb +17 -0
- data/lib/ruby/rego/builtins/aggregates.rb +124 -0
- data/lib/ruby/rego/builtins/base.rb +95 -0
- data/lib/ruby/rego/builtins/collections/array_ops.rb +103 -0
- data/lib/ruby/rego/builtins/collections/object_ops.rb +120 -0
- data/lib/ruby/rego/builtins/collections/set_ops.rb +51 -0
- data/lib/ruby/rego/builtins/collections.rb +137 -0
- data/lib/ruby/rego/builtins/comparisons/casts.rb +139 -0
- data/lib/ruby/rego/builtins/comparisons.rb +84 -0
- data/lib/ruby/rego/builtins/numeric_helpers.rb +56 -0
- data/lib/ruby/rego/builtins/registry.rb +199 -0
- data/lib/ruby/rego/builtins/registry_helpers.rb +27 -0
- data/lib/ruby/rego/builtins/strings/case_ops.rb +22 -0
- data/lib/ruby/rego/builtins/strings/concat.rb +19 -0
- data/lib/ruby/rego/builtins/strings/formatting.rb +35 -0
- data/lib/ruby/rego/builtins/strings/helpers.rb +62 -0
- data/lib/ruby/rego/builtins/strings/number_helpers.rb +48 -0
- data/lib/ruby/rego/builtins/strings/search.rb +63 -0
- data/lib/ruby/rego/builtins/strings/split.rb +19 -0
- data/lib/ruby/rego/builtins/strings/substring.rb +22 -0
- data/lib/ruby/rego/builtins/strings/trim.rb +42 -0
- data/lib/ruby/rego/builtins/strings/trim_helpers.rb +62 -0
- data/lib/ruby/rego/builtins/strings.rb +58 -0
- data/lib/ruby/rego/builtins/types.rb +89 -0
- data/lib/ruby/rego/call_name.rb +55 -0
- data/lib/ruby/rego/cli.rb +1122 -0
- data/lib/ruby/rego/compiled_module.rb +114 -0
- data/lib/ruby/rego/compiler.rb +1097 -0
- data/lib/ruby/rego/environment/overrides.rb +33 -0
- data/lib/ruby/rego/environment/reference_resolution.rb +86 -0
- data/lib/ruby/rego/environment.rb +230 -0
- data/lib/ruby/rego/environment_pool.rb +71 -0
- data/lib/ruby/rego/error_handling.rb +58 -0
- data/lib/ruby/rego/error_payload.rb +34 -0
- data/lib/ruby/rego/errors.rb +196 -0
- data/lib/ruby/rego/evaluator/assignment_support.rb +126 -0
- data/lib/ruby/rego/evaluator/binding_helpers.rb +60 -0
- data/lib/ruby/rego/evaluator/comprehension_evaluator.rb +182 -0
- data/lib/ruby/rego/evaluator/expression_dispatch.rb +45 -0
- data/lib/ruby/rego/evaluator/expression_evaluator.rb +492 -0
- data/lib/ruby/rego/evaluator/object_literal_evaluator.rb +52 -0
- data/lib/ruby/rego/evaluator/operator_evaluator.rb +163 -0
- data/lib/ruby/rego/evaluator/query_node_builder.rb +38 -0
- data/lib/ruby/rego/evaluator/reference_key_resolver.rb +50 -0
- data/lib/ruby/rego/evaluator/reference_resolver.rb +352 -0
- data/lib/ruby/rego/evaluator/rule_evaluator/bindings.rb +70 -0
- data/lib/ruby/rego/evaluator/rule_evaluator.rb +550 -0
- data/lib/ruby/rego/evaluator/rule_value_provider.rb +56 -0
- data/lib/ruby/rego/evaluator/variable_collector.rb +221 -0
- data/lib/ruby/rego/evaluator.rb +174 -0
- data/lib/ruby/rego/lexer/number_reader.rb +68 -0
- data/lib/ruby/rego/lexer/stream.rb +137 -0
- data/lib/ruby/rego/lexer/string_reader.rb +90 -0
- data/lib/ruby/rego/lexer/template_string_reader.rb +62 -0
- data/lib/ruby/rego/lexer.rb +206 -0
- data/lib/ruby/rego/location.rb +73 -0
- data/lib/ruby/rego/memoization.rb +67 -0
- data/lib/ruby/rego/parser/collections.rb +173 -0
- data/lib/ruby/rego/parser/expressions.rb +216 -0
- data/lib/ruby/rego/parser/precedence.rb +42 -0
- data/lib/ruby/rego/parser/query.rb +139 -0
- data/lib/ruby/rego/parser/references.rb +115 -0
- data/lib/ruby/rego/parser/rules.rb +310 -0
- data/lib/ruby/rego/parser.rb +210 -0
- data/lib/ruby/rego/policy.rb +50 -0
- data/lib/ruby/rego/result.rb +91 -0
- data/lib/ruby/rego/token.rb +206 -0
- data/lib/ruby/rego/unifier.rb +451 -0
- data/lib/ruby/rego/value.rb +379 -0
- data/lib/ruby/rego/version.rb +7 -0
- data/lib/ruby/rego/with_modifiers/with_modifier.rb +37 -0
- data/lib/ruby/rego/with_modifiers/with_modifier_applier.rb +48 -0
- data/lib/ruby/rego/with_modifiers/with_modifier_builtin_override.rb +128 -0
- data/lib/ruby/rego/with_modifiers/with_modifier_context.rb +120 -0
- data/lib/ruby/rego/with_modifiers/with_modifier_path_key_resolver.rb +42 -0
- data/lib/ruby/rego/with_modifiers/with_modifier_path_override.rb +99 -0
- data/lib/ruby/rego/with_modifiers/with_modifier_root_scope.rb +58 -0
- data/lib/ruby/rego.rb +72 -0
- data/sig/objspace.rbs +4 -0
- data/sig/psych.rbs +7 -0
- data/sig/rego_validate.rbs +382 -0
- data/sig/ruby/rego.rbs +2150 -0
- metadata +172 -0
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "yaml"
|
|
6
|
+
require "ruby/rego"
|
|
7
|
+
|
|
8
|
+
# CLI entrypoints and helpers for rego-validate.
|
|
9
|
+
module RegoValidate
|
|
10
|
+
# CLI option values.
|
|
11
|
+
Options = Struct.new(:policy, :config, :query, :format, :help, :yaml_aliases, :profile, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
# CLI option values.
|
|
14
|
+
class Options
|
|
15
|
+
# Check whether help output was requested.
|
|
16
|
+
#
|
|
17
|
+
# @return [Boolean]
|
|
18
|
+
def help?
|
|
19
|
+
help
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Check whether profiling output was requested.
|
|
23
|
+
#
|
|
24
|
+
# @return [Boolean]
|
|
25
|
+
def profile?
|
|
26
|
+
profile
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Parsed options plus parser state and error details.
|
|
31
|
+
ParseResult = Struct.new(:options, :parser, :error, keyword_init: true)
|
|
32
|
+
|
|
33
|
+
# Parsed options plus parser state and error details.
|
|
34
|
+
class ParseResult
|
|
35
|
+
# Check whether parsing succeeded.
|
|
36
|
+
#
|
|
37
|
+
# @return [Boolean]
|
|
38
|
+
def success?
|
|
39
|
+
!error
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Report the parse error using the configured output format.
|
|
43
|
+
#
|
|
44
|
+
# @param stdout [IO]
|
|
45
|
+
# @param stderr [IO]
|
|
46
|
+
# @return [void]
|
|
47
|
+
def report_error(stdout:, stderr:)
|
|
48
|
+
reporter = ErrorReporter.new(stdout: stdout, stderr: stderr, format: options.format)
|
|
49
|
+
reporter.error(error_message, parser)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def error_message
|
|
55
|
+
error ? error.message : "Invalid command-line options"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Captures the outcome of loading a config file.
|
|
60
|
+
ConfigLoadResult = Struct.new(:value, :success, keyword_init: true)
|
|
61
|
+
|
|
62
|
+
# Captures the outcome of loading a config file.
|
|
63
|
+
class ConfigLoadResult
|
|
64
|
+
# Check whether loading succeeded.
|
|
65
|
+
#
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def success?
|
|
68
|
+
success
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Policy evaluation outcome with optional error message.
|
|
73
|
+
EvaluationResult = Struct.new(:outcome, :error_message, keyword_init: true)
|
|
74
|
+
|
|
75
|
+
# Policy evaluation outcome with optional error message.
|
|
76
|
+
class EvaluationResult
|
|
77
|
+
# Check whether evaluation succeeded.
|
|
78
|
+
#
|
|
79
|
+
# @return [Boolean]
|
|
80
|
+
def success?
|
|
81
|
+
!!outcome && error_message.to_s.empty?
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Normalized policy evaluation outcome.
|
|
86
|
+
Outcome = Struct.new(:success, :value, :errors, keyword_init: true)
|
|
87
|
+
|
|
88
|
+
# Normalized policy evaluation outcome.
|
|
89
|
+
class Outcome
|
|
90
|
+
# Check whether the outcome indicates success.
|
|
91
|
+
#
|
|
92
|
+
# @return [Boolean]
|
|
93
|
+
def success?
|
|
94
|
+
success
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Command-line interface for validating inputs against a Rego policy.
|
|
99
|
+
class CLI
|
|
100
|
+
# Create a CLI instance.
|
|
101
|
+
#
|
|
102
|
+
# @param argv [Array<String>] command-line arguments
|
|
103
|
+
# @param stdout [IO] output stream
|
|
104
|
+
# @param stderr [IO] error stream
|
|
105
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr)
|
|
106
|
+
@argv = argv
|
|
107
|
+
@stdout = stdout
|
|
108
|
+
@stderr = stderr
|
|
109
|
+
@options = Options.new(format: "text", help: false, yaml_aliases: false, profile: false)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Run the CLI and return an exit status.
|
|
113
|
+
#
|
|
114
|
+
# @return [Integer]
|
|
115
|
+
def run
|
|
116
|
+
perform_run
|
|
117
|
+
rescue Ruby::Rego::Error => e
|
|
118
|
+
handle_rego_error(e)
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
handle_unexpected_error(e)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
attr_reader :argv, :options, :stdout, :stderr
|
|
126
|
+
|
|
127
|
+
def perform_run
|
|
128
|
+
parse_result = OptionsParser.new(argv).parse
|
|
129
|
+
return handle_parse_error(parse_result) unless parse_result.success?
|
|
130
|
+
|
|
131
|
+
apply_parse_result(parse_result)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def apply_parse_result(parse_result)
|
|
135
|
+
parser = parse_result.parser
|
|
136
|
+
@options = parse_result.options
|
|
137
|
+
return handle_help(parser) if options.help?
|
|
138
|
+
return 2 unless required_options_present?(parser)
|
|
139
|
+
|
|
140
|
+
handle_evaluation(parser)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def handle_evaluation(parser)
|
|
144
|
+
evaluation = evaluate_policy(parser, profiler: options.profile? ? Profiler.new(stderr: stderr) : nil)
|
|
145
|
+
outcome = evaluation.outcome
|
|
146
|
+
return 2 unless evaluation.success? && outcome
|
|
147
|
+
|
|
148
|
+
emit_outcome(outcome)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def emit_outcome(outcome)
|
|
152
|
+
OutcomeEmitter.new(stdout, format: options.format).emit(outcome)
|
|
153
|
+
outcome.success? ? 0 : 1
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def handle_parse_error(parse_result)
|
|
157
|
+
parse_result.report_error(stdout: stdout, stderr: stderr)
|
|
158
|
+
2
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def required_options_present?(parser)
|
|
162
|
+
missing = OptionsValidator.new(options).missing_required
|
|
163
|
+
return true if missing.empty?
|
|
164
|
+
|
|
165
|
+
reporter.error("Missing required options: #{missing.join(", ")}", parser)
|
|
166
|
+
false
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def evaluate_policy(parser, profiler: nil)
|
|
170
|
+
policy_source, config_result = SourceLoader.new(options: options, reporter: reporter, parser: parser).load
|
|
171
|
+
return EvaluationResult.new unless policy_source && config_result.success?
|
|
172
|
+
|
|
173
|
+
evaluation = PolicyEvaluator.new(policy_source, config_result.value, options.query, profiler: profiler).evaluate
|
|
174
|
+
report_evaluation_error(evaluation, parser)
|
|
175
|
+
evaluation
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def report_evaluation_error(evaluation, parser)
|
|
179
|
+
message = evaluation.error_message
|
|
180
|
+
return unless message
|
|
181
|
+
|
|
182
|
+
reporter.error(message, parser)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def handle_help(parser)
|
|
186
|
+
if options.format == "json"
|
|
187
|
+
stdout.puts(JSON.generate({ success: true, help: parser.to_s }))
|
|
188
|
+
else
|
|
189
|
+
stdout.puts(parser)
|
|
190
|
+
end
|
|
191
|
+
0
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def reporter
|
|
195
|
+
ErrorReporter.new(stdout: stdout, stderr: stderr, format: options.format)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def handle_rego_error(error)
|
|
199
|
+
reporter.rego_error(error)
|
|
200
|
+
2
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def handle_unexpected_error(error)
|
|
204
|
+
reporter.error("Unexpected error: #{error.message}")
|
|
205
|
+
2
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Parses CLI arguments into a structured options object.
|
|
210
|
+
class OptionsParser
|
|
211
|
+
VALID_FORMATS = %w[text json].freeze
|
|
212
|
+
|
|
213
|
+
# Create an options parser.
|
|
214
|
+
#
|
|
215
|
+
# @param argv [Array<String>] command-line arguments
|
|
216
|
+
def initialize(argv)
|
|
217
|
+
@argv = argv
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Parse arguments into an options result.
|
|
221
|
+
#
|
|
222
|
+
# @return [ParseResult]
|
|
223
|
+
def parse
|
|
224
|
+
ParseResultBuilder.new(argv).call
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
attr_reader :argv
|
|
230
|
+
|
|
231
|
+
# Builds ParseResult objects from argv values.
|
|
232
|
+
class ParseResultBuilder
|
|
233
|
+
# @param argv [Array<String>]
|
|
234
|
+
def initialize(argv)
|
|
235
|
+
@argv = argv
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# @return [ParseResult]
|
|
239
|
+
def call
|
|
240
|
+
# @type var options: Options
|
|
241
|
+
options = Options.new(format: "text", help: false, yaml_aliases: false, profile: false)
|
|
242
|
+
parse_with(options)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
private
|
|
246
|
+
|
|
247
|
+
attr_reader :argv
|
|
248
|
+
|
|
249
|
+
def parse_with(options)
|
|
250
|
+
parser = OptionDefinitions.new(options).build
|
|
251
|
+
parser.parse!(@argv)
|
|
252
|
+
ParseResult.new(options: options, parser: parser)
|
|
253
|
+
rescue OptionParser::ParseError => e
|
|
254
|
+
ParseResult.new(options: options, parser: parser, error: e)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Builds option definitions for OptionParser.
|
|
259
|
+
class OptionDefinitions
|
|
260
|
+
OPTION_BUILDERS = %i[
|
|
261
|
+
add_policy_option
|
|
262
|
+
add_config_option
|
|
263
|
+
add_query_option
|
|
264
|
+
add_format_option
|
|
265
|
+
add_profile_option
|
|
266
|
+
add_yaml_aliases_option
|
|
267
|
+
add_help_option
|
|
268
|
+
].freeze
|
|
269
|
+
|
|
270
|
+
# @param options [Options]
|
|
271
|
+
def initialize(options)
|
|
272
|
+
@options = options
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# @return [OptionParser]
|
|
276
|
+
def build
|
|
277
|
+
OptionParser.new do |opts|
|
|
278
|
+
opts.banner = "Usage: rego-validate --policy POLICY_FILE --config CONFIG_FILE [options]"
|
|
279
|
+
apply_options(opts)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
private
|
|
284
|
+
|
|
285
|
+
attr_reader :options
|
|
286
|
+
|
|
287
|
+
def apply_options(opts)
|
|
288
|
+
OPTION_BUILDERS.each { |builder| send(builder, opts) }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def add_policy_option(opts)
|
|
292
|
+
opts.on("--policy FILE", "Rego policy file (required)") do |file|
|
|
293
|
+
options.policy = file
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def add_config_option(opts)
|
|
298
|
+
opts.on("--config FILE", "YAML/JSON config file (required)") do |file|
|
|
299
|
+
options.config = file
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def add_query_option(opts)
|
|
304
|
+
opts.on("--query QUERY", "Query path (optional, defaults to violations/errors)") do |query|
|
|
305
|
+
options.query = query
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def add_format_option(opts)
|
|
310
|
+
message = "Output format: #{OptionsParser::VALID_FORMATS.join(", ")} (default: text)"
|
|
311
|
+
opts.on("--format FORMAT", OptionsParser::VALID_FORMATS, message) do |format|
|
|
312
|
+
options.format = format
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def add_profile_option(opts)
|
|
317
|
+
opts.on("--profile", "Emit evaluation profiling to stderr") do
|
|
318
|
+
options.profile = true
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def add_help_option(opts)
|
|
323
|
+
opts.on("-h", "--help", "Show this help") do
|
|
324
|
+
options.help = true
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def add_yaml_aliases_option(opts)
|
|
329
|
+
opts.on("--yaml-aliases", "Allow YAML aliases in config files") do
|
|
330
|
+
options.yaml_aliases = true
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Checks presence of required CLI options.
|
|
337
|
+
class OptionsValidator
|
|
338
|
+
# Create a validator for parsed options.
|
|
339
|
+
#
|
|
340
|
+
# @param options [Options]
|
|
341
|
+
def initialize(options)
|
|
342
|
+
@options = options
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# List missing required flags.
|
|
346
|
+
#
|
|
347
|
+
# @return [Array<String>]
|
|
348
|
+
def missing_required
|
|
349
|
+
# @type var missing: Array[String]
|
|
350
|
+
missing = []
|
|
351
|
+
missing << "--policy" unless options.policy
|
|
352
|
+
missing << "--config" unless options.config
|
|
353
|
+
missing
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
private
|
|
357
|
+
|
|
358
|
+
attr_reader :options
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Loads policy and input configuration files.
|
|
362
|
+
class ConfigLoader
|
|
363
|
+
JSON_EXTENSIONS = [".json"].freeze
|
|
364
|
+
|
|
365
|
+
# Create a config loader.
|
|
366
|
+
#
|
|
367
|
+
# @param reporter [ErrorReporter]
|
|
368
|
+
# @param parser [OptionParser]
|
|
369
|
+
def initialize(reporter:, parser:, yaml_aliases:)
|
|
370
|
+
@reporter = reporter
|
|
371
|
+
@parser = parser
|
|
372
|
+
@json_extensions = JSON_EXTENSIONS
|
|
373
|
+
@yaml_aliases = yaml_aliases
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Read the policy file content.
|
|
377
|
+
#
|
|
378
|
+
# @param path [String]
|
|
379
|
+
# @return [String, nil]
|
|
380
|
+
def read_policy(path)
|
|
381
|
+
read_file(path, "policy")
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Read and parse the config file content.
|
|
385
|
+
#
|
|
386
|
+
# @param path [String]
|
|
387
|
+
# @return [ConfigLoadResult]
|
|
388
|
+
def read_config(path)
|
|
389
|
+
content = read_file(path, "config")
|
|
390
|
+
return ConfigLoadResult.new(success: false) unless content
|
|
391
|
+
|
|
392
|
+
parse_config(content, path)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
private
|
|
396
|
+
|
|
397
|
+
attr_reader :reporter, :parser, :json_extensions, :yaml_aliases
|
|
398
|
+
|
|
399
|
+
def read_file(path, label)
|
|
400
|
+
File.read(path)
|
|
401
|
+
rescue Errno::ENOENT
|
|
402
|
+
report_file_error(label, "not found", path)
|
|
403
|
+
nil
|
|
404
|
+
rescue Errno::EACCES
|
|
405
|
+
report_file_error(label, "not readable", path)
|
|
406
|
+
nil
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def parse_config(content, path)
|
|
410
|
+
value = parse_config_value(content, path)
|
|
411
|
+
ConfigLoadResult.new(value: value, success: true)
|
|
412
|
+
rescue JSON::ParserError, Psych::BadAlias, Psych::SyntaxError => e
|
|
413
|
+
reporter.error("Invalid config file: #{e.message}", parser)
|
|
414
|
+
ConfigLoadResult.new(success: false)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def parse_config_value(content, path)
|
|
418
|
+
json_config?(path) ? JSON.parse(content) : YAML.safe_load(content, aliases: yaml_aliases)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def report_file_error(label, reason, path)
|
|
422
|
+
reporter.error("#{label.capitalize} file #{reason}: #{path}", parser)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def json_config?(path)
|
|
426
|
+
json_extensions.include?(File.extname(path).downcase)
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Loads policy and config sources based on CLI options.
|
|
431
|
+
class SourceLoader
|
|
432
|
+
# @param options [Options]
|
|
433
|
+
# @param reporter [ErrorReporter]
|
|
434
|
+
# @param parser [OptionParser]
|
|
435
|
+
def initialize(options:, reporter:, parser:)
|
|
436
|
+
@options = options
|
|
437
|
+
@loader = ConfigLoader.new(reporter: reporter, parser: parser, yaml_aliases: options.yaml_aliases)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# @return [Array<(String, ConfigLoadResult)>]
|
|
441
|
+
def load
|
|
442
|
+
policy_source = load_policy_source
|
|
443
|
+
return [nil, ConfigLoadResult.new(success: false)] unless policy_source
|
|
444
|
+
|
|
445
|
+
[policy_source, load_config]
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
private
|
|
449
|
+
|
|
450
|
+
attr_reader :options, :loader
|
|
451
|
+
|
|
452
|
+
def load_policy_source
|
|
453
|
+
policy_path = options.policy
|
|
454
|
+
return nil unless policy_path
|
|
455
|
+
|
|
456
|
+
loader.read_policy(policy_path)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def load_config
|
|
460
|
+
config_path = options.config
|
|
461
|
+
return ConfigLoadResult.new(success: false) unless config_path
|
|
462
|
+
|
|
463
|
+
loader.read_config(config_path)
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Resolves default queries based on available rules.
|
|
468
|
+
class DefaultQueryResolver
|
|
469
|
+
DEFAULT_RULE_NAMES = %w[deny violations violation errors error].freeze
|
|
470
|
+
FALLBACK_RULE_NAMES = %w[allow].freeze
|
|
471
|
+
|
|
472
|
+
# @param compiled_module [Ruby::Rego::CompiledModule]
|
|
473
|
+
def initialize(compiled_module)
|
|
474
|
+
@compiled_module = compiled_module
|
|
475
|
+
@rule_names = DEFAULT_RULE_NAMES + FALLBACK_RULE_NAMES
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# @return [String, nil]
|
|
479
|
+
def resolve
|
|
480
|
+
rule_name = rule_names.find do |name|
|
|
481
|
+
rule_available?(name)
|
|
482
|
+
end
|
|
483
|
+
return nil unless rule_name
|
|
484
|
+
|
|
485
|
+
base = ["data", *package_path].join(".")
|
|
486
|
+
"#{base}.#{rule_name}"
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
private
|
|
490
|
+
|
|
491
|
+
attr_reader :compiled_module, :rule_names
|
|
492
|
+
|
|
493
|
+
def rule_available?(name)
|
|
494
|
+
compiled_module.has_rule?(name)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def package_path
|
|
498
|
+
compiled_module.package_path
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
private_constant :DefaultQueryResolver
|
|
502
|
+
|
|
503
|
+
# Captures timing and memory statistics for policy evaluation.
|
|
504
|
+
class Profiler
|
|
505
|
+
# Holds a single profiler sample.
|
|
506
|
+
Sample = Struct.new(:label, :duration_ms, :allocations, :memory_bytes, :top_objects, keyword_init: true)
|
|
507
|
+
|
|
508
|
+
# Rendering helpers for profiler samples.
|
|
509
|
+
class Sample
|
|
510
|
+
def report_line
|
|
511
|
+
parts = [
|
|
512
|
+
" #{label}: #{format_duration}",
|
|
513
|
+
"allocs +#{allocations}",
|
|
514
|
+
"mem #{format_bytes}"
|
|
515
|
+
]
|
|
516
|
+
parts.join(", ")
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def top_objects_line
|
|
520
|
+
return nil if top_objects.empty?
|
|
521
|
+
|
|
522
|
+
" top allocations: #{top_objects.join(", ")}"
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
private
|
|
526
|
+
|
|
527
|
+
def format_duration
|
|
528
|
+
format("%.2f ms", duration_ms)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def format_bytes
|
|
532
|
+
ByteFormatter.new(memory_bytes).render
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Formats byte sizes for profiler output.
|
|
537
|
+
class ByteFormatter
|
|
538
|
+
def initialize(bytes)
|
|
539
|
+
@sign = bytes.negative? ? "-" : "+"
|
|
540
|
+
@size = bytes.abs
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def render
|
|
544
|
+
unit, value = if size < 1024
|
|
545
|
+
["B", size.to_s]
|
|
546
|
+
elsif size < 1024 * 1024
|
|
547
|
+
["KB", Kernel.format("%.2f", size / 1024.0)]
|
|
548
|
+
else
|
|
549
|
+
["MB", Kernel.format("%.2f", size / (1024.0 * 1024.0))]
|
|
550
|
+
end
|
|
551
|
+
"#{sign}#{value} #{unit}"
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
private
|
|
555
|
+
|
|
556
|
+
attr_reader :sign, :size
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Captures a memory snapshot for diffing.
|
|
560
|
+
class Snapshot
|
|
561
|
+
class << self
|
|
562
|
+
def capture
|
|
563
|
+
require "objspace"
|
|
564
|
+
build_snapshot(memsize: ObjectSpace.memsize_of_all, objects: ObjectSpace.count_objects)
|
|
565
|
+
rescue LoadError, NoMethodError
|
|
566
|
+
build_snapshot(memsize: 0, objects: empty_object_counts)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def capture_before
|
|
570
|
+
capture
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def capture_after
|
|
574
|
+
capture
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
private
|
|
578
|
+
|
|
579
|
+
def build_snapshot(memsize:, objects:)
|
|
580
|
+
new(
|
|
581
|
+
allocated: GC.stat[:total_allocated_objects],
|
|
582
|
+
memsize: memsize,
|
|
583
|
+
objects: objects
|
|
584
|
+
)
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def empty_object_counts
|
|
588
|
+
{} # @type var objects: Hash[Symbol, Integer]
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def initialize(allocated:, memsize:, objects:)
|
|
593
|
+
@allocated = allocated
|
|
594
|
+
@memsize = memsize
|
|
595
|
+
@objects = objects
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
attr_reader :allocated, :memsize, :objects
|
|
599
|
+
|
|
600
|
+
def delta(other)
|
|
601
|
+
Delta.new(
|
|
602
|
+
allocations: other.allocated - allocated,
|
|
603
|
+
memory_bytes: other.memsize - memsize,
|
|
604
|
+
object_deltas: object_delta_map(other.objects)
|
|
605
|
+
)
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
private
|
|
609
|
+
|
|
610
|
+
def object_delta_map(after_objects)
|
|
611
|
+
deltas = {} # @type var deltas: Hash[Symbol, Integer]
|
|
612
|
+
after_objects.each { |key, count| add_delta(deltas, key, count) }
|
|
613
|
+
deltas
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def add_delta(deltas, key, count)
|
|
617
|
+
return if Delta.skip_key?(key)
|
|
618
|
+
|
|
619
|
+
delta = count - (objects[key] || 0)
|
|
620
|
+
deltas[key] = delta if delta.positive?
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# Computes deltas between snapshots.
|
|
625
|
+
class Delta
|
|
626
|
+
SKIP_KEYS = %i[TOTAL FREE].freeze
|
|
627
|
+
|
|
628
|
+
def self.skip_key?(key)
|
|
629
|
+
SKIP_KEYS.include?(key)
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def initialize(allocations:, memory_bytes:, object_deltas:)
|
|
633
|
+
@allocations = allocations
|
|
634
|
+
@memory_bytes = memory_bytes
|
|
635
|
+
@object_deltas = object_deltas
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
attr_reader :allocations, :memory_bytes, :object_deltas
|
|
639
|
+
|
|
640
|
+
def top_objects(limit: 3)
|
|
641
|
+
object_deltas
|
|
642
|
+
.sort_by { |(_, count)| -count }
|
|
643
|
+
.first(limit)
|
|
644
|
+
.map { |(key, count)| "#{key} +#{count}" }
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
# Tracks measurement state for a single sample.
|
|
649
|
+
class Measurement
|
|
650
|
+
def initialize(label:, before:, start:)
|
|
651
|
+
@label = label
|
|
652
|
+
@before = before
|
|
653
|
+
@start = start
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def finish(after:, finish:)
|
|
657
|
+
delta = before.delta(after)
|
|
658
|
+
Sample.new(
|
|
659
|
+
label: label,
|
|
660
|
+
duration_ms: ((finish - start) * 1000.0),
|
|
661
|
+
allocations: delta.allocations,
|
|
662
|
+
memory_bytes: delta.memory_bytes,
|
|
663
|
+
top_objects: delta.top_objects
|
|
664
|
+
)
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
private
|
|
668
|
+
|
|
669
|
+
attr_reader :before, :label, :start
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# @param stderr [IO]
|
|
673
|
+
def initialize(stderr: $stderr)
|
|
674
|
+
@stderr = stderr
|
|
675
|
+
@samples = [] # @type var @samples: Array[Sample]
|
|
676
|
+
@clock = Process.method(:clock_gettime)
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# @param label [String]
|
|
680
|
+
# @return [Object]
|
|
681
|
+
def measure(label)
|
|
682
|
+
measurement = start_measurement(label)
|
|
683
|
+
result = yield
|
|
684
|
+
result
|
|
685
|
+
ensure
|
|
686
|
+
finish_measurement(measurement)
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# @return [void]
|
|
690
|
+
def report
|
|
691
|
+
return if samples.empty?
|
|
692
|
+
|
|
693
|
+
stderr.puts("Profile:")
|
|
694
|
+
report_samples
|
|
695
|
+
report_hotspot
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
private
|
|
699
|
+
|
|
700
|
+
attr_reader :clock, :samples, :stderr
|
|
701
|
+
|
|
702
|
+
def report_samples
|
|
703
|
+
samples.each do |sample|
|
|
704
|
+
stderr.puts(sample.report_line)
|
|
705
|
+
top_line = sample.top_objects_line
|
|
706
|
+
stderr.puts(top_line) if top_line
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def report_hotspot
|
|
711
|
+
hotspot = samples.max_by(&:duration_ms)
|
|
712
|
+
stderr.puts(" hotspot: #{hotspot.label}") if hotspot
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def start_measurement(label)
|
|
716
|
+
Measurement.new(
|
|
717
|
+
label: label,
|
|
718
|
+
before: Snapshot.capture_before,
|
|
719
|
+
start: clock_time
|
|
720
|
+
)
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
def finish_measurement(measurement)
|
|
724
|
+
return unless measurement
|
|
725
|
+
|
|
726
|
+
sample = measurement.finish(after: Snapshot.capture_after, finish: clock_time)
|
|
727
|
+
samples << sample
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
def clock_time
|
|
731
|
+
clock.call(Process::CLOCK_MONOTONIC)
|
|
732
|
+
end
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
# Compiles and evaluates policies with a resolved query.
|
|
736
|
+
class PolicyEvaluator
|
|
737
|
+
# Create a policy evaluator.
|
|
738
|
+
#
|
|
739
|
+
# @param policy_source [String]
|
|
740
|
+
# @param input [Object]
|
|
741
|
+
# @param query [String, nil]
|
|
742
|
+
def initialize(policy_source, input, query, profiler: nil)
|
|
743
|
+
@policy_source = policy_source
|
|
744
|
+
@input = input
|
|
745
|
+
@query = query
|
|
746
|
+
@profiler = profiler
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# Compile and evaluate the policy using the resolved query.
|
|
750
|
+
#
|
|
751
|
+
# @return [EvaluationResult]
|
|
752
|
+
def evaluate
|
|
753
|
+
compiled_module = measure("compile") { Ruby::Rego.compile(policy_source) }
|
|
754
|
+
query_path = resolve_query(compiled_module)
|
|
755
|
+
return EvaluationResult.new(error_message: "No default validation rule found. Provide --query.") unless query_path
|
|
756
|
+
|
|
757
|
+
build_evaluation(compiled_module, query_path)
|
|
758
|
+
ensure
|
|
759
|
+
profiler&.report
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
private
|
|
763
|
+
|
|
764
|
+
attr_reader :policy_source, :input, :query, :profiler
|
|
765
|
+
|
|
766
|
+
def resolve_query(compiled_module)
|
|
767
|
+
query || DefaultQueryResolver.new(compiled_module).resolve
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def build_evaluation(compiled_module, query_path)
|
|
771
|
+
result = measure("evaluate") { evaluate_compiled(compiled_module, query_path) }
|
|
772
|
+
outcome = OutcomeBuilder.new(result, query_path).build
|
|
773
|
+
EvaluationResult.new(outcome: outcome)
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
def evaluate_compiled(compiled_module, query_path)
|
|
777
|
+
Ruby::Rego::Evaluator.new(compiled_module, input: input, data: nil).evaluate(query_path)
|
|
778
|
+
rescue Ruby::Rego::Error
|
|
779
|
+
raise
|
|
780
|
+
rescue StandardError => e
|
|
781
|
+
raise Ruby::Rego::Error.new("Rego evaluation failed: #{e.message}", location: nil), cause: e
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
def measure(label, &)
|
|
785
|
+
return yield unless profiler
|
|
786
|
+
|
|
787
|
+
profiler.measure(label, &)
|
|
788
|
+
end
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
# Builds a normalized outcome payload from evaluation results.
|
|
792
|
+
class OutcomeBuilder
|
|
793
|
+
# Create an outcome builder.
|
|
794
|
+
#
|
|
795
|
+
# @param result [Ruby::Rego::Result]
|
|
796
|
+
# @param query [String]
|
|
797
|
+
def initialize(result, query)
|
|
798
|
+
@result = result
|
|
799
|
+
@query = query
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
# Build the normalized outcome.
|
|
803
|
+
#
|
|
804
|
+
# @return [Outcome]
|
|
805
|
+
def build
|
|
806
|
+
return undefined_outcome if result.undefined?
|
|
807
|
+
|
|
808
|
+
build_defined_outcome
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
private
|
|
812
|
+
|
|
813
|
+
attr_reader :result, :query
|
|
814
|
+
|
|
815
|
+
def build_defined_outcome
|
|
816
|
+
value = result.value.to_ruby
|
|
817
|
+
errors = errors_for(value)
|
|
818
|
+
Outcome.new(success: errors.empty?, value: value, errors: errors)
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
def errors_for(value)
|
|
822
|
+
errors = errors_from_value(value)
|
|
823
|
+
result_errors = result.errors
|
|
824
|
+
errors.concat(result_errors.map(&:to_s)) unless result_errors.empty?
|
|
825
|
+
errors
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
def undefined_outcome
|
|
829
|
+
Outcome.new(success: false, value: nil, errors: [format_rule_error("undefined")])
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
def errors_from_value(value)
|
|
833
|
+
return [] if value == true
|
|
834
|
+
|
|
835
|
+
errors_for_non_true(value)
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
def errors_for_non_true(value)
|
|
839
|
+
scalar = scalar_error(value)
|
|
840
|
+
return scalar unless value
|
|
841
|
+
return collection_errors(value) if value.is_a?(Array) || value.is_a?(Set)
|
|
842
|
+
return hash_errors(value) if value.is_a?(Hash)
|
|
843
|
+
|
|
844
|
+
scalar
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
def scalar_error(value)
|
|
848
|
+
[format_rule_error(value)]
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
def collection_errors(value)
|
|
852
|
+
value.to_a.map { |item| format_rule_error(item) }
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
def hash_errors(value)
|
|
856
|
+
return [] if value.empty?
|
|
857
|
+
|
|
858
|
+
[format_rule_error(value)]
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
def format_rule_error(value)
|
|
862
|
+
"Rule '#{rule_name}' returned: #{value.inspect}"
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
def rule_name
|
|
866
|
+
@rule_name ||= query.to_s.split(".").last
|
|
867
|
+
end
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
# Emits human-readable or JSON output.
|
|
871
|
+
class OutcomeEmitter
|
|
872
|
+
# Emits JSON-formatted validation output.
|
|
873
|
+
class JsonFormatter
|
|
874
|
+
# @param stdout [IO]
|
|
875
|
+
def initialize(stdout)
|
|
876
|
+
@stdout = stdout
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
# @param outcome [Outcome]
|
|
880
|
+
# @return [void]
|
|
881
|
+
def emit(outcome)
|
|
882
|
+
payload = OutcomePayload.new(outcome).to_h
|
|
883
|
+
stdout.puts(JSON.generate(payload))
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
private
|
|
887
|
+
|
|
888
|
+
attr_reader :stdout
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
# Emits human-readable validation output.
|
|
892
|
+
class TextFormatter
|
|
893
|
+
# @param stdout [IO]
|
|
894
|
+
def initialize(stdout)
|
|
895
|
+
@stdout = stdout
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
# @param outcome [Outcome]
|
|
899
|
+
# @return [void]
|
|
900
|
+
def emit(outcome)
|
|
901
|
+
return stdout.puts("✓ Validation passed") if outcome.success?
|
|
902
|
+
|
|
903
|
+
stdout.puts("✗ Validation failed:")
|
|
904
|
+
outcome.errors.each { |error| stdout.puts(" - #{error}") }
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
private
|
|
908
|
+
|
|
909
|
+
attr_reader :stdout
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
# Builds a JSON-serializable payload from an outcome.
|
|
913
|
+
class OutcomePayload
|
|
914
|
+
# @param outcome [Outcome]
|
|
915
|
+
def initialize(outcome)
|
|
916
|
+
@outcome = outcome
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
# @return [Hash{Symbol => Object}]
|
|
920
|
+
def to_h
|
|
921
|
+
return { success: true, result: normalize_json(outcome.value) } if outcome.success?
|
|
922
|
+
|
|
923
|
+
{ success: false, errors: outcome.errors }
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
private
|
|
927
|
+
|
|
928
|
+
attr_reader :outcome
|
|
929
|
+
|
|
930
|
+
def normalize_json(value)
|
|
931
|
+
case value
|
|
932
|
+
when Array
|
|
933
|
+
normalize_array(value)
|
|
934
|
+
when Hash
|
|
935
|
+
normalize_hash(value)
|
|
936
|
+
when Set
|
|
937
|
+
normalize_set(value)
|
|
938
|
+
else
|
|
939
|
+
value
|
|
940
|
+
end
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
def normalize_array(values)
|
|
944
|
+
values.map { |item| normalize_json(item) }
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
def normalize_hash(values)
|
|
948
|
+
values.transform_values { |item| normalize_json(item) }
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
def normalize_set(values)
|
|
952
|
+
values.to_a.map { |item| normalize_json(item) }
|
|
953
|
+
end
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
FORMATTERS = {
|
|
957
|
+
"json" => JsonFormatter,
|
|
958
|
+
"text" => TextFormatter
|
|
959
|
+
}.freeze
|
|
960
|
+
|
|
961
|
+
# Create an emitter for CLI output.
|
|
962
|
+
#
|
|
963
|
+
# @param stdout [IO]
|
|
964
|
+
# @param format [String]
|
|
965
|
+
def initialize(stdout, format: "text")
|
|
966
|
+
@formatter = FORMATTERS.fetch(format, TextFormatter).new(stdout)
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
# Emit the outcome payload.
|
|
970
|
+
#
|
|
971
|
+
# @param outcome [Outcome]
|
|
972
|
+
# @return [void]
|
|
973
|
+
def emit(outcome)
|
|
974
|
+
formatter.emit(outcome)
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
private
|
|
978
|
+
|
|
979
|
+
attr_reader :formatter
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
# Formats and emits CLI errors to stderr/stdout.
|
|
983
|
+
class ErrorReporter
|
|
984
|
+
# Serializes error details for JSON output.
|
|
985
|
+
class ErrorPayload
|
|
986
|
+
# Build a payload for a CLI error message.
|
|
987
|
+
#
|
|
988
|
+
# @param message [String]
|
|
989
|
+
# @return [ErrorPayload]
|
|
990
|
+
def self.from_cli_error(message)
|
|
991
|
+
new(message: message, type: "CLIError")
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
# Build a payload for a Rego error.
|
|
995
|
+
#
|
|
996
|
+
# @param error [Ruby::Rego::Error]
|
|
997
|
+
# @return [ErrorPayload]
|
|
998
|
+
def self.from_rego_error(error)
|
|
999
|
+
new(message: error.message, type: error.class.name, location: error.location)
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
# @param message [String]
|
|
1003
|
+
# @param type [String]
|
|
1004
|
+
# @param location [Ruby::Rego::Location, nil]
|
|
1005
|
+
def initialize(message:, type:, location: nil)
|
|
1006
|
+
@message = message
|
|
1007
|
+
@type = type
|
|
1008
|
+
@location = location
|
|
1009
|
+
end
|
|
1010
|
+
|
|
1011
|
+
# @return [Hash{Symbol => Object}]
|
|
1012
|
+
def to_h
|
|
1013
|
+
payload = { success: false, error: message, type: type }
|
|
1014
|
+
return payload unless location
|
|
1015
|
+
|
|
1016
|
+
payload.merge(
|
|
1017
|
+
location: location.to_s,
|
|
1018
|
+
line: location.line,
|
|
1019
|
+
column: location.column
|
|
1020
|
+
)
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
private
|
|
1024
|
+
|
|
1025
|
+
attr_reader :message, :type, :location
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
# Emits JSON-formatted error output.
|
|
1029
|
+
class JsonFormatter
|
|
1030
|
+
# @param stdout [IO]
|
|
1031
|
+
# @param stderr [IO]
|
|
1032
|
+
def initialize(stdout:, stderr:)
|
|
1033
|
+
@stdout = stdout
|
|
1034
|
+
@stderr = stderr
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
# @param message [String]
|
|
1038
|
+
# @param parser [OptionParser, nil]
|
|
1039
|
+
# @return [void]
|
|
1040
|
+
def error(message, _parser = nil)
|
|
1041
|
+
payload = ErrorPayload.from_cli_error(message).to_h
|
|
1042
|
+
stdout.puts(JSON.generate(payload))
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
# @param error [Ruby::Rego::Error]
|
|
1046
|
+
# @return [void]
|
|
1047
|
+
def rego_error(error)
|
|
1048
|
+
payload = ErrorPayload.from_rego_error(error).to_h
|
|
1049
|
+
stdout.puts(JSON.generate(payload))
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
private
|
|
1053
|
+
|
|
1054
|
+
attr_reader :stdout, :stderr
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
# Emits text error output for CLI.
|
|
1058
|
+
class TextFormatter
|
|
1059
|
+
# @param stdout [IO]
|
|
1060
|
+
# @param stderr [IO]
|
|
1061
|
+
def initialize(stdout:, stderr:)
|
|
1062
|
+
@stdout = stdout
|
|
1063
|
+
@stderr = stderr
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
# @param message [String]
|
|
1067
|
+
# @param parser [OptionParser, nil]
|
|
1068
|
+
# @return [void]
|
|
1069
|
+
def error(message, parser = nil)
|
|
1070
|
+
stderr.puts("Error: #{message}")
|
|
1071
|
+
stderr.puts(parser) if parser
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
# @param error [Ruby::Rego::Error]
|
|
1075
|
+
# @return [void]
|
|
1076
|
+
def rego_error(error)
|
|
1077
|
+
location = error.location
|
|
1078
|
+
stderr.puts("Error: #{error.message}")
|
|
1079
|
+
stderr.puts(" at #{location}") if location
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
private
|
|
1083
|
+
|
|
1084
|
+
attr_reader :stdout, :stderr
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
FORMATTERS = {
|
|
1088
|
+
"json" => JsonFormatter,
|
|
1089
|
+
"text" => TextFormatter
|
|
1090
|
+
}.freeze
|
|
1091
|
+
|
|
1092
|
+
# Create an error reporter.
|
|
1093
|
+
#
|
|
1094
|
+
# @param stdout [IO]
|
|
1095
|
+
# @param stderr [IO]
|
|
1096
|
+
# @param format [String]
|
|
1097
|
+
def initialize(stdout:, stderr:, format: "text")
|
|
1098
|
+
@formatter = FORMATTERS.fetch(format, TextFormatter).new(stdout: stdout, stderr: stderr)
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
# Emit a generic CLI error.
|
|
1102
|
+
#
|
|
1103
|
+
# @param message [String]
|
|
1104
|
+
# @param parser [OptionParser, nil]
|
|
1105
|
+
# @return [void]
|
|
1106
|
+
def error(message, parser = nil)
|
|
1107
|
+
@formatter.error(message, parser)
|
|
1108
|
+
end
|
|
1109
|
+
|
|
1110
|
+
# Emit a Ruby::Rego error with location details.
|
|
1111
|
+
#
|
|
1112
|
+
# @param error [Ruby::Rego::Error]
|
|
1113
|
+
# @return [void]
|
|
1114
|
+
def rego_error(error)
|
|
1115
|
+
@formatter.rego_error(error)
|
|
1116
|
+
end
|
|
1117
|
+
|
|
1118
|
+
private
|
|
1119
|
+
|
|
1120
|
+
attr_reader :formatter
|
|
1121
|
+
end
|
|
1122
|
+
end
|