debugbundle 2.0.0 → 2.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +133 -0
  3. data/lib/debugbundle/acknowledgement.rb +6 -2
  4. data/lib/debugbundle/before_send.rb +58 -30
  5. data/lib/debugbundle/bounded_http_response.rb +24 -0
  6. data/lib/debugbundle/canonical_event_schema.rb +137 -0
  7. data/lib/debugbundle/client.rb +55 -75
  8. data/lib/debugbundle/client_event_support.rb +11 -12
  9. data/lib/debugbundle/client_evidence_support.rb +169 -0
  10. data/lib/debugbundle/client_exception_support.rb +54 -0
  11. data/lib/debugbundle/client_process_support.rb +5 -12
  12. data/lib/debugbundle/client_queue_support.rb +59 -4
  13. data/lib/debugbundle/config.rb +15 -2
  14. data/lib/debugbundle/diagnostic_budget.rb +155 -0
  15. data/lib/debugbundle/event-envelope.schema.json +4904 -0
  16. data/lib/debugbundle/event_batch.rb +48 -0
  17. data/lib/debugbundle/evidence_delivery.rb +50 -0
  18. data/lib/debugbundle/evidence_graph.rb +73 -0
  19. data/lib/debugbundle/evidence_json.rb +55 -0
  20. data/lib/debugbundle/evidence_resources.rb +60 -0
  21. data/lib/debugbundle/evidence_value.rb +107 -0
  22. data/lib/debugbundle/evidence_wire.rb +193 -0
  23. data/lib/debugbundle/exception_evidence.rb +179 -0
  24. data/lib/debugbundle/framework_operation.rb +13 -0
  25. data/lib/debugbundle/operation_context.rb +90 -0
  26. data/lib/debugbundle/rack/middleware.rb +24 -21
  27. data/lib/debugbundle/rack/relay_middleware.rb +4 -1
  28. data/lib/debugbundle/rails/railtie.rb +4 -1
  29. data/lib/debugbundle/rails/relay_endpoint.rb +2 -1
  30. data/lib/debugbundle/relay/forwarder.rb +83 -0
  31. data/lib/debugbundle/relay/handler.rb +83 -19
  32. data/lib/debugbundle/remote_config.rb +5 -0
  33. data/lib/debugbundle/sidekiq/server_middleware.rb +22 -18
  34. data/lib/debugbundle/telemetry_privacy.rb +9 -1
  35. data/lib/debugbundle/transport.rb +49 -19
  36. data/lib/debugbundle/version.rb +1 -1
  37. data/lib/debugbundle.rb +11 -1
  38. metadata +19 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 154069d58e3649c8b90ab0b5567749fef0b0ae27756354467d2f54e347c20bdb
4
- data.tar.gz: 10364db06a596a352b0b96cdfda8b54fe6ec228ab2a4682b89740d38ca0f2a09
3
+ metadata.gz: fa08dbcf220cb84131821cf546272d4d08d6f15f01ba4e8c7f41f77fbc53a1c0
4
+ data.tar.gz: fe1a46ab795df53b5dd1ac5bee6684b43ea35501d031a345b52c576e9a8881f9
5
5
  SHA512:
6
- metadata.gz: 529aa156d0361a75d06a6b9c1f1dd9950269c1c0c82a97367436a6987a8546c0f9274631c1027e0e5585147b18e34ce5a441eca453891a442e105617370f373f
7
- data.tar.gz: 3d768b54c0ca11c5ed227bbe02ece3ad61a40a77597028366e900c1cc42a4e4777bb93fc837dfc2eeee030e610c0756993fb363f0d2eab5e17457b34b0c66e02
6
+ metadata.gz: 69e7d24b4c4615dc8ff2da958c90427c0a19d9f781629ff31f9e2918912e97f4298d6a44bba5fd2f19e56cef5fcb426796ed26e7f71b73eefb6384eaa26e0ca7
7
+ data.tar.gz: 190296bc690b90b3113a59b99c7062ed5f3f95705bb633b4998577d884743f5f64570a727ca81334fb45fe7c3d02f13be4d38cda714244cca79998ad94a2887d
data/README.md CHANGED
@@ -10,6 +10,114 @@ Version 2.0 changes capture and hook timing. Existing installed 1.5.x applicatio
10
10
 
