ruby_llm 1.0.1 → 1.1.0rc1
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.
- checksums.yaml +4 -4
- data/README.md +28 -12
- data/lib/ruby_llm/active_record/acts_as.rb +46 -7
- data/lib/ruby_llm/aliases.json +65 -0
- data/lib/ruby_llm/aliases.rb +56 -0
- data/lib/ruby_llm/chat.rb +10 -9
- data/lib/ruby_llm/configuration.rb +4 -0
- data/lib/ruby_llm/error.rb +15 -4
- data/lib/ruby_llm/models.json +1163 -303
- data/lib/ruby_llm/models.rb +40 -11
- data/lib/ruby_llm/provider.rb +32 -39
- data/lib/ruby_llm/providers/anthropic/capabilities.rb +8 -9
- data/lib/ruby_llm/providers/anthropic/chat.rb +31 -4
- data/lib/ruby_llm/providers/anthropic/streaming.rb +12 -6
- data/lib/ruby_llm/providers/anthropic.rb +4 -0
- data/lib/ruby_llm/providers/bedrock/capabilities.rb +168 -0
- data/lib/ruby_llm/providers/bedrock/chat.rb +108 -0
- data/lib/ruby_llm/providers/bedrock/models.rb +84 -0
- data/lib/ruby_llm/providers/bedrock/signing.rb +831 -0
- data/lib/ruby_llm/providers/bedrock/streaming/base.rb +46 -0
- data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +63 -0
- data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +79 -0
- data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +90 -0
- data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +91 -0
- data/lib/ruby_llm/providers/bedrock/streaming.rb +36 -0
- data/lib/ruby_llm/providers/bedrock.rb +83 -0
- data/lib/ruby_llm/providers/deepseek/chat.rb +17 -0
- data/lib/ruby_llm/providers/deepseek.rb +5 -0
- data/lib/ruby_llm/providers/gemini/capabilities.rb +50 -34
- data/lib/ruby_llm/providers/gemini/chat.rb +8 -15
- data/lib/ruby_llm/providers/gemini/images.rb +5 -10
- data/lib/ruby_llm/providers/gemini/streaming.rb +35 -76
- data/lib/ruby_llm/providers/gemini/tools.rb +12 -12
- data/lib/ruby_llm/providers/gemini.rb +4 -0
- data/lib/ruby_llm/providers/openai/capabilities.rb +146 -206
- data/lib/ruby_llm/providers/openai/streaming.rb +9 -13
- data/lib/ruby_llm/providers/openai.rb +4 -0
- data/lib/ruby_llm/streaming.rb +96 -0
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/ruby_llm.rb +6 -3
- data/lib/tasks/browser_helper.rb +97 -0
- data/lib/tasks/capability_generator.rb +123 -0
- data/lib/tasks/capability_scraper.rb +224 -0
- data/lib/tasks/cli_helper.rb +22 -0
- data/lib/tasks/code_validator.rb +29 -0
- data/lib/tasks/model_updater.rb +66 -0
- data/lib/tasks/models.rake +28 -193
- data/lib/tasks/vcr.rake +13 -30
- metadata +27 -19
- data/.github/workflows/cicd.yml +0 -158
- data/.github/workflows/docs.yml +0 -53
- data/.gitignore +0 -59
- data/.overcommit.yml +0 -26
- data/.rspec +0 -3
- data/.rubocop.yml +0 -10
- data/.yardopts +0 -12
- data/CONTRIBUTING.md +0 -207
- data/Gemfile +0 -33
- data/Rakefile +0 -9
- data/bin/console +0 -17
- data/bin/setup +0 -6
- data/ruby_llm.gemspec +0 -44
@@ -0,0 +1,831 @@
|
|
1
|
+
# Portions of this file are derived from AWS SDK for Ruby (Apache License 2.0)
|
2
|
+
# Modifications made by RubyLLM in 2025
|
3
|
+
# See THIRD_PARTY_LICENSES/AWS-SDK-NOTICE.txt for details
|
4
|
+
|
5
|
+
# frozen_string_literal: true
|
6
|
+
|
7
|
+
require 'openssl'
|
8
|
+
require 'tempfile'
|
9
|
+
require 'time'
|
10
|
+
require 'uri'
|
11
|
+
require 'set'
|
12
|
+
require 'cgi'
|
13
|
+
require 'pathname'
|
14
|
+
|
15
|
+
module RubyLLM
|
16
|
+
module Providers
|
17
|
+
module Bedrock
|
18
|
+
module Signing
|
19
|
+
# Utility class for creating AWS signature version 4 signature. This class
|
20
|
+
# provides a method for generating signatures:
|
21
|
+
#
|
22
|
+
# * {#sign_request} - Computes a signature of the given request, returning
|
23
|
+
# the hash of headers that should be applied to the request.
|
24
|
+
#
|
25
|
+
# ## Configuration
|
26
|
+
#
|
27
|
+
# To use the signer, you need to specify the service, region, and credentials.
|
28
|
+
# The service name is normally the endpoint prefix to an AWS service. For
|
29
|
+
# example:
|
30
|
+
#
|
31
|
+
# ec2.us-west-1.amazonaws.com => ec2
|
32
|
+
#
|
33
|
+
# The region is normally the second portion of the endpoint, following
|
34
|
+
# the service name.
|
35
|
+
#
|
36
|
+
# ec2.us-west-1.amazonaws.com => us-west-1
|
37
|
+
#
|
38
|
+
# It is important to have the correct service and region name, or the
|
39
|
+
# signature will be invalid.
|
40
|
+
#
|
41
|
+
# ## Credentials
|
42
|
+
#
|
43
|
+
# The signer requires credentials. You can configure the signer
|
44
|
+
# with static credentials:
|
45
|
+
#
|
46
|
+
# signer = Aws::Sigv4::Signer.new(
|
47
|
+
# service: 's3',
|
48
|
+
# region: 'us-east-1',
|
49
|
+
# # static credentials
|
50
|
+
# access_key_id: 'akid',
|
51
|
+
# secret_access_key: 'secret'
|
52
|
+
# )
|
53
|
+
#
|
54
|
+
# You can also provide refreshing credentials via the `:credentials_provider`.
|
55
|
+
# If you are using the AWS SDK for Ruby, you can use any of the credential
|
56
|
+
# classes:
|
57
|
+
#
|
58
|
+
# signer = Aws::Sigv4::Signer.new(
|
59
|
+
# service: 's3',
|
60
|
+
# region: 'us-east-1',
|
61
|
+
# credentials_provider: Aws::InstanceProfileCredentials.new
|
62
|
+
# )
|
63
|
+
#
|
64
|
+
# Other AWS SDK for Ruby classes that can be provided via `:credentials_provider`:
|
65
|
+
#
|
66
|
+
# * `Aws::Credentials`
|
67
|
+
# * `Aws::SharedCredentials`
|
68
|
+
# * `Aws::InstanceProfileCredentials`
|
69
|
+
# * `Aws::AssumeRoleCredentials`
|
70
|
+
# * `Aws::ECSCredentials`
|
71
|
+
#
|
72
|
+
# A credential provider is any object that responds to `#credentials`
|
73
|
+
# returning another object that responds to `#access_key_id`, `#secret_access_key`,
|
74
|
+
# and `#session_token`.
|
75
|
+
module Errors
|
76
|
+
# Error raised when AWS credentials are missing or incomplete
|
77
|
+
class MissingCredentialsError < ArgumentError
|
78
|
+
def initialize(msg = nil)
|
79
|
+
super(msg || <<~MSG.strip)
|
80
|
+
missing credentials, provide credentials with one of the following options:
|
81
|
+
- :access_key_id and :secret_access_key
|
82
|
+
- :credentials
|
83
|
+
- :credentials_provider
|
84
|
+
MSG
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Error raised when AWS region is not specified
|
89
|
+
class MissingRegionError < ArgumentError
|
90
|
+
def initialize(*_args)
|
91
|
+
super('missing required option :region')
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Represents a signature for AWS request signing
|
97
|
+
class Signature
|
98
|
+
# @api private
|
99
|
+
def initialize(options)
|
100
|
+
options.each_pair do |attr_name, attr_value|
|
101
|
+
send("#{attr_name}=", attr_value)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# @return [Hash<String,String>] A hash of headers that should
|
106
|
+
# be applied to the HTTP request. Header keys are lower
|
107
|
+
# cased strings and may include the following:
|
108
|
+
#
|
109
|
+
# * 'host'
|
110
|
+
# * 'x-amz-date'
|
111
|
+
# * 'x-amz-security-token'
|
112
|
+
# * 'x-amz-content-sha256'
|
113
|
+
# * 'authorization'
|
114
|
+
#
|
115
|
+
attr_accessor :headers
|
116
|
+
|
117
|
+
# @return [String] For debugging purposes.
|
118
|
+
attr_accessor :canonical_request
|
119
|
+
|
120
|
+
# @return [String] For debugging purposes.
|
121
|
+
attr_accessor :string_to_sign
|
122
|
+
|
123
|
+
# @return [String] For debugging purposes.
|
124
|
+
attr_accessor :content_sha256
|
125
|
+
|
126
|
+
# @return [String] For debugging purposes.
|
127
|
+
attr_accessor :signature
|
128
|
+
|
129
|
+
# @return [Hash] Internal data for debugging purposes.
|
130
|
+
attr_accessor :extra
|
131
|
+
end
|
132
|
+
|
133
|
+
# Manages AWS credentials for authentication
|
134
|
+
class Credentials
|
135
|
+
# @option options [required, String] :access_key_id
|
136
|
+
# @option options [required, String] :secret_access_key
|
137
|
+
# @option options [String, nil] :session_token (nil)
|
138
|
+
def initialize(options = {})
|
139
|
+
if options[:access_key_id] && options[:secret_access_key]
|
140
|
+
@access_key_id = options[:access_key_id]
|
141
|
+
@secret_access_key = options[:secret_access_key]
|
142
|
+
@session_token = options[:session_token]
|
143
|
+
else
|
144
|
+
msg = 'expected both :access_key_id and :secret_access_key options'
|
145
|
+
raise ArgumentError, msg
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
# @return [String]
|
150
|
+
attr_reader :access_key_id
|
151
|
+
|
152
|
+
# @return [String]
|
153
|
+
attr_reader :secret_access_key
|
154
|
+
|
155
|
+
# @return [String, nil]
|
156
|
+
attr_reader :session_token
|
157
|
+
|
158
|
+
# @return [Boolean] Returns `true` if the access key id and secret
|
159
|
+
# access key are both set.
|
160
|
+
def set?
|
161
|
+
!access_key_id.nil? &&
|
162
|
+
!access_key_id.empty? &&
|
163
|
+
!secret_access_key.nil? &&
|
164
|
+
!secret_access_key.empty?
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# Users that wish to configure static credentials can use the
|
169
|
+
# `:access_key_id` and `:secret_access_key` constructor options.
|
170
|
+
# @api private
|
171
|
+
class StaticCredentialsProvider
|
172
|
+
# @option options [Credentials] :credentials
|
173
|
+
# @option options [String] :access_key_id
|
174
|
+
# @option options [String] :secret_access_key
|
175
|
+
# @option options [String] :session_token (nil)
|
176
|
+
def initialize(options = {})
|
177
|
+
@credentials = options[:credentials] || Credentials.new(options)
|
178
|
+
end
|
179
|
+
|
180
|
+
# @return [Credentials]
|
181
|
+
attr_reader :credentials
|
182
|
+
|
183
|
+
# @return [Boolean]
|
184
|
+
def set?
|
185
|
+
!!credentials && credentials.set?
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
ParamComponent = Struct.new(:name, :value, :offset)
|
190
|
+
|
191
|
+
# Utility methods for URI manipulation and hashing
|
192
|
+
module UriUtils
|
193
|
+
module_function
|
194
|
+
|
195
|
+
def uri_escape_path(path)
|
196
|
+
path.gsub(%r{[^/]+}) { |part| uri_escape(part) }
|
197
|
+
end
|
198
|
+
|
199
|
+
def uri_escape(string)
|
200
|
+
if string.nil?
|
201
|
+
nil
|
202
|
+
else
|
203
|
+
CGI.escape(string.encode('UTF-8')).gsub('+', '%20').gsub('%7E', '~')
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
def normalize_path(uri)
|
208
|
+
normalized_path = Pathname.new(uri.path).cleanpath.to_s
|
209
|
+
# Pathname is probably not correct to use. Empty paths will
|
210
|
+
# resolve to "." and should be disregarded
|
211
|
+
normalized_path = '' if normalized_path == '.'
|
212
|
+
# Ensure trailing slashes are correctly preserved
|
213
|
+
normalized_path << '/' if uri.path.end_with?('/') && !normalized_path.end_with?('/')
|
214
|
+
uri.path = normalized_path
|
215
|
+
end
|
216
|
+
|
217
|
+
def host(uri)
|
218
|
+
# Handles known and unknown URI schemes; default_port nil when unknown.
|
219
|
+
if uri.default_port == uri.port
|
220
|
+
uri.host
|
221
|
+
else
|
222
|
+
"#{uri.host}:#{uri.port}"
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
# Cryptographic hash and digest utilities
|
228
|
+
module CryptoUtils
|
229
|
+
module_function
|
230
|
+
|
231
|
+
# @param [String] value
|
232
|
+
# @return [String<SHA256 Hexdigest>]
|
233
|
+
def sha256_hexdigest(value)
|
234
|
+
OpenSSL::Digest::SHA256.hexdigest(value)
|
235
|
+
end
|
236
|
+
|
237
|
+
def hmac(key, value)
|
238
|
+
OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), key, value)
|
239
|
+
end
|
240
|
+
|
241
|
+
def hexhmac(key, value)
|
242
|
+
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), key, value)
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
# Configuration for canonical request creation
|
247
|
+
class CanonicalRequestConfig
|
248
|
+
attr_reader :uri_escape_path, :unsigned_headers
|
249
|
+
|
250
|
+
def initialize(options = {})
|
251
|
+
@uri_escape_path = options[:uri_escape_path] || true
|
252
|
+
@unsigned_headers = options[:unsigned_headers] || Set.new
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
# Handles canonical requests for AWS signature
|
257
|
+
class CanonicalRequest
|
258
|
+
# Builds a canonical request for AWS signature
|
259
|
+
# @param [Hash] params Parameters for the canonical request
|
260
|
+
def initialize(params = {})
|
261
|
+
@http_method = params[:http_method]
|
262
|
+
@url = params[:url]
|
263
|
+
@headers = params[:headers]
|
264
|
+
@content_sha256 = params[:content_sha256]
|
265
|
+
@config = params[:config] || CanonicalRequestConfig.new
|
266
|
+
end
|
267
|
+
|
268
|
+
def to_s
|
269
|
+
[
|
270
|
+
@http_method,
|
271
|
+
path,
|
272
|
+
'', # Empty string instead of normalized_querystring since we don't use query params
|
273
|
+
"#{canonical_headers}\n",
|
274
|
+
signed_headers,
|
275
|
+
@content_sha256
|
276
|
+
].join("\n")
|
277
|
+
end
|
278
|
+
|
279
|
+
# Returns the list of signed headers for authorization
|
280
|
+
def signed_headers
|
281
|
+
@headers.inject([]) do |signed_headers, (header, _)|
|
282
|
+
if @config.unsigned_headers.include?(header)
|
283
|
+
signed_headers
|
284
|
+
else
|
285
|
+
signed_headers << header
|
286
|
+
end
|
287
|
+
end.sort.join(';')
|
288
|
+
end
|
289
|
+
|
290
|
+
private
|
291
|
+
|
292
|
+
def path
|
293
|
+
path = @url.path
|
294
|
+
path = '/' if path == ''
|
295
|
+
if @config.uri_escape_path
|
296
|
+
UriUtils.uri_escape_path(path)
|
297
|
+
else
|
298
|
+
path
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
def canonical_headers
|
303
|
+
headers = @headers.inject([]) do |hdrs, (k, v)|
|
304
|
+
if @config.unsigned_headers.include?(k)
|
305
|
+
hdrs
|
306
|
+
else
|
307
|
+
hdrs << [k, v]
|
308
|
+
end
|
309
|
+
end
|
310
|
+
headers = headers.sort_by(&:first)
|
311
|
+
headers.map { |k, v| "#{k}:#{canonical_header_value(v.to_s)}" }.join("\n")
|
312
|
+
end
|
313
|
+
|
314
|
+
def canonical_header_value(value)
|
315
|
+
value.gsub(/\s+/, ' ').strip
|
316
|
+
end
|
317
|
+
end
|
318
|
+
|
319
|
+
# Handles signature computation
|
320
|
+
class SignatureComputation
|
321
|
+
def initialize(service, region, signing_algorithm)
|
322
|
+
@service = service
|
323
|
+
@region = region
|
324
|
+
@signing_algorithm = signing_algorithm
|
325
|
+
end
|
326
|
+
|
327
|
+
def string_to_sign(datetime, canonical_request, algorithm)
|
328
|
+
[
|
329
|
+
algorithm,
|
330
|
+
datetime,
|
331
|
+
credential_scope(datetime[0, 8]),
|
332
|
+
CryptoUtils.sha256_hexdigest(canonical_request)
|
333
|
+
].join("\n")
|
334
|
+
end
|
335
|
+
|
336
|
+
def credential_scope(date)
|
337
|
+
[
|
338
|
+
date,
|
339
|
+
@region,
|
340
|
+
@service,
|
341
|
+
'aws4_request'
|
342
|
+
].join('/')
|
343
|
+
end
|
344
|
+
|
345
|
+
def credential(credentials, date)
|
346
|
+
"#{credentials.access_key_id}/#{credential_scope(date)}"
|
347
|
+
end
|
348
|
+
|
349
|
+
def signature(secret_access_key, date, string_to_sign)
|
350
|
+
k_date = CryptoUtils.hmac("AWS4#{secret_access_key}", date)
|
351
|
+
k_region = CryptoUtils.hmac(k_date, @region)
|
352
|
+
k_service = CryptoUtils.hmac(k_region, @service)
|
353
|
+
k_credentials = CryptoUtils.hmac(k_service, 'aws4_request')
|
354
|
+
CryptoUtils.hexhmac(k_credentials, string_to_sign)
|
355
|
+
end
|
356
|
+
end
|
357
|
+
|
358
|
+
# Extracts and validates request components
|
359
|
+
class RequestExtractor
|
360
|
+
# Extract and process request components
|
361
|
+
# @param [Hash] request The request to process
|
362
|
+
# @param [Hash] options Options for extraction
|
363
|
+
# @return [Hash] Processed request components
|
364
|
+
def self.extract_components(request, options = {})
|
365
|
+
normalize_path = options.fetch(:normalize_path, true)
|
366
|
+
|
367
|
+
# Extract base components
|
368
|
+
http_method, url, headers = extract_base_components(request)
|
369
|
+
UriUtils.normalize_path(url) if normalize_path
|
370
|
+
|
371
|
+
# Process headers and compute content SHA256
|
372
|
+
datetime = headers['x-amz-date'] || Time.now.utc.strftime('%Y%m%dT%H%M%SZ')
|
373
|
+
content_sha256 = extract_content_sha256(headers, request[:body])
|
374
|
+
|
375
|
+
build_component_hash(http_method, url, headers, datetime, content_sha256)
|
376
|
+
end
|
377
|
+
|
378
|
+
def self.build_component_hash(http_method, url, headers, datetime, content_sha256)
|
379
|
+
{
|
380
|
+
http_method: http_method,
|
381
|
+
url: url,
|
382
|
+
headers: headers,
|
383
|
+
datetime: datetime,
|
384
|
+
date: datetime[0, 8],
|
385
|
+
content_sha256: content_sha256
|
386
|
+
}
|
387
|
+
end
|
388
|
+
|
389
|
+
def self.extract_base_components(request)
|
390
|
+
http_method = extract_http_method(request)
|
391
|
+
url = extract_url(request)
|
392
|
+
headers = downcase_headers(request[:headers])
|
393
|
+
[http_method, url, headers]
|
394
|
+
end
|
395
|
+
|
396
|
+
def self.extract_content_sha256(headers, body)
|
397
|
+
headers['x-amz-content-sha256'] || CryptoUtils.sha256_hexdigest(body || '')
|
398
|
+
end
|
399
|
+
|
400
|
+
def self.extract_http_method(request)
|
401
|
+
if request[:http_method]
|
402
|
+
request[:http_method].upcase
|
403
|
+
else
|
404
|
+
msg = 'missing required option :http_method'
|
405
|
+
raise ArgumentError, msg
|
406
|
+
end
|
407
|
+
end
|
408
|
+
|
409
|
+
def self.extract_url(request)
|
410
|
+
if request[:url]
|
411
|
+
URI.parse(request[:url].to_s)
|
412
|
+
else
|
413
|
+
msg = 'missing required option :url'
|
414
|
+
raise ArgumentError, msg
|
415
|
+
end
|
416
|
+
end
|
417
|
+
|
418
|
+
def self.downcase_headers(headers)
|
419
|
+
(headers || {}).to_hash.transform_keys(&:downcase)
|
420
|
+
end
|
421
|
+
end
|
422
|
+
|
423
|
+
# Handles generating headers for AWS request signing
|
424
|
+
class HeaderBuilder
|
425
|
+
def initialize(options = {})
|
426
|
+
@signing_algorithm = options[:signing_algorithm]
|
427
|
+
@apply_checksum_header = options[:apply_checksum_header]
|
428
|
+
@omit_session_token = options[:omit_session_token]
|
429
|
+
@region = options[:region]
|
430
|
+
end
|
431
|
+
|
432
|
+
# Build headers for a signed request
|
433
|
+
# @param [Hash] components Request components
|
434
|
+
# @param [Credentials] creds AWS credentials
|
435
|
+
# @return [Hash] Generated headers
|
436
|
+
def build_sigv4_headers(components, creds)
|
437
|
+
headers = {
|
438
|
+
'host' => components[:headers]['host'] || UriUtils.host(components[:url]),
|
439
|
+
'x-amz-date' => components[:datetime]
|
440
|
+
}
|
441
|
+
|
442
|
+
add_session_token_header(headers, creds)
|
443
|
+
add_content_sha256_header(headers, components[:content_sha256])
|
444
|
+
|
445
|
+
headers
|
446
|
+
end
|
447
|
+
|
448
|
+
# Build authorization headers for a signature
|
449
|
+
# @param [Hash] sigv4_headers Headers for the signature
|
450
|
+
# @param [Hash] signature The computed signature
|
451
|
+
# @param [Hash] components Request components
|
452
|
+
# @return [Hash] Headers with authorization
|
453
|
+
def build_headers(sigv4_headers, signature, components)
|
454
|
+
headers = sigv4_headers.merge(
|
455
|
+
'authorization' => build_authorization_header(signature)
|
456
|
+
)
|
457
|
+
|
458
|
+
add_omitted_session_token(headers, components[:creds]) if @omit_session_token
|
459
|
+
headers
|
460
|
+
end
|
461
|
+
|
462
|
+
def build_authorization_header(signature)
|
463
|
+
[
|
464
|
+
"#{signature[:algorithm]} Credential=#{signature[:credential]}",
|
465
|
+
"SignedHeaders=#{signature[:signed_headers]}",
|
466
|
+
"Signature=#{signature[:signature]}"
|
467
|
+
].join(', ')
|
468
|
+
end
|
469
|
+
|
470
|
+
private
|
471
|
+
|
472
|
+
def add_session_token_header(headers, creds)
|
473
|
+
return unless creds.session_token && !@omit_session_token
|
474
|
+
|
475
|
+
headers['x-amz-security-token'] = creds.session_token
|
476
|
+
end
|
477
|
+
|
478
|
+
def add_content_sha256_header(headers, content_sha256)
|
479
|
+
headers['x-amz-content-sha256'] = content_sha256 if @apply_checksum_header
|
480
|
+
end
|
481
|
+
|
482
|
+
def add_omitted_session_token(headers, creds)
|
483
|
+
return unless creds&.session_token
|
484
|
+
|
485
|
+
headers['x-amz-security-token'] = creds.session_token
|
486
|
+
end
|
487
|
+
end
|
488
|
+
|
489
|
+
# Credential management and fetching
|
490
|
+
class CredentialManager
|
491
|
+
def initialize(credentials_provider)
|
492
|
+
@credentials_provider = credentials_provider
|
493
|
+
end
|
494
|
+
|
495
|
+
def fetch_credentials
|
496
|
+
credentials = @credentials_provider.credentials
|
497
|
+
if credentials_set?(credentials)
|
498
|
+
expiration = nil
|
499
|
+
expiration = @credentials_provider.expiration if @credentials_provider.respond_to?(:expiration)
|
500
|
+
[credentials, expiration]
|
501
|
+
else
|
502
|
+
raise Errors::MissingCredentialsError,
|
503
|
+
'unable to sign request without credentials set'
|
504
|
+
end
|
505
|
+
end
|
506
|
+
|
507
|
+
def credentials_set?(credentials)
|
508
|
+
!credentials.access_key_id.nil? &&
|
509
|
+
!credentials.access_key_id.empty? &&
|
510
|
+
!credentials.secret_access_key.nil? &&
|
511
|
+
!credentials.secret_access_key.empty?
|
512
|
+
end
|
513
|
+
end
|
514
|
+
|
515
|
+
# Result builder for signature computation
|
516
|
+
class SignatureResultBuilder
|
517
|
+
def initialize(signature_computation)
|
518
|
+
@signature_computation = signature_computation
|
519
|
+
end
|
520
|
+
|
521
|
+
def build_result(request_data)
|
522
|
+
result_hash(request_data)
|
523
|
+
end
|
524
|
+
|
525
|
+
private
|
526
|
+
|
527
|
+
def result_hash(request_data)
|
528
|
+
{
|
529
|
+
algorithm: request_data[:algorithm],
|
530
|
+
credential: credential_from_request(request_data),
|
531
|
+
signed_headers: request_data[:canonical_request].signed_headers,
|
532
|
+
signature: request_data[:signature],
|
533
|
+
canonical_request: request_data[:creq],
|
534
|
+
string_to_sign: request_data[:sts]
|
535
|
+
}
|
536
|
+
end
|
537
|
+
|
538
|
+
def credential_from_request(request_data)
|
539
|
+
@signature_computation.credential(
|
540
|
+
request_data[:credentials],
|
541
|
+
request_data[:date]
|
542
|
+
)
|
543
|
+
end
|
544
|
+
end
|
545
|
+
|
546
|
+
# Core functionality for computing signatures
|
547
|
+
class SignatureGenerator
|
548
|
+
def initialize(options = {})
|
549
|
+
@signing_algorithm = :sigv4 # Always use sigv4
|
550
|
+
@uri_escape_path = options[:uri_escape_path] || true
|
551
|
+
@unsigned_headers = options[:unsigned_headers] || Set.new
|
552
|
+
@service = options[:service]
|
553
|
+
@region = options[:region]
|
554
|
+
|
555
|
+
@signature_computation = SignatureComputation.new(@service, @region, @signing_algorithm)
|
556
|
+
@result_builder = SignatureResultBuilder.new(@signature_computation)
|
557
|
+
end
|
558
|
+
|
559
|
+
def sts_algorithm
|
560
|
+
'AWS4-HMAC-SHA256' # Always use HMAC-SHA256
|
561
|
+
end
|
562
|
+
|
563
|
+
def compute_signature(components, creds, sigv4_headers)
|
564
|
+
algorithm = sts_algorithm
|
565
|
+
headers = components[:headers].merge(sigv4_headers)
|
566
|
+
|
567
|
+
# Process request and generate signature
|
568
|
+
canonical_request = create_canonical_request(components, headers)
|
569
|
+
sig = compute_signature_from_request(canonical_request, components, creds, algorithm)
|
570
|
+
|
571
|
+
# Build and return the final result
|
572
|
+
build_signature_result(components, creds, canonical_request, sig, algorithm)
|
573
|
+
end
|
574
|
+
|
575
|
+
private
|
576
|
+
|
577
|
+
def compute_signature_from_request(canonical_request, components, creds, algorithm)
|
578
|
+
creq = canonical_request.to_s
|
579
|
+
sts = generate_string_to_sign(components, creq, algorithm)
|
580
|
+
generate_signature(creds, components[:date], sts)
|
581
|
+
end
|
582
|
+
|
583
|
+
def generate_string_to_sign(components, creq, algorithm)
|
584
|
+
@signature_computation.string_to_sign(
|
585
|
+
components[:datetime],
|
586
|
+
creq,
|
587
|
+
algorithm
|
588
|
+
)
|
589
|
+
end
|
590
|
+
|
591
|
+
def build_signature_result(components, creds, canonical_request, sig, algorithm)
|
592
|
+
@result_builder.build_result(
|
593
|
+
algorithm: algorithm,
|
594
|
+
credentials: creds,
|
595
|
+
date: components[:date],
|
596
|
+
signature: sig,
|
597
|
+
creq: canonical_request.to_s,
|
598
|
+
sts: generate_string_to_sign(components, canonical_request.to_s, algorithm),
|
599
|
+
canonical_request: canonical_request
|
600
|
+
)
|
601
|
+
end
|
602
|
+
|
603
|
+
def create_canonical_request(components, headers)
|
604
|
+
canon_req_config = create_canonical_request_config
|
605
|
+
|
606
|
+
CanonicalRequest.new(
|
607
|
+
http_method: components[:http_method],
|
608
|
+
url: components[:url],
|
609
|
+
headers: headers,
|
610
|
+
content_sha256: components[:content_sha256],
|
611
|
+
config: canon_req_config
|
612
|
+
)
|
613
|
+
end
|
614
|
+
|
615
|
+
def create_canonical_request_config
|
616
|
+
CanonicalRequestConfig.new(
|
617
|
+
uri_escape_path: @uri_escape_path,
|
618
|
+
unsigned_headers: @unsigned_headers
|
619
|
+
)
|
620
|
+
end
|
621
|
+
|
622
|
+
def generate_signature(creds, date, string_to_sign)
|
623
|
+
@signature_computation.signature(creds.secret_access_key, date, string_to_sign)
|
624
|
+
end
|
625
|
+
end
|
626
|
+
|
627
|
+
# Utility for extracting options and config
|
628
|
+
class SignerOptionExtractor
|
629
|
+
def self.extract_service(options)
|
630
|
+
if options[:service]
|
631
|
+
options[:service]
|
632
|
+
else
|
633
|
+
msg = 'missing required option :service'
|
634
|
+
raise ArgumentError, msg
|
635
|
+
end
|
636
|
+
end
|
637
|
+
|
638
|
+
def self.extract_region(options)
|
639
|
+
raise Errors::MissingRegionError unless options[:region]
|
640
|
+
|
641
|
+
options[:region]
|
642
|
+
end
|
643
|
+
|
644
|
+
def self.extract_credentials_provider(options)
|
645
|
+
if options[:credentials_provider]
|
646
|
+
options[:credentials_provider]
|
647
|
+
elsif options.key?(:credentials) || options.key?(:access_key_id)
|
648
|
+
StaticCredentialsProvider.new(options)
|
649
|
+
else
|
650
|
+
raise Errors::MissingCredentialsError
|
651
|
+
end
|
652
|
+
end
|
653
|
+
|
654
|
+
def self.initialize_unsigned_headers(options)
|
655
|
+
headers = Set.new(options.fetch(:unsigned_headers, []).map(&:downcase))
|
656
|
+
headers.merge(%w[authorization x-amzn-trace-id expect])
|
657
|
+
end
|
658
|
+
end
|
659
|
+
|
660
|
+
# Handles initialization of Signer dependencies
|
661
|
+
class SignerInitializer
|
662
|
+
def self.create_components(options = {})
|
663
|
+
service, region, credentials_provider = extract_core_components(options)
|
664
|
+
unsigned_headers = SignerOptionExtractor.initialize_unsigned_headers(options)
|
665
|
+
|
666
|
+
config_options = extract_config_options(options)
|
667
|
+
|
668
|
+
components = {
|
669
|
+
service: service,
|
670
|
+
region: region,
|
671
|
+
credentials_provider: credentials_provider,
|
672
|
+
unsigned_headers: unsigned_headers
|
673
|
+
}.merge(config_options)
|
674
|
+
|
675
|
+
create_service_components(components)
|
676
|
+
end
|
677
|
+
|
678
|
+
def self.extract_core_components(options)
|
679
|
+
service = SignerOptionExtractor.extract_service(options)
|
680
|
+
region = SignerOptionExtractor.extract_region(options)
|
681
|
+
credentials_provider = SignerOptionExtractor.extract_credentials_provider(options)
|
682
|
+
|
683
|
+
[service, region, credentials_provider]
|
684
|
+
end
|
685
|
+
|
686
|
+
def self.extract_config_options(options)
|
687
|
+
{
|
688
|
+
uri_escape_path: options.fetch(:uri_escape_path, true),
|
689
|
+
apply_checksum_header: options.fetch(:apply_checksum_header, true),
|
690
|
+
normalize_path: options.fetch(:normalize_path, true),
|
691
|
+
omit_session_token: options.fetch(:omit_session_token, false)
|
692
|
+
}
|
693
|
+
end
|
694
|
+
|
695
|
+
def self.create_service_components(components)
|
696
|
+
signature_generator = create_signature_generator(components)
|
697
|
+
header_builder = create_header_builder(components)
|
698
|
+
credential_manager = CredentialManager.new(components[:credentials_provider])
|
699
|
+
|
700
|
+
components.merge(
|
701
|
+
signature_generator: signature_generator,
|
702
|
+
header_builder: header_builder,
|
703
|
+
credential_manager: credential_manager
|
704
|
+
)
|
705
|
+
end
|
706
|
+
|
707
|
+
def self.create_signature_generator(components)
|
708
|
+
SignatureGenerator.new(
|
709
|
+
signing_algorithm: components[:signing_algorithm],
|
710
|
+
uri_escape_path: components[:uri_escape_path],
|
711
|
+
unsigned_headers: components[:unsigned_headers],
|
712
|
+
service: components[:service],
|
713
|
+
region: components[:region]
|
714
|
+
)
|
715
|
+
end
|
716
|
+
|
717
|
+
def self.create_header_builder(components)
|
718
|
+
HeaderBuilder.new(
|
719
|
+
signing_algorithm: components[:signing_algorithm],
|
720
|
+
apply_checksum_header: components[:apply_checksum_header],
|
721
|
+
omit_session_token: components[:omit_session_token],
|
722
|
+
region: components[:region]
|
723
|
+
)
|
724
|
+
end
|
725
|
+
end
|
726
|
+
|
727
|
+
# Handles AWS request signing using SigV4 or SigV4a
|
728
|
+
class Signer
|
729
|
+
# Initialize a new signer with the provided options
|
730
|
+
# @param [Hash] options Configuration options for the signer
|
731
|
+
def initialize(options = {})
|
732
|
+
components = SignerInitializer.create_components(options)
|
733
|
+
setup_configuration(components)
|
734
|
+
setup_service_components(components)
|
735
|
+
end
|
736
|
+
|
737
|
+
# @return [String]
|
738
|
+
attr_reader :service
|
739
|
+
|
740
|
+
# @return [String]
|
741
|
+
attr_reader :region
|
742
|
+
|
743
|
+
# @return [#credentials]
|
744
|
+
attr_reader :credentials_provider
|
745
|
+
|
746
|
+
# @return [Set<String>]
|
747
|
+
attr_reader :unsigned_headers
|
748
|
+
|
749
|
+
# @return [Boolean]
|
750
|
+
attr_reader :apply_checksum_header
|
751
|
+
|
752
|
+
# Sign an AWS request with SigV4 or SigV4a
|
753
|
+
# @param [Hash] request The request to sign
|
754
|
+
# @return [Signature] The signature with headers to apply
|
755
|
+
def sign_request(request)
|
756
|
+
creds = @credential_manager.fetch_credentials.first
|
757
|
+
request_components = extract_request_components(request)
|
758
|
+
sigv4_headers = build_sigv4_headers(request_components, creds)
|
759
|
+
signature = compute_signature(request_components, creds, sigv4_headers)
|
760
|
+
build_signature_response(request_components, sigv4_headers, signature)
|
761
|
+
end
|
762
|
+
|
763
|
+
private
|
764
|
+
|
765
|
+
def setup_configuration(components)
|
766
|
+
@service = components[:service]
|
767
|
+
@region = components[:region]
|
768
|
+
@credentials_provider = components[:credentials_provider]
|
769
|
+
@unsigned_headers = components[:unsigned_headers]
|
770
|
+
@uri_escape_path = components[:uri_escape_path]
|
771
|
+
@apply_checksum_header = components[:apply_checksum_header]
|
772
|
+
@signing_algorithm = components[:signing_algorithm]
|
773
|
+
@normalize_path = components[:normalize_path]
|
774
|
+
@omit_session_token = components[:omit_session_token]
|
775
|
+
end
|
776
|
+
|
777
|
+
def setup_service_components(components)
|
778
|
+
@signature_generator = components[:signature_generator]
|
779
|
+
@header_builder = components[:header_builder]
|
780
|
+
@credential_manager = components[:credential_manager]
|
781
|
+
end
|
782
|
+
|
783
|
+
def extract_request_components(request)
|
784
|
+
RequestExtractor.extract_components(
|
785
|
+
request,
|
786
|
+
normalize_path: @normalize_path
|
787
|
+
)
|
788
|
+
end
|
789
|
+
|
790
|
+
def build_sigv4_headers(request_components, creds)
|
791
|
+
@header_builder.build_sigv4_headers(request_components, creds)
|
792
|
+
end
|
793
|
+
|
794
|
+
def compute_signature(request_components, creds, sigv4_headers)
|
795
|
+
@signature_generator.compute_signature(
|
796
|
+
request_components,
|
797
|
+
creds,
|
798
|
+
sigv4_headers
|
799
|
+
)
|
800
|
+
end
|
801
|
+
|
802
|
+
def build_signature_response(components, sigv4_headers, signature)
|
803
|
+
headers = @header_builder.build_headers(sigv4_headers, signature, components)
|
804
|
+
|
805
|
+
Signature.new(
|
806
|
+
headers: headers,
|
807
|
+
string_to_sign: signature[:string_to_sign],
|
808
|
+
canonical_request: signature[:canonical_request],
|
809
|
+
content_sha256: components[:content_sha256],
|
810
|
+
signature: signature[:signature]
|
811
|
+
)
|
812
|
+
end
|
813
|
+
|
814
|
+
class << self
|
815
|
+
def uri_escape_path(path)
|
816
|
+
UriUtils.uri_escape_path(path)
|
817
|
+
end
|
818
|
+
|
819
|
+
def uri_escape(string)
|
820
|
+
UriUtils.uri_escape(string)
|
821
|
+
end
|
822
|
+
|
823
|
+
def normalize_path(uri)
|
824
|
+
UriUtils.normalize_path(uri)
|
825
|
+
end
|
826
|
+
end
|
827
|
+
end
|
828
|
+
end
|
829
|
+
end
|
830
|
+
end
|
831
|
+
end
|