hyperprobe-agent 1.2.27.pre.1 → 1.2.27.pre.3

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.
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
  require 'time'
5
5
  require 'set'
6
+ require 'objspace' unless RUBY_PLATFORM.include?('java')
6
7
 
7
8
  module HyperProbe
8
9
  module Core
@@ -11,413 +12,424 @@ module HyperProbe
11
12
  DEFAULT_MAX_ARRAY_LENGTH = 3
12
13
  DEFAULT_MAX_OBJECT_PROPERTIES = 50
13
14
  DEFAULT_MAX_STRING_LENGTH = 1024
14
-
15
- def initialize(
16
- max_depth: DEFAULT_MAX_DEPTH,
17
- max_array_length: DEFAULT_MAX_ARRAY_LENGTH,
18
- max_object_properties: DEFAULT_MAX_OBJECT_PROPERTIES,
19
- max_string_length: DEFAULT_MAX_STRING_LENGTH,
20
- redact_keys: nil,
21
- redact_values: nil
22
- )
23
- @max_depth = max_depth || DEFAULT_MAX_DEPTH
24
- @max_array_length = max_array_length || DEFAULT_MAX_ARRAY_LENGTH
25
- @max_object_properties = max_object_properties || DEFAULT_MAX_OBJECT_PROPERTIES
26
- @max_string_length = max_string_length || DEFAULT_MAX_STRING_LENGTH
15
+ MAX_DEPTH = 16
16
+ MAX_ITEMS = 128
17
+ MAX_STRING_BYTES = 4096
18
+ MAX_NODES = 512
19
+ MAX_BYTES = 65_536
20
+ MAX_PATTERNS = 16
21
+ MAX_PATTERN_BYTES = 256
22
+ HASH_STORAGE_BYTES = 131_072
23
+ MEMORY_SIZE = ObjectSpace.method(:memsize_of) if ObjectSpace.respond_to?(:memsize_of)
24
+ LEGACY_STRING_SLICE = RUBY_PLATFORM.include?('java') && RUBY_VERSION.start_with?('2.6.')
25
+ BUDGET_MARKER = '[Capture budget exhausted]'
26
+
27
+ # Capture implementations once, then bind them directly. Even innocuous
28
+ # calls such as obj.class, ==, or hash[key] can execute application code.
29
+ CORE = {
30
+ klass: Kernel.instance_method(:class),
31
+ kind: Kernel.instance_method(:is_a?),
32
+ identity: BasicObject.instance_method(:__id__),
33
+ equal: BasicObject.instance_method(:equal?),
34
+ ivars: Kernel.instance_method(:instance_variables),
35
+ ivar: Kernel.instance_method(:instance_variable_get),
36
+ class_name: Module.instance_method(:name),
37
+ string_size: String.instance_method(:bytesize),
38
+ string_slice: String.instance_method(:byteslice),
39
+ array_size: Array.instance_method(:length),
40
+ array_get: Array.instance_method(:[]),
41
+ hash_size: Hash.instance_method(:size),
42
+ hash_each: Hash.instance_method(:each_pair),
43
+ integer_bits: Integer.instance_method(:bit_length),
44
+ integer_string: Integer.instance_method(:to_s),
45
+ float_finite: Float.instance_method(:finite?),
46
+ float_nan: Float.instance_method(:nan?),
47
+ float_positive: Float.instance_method(:positive?),
48
+ time_format: Time.instance_method(:strftime),
49
+ time_utc: Time.instance_method(:utc?),
50
+ time_compare: Time.instance_method(:<=>),
51
+ date_format: Date.instance_method(:strftime),
52
+ date_compare: Date.instance_method(:<=>),
53
+ proc_lambda: Proc.instance_method(:lambda?),
54
+ method_name: Method.instance_method(:name),
55
+ unbound_name: UnboundMethod.instance_method(:name),
56
+ struct_size: Struct.instance_method(:size),
57
+ struct_each: Struct.instance_method(:each_pair)
58
+ }.freeze
59
+ SYMBOL_NAME = if Symbol.method_defined?(:name)
60
+ Symbol.instance_method(:name)
61
+ elsif RUBY_PLATFORM.include?('java')
62
+ # JRuby shares the symbol's bytes; slice before encoding.
63
+ Symbol.instance_method(:to_s)
64
+ end
65
+ if Set.instance_method(:each).source_location.nil? && Set.instance_method(:size).source_location.nil?
66
+ SET_EACH = Set.instance_method(:each)
67
+ SET_SIZE = Set.instance_method(:size)
68
+ end
69
+ JAVA_SEND = Java::JavaLang::Object.instance_method(:java_send) if RUBY_PLATFORM.include?('java')
70
+ TIME_MIN = Time.utc(0).freeze
71
+ TIME_MAX = Time.utc(10_000).freeze
72
+ DATE_MIN = Date.new(0, 1, 1).freeze
73
+ DATE_MAX = Date.new(10_000, 1, 1).freeze
74
+
75
+ def initialize(max_depth: DEFAULT_MAX_DEPTH,
76
+ max_array_length: DEFAULT_MAX_ARRAY_LENGTH,
77
+ max_object_properties: DEFAULT_MAX_OBJECT_PROPERTIES,
78
+ max_string_length: DEFAULT_MAX_STRING_LENGTH,
79
+ redact_keys: nil, redact_values: nil)
80
+ @max_depth = limit(max_depth, DEFAULT_MAX_DEPTH, MAX_DEPTH)
81
+ @max_array_length = limit(max_array_length, DEFAULT_MAX_ARRAY_LENGTH, MAX_ITEMS)
82
+ @max_object_properties = limit(max_object_properties, DEFAULT_MAX_OBJECT_PROPERTIES, MAX_ITEMS)
83
+ @max_string_length = limit(max_string_length, DEFAULT_MAX_STRING_LENGTH, MAX_STRING_BYTES)
27
84
  @redact_keys_re = compile_regex(redact_keys)
