hyperprobe-agent 1.2.27.pre.3-java

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.
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'socket'
5
+ require 'json'
6
+
7
+ if RUBY_PLATFORM !~ /java/
8
+ require 'grpc'
9
+ else
10
+ require_relative 'transports/java_grpc'
11
+ end
12
+ require_relative '../protos'
13
+
14
+ module HyperProbe
15
+ module Core
16
+ class BrokerClient
17
+ attr_reader :agent_id, :service_id, :environment, :commit_sha, :agent_version, :stub
18
+
19
+ def initialize(
20
+ broker_url:,
21
+ service_id:,
22
+ environment:,
23
+ commit_sha:,
24
+ agent_id: nil,
25
+ agent_version: '1.0.0',
26
+ rpc_timeout_sec: 10.0,
27
+ enable_keep_alive: true
28
+ )
29
+ @service_id = service_id.to_s
30
+ @environment = environment.to_s
31
+ @commit_sha = commit_sha.to_s
32
+ @agent_id = agent_id || SecureRandom.uuid
33
+ @agent_version = agent_version.to_s
34
+ @rpc_timeout_sec = valid_timeout(rpc_timeout_sec)
35
+ @hostname = ENV['HOSTNAME'] || Socket.gethostname rescue 'unknown'
36
+ @is_jruby = RUBY_PLATFORM =~ /java/
37
+
38
+ if @is_jruby
39
+ @transport = Transports::JavaGrpc.new(broker_url, @service_id, enable_keep_alive)
40
+ else
41
+ @calls_mutex = Mutex.new
42
+ @active_calls = []
43
+ @closed = false
44
+ target, creds, channel_args = parse_broker_url(broker_url, enable_keep_alive)
45
+ @channel = GRPC::Core::Channel.new(target, channel_args, creds)
46
+ @stub = Hyperprobe::Agent::V1::AgentBroker::Stub.new(target, creds, channel_override: @channel)
47
+ end
48
+ end
49
+
50
+ def get_probes(timeout_sec = nil)
51
+ deadline = calculate_deadline(timeout_sec || @rpc_timeout_sec)
52
+ req = Hyperprobe::Agent::V1::GetProbesRequest.new(
53
+ agent_id: @agent_id,
54
+ service_id: @service_id,
55
+ environment: @environment,
56
+ commit_sha: @commit_sha,
57
+ language: @is_jruby ? 'jruby' : 'ruby',
58
+ agent_version: @agent_version,
59
+ hostname: @hostname
60
+ )
61
+
62
+ if @is_jruby
63
+ call_jruby_rpc('hyperprobe.agent.v1.AgentBroker/GetProbes', req, Hyperprobe::Agent::V1::GetProbesResponse, deadline)
64
+ else
65
+ call_cruby_rpc(:get_probes, req, deadline)
66
+ end
67
+ end
68
+
69
+ def report_telemetry(events, timeout_sec = nil)
70
+ return [] if events.nil? || events.empty?
71
+
72
+ deadline = calculate_deadline(timeout_sec || @rpc_timeout_sec)
73
+ proto_events = events.map { |evt| build_telemetry_event_proto(evt) }
74
+ batch = Hyperprobe::Agent::V1::TelemetryBatch.new(agent_id: @agent_id, events: proto_events)
75
+
76
+ response = if @is_jruby
77
+ call_jruby_rpc('hyperprobe.agent.v1.AgentBroker/ReportTelemetry', batch, Hyperprobe::Agent::V1::TelemetryResponse, deadline)
78
+ else
79
+ call_cruby_rpc(:report_telemetry, batch, deadline)
80
+ end
81
+ response.finished_probe_ids.to_a
82
+ end
83
+
84
+ def estimate_event_size(event)
85
+ proto = build_telemetry_event_proto(event)
86
+ proto.respond_to?(:serialized_size) ? proto.serialized_size : proto.to_proto.bytesize
87
+ rescue StandardError
88
+ JSON.generate(event).bytesize rescue 512
89
+ end
90
+
91
+ def shutdown
92
+ if @is_jruby
93
+ @transport.shutdown
94
+ else
95
+ calls = @calls_mutex.synchronize do
96
+ @closed = true
97
+ @active_calls.dup
98
+ end
99
+ # Closing a C-core channel alone does not cancel outstanding Ruby operations.
100
+ calls.each(&:cancel)
101
+ @channel.close
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ def call_cruby_rpc(method, request, deadline)
108
+ operation = @stub.public_send(method, request, metadata: { 'x-hp-service-id' => @service_id },
109
+ deadline: deadline, return_op: true)
110
+ @calls_mutex.synchronize do
111
+ raise IOError, 'Broker transport is shut down' if @closed
112
+
113
+ @active_calls << operation
114
+ end
115
+ operation.execute
116
+ ensure
117
+ @calls_mutex.synchronize { @active_calls.delete(operation) }
118
+ end
119
+
120
+ def call_jruby_rpc(method_path, request_proto, response_class, deadline)
121
+ payload = request_proto.to_proto
122
+ response_class.decode(@transport.call(method_path, payload, deadline))
123
+ end
124
+
125
+ def build_telemetry_event_proto(evt)
126
+ return evt if evt.is_a?(Hyperprobe::Agent::V1::TelemetryEvent)
127
+
128
+ stack_frames = Array(evt[:stack_frames]).map do |frame|
129
+ Hyperprobe::Agent::V1::StackFrame.new(
130
+ function_name: frame[:function_name].to_s,
131
+ file_name: frame[:file_name].to_s,
132
+ line_number: frame[:line_number].to_i,
133
+ column_number: frame[:column_number].to_i,
134
+ class_name: frame[:class_name] ? frame[:class_name].to_s : nil
135
+ )
136
+ end
137
+
138
+ proto_params = {
139
+ probe_id: evt[:probe_id].to_s,
140
+ timestamp_ms: (evt[:timestamp_ms] || (Time.now.to_f * 1000)).to_i,
141
+ stack_frames: stack_frames,
142
+ captured_vars_json: evt[:captured_vars_json].to_s,
143
+ watch_results_json: evt[:watch_results_json].to_s,
144
+ evaluated_log: evt[:evaluated_log].to_s,
145
+ metric_value: evt[:metric_value].to_f,
146
+ capture_error: evt[:capture_error].to_s
147
+ }
148
+
149
+ proto_params[:trace_id] = evt[:trace_id].to_s if evt[:trace_id] && !evt[:trace_id].to_s.empty?
150
+
151
+ Hyperprobe::Agent::V1::TelemetryEvent.new(proto_params)
152
+ end
153
+
154
+ def calculate_deadline(timeout_sec)
155
+ now = @is_jruby ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : Time.now
156
+ now + valid_timeout(timeout_sec)
157
+ end
158
+
159
+ def valid_timeout(value)
160
+ timeout = Float(value)
161
+ raise ArgumentError, 'RPC timeout must be finite and positive' unless timeout.finite? && timeout.positive?
162
+
163
+ timeout
164
+ end
165
+
166
+ def parse_broker_url(url, enable_keep_alive)
167
+ raw_url = url.to_s.strip
168
+ channel_args = {}
169
+ if enable_keep_alive
170
+ channel_args['grpc.keepalive_time_ms'] = 60_000
171
+ channel_args['grpc.keepalive_timeout_ms'] = 20_000
172
+ channel_args['grpc.keepalive_permit_without_calls'] = 1
173
+ end
174
+
175
+ if raw_url.start_with?('https://')
176
+ [raw_url.sub('https://', ''), GRPC::Core::ChannelCredentials.new, channel_args]
177
+ else
178
+ [raw_url.sub(/\Ahttp:\/\//, ''), :this_channel_is_insecure, channel_args]
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,418 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'safe_ast_validator'
4
+ require 'objspace' unless RUBY_PLATFORM.include?('java')
5
+
6
+ module HyperProbe
7
+ module Core
8
+ class Evaluator
9
+ MAX_STRING_BYTES = 4096
10
+ MAX_INTEGER_BITS = 1024
11
+ MAX_COLLECTION_ITEMS = 128
12
+ MAX_STEPS = 512
13
+ MAX_TEMPLATE_BYTES = 16_384
14
+ MAX_PLACEHOLDERS = 32
15
+ MEMORY_SIZE = ObjectSpace.method(:memsize_of) if ObjectSpace.respond_to?(:memsize_of)
16
+ JS_FLOAT_PREFIX_RE = /\A\s*([+-]?(?:(?:[0-9]+\.?[0-9]*)|(?:\.[0-9]+))(?:[eE][+-]?[0-9]+)?)/.freeze
17
+ CLASS = Object.instance_method(:class)
18
+ IDENTITY = BasicObject.instance_method(:equal?)
19
+ LOCAL_GET = Binding.instance_method(:local_variable_get)
20
+ LOCAL_DEFINED = Binding.instance_method(:local_variable_defined?)
21
+ BINDING_EVAL = Binding.instance_method(:eval)
22
+ TYPES = { string: String, integer: Integer, float: Float, symbol: Symbol,
23
+ array: Array, hash: Hash, binding: Binding, nil: NilClass,
24
+ true: TrueClass, false: FalseClass }.freeze
25
+ SYMBOL_NAME = if Symbol.method_defined?(:name)
26
+ Symbol.instance_method(:name)
27
+ elsif RUBY_PLATFORM.include?('java')
28
+ # JRuby 9.3 returns a constant-sized copy-on-write wrapper.
29
+ Symbol.instance_method(:to_s)
30
+ end
31
+ # Bind captured builtin implementations, bypassing singleton overrides.
32
+ # Only allowlisted interpreter operations reach this table.
33
+ BUILTINS = {
34
+ string: %i[bytesize byteslice [] length + == < <= > >= inspect to_f],
35
+ integer: %i[bit_length + - * / % == < <= > >= +@ -@ to_f to_s],
36
+ float: %i[+ - * / % == < <= > >= +@ -@ to_f to_s finite? nan? infinite? to_i],
37
+ symbol: %i[== name inspect],
38
+ array: %i[length []],
39
+ hash: %i[length each_pair compare_by_identity?]
40
+ }.each_with_object({}) do |(kind, methods), result|
41
+ result[kind] = methods.to_h do |method|
42
+ implementation = kind == :symbol && method == :name ? SYMBOL_NAME : TYPES.fetch(kind).instance_method(method)
43
+ [method, implementation]
44
+ end.freeze
45
+ end.freeze
46
+
47
+ class << self
48
+ def safe_evaluation_enabled?(disable = nil)
49
+ disable = ENV['HYPERPROBE_DISABLE_SAFE_EVALUATION'] if disable.nil?
50
+ !(disable == true || (disable.is_a?(String) && disable.strip.downcase == 'true'))
51
+ end
52
+
53
+ def eval_condition(condition, binding_ctx, safe: safe_evaluation_enabled?)
54
+ return true if blank?(condition)
55
+
56
+ truthy?(evaluate(condition, binding_ctx, safe: safe))
57
+ end
58
+
59
+ def evaluate_watches(watch_expressions, binding_ctx, safe: safe_evaluation_enabled?)
60
+ results = {}
61
+ return results if kind(watch_expressions) == :nil
62
+ raise SecurityError, 'Watches must be an exact builtin Array' unless kind(watch_expressions) == :array
63
+ size = builtin(watch_expressions, :length)
64
+ raise SecurityError, 'Too many watch expressions' if size > MAX_COLLECTION_ITEMS
65
+
66
+ size.times do |index|
67
+ expression = nil
68
+ begin
69
+ expression = SafeASTValidator.source(builtin(watch_expressions, :[], index))
70
+ next if expression.strip.empty?
71
+ results[expression] = evaluate(expression, binding_ctx, safe: safe)
72
+ rescue StandardError, ScriptError, SecurityError => e
73
+ results[expression || "<invalid expression #{index}>"] = "Error: #{e.class.name}: #{e.message}"
74
+ end
75
+ end
76
+ results
77
+ end
78
+
79
+ def evaluate_log_template(template, binding_ctx, safe: safe_evaluation_enabled?)
80
+ return '' if kind(template) == :nil
81
+
82
+ text = SafeASTValidator.source(template, MAX_TEMPLATE_BYTES)
83
+ characters = text.chars
84
+ output = String.new
85
+ cursor = 0
86
+ placeholders = 0
87
+ while cursor < characters.length
88
+ unless ['$', '#'].include?(characters[cursor]) && characters[cursor + 1] == '{'
89
+ output << characters[cursor]
90
+ cursor += 1
91
+ next
92
+ end
93
+
94
+ placeholders += 1
95
+ raise SecurityError, 'Too many log placeholders' if placeholders > MAX_PLACEHOLDERS
96
+ start = cursor + 2
97
+ cursor = start
98
+ depth = 1
99
+ quote = nil
100
+ escaped = false
101
+ while cursor < characters.length && depth > 0
102
+ char = characters[cursor]
103
+ if quote
104
+ if escaped
105
+ escaped = false
106
+ elsif char == '\\'
107
+ escaped = true
108
+ elsif char == quote
109
+ quote = nil
110
+ end
111
+ elsif ["'", '"'].include?(char)
112
+ quote = char
113
+ elsif char == '{'
114
+ depth += 1
115
+ elsif char == '}'
116
+ depth -= 1
117
+ end
118
+ cursor += 1
119
+ end
120
+
121
+ begin
122
+ raise ArgumentError, 'Unterminated log placeholder' unless depth.zero?
123
+ value = evaluate(characters[start...(cursor - 1)].join, binding_ctx, safe: safe)
124
+ rendered = display(value, [MAX_STEPS], 0)
125
+ raise SecurityError, 'Log output is too large' if output.bytesize + rendered.bytesize > MAX_TEMPLATE_BYTES
126
+ output << rendered
127
+ rescue StandardError, ScriptError, SecurityError => e
128
+ output << "<Error: #{e.class.name}: #{e.message}>"
129
+ end
130
+ raise SecurityError, 'Log output is too large' if output.bytesize > MAX_TEMPLATE_BYTES
131
+ end
132
+ raise SecurityError, 'Log output is too large' if output.bytesize > MAX_TEMPLATE_BYTES
133
+ output
134
+ end
135
+
136
+ def evaluate_metric(metric_expression, binding_ctx, safe: safe_evaluation_enabled?)
137
+ return nil if blank?(metric_expression)
138
+
139
+ coerce_metric_value(evaluate(metric_expression, binding_ctx, safe: safe))
140
+ end
141
+
142
+ def evaluate_correlation(correlation_expression, binding_ctx, safe: safe_evaluation_enabled?)
143
+ return 'static-singleton' if blank?(correlation_expression)
144
+
145
+ normalize_correlation_value(evaluate(correlation_expression, binding_ctx, safe: safe))
146
+ end
147
+
148
+ def coerce_metric_value(value)
149
+ case kind(value)
150
+ when :integer, :float
151
+ checked(value)
152
+ number = builtin(value, :to_f)
153
+ when :string
154
+ match = JS_FLOAT_PREFIX_RE.match(copy_string(value))
155
+ number = match && match[1].to_f
156
+ else
157
+ return nil
158
+ end
159
+ number && builtin(number, :finite?) ? number : nil
160
+ end
161
+
162
+ def normalize_correlation_value(value)
163
+ case kind(value)
164
+ when :nil
165
+ 'static-singleton'
166
+ when :true, :false
167
+ raise TypeError, 'Correlation expression must evaluate to a string or number, got boolean'
168
+ when :string
169
+ copy = copy_string(value)
170
+ raise StandardError, copy if copy.start_with?('Error: ')
171
+ copy.freeze
172
+ when :integer
173
+ builtin(checked(value), :to_s).freeze
174
+ when :float
175
+ return 'NaN' if builtin(value, :nan?)
176
+ infinite = builtin(value, :infinite?)
177
+ return infinite == 1 ? 'Infinity' : '-Infinity' if infinite
178
+ return builtin(checked(builtin(value, :to_i)), :to_s).freeze if builtin(value, :%, 1.0).zero?
179
+
180
+ builtin(value, :to_s).freeze
181
+ else
182
+ type = { array: 'Array', hash: 'Hash', symbol: 'Symbol' }[kind(value)] || 'unsupported object'
183
+ raise TypeError, "Correlation expression must evaluate to a string or number, got #{type}"
184
+ end
185
+ end
186
+
187
+ private
188
+
189
+ def kind(value)
190
+ type = CLASS.bind(value).call
191
+ TYPES.each { |name, trusted| return name if IDENTITY.bind(trusted).call(type) }
192
+ :unsupported
193
+ end
194
+
195
+ def builtin(value, method, *args, &block)
196
+ implementation = BUILTINS.fetch(kind(value)).fetch(method)
197
+ implementation.bind(value).call(*args, &block)
198
+ end
199
+
200
+ def truthy?(value)
201
+ !IDENTITY.bind(value).call(nil) && !IDENTITY.bind(value).call(false)
202
+ end
203
+
204
+ def blank?(expression)
205
+ kind(expression) == :nil || SafeASTValidator.source(expression).strip.empty?
206
+ end
207
+
208
+ def copy_string(value)
209
+ raise SecurityError, 'String exceeds expression limit' if builtin(value, :bytesize) > MAX_STRING_BYTES
210
+ copy = builtin(value, :byteslice, 0, MAX_STRING_BYTES + 1)
211
+ raise SecurityError, 'String exceeds expression limit' if copy.bytesize > MAX_STRING_BYTES
212
+ copy
213
+ end
214
+
215
+ def checked(value)
216
+ case kind(value)
217
+ when :integer
218
+ raise SecurityError, 'Number exceeds expression limit' if builtin(value, :bit_length) > MAX_INTEGER_BITS
219
+ when :string
220
+ return copy_string(value)
221
+ when :symbol
222
+ if builtin(builtin(value, :name), :bytesize) > MAX_STRING_BYTES
223
+ raise SecurityError, 'Symbol exceeds expression limit'
224
+ end
225
+ end
226
+ value
227
+ end
228
+
229
+ def step!(budget)
230
+ budget[0] -= 1
231
+ raise SecurityError, 'Expression step limit exceeded' if budget[0] < 0
232
+ end
233
+
234
+ def evaluate(expression, binding_ctx, safe:)
235
+ raise SecurityError, 'Expected an exact builtin Binding' unless kind(binding_ctx) == :binding
236
+ if safe == false
237
+ # Explicit opt-in executes Ruby in the hit binding, with application
238
+ # permissions and side effects. Source and output size limits remain.
239
+ return BINDING_EVAL.bind(binding_ctx).call(SafeASTValidator.source(expression), '(hyperprobe)', 1)
240
+ end
241
+
242
+ tree = SafeASTValidator.parse!(expression)
243
+ execute(tree, binding_ctx, [MAX_STEPS])
244
+ end
245
+
246
+ def execute(node, binding_ctx, budget)
247
+ step!(budget)
248
+ case node[0]
249
+ when :literal
250
+ checked(node[1])
251
+ when :local
252
+ unless LOCAL_DEFINED.bind(binding_ctx).call(node[1])
253
+ raise NameError, "Unknown local variable '#{node[1]}'"
254
+ end
255
+ checked(LOCAL_GET.bind(binding_ctx).call(node[1]))
256
+ when :array
257
+ node[1].map { |item| execute(item, binding_ctx, budget) }
258
+ when :hash
259
+ node[1].each_with_object({}) do |(key_node, value_node), hash|
260
+ key = primitive_key(execute(key_node, binding_ctx, budget))
261
+ hash[key] = execute(value_node, binding_ctx, budget)
262
+ end
263
+ when :lookup
264
+ receiver = execute(node[1], binding_ctx, budget)
265
+ index = execute(node[2], binding_ctx, budget)
266
+ lookup(receiver, index, budget)
267
+ when :length
268
+ receiver = execute(node[1], binding_ctx, budget)
269
+ unless %i[string array hash].include?(kind(receiver))
270
+ raise SecurityError, 'length/size requires an exact builtin String, Array or Hash'
271
+ end
272
+ builtin(receiver, :length)
273
+ when :conditional
274
+ branch = truthy?(execute(node[1], binding_ctx, budget)) ? node[2] : node[3]
275
+ execute(branch, binding_ctx, budget)
276
+ when :unary
277
+ value = execute(node[2], binding_ctx, budget)
278
+ return !truthy?(value) if %i[! not].include?(node[1])
279
+ raise SecurityError, 'Unary arithmetic requires builtin numbers' unless numeric?(value)
280
+ checked(builtin(value, node[1]))
281
+ when :binary
282
+ left = execute(node[2], binding_ctx, budget)
283
+ operator = node[1]
284
+ if %i[&& and].include?(operator)
285
+ return truthy?(left) ? execute(node[3], binding_ctx, budget) : left
286
+ elsif %i[|| or].include?(operator)
287
+ return truthy?(left) ? left : execute(node[3], binding_ctx, budget)
288
+ end
289
+ right = execute(node[3], binding_ctx, budget)
290
+ binary(left, operator, right)
291
+ else
292
+ raise SecurityError, 'Unsupported expression instruction'
293
+ end
294
+ end
295
+
296
+ def numeric?(value)
297
+ %i[integer float].include?(kind(value))
298
+ end
299
+
300
+ def primitive_key(value)
301
+ unless %i[string symbol integer float nil true false].include?(kind(value))
302
+ raise SecurityError, 'Hash keys must be builtin primitives'
303
+ end
304
+ if kind(value) == :float && !builtin(value, :finite?)
305
+ raise SecurityError, 'Hash keys must be finite'
306
+ end
307
+ # Numeric and symbol objects cannot carry singleton overrides. Strings
308
+ # must be copied before insertion, which invokes hash/eql? internally.
309
+ checked(value)
310
+ end
311
+
312
+ def lookup(receiver, index, budget)
313
+ case kind(receiver)
314
+ when :array, :string
315
+ raise SecurityError, 'Subscript must be a builtin Integer' unless kind(index) == :integer
316
+ # Oversized indices must not reach native long conversion.
317
+ return nil if builtin(index, :bit_length) > 62
318
+ checked(builtin(receiver, :[], index))
319
+ when :hash
320
+ key = primitive_key(index)
321
+ raise SecurityError, 'Identity hash lookup is unsupported' if builtin(receiver, :compare_by_identity?)
322
+ raise SecurityError, 'Hash exceeds lookup limit' if builtin(receiver, :length) > MAX_COLLECTION_ITEMS
323
+ if defined?(MEMORY_SIZE) && MEMORY_SIZE.call(receiver) > 131_072
324
+ raise SecurityError, 'Hash storage exceeds lookup limit'
325
+ end
326
+ # Hash#[]/fetch can execute default procs or custom hash/eql? keys.
327
+ # A bounded scan avoids all of those hooks, even on missing keys.
328
+ builtin(receiver, :each_pair) do |stored, value|
329
+ step!(budget)
330
+ stored = primitive_key(stored)
331
+ next unless kind(stored) == kind(key)
332
+ return checked(value) if primitive_equal?(stored, key)
333
+ end
334
+ nil
335
+ else
336
+ raise SecurityError, 'Subscript requires an exact builtin Array, Hash or String'
337
+ end
338
+ end
339
+
340
+ def primitive_equal?(left, right)
341
+ left_kind = kind(left)
342
+ right_kind = kind(right)
343
+ allowed = %i[string symbol integer float nil true false]
344
+ unless allowed.include?(left_kind) && allowed.include?(right_kind)
345
+ raise SecurityError, 'Comparison requires builtin primitives'
346
+ end
347
+ if numeric?(left) && numeric?(right)
348
+ builtin(left, :==, right)
349
+ elsif left_kind != right_kind
350
+ false
351
+ elsif %i[nil true false].include?(left_kind)
352
+ true
353
+ else
354
+ builtin(left, :==, right)
355
+ end
356
+ end
357
+
358
+ def binary(left, operator, right)
359
+ if %i[== !=].include?(operator)
360
+ equal = primitive_equal?(left, right)
361
+ return operator == :== ? equal : !equal
362
+ end
363
+ if numeric?(left) && numeric?(right)
364
+ result = builtin(left, operator, right)
365
+ if kind(result) == :float && !builtin(result, :finite?)
366
+ raise SecurityError, 'Non-finite arithmetic result'
367
+ end
368
+ return checked(result)
369
+ end
370
+ if kind(left) == :string && kind(right) == :string && %i[+ < <= > >=].include?(operator)
371
+ if operator == :+ && builtin(left, :bytesize) + builtin(right, :bytesize) > MAX_STRING_BYTES
372
+ raise SecurityError, 'String computation exceeds expression limit'
373
+ end
374
+ return builtin(left, operator, right)
375
+ end
376
+ raise SecurityError, 'Operator requires builtin numbers or supported string operands'
377
+ end
378
+
379
+ def display(value, budget, depth, nested = false)
380
+ step!(budget)
381
+ raise SecurityError, 'Log value nesting limit exceeded' if depth > SafeASTValidator::MAX_DEPTH
382
+ value = checked(value)
383
+ result = case kind(value)
384
+ when :string
385
+ nested ? builtin(value, :inspect) : value
386
+ when :integer, :float
387
+ builtin(value, :to_s)
388
+ when :symbol
389
+ builtin(value, :inspect)
390
+ when :nil, :true, :false
391
+ { nil: 'nil', true: 'true', false: 'false' }.fetch(kind(value))
392
+ when :array
393
+ size = builtin(value, :length)
394
+ raise SecurityError, 'Log collection exceeds limit' if size > MAX_COLLECTION_ITEMS
395
+ parts = []
396
+ size.times do |index|
397
+ parts << display(builtin(value, :[], index), budget, depth + 1, true)
398
+ raise SecurityError, 'Log value exceeds limit' if parts.sum(&:bytesize) > MAX_STRING_BYTES
399
+ end
400
+ "[#{parts.join(', ')}]"
401
+ when :hash
402
+ raise SecurityError, 'Log collection exceeds limit' if builtin(value, :length) > MAX_COLLECTION_ITEMS
403
+ parts = []
404
+ builtin(value, :each_pair) do |key, item|
405
+ parts << "#{display(key, budget, depth + 1, true)}=>#{display(item, budget, depth + 1, true)}"
406
+ raise SecurityError, 'Log value exceeds limit' if parts.sum(&:bytesize) > MAX_STRING_BYTES
407
+ end
408
+ "{#{parts.join(', ')}}"
409
+ else
410
+ raise SecurityError, 'Log rendering requires builtin primitives or containers'
411
+ end
412
+ raise SecurityError, 'Log value exceeds limit' if result.bytesize > MAX_STRING_BYTES
413
+ result
414
+ end
415
+ end
416
+ end
417
+ end
418
+ end