overseer-testing-protocol 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.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +19 -0
  3. data/README.md +75 -0
  4. data/SOURCE.json +6 -0
  5. data/bin/overseer-testing-conformance +53 -0
  6. data/docs/testing-control-implementation-guide.md +116 -0
  7. data/docs/testing-control-protocol.md +432 -0
  8. data/lib/overseer/testing_control/conformance/case_file.rb +120 -0
  9. data/lib/overseer/testing_control/conformance/http_transport.rb +114 -0
  10. data/lib/overseer/testing_control/conformance/report.rb +104 -0
  11. data/lib/overseer/testing_control/conformance/runner.rb +783 -0
  12. data/lib/overseer/testing_control/discovery.rb +79 -0
  13. data/lib/overseer/testing_control/json_subset_matcher.rb +120 -0
  14. data/lib/overseer/testing_control/protocol_v3.rb +298 -0
  15. data/lib/overseer/testing_control/redaction.rb +111 -0
  16. data/lib/overseer/testing_protocol.rb +9 -0
  17. data/protocol/testing-control/v3/conformance-case.schema.json +151 -0
  18. data/protocol/testing-control/v3/conformance-report.schema.json +255 -0
  19. data/protocol/testing-control/v3/fixtures/capabilities-response.json +147 -0
  20. data/protocol/testing-control/v3/fixtures/conformance-case.json +23 -0
  21. data/protocol/testing-control/v3/fixtures/conformance-report.json +88 -0
  22. data/protocol/testing-control/v3/fixtures/error-response.json +20 -0
  23. data/protocol/testing-control/v3/fixtures/manifest.json +13 -0
  24. data/protocol/testing-control/v3/fixtures/probe-request.json +20 -0
  25. data/protocol/testing-control/v3/fixtures/probe-response.json +22 -0
  26. data/protocol/testing-control/v3/fixtures/reset-response.json +15 -0
  27. data/protocol/testing-control/v3/fixtures/sink-query-request.json +26 -0
  28. data/protocol/testing-control/v3/fixtures/sink-query-response.json +39 -0
  29. data/protocol/testing-control/v3/fixtures/state-request.json +20 -0
  30. data/protocol/testing-control/v3/fixtures/state-response.json +23 -0
  31. data/protocol/testing-control/v3/openapi.yaml +343 -0
  32. data/protocol/testing-control/v3/schema.json +772 -0
  33. metadata +85 -0
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module Overseer
7
+ module TestingControl
8
+ # A stable semantic projection of a fully validated v3 capabilities document.
9
+ module Discovery
10
+ MAX_CAPABILITIES_PER_FAMILY = 128
11
+ MAX_BYTES = 256 * 1024
12
+ Result = Data.define(:snapshot, :digest, :bytes)
13
+
14
+ class Error < StandardError; end
15
+
16
+ module_function
17
+
18
+ def build(document)
19
+ data = document.fetch('data')
20
+ %w[states probes sinks].each do |family|
21
+ raise Error, "Testing-control discovery exceeds #{family} capability boundary" if
22
+ data.fetch(family).length > MAX_CAPABILITIES_PER_FAMILY
23
+ end
24
+
25
+ snapshot = {
26
+ 'discoveryVersion' => '1',
27
+ 'protocol' => document.fetch('protocol').slice('name', 'version'),
28
+ 'application' => data.fetch('application').slice('id', 'version'),
29
+ 'environment' => data.fetch('environment').slice('kind', 'isolated'),
30
+ 'features' => data.fetch('features').sort,
31
+ 'limits' => data.fetch('limits'),
32
+ 'safety' => data.fetch('safety'),
33
+ 'reset' => data.fetch('reset'),
34
+ 'states' => capabilities(
35
+ data.fetch('states'),
36
+ %w[id version title description idempotency inputSchema outputSchema]
37
+ ),
38
+ 'probes' => capabilities(
39
+ data.fetch('probes'),
40
+ %w[id version title description readOnly inputSchema outputSchema]
41
+ ),
42
+ 'sinks' => capabilities(
43
+ data.fetch('sinks'),
44
+ %w[id version title description effectKinds outcomes queryInputSchema recordSchema]
45
+ )
46
+ }
47
+ source = canonical_json(snapshot)
48
+ raise Error, 'Stable testing-control discovery exceeds its artifact boundary' if source.bytesize > MAX_BYTES
49
+
50
+ Result.new(snapshot:, digest: Digest::SHA256.hexdigest(source), bytes: source.bytesize)
51
+ rescue KeyError => e
52
+ raise Error, "Validated testing-control discovery is incomplete: #{e.message}"
53
+ end
54
+
55
+ def canonical_json(value)
56
+ normalized = case value
57
+ when Hash
58
+ value.keys.sort.to_h { |key| [key, JSON.parse(canonical_json(value.fetch(key)))] }
59
+ when Array
60
+ value.map { |item| JSON.parse(canonical_json(item)) }
61
+ else
62
+ value
63
+ end
64
+ JSON.generate(normalized)
65
+ end
66
+
67
+ def capabilities(items, keys)
68
+ selected_items = items.map do |item|
69
+ selected = item.slice(*keys)
70
+ selected['effectKinds'] = selected.fetch('effectKinds').sort if selected.key?('effectKinds')
71
+ selected['outcomes'] = selected.fetch('outcomes').sort if selected.key?('outcomes')
72
+ selected
73
+ end
74
+ selected_items.sort_by { |item| [item.fetch('id'), item.fetch('version')] }
75
+ end
76
+ private_class_method :capabilities
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Overseer
4
+ module TestingControl
5
+ module JsonSubsetMatcher
6
+ Result = Data.define(:matched, :mismatch) do
7
+ def matched?
8
+ matched
9
+ end
10
+
11
+ def to_h
12
+ {
13
+ 'matched' => matched,
14
+ **(mismatch ? { 'mismatch' => mismatch } : {})
15
+ }
16
+ end
17
+ end
18
+
19
+ class << self
20
+ def call(expected:, actual:)
21
+ mismatch = compare(expected, actual, '')
22
+ Result.new(matched: mismatch.nil?, mismatch: mismatch&.freeze)
23
+ end
24
+
25
+ private
26
+
27
+ def compare(expected, actual, path)
28
+ return compare_object(expected, actual, path) if expected.is_a?(Hash)
29
+ return compare_array(expected, actual, path) if expected.is_a?(Array)
30
+ return if expected == actual
31
+
32
+ mismatch(
33
+ reason: json_kind(expected) == json_kind(actual) ? 'value-mismatch' : 'type-mismatch',
34
+ path:,
35
+ expected_kind: json_kind(expected),
36
+ actual_kind: json_kind(actual)
37
+ )
38
+ end
39
+
40
+ def compare_object(expected, actual, path)
41
+ return type_mismatch(expected, actual, path) unless actual.is_a?(Hash)
42
+
43
+ expected.keys.sort.each do |key|
44
+ child_path = pointer(path, key)
45
+ unless actual.key?(key)
46
+ return mismatch(
47
+ reason: 'missing-key',
48
+ path: child_path,
49
+ expected_kind: json_kind(expected.fetch(key)),
50
+ actual_kind: 'missing'
51
+ )
52
+ end
53
+
54
+ finding = compare(expected.fetch(key), actual.fetch(key), child_path)
55
+ return finding if finding
56
+ end
57
+ nil
58
+ end
59
+
60
+ def compare_array(expected, actual, path)
61
+ return type_mismatch(expected, actual, path) unless actual.is_a?(Array)
62
+ unless expected.length == actual.length
63
+ return mismatch(
64
+ reason: 'array-length-mismatch',
65
+ path:,
66
+ expected_kind: 'array',
67
+ actual_kind: 'array',
68
+ lengths: {
69
+ 'expectedLength' => expected.length,
70
+ 'actualLength' => actual.length
71
+ }
72
+ )
73
+ end
74
+
75
+ expected.each_index do |index|
76
+ finding = compare(expected.fetch(index), actual.fetch(index), pointer(path, index))
77
+ return finding if finding
78
+ end
79
+ nil
80
+ end
81
+
82
+ def type_mismatch(expected, actual, path)
83
+ mismatch(
84
+ reason: 'type-mismatch',
85
+ path:,
86
+ expected_kind: json_kind(expected),
87
+ actual_kind: json_kind(actual)
88
+ )
89
+ end
90
+
91
+ def mismatch(reason:, path:, expected_kind:, actual_kind:, lengths: {})
92
+ {
93
+ 'reason' => reason,
94
+ 'path' => path,
95
+ 'expectedKind' => expected_kind,
96
+ 'actualKind' => actual_kind,
97
+ **lengths
98
+ }
99
+ end
100
+
101
+ def pointer(path, token)
102
+ escaped = token.to_s.gsub('~', '~0').gsub('/', '~1')
103
+ "#{path}/#{escaped}"
104
+ end
105
+
106
+ def json_kind(value)
107
+ case value
108
+ when Hash then 'object'
109
+ when Array then 'array'
110
+ when String then 'string'
111
+ when Numeric then 'number'
112
+ when TrueClass, FalseClass then 'boolean'
113
+ when NilClass then 'null'
114
+ else 'unknown'
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require 'json_schemer'
6
+
7
+ module Overseer
8
+ module TestingControl
9
+ module ProtocolV3
10
+ NAME = 'overseer-testing-control'
11
+ VERSION = '3'
12
+ PACKAGE_VERSION = '0.1.0'
13
+ SCHEMA_SHA256 = '9e0591133fd38c15e96383eae11b202cb27ea8f7ddc98d2a64f2abff326cb924'
14
+ CAPABILITY_PATTERN = /\A[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*\z/
15
+ CAPABILITY_VERSION_PATTERN = /\A[1-9][0-9]{0,31}\z/
16
+ JSON_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema'
17
+ IDENTIFIER_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z/
18
+ REQUIRED_FEATURES = %w[
19
+ correlation-propagation
20
+ deterministic-replay
21
+ reset
22
+ schema-validation
23
+ sink-clearing
24
+ ].freeze
25
+ HEADERS = {
26
+ version: 'Overseer-Testing-Control-Version',
27
+ run_id: 'Overseer-Run-Id',
28
+ correlation_id: 'Overseer-Correlation-Id',
29
+ step_id: 'Overseer-Step-Id',
30
+ idempotency_key: 'Overseer-Idempotency-Key'
31
+ }.freeze
32
+ PATHS = {
33
+ capabilities: '/capabilities',
34
+ reset: '/reset',
35
+ states: '/states',
36
+ probes: '/probes',
37
+ sink_query: '/sinks/query'
38
+ }.freeze
39
+ SCHEMA_PATH = File.expand_path(
40
+ '../../../protocol/testing-control/v3/schema.json',
41
+ __dir__
42
+ )
43
+ OPENAPI_PATH = File.join(File.dirname(SCHEMA_PATH), 'openapi.yaml')
44
+ DOCUMENT_DEFINITIONS = {
45
+ capabilities: 'capabilitiesResponse',
46
+ reset: 'resetResponse',
47
+ state_request: 'stateRequest',
48
+ state_response: 'stateResponse',
49
+ probe_request: 'probeRequest',
50
+ probe_response: 'probeResponse',
51
+ sink_query_request: 'sinkQueryRequest',
52
+ sink_query_response: 'sinkQueryResponse',
53
+ error: 'errorResponse'
54
+ }.freeze
55
+
56
+ module_function
57
+
58
+ def protocol
59
+ { 'name' => NAME, 'version' => VERSION }
60
+ end
61
+
62
+ def meta(run_id:, correlation_id:, step_id: nil)
63
+ {
64
+ 'runId' => run_id,
65
+ 'correlationId' => correlation_id,
66
+ **(step_id ? { 'stepId' => step_id } : {})
67
+ }
68
+ end
69
+
70
+ def envelope(data:, **identity)
71
+ { 'protocol' => protocol, 'meta' => meta(**identity), 'data' => data }
72
+ end
73
+
74
+ def error_envelope(errors:, **identity)
75
+ { 'protocol' => protocol, 'meta' => meta(**identity), 'errors' => errors }
76
+ end
77
+
78
+
79
+ def validate_schema!(schema, label:, max_bytes:)
80
+ bytes = JSON.generate(schema).bytesize
81
+ raise ValidationError, "#{label} schema exceeds #{max_bytes} bytes" if bytes > max_bytes
82
+ unless schema.is_a?(Hash) && schema['$schema'] == JSON_SCHEMA_DIALECT && schema['type'] == 'object'
83
+ raise ValidationError, "#{label} must be a Draft 2020-12 schema with an object root"
84
+ end
85
+ raise ValidationError, "#{label} schema contains a non-local reference" unless only_local_references?(schema)
86
+
87
+ errors = JSONSchemer.validate_schema(schema).take(3)
88
+ raise ValidationError, "#{label} schema is invalid: #{error_details(errors)}" unless errors.empty?
89
+
90
+ JSONSchemer.schema(schema).validate({}).to_a
91
+ schema
92
+ rescue JSONSchemer::UnknownRef, JSONSchemer::InvalidRefResolution, JSONSchemer::InvalidRefPointer => e
93
+ raise ValidationError, "#{label} schema cannot be compiled: #{e.message}"
94
+ end
95
+
96
+
97
+ def capability_id!(value, label:)
98
+ return value if value.is_a?(String) && value.bytesize <= 96 && value.match?(CAPABILITY_PATTERN)
99
+
100
+ raise ValidationError, "#{label} must be a valid capability identifier"
101
+ end
102
+
103
+ def capability_version!(value, label:)
104
+ return value if value.is_a?(String) && value.match?(CAPABILITY_VERSION_PATTERN)
105
+
106
+ raise ValidationError, "#{label} must be a positive decimal version"
107
+ end
108
+
109
+ def application_version!(value)
110
+ return value if value.is_a?(String) && value.bytesize.between?(1, 128)
111
+
112
+ raise ValidationError, 'application source identity must be between 1 and 128 bytes'
113
+ end
114
+
115
+ def timestamp
116
+ Time.now.utc.iso8601(3)
117
+ end
118
+
119
+ def stringify(value)
120
+ case value
121
+ when Hash
122
+ value.to_h { |key, child| [key.to_s, stringify(child)] }
123
+ when Array
124
+ value.map { |child| stringify(child) }
125
+ else
126
+ value
127
+ end
128
+ end
129
+
130
+
131
+ def validate_document!(kind, value)
132
+ errors = document_schemer(kind).validate(value).take(3)
133
+ return value if errors.empty?
134
+
135
+ raise Error, "Invalid protocol v3 #{kind} response: #{error_details(errors)}"
136
+ end
137
+
138
+ def validate_response_headers!(headers, correlation_id:)
139
+ version = header_value(headers, HEADERS.fetch(:version))
140
+ correlation = header_value(headers, HEADERS.fetch(:correlation_id))
141
+ raise Error, 'Protocol v3 response version header must equal 3' unless version == VERSION
142
+ raise Error, 'Protocol v3 response correlation header is invalid' unless
143
+ correlation&.match?(IDENTIFIER_PATTERN)
144
+ raise Error, 'Protocol v3 response correlation does not match the request' unless
145
+ correlation == correlation_id
146
+ end
147
+
148
+ def validate_meta!(document, run_id:, correlation_id:, step_id: nil)
149
+ meta = document.fetch('meta')
150
+ return if meta['runId'] == run_id &&
151
+ meta['correlationId'] == correlation_id &&
152
+ meta['stepId'] == step_id &&
153
+ meta.key?('stepId') == !step_id.nil?
154
+
155
+ raise Error, 'Protocol v3 response metadata does not match the request identity'
156
+ end
157
+
158
+ def validate_capabilities!(document, required:)
159
+ validate_document!(:capabilities, document)
160
+ data = document.fetch('data')
161
+ validate_required_features!(data.fetch('features'))
162
+ validate_capability_family!('state', data.fetch('states'), data.dig('limits', 'maxSchemaBytes')) do |item|
163
+ [item.fetch('inputSchema'), item.fetch('outputSchema')]
164
+ end
165
+ validate_capability_family!('probe', data.fetch('probes'), data.dig('limits', 'maxSchemaBytes')) do |item|
166
+ [item.fetch('inputSchema'), item.fetch('outputSchema')]
167
+ end
168
+ validate_capability_family!('sink', data.fetch('sinks'), data.dig('limits', 'maxSchemaBytes')) do |item|
169
+ [item.fetch('queryInputSchema'), item.fetch('recordSchema')]
170
+ end
171
+ validate_required_capabilities!(data, required)
172
+ document
173
+ end
174
+
175
+ def validate_value!(schema, value, label:)
176
+ errors = JSONSchemer.schema(schema).validate(value).take(3)
177
+ return value if errors.empty?
178
+
179
+ raise Error, "#{label} does not satisfy its advertised schema: #{error_details(errors)}"
180
+ end
181
+
182
+ def identifier!(value, label:)
183
+ return value if value.is_a?(String) && value.match?(IDENTIFIER_PATTERN)
184
+
185
+ raise Error, "#{label} must be a valid opaque identifier"
186
+ end
187
+
188
+ def document_schemer(kind)
189
+ definition = DOCUMENT_DEFINITIONS.fetch(kind)
190
+ @document_schemers ||= {}
191
+ @document_schemers[definition] ||= root_schemer.ref("#/$defs/#{definition}")
192
+ end
193
+ private_class_method :document_schemer
194
+
195
+ def root_schemer
196
+ @root_schemer ||= JSONSchemer.schema(JSON.parse(File.read(SCHEMA_PATH, encoding: 'UTF-8')))
197
+ end
198
+ private_class_method :root_schemer
199
+
200
+ def validate_required_features!(features)
201
+ missing = REQUIRED_FEATURES - features
202
+ return if missing.empty?
203
+
204
+ raise Error, "Protocol v3 capabilities omit required features: #{missing.join(', ')}"
205
+ end
206
+ private_class_method :validate_required_features!
207
+
208
+ def validate_capability_family!(kind, capabilities, max_schema_bytes)
209
+ seen = {}
210
+ capabilities.each do |capability|
211
+ key = "#{capability.fetch('id')}@#{capability.fetch('version')}"
212
+ raise Error, "Duplicate #{kind} capability #{key}" if seen[key]
213
+
214
+ seen[key] = true
215
+ yield(capability).each do |schema|
216
+ validate_advertised_schema!(schema, label: "#{kind} #{key}", max_bytes: max_schema_bytes)
217
+ end
218
+ end
219
+ end
220
+ private_class_method :validate_capability_family!
221
+
222
+ def validate_advertised_schema!(schema, label:, max_bytes:)
223
+ bytes = JSON.generate(schema).bytesize
224
+ raise Error, "#{label} schema exceeds maxSchemaBytes #{max_bytes}" if bytes > max_bytes
225
+ raise Error, "#{label} schema contains a non-local reference" unless only_local_references?(schema)
226
+
227
+ errors = JSONSchemer.validate_schema(schema).take(3)
228
+ raise Error, "#{label} schema is invalid: #{error_details(errors)}" unless errors.empty?
229
+
230
+ compile_schema!(schema, label:)
231
+ end
232
+ private_class_method :validate_advertised_schema!
233
+
234
+ def only_local_references?(value)
235
+ references(value).all? { |reference| reference.is_a?(String) && reference.start_with?('#') }
236
+ end
237
+ private_class_method :only_local_references?
238
+
239
+ def references(value)
240
+ case value
241
+ when Hash
242
+ own = value.filter_map do |key, child|
243
+ child if %w[$ref $dynamicRef].include?(key)
244
+ end
245
+ own + value.values.flat_map { |child| references(child) }
246
+ when Array
247
+ value.flat_map { |child| references(child) }
248
+ else
249
+ []
250
+ end
251
+ end
252
+ private_class_method :references
253
+
254
+ def compile_schema!(schema, label:)
255
+ JSONSchemer.schema(schema).validate({}).to_a
256
+ rescue StandardError => e
257
+ raise Error, "#{label} schema cannot be compiled: #{e.message}"
258
+ end
259
+ private_class_method :compile_schema!
260
+
261
+ def validate_required_capabilities!(data, required)
262
+ {
263
+ 'state' => ['states', required.fetch('states')],
264
+ 'probe' => ['probes', required.fetch('probes')],
265
+ 'sink' => ['sinks', required.fetch('sinks')]
266
+ }.each do |kind, (family, expected)|
267
+ advertised = data.fetch(family)
268
+ expected.each do |id, version|
269
+ next if advertised.any? do |capability|
270
+ capability['id'] == id && capability['version'] == version
271
+ end
272
+
273
+ raise Error, "Required testing #{kind} #{id}@#{version} is unavailable"
274
+ end
275
+ end
276
+ end
277
+ private_class_method :validate_required_capabilities!
278
+
279
+ def header_value(headers, name)
280
+ headers.each do |candidate, value|
281
+ return Array(value).first if candidate.to_s.casecmp?(name)
282
+ end
283
+ nil
284
+ end
285
+ private_class_method :header_value
286
+
287
+ def error_details(errors)
288
+ errors.map do |error|
289
+ "#{error.fetch('data_pointer', '/')} #{error.fetch('type')}"
290
+ end.join('; ')
291
+ end
292
+ private_class_method :error_details
293
+
294
+ class Error < StandardError; end
295
+ ValidationError = Error
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Overseer
6
+ module TestingControl
7
+ module Redaction
8
+ REDACTED = '<redacted>'
9
+ REDACTED_EMAIL = '<redacted-email>'
10
+ JWT_PATTERN = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/
11
+ BEARER_PATTERN = %r{\bBearer\s+[A-Za-z0-9._~+/-]+=*}i
12
+ EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i
13
+
14
+ Policy = Data.define(:header_names, :field_patterns, :max_body_bytes, :exact_values) do
15
+ def initialize(exact_values: [], **values)
16
+ safe = exact_values.select do |value|
17
+ value.is_a?(String) && !value.empty?
18
+ end
19
+ safe = safe.uniq.sort_by { |value| -value.bytesize }
20
+ super(**values, exact_values: safe.freeze)
21
+ end
22
+
23
+ def self.from_config(config, exact_values: [])
24
+ new(
25
+ header_names: config.fetch('headerNames').map(&:downcase).freeze,
26
+ field_patterns: config.fetch('fieldPatterns').map(&:downcase).freeze,
27
+ max_body_bytes: config.fetch('maxBodyBytes'),
28
+ exact_values:
29
+ )
30
+ end
31
+
32
+ def with_exact_values(values)
33
+ self.class.new(
34
+ header_names:,
35
+ field_patterns:,
36
+ max_body_bytes:,
37
+ exact_values: exact_values + Array(values)
38
+ )
39
+ end
40
+ end
41
+
42
+ module_function
43
+
44
+ def value(value, policy, key: '')
45
+ return REDACTED if sensitive_key?(key, policy)
46
+
47
+ case value
48
+ when String
49
+ string(value, policy)
50
+ when Array
51
+ value.map { |item| self.value(item, policy) }
52
+ when Hash
53
+ value.to_h do |entry_key, entry_value|
54
+ [safe_hash_key(entry_key, policy), self.value(entry_value, policy, key: entry_key.to_s)]
55
+ end
56
+ else
57
+ value
58
+ end
59
+ end
60
+
61
+ def string(value, policy = nil)
62
+ safe = value
63
+ .gsub(JWT_PATTERN, REDACTED)
64
+ .gsub(BEARER_PATTERN, "Bearer #{REDACTED}")
65
+ .gsub(EMAIL_PATTERN) do |email|
66
+ email.downcase.end_with?('.invalid') ? email : REDACTED_EMAIL
67
+ end
68
+ Array(policy&.exact_values).reduce(safe) { |text, exact| text.gsub(exact, '<redacted-generated-secret>') }
69
+ end
70
+
71
+ def headers(headers, policy)
72
+ headers.to_h do |name, value|
73
+ normalized_name = name.to_s.downcase
74
+ normalized_value = Array(value).join(', ')
75
+ safe_name = string(normalized_name, policy)
76
+ safe_value = if policy.header_names.include?(normalized_name)
77
+ REDACTED
78
+ else
79
+ string(normalized_value, policy)
80
+ end
81
+ [safe_name, safe_value]
82
+ end
83
+ end
84
+
85
+ def body(value, policy)
86
+ safe = self.value(value, policy)
87
+ serialized = JSON.generate(safe)
88
+ return safe if serialized.bytesize <= policy.max_body_bytes
89
+
90
+ {
91
+ 'truncated' => true,
92
+ 'originalBytes' => serialized.bytesize,
93
+ 'retainedBytes' => policy.max_body_bytes,
94
+ 'preview' => string(serialized.byteslice(0, policy.max_body_bytes).to_s.scrub, policy)
95
+ }
96
+ end
97
+
98
+ def sensitive_key?(key, policy)
99
+ normalized = key.downcase
100
+ policy.field_patterns.each { |pattern| return true if normalized.include?(pattern) }
101
+ false
102
+ end
103
+ private_class_method :sensitive_key?
104
+
105
+ def safe_hash_key(key, policy)
106
+ key.is_a?(String) ? string(key, policy) : key
107
+ end
108
+ private_class_method :safe_hash_key
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'testing_control/protocol_v3'
4
+ require_relative 'testing_control/discovery'
5
+ require_relative 'testing_control/redaction'
6
+ require_relative 'testing_control/conformance/case_file'
7
+ require_relative 'testing_control/conformance/http_transport'
8
+ require_relative 'testing_control/conformance/report'
9
+ require_relative 'testing_control/conformance/runner'