28
85
  @redact_values_re = compile_regex(redact_values)
86
+ @capture_lock = Mutex.new
87
+ @capture_context = nil
29
88
  end
30
89
 
31
- def serialize(obj, path = '$', depth = 0, visited = {})
32
- return nil if obj.nil?
33
- return obj if obj == true || obj == false
34
- return serialize_number(obj) if obj.is_a?(Numeric)
35
- return serialize_string(obj) if obj.is_a?(String)
36
- return obj.to_s if obj.is_a?(Symbol)
37
- return serialize_callable(obj) if callable?(obj)
38
- return serialize_time(obj) if time_object?(obj)
39
- return serialize_exception(obj) if obj.is_a?(Exception)
40
- return serialize_java_throwable(obj) if java_throwable?(obj)
41
- return obj.inspect if obj.is_a?(Regexp) || obj.is_a?(Range)
42
-
43
- # Reference / Cycle check (immediate primitives like symbols/integers/floats/true/false are handled above)
44
- obj_id = obj.object_id
45
- if visited.key?(obj_id)
46
- return "[REF - #{visited[obj_id]}]"
47
- end
48
-
49
- # Depth check
50
- if depth > @max_depth
51
- return summary_of(obj)
52
- end
53
-
54
- # Track visited for complex objects
55
- visited[obj_id] = path
56
-
57
- if obj.is_a?(Array)
58
- serialize_array(obj, path, depth, visited)
59
- elsif obj.is_a?(Set)
60
- serialize_array(obj.to_a, path, depth, visited)
61
- elsif obj.is_a?(Hash)
62
- serialize_hash(obj, path, depth, visited)
63
- elsif java_map?(obj)
64
- serialize_java_map(obj, path, depth, visited)
65
- elsif java_list?(obj)
66
- serialize_java_list(obj, path, depth, visited)
67
- elsif java_set?(obj)
68
- serialize_java_set(obj, path, depth, visited)
69
- elsif obj.is_a?(Struct) || (defined?(OpenStruct) && obj.is_a?(OpenStruct))
70
- serialize_struct(obj, path, depth, visited)
71
- elsif java_object?(obj)
72
- serialize_java_pojo(obj, path, depth, visited)
73
- else
74
- serialize_object(obj, path, depth, visited)
90
+ # A supplied visited object is an identity token, never a mutable table.
91
+ # Keep only the active snapshot's context; a different token replaces it.
92
+ # Calls without a token use fresh state without disturbing that context.
93
+ def serialize(obj, path = '$', depth = 0, visited = nil)
94
+ @capture_lock.synchronize do
95
+ shared = !core(:equal, visited, nil)
96
+ if shared && @capture_context && core(:equal, visited, @capture_context[0])
97
+ state = @capture_context[1]
98
+ else
99
+ # Leave room for exhaustion markers while unwinding containers.
100
+ state = { nodes: MAX_NODES, bytes: MAX_BYTES - 4096, visited: {} }
101
+ @capture_context = [visited, state] if shared
102
+ end
103
+ return BUDGET_MARKER if exhausted?(state)
104
+ path = kind?(path, String) ? text(path, state) : '$'
105
+ capture(obj, path, limit(depth, 0, MAX_DEPTH), state)
75
106
  end
107
+ rescue SignalException, SystemExit
108
+ raise
109
+ rescue Exception # Snapshot failures must not escape into the client.
110
+ '[Serialization unavailable]'
76
111
  end
77
112
 
78
113
  def self.safe_dump_json(data)