11
11
  `make sdk-safety-perf` runs a Docker-backed host-safety budget with 10,000 filtered INFO calls, 1,000 accepted ERROR calls with and without custom privacy fields, and 10,000 concurrent ERROR calls against a full queue while transport is held. CI and the release workflow run this gate before publication. These bounded synthetic checks supplement, but do not replace, installed runtime and workload qualification.
12
12
 
13
+ `make evidence-performance` compares the same exception/recovery calls with
14
+ evidence disabled and enabled, including a full warning queue. It reports capture
15
+ time, allocated Ruby objects and retained bytes, and verifies original-error
16
+ priority and the existing queue/diagnostic ceilings. Setup and cleanup are excluded;
17
+ the existing `sdk-safety-perf` timing limits remain unchanged.
18
+
19
+ ## Unreleased evidence candidate
20
+
21
+ The working candidate adds structured exception evidence and explicit recovery;
22
+ it is not a published release. Enrichment requires a valid capability descriptor
23
+ from the destination on an existing config/ingestion response. No extra discovery
24
+ request is made. Unknown paths keep ordinary exception capture. The optional
25
+ `evidence_capabilities: { version: 1, features: [...] }` pin asserts that every
26
+ receiver, relay, spool/local processor and reader on that path has been qualified;
27
+ it does not upgrade any receiver. `capture_exception_evidence: false` disables
28
+ the new exception fields independently.
29
+
30
+ `capture_recovery_observations` defaults to `false`. With destination proof and
31
+ the option enabled, report application-observed outcomes inside an operation:
32
+
33
+ ```ruby
34
+ require "securerandom"
35
+
36
+ client = DebugBundle::Client.new(
37
+ project_token: ENV.fetch("DEBUGBUNDLE_TOKEN"),
38
+ capture_recovery_observations: true
39
+ )
40
+
41
+ client.with_operation_context(kind: "job", name: "asset.prepare", limit_ms: 5000) do
42
+ begin
43
+ prepare_asset
44
+ rescue => error
45
+ failure_ref = SecureRandom.uuid
46
+ attempt_id = SecureRandom.uuid
47
+ client.capture_exception(error, context: { failure_ref: failure_ref })
48
+ client.capture_recovery(failure_ref: failure_ref, attempt_id: attempt_id, state: "attempt_started")
49
+ result = application_recovery
50
+ client.capture_recovery(failure_ref: failure_ref, attempt_id: attempt_id, state: "attempt_completed")
51
+ client.capture_recovery(
52
+ failure_ref: failure_ref, attempt_id: attempt_id,
53
+ state: result.usable? ? "recovery_observed" : "failure_persisted"
54
+ )
55
+ end
56
+ end
57
+ ```
58
+
59
+ The application owns recovery and its success check. `attempt_completed` and a
60
+ successful HTTP status do not establish recovery. `attempt_failed` records a
61
+ failed attempt. Observations need no subsequent exception and do not open or
62
+ resolve incidents. Use fresh UUID-v4 failure, attempt and scope IDs. For reporting
63
+ outside the active operation, provide the same explicit `evidence_scope` at
64
+ failure capture and in each observation. Page scopes additionally require the
65
+ same nonblank session correlation; the string `"0"` is valid. Recovery accepts a
66
+ Hash or keyword fields; scope/context Hash keys may be strings or symbols.
67
+
68
+ Operations retain only bounded kind/name/elapsed/known-limit and correlation
69
+ metadata. They preserve the block's return value and exceptions. Rack/Rails
70
+ request and Sidekiq job capture runs inside its owned scope; other Fibers/threads
71
+ do not inherit it. Completed scopes and process resets revoke ownership. Graphs
72
+ use retained scalar exception snapshots, with no additional custom rendering or
73
+ source/local-variable inspection. Ruby's linear cause path has at most five
74
+ nodes and 32 frames per node, within a combined 32 KiB evidence budget. Fatal
75
+ hooks use at most two nodes, eight frames per node and 4 KiB of total new evidence;
76
+ delivery remains best-effort at process exit. Original sanitised stacks remain.
77
+ Engine metadata is preserved under `payload.runtime.framework_extras`.
78
+
79
+ Diagnostic metadata and pending events share 64 KiB, at most 50 failure windows,
80
+ five attempts and 20 observations per window, and a five-minute capture window.
81
+ Policy `capture_diagnostic_context: false` and the minimal preset disable recovery.
82
+ Canonical `before_send` replacements may change identity and event type; the SDK
83
+ reapplies privacy, policy, path proof and bounded ownership. An acknowledged
84
+ capability downgrade may retry an original exception once without optional
85
+ evidence, using the same identity and no second hook invocation.
86
+
87
+ Hook replacements pass the complete vendored canonical schema and its conditional
88
+ frontend identity checks before and after mandatory privacy. Invalid replacements
89
+ fall back to the protected original; explicit `nil` still drops an event. Existing
90
+ queue-pressure aggregate `reason` and `level` fields remain supported.
91
+
92
+ Outgoing requests stay within the receiver's 256 KiB body limit, counting actual
93
+ escaped UTF-8 JSON. The sender keeps ordered byte-sized batches and schedules
94
+ remaining work on its existing worker. Acknowledgement indices apply to each
95
+ submitted chunk. Oversized optional evidence is removed first; a valid hook
96
+ replacement that still cannot fit is withheld without restoring pre-hook content.
97
+ Invalid or throwing hooks retain the established safe-original fallback. Flush remains a bounded,
98
+ best-effort wait; queued tails can continue after it returns. Local file transport
99
+ keeps its existing bounded ownership and file semantics; the HTTP body limit
100
+ does not narrow admissible local captures.
101
+
102
+ Native HTTP config/ACK replies request identity encoding and retain at most
103
+ 256 KiB before JSON parsing. Existing native connect/read timeouts remain five
104
+ seconds; relay chunks and downgrade retries share one five-second monotonic budget,
105
+ passed to native response reads and remaining connect/read timeouts. A body deadline
106
+ is checked between streamed chunks. Native DNS/socket waits remain advisory. Explicit
107
+ `flush` still waits at most five seconds. `close` stops this process's sender,
108
+ discards queued work and retains active-send accounting until completion. A
109
+ forked child follows the existing fresh-worker contract. `dispose` permanently
110
+ retires a client across process generations; singleton `DebugBundle.init` disposes
111
+ its superseded client. Reinstall explicitly configured logger/integration
112
+ bindings for a replacement client. Independently constructed clients remain
113
+ available for applications that intentionally own multiple projects.
114
+
115
+ Rack and Rails browser relays preserve validated evidence and server-owned
116
+ credentials. Downstream chunks account for server metadata expansion; a durable
117
+ spool is marked delivered only when every chunk has a complete canonical
118
+ acknowledgement. Legacy custom transport success remains compatible and cannot
119
+ create a delivered marker. Final installed/runtime qualification is in progress.
120
+
13
121
  ## Automatic capture and application filtering
