restless-sdk 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.
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "set"
5
+
6
+ require_relative "text"
7
+
8
+ module Restless
9
+ # CONTRACT.md section 4. Redaction for sensitive values in captured
10
+ # requests. Runs at the single choke point before anything enters the
11
+ # upload queue; no adapter may bypass it.
12
+ #
13
+ # Format: `<REDACTED:<length>[:<last4>]>`
14
+ module Redact
15
+ TAIL_MIN_LENGTH = 8
16
+ TAIL_CHARS = 4
17
+
18
+ # REDACT-011. Matched after REDACT-010 normalization.
19
+ DEFAULT_HEADER_DENYLIST = %w[
20
+ authorization
21
+ cookie
22
+ set-cookie
23
+ proxy-authorization
24
+ x-api-key
25
+ x-auth-token
26
+ ].freeze
27
+
28
+ # REDACT-012.
29
+ DEFAULT_BODY_KEY_DENYLIST = %w[
30
+ password
31
+ pass
32
+ pwd
33
+ token
34
+ secret
35
+ apikey
36
+ accesstoken
37
+ refreshtoken
38
+ idtoken
39
+ sessionid
40
+ ssn
41
+ creditcard
42
+ ccnumber
43
+ cvv
44
+ cvc
45
+ ].freeze
46
+
47
+ # REDACT-013. Query params use the same list as body keys.
48
+ DEFAULT_QUERY_PARAM_DENYLIST = DEFAULT_BODY_KEY_DENYLIST
49
+
50
+ # REDACT-016. Headers that carry an HTTP auth-scheme prefix. For these the
51
+ # scheme word survives, so a debugger reading the dashboard can see at a
52
+ # glance whether the caller used Bearer, Basic or something custom.
53
+ SCHEME_PREFIX_HEADERS = Set.new(%w[authorization proxyauthorization]).freeze
54
+
55
+ # REDACT-030. 256 KiB, measured in UTF-8 bytes.
56
+ MAX_BODY_BYTES = 262_144
57
+
58
+ # PRIM-006. Hex digits are validated explicitly rather than handed to
59
+ # `String#to_i(16)`, which silently returns 0 for garbage.
60
+ HEX_PAIR_RE = /\A[0-9a-fA-F]{2}\z/.freeze
61
+
62
+ # REDACT-028. The RFC 3986 unreserved set, spelled out. No two languages'
63
+ # builtins agree: `encodeURIComponent` also leaves `!'()*` alone, Ruby's
64
+ # `CGI.escape` turns a space into `+` and escapes `~`, and Go's differs
65
+ # from both.
66
+ UNRESERVED_BYTES = begin
67
+ set = Array.new(256, false)
68
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
69
+ .each_byte { |b| set[b] = true }
70
+ set.freeze
71
+ end
72
+
73
+ module_function
74
+
75
+ # REDACT-001, REDACT-002. Length and tail are counted in CODE POINTS.
76
+ #
77
+ # An emoji is 1 code point, 2 UTF-16 code units and 4 UTF-8 bytes; a
78
+ # sentinel built from any other unit disagrees with every other SDK for
79
+ # the same secret, and a byte-wise tail slice produces an ill-formed
80
+ # string.
81
+ def redact_value(value)
82
+ points = value.chars
83
+ len = points.length
84
+ return "<REDACTED:#{len}>" if len < TAIL_MIN_LENGTH
85
+
86
+ "<REDACTED:#{len}:#{points.last(TAIL_CHARS).join}>"
87
+ end
88
+
89
+ # REDACT-010. Lowercase (PRIM-020, FULL Unicode) and drop every `-` and
90
+ # `_`, so `api_key`, `apiKey`, `API-KEY` and `APIKEY` all match `apikey`.
91
+ #
92
+ # Full Unicode, and that is a SECURITY requirement rather than a cosmetic
93
+ # one. This function is what decides whether a value gets redacted, so the
94
+ # safe direction is to fold MORE aggressively, never less. A header named
95
+ # `x-api-Key` or a body key `toKen` whose `K` is U+212A KELVIN SIGN
96
+ # full-lowercases to `x-api-key` / `token` -- both denylisted -- and the
97
+ # reference redacts them.
98
+ #
99
+ # An ASCII-only fold leaves the KELVIN spelling unmatched and ships the
100
+ # plaintext secret to the dashboard. Do not reintroduce it, and do not
101
+ # substitute `casefold`-style folding either (PRIM-020).
102
+ def normalize_name(name)
103
+ Text.full_lower(name).delete("-_")
104
+ end
105
+
106
+ def build_deny_set(defaults, extra)
107
+ # REDACT-014: defaults are ALWAYS applied. Configuration extends the
108
+ # lists; it can never shrink or replace them.
109
+ Set.new((defaults + Array(extra)).map { |n| normalize_name(n.to_s) })
110
+ end
111
+
112
+ # REDACT-016. Split `Bearer <credential>` into its three parts, or nil
113
+ # when there is no scheme prefix to preserve.
114
+ #
115
+ # An explicit scan over CODE POINTS, never a regex. The obvious pattern
116
+ # `^(\S+)(\s+)(\S.*)$` is unportable and fails silently: JavaScript's `.`
117
+ # excludes CR/LS/PS so a credential containing a stray CR falls through to
118
+ # whole-value redaction, while a Ruby or Python `.` with DOTALL matches
119
+ # and preserves the scheme. Same header, same input, two captured values.
120
+ def split_auth_scheme(value)
121
+ chars = value.chars
122
+ i = 0
123
+ i += 1 while i < chars.length && !Text.ws?(chars[i])
124
+ # No whitespace at all, or the value starts with it: nothing to keep.
125
+ return nil if i.zero? || i >= chars.length
126
+
127
+ j = i
128
+ j += 1 while j < chars.length && Text.ws?(chars[j])
129
+ return nil if j >= chars.length # whitespace but no credential after it
130
+
131
+ {
132
+ scheme: chars[0, i].join,
133
+ gap: chars[i, j - i].join,
134
+ credential: chars[j..-1].join
135
+ }
136
+ end
137
+
138
+ # REDACT-018, REDACT-019. Returns a NEW hash; never mutates the caller's.
139
+ def redact_headers(headers, extra = [])
140
+ deny = build_deny_set(DEFAULT_HEADER_DENYLIST, extra)
141
+ out = {}
142
+ headers.each do |key, value|
143
+ norm = normalize_name(key.to_s)
144
+ unless deny.include?(norm)
145
+ out[key] = value
146
+ next
147
+ end
148
+
149
+ if SCHEME_PREFIX_HEADERS.include?(norm)
150
+ split = split_auth_scheme(value.to_s)
151
+ if split
152
+ out[key] = "#{split[:scheme]}#{split[:gap]}" \
153
+ "#{redact_value(split[:credential])}"
154
+ next
155
+ end
156
+ end
157
+
158
+ out[key] = redact_value(value.to_s)
159
+ end
160
+ out
161
+ end
162
+
163
+ # REDACT-028.
164
+ def percent_encode(value)
165
+ out = +""
166
+ Text.to_utf8(value).each_byte do |b|
167
+ out << (UNRESERVED_BYTES[b] ? b.chr : format("%%%02X", b))
168
+ end
169
+ out
170
+ end
171
+
172
+ # REDACT-029. `+` is a space, `%XX` is a byte, a `%` not followed by two
173
+ # hex digits is literal, and invalid UTF-8 in the decoded bytes becomes
174
+ # U+FFFD instead of raising.
175
+ #
176
+ # Iterates CODE POINTS. Node's original indexed UTF-16 code units, walked
177
+ # into the middle of an astral character and handed each surrogate half to
178
+ # the encoder separately, turning one emoji into two U+FFFD -- so the
179
+ # sentinel reported the wrong length.
180
+ def percent_decode(value)
181
+ bytes = []
182
+ chars = value.chars
183
+ i = 0
184
+ len = chars.length
185
+ while i < len
186
+ ch = chars[i]
187
+ if ch == "+"
188
+ bytes << 0x20
189
+ i += 1
190
+ next
191
+ end
192
+ if ch == "%"
193
+ pair = chars[i + 1, 2]
194
+ if pair && pair.length == 2
195
+ hex = pair.join
196
+ if HEX_PAIR_RE.match?(hex)
197
+ bytes << hex.to_i(16)
198
+ i += 3
199
+ next
200
+ end
201
+ end
202
+ end
203
+ Text.to_utf8(ch).each_byte { |b| bytes << b }
204
+ i += 1
205
+ end
206
+ Text.bytes_to_utf8(bytes)
207
+ end
208
+
209
+ # REDACT-025..027. Rewrite denylisted query values IN PLACE.
210
+ #
211
+ # Scheme, host, port, path, parameter order, separators and fragment come
212
+ # through byte for byte; only the matched values change. Parsing and
213
+ # re-serializing through a URL library is wrong twice over: it loses
214
+ # repeated parameters (`?token=a&token=b` collapses to one) and it applies
215
+ # WHATWG normalization no other language reproduces.
216
+ def redact_url(url, extra = [])
217
+ deny = build_deny_set(DEFAULT_QUERY_PARAM_DENYLIST, extra)
218
+
219
+ q = url.index("?")
220
+ return url if q.nil?
221
+
222
+ head = url[0, q + 1]
223
+ rest = url[(q + 1)..-1] || ""
224
+
225
+ hash = rest.index("#")
226
+ query = hash.nil? ? rest : rest[0, hash]
227
+ tail = hash.nil? ? "" : rest[hash..-1]
228
+ return url if query.empty?
229
+
230
+ # `split("&", -1)`: without the negative limit Ruby drops trailing empty
231
+ # fields, so `?a=1&` would come back as `?a=1`.
232
+ parts = query.split("&", -1).map do |pair|
233
+ eq = pair.index("=")
234
+ next pair if eq.nil?
235
+
236
+ raw_key = pair[0, eq]
237
+ raw_val = pair[(eq + 1)..-1] || ""
238
+ next pair unless deny.include?(normalize_name(percent_decode(raw_key)))
239
+
240
+ "#{raw_key}=#{percent_encode(redact_value(percent_decode(raw_val)))}"
241
+ end
242
+
243
+ head + parts.join("&") + tail
244
+ end
245
+
246
+ # REDACT-022. Recursively redact denylisted keys in a parsed JSON value.
247
+ def redact_json_value(val, deny)
248
+ return val if val.nil?
249
+ return val.map { |v| redact_json_value(v, deny) } if val.is_a?(Array)
250
+
251
+ if val.is_a?(Hash)
252
+ out = {}
253
+ val.each do |k, v|
254
+ if deny.include?(normalize_name(k.to_s))
255
+ # REDACT-004: a non-string value becomes the bare `<REDACTED>`;
256
+ # null is left alone (there is nothing to leak, and nulling it out
257
+ # would lose schema information).
258
+ out[k] = if v.nil?
259
+ nil
260
+ elsif v.is_a?(String)
261
+ redact_value(v)
262
+ else
263
+ "<REDACTED>"
264
+ end
265
+ else
266
+ out[k] = redact_json_value(v, deny)
267
+ end
268
+ end
269
+ return out
270
+ end
271
+
272
+ val
273
+ end
274
+
275
+ # REDACT-021. Walk the PARSED value rather than pattern-matching the raw
276
+ # text, so every SDK reaches the same verdict with its own JSON parser and
277
+ # there is no regex dialect or escape decoding to get wrong.
278
+ def contains_denied_key?(val, deny)
279
+ return false if val.nil?
280
+ return val.any? { |v| contains_denied_key?(v, deny) } if val.is_a?(Array)
281
+
282
+ if val.is_a?(Hash)
283
+ val.each do |k, v|
284
+ return true if deny.include?(normalize_name(k.to_s))
285
+ return true if contains_denied_key?(v, deny)
286
+ end
287
+ end
288
+
289
+ false
290
+ end
291
+
292
+ # REDACT-020..024.
293
+ #
294
+ # When the body contains NOTHING to redact, the caller's original string
295
+ # is returned BYTE FOR BYTE rather than a re-serialized copy. That is a
296
+ # fidelity requirement, not an optimization: a parse/serialize round trip
297
+ # is lossy in every language, differently, and re-serialization is exactly
298
+ # where SDKs disagree.
299
+ def redact_body(body, content_type, extra = [])
300
+ return body if body.nil? || body.empty?
301
+ # REDACT-023: case-insensitive substring match on the content type.
302
+ return body unless Text.full_lower(content_type.to_s).include?("application/json")
303
+
304
+ begin
305
+ # max_nesting: false because the reference parser has no depth limit;
306
+ # a 200-deep body must not silently take a different branch here.
307
+ parsed = JSON.parse(body, max_nesting: false)
308
+ rescue StandardError
309
+ # REDACT-024: a body that does not parse passes through unchanged.
310
+ return body
311
+ end
312
+
313
+ deny = build_deny_set(DEFAULT_BODY_KEY_DENYLIST, extra)
314
+ return body unless contains_denied_key?(parsed, deny)
315
+
316
+ # PRIM-030 (compact), PRIM-031 (insertion order), PRIM-032 (literal
317
+ # UTF-8). Ruby's JSON encoder does all three by default.
318
+ JSON.generate(redact_json_value(parsed, deny))
319
+ rescue StandardError
320
+ body
321
+ end
322
+
323
+ # REDACT-030..032. The cut is made at the byte limit and then backed off
324
+ # to the nearest character boundary, so the kept prefix is always a
325
+ # complete sequence of Unicode scalar values.
326
+ def truncate_body(body, max_bytes)
327
+ return body if body.nil? || body.empty?
328
+
329
+ buf = Text.to_utf8(body).dup.force_encoding(Encoding::ASCII_8BIT)
330
+ total = buf.bytesize
331
+ return body if total <= max_bytes
332
+
333
+ cut = max_bytes
334
+ cut = 0 if cut.negative?
335
+ # Walk back off any UTF-8 continuation byte (0b10xxxxxx).
336
+ cut -= 1 while cut.positive? && (buf.getbyte(cut) & 0xC0) == 0x80
337
+
338
+ kept = buf.byteslice(0, cut).force_encoding(Encoding::UTF_8)
339
+ "#{kept}\n[...TRUNCATED: original #{total} bytes]"
340
+ end
341
+ end
342
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Restless
6
+ # CONTRACT.md section 6.
7
+ module RequestId
8
+ UUID_RE = /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/.freeze
9
+
10
+ # REQID-005. `/m` so `.` behaves like a JavaScript `.` cannot -- it is a
11
+ # superset, and the UUID test below is strict enough that the extra
12
+ # matches are all rejected anyway, which makes the two engines agree.
13
+ PREFIXED_RE = /\A[A-Za-z0-9]{1,7}-(.+)\z/m.freeze
14
+
15
+ module_function
16
+
17
+ # REQID-001, REQID-002. An RFC 4122 v4 UUID from a CSPRNG, lowercase and
18
+ # hyphenated. Explicitly NOT time-ordered: request ids appear in
19
+ # user-visible URLs and logs and must not leak ordering or timing.
20
+ def new_request_id
21
+ SecureRandom.uuid
22
+ end
23
+
24
+ # REQID-004. A configured prefix is for DISPLAY only; the raw UUID is what
25
+ # goes on the wire as `_id` (WIRE-011).
26
+ def format_request_id(raw_id, prefix = nil)
27
+ return raw_id if prefix.nil? || prefix.empty?
28
+
29
+ "#{prefix}-#{raw_id}"
30
+ end
31
+
32
+ # REQID-005. Returns group 1 only when it is itself a valid UUID;
33
+ # otherwise the input comes back untouched.
34
+ def strip_request_id_prefix(request_id)
35
+ match = PREFIXED_RE.match(request_id)
36
+ return request_id unless match
37
+ return match[1] if UUID_RE.match?(match[1])
38
+
39
+ request_id
40
+ end
41
+
42
+ def valid_request_id?(raw)
43
+ UUID_RE.match?(strip_request_id_prefix(raw))
44
+ end
45
+
46
+ # REQID-010, REQID-011. Exactly one id header per response.
47
+ #
48
+ # Emit `x-request-id` -- the header everyone knows -- carrying our freshly
49
+ # minted id. If the incoming request already carried one (a client, a
50
+ # reverse proxy, upstream middleware), fall back to `x-restless-id` so an
51
+ # existing chain is never clobbered.
52
+ #
53
+ # The literal `missing-key` is the setup CLI's signal that the server is
54
+ # running but the key never loaded, as opposed to the request silently
55
+ # dropping before upload.
56
+ def response_headers(our_id, incoming_headers, prefix = nil, has_api_key = true)
57
+ value = has_api_key ? format_request_id(our_id, prefix) : "missing-key"
58
+ incoming = incoming_headers["x-request-id"]
59
+ name = incoming.nil? || incoming == "" || incoming == false ? "x-request-id" : "x-restless-id"
60
+ { name => value }
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Restless
6
+ # CONTRACT.md section 12. `.restless/settings.json` is created and owned by
7
+ # the `api` CLI (`npx api setup`); the SDK consumes exactly two fields of it
8
+ # at runtime (CONFIG-015).
9
+ module Settings
10
+ class ConfigError < StandardError; end
11
+
12
+ @cache = nil
13
+ @loaded = false
14
+ @mutex = Mutex.new
15
+
16
+ class << self
17
+ # CONFIG-010, CONFIG-011. Walk up from the working directory to the
18
+ # filesystem root, take the first hit, and read at most once per process
19
+ # -- including the negative result.
20
+ def load(start_dir = Dir.pwd)
21
+ @mutex.synchronize do
22
+ return @cache if @loaded
23
+
24
+ @loaded = true
25
+ @cache = read_settings(start_dir)
26
+ end
27
+ end
28
+
29
+ # Test-only. Do not call from production code.
30
+ def reset_cache!
31
+ @mutex.synchronize do
32
+ @loaded = false
33
+ @cache = nil
34
+ end
35
+ end
36
+
37
+ # CONFIG-013, CONFIG-014. Returns {id:, name:, request_id_prefix:,
38
+ # redact:} or nil.
39
+ def resolve_api(settings, name = nil)
40
+ return nil if settings.nil?
41
+
42
+ apis = settings["apis"]
43
+ return nil unless apis.is_a?(Array) && !apis.empty?
44
+
45
+ if name && !name.empty?
46
+ match = apis.find { |a| a["name"] == name } || apis.find { |a| a["id"] == name }
47
+ unless match
48
+ raise ConfigError,
49
+ "restless-sdk: no API named #{name.inspect} in .restless/settings.json " \
50
+ "(found: #{apis.map { |a| a['name'] }.join(', ')})"
51
+ end
52
+ return entry(match)
53
+ end
54
+
55
+ return entry(apis[0]) if apis.length == 1
56
+
57
+ # Guessing would silently apply the wrong redaction list.
58
+ raise ConfigError,
59
+ "restless-sdk: .restless/settings.json has multiple APIs " \
60
+ "(#{apis.map { |a| a['name'] }.join(', ')}) -- pass api: \"<name>\" to " \
61
+ "Restless::Client.new to pick one."
62
+ end
63
+
64
+ private
65
+
66
+ def entry(api)
67
+ {
68
+ id: api["id"],
69
+ name: api["name"],
70
+ request_id_prefix: api["requestIdPrefix"],
71
+ redact: api["redact"].is_a?(Hash) ? api["redact"] : nil
72
+ }
73
+ end
74
+
75
+ def read_settings(start_dir)
76
+ file = find_settings_file(start_dir)
77
+ return nil if file.nil?
78
+
79
+ # CONFIG-012: a missing or malformed file yields no configuration. It
80
+ # must not raise and must not prevent construction.
81
+ parsed = JSON.parse(File.read(file))
82
+ parsed.is_a?(Hash) ? parsed : nil
83
+ rescue StandardError
84
+ nil
85
+ end
86
+
87
+ def find_settings_file(start_dir)
88
+ dir = File.expand_path(start_dir)
89
+ loop do
90
+ candidate = File.join(dir, ".restless", "settings.json")
91
+ return candidate if File.file?(candidate)
92
+
93
+ parent = File.dirname(dir)
94
+ return nil if parent == dir
95
+
96
+ dir = parent
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ require_relative "fingerprint"
6
+
7
+ module Restless
8
+ # CONTRACT.md FP-043, FP-044, FP-045. The Ruby dialect of stack parsing.
9
+ #
10
+ # FP-044 makes frame parsing and the skip list explicitly per-language: only
11
+ # the OUTPUT shape is contract surface. This module is therefore the one
12
+ # place in the SDK that knows what a Ruby backtrace looks like, and the
13
+ # shared conformance vectors deliberately do not cover it (FP-046); see
14
+ # `test/test_stack_frames.rb`.
15
+ module StackFrames
16
+ # Ruby <= 3.3: "path.rb:12:in `method'"
17
+ # Ruby >= 3.4: "path.rb:12:in 'Klass#method'"
18
+ FRAME_RE = /\A(.+):(\d+):in [`'](.+)'\z/.freeze
19
+ # Some frames carry no method label at all.
20
+ FRAME_NO_FN_RE = /\A(.+):(\d+)\z/.freeze
21
+
22
+ # `block (3 levels) in handler` -> `block in handler`. The nesting count
23
+ # is closer to a line number than to an identity: it moves when somebody
24
+ # wraps the throw site in one more `each`, which FP-041 says must not
25
+ # split a group.
26
+ BLOCK_LEVELS_RE = /\Ablock \(\d+ levels\) in /.freeze
27
+
28
+ # Ruby 3.4 changed backtrace method labels to carry the receiver:
29
+ # `detonate` became `Exploder.detonate`, and `run` became `Exploder#run`.
30
+ # Stripping it back off is not cosmetic. Without it the SAME crash in the
31
+ # SAME file fingerprints differently depending on which Ruby the service
32
+ # happens to run, so a routine runtime upgrade silently splits every
33
+ # existing 5xx group and orphans the Agent Recovery guidance attached to
34
+ # it. FP-041 forbids exactly that churn for line numbers; a runtime
35
+ # version is no different.
36
+ #
37
+ # Stripping rather than keeping, because that reproduces the label Ruby
38
+ # 3.3 and earlier already emit, so no stored key moves.
39
+ RECEIVER_RE = /\A(?:[A-Z]\w*(?:::[A-Z]\w*)*)[.#]/.freeze
40
+
41
+ # FP-044. The Ruby equivalent of `node_modules` / `node:internal` /
42
+ # `@restlessai/sdk`.
43
+ #
44
+ # Everything here is matched by FILE PATH, never by module or class name.
45
+ # A name check would also skip a customer's own `Restless`-flavoured code
46
+ # and, worse, would not skip this gem when it is vendored under a
47
+ # different constant. The SDK's own directory is resolved from `__dir__`,
48
+ # so it is correct however the gem was installed.
49
+ SDK_DIR = File.expand_path("..", __dir__).freeze
50
+
51
+ STDLIB_DIRS = [
52
+ RbConfig::CONFIG["rubylibdir"],
53
+ RbConfig::CONFIG["rubyarchdir"],
54
+ RbConfig::CONFIG["sitelibdir"],
55
+ RbConfig::CONFIG["vendorlibdir"]
56
+ ].compact.reject(&:empty?).map { |d| File.expand_path(d) }.freeze
57
+
58
+ GEM_DIRS = begin
59
+ dirs = []
60
+ begin
61
+ dirs.concat(Array(Gem.path)) if defined?(Gem)
62
+ dirs << Gem.dir if defined?(Gem) && Gem.respond_to?(:dir)
63
+ rescue StandardError
64
+ # Gem may not be loaded at all; the "/gems/" fallback below covers it.
65
+ end
66
+ dirs.compact.uniq.map { |d| File.expand_path(d) }.freeze
67
+ end
68
+
69
+ module_function
70
+
71
+ # FP-043. The frame NEAREST THE THROW SITE that is not vendor, runtime or
72
+ # SDK code.
73
+ #
74
+ # Ruby's `Exception#backtrace` is innermost-FIRST (index 0 is where the
75
+ # exception was raised), like a v8 `Error.stack` and unlike a Python
76
+ # traceback, so the walk goes FORWARDS. Implementing this positionally in
77
+ # the wrong direction returns the Rack entry point for every crash in the
78
+ # process, which collapses every 500 into one fingerprint group and
79
+ # defeats the strategy entirely. Verified empirically in
80
+ # `test/test_stack_frames.rb`, not assumed.
81
+ def top_user_frame(backtrace)
82
+ return nil if backtrace.nil?
83
+
84
+ Array(backtrace).each do |raw|
85
+ frame = parse_frame(raw.to_s)
86
+ next if frame.nil?
87
+ next if skip_path?(frame[:file])
88
+
89
+ return {
90
+ file: Fingerprint.project_relative(frame[:file]),
91
+ fn: frame[:fn]
92
+ }
93
+ end
94
+ nil
95
+ end
96
+
97
+ def parse_frame(line)
98
+ if (m = FRAME_RE.match(line))
99
+ return { file: m[1], fn: normalize_fn(m[3]) }
100
+ end
101
+ if (m = FRAME_NO_FN_RE.match(line))
102
+ # FP-045.
103
+ return { file: m[1], fn: "anonymous" }
104
+ end
105
+
106
+ nil
107
+ end
108
+
109
+ def normalize_fn(name)
110
+ cleaned = name.sub(BLOCK_LEVELS_RE, "block in ")
111
+ cleaned = strip_receiver(cleaned)
112
+ cleaned.empty? ? "anonymous" : cleaned
113
+ end
114
+
115
+ # Applied after the block normalization, and to the method portion of a
116
+ # `block in X` label as well as a bare one, since 3.4 qualifies both.
117
+ def strip_receiver(name)
118
+ if name.start_with?("block in ")
119
+ "block in " + name.sub(/\Ablock in /, "").sub(RECEIVER_RE, "")
120
+ else
121
+ name.sub(RECEIVER_RE, "")
122
+ end
123
+ end
124
+
125
+ def skip_path?(path)
126
+ return true if path.nil? || path.empty?
127
+ # Ruby 3.x synthesises frames like "<internal:kernel>:90:in `tap'".
128
+ return true if path.start_with?("<internal:")
129
+ return true if path.start_with?(SDK_DIR)
130
+ return true if path.include?("/gems/")
131
+ return true if STDLIB_DIRS.any? { |dir| path.start_with?(dir) }
132
+ return true if GEM_DIRS.any? { |dir| path.start_with?(dir) }
133
+
134
+ false
135
+ end
136
+
137
+ # Convenience for adapters: fingerprint-ready frame from an exception.
138
+ def from_exception(error)
139
+ return nil unless error.respond_to?(:backtrace)
140
+
141
+ top_user_frame(error.backtrace)
142
+ rescue StandardError
143
+ nil
144
+ end
145
+ end
146
+ end