79
- JSON.generate(data)
80
- rescue StandardError => e
81
- JSON.generate({ 'error' => "JSON serialization failed: #{e.message}" })
82
- end
83
-
84
- private
85
-
86
- def compile_regex(patterns)
87
- return nil if patterns.nil? || patterns.empty?
88
-
89
- valid = Array(patterns).map(&:to_s).map(&:strip).reject(&:empty?)
90
- return nil if valid.empty?
91
-
92
- Regexp.union(valid.map { |p| Regexp.new(p, Regexp::IGNORECASE) })
93
- rescue StandardError
94
- nil
114
+ # JSON.generate dispatches to to_json on arbitrary inputs, including
115
+ # singleton methods on strings/containers. Only give it fresh core data.
116
+ new.send(:dump_json, data)
117
+ rescue SignalException, SystemExit
118
+ raise
119
+ rescue Exception
120
+ '{"error":"JSON serialization failed"}'
95
121
  end
96
122
 
97
- def serialize_number(num)
98
- if num.is_a?(Float)
99
- return 'NaN' if num.nan?
100
- return 'Infinity' if num.infinite? && num.positive?
101
- return '-Infinity' if num.infinite? && num.negative?
102
- end
103
- num
123
+ def finish_capture
124
+ @capture_lock.synchronize { @capture_context = nil }
104
125
  end
105
126
 
106
- def serialize_string(str)
107
- val = str.dup
108
- if @redact_values_re && @redact_values_re.match?(val)
109
- val = val.gsub(@redact_values_re, '[REDACTED Value]')
110
- end
127
+ private
111
128
 
112
- if val.length > @max_string_length
113
- head = val[0...@max_string_length]
114
- # Match Node.js truncated head word boundary trimming
115
- truncated_head = head.sub(/\b\w*$/, '').rstrip
116
- truncated_head = head if truncated_head.empty?
117
- val = "#{truncated_head}... [Truncated: +#{val.length - @max_string_length} more chars]"
118
- end
119
- val
129
+ def dump_json(data)
130
+ # Captures have already spent their budgets. Allow bounded headroom for
131
+ # frame/watch wrappers and generated summaries/truncation markers, rather
132
+ # than erasing captured fields by applying capture limits a second time.
133
+ @max_depth = MAX_DEPTH * 2
134
+ @max_array_length = @max_object_properties = MAX_NODES * 2
135
+ @max_string_length = MAX_STRING_BYTES * 2
136
+ state = { nodes: MAX_NODES * 4, bytes: MAX_BYTES * 2, visited: {} }
137
+ JSON.generate(capture(data, '$', 0, state))
120
138
  end
121
139
 
122
- def callable?(obj)
123
- obj.is_a?(Proc) || obj.is_a?(Method) || obj.is_a?(UnboundMethod)
140
+ def core(name, obj, *args, &block)
141
+ CORE.fetch(name).bind(obj).call(*args, &block)
124
142
  end
125
143
 
126
- def serialize_callable(obj)
127
- name = if obj.respond_to?(:name) && obj.name
128
- ": #{obj.name}"
129
- elsif obj.lambda?
130
- ': (lambda)'
131
- else
132
- ''
133
- end
134
- "[Function#{name}]"
144
+ def kind?(obj, klass)
145
+ core(:kind, obj, klass)
135
146
  end
136
147
 
137
- def time_object?(obj)
138
- obj.is_a?(Time) || obj.is_a?(Date) || (defined?(DateTime) && obj.is_a?(DateTime))
148
+ def exact?(obj, klass)
149
+ core(:equal, core(:klass, obj), klass)
139
150
  end
140
151
 
141
- def serialize_time(obj)
142
- obj.respond_to?(:iso8601) ? obj.iso8601 : obj.to_s
152
+ def limit(value, default, maximum)
153
+ return default unless exact?(value, Integer)
154
+ [[value, 0].max, maximum].min
143
155
  end
144
156
 
