necropsy 0.1.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +33 -0
  3. data/CHANGELOG.md +44 -0
  4. data/MEASUREMENTS.md +112 -0
  5. data/README.md +233 -16
  6. data/Rakefile +3 -1
  7. data/bench/README.md +92 -0
  8. data/bench/audit.rb +113 -0
  9. data/bench/audits/0.2.1/audit.json +501 -0
  10. data/bench/audits/0.2.1/audit.md +55 -0
  11. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  12. data/bench/audits/0.2.1/config.yml +42 -0
  13. data/bench/audits/0.2.1/review.yml +56 -0
  14. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  15. data/bench/corpora/v1/README.md +30 -0
  16. data/bench/corpora/v1/labels.yml +37 -0
  17. data/bench/corpora/v1/manifest.yml +63 -0
  18. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  19. data/bench/corpora/v1/self.necropsy.yml +8 -0
  20. data/bench/corpora/v1/tools/debride.yml +42 -0
  21. data/bench/corpora/v1/tools/spoom.yml +12 -0
  22. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  23. data/bench/golden/v1/candidate_union.json +21918 -0
  24. data/bench/golden/v1/metadata.json +12 -0
  25. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  26. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  27. data/bench/golden/v1/reports/rails.json +176 -0
  28. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  29. data/bench/golden/v1/reports/self.json +3449 -0
  30. data/bench/review_queue.rb +35 -0
  31. data/bench/run.rb +31 -0
  32. data/bench/schema/candidate-union-v1.schema.json +70 -0
  33. data/docs/impv_implementation_matrix.md +179 -0
  34. data/docs/migrations/0.2.1.md +57 -0
  35. data/docs/migrations/0.3.0.md +207 -0
  36. data/docs/migrations/0.4.0.md +13 -0
  37. data/docs/necropsy_performance_adr.md +43 -0
  38. data/docs/necropsy_scope_decisions.md +58 -0
  39. data/docs/necropsy_type_facts_adr.md +22 -0
  40. data/gemfiles/prism_min.gemfile +9 -0
  41. data/gemfiles/prism_min.gemfile.lock +50 -0
  42. data/lib/necropsy/analyzer.rb +121 -2
  43. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +81 -19
  44. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
  45. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
  46. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  47. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  50. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  51. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  52. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  53. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +225 -20
  54. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  55. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  56. data/lib/necropsy/analyzers/static/cha.rb +33 -69
  57. data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
  58. data/lib/necropsy/analyzers/static/rta.rb +249 -25
  59. data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
  60. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  61. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  62. data/lib/necropsy/ast_scanner/dsl_macros.rb +532 -0
  63. data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
  64. data/lib/necropsy/ast_scanner/references.rb +115 -0
  65. data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
  66. data/lib/necropsy/ast_scanner/traversal.rb +347 -0
  67. data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
  68. data/lib/necropsy/ast_scanner.rb +89 -564
  69. data/lib/necropsy/bench/candidate_union.rb +555 -0
  70. data/lib/necropsy/bench/claim_gate.rb +112 -0
  71. data/lib/necropsy/bench/evaluator.rb +356 -24
  72. data/lib/necropsy/bench/finding_facts.rb +152 -0
  73. data/lib/necropsy/bench/precision_gate.rb +144 -0
  74. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  75. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  76. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  77. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  78. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  79. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  80. data/lib/necropsy/bench/release_audit.rb +360 -0
  81. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  82. data/lib/necropsy/bench/review_queue.rb +154 -0
  83. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  84. data/lib/necropsy/bench/seed_runner.rb +408 -0
  85. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  86. data/lib/necropsy/cache/scan_cache.rb +94 -18
  87. data/lib/necropsy/call_site_identity.rb +54 -0
  88. data/lib/necropsy/cli.rb +296 -53
  89. data/lib/necropsy/clock.rb +40 -0
  90. data/lib/necropsy/confidence/scorer.rb +181 -58
  91. data/lib/necropsy/configuration.rb +339 -18
  92. data/lib/necropsy/convention_rules.rb +138 -0
  93. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  94. data/lib/necropsy/definition_identity.rb +37 -0
  95. data/lib/necropsy/diagnostics.rb +342 -0
  96. data/lib/necropsy/embedded_ruby.rb +55 -0
  97. data/lib/necropsy/entry_points/plain.rb +125 -7
  98. data/lib/necropsy/entry_points/rails.rb +422 -105
  99. data/lib/necropsy/entry_points/test.rb +6 -1
  100. data/lib/necropsy/flow_interpreter.rb +460 -0
  101. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  102. data/lib/necropsy/graph/call_graph.rb +1234 -62
  103. data/lib/necropsy/graph/definition_index.rb +149 -0
  104. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  105. data/lib/necropsy/graph/evidence_store.rb +213 -0
  106. data/lib/necropsy/graph/resolution_store.rb +497 -0
  107. data/lib/necropsy/graph_self_check.rb +79 -0
  108. data/lib/necropsy/guardrail/baseline.rb +359 -13
  109. data/lib/necropsy/guardrail/diff.rb +5 -2
  110. data/lib/necropsy/guardrail/quarantine.rb +101 -10
  111. data/lib/necropsy/load_graph.rb +206 -0
  112. data/lib/necropsy/models.rb +894 -21
  113. data/lib/necropsy/performance_profiler.rb +108 -0
  114. data/lib/necropsy/project.rb +361 -13
  115. data/lib/necropsy/reachability/engine.rb +80 -15
  116. data/lib/necropsy/reference_barrier.rb +458 -0
  117. data/lib/necropsy/report.rb +153 -14
  118. data/lib/necropsy/reporter.rb +441 -19
  119. data/lib/necropsy/runner.rb +258 -14
  120. data/lib/necropsy/runtime_feedback.rb +136 -0
  121. data/lib/necropsy/semantics_matrix.rb +153 -0
  122. data/lib/necropsy/trace_point_runtime.rb +19 -0
  123. data/lib/necropsy/type_facts.rb +53 -0
  124. data/lib/necropsy/version.rb +1 -1
  125. data/lib/necropsy/why_not_explanation.rb +436 -0
  126. data/lib/necropsy/why_not_renderer.rb +197 -0
  127. data/lib/necropsy/world_policy.rb +90 -0
  128. data/lib/necropsy.rb +39 -2
  129. data/schema/necropsy-report-v2.schema.json +366 -0
  130. data/script/measure.rb +20 -0
  131. metadata +106 -2
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ module Analyzers
5
+ module Dynamic
6
+ class CoverbandPayloadSet
7
+ PAYLOAD_KEYS = %w[files coverage runtime production nodes executed observation].freeze
8
+
9
+ def initialize(limits:, deadline:)
10
+ @limits = limits
11
+ @deadline = deadline
12
+ end
13
+
14
+ def merge(payloads)
15
+ payloads.compact.reduce({}) do |merged, payload|
16
+ validate_payload_hash!(payload)
17
+ deadline.check!
18
+ limits.validate_payload!(merge_payload(merged, payload))
19
+ end
20
+ end
21
+
22
+ def from_hash_entries(entries, &)
23
+ domain_error('Malformed Redis hash response') unless entries.is_a?(Array) && entries.length.even?
24
+
25
+ hash = entries.each_slice(2).to_h
26
+ return coverband_hash_payload(hash) if coverband_hash_payload?(hash)
27
+
28
+ generic_hash_payload(hash, &)
29
+ end
30
+
31
+ def payload_shape?(value)
32
+ value.is_a?(Hash) && value.keys.any? { |key| PAYLOAD_KEYS.include?(key.to_s) }
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :limits, :deadline
38
+
39
+ def generic_hash_payload(hash)
40
+ payloads = []
41
+ files = {}
42
+ hash.each do |field, value|
43
+ deadline.check!
44
+ parsed = yield(value)
45
+ if payload_shape?(parsed)
46
+ payloads << parsed
47
+ elsif parsed.is_a?(Hash) || parsed.is_a?(Array)
48
+ files[field.to_s] = parsed
49
+ else
50
+ domain_error('Invalid Redis coverage payload value')
51
+ end
52
+ end
53
+
54
+ payloads << { 'files' => files } unless files.empty?
55
+ merge(payloads)
56
+ end
57
+
58
+ def coverband_hash_payload?(hash)
59
+ hash.key?('file') && hash.key?('file_length')
60
+ end
61
+
62
+ def coverband_hash_payload(hash)
63
+ payload = {
64
+ 'files' => { hash.fetch('file') => coverband_line_counts(hash) },
65
+ 'observation' => coverband_observation(hash)
66
+ }
67
+ limits.validate_payload!(payload)
68
+ end
69
+
70
+ def coverband_line_counts(hash)
71
+ length = Integer(hash.fetch('file_length'), exception: false)
72
+ domain_error('Invalid Coverband file length') unless length && length >= 0
73
+ domain_error('Coverband file length exceeds configured limit') if length > limits.max_array_elements
74
+
75
+ Array.new(length) do |index|
76
+ deadline.check!
77
+ count = hash[index.to_s]
78
+ count&.to_i
79
+ end
80
+ end
81
+
82
+ def coverband_observation(hash)
83
+ %w[first_updated_at last_updated_at file_hash].each_with_object({}) do |key, observation|
84
+ observation[key] = hash[key] if hash[key]
85
+ end
86
+ end
87
+
88
+ def validate_payload_hash!(payload)
89
+ domain_error('Redis coverage payload must be a mapping') unless payload.is_a?(Hash)
90
+
91
+ limits.validate_payload!(payload)
92
+ end
93
+
94
+ def merge_payload(merged, payload)
95
+ merged.merge(payload) do |key, left, right|
96
+ case key.to_s
97
+ when 'files', 'coverage', 'runtime', 'production' then merge_files(left, right)
98
+ when 'nodes', 'executed' then (Array(left) + Array(right)).uniq
99
+ when 'edges' then Array(left) + Array(right)
100
+ when 'observation' then ObservationPolicy.compatible_merge(left, right)
101
+ else right
102
+ end
103
+ end
104
+ end
105
+
106
+ def merge_files(left, right)
107
+ return right unless left.is_a?(Hash)
108
+ return left unless right.is_a?(Hash)
109
+
110
+ left.merge(right) do |_file, left_lines, right_lines|
111
+ merge_file_coverage(left_lines, right_lines)
112
+ end
113
+ end
114
+
115
+ def merge_file_coverage(left, right)
116
+ return merge_count_hashes(left, right) if left.is_a?(Hash) && right.is_a?(Hash)
117
+ return merge_count_arrays(left, right) if count_array?(left) || count_array?(right)
118
+
119
+ (Array(left) + Array(right)).uniq
120
+ end
121
+
122
+ def merge_count_hashes(left, right)
123
+ left.merge(right) { |_line, left_count, right_count| left_count.to_i + right_count.to_i }
124
+ end
125
+
126
+ def merge_count_arrays(left, right)
127
+ length = [Array(left).length, Array(right).length].max
128
+ domain_error('Merged Redis coverage exceeds configured collection size') if length > limits.max_array_elements
129
+
130
+ Array.new(length) do |index|
131
+ left_value = Array(left)[index]
132
+ right_value = Array(right)[index]
133
+ next if left_value.nil? && right_value.nil?
134
+
135
+ left_value.to_i + right_value.to_i
136
+ end
137
+ end
138
+
139
+ def count_array?(value)
140
+ value.is_a?(Array) && value.any? { |item| item.nil? || item.to_i.zero? }
141
+ end
142
+
143
+ def domain_error(message)
144
+ raise Error, message
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ module Analyzers
5
+ module Dynamic
6
+ module ObservationPolicy
7
+ module_function
8
+
9
+ def metadata(payload = nil, expected_revision: nil, **keyword_payload)
10
+ payload ||= keyword_payload
11
+ observation = payload['observation']
12
+ observation = {} unless observation.is_a?(Hash)
13
+ schema_version = Integer(payload.fetch('schema_version', 1), exception: false)
14
+ raise Error, 'Dynamic observation schema version must be 1 or 2' unless [1, 2].include?(schema_version)
15
+
16
+ revision = source_revision(payload, observation)
17
+
18
+ status = source_revision_status(payload, observation, revision, expected_revision)
19
+ normalized = observation.merge(
20
+ 'schema_version' => schema_version,
21
+ 'positive_evidence_policy' => 'alive_only',
22
+ 'source_revision_status' => status,
23
+ 'source_revision_policy' => 'accepted_for_liveness_only'
24
+ )
25
+ normalized = normalized.merge('source_revision' => revision) if revision
26
+ normalized = normalized.merge('expected_source_revision' => expected_revision) if expected_revision
27
+ return normalized unless schema_version == 2
28
+
29
+ collector = payload['collector']
30
+ collector = {} unless collector.is_a?(Hash)
31
+ source = payload['source']
32
+ source = {} unless source.is_a?(Hash)
33
+ scope = payload['scope']
34
+ scope = {} unless scope.is_a?(Hash)
35
+ quality = payload['quality']
36
+ quality = {} unless quality.is_a?(Hash)
37
+ normalized.merge(
38
+ 'collector_name' => collector['name'] || payload['collector_name'],
39
+ 'collector_version' => collector['version'] || payload['collector_version'],
40
+ 'source_metadata' => source,
41
+ 'environment' => scope['environment'] || observation['environment'],
42
+ 'sample_unit' => scope['sample_unit'] || observation['sample_unit'],
43
+ 'sample_rate' => scope['sample_rate'] || observation['sample_rate'],
44
+ 'quality' => {
45
+ 'dropped_events' => quality.fetch('dropped_events', 0),
46
+ 'overflowed' => quality.fetch('overflowed', false)
47
+ }
48
+ ).compact
49
+ end
50
+
51
+ def compatible_merge(left, right)
52
+ return left.merge(right) unless left.is_a?(Hash) && right.is_a?(Hash)
53
+
54
+ %w[source_revision source_revision_status environment collector_name collector_version].each do |key|
55
+ next if left[key].nil? || right[key].nil? || left[key] == right[key]
56
+
57
+ raise Error, "Dynamic observation #{key} is incompatible"
58
+ end
59
+ left.merge(right)
60
+ end
61
+
62
+ def evidence_scope(observation)
63
+ declared_scope = observation['scope']
64
+ declared_scope = {} unless declared_scope.is_a?(Hash)
65
+ declared_scope = declared_scope.reject do |key, _value|
66
+ %w[revision source_revision].include?(key.to_s)
67
+ end
68
+ selectors = {
69
+ 'revision' => observation['source_revision'],
70
+ 'source_revision_status' => observation['source_revision_status'],
71
+ 'environment' => observation['environment'],
72
+ 'collector' => observation['collector']
73
+ }.compact
74
+ declared_scope.merge(selectors)
75
+ end
76
+
77
+ def source_revision(payload, observation)
78
+ source = payload['source']
79
+ source_revision = source['git_sha'] if source.is_a?(Hash)
80
+ source_revision || payload['source_revision'] || observation['source_revision']
81
+ end
82
+ private_class_method :source_revision
83
+
84
+ def source_revision_status(payload, observation, revision, expected_revision)
85
+ stale = payload['stale'] || observation['stale'] || payload['source_revision_status'] == 'stale'
86
+ return 'stale' if stale
87
+ return 'unknown' unless revision
88
+ return 'provided_unverified' unless expected_revision
89
+
90
+ revision.to_s == expected_revision.to_s ? 'match' : 'mismatch'
91
+ end
92
+ private_class_method :source_revision_status
93
+ end
94
+ end
95
+ end
96
+ end
@@ -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