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.
- checksums.yaml +4 -4
- data/README.md +316 -87
- data/lib/hyperprobe/agent.rb +243 -144
- data/lib/hyperprobe/core/broker.rb +58 -88
- data/lib/hyperprobe/core/evaluator.rb +358 -68
- data/lib/hyperprobe/core/lexical_scope.rb +222 -0
- data/lib/hyperprobe/core/logger.rb +33 -12
- data/lib/hyperprobe/core/monitoring_engine.rb +294 -120
- data/lib/hyperprobe/core/safe_ast_validator.rb +140 -61
- data/lib/hyperprobe/core/safety.rb +92 -15
- data/lib/hyperprobe/core/serializer.rb +354 -342
- data/lib/hyperprobe/core/transports/java_grpc.rb +57 -0
- data/lib/hyperprobe/lambda.rb +44 -42
- data/lib/hyperprobe/protos/agent_descriptor.rb +7 -0
- data/lib/hyperprobe/protos/java_messages.rb +199 -0
- data/lib/hyperprobe/protos.rb +7 -7
- data/lib/hyperprobe/railtie.rb +6 -0
- data/lib/hyperprobe/version.rb +1 -1
- data/lib/hyperprobe.rb +149 -20
- metadata +25 -9
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
123
|
-
|
|
140
|
+
def core(name, obj, *args, &block)
|
|
141
|
+
CORE.fetch(name).bind(obj).call(*args, &block)
|
|
124
142
|
end
|
|
125
143
|
|
|
126
|
-
def
|
|
127
|
-
|
|
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
|
|
138
|
-
|
|
148
|
+
def exact?(obj, klass)
|
|
149
|
+
core(:equal, core(:klass, obj), klass)
|
|
139
150
|
end
|
|
140
151
|
|
|
141
|
-
def
|
|
142
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
197
|
+
# Account conservatively for JSON escaping and container punctuation.
|
|
198
|
+
state[:bytes] -= cost
|
|
199
|
+
value
|
|
173
200
|
end
|
|
174
201
|
|
|
175
|
-
def
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
(
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
if
|
|
283
|
-
return
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
295
|
-
|
|
313
|
+
append_remaining(result, size, state)
|
|
314
|
+
return result
|
|
296
315
|
end
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
return
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
return
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
368
|
-
|
|
369
|
-
|
|
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
|
|
377
|
+
def properties(size, path, depth, state, ivars: false, &enumerate)
|
|
377
378
|
result = {}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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 =
|
|
392
|
-
|
|
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
|
|
411
|
-
|
|
403
|
+
def java_call(obj, method, types = [], *args)
|
|
404
|
+
JAVA_SEND.bind(obj).call(method, types, *args)
|
|
405
|
+
end
|
|
412
406
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
when
|
|
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
|
-
|
|
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
|