145
- def serialize_exception(obj)
146
- stack = if obj.backtrace
147
- obj.backtrace.first(3).join("\n") + '...'
148
- end
149
- {
150
- 'name' => obj.class.name,
151
- 'message' => obj.message,
152
- 'stack' => stack
153
- }
157
+ def compile_regex(patterns)
158
+ return nil if core(:equal, patterns, nil)
159
+ patterns = [patterns] if kind?(patterns, String)
160
+ return /\A[\s\S]*\z/ unless exact?(patterns, Array)
161
+ count = core(:array_size, patterns)
162
+ return nil if count.zero?
163
+ return /\A[\s\S]*\z/ if count > MAX_PATTERNS
164
+
165
+ sources = []
166
+ count.times do |i|
167
+ pattern = core(:array_get, patterns, i)
168
+ return /\A[\s\S]*\z/ unless kind?(pattern, String)
169
+ return /\A[\s\S]*\z/ if core(:string_size, pattern) > MAX_PATTERN_BYTES
170
+ source = core(:string_slice, pattern, 0, MAX_PATTERN_BYTES).encode('UTF-8', invalid: :replace, undef: :replace).strip
171
+ next if source.empty?
172
+
173
+ # Only literal alternatives with optional anchors and escaped literal
174
+ # punctuation. No repetition, groups, lookaround, backreferences, or
175
+ # character classes: bounded input alone does not prevent ReDoS.
176
+ return /\A[\s\S]*\z/ unless /\A\^?(?:[a-zA-Z0-9 _:@,\/-]|\\[.\^$|?*+()\[\]{}\\-])+(?:\|(?:[a-zA-Z0-9 _:@,\/-]|\\[.\^$|?*+()\[\]{}\\-])+)*\$?\z/.match?(source)
177
+ sources << Regexp.new(source, Regexp::IGNORECASE)
178
+ end
179
+ sources.empty? ? nil : Regexp.union(sources)
180
+ rescue SignalException, SystemExit
181
+ raise
182
+ rescue Exception
183
+ /\A[\s\S]*\z/
154
184
  end
155
185
 
156
- def serialize_array(arr, path, depth, visited)
157
- result = []
158
- len = [arr.length, @max_array_length].min
159
-
160
- (0...len).each do |i|
161
- elem = arr[i]
162
- elem_path = "#{path}[#{i}]"
163
- result << serialize(elem, elem_path, depth + 1, visited)
164
- rescue StandardError => e
165
- result << "[Error: #{e.message}]"
166
- end
186
+ def exhausted?(state)
187
+ state[:nodes] <= 0 || state[:bytes] <= 0
188
+ end
167
189
 
168
- if arr.length > @max_array_length
169
- result << "[+ #{arr.length - @max_array_length} more items truncated]"
190
+ def emit(value, state)
191
+ return BUDGET_MARKER if state[:bytes] <= 0
192
+ cost = value.bytesize * 6 + 8
193
+ if cost > state[:bytes]
194
+ state[:bytes] = 0
195
+ return BUDGET_MARKER
170
196
  end
171
-
172
- result
197
+ # Account conservatively for JSON escaping and container punctuation.
198
+ state[:bytes] -= cost
199
+ value
173
200
  end
174
201
 
175
- def serialize_hash(hash, path, depth, visited)
176
- result = {}
177
- keys = hash.keys
178
- limit = @max_object_properties
179
- num_to_process = [keys.length, limit].min
180
-
181
- (0...num_to_process).each do |i|
182
- key = keys[i]
183
- key_str = key.to_s
184
-
185
- if @redact_keys_re && @redact_keys_re.match?(key_str)
186
- result[key_str] = '[REDACTED Key]'
187
- next
188
- end
202
+ def bounded_string(str, offset = 0)
203
+ size = core(:string_size, str) - offset
204
+ raw = core(:string_slice, str, offset, @max_string_length)
205
+ # Older JRuby preserves String subclasses on byteslice; normalize the
206
+ # already-bounded slice before calling encoding methods.
207
+ raw = String.new(raw) if LEGACY_STRING_SLICE
208
+ val = raw.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
209
+ [val, size > @max_string_length, size]
210
+ end
189
211
 
190
- child_path = path == '$' ? key_str : "#{path}.#{key_str}"
191
- val = hash[key]
192
- result[key_str] = serialize(val, child_path, depth + 1, visited)
193
- rescue StandardError => e
194
- result[key_str] = "[Error accessing property: #{e.message}]"
212
+ def text(str, state, key: false, offset: 0)
213
+ val, truncated, size = bounded_string(str, offset)
214
+ sensitive = key && @redact_keys_re && (truncated || @redact_keys_re.match?(val))
215
+ if @redact_values_re
216
+ # A cut can split a secret. With value redaction enabled, do not expose
217
+ # any prefix of a string whose remaining bytes we have not examined.
218
+ val = truncated ? '[REDACTED Value]' : val.gsub(@redact_values_re, '[REDACTED Value]')
195
219
  end
196
-
197
- if keys.length > limit
198
- result['__probe_meta'] = "+ #{keys.length - limit} more properties truncated"
220
+ if val.bytesize > @max_string_length
221
+ val = val.byteslice(0, @max_string_length).scrub('?')
222
+ truncated = true
199
223
  end
200
-
201
- result
224
+ val += "... [Truncated: +#{[size - @max_string_length, 0].max} more bytes]" if truncated
225
+ result = emit(val, state)
226
+ key ? [result, sensitive] : result
202
227
  end
203
228
 
