necropsy 0.2.0 → 0.3.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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/MEASUREMENTS.md +27 -0
  4. data/README.md +182 -17
  5. data/bench/README.md +92 -0
  6. data/bench/audit.rb +113 -0
  7. data/bench/audits/0.2.1/audit.json +501 -0
  8. data/bench/audits/0.2.1/audit.md +55 -0
  9. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  10. data/bench/audits/0.2.1/config.yml +42 -0
  11. data/bench/audits/0.2.1/review.yml +56 -0
  12. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  13. data/bench/corpora/v1/README.md +30 -0
  14. data/bench/corpora/v1/labels.yml +37 -0
  15. data/bench/corpora/v1/manifest.yml +63 -0
  16. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  17. data/bench/corpora/v1/self.necropsy.yml +8 -0
  18. data/bench/corpora/v1/tools/debride.yml +42 -0
  19. data/bench/corpora/v1/tools/spoom.yml +12 -0
  20. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  21. data/bench/golden/v1/candidate_union.json +21918 -0
  22. data/bench/golden/v1/metadata.json +12 -0
  23. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  24. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  25. data/bench/golden/v1/reports/rails.json +176 -0
  26. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  27. data/bench/golden/v1/reports/self.json +3449 -0
  28. data/bench/review_queue.rb +35 -0
  29. data/bench/run.rb +31 -0
  30. data/bench/schema/candidate-union-v1.schema.json +70 -0
  31. data/docs/impv_implementation_matrix.md +179 -0
  32. data/docs/migrations/0.2.1.md +57 -0
  33. data/docs/migrations/0.3.0.md +207 -0
  34. data/docs/migrations/0.4.0.md +13 -0
  35. data/docs/necropsy_performance_adr.md +43 -0
  36. data/docs/necropsy_scope_decisions.md +58 -0
  37. data/docs/necropsy_type_facts_adr.md +22 -0
  38. data/gemfiles/prism_min.gemfile +9 -0
  39. data/gemfiles/prism_min.gemfile.lock +50 -0
  40. data/lib/necropsy/analyzer.rb +121 -2
  41. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
  42. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
  43. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
  44. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  45. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  46. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  47. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  50. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  51. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
  52. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  53. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  54. data/lib/necropsy/analyzers/static/cha.rb +33 -73
  55. data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
  56. data/lib/necropsy/analyzers/static/rta.rb +237 -29
  57. data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
  58. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  59. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  60. data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
  61. data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
  62. data/lib/necropsy/ast_scanner/references.rb +43 -9
  63. data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
  64. data/lib/necropsy/ast_scanner/traversal.rb +234 -71
  65. data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
  66. data/lib/necropsy/ast_scanner.rb +67 -6
  67. data/lib/necropsy/bench/candidate_union.rb +555 -0
  68. data/lib/necropsy/bench/claim_gate.rb +112 -0
  69. data/lib/necropsy/bench/evaluator.rb +329 -16
  70. data/lib/necropsy/bench/finding_facts.rb +152 -0
  71. data/lib/necropsy/bench/precision_gate.rb +144 -0
  72. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  73. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  74. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  75. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  76. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  77. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  78. data/lib/necropsy/bench/release_audit.rb +360 -0
  79. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  80. data/lib/necropsy/bench/review_queue.rb +154 -0
  81. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  82. data/lib/necropsy/bench/seed_runner.rb +408 -0
  83. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  84. data/lib/necropsy/cache/scan_cache.rb +85 -17
  85. data/lib/necropsy/call_site_identity.rb +54 -0
  86. data/lib/necropsy/cli.rb +220 -33
  87. data/lib/necropsy/clock.rb +40 -0
  88. data/lib/necropsy/confidence/scorer.rb +103 -58
  89. data/lib/necropsy/configuration.rb +224 -21
  90. data/lib/necropsy/convention_rules.rb +138 -0
  91. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  92. data/lib/necropsy/definition_identity.rb +37 -0
  93. data/lib/necropsy/diagnostics.rb +176 -36
  94. data/lib/necropsy/embedded_ruby.rb +55 -0
  95. data/lib/necropsy/entry_points/plain.rb +111 -10
  96. data/lib/necropsy/entry_points/rails.rb +322 -41
  97. data/lib/necropsy/entry_points/test.rb +6 -1
  98. data/lib/necropsy/flow_interpreter.rb +460 -0
  99. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  100. data/lib/necropsy/graph/call_graph.rb +1099 -109
  101. data/lib/necropsy/graph/definition_index.rb +149 -0
  102. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  103. data/lib/necropsy/graph/evidence_store.rb +213 -0
  104. data/lib/necropsy/graph/resolution_store.rb +497 -0
  105. data/lib/necropsy/graph_self_check.rb +79 -0
  106. data/lib/necropsy/guardrail/baseline.rb +350 -13
  107. data/lib/necropsy/guardrail/quarantine.rb +94 -9
  108. data/lib/necropsy/load_graph.rb +206 -0
  109. data/lib/necropsy/models.rb +878 -13
  110. data/lib/necropsy/performance_profiler.rb +108 -0
  111. data/lib/necropsy/project.rb +327 -25
  112. data/lib/necropsy/reachability/engine.rb +54 -13
  113. data/lib/necropsy/reference_barrier.rb +458 -0
  114. data/lib/necropsy/report.rb +113 -4
  115. data/lib/necropsy/reporter.rb +431 -15
  116. data/lib/necropsy/runner.rb +233 -18
  117. data/lib/necropsy/runtime_feedback.rb +136 -0
  118. data/lib/necropsy/semantics_matrix.rb +153 -0
  119. data/lib/necropsy/type_facts.rb +53 -0
  120. data/lib/necropsy/version.rb +1 -1
  121. data/lib/necropsy/why_not_explanation.rb +436 -0
  122. data/lib/necropsy/why_not_renderer.rb +197 -0
  123. data/lib/necropsy/world_policy.rb +90 -0
  124. data/lib/necropsy.rb +35 -2
  125. data/schema/necropsy-report-v2.schema.json +366 -0
  126. metadata +85 -1
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Necropsy
6
+ module Analyzers
7
+ module Dynamic
8
+ class RedisInputLimits
9
+ DEFAULTS = {
10
+ 'connect_timeout' => 5.0,
11
+ 'read_timeout' => 5.0,
12
+ 'total_timeout' => 15.0,
13
+ 'max_response_bytes' => 16 * 1024 * 1024,
14
+ 'max_bulk_bytes' => 8 * 1024 * 1024,
15
+ 'max_array_elements' => 100_000,
16
+ 'max_resp_depth' => 16,
17
+ 'max_keys' => 1_000,
18
+ 'max_payload_depth' => 64
19
+ }.freeze
20
+ TIMEOUT_KEYS = %w[connect_timeout read_timeout total_timeout].freeze
21
+ INTEGER_KEYS = (DEFAULTS.keys - TIMEOUT_KEYS).freeze
22
+
23
+ attr_reader(*DEFAULTS.keys.map(&:to_sym))
24
+
25
+ def initialize(config)
26
+ TIMEOUT_KEYS.each { |key| instance_variable_set("@#{key}", positive_float(config, key)) }
27
+ INTEGER_KEYS.each { |key| instance_variable_set("@#{key}", positive_integer(config, key)) }
28
+ end
29
+
30
+ def validate_payload!(payload)
31
+ stack = [[payload, 0]]
32
+ until stack.empty?
33
+ value, depth = stack.pop
34
+ raise Error, 'Redis coverage payload exceeds maximum nesting depth' if depth > max_payload_depth
35
+ if (value.is_a?(Hash) || value.is_a?(Array)) && value.length > max_array_elements
36
+ raise Error, 'Redis coverage payload exceeds maximum collection size'
37
+ end
38
+
39
+ children = value.is_a?(Hash) ? value.flat_map { |key, child| [key, child] } : Array(value)
40
+ stack.concat(children.map { |child| [child, depth + 1] }) if value.is_a?(Hash) || value.is_a?(Array)
41
+ end
42
+ payload
43
+ end
44
+
45
+ private
46
+
47
+ def positive_float(config, key)
48
+ value = Float(config.fetch(key, DEFAULTS.fetch(key)))
49
+ return value if value.positive? && value.finite?
50
+
51
+ invalid_limit!(key)
52
+ rescue ArgumentError, TypeError
53
+ invalid_limit!(key)
54
+ end
55
+
56
+ def positive_integer(config, key)
57
+ value = Integer(config.fetch(key, DEFAULTS.fetch(key)))
58
+ return value if value.positive?
59
+
60
+ invalid_limit!(key)
61
+ rescue ArgumentError, TypeError
62
+ invalid_limit!(key)
63
+ end
64
+
65
+ def invalid_limit!(key)
66
+ raise Error, "analyzers.dynamic.coverband.#{key} must be a finite positive number"
67
+ end
68
+ end
69
+
70
+ class RedisDeadline
71
+ def initialize(seconds)
72
+ @expires_at = monotonic_time + seconds
73
+ end
74
+
75
+ def remaining
76
+ seconds = @expires_at - monotonic_time
77
+ raise Error, 'Redis total timeout exceeded' unless seconds.positive?
78
+
79
+ seconds
80
+ end
81
+
82
+ def check!
83
+ remaining
84
+ nil
85
+ end
86
+
87
+ private
88
+
89
+ def monotonic_time
90
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
91
+ end
92
+ end
93
+
94
+ class RedisCredentialRedactor
95
+ REDACTED = '[REDACTED]'
96
+
97
+ def initialize(source)
98
+ @source = source.to_s
99
+ @safe_source = @source.sub(%r{(?<=://)[^/@\s]+@}, "#{REDACTED}@")
100
+ @secrets = credentials(@source)
101
+ end
102
+
103
+ def redact(message)
104
+ sanitized = message.to_s.gsub(@source, @safe_source)
105
+ @secrets.reduce(sanitized) { |text, secret| text.gsub(secret, REDACTED) }
106
+ end
107
+
108
+ private
109
+
110
+ def credentials(source)
111
+ uri = URI(source)
112
+ [uri.user, uri.password].compact.flat_map do |credential|
113
+ [credential, URI.decode_www_form_component(credential)]
114
+ end.reject(&:empty?).uniq.sort_by { |value| -value.length }
115
+ rescue URI::Error, ArgumentError
116
+ []
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/wait'
4
+
5
+ module Necropsy
6
+ module Analyzers
7
+ module Dynamic
8
+ module RedisNonblockingIO
9
+ private
10
+
11
+ def finish_tls_connect(ssl_socket)
12
+ expires_at = monotonic_time + limits.connect_timeout
13
+ loop do
14
+ status = ssl_socket.connect_nonblock(exception: false)
15
+ return unless wait_status?(status)
16
+
17
+ wait_for_io(status, io: ssl_socket.to_io, expires_at: expires_at,
18
+ label: 'Redis TLS handshake timeout exceeded')
19
+ rescue IO::WaitReadable
20
+ wait_for_io(:wait_readable, io: ssl_socket.to_io, expires_at: expires_at,
21
+ label: 'Redis TLS handshake timeout exceeded')
22
+ rescue IO::WaitWritable
23
+ wait_for_io(:wait_writable, io: ssl_socket.to_io, expires_at: expires_at,
24
+ label: 'Redis TLS handshake timeout exceeded')
25
+ end
26
+ end
27
+
28
+ def write_all(value)
29
+ offset = 0
30
+ expires_at = monotonic_time + limits.read_timeout
31
+ while offset < value.bytesize
32
+ check_io_deadline!(expires_at, 'Redis write timeout exceeded')
33
+ begin
34
+ written = socket.write_nonblock(value.byteslice(offset, value.bytesize - offset), exception: false)
35
+ if wait_status?(written)
36
+ wait_for_io(written, io: socket.to_io, expires_at: expires_at, label: 'Redis write timeout exceeded')
37
+ elsif written&.positive?
38
+ offset += written
39
+ else
40
+ domain_error('Redis closed the connection while writing a request')
41
+ end
42
+ rescue IO::WaitReadable
43
+ wait_for_io(:wait_readable, io: socket.to_io, expires_at: expires_at,
44
+ label: 'Redis write timeout exceeded')
45
+ rescue IO::WaitWritable
46
+ wait_for_io(:wait_writable, io: socket.to_io, expires_at: expires_at,
47
+ label: 'Redis write timeout exceeded')
48
+ end
49
+ end
50
+ check_io_deadline!(expires_at, 'Redis write timeout exceeded')
51
+ end
52
+
53
+ def read_nonblock(length)
54
+ expires_at = monotonic_time + limits.read_timeout
55
+ loop do
56
+ value = socket.read_nonblock(length, exception: false)
57
+ return value if value.is_a?(String) && !value.empty?
58
+
59
+ domain_error('Redis closed the connection while reading a response') if value.nil? || value == ''
60
+ wait_for_io(value, io: socket.to_io, expires_at: expires_at, label: 'Redis read timeout exceeded')
61
+ rescue IO::WaitReadable
62
+ wait_for_io(:wait_readable, io: socket.to_io, expires_at: expires_at, label: 'Redis read timeout exceeded')
63
+ rescue IO::WaitWritable
64
+ wait_for_io(:wait_writable, io: socket.to_io, expires_at: expires_at, label: 'Redis read timeout exceeded')
65
+ end
66
+ end
67
+
68
+ def wait_status?(value)
69
+ %i[wait_readable wait_writable].include?(value)
70
+ end
71
+
72
+ def check_io_deadline!(expires_at, label)
73
+ deadline.check!
74
+ domain_error(label) unless (expires_at - monotonic_time).positive?
75
+ end
76
+
77
+ def wait_for_io(status, io:, expires_at:, label:)
78
+ timeout = [expires_at - monotonic_time, deadline.remaining].min
79
+ domain_error(label) unless timeout.positive?
80
+
81
+ ready = status == :wait_readable ? io.wait_readable(timeout) : io.wait_writable(timeout)
82
+ return if ready
83
+
84
+ deadline.check!
85
+ domain_error(label)
86
+ end
87
+
88
+ def monotonic_time
89
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'uri'
5
+ require 'yaml'
6
+
7
+ require_relative 'coverband_payload_set'
8
+ require_relative 'redis_input_limits'
9
+ require_relative 'redis_transport'
10
+
11
+ module Necropsy
12
+ module Analyzers
13
+ module Dynamic
14
+ class RedisPayloadLoader
15
+ DEFAULT_PATTERN = 'coverband*'
16
+
17
+ def initialize(source:, config:)
18
+ @redactor = RedisCredentialRedactor.new(source)
19
+ @uri = parse_uri(source)
20
+ @config = config
21
+ @limits = RedisInputLimits.new(config)
22
+ end
23
+
24
+ def load
25
+ start_session
26
+ transport.connect
27
+ authenticate
28
+ select_database
29
+ payload_processor.merge(keys.filter_map { |key| payload_for_key(key) })
30
+ rescue RedisCommandError, Error => e
31
+ raise Error, redactor.redact(e.message), cause: nil
32
+ rescue StandardError => e
33
+ raise Error, redactor.redact("Could not load Redis coverage: #{e.message}"), cause: nil
34
+ ensure
35
+ transport&.close
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :uri, :config, :limits, :redactor, :transport, :deadline, :payload_processor
41
+
42
+ def parse_uri(source)
43
+ parsed = URI(source)
44
+ return parsed if %w[redis rediss].include?(parsed.scheme) && parsed.host
45
+
46
+ raise Error, 'Invalid Redis source URI'
47
+ rescue URI::Error, ArgumentError
48
+ raise Error, 'Invalid Redis source URI', cause: nil
49
+ end
50
+
51
+ def start_session
52
+ @deadline = RedisDeadline.new(limits.total_timeout)
53
+ @payload_processor = CoverbandPayloadSet.new(limits: limits, deadline: deadline)
54
+ @transport = RedisTransport.new(uri: uri, limits: limits, deadline: deadline, redactor: redactor)
55
+ end
56
+
57
+ def authenticate
58
+ return unless uri.password
59
+
60
+ password = URI.decode_www_form_component(uri.password)
61
+ return command('AUTH', password) unless uri.user
62
+
63
+ command('AUTH', URI.decode_www_form_component(uri.user), password)
64
+ end
65
+
66
+ def select_database
67
+ database = uri.path.to_s.delete_prefix('/')
68
+ command('SELECT', database) unless database.empty?
69
+ end
70
+
71
+ def keys
72
+ configured = Array(config['keys'] || config['key'] || query['key']).compact
73
+ return validate_key_count!(configured) unless configured.empty?
74
+
75
+ scan(pattern: config['pattern'] || query['pattern'] || DEFAULT_PATTERN)
76
+ end
77
+
78
+ def query
79
+ @query ||= URI.decode_www_form(uri.query.to_s).to_h
80
+ end
81
+
82
+ def scan(pattern:)
83
+ cursor = '0'
84
+ found = []
85
+ loop do
86
+ deadline.check!
87
+ cursor, batch = scan_page(cursor, pattern)
88
+ append_keys!(found, batch)
89
+ break if cursor == '0'
90
+ end
91
+ found
92
+ end
93
+
94
+ def scan_page(cursor, pattern)
95
+ response = command('SCAN', cursor, 'MATCH', pattern)
96
+ valid = response.is_a?(Array) && response.length == 2 && response.last.is_a?(Array)
97
+ raise Error, 'Malformed Redis SCAN response' unless valid
98
+
99
+ next_cursor = response.first.to_s
100
+ raise Error, 'Malformed Redis SCAN cursor' unless next_cursor.match?(/\A\d+\z/)
101
+
102
+ [next_cursor, response.last]
103
+ end
104
+
105
+ def append_keys!(found, batch)
106
+ raise Error, 'Redis key count exceeds configured limit' if found.length + batch.length > limits.max_keys
107
+
108
+ found.concat(batch)
109
+ end
110
+
111
+ def validate_key_count!(keys)
112
+ raise Error, 'Redis key count exceeds configured limit' if keys.length > limits.max_keys
113
+
114
+ keys
115
+ end
116
+
117
+ def payload_for_key(key)
118
+ value = command('GET', key)
119
+ return nil if value.nil? || value.empty?
120
+
121
+ payload = parse_payload(value)
122
+ raise Error, 'Redis coverage payload must be a mapping' unless payload.is_a?(Hash)
123
+
124
+ limits.validate_payload!(payload)
125
+ rescue RedisCommandError => e
126
+ raise unless e.message.start_with?('WRONGTYPE')
127
+
128
+ parse_hash_payload(command('HGETALL', key))
129
+ end
130
+
131
+ def command(*parts)
132
+ transport.command(*parts)
133
+ end
134
+
135
+ def parse_payload(value)
136
+ return nil if value.nil? || value.empty?
137
+
138
+ parsed = begin
139
+ JSON.parse(value, max_nesting: limits.max_payload_depth)
140
+ rescue JSON::ParserError
141
+ parse_yaml_payload(value)
142
+ end
143
+ limits.validate_payload!(parsed)
144
+ end
145
+
146
+ def parse_yaml_payload(value)
147
+ YAML.safe_load(value, aliases: false)
148
+ rescue Psych::Exception, ArgumentError, EncodingError, SystemStackError => e
149
+ raise Error, redactor.redact("Invalid Redis coverage payload: #{e.message}"), cause: nil
150
+ end
151
+
152
+ def parse_hash_payload(entries)
153
+ active_payload_processor.from_hash_entries(entries) { |value| parse_payload(value) }
154
+ end
155
+
156
+ def active_payload_processor
157
+ @active_payload_processor ||= CoverbandPayloadSet.new(
158
+ limits: limits,
159
+ deadline: (@deadline ||= RedisDeadline.new(limits.total_timeout))
160
+ )
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'socket'
5
+ require 'timeout'
6
+
7
+ require_relative 'redis_nonblocking_io'
8
+
9
+ module Necropsy
10
+ module Analyzers
11
+ module Dynamic
12
+ class RedisCommandError < StandardError; end
13
+
14
+ class RedisTransport
15
+ include RedisNonblockingIO
16
+
17
+ CRLF = "\r\n"
18
+
19
+ def initialize(uri:, limits:, deadline:, redactor:)
20
+ @uri = uri
21
+ @limits = limits
22
+ @deadline = deadline
23
+ @redactor = redactor
24
+ @response_bytes = 0
25
+ @read_buffer = +''
26
+ end
27
+
28
+ def connect
29
+ @tcp_socket = connect_tcp
30
+ @socket = uri.scheme == 'rediss' ? connect_tls : tcp_socket
31
+ deadline.check!
32
+ self
33
+ rescue Error
34
+ close
35
+ raise
36
+ rescue SystemCallError, SocketError, IO::TimeoutError, OpenSSL::SSL::SSLError => e
37
+ close
38
+ domain_error("Could not connect securely to Redis at #{target}: #{e.message}")
39
+ end
40
+
41
+ def close
42
+ socket&.close
43
+ tcp_socket&.close unless socket.equal?(tcp_socket)
44
+ rescue IOError, SystemCallError
45
+ nil
46
+ ensure
47
+ @socket = nil
48
+ @tcp_socket = nil
49
+ end
50
+
51
+ def command(*parts)
52
+ deadline.check!
53
+ write_all(redis_command(parts))
54
+ response = read_response
55
+ deadline.check!
56
+ response
57
+ rescue RedisCommandError
58
+ raise
59
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
60
+ domain_error("Redis connection failed: #{e.message}")
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :uri, :limits, :deadline, :redactor, :socket, :tcp_socket
66
+
67
+ def connect_tcp
68
+ expires_at = monotonic_time + limits.connect_timeout
69
+ last_error = nil
70
+ addresses(expires_at).each do |address|
71
+ candidate = Socket.new(address.afamily, address.socktype, address.protocol)
72
+ begin
73
+ finish_tcp_connect(candidate, address, expires_at)
74
+ return candidate
75
+ rescue SystemCallError, SocketError => e
76
+ candidate.close
77
+ last_error = e
78
+ rescue Error
79
+ candidate.close
80
+ raise
81
+ end
82
+ end
83
+ raise(last_error || SocketError.new("No Redis address found for #{uri.host}"))
84
+ end
85
+
86
+ def addresses(expires_at)
87
+ timeout = [expires_at - monotonic_time, deadline.remaining].min
88
+ domain_error('Redis address resolution timeout exceeded') unless timeout.positive?
89
+
90
+ # Limit asynchronous interruption to pure resolution, before any socket resource exists.
91
+ resolved = Timeout.timeout(timeout) do
92
+ Addrinfo.getaddrinfo(uri.host, uri.port || 6379, nil, :STREAM)
93
+ end
94
+ deadline.check!
95
+ domain_error('Redis address resolution timeout exceeded') unless (expires_at - monotonic_time).positive?
96
+ resolved
97
+ rescue Timeout::Error
98
+ deadline.check!
99
+ domain_error('Redis address resolution timeout exceeded')
100
+ end
101
+
102
+ def finish_tcp_connect(candidate, address, expires_at)
103
+ status = candidate.connect_nonblock(address, exception: false)
104
+ return unless wait_status?(status)
105
+
106
+ wait_for_io(status, io: candidate, expires_at: expires_at, label: 'Redis connect timeout exceeded')
107
+ error = candidate.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int
108
+ raise SystemCallError.new("connect(2) for #{target}", error) unless error.zero?
109
+ rescue Errno::EISCONN
110
+ nil
111
+ end
112
+
113
+ def connect_tls
114
+ context = OpenSSL::SSL::SSLContext.new
115
+ store = OpenSSL::X509::Store.new
116
+ store.set_default_paths
117
+ context.cert_store = store
118
+ context.verify_mode = OpenSSL::SSL::VERIFY_PEER
119
+ context.verify_hostname = true if context.respond_to?(:verify_hostname=)
120
+
121
+ ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, context)
122
+ ssl_socket.sync_close = true
123
+ ssl_socket.hostname = uri.host
124
+ finish_tls_connect(ssl_socket)
125
+ ssl_socket.post_connection_check(uri.host)
126
+ ssl_socket
127
+ rescue StandardError
128
+ ssl_socket&.close
129
+ raise
130
+ end
131
+
132
+ def target
133
+ "#{uri.host}:#{uri.port || 6379}"
134
+ end
135
+
136
+ def redis_command(parts)
137
+ encoded = parts.flat_map { |part| ["$#{part.to_s.bytesize}", part.to_s] }
138
+ (["*#{parts.length}"] + encoded).join(CRLF) + CRLF
139
+ end
140
+
141
+ def read_response(depth = 0)
142
+ prefix = read_exact(1)
143
+ case prefix
144
+ when '+' then read_line
145
+ when '-' then raise RedisCommandError, redactor.redact(read_line)
146
+ when ':' then read_integer('integer')
147
+ when '$' then read_bulk_string
148
+ when '*' then read_array(depth)
149
+ else domain_error("Malformed Redis response prefix #{prefix.inspect}")
150
+ end
151
+ end
152
+
153
+ def read_bulk_string
154
+ length = read_integer('bulk length', nullable: true)
155
+ return nil if length == -1
156
+
157
+ domain_error('Invalid negative Redis bulk length') if length.negative?
158
+ domain_error('Redis bulk response exceeds configured limit') if length > limits.max_bulk_bytes
159
+
160
+ value = read_exact(length)
161
+ domain_error('Malformed Redis bulk response terminator') unless read_exact(2) == CRLF
162
+ value
163
+ end
164
+
165
+ def read_array(depth)
166
+ length = read_integer('array length', nullable: true)
167
+ return nil if length == -1
168
+
169
+ domain_error('Invalid negative Redis array length') if length.negative?
170
+ domain_error('Redis array response exceeds configured element limit') if length > limits.max_array_elements
171
+
172
+ child_depth = depth + 1
173
+ domain_error('Redis response exceeds configured nesting depth') if child_depth > limits.max_resp_depth
174
+ Array.new(length) { read_response(child_depth) }
175
+ end
176
+
177
+ def read_integer(label, nullable: false)
178
+ text = read_line
179
+ domain_error("Malformed Redis #{label}") unless text.match?(/\A-?\d+\z/)
180
+
181
+ value = Integer(text, 10)
182
+ domain_error("Invalid negative Redis #{label}") if value.negative? && (!nullable || value < -1)
183
+ value
184
+ end
185
+
186
+ def read_line
187
+ loop do
188
+ if (terminator = @read_buffer.index(CRLF))
189
+ return @read_buffer.slice!(0, terminator + CRLF.bytesize).delete_suffix(CRLF)
190
+ end
191
+
192
+ fill_buffer
193
+ end
194
+ end
195
+
196
+ def read_exact(length)
197
+ fill_buffer while @read_buffer.bytesize < length
198
+ @read_buffer.slice!(0, length)
199
+ end
200
+
201
+ def fill_buffer
202
+ deadline.check!
203
+ remaining = limits.max_response_bytes - @response_bytes
204
+ domain_error('Redis response exceeds configured byte limit') unless remaining.positive?
205
+
206
+ chunk = read_nonblock([4096, remaining].min)
207
+ @response_bytes += chunk.bytesize
208
+ @read_buffer << chunk
209
+ end
210
+
211
+ def domain_error(message)
212
+ raise Error, redactor.redact(message), cause: nil
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ module Analyzers
5
+ module Dynamic
6
+ module RuntimeReference
7
+ module_function
8
+
9
+ def build(symbol_id:, file: nil, line: nil, definition_id: nil)
10
+ {
11
+ 'definition_id' => definition_id,
12
+ 'symbol_id' => symbol_id,
13
+ 'file' => file,
14
+ 'line' => line
15
+ }.compact
16
+ end
17
+
18
+ def normalize(value)
19
+ return value if value.is_a?(String)
20
+ return unless value.is_a?(Hash)
21
+
22
+ data = value.to_h { |key, item| [key.to_s, item] }
23
+ symbol_id = data['symbol_id']
24
+ return unless symbol_id.is_a?(String) && !symbol_id.empty?
25
+ return unless valid_optional_string?(data, 'definition_id') && valid_optional_string?(data, 'file')
26
+
27
+ line = normalize_line(data['line'])
28
+ return if data.key?('line') && !data['line'].nil? && line.nil?
29
+
30
+ build(
31
+ definition_id: optional_string(data['definition_id']),
32
+ symbol_id: symbol_id,
33
+ file: optional_string(data['file']),
34
+ line: line
35
+ )
36
+ end
37
+
38
+ def sort_key(reference)
39
+ normalized = normalize(reference)
40
+ return ['', reference.to_s, '', 0] unless normalized.is_a?(Hash)
41
+
42
+ [
43
+ normalized.fetch('definition_id', ''),
44
+ normalized.fetch('symbol_id'),
45
+ normalized.fetch('file', ''),
46
+ normalized.fetch('line', 0)
47
+ ]
48
+ end
49
+
50
+ def key(reference)
51
+ sort_key(reference)
52
+ end
53
+
54
+ def preferred(structured:, legacy:)
55
+ return Array(legacy) if structured.nil?
56
+
57
+ structured_values = Array(structured)
58
+ structured_symbols = structured_values.filter_map do |reference|
59
+ normalize(reference).then { |normalized| normalized['symbol_id'] if normalized.is_a?(Hash) }
60
+ end.to_set
61
+ structured_values + Array(legacy).reject { |symbol_id| structured_symbols.include?(symbol_id) }
62
+ end
63
+
64
+ def malformed?(reference)
65
+ !reference.is_a?(String) && normalize(reference).nil?
66
+ end
67
+
68
+ def relative_file(root, path)
69
+ expanded = File.expand_path(path)
70
+ prefix = "#{File.expand_path(root)}/"
71
+ return unless expanded.start_with?(prefix)
72
+
73
+ expanded.delete_prefix(prefix)
74
+ end
75
+
76
+ def optional_string(value)
77
+ value if value.is_a?(String) && !value.empty?
78
+ end
79
+ private_class_method :optional_string
80
+
81
+ def valid_optional_string?(data, key)
82
+ !data.key?(key) || data[key].nil? || (data[key].is_a?(String) && !data[key].empty?)
83
+ end
84
+ private_class_method :valid_optional_string?
85
+
86
+ def normalize_line(value)
87
+ return if value.nil?
88
+
89
+ line = Integer(value, exception: false)
90
+ line if line&.positive?
91
+ end
92
+ private_class_method :normalize_line
93
+ end
94
+ end
95
+ end
96
+ end