signalwire-sdk 2.0.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 (85) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +259 -0
  4. data/bin/swaig-test +872 -0
  5. data/lib/signalwire/agent/agent_base.rb +2134 -0
  6. data/lib/signalwire/contexts/context_builder.rb +861 -0
  7. data/lib/signalwire/core/logging_config.rb +54 -0
  8. data/lib/signalwire/datamap/data_map.rb +315 -0
  9. data/lib/signalwire/logging.rb +92 -0
  10. data/lib/signalwire/pom/prompt_object_model.rb +269 -0
  11. data/lib/signalwire/pom/section.rb +202 -0
  12. data/lib/signalwire/prefabs/concierge.rb +92 -0
  13. data/lib/signalwire/prefabs/faq_bot.rb +67 -0
  14. data/lib/signalwire/prefabs/info_gatherer.rb +79 -0
  15. data/lib/signalwire/prefabs/receptionist.rb +74 -0
  16. data/lib/signalwire/prefabs/survey.rb +75 -0
  17. data/lib/signalwire/relay/action.rb +291 -0
  18. data/lib/signalwire/relay/call.rb +523 -0
  19. data/lib/signalwire/relay/client.rb +789 -0
  20. data/lib/signalwire/relay/constants.rb +124 -0
  21. data/lib/signalwire/relay/message.rb +137 -0
  22. data/lib/signalwire/relay/relay_event.rb +670 -0
  23. data/lib/signalwire/rest/http_client.rb +159 -0
  24. data/lib/signalwire/rest/namespaces/addresses.rb +19 -0
  25. data/lib/signalwire/rest/namespaces/calling.rb +179 -0
  26. data/lib/signalwire/rest/namespaces/chat.rb +18 -0
  27. data/lib/signalwire/rest/namespaces/compat.rb +229 -0
  28. data/lib/signalwire/rest/namespaces/datasphere.rb +39 -0
  29. data/lib/signalwire/rest/namespaces/fabric.rb +235 -0
  30. data/lib/signalwire/rest/namespaces/imported_numbers.rb +18 -0
  31. data/lib/signalwire/rest/namespaces/logs.rb +46 -0
  32. data/lib/signalwire/rest/namespaces/lookup.rb +18 -0
  33. data/lib/signalwire/rest/namespaces/mfa.rb +26 -0
  34. data/lib/signalwire/rest/namespaces/number_groups.rb +32 -0
  35. data/lib/signalwire/rest/namespaces/phone_numbers.rb +124 -0
  36. data/lib/signalwire/rest/namespaces/project.rb +33 -0
  37. data/lib/signalwire/rest/namespaces/pubsub.rb +18 -0
  38. data/lib/signalwire/rest/namespaces/queues.rb +28 -0
  39. data/lib/signalwire/rest/namespaces/recordings.rb +18 -0
  40. data/lib/signalwire/rest/namespaces/registry.rb +67 -0
  41. data/lib/signalwire/rest/namespaces/short_codes.rb +26 -0
  42. data/lib/signalwire/rest/namespaces/sip_profile.rb +22 -0
  43. data/lib/signalwire/rest/namespaces/verified_callers.rb +24 -0
  44. data/lib/signalwire/rest/namespaces/video.rb +129 -0
  45. data/lib/signalwire/rest/pagination.rb +89 -0
  46. data/lib/signalwire/rest/phone_call_handler.rb +56 -0
  47. data/lib/signalwire/rest/rest_client.rb +114 -0
  48. data/lib/signalwire/runtime.rb +98 -0
  49. data/lib/signalwire/security/session_manager.rb +124 -0
  50. data/lib/signalwire/security/webhook_middleware.rb +191 -0
  51. data/lib/signalwire/security/webhook_validator.rb +327 -0
  52. data/lib/signalwire/server/agent_server.rb +413 -0
  53. data/lib/signalwire/serverless/lambda_handler.rb +251 -0
  54. data/lib/signalwire/skills/builtin/api_ninjas_trivia.rb +99 -0
  55. data/lib/signalwire/skills/builtin/claude_skills.rb +92 -0
  56. data/lib/signalwire/skills/builtin/custom_skills.rb +54 -0
  57. data/lib/signalwire/skills/builtin/datasphere.rb +153 -0
  58. data/lib/signalwire/skills/builtin/datasphere_serverless.rb +107 -0
  59. data/lib/signalwire/skills/builtin/datetime.rb +97 -0
  60. data/lib/signalwire/skills/builtin/google_maps.rb +168 -0
  61. data/lib/signalwire/skills/builtin/info_gatherer.rb +189 -0
  62. data/lib/signalwire/skills/builtin/joke.rb +65 -0
  63. data/lib/signalwire/skills/builtin/math.rb +176 -0
  64. data/lib/signalwire/skills/builtin/mcp_gateway.rb +121 -0
  65. data/lib/signalwire/skills/builtin/native_vector_search.rb +116 -0
  66. data/lib/signalwire/skills/builtin/play_background_file.rb +86 -0
  67. data/lib/signalwire/skills/builtin/spider.rb +169 -0
  68. data/lib/signalwire/skills/builtin/swml_transfer.rb +118 -0
  69. data/lib/signalwire/skills/builtin/weather_api.rb +92 -0
  70. data/lib/signalwire/skills/builtin/web_search.rb +141 -0
  71. data/lib/signalwire/skills/builtin/wikipedia_search.rb +125 -0
  72. data/lib/signalwire/skills/skill_base.rb +82 -0
  73. data/lib/signalwire/skills/skill_manager.rb +97 -0
  74. data/lib/signalwire/skills/skill_registry.rb +258 -0
  75. data/lib/signalwire/swaig/function_result.rb +777 -0
  76. data/lib/signalwire/swml/document.rb +84 -0
  77. data/lib/signalwire/swml/schema.json +12250 -0
  78. data/lib/signalwire/swml/schema.rb +81 -0
  79. data/lib/signalwire/swml/service.rb +650 -0
  80. data/lib/signalwire/utils/schema_utils.rb +298 -0
  81. data/lib/signalwire/utils/serverless.rb +19 -0
  82. data/lib/signalwire/utils/url_validator.rb +138 -0
  83. data/lib/signalwire/version.rb +5 -0
  84. data/lib/signalwire.rb +114 -0
  85. metadata +225 -0
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2025 SignalWire
4
+ #
5
+ # Licensed under the MIT License.
6
+ # See LICENSE file in the project root for full license information.
7
+ #
8
+ # Rack middleware for SignalWire webhook signature validation.
9
+ #
10
+ # This adapter wraps {SignalWire::Security::WebhookValidator} so it can be
11
+ # inserted into any Rack pipeline (Rails, Sinatra, plain Rack).
12
+ #
13
+ # Why a custom middleware rather than vanilla Rack?
14
+ #
15
+ # - We MUST capture the raw bytes BEFORE any JSON / form parser consumes
16
+ # the stream — re-serialization changes whitespace and key order, which
17
+ # breaks the Scheme A digest. The middleware reads ``rack.input``, then
18
+ # rewinds the IO and stashes the bytes on
19
+ # ``env['signalwire.raw_body']`` so downstream handlers can re-parse
20
+ # without re-reading the stream.
21
+ # - Reverse-proxy / ngrok deployments need the URL the platform POSTed
22
+ # to, which differs from the URL the SDK sees. The middleware honors
23
+ # ``X-Forwarded-Proto`` / ``X-Forwarded-Host`` when ``trust_proxy``
24
+ # is true, plus the ``SWML_PROXY_URL_BASE`` env var, with the request
25
+ # URL as last resort.
26
+ # - The legacy cXML/Compatibility scheme used the ``X-Twilio-Signature``
27
+ # header. We accept it as an alias of ``X-SignalWire-Signature`` so users
28
+ # migrating from the legacy SDK can keep their callers unchanged.
29
+ #
30
+ # Usage::
31
+ #
32
+ # use SignalWire::Security::WebhookMiddleware,
33
+ # signing_key: ENV['SIGNALWIRE_SIGNING_KEY'],
34
+ # trust_proxy: true,
35
+ # paths: ['/', '/swaig', '/post_prompt']
36
+
37
+ require 'rack'
38
+ require_relative 'webhook_validator'
39
+
40
+ module SignalWire
41
+ module Security
42
+ # Rack middleware that rejects webhook requests with bad signatures.
43
+ #
44
+ # Configure with the customer's Signing Key (and optional ``trust_proxy``
45
+ # to honor X-Forwarded headers). Mount upstream of any body-parsing
46
+ # middleware so the raw bytes survive intact.
47
+ class WebhookMiddleware
48
+ SIGNALWIRE_SIGNATURE_HEADER = 'HTTP_X_SIGNALWIRE_SIGNATURE'
49
+ TWILIO_COMPAT_SIGNATURE_HEADER = 'HTTP_X_TWILIO_SIGNATURE'
50
+
51
+ # Key under which the captured raw body is stashed on the request env.
52
+ RAW_BODY_ENV_KEY = 'signalwire.raw_body'
53
+
54
+ # @param app [#call] the wrapped Rack app.
55
+ # @param signing_key [String] customer Signing Key (required, non-empty).
56
+ # @param trust_proxy [Boolean] honor X-Forwarded-Proto / X-Forwarded-Host
57
+ # when reconstructing the request URL. Default false — proxy headers
58
+ # are spoofable, so opt in only when you control the proxy.
59
+ # @param paths [Array<String>, nil] when set, only apply the validation
60
+ # on these PATH_INFO values; everything else passes through. When nil,
61
+ # apply to every request.
62
+ # @param methods [Array<String>, nil] limit to these HTTP methods. When
63
+ # nil, apply to every method.
64
+ #
65
+ # @raise [ArgumentError] when ``signing_key`` is missing.
66
+ def initialize(app, signing_key:, trust_proxy: false, paths: nil, methods: ['POST'])
67
+ raise ArgumentError, 'signing_key is required' if signing_key.nil? || signing_key.to_s.empty?
68
+
69
+ @app = app
70
+ @signing_key = signing_key
71
+ @trust_proxy = trust_proxy
72
+ @paths = paths.nil? ? nil : Array(paths).map(&:to_s)
73
+ @methods = methods.nil? ? nil : Array(methods).map { |m| m.to_s.upcase }
74
+ end
75
+
76
+ # @api private
77
+ def call(env)
78
+ return @app.call(env) unless _applies?(env)
79
+
80
+ # Capture raw body BEFORE any other middleware reads the stream.
81
+ raw_body = _read_raw_body(env)
82
+ env[RAW_BODY_ENV_KEY] = raw_body
83
+
84
+ signature = _extract_signature_header(env)
85
+ return _forbidden if signature.nil? || signature.empty?
86
+
87
+ url = _reconstruct_url(env)
88
+
89
+ valid = begin
90
+ WebhookValidator.validate_webhook_signature(@signing_key, signature, url, raw_body)
91
+ rescue ArgumentError, TypeError
92
+ # Programming errors at the boundary — never leak which branch
93
+ # tripped. Reject the request without raising.
94
+ return _forbidden
95
+ end
96
+
97
+ return _forbidden unless valid
98
+
99
+ @app.call(env)
100
+ end
101
+
102
+ private
103
+
104
+ # @api private
105
+ def _applies?(env)
106
+ if @methods
107
+ method = env['REQUEST_METHOD'].to_s.upcase
108
+ return false unless @methods.include?(method)
109
+ end
110
+ if @paths
111
+ path = env['PATH_INFO'].to_s
112
+ # Exact match — paths is intentionally a tight allowlist.
113
+ return false unless @paths.include?(path)
114
+ end
115
+ true
116
+ end
117
+
118
+ # @api private
119
+ def _read_raw_body(env)
120
+ input = env['rack.input']
121
+ return '' if input.nil?
122
+
123
+ body = input.read
124
+ # Rewind so downstream middleware / handlers can read the body too.
125
+ # Some Rack inputs (e.g. Tempfile-backed) are seekable; StringIO is
126
+ # always rewindable.
127
+ input.rewind if input.respond_to?(:rewind)
128
+ body.to_s
129
+ rescue StandardError
130
+ ''
131
+ end
132
+
133
+ # @api private
134
+ def _extract_signature_header(env)
135
+ sig = env[SIGNALWIRE_SIGNATURE_HEADER]
136
+ sig = env[TWILIO_COMPAT_SIGNATURE_HEADER] if sig.nil? || sig.empty?
137
+ sig
138
+ end
139
+
140
+ # @api private
141
+ # Reconstruct the public URL SignalWire POSTed to.
142
+ #
143
+ # Resolution order (highest priority first):
144
+ # 1. ``SWML_PROXY_URL_BASE`` env var (joined with the request path + query).
145
+ # 2. ``X-Forwarded-Proto`` / ``X-Forwarded-Host`` headers, when
146
+ # ``trust_proxy`` is true and X-Forwarded-Host is present.
147
+ # 3. ``scheme://host[:port]/path?query`` derived from the rack env.
148
+ def _reconstruct_url(env)
149
+ path = env['PATH_INFO'].to_s
150
+ path = '/' if path.empty?
151
+ query = env['QUERY_STRING'].to_s
152
+ path_and_query = query.empty? ? path : "#{path}?#{query}"
153
+
154
+ proxy_base = ENV['SWML_PROXY_URL_BASE']
155
+ return "#{proxy_base.sub(%r{/+\z}, '')}#{path_and_query}" if proxy_base && !proxy_base.empty?
156
+
157
+ if @trust_proxy
158
+ fwd_host = env['HTTP_X_FORWARDED_HOST']
159
+ fwd_proto = env['HTTP_X_FORWARDED_PROTO'] || 'https'
160
+ if fwd_host && !fwd_host.empty?
161
+ return "#{fwd_proto}://#{fwd_host}#{path_and_query}"
162
+ end
163
+ end
164
+
165
+ scheme = env['rack.url_scheme'] || 'http'
166
+ host = env['HTTP_HOST'] || env['SERVER_NAME']
167
+ port = env['SERVER_PORT']
168
+ # Only include port if it's non-standard AND not already in HTTP_HOST.
169
+ host_with_port =
170
+ if host && host.include?(':')
171
+ host
172
+ elsif port && (
173
+ (scheme == 'http' && port.to_s != '80') ||
174
+ (scheme == 'https' && port.to_s != '443')
175
+ )
176
+ "#{host}:#{port}"
177
+ else
178
+ host
179
+ end
180
+
181
+ "#{scheme}://#{host_with_port}#{path_and_query}"
182
+ end
183
+
184
+ # @api private
185
+ def _forbidden
186
+ # No body detail — never leak which branch tripped.
187
+ [403, { 'content-type' => 'text/plain' }, ['']]
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2025 SignalWire
4
+ #
5
+ # Licensed under the MIT License.
6
+ # See LICENSE file in the project root for full license information.
7
+ #
8
+ # Webhook signature validation for SignalWire-signed HTTP requests.
9
+ #
10
+ # Implements both schemes from porting-sdk/webhooks.md:
11
+ #
12
+ # - Scheme A (RELAY/SWML/JSON): hex(HMAC-SHA1(key, url + raw_body))
13
+ # - Scheme B (Compat/cXML form): base64(HMAC-SHA1(key, url + sortedFormParams))
14
+ # with optional bodySHA256 query-param fallback for JSON-on-compat-surface.
15
+ #
16
+ # Public API:
17
+ # SignalWire::Security::WebhookValidator.validate_webhook_signature(
18
+ # signing_key, signature, url, raw_body) -> Boolean
19
+ # SignalWire::Security::WebhookValidator.validate_request(
20
+ # signing_key, signature, url, params_or_raw_body) -> Boolean
21
+ #
22
+ # All comparisons use ``Rack::Utils.secure_compare`` (constant-time) so the
23
+ # secret is not leaked over repeated requests.
24
+
25
+ require 'base64'
26
+ require 'cgi'
27
+ require 'digest'
28
+ require 'openssl'
29
+ require 'rack/utils'
30
+ require 'uri'
31
+
32
+ module SignalWire
33
+ module Security
34
+ # Stateless validator for SignalWire-signed webhook requests.
35
+ #
36
+ # Both Scheme A (JSON, hex digest) and Scheme B (form-encoded, base64
37
+ # digest with bodySHA256 fallback) per porting-sdk/webhooks.md are
38
+ # tried by the combined entry point.
39
+ #
40
+ # The two public entry points are exposed via ``module_function`` so
41
+ # they can be invoked as ``WebhookValidator.validate_webhook_signature(...)``.
42
+ # All internal helpers are deliberately ``_``-prefixed and private so
43
+ # they don't pollute the public surface (``audit_no_cheat_tests`` and
44
+ # ``signature_dump.rb`` skip ``_``-prefixed methods).
45
+ module WebhookValidator
46
+ # Validate a SignalWire webhook signature against both schemes.
47
+ #
48
+ # @param signing_key [String] Customer's Signing Key from the Dashboard.
49
+ # UTF-8 string, secret. ``nil`` / empty raises ``ArgumentError`` —
50
+ # that's a programming error, not a validation failure.
51
+ # @param signature [String, nil] The ``X-SignalWire-Signature`` header
52
+ # value (or ``X-Twilio-Signature`` for cXML compat). Missing / empty
53
+ # returns false without raising.
54
+ # @param url [String] The full URL SignalWire POSTed to (scheme, host,
55
+ # optional port, path, query). Must match what the platform saw —
56
+ # see the URL reconstruction section of porting-sdk/webhooks.md.
57
+ # @param raw_body [String] The raw request body bytes as a UTF-8 string,
58
+ # BEFORE any JSON / form parsing. Must be a ``String`` — passing a
59
+ # parsed Hash raises ``TypeError``.
60
+ #
61
+ # @return [Boolean] true if the signature matches either Scheme A or
62
+ # Scheme B (with port-normalization variants and optional bodySHA256
63
+ # fallback). false otherwise.
64
+ #
65
+ # @raise [ArgumentError] when ``signing_key`` is missing.
66
+ # @raise [TypeError] when ``raw_body`` is not a String.
67
+ def self.validate_webhook_signature(signing_key, signature, url, raw_body)
68
+ raise ArgumentError, 'signing_key is required' if signing_key.nil? || signing_key.to_s.empty?
69
+ unless raw_body.is_a?(String)
70
+ raise TypeError,
71
+ 'raw_body must be a String — did you pass parsed JSON by mistake?'
72
+ end
73
+ return false if signature.nil? || signature.to_s.empty?
74
+
75
+ # ------------------------------------------------------------------
76
+ # Scheme A — RELAY/SWML/JSON: hex(HMAC-SHA1(key, url + raw_body))
77
+ # ------------------------------------------------------------------
78
+ expected_a = _hex_hmac_sha1(signing_key, url.to_s + raw_body)
79
+ return true if _safe_eq(expected_a, signature)
80
+
81
+ # ------------------------------------------------------------------
82
+ # Scheme B — Compat/cXML form: base64(HMAC-SHA1(key, url + sorted_concat))
83
+ # Try parsed form params and the empty-params fallback (for JSON on
84
+ # the compat surface). Try with-port and without-port URL variants.
85
+ # ------------------------------------------------------------------
86
+ parsed_params = _parse_form_body(raw_body)
87
+ param_shapes = [parsed_params, []]
88
+
89
+ _candidate_urls(url.to_s).each do |candidate_url|
90
+ param_shapes.each do |shape|
91
+ concat = _sorted_concat_params(shape)
92
+ expected_b = _b64_hmac_sha1(signing_key, candidate_url + concat)
93
+ next unless _safe_eq(expected_b, signature)
94
+
95
+ # If the URL carries bodySHA256, the body hash must match too.
96
+ return true if _check_body_sha256(candidate_url, raw_body)
97
+ # bodySHA256 mismatched — keep trying other shapes/urls.
98
+ end
99
+ end
100
+
101
+ false
102
+ end
103
+
104
+ # Legacy ``@signalwire/compatibility-api`` drop-in entry point.
105
+ #
106
+ # If ``params_or_raw_body`` is a ``String``, delegates to
107
+ # {validate_webhook_signature} (Scheme A then Scheme B with parsed form).
108
+ #
109
+ # If it's a ``Hash`` or an array of (key, value) pairs, treats it as
110
+ # pre-parsed form params and runs Scheme B directly (with URL port
111
+ # normalization and optional bodySHA256 fallback).
112
+ #
113
+ # @param signing_key [String]
114
+ # @param signature [String, nil]
115
+ # @param url [String]
116
+ # @param params_or_raw_body [String, Hash, Array, nil]
117
+ # @return [Boolean]
118
+ # @raise [ArgumentError] when ``signing_key`` is missing.
119
+ # @raise [TypeError] when ``params_or_raw_body`` is neither a String,
120
+ # Hash, nor an array of pairs.
121
+ def self.validate_request(signing_key, signature, url, params_or_raw_body)
122
+ raise ArgumentError, 'signing_key is required' if signing_key.nil? || signing_key.to_s.empty?
123
+ return false if signature.nil? || signature.to_s.empty?
124
+
125
+ if params_or_raw_body.is_a?(String)
126
+ return validate_webhook_signature(signing_key, signature, url, params_or_raw_body)
127
+ end
128
+
129
+ params_or_raw_body = [] if params_or_raw_body.nil?
130
+
131
+ unless params_or_raw_body.is_a?(Hash) || params_or_raw_body.is_a?(Array)
132
+ raise TypeError,
133
+ 'params_or_raw_body must be a String (raw body) or a Hash/Array of form params'
134
+ end
135
+
136
+ # Pre-parsed form params → Scheme B only.
137
+ concat = _sorted_concat_params(params_or_raw_body)
138
+ _candidate_urls(url.to_s).each do |candidate_url|
139
+ expected_b = _b64_hmac_sha1(signing_key, candidate_url + concat)
140
+ # bodySHA256 has no raw body to verify here — skip that check.
141
+ return true if _safe_eq(expected_b, signature)
142
+ end
143
+ false
144
+ end
145
+
146
+ # ----------------------------------------------------------------------
147
+ # Internal helpers (underscore-prefixed: not part of the public surface).
148
+ # ----------------------------------------------------------------------
149
+
150
+ # @api private
151
+ def self._hex_hmac_sha1(key, message)
152
+ OpenSSL::HMAC.hexdigest('SHA1', key.to_s, message.to_s)
153
+ end
154
+
155
+ # @api private
156
+ def self._b64_hmac_sha1(key, message)
157
+ Base64.strict_encode64(
158
+ OpenSSL::HMAC.digest('SHA1', key.to_s, message.to_s)
159
+ )
160
+ end
161
+
162
+ # @api private
163
+ # Constant-time string compare. Returns false on any error so malformed
164
+ # inputs never raise.
165
+ def self._safe_eq(a, b)
166
+ Rack::Utils.secure_compare(a.to_s, b.to_s)
167
+ rescue StandardError
168
+ false
169
+ end
170
+
171
+ # @api private
172
+ # Concatenate form params per Scheme B rules:
173
+ # - Sort by key, ASCII ascending.
174
+ # - For repeated keys (Array values OR multiple [k,v] pairs): keep
175
+ # original submission order, emit ``key + value`` once per occurrence.
176
+ # - Non-string values are stringified via ``to_s``.
177
+ def self._sorted_concat_params(params)
178
+ return '' if params.nil? || (params.respond_to?(:empty?) && params.empty?)
179
+
180
+ items = []
181
+ if params.is_a?(Hash)
182
+ params.each do |k, v|
183
+ if v.is_a?(Array)
184
+ v.each { |vi| items << [k.to_s, vi] }
185
+ else
186
+ items << [k.to_s, v]
187
+ end
188
+ end
189
+ elsif params.is_a?(Array)
190
+ params.each do |pair|
191
+ # Accept [k, v] pairs (the most common form).
192
+ next unless pair.is_a?(Array) && pair.length >= 2
193
+
194
+ items << [pair[0].to_s, pair[1]]
195
+ end
196
+ else
197
+ return ''
198
+ end
199
+
200
+ # Stable sort by key — preserves original order within repeated keys.
201
+ items = items.each_with_index.sort_by { |(k, _v), idx| [k, idx] }.map(&:first)
202
+
203
+ items.map { |k, v| "#{k}#{v.nil? ? '' : v}" }.join
204
+ end
205
+
206
+ # @api private
207
+ # Best-effort parse of an x-www-form-urlencoded body. Returns an
208
+ # array of [key, value] pairs (preserving order, including duplicate
209
+ # keys). Returns [] if the body doesn't decode as form data.
210
+ def self._parse_form_body(raw_body)
211
+ return [] if raw_body.nil? || raw_body.empty?
212
+
213
+ pairs = []
214
+ raw_body.split('&').each do |chunk|
215
+ next if chunk.empty?
216
+
217
+ k, _eq, v = chunk.partition('=')
218
+ decoded_k = CGI.unescape(k)
219
+ decoded_v = CGI.unescape(v)
220
+ pairs << [decoded_k, decoded_v]
221
+ end
222
+ pairs
223
+ rescue StandardError
224
+ []
225
+ end
226
+
227
+ # @api private
228
+ # Return the URL variants to try for Scheme B port normalization.
229
+ #
230
+ # - If the URL already has a non-standard port: just the input URL.
231
+ # - If https + no port: input URL AND url with ``:443``.
232
+ # - If http + no port: input URL AND url with ``:80``.
233
+ # - If https + ``:443`` / http + ``:80``: input URL AND url without port.
234
+ # - Otherwise (any explicit non-standard port): just the input URL.
235
+ def self._candidate_urls(url)
236
+ parsed = URI.parse(url)
237
+ host = parsed.host
238
+ return [url] if host.nil? || host.empty?
239
+
240
+ scheme = (parsed.scheme || '').downcase
241
+ standard = { 'http' => 80, 'https' => 443 }[scheme]
242
+ port = parsed.port
243
+
244
+ candidates = [url]
245
+
246
+ if standard && parsed.respond_to?(:default_port) && port == parsed.default_port &&
247
+ !_explicit_port?(url, scheme)
248
+ # No explicit port in original URL; URI added the default.
249
+ with_port_url = _build_url_with_port(parsed, standard)
250
+ candidates << with_port_url if with_port_url != url
251
+ elsif standard && port == standard && _explicit_port?(url, scheme)
252
+ # Original URL had the standard port spelled out — also try without.
253
+ without_port_url = _build_url_without_port(parsed)
254
+ candidates << without_port_url if without_port_url != url
255
+ end
256
+ # Else: non-standard explicit port — only try as-is.
257
+
258
+ candidates
259
+ rescue URI::InvalidURIError
260
+ [url]
261
+ end
262
+
263
+ # @api private
264
+ # Heuristic: was a port explicitly written into the URL string?
265
+ # ``URI`` always populates ``port`` (with the default), so we have to
266
+ # look at the raw string.
267
+ def self._explicit_port?(url, _scheme)
268
+ # Look for ``:NNN`` between the host and the path / query / end.
269
+ # Avoid false positives in ``://``, userinfo, IPv6 brackets, etc.
270
+ no_scheme = url.sub(%r{\A[^:]+://}, '')
271
+ no_userinfo = no_scheme.sub(/\A[^@\/?#]*@/, '')
272
+ # Strip IPv6 zone if any: "[..]" then look after ]
273
+ if no_userinfo.start_with?('[')
274
+ after_bracket = no_userinfo.sub(/\A\[[^\]]*\]/, '')
275
+ !!(after_bracket =~ /\A:\d+/)
276
+ else
277
+ host_and_rest = no_userinfo
278
+ host_part, _sep, _rest = host_and_rest.partition(%r{[/?#]})
279
+ !!(host_part =~ /:\d+\z/)
280
+ end
281
+ end
282
+
283
+ # @api private
284
+ def self._build_url_with_port(parsed, port)
285
+ netloc_host = parsed.host.include?(':') ? "[#{parsed.host}]" : parsed.host
286
+ prefix = "#{parsed.scheme}://"
287
+ prefix += "#{parsed.userinfo}@" if parsed.userinfo
288
+ rest = +''
289
+ rest << (parsed.path || '')
290
+ rest << "?#{parsed.query}" if parsed.query
291
+ rest << "##{parsed.fragment}" if parsed.fragment
292
+ "#{prefix}#{netloc_host}:#{port}#{rest}"
293
+ end
294
+
295
+ # @api private
296
+ def self._build_url_without_port(parsed)
297
+ netloc_host = parsed.host.include?(':') ? "[#{parsed.host}]" : parsed.host
298
+ prefix = "#{parsed.scheme}://"
299
+ prefix += "#{parsed.userinfo}@" if parsed.userinfo
300
+ rest = +''
301
+ rest << (parsed.path || '')
302
+ rest << "?#{parsed.query}" if parsed.query
303
+ rest << "##{parsed.fragment}" if parsed.fragment
304
+ "#{prefix}#{netloc_host}#{rest}"
305
+ end
306
+
307
+ # @api private
308
+ # If URL has ``?bodySHA256=<hex>``, verify ``sha256_hex(raw_body)`` matches.
309
+ # Returns true if the param is absent (no constraint), or present and
310
+ # matches. Returns false only when the param is present and mismatches.
311
+ def self._check_body_sha256(url, raw_body)
312
+ parsed = URI.parse(url)
313
+ return true if parsed.query.nil? || parsed.query.empty?
314
+
315
+ # Use _parse_form_body for consistency (handles repeated keys etc).
316
+ qparams = _parse_form_body(parsed.query)
317
+ body_hash = qparams.find { |(k, _)| k == 'bodySHA256' }
318
+ return true if body_hash.nil?
319
+
320
+ actual = Digest::SHA256.hexdigest(raw_body.to_s)
321
+ _safe_eq(actual, body_hash[1])
322
+ rescue URI::InvalidURIError
323
+ true
324
+ end
325
+ end
326
+ end
327
+ end