204
- def serialize_struct(struct_obj, path, depth, visited)
205
- hash = struct_obj.to_h
206
- serialize_hash(hash, path, depth, visited)
207
- rescue StandardError
208
- serialize_object(struct_obj, path, depth, visited)
229
+ def symbol_text(obj, state)
230
+ return emit('[Symbol]', state) unless SYMBOL_NAME
231
+ text(SYMBOL_NAME.bind(obj).call, state)
209
232
  end
210
233
 
211
- # Java Collection & POJO Serialization for JRuby
212
- def serialize_java_map(map, path, depth, visited)
213
- result = {}
214
- keys = (map.respond_to?(:keySet) ? map.keySet.to_a : map.keys) rescue []
215
- limit = @max_object_properties
216
- num_to_process = [keys.length, limit].min
217
-
218
- (0...num_to_process).each do |i|
219
- key = keys[i]
220
- key_str = key.to_s
221
-
222
- if @redact_keys_re && @redact_keys_re.match?(key_str)
223
- result[key_str] = '[REDACTED Key]'
224
- next
225
- end
234
+ def summary(obj, state)
235
+ name = core(:class_name, core(:klass, obj))
236
+ name = kind?(name, String) ? text(name, state) : 'anonymous'
237
+ emit("[Obj: #{name}]", state)
238
+ end
226
239
 
227
- child_path = path == '$' ? key_str : "#{path}.#{key_str}"
228
- val = map.respond_to?(:get) ? map.get(key) : map[key] rescue map[key]
229
- result[key_str] = serialize(val, child_path, depth + 1, visited)
230
- rescue StandardError => e
231
- result[key_str] = "[Error accessing property: #{e.message}]"
240
+ def capture(obj, path, depth, state)
241
+ return BUDGET_MARKER if exhausted?(state)
242
+ state[:nodes] -= 1
243
+ state[:bytes] -= 16
244
+ return nil if core(:equal, obj, nil)
245
+ return true if core(:equal, obj, true)
246
+ return false if core(:equal, obj, false)
247
+ if exact?(obj, Integer)
248
+ return emit('[Integer: too large]', state) if core(:integer_bits, obj) > 256
249
+ state[:bytes] -= 80
250
+ return obj
232
251
  end
233
-
234
- if keys.length > limit
235
- result['__probe_meta'] = "+ #{keys.length - limit} more properties truncated"
252
+ if exact?(obj, Float)
253
+ state[:bytes] -= 32
254
+ return obj if core(:float_finite, obj)
255
+ return emit('NaN', state) if core(:float_nan, obj)
256
+ return emit(core(:float_positive, obj) ? 'Infinity' : '-Infinity', state)
236
257
  end
237
-
238
- result
239
- end
240
-
241
- def serialize_java_list(list, path, depth, visited)
242
- result = []
243
- size = (list.respond_to?(:size) ? list.size : list.length) rescue 0
244
- len = [size, @max_array_length].min
245
-
246
- (0...len).each do |i|
247
- elem = list.respond_to?(:get) ? list.get(i) : list[i] rescue list[i]
248
- elem_path = "#{path}[#{i}]"
249
- result << serialize(elem, elem_path, depth + 1, visited)
250
- rescue StandardError => e
251
- result << "[Error: #{e.message}]"
258
+ return text(obj, state) if kind?(obj, String)
259
+ return symbol_text(obj, state) if exact?(obj, Symbol)
260
+ if kind?(obj, Proc)
261
+ return emit(core(:proc_lambda, obj) ? '[Function: (lambda)]' : '[Function]', state)
252
262
  end
253
-
254
- if size > @max_array_length
255
- result << "[+ #{size - @max_array_length} more items truncated]"
263
+ if kind?(obj, Method) || kind?(obj, UnboundMethod)
264
+ method = kind?(obj, Method) ? :method_name : :unbound_name
265
+ return emit("[Function: #{symbol_text(core(method, obj), state)}]", state)
256
266
  end
257
-
258
- result
259
- end
260
-
261
- def serialize_java_set(set, path, depth, visited)
262
- arr = (set.respond_to?(:toArray) ? set.toArray.to_a : set.to_a) rescue []
263
- serialize_array(arr, path, depth, visited)
264
- end
265
-
266
- def serialize_java_throwable(throwable)
267
- stack_elements = nil
268
- if throwable.respond_to?(:getStackTrace)
269
- stack_elements = (throwable.getStackTrace.to_a.first(3).map(&:toString).join("\n") + '...') rescue nil
267
+ if exact?(obj, Time)
268
+ # Formatting an astronomical year can allocate an astronomical string.
269
+ return summary(obj, state) unless core(:time_compare, obj, TIME_MIN) >= 0 && core(:time_compare, obj, TIME_MAX) < 0
270
+ format = core(:time_utc, obj) ? '%Y-%m-%dT%H:%M:%SZ' : '%Y-%m-%dT%H:%M:%S%:z'
271
+ return text(core(:time_format, obj, format), state)
272
+ end
273
+ if exact?(obj, Date) || exact?(obj, DateTime)
274
+ return summary(obj, state) unless core(:date_compare, obj, DATE_MIN) >= 0 && core(:date_compare, obj, DATE_MAX) < 0
275
+ format = exact?(obj, Date) ? '%Y-%m-%d' : '%Y-%m-%dT%H:%M:%S%:z'
276
+ return text(core(:date_format, obj, format), state)
270
277
  end