14
122
 
15
123
  `capture_logger(logger)` respects the logger level, Rails silence and disabled output. Lazy message blocks are evaluated by the native logger: zero times when suppressed and once when accepted. Native return values and application block exceptions are preserved. DebugBundle applies its own configured minimum capture level after the logger accepts a message.
@@ -368,3 +476,28 @@ The repository ships a GitHub Actions release workflow at `.github/workflows/rel
368
476
  ## License
369
477
 
370
478
  Apache-2.0. See LICENSE.
479
+
480
+ ## Delivery acknowledgement and retry limits
481
+
482
+ The built-in HTTP transport requires the canonical ingestion acknowledgement (`accepted`, `rejected`, and `errors`). Empty responses, unrelated JSON, malformed counts, or invalid rejection indices retain the full batch with backoff. Valid acknowledgements remove accepted and terminally rejected events and retry only the indexed retryable rejections; an all-rejected batch does not advance `lastEventAt`.
483
+
484
+ File transports and explicitly supplied custom/legacy transports retain their documented bodyless success fallback. A custom transport that returns acknowledgement fields must return the complete canonical shape. Retry hints (delay seconds or HTTP dates) are bounded to five minutes and measured from response receipt, including partial/protocol acknowledgements and retryable server failures. Without a server hint, existing retry timing is preserved. Failures remain contained within the SDK's existing delivery path.
485
+
486
+ ## Probe checkpoint guidance
487
+
488
+ Application instrumentation supplies probe labels and bounded data. Deploy a new
489
+ checkpoint before activating it. Remote activation affects future matching calls
490
+ when policy permits; it does not inject code or recover unretained measurements.
491
+ Built-in runtime/error/device collectors keep their documented SDK settings.
492
+
493
+ Use fixed labels and allowlisted technical states: `media.preview.checkpoint`
494
+ (stage/input kind/attempt), `auth.session.checkpoint` (observed session presence
495
+ and result), `job.dependency.checkpoint` (named dependency/attempt/result), or
496
+ `cache.preview.checkpoint` (hit/miss/coarse age bucket). Omit customer identifiers,
497
+ tokens/cookies, cache keys, URLs and raw payloads. HTTP 200 or attempt completion
498
+ alone does not establish semantic recovery.
499
+
500
+ Empty probe data means no matching retained observation for the selected sample
501
+ window. It does not confirm activation delivery to every SDK or execution of a
502
+ label. Check the deployed SDK/wrapper version, instrumentation, scope, expiry,
503
+ policy, sampling and delivery receipts. See the [probe recipes](https://debugbundle.com/docs/probes#bounded-checkpoint-recipes).
@@ -11,8 +11,8 @@ module DebugBundle
11
11
 
12
12
  module_function
13
13
 
14
- def decide(body, batch_length)
15
- return { kind: :legacy } unless body.is_a?(Hash) && FIELDS.any? { |field| body.key?(field) }
14
+ def decide(body, batch_length, required: false)
15
+ return missing_acknowledgement(required) unless body.is_a?(Hash) && FIELDS.any? { |field| body.key?(field) }
16
16
  return protocol_failure unless FIELDS.all? { |field| body.key?(field) }
17
17
 
18
18
  accepted = body['accepted']
@@ -55,5 +55,9 @@ module DebugBundle
55
55
  def protocol_failure
56
56
  { kind: :protocol_failure }
57
57
  end
58
+
59
+ def missing_acknowledgement(required)
60
+ required ? protocol_failure : { kind: :legacy }
61
+ end
58
62
  end
59
63
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'time'
4
+ require 'debugbundle/evidence_wire'
5
+ require 'debugbundle/canonical_event_schema'
4
6
 
5
7
  module DebugBundle
6
8
  module BeforeSend
@@ -12,10 +14,12 @@ module DebugBundle
12
14
  'frontend_exception' => %w[name message stack],
13
15
  'deploy_metadata' => %w[commit_sha version branch environment deployed_at],
14
16
  'error_suppressed' => %w[fingerprint suppressed_count window_seconds first_seen last_seen],
15
- 'probe_event' => %w[label data activation_id probe_label_pattern]
17
+ 'probe_event' => %w[label data activation_id probe_label_pattern],
18
+ 'diagnostic_context' => %w[version kind failure_ref scope]
16
19
  }.freeze
17
20
  ALLOWED_PAYLOAD_FIELDS = {
18
- 'backend_exception' => %w[name message stack handled request response runtime probe_data],
21
+ 'backend_exception' => %w[name message stack handled request response runtime probe_data exception_evidence
22
+ execution],
19
23
  'request_event' => %w[
20
24
  method path query headers body response_status duration_ms route_template
21
25
  response_headers response_body device
@@ -24,17 +28,20 @@ module DebugBundle
24
28
  'frontend_breadcrumb' => %w[breadcrumb_type route data device],
25
29
  'frontend_exception' => %w[
26
30
  name message stack route browser breadcrumbs device browser_event
27
- rejection_reason dom_context probe_data
31
+ rejection_reason dom_context probe_data exception_evidence resource_observations
28
32
  ],
29
33
  'deploy_metadata' => %w[commit_sha version branch environment deployed_at],
30
34
  'error_suppressed' => %w[fingerprint suppressed_count window_seconds first_seen last_seen reason level device],
31
- 'probe_event' => %w[label data activation_id probe_label_pattern device]
35
+ 'probe_event' => %w[label data activation_id probe_label_pattern device],
36
+ 'diagnostic_context' => %w[version kind failure_ref scope attempt_id state operation status_code duration_ms
37
+ source_event_id observations]
32
38
  }.freeze
33
39
  ROOT_FIELDS = %w[
34
40
  schema_version event_id event_type project_token project_id sdk_name sdk_version
35
41
  service occurred_at correlation context payload
36
42
  ].freeze
37
- UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
43
+ UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
44
+ MOBILE_SDK_NAMES = %w[@debugbundle/sdk-android @debugbundle/sdk-swift @debugbundle/sdk-react-native].freeze
38
45
 
39
46
  module_function
40
47
 
@@ -44,35 +51,56 @@ module DebugBundle
44
51
  result = hook.call(Marshal.load(Marshal.dump(event)))
45
52
  return nil if result.nil?
46
53
 
47
- valid?(result) ? result : event
54
+ checked = normalization_result(result)
55
+ return nil if checked.equal?(EvidenceValue::EXHAUSTED)
56
+
57
+ checked || event
48
58
  rescue StandardError
49
59
  event
50
60
  end
51
61
 
52
62
  def valid?(event)
53
- return false unless event.is_a?(Hash)
54
- return false unless (event.keys - ROOT_FIELDS).empty?
55
- return false unless %w[schema_version event_id event_type occurred_at sdk_name sdk_version].all? do |field|
56
- event[field].is_a?(String) && !event[field].empty?
57
- end
58
- return false unless UUID_PATTERN.match?(event['event_id'])
63
+ !normalized(event).nil?
64
+ end
65
+
66
+ def normalized(event)
67
+ result = normalization_result(event)
68
+ EvidenceValue.type?(result, Hash) ? result : nil
69
+ end
70
+
71
+ def normalization_result(event)
72
+ return nil unless EvidenceValue.type?(event, Hash)
73
+ return nil if Hash.instance_method(:size).bind_call(event) > ROOT_FIELDS.length
74
+
75
+ # Local/queued events keep their established ceiling independently of
76
+ # the smaller evidence DTO and HTTP budgets enforced at those boundaries.
77
+ owned = EvidenceValue.copy_with_budget(event, symbol_keys: true, max_bytes: 8 * 1024 * 1024,
78
+ max_string_bytes: 8 * 1024 * 1024)
79
+ return owned if owned.equal?(EvidenceValue::EXHAUSTED)
80
+ return nil unless EvidenceValue.type?(owned, Hash)
81
+ return nil unless CanonicalEventSchema.valid?(owned) && EvidenceWire.valid_optional?(owned)
82
+ return nil unless required_frontend_context?(owned)
83
+
84
+ owned
85
+ rescue StandardError
86
+ nil
87
+ end
88
+
89
+ # The canonical Zod contract refines this conditional outside its shape;
90
+ # the generated JSON Schema alone cannot establish frontend identity.
91
+ def required_frontend_context?(event)
92
+ return true unless event['event_type'] == 'frontend_exception'
59
93
 
60
- Time.iso8601(event['occurred_at'])
61
- service = event['service']
62
94
  payload = event['payload']
63
- fields = REQUIRED_PAYLOAD_FIELDS[event['event_type']]
64
- allowed_fields = ALLOWED_PAYLOAD_FIELDS[event['event_type']]
65
- service.is_a?(Hash) &&
66
- service['name'].is_a?(String) && !service['name'].empty? &&
67
- service['environment'].is_a?(String) && !service['environment'].empty? &&
68
- payload.is_a?(Hash) &&
69
- fields &&
70
- allowed_fields &&
71
- (payload.keys - allowed_fields).empty? &&
72
- fields.all? { |field| payload.key?(field) } &&
73
- valid_payload_shape?(event['event_type'], payload)
74
- rescue ArgumentError
75
- false
95
+ MOBILE_SDK_NAMES.include?(event['sdk_name']) ? payload['device'].is_a?(Hash) : payload.key?('browser')
96
+ end
97
+
98
+ # Existing shape helpers stay available to integrations. Full acceptance
99
+ # above uses the vendored canonical contract, including nested fields.
100
+ def valid_identity?(event)
101
+ %w[schema_version event_id event_type occurred_at sdk_name sdk_version].all? do |field|
102
+ event[field].is_a?(String) && !event[field].empty?
103
+ end && UUID_PATTERN.match?(event['event_id']) && EvidenceWire.timestamp(event['occurred_at'])
76
104
  end
77
105
 
78
106
  def valid_payload_shape?(event_type, payload)
@@ -93,6 +121,8 @@ module DebugBundle
93
121
  valid_error_suppressed?(payload)
94
122
  when 'probe_event'
95
123
  valid_probe_event?(payload)
124
+ when 'diagnostic_context'
125
+ EvidenceWire.diagnostic(payload)
96
126
  else
97
127
  false
98
128
  end
@@ -160,9 +190,7 @@ module DebugBundle
160
190
  end
161
191
 
162
192
  def timestamp?(value)
163
- value.is_a?(String) && Time.iso8601(value)
164
- rescue ArgumentError
165
- false
193
+ EvidenceWire.timestamp(value)
166
194
  end
167
195
 
168
196
  def nullable_uuid?(value)
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DebugBundle
4
+ # Native HTTP adapters request identity encoding and stream existing replies.
5
+ # This cap precedes JSON parsing; no extra discovery request is introduced.
6
+ module BoundedHttpResponse
7
+ class LimitExceeded < StandardError; end
8
+
9
+ module_function
10
+
11
+ def read(deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5)
12
+ body = +''
13
+ response = yield lambda { |chunk|
14
+ raise LimitExceeded if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
15
+ raise LimitExceeded if body.bytesize + chunk.bytesize > 262_144
16
+
17
+ body << chunk
18
+ }
19
+ raise LimitExceeded if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
20
+
21
+ [response, body]
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'debugbundle/evidence_wire'
5
+
6
+ module DebugBundle
7
+ # A small closed interpreter for the vendored accepting Draft 7 contract.
8
+ # No remote refs, application serializers or runtime dependency are needed.
9
+ # Unknown schema keywords fail closed when a future contract is generated.
10
+ class CanonicalEventSchema
11
+ KEYWORDS = %w[$schema $id $comment $ref type anyOf const enum properties required additionalProperties
12
+ items minimum maximum exclusiveMinimum minLength maxLength minItems maxItems format pattern].freeze
13
+ TYPES = %w[null boolean number integer string array object].freeze
14
+ LOAD_MUTEX = Mutex.new
15
+
16
+ def self.valid?(value)
17
+ unless defined?(@engine)
18
+ LOAD_MUTEX.synchronize do
19
+ unless defined?(@engine)
20
+ begin
21
+ @engine = new(JSON.parse(File.read(File.join(__dir__, 'event-envelope.schema.json'))))
22
+ rescue StandardError
23
+ @engine = nil
24
+ end
25
+ end
26
+ end
27
+ end
28
+ !!(@engine && @engine.accepts?(value))
29
+ rescue StandardError
30
+ false
31
+ end
32
+
33
+ def initialize(schema)
34
+ @root = schema
35
+ @references = {}
36
+ @patterns = {}
37
+ compile(schema)
38
+ end
39
+
40
+ def compile(node)
41
+ return if [true, false].include?(node)
42
+
43
+ raise ArgumentError, 'unsupported schema keyword' unless (node.keys - KEYWORDS).empty?
44
+ raise ArgumentError, 'unsupported schema type' unless (Array(node['type']) - TYPES).empty?
45
+ raise ArgumentError, 'unsupported schema format' unless [nil, 'uuid', 'date-time'].include?(node['format'])
46
+
47
+ @patterns[node['pattern']] ||= Regexp.new(node['pattern']) if node.key?('pattern')
48
+ resolve(node['$ref']) if node.key?('$ref')
49
+ node.fetch('properties', {}).each_value { |child| compile(child) }
50
+ node.fetch('anyOf', []).each { |child| compile(child) }
51
+ %w[items additionalProperties].each { |key| compile(node[key]) if node.key?(key) }
52
+ end
53
+
54
+ def resolve(reference)
55
+ raise ArgumentError, 'external schema ref' unless reference.start_with?('#/')
56
+
57
+ @references[reference] ||= reference.delete_prefix('#/').split('/').reduce(@root) do |node, part|
58
+ node.fetch(node.is_a?(Array) ? Integer(part) : part.gsub('~1', '/').gsub('~0', '~'))
59
+ end
60
+ end
61
+
62
+ def accepts?(value)
63
+ check(@root, value, { steps: 100_000 }, 0)
64
+ rescue StandardError
65
+ false
66
+ end
67
+
68
+ def check(node, value, budget, depth)
69
+ budget[:steps] -= 1
70
+ return false if depth > 64 || budget[:steps].negative?
71
+ return node if [true, false].include?(node)
72
+ return check(resolve(node['$ref']), value, budget, depth + 1) if node.key?('$ref')
73
+
74
+ return false if node.key?('anyOf') && node['anyOf'].none? { |child| check(child, value, budget, depth + 1) }
75
+ return false if node.key?('type') && Array(node['type']).none? { |type| matches_type?(value, type) }
76
+ return false if node.key?('const') && value != node['const']
77
+ return false if node.key?('enum') && !node['enum'].include?(value)
78
+
79
+ case value
80
+ when Hash then object?(node, value, budget, depth)
81
+ when Array then array?(node, value, budget, depth)
82
+ when String then string?(node, value)
83
+ when Integer, Float then number?(node, value)
84
+ else true
85
+ end
86
+ end
87
+
88
+ def matches_type?(value, type)
89
+ case type
90
+ when 'null' then value.nil?
91
+ when 'boolean' then [true, false].include?(value)
92
+ when 'string' then value.is_a?(String)
93
+ when 'object' then value.is_a?(Hash)
94
+ when 'array' then value.is_a?(Array)
95
+ when 'number' then (value.is_a?(Integer) || value.is_a?(Float)) && value.to_f.finite?
96
+ when 'integer' then matches_type?(value, 'number') && value == value.floor
97
+ else false
98
+ end
99
+ end
100
+
101
+ def object?(node, value, budget, depth)
102
+ return false unless node.fetch('required', []).all? { |key| value.key?(key) }
103
+
104
+ properties = node.fetch('properties', {})
105
+ value.all? do |key, child|
106
+ if properties.key?(key)
107
+ check(properties[key], child, budget, depth + 1)
108
+ else
109
+ check(node.fetch('additionalProperties', true), child, budget, depth + 1)
110
+ end
111
+ end
112
+ end
113
+
114
+ def array?(node, value, budget, depth)
115
+ return false if node.key?('minItems') && value.length < node['minItems']
116
+ return false if node.key?('maxItems') && value.length > node['maxItems']
117
+
118
+ !node.key?('items') || value.all? { |child| check(node['items'], child, budget, depth + 1) }
119
+ end
120
+
121
+ def string?(node, value)
122
+ return false if node.key?('minLength') && value.length < node['minLength']
123
+ return false if node.key?('maxLength') && value.length > node['maxLength']
124
+ return false if node.key?('pattern') && !@patterns.fetch(node['pattern']).match?(value)
125
+ return EvidenceWire.uuid(value) if node['format'] == 'uuid'
126
+ return EvidenceWire.timestamp(value) if node['format'] == 'date-time'
127
+
128
+ true
129
+ end
130
+
131
+ def number?(node, value)
132
+ (!node.key?('minimum') || value >= node['minimum']) &&
133
+ (!node.key?('maximum') || value <= node['maximum']) &&
134
+ (!node.key?('exclusiveMinimum') || value > node['exclusiveMinimum'])
135
+ end
136
+ end
137
+ end