271
- class_name = throwable.respond_to?(:getClass) ? throwable.getClass.getName : throwable.class.name
272
- message = throwable.respond_to?(:getMessage) ? throwable.getMessage : throwable.to_s
273
- {
274
- 'name' => class_name,
275
- 'message' => message || '',
276
- 'stack' => stack_elements
277
- }
278
- end
279
278
 
280
- def serialize_java_pojo(obj, path, depth, visited)
281
- ivars = obj.instance_variables rescue []
282
- if ivars && !ivars.empty?
283
- return serialize_object(obj, path, depth, visited)
279
+ id = core(:identity, obj)
280
+ return emit("[REF - #{state[:visited][id][1]}]", state) if state[:visited].key?(id)
281
+ if depth > @max_depth
282
+ return emit("[Arr: #{core(:array_size, obj)}]", state) if exact?(obj, Array)
283
+ return emit("[Obj: #{core(:hash_size, obj)} keys]", state) if exact?(obj, Hash)
284
+ return summary(obj, state)
284
285
  end
286
+ # Retain bounded references too: object IDs can otherwise be reused by
287
+ # GC between separate roots sharing the same snapshot token.
288
+ state[:visited][id] = [obj, path]
285
289
 
286
- result = {}
287
- ignored_methods = %w[getClass getDeclaringClass hashCode toString equals wait notify notifyAll]
288
- getters = []
289
- begin
290
- getters = obj.class.instance_methods(false).select do |m|
291
- name = m.to_s
292
- (name.start_with?('get') && name.length > 3 || name.start_with?('is') && name.length > 2) && !ignored_methods.include?(name)
290
+ if exact?(obj, Array)
291
+ size = core(:array_size, obj)
292
+ return sequence(size, path, depth, state) { |i| core(:array_get, obj, i) }
293
+ end
294
+ if exact?(obj, Set)
295
+ return summary(obj, state) if defined?(MEMORY_SIZE) && MEMORY_SIZE.call(obj) > HASH_STORAGE_BYTES
296
+ result = []
297
+ if defined?(SET_EACH)
298
+ size = SET_SIZE.bind(obj).call
299
+ enumerate = ->(&block) { SET_EACH.bind(obj).call(&block) }
300
+ else
301
+ hash = core(:ivar, obj, :@hash)
302
+ return summary(obj, state) unless exact?(hash, Hash)
303
+ return summary(obj, state) if defined?(MEMORY_SIZE) && MEMORY_SIZE.call(hash) > HASH_STORAGE_BYTES
304
+ size = core(:hash_size, hash)
305
+ enumerate = lambda do |&block|
306
+ core(:hash_each, hash) { |key, _value| block.call(key) }
307
+ end
308
+ end
309
+ enumerate.call do |key|
310
+ break if result.length >= @max_array_length || exhausted?(state)
311
+ result << capture(key, "#{path}[#{result.length}]", depth + 1, state)
293
312
  end
294
- rescue StandardError
295
- getters = []
313
+ append_remaining(result, size, state)
314
+ return result
296
315
  end
297
-
298
- limit = @max_object_properties
299
- num_to_process = [getters.length, limit].min
300
-
301
- (0...num_to_process).each do |i|
302
- getter_sym = getters[i]
303
- raw_name = getter_sym.to_s
304
- prop_name = if raw_name.start_with?('get')
305
- raw_name[3..].sub(/^[A-Z]/, &:downcase)
306
- elsif raw_name.start_with?('is')
307
- raw_name[2..].sub(/^[A-Z]/, &:downcase)
308
- else
309
- raw_name
310
- end
311
-
312
- if @redact_keys_re && @redact_keys_re.match?(prop_name)
313
- result[prop_name] = '[REDACTED Key]'
314
- next
316
+ if exact?(obj, Hash)
317
+ return summary(obj, state) if defined?(MEMORY_SIZE) && MEMORY_SIZE.call(obj) > HASH_STORAGE_BYTES
318
+ return properties(core(:hash_size, obj), path, depth, state) do |&block|
319
+ core(:hash_each, obj, &block)
315
320
  end
316
-
317
- child_path = path == '$' ? prop_name : "#{path}.#{prop_name}"
318
- val = obj.public_send(getter_sym)
319
- result[prop_name] = serialize(val, child_path, depth + 1, visited)
320
- rescue StandardError => e
321
- result[prop_name] = "[Error accessing property: #{e.message}]"
322
321
  end
323
-
324
- if getters.length > limit
325
- result['__probe_meta'] = "+ #{getters.length - limit} more properties truncated"
322
+ if kind?(obj, Struct)
323
+ # Struct#each_pair is native, but some runtimes build all member names.
324
+ size = core(:struct_size, obj)
325
+ return summary(obj, state) if size > MAX_ITEMS
326
+ return properties(size, path, depth, state) { |&block| core(:struct_each, obj, &block) }
326
327
  end
328
+ if defined?(OpenStruct) && exact?(obj, OpenStruct)
329
+ table = core(:ivar, obj, :@table)
330
+ return capture(table, path, depth, state) if exact?(table, Hash)
331
+ end
332
+ return java_capture(obj, path, depth, state) if defined?(JAVA_SEND) && kind?(obj, Java::JavaLang::Object)
327
333
 
328
- result.empty? ? summary_of(obj) : result
329
- end
330
-
331
- def java_object?(obj)
332
- return false unless defined?(JRUBY_VERSION) || defined?(Java)
333
- return true if defined?(java.lang.Object) && obj.is_a?(java.lang.Object)
334
- return true if obj.respond_to?(:java_class)
335
- false
336
- rescue StandardError
337
- false
338
- end
339
-
340
- def java_map?(obj)
341
- return false unless defined?(JRUBY_VERSION) || defined?(Java)
342
- return true if defined?(java.util.Map) && obj.is_a?(java.util.Map)
343
- return true if defined?(Java::JavaUtil::Map) && obj.is_a?(Java::JavaUtil::Map)
344
- false
345
- rescue StandardError
346
- false
347
- end
348
-
349
- def java_list?(obj)
350
- return false unless defined?(JRUBY_VERSION) || defined?(Java)
351
- return true if defined?(java.util.List) && obj.is_a?(java.util.List)
352
- return true if defined?(Java::JavaUtil::List) && obj.is_a?(Java::JavaUtil::List)
353
- false
354
- rescue StandardError
355
- false
334
+ return summary(obj, state) unless kind?(obj, Object)
335
+ # Even bound Exception#to_s calls a message object's to_s on CRuby and
336
+ # JRuby. Keep exceptions and unsupported built-in subclasses opaque.
337
+ if [Array, Hash, Set, Numeric, Exception, Regexp, Range, Time, Date].any? { |type| kind?(obj, type) }
338
+ return summary(obj, state)
339
+ end
340
+ return summary(obj, state) if defined?(OpenStruct) && kind?(obj, OpenStruct)
341
+ return summary(obj, state) if exhausted?(state)
342
+
343
+ # Like Node's own-property-name enumeration, this native operation
344
+ # materializes the full name list. Ruby has no bounded alternative.
345
+ # Only subsequent field reads, recursion, and output are budget-bounded.
346
+ names = core(:ivars, obj)
347
+ size = core(:array_size, names)
348
+ return summary(obj, state) if size.zero?
349
+ properties(size, path, depth, state, ivars: true) do |&block|
350
+ [size, @max_object_properties].min.times do |i|
351
+ break if exhausted?(state)
352
+ name = core(:array_get, names, i)
353
+ block.call(name, core(:ivar, obj, name))
354
+ end
355
+ end
356
+ rescue SignalException, SystemExit
357
+ raise
358
+ rescue Exception
359
+ emit('[Serialization unavailable]', state)
356
360
  end
357
361
 
358
- def java_set?(obj)
359
- return false unless defined?(JRUBY_VERSION) || defined?(Java)
360
- return true if defined?(java.util.Set) && obj.is_a?(java.util.Set)
361
- return true if defined?(Java::JavaUtil::Set) && obj.is_a?(Java::JavaUtil::Set)
362
- false
363
- rescue StandardError
364
- false
362
+ def sequence(size, path, depth, state)
363
+ result = []
364
+ [size, @max_array_length].min.times do |i|
365
+ break if exhausted?(state)
366
+ result << capture(yield(i), "#{path}[#{i}]", depth + 1, state)
367
+ end
368
+ append_remaining(result, size, state)
369
+ result
365
370
  end
366
371
 
367
- def java_throwable?(obj)
368
- return false unless defined?(JRUBY_VERSION) || defined?(Java)
369
- return true if defined?(java.lang.Throwable) && obj.is_a?(java.lang.Throwable)
370
- return true if defined?(Java::JavaLang::Throwable) && obj.is_a?(Java::JavaLang::Throwable)
371
- false
372
- rescue StandardError
373
- false
372
+ def append_remaining(result, size, state)
373
+ remaining = size - result.length
374
+ result << emit("[+ #{remaining} more items truncated]", state) if remaining > 0
374
375
  end
375
376
 
376
- def serialize_object(obj, path, depth, visited)
377
+ def properties(size, path, depth, state, ivars: false, &enumerate)
377
378
  result = {}
378
- ivars = obj.instance_variables
379
- limit = @max_object_properties
380
- num_to_process = [ivars.length, limit].min
381
-
382
- (0...num_to_process).each do |i|
383
- ivar_sym = ivars[i]
384
- key_str = ivar_sym.to_s.sub(/^@/, '')
385
-
386
- if @redact_keys_re && @redact_keys_re.match?(key_str)
387
- result[key_str] = '[REDACTED Key]'
388
- next
379
+ count = 0
380
+ enumerate.call do |key, value|
381
+ break if count >= @max_object_properties || exhausted?(state)
382
+ count += 1
383
+ state[:nodes] -= 1
384
+ if kind?(key, String)
385
+ name, sensitive = text(key, state, key: true)
386
+ elsif exact?(key, Symbol) && SYMBOL_NAME
387
+ name, sensitive = text(SYMBOL_NAME.bind(key).call, state, key: true, offset: ivars ? 1 : 0)
388
+ elsif exact?(key, Integer) && core(:integer_bits, key) <= 256
389
+ name, sensitive = text(core(:integer_string, key), state, key: true)
390
+ else
391
+ # Never hash, compare, inspect, or stringify application map keys.
392
+ name = emit("[Opaque key #{count}]", state)
393
+ sensitive = !!@redact_keys_re
389
394
  end
390
-
391
- child_path = path == '$' ? key_str : "#{path}.#{key_str}"
392
- val = obj.instance_variable_get(ivar_sym)
393
- result[key_str] = serialize(val, child_path, depth + 1, visited)
394
- rescue StandardError => e
395
- result[key_str] = "[Error accessing property: #{e.message}]"
396
- end
397
-
398
- if ivars.length > limit
399
- result['__probe_meta'] = "+ #{ivars.length - limit} more properties truncated"
395
+ child_path = path == '$' ? name : "#{path}.#{name}"
396
+ child_path = child_path.byteslice(0, MAX_STRING_BYTES).scrub('?')
397
+ result[name] = sensitive ? emit('[REDACTED Key]', state) : capture(value, child_path, depth + 1, state)
400
398
  end
401
-
402
- # If an object has no instance variables, check if it responds to meaningful getters or to_s
403
- if ivars.empty?
404
- return summary_of(obj)
405
- end
406
-
399
+ result['__probe_meta'] = emit("+ #{size - count} more properties truncated", state) if size > count
407
400
  result
408
401
  end
409
402
 
410
- def summary_of(obj)
411
- return 'null' if obj.nil?
403
+ def java_call(obj, method, types = [], *args)
404
+ JAVA_SEND.bind(obj).call(method, types, *args)
405
+ end
412
406
 
413
- case obj
414
- when String then "[Str: #{obj.length}]"
415
- when Array then "[Arr: #{obj.length}]"
416
- when Set then "[Set: #{obj.length}]"
417
- when Hash then "[Obj: #{obj.length} keys]"
418
- when Proc, Method then '[Function]'
407
+ def java_capture(obj, path, depth, state)
408
+ # getClass is final. Bound java_send bypasses Ruby singleton overrides;
409
+ # checking the actual Java class excludes Java/Ruby collection subclasses.
410
+ name = java_call(java_call(obj, :getClass), :getName)
411
+ case name
412
+ when 'java.util.ArrayList', 'java.util.LinkedList'
413
+ size = java_call(obj, :size)
414
+ sequence(size, path, depth, state) { |i| java_call(obj, :get, [Java::int], i) }
415
+ when 'java.util.LinkedHashMap'
416
+ size = java_call(obj, :size)
417
+ iterator = java_call(java_call(obj, :entrySet), :iterator)
418
+ properties(size, path, depth, state) do |&block|
419
+ [size, @max_object_properties].min.times do
420
+ break if exhausted?(state) || !java_call(iterator, :hasNext)
421
+ entry = java_call(iterator, :next)
422
+ block.call(java_call(entry, :getKey), java_call(entry, :getValue))
423
+ end
424
+ end
425
+ when 'java.util.LinkedHashSet'
426
+ size = java_call(obj, :size)
427
+ iterator = java_call(obj, :iterator)
428
+ sequence(size, path, depth, state) { java_call(iterator, :next) }
419
429
  else
420
- "[Obj: #{obj.class.name}]"
430
+ # No POJO getters, Throwable accessors, equals, or toString. HashMap
431
+ # and HashSet are opaque too: sparse bucket scans have unbounded work.
432
+ summary(obj, state)
421
433
  end
422
434
  end
423
435
  end