gitlab-fog-azure-rm 2.5.0 → 2.5.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3c8efd2c5ce76c59fe96a72faceb7470f8a1bc0e75ffdef6808434583108cdf
4
- data.tar.gz: 72fce32424077553bb8c4b6e8a1a6ffa56307261352a801355b1a93878628715
3
+ metadata.gz: faf6ef8f40417712f91769e2ac0dea345c7451f1457919399c1aa574cf6a44a5
4
+ data.tar.gz: 4596861b6a717a743ff2d250f91f3bb4cafd911cb57e0efeeae14368e04162eb
5
5
  SHA512:
6
- metadata.gz: 6cf9691fa2a0423b6da2f5b0874447364207d0a1e10852a11f3e14cd5fc213fc20a8de8987263e70d0f04ac59d5c044779ced3c8bb8451d13622c608f1e73f29
7
- data.tar.gz: e92ddc401cdf2b5e606afd6342ef6a136e7d6dae56590fa27338cdfb42dd6a6b2f563f1cde3290274182e3d1370355cc94aefeb1b67261bb555ff031df631b8d
6
+ metadata.gz: f59d2e0764335c68f69462f380781e3a6f395467959f8af92f076dd4492f968d82bdc52b471f93a3fb1568e939c277e70cf6eac8fc79ed65e144b53eb1d966a2
7
+ data.tar.gz: ea77ec4d01cff9bebec6642ad94517c0df51246de8ab41f48241f8281841737c08da6f20ec17e9c79a9ad5d6f56bc51940c8cac169a17d226f0037c84ea5c706
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 2.5.1
4
+
5
+ - Fix thread-safety race in SAS signer credential refresh !64
6
+
3
7
  ## 2.5.0
4
8
 
5
9
  - Support Ruby 4 !61
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'time'
4
5
 
5
6
  module Fog
6
7
  module AzureRM
@@ -87,6 +88,25 @@ module Fog
87
88
 
88
89
  attr_accessor :options
89
90
 
91
+ # Immutable snapshot of a built SharedAccessSignature and the delegation
92
+ # key expiry it is valid until. It is published by a single reference
93
+ # assignment so signature_client can read it on a lock-free fast path.
94
+ # A nil expiry means the signature never expires (shared-key auth, which
95
+ # signs with the access key rather than a user delegation key).
96
+ class SignatureState
97
+ attr_reader :client, :expiry
98
+
99
+ def initialize(client, expiry)
100
+ @client = client
101
+ @expiry = expiry
102
+ freeze
103
+ end
104
+
105
+ def covers?(requested_expiry)
106
+ expiry.nil? || expiry >= requested_expiry
107
+ end
108
+ end
109
+
90
110
  def initialize(options)
91
111
  begin
92
112
  require 'azure/storage/common'
@@ -100,8 +120,8 @@ module Fog
100
120
  raise e.message
101
121
  end
102
122
 
103
- @user_delegation_key_mutex = Mutex.new
104
- @blob_client_mutex = Mutex.new
123
+ @credential_mutex = Mutex.new
124
+ @signature_mutex = Mutex.new
105
125
 
106
126
  options[:environment] = options[:environment] || ENV['AZURE_ENVIRONMENT'] || ENVIRONMENT_AZURE_CLOUD
107
127
  @environment = options[:environment]
@@ -117,7 +137,7 @@ module Fog
117
137
  @azure_storage_endpoint = options[:azure_storage_endpoint]
118
138
  @azure_storage_domain = options[:azure_storage_domain]
119
139
 
120
- refresh_blob_client
140
+ @blob_client = build_blob_client(@azure_storage_token_signer)
121
141
  end
122
142
 
123
143
  private
@@ -142,62 +162,119 @@ module Fog
142
162
  Azure::Storage::Common::Core::Auth::TokenSigner.new(cred)
143
163
  end
144
164
 
165
+ # Returns a SharedAccessSignature for signing a URL that expires at
166
+ # requested_expiry.
167
+ #
168
+ # A user delegation key is valid for up to a week
169
+ # (MAX_USER_DELEGATION_KEY_SECONDS), so the built signature is cached in
170
+ # a frozen @signature_state snapshot and reused on a lock-free fast path.
171
+ # This keeps signing off the lock in the common case, so an in-flight
172
+ # credential or delegation-key refresh on another thread never stalls it.
173
+ #
174
+ # Only the slow path (no cached signature, or the cached delegation key
175
+ # no longer covers the requested expiry) takes @signature_mutex, where
176
+ # the delegation-key fetch and the snapshot rebuild happen atomically.
177
+ # Holding the lock there also collapses a burst of concurrent refetches
178
+ # into a single fetch.
145
179
  def signature_client(requested_expiry)
146
- access_key = @azure_storage_access_key.to_s
147
- user_delegation_key = user_delegation_key(requested_expiry)
148
-
149
- # invalidate cache when the delegation key changes
150
- unless @signature_client_delegation_key == user_delegation_key
151
- @signature_client_delegation_key = user_delegation_key
152
- @signature_client = nil
180
+ # Keep the blob client's credentials fresh without blocking signing;
181
+ # see maybe_refresh_credentials.
182
+ maybe_refresh_credentials
183
+
184
+ state = @signature_state
185
+ return state.client if state&.covers?(requested_expiry)
186
+
187
+ @signature_mutex.synchronize do
188
+ # Re-check: another thread may have rebuilt the snapshot while we
189
+ # were waiting for the lock.
190
+ state = @signature_state
191
+ return state.client if state&.covers?(requested_expiry)
192
+
193
+ delegation_key = fetch_user_delegation_key(requested_expiry)
194
+ client = Azure::Storage::Common::Core::Auth::SharedAccessSignature.new(
195
+ @azure_storage_account_name,
196
+ @azure_storage_access_key.to_s,
197
+ delegation_key
198
+ )
199
+ @signature_state = SignatureState.new(client, @user_delegation_key_expiry)
200
+ client
153
201
  end
154
-
155
- @signature_client ||= Azure::Storage::Common::Core::Auth::SharedAccessSignature.new(
156
- @azure_storage_account_name,
157
- access_key,
158
- user_delegation_key
159
- )
160
202
  end
161
203
 
162
- def user_delegation_key(requested_expiry)
163
- return nil unless @azure_storage_token_signer
204
+ # Refreshes the credential-derived state (@credentials, the token signer
205
+ # and the blob client) when the access token is near expiry. Only the
206
+ # SAS URL requests reach this, through signature_client; the other blob
207
+ # operations read @blob_client directly and do not refresh, so this does
208
+ # not by itself keep their token fresh across rotation.
209
+ #
210
+ # The refresh is best-effort and non-blocking: a token fetch can be slow
211
+ # (or hang), so only one thread performs it at a time via a try-lock, and
212
+ # every other thread proceeds with the current, still-valid credentials
213
+ # rather than waiting. This keeps a slow token endpoint from stalling all
214
+ # callers that share the instance.
215
+ #
216
+ # The new signer and client are built into locals first and @credentials
217
+ # is published last. If building raises, @credentials is unchanged so
218
+ # refresh_needed? stays true and the next call retries, rather than
219
+ # leaving the swap half-applied with a stale signer or client.
220
+ def maybe_refresh_credentials
221
+ return unless @credential_client
222
+
223
+ current = @credentials
224
+ return unless current.nil? || current.refresh_needed?
225
+ return unless @credential_mutex.try_lock
226
+
227
+ begin
228
+ return unless @credentials.nil? || @credentials.refresh_needed?
164
229
 
165
- if @credential_client
166
230
  new_credentials = @credential_client.fetch_credentials_if_needed
167
- changed = new_credentials != @credentials
231
+ return if new_credentials.nil? || new_credentials == @credentials
168
232
 
169
- if changed
170
- @credentials = new_credentials
171
- @azure_storage_token_signer = token_signer
172
- refresh_blob_client
173
- end
233
+ signer = access_token_signer(new_credentials.token)
234
+ client = build_blob_client(signer)
235
+
236
+ @azure_storage_token_signer = signer
237
+ @blob_client = client
238
+ @credentials = new_credentials
239
+ ensure
240
+ @credential_mutex.unlock
174
241
  end
242
+ end
175
243
 
176
- @user_delegation_key_mutex.synchronize do
177
- if @user_delegation_key_expiry.nil? || @user_delegation_key_expiry < requested_expiry
178
- start = Time.now
179
- expiry = start + Azure::Storage::Blob::BlobConstants::MAX_USER_DELEGATION_KEY_SECONDS
180
-
181
- @user_delegation_key = @blob_client.get_user_delegation_key(
182
- start,
183
- expiry
184
- )
185
- @user_delegation_key_expiry = expiry
186
- end
244
+ # Returns the cached user delegation key, fetching a new one when the
245
+ # cache is empty or no longer covers requested_expiry. Must be called
246
+ # while holding @signature_mutex. Returns nil for shared-key auth, where
247
+ # the SAS is signed with the access key instead.
248
+ def fetch_user_delegation_key(requested_expiry)
249
+ return nil unless @azure_storage_token_signer
250
+
251
+ if @user_delegation_key_expiry.nil? || @user_delegation_key_expiry < requested_expiry
252
+ start = Time.now
253
+ expiry = start + Azure::Storage::Blob::BlobConstants::MAX_USER_DELEGATION_KEY_SECONDS
254
+
255
+ @user_delegation_key = @blob_client.get_user_delegation_key(start, expiry)
256
+ # Cache against the expiry Azure granted (signed_expiry), not the one
257
+ # we requested: Azure uses its own clock and may return an earlier
258
+ # time, and keying on the request would over-claim validity and emit
259
+ # SAS URLs that Azure rejects.
260
+ @user_delegation_key_expiry = Time.parse(@user_delegation_key.signed_expiry)
187
261
  end
188
262
 
189
263
  @user_delegation_key
190
264
  end
191
265
 
192
- def refresh_blob_client
193
- @blob_client_mutex.synchronize do
194
- azure_client = create_azure_client
195
- azure_client.storage_blob_host = storage_blob_host
196
- @blob_client = Azure::Storage::Blob::BlobService.new(client: azure_client, api_version: @api_version)
197
- @blob_client.with_filter(Fog::AzureRM::IdentityEncodingFilter.new)
198
- @blob_client.with_filter(Azure::Storage::Common::Core::Filter::ExponentialRetryPolicyFilter.new)
199
- @blob_client.with_filter(Azure::Core::Http::DebugFilter.new) if @debug
200
- end
266
+ # Builds and returns a fully configured blob client. The caller publishes
267
+ # it with a single reference assignment (see initialize and
268
+ # maybe_refresh_credentials), so a concurrent reader of @blob_client never
269
+ # observes a client that is missing its filters mid-build.
270
+ def build_blob_client(signer)
271
+ azure_client = create_azure_client(signer)
272
+ azure_client.storage_blob_host = storage_blob_host
273
+ client = Azure::Storage::Blob::BlobService.new(client: azure_client, api_version: @api_version)
274
+ client.with_filter(Fog::AzureRM::IdentityEncodingFilter.new)
275
+ client.with_filter(Azure::Storage::Common::Core::Filter::ExponentialRetryPolicyFilter.new)
276
+ client.with_filter(Azure::Core::Http::DebugFilter.new) if @debug
277
+ client
201
278
  end
202
279
 
203
280
  def storage_blob_host
@@ -210,11 +287,11 @@ module Fog
210
287
  end
211
288
  end
212
289
 
213
- def create_azure_client
290
+ def create_azure_client(signer)
214
291
  Azure::Storage::Common::Client.create({
215
292
  storage_account_name: @azure_storage_account_name,
216
293
  storage_access_key: @azure_storage_access_key,
217
- signer: @azure_storage_token_signer
294
+ signer: signer
218
295
  }.compact)
219
296
  end
220
297
  end
@@ -1,5 +1,5 @@
1
1
  module Fog
2
2
  module AzureRM
3
- VERSION = '2.5.0'.freeze
3
+ VERSION = '2.5.1'.freeze
4
4
  end
5
5
  end
@@ -118,18 +118,7 @@ class TestGetBlobHttpsUrl < Minitest::Test
118
118
 
119
119
  requested_expiry = Time.now + 60
120
120
 
121
- response = <<~MSG
122
- <UserDelegationKey>
123
- <SignedOid>f81d4fae-7dec-11d0-a765-00a0c91e6bf6</SignedOid>
124
- <SignedTid>72f988bf-86f1-41af-91ab-2d7cd011db47</SignedTid>
125
- <SignedStart>2024-09-19T00:00:00Z</SignedStart>
126
- <SignedExpiry>2024-09-26T00:00:00Z</SignedExpiry>
127
- <SignedService>b</SignedService>
128
- <SignedVersion>2020-02-10</SignedVersion>
129
- <Value>UDELEGATIONKEYXYZ....</Value>
130
- <SignedKey>rL7...ABC</SignedKey>
131
- </UserDelegationKey>
132
- MSG
121
+ response = user_delegation_key_response
133
122
 
134
123
  stub_request(:post, 'https://mockaccount.blob.core.windows.net?comp=userdelegationkey&restype=service')
135
124
  .to_return(status: 200, headers: { 'Content-Type': 'application/xml' }, body: response)
@@ -159,29 +148,19 @@ class TestGetBlobHttpsUrl < Minitest::Test
159
148
 
160
149
  ref_time = Time.now
161
150
 
162
- stubbed_times = []
163
- requested_expiries = []
164
- expected_user_delegation_key_starts = []
151
+ # Each entry: [Time.now offset from ref_time, requested-expiry offset from
152
+ # that time, whether a fresh delegation key is expected]. A delegation key
153
+ # is valid for one WEEK from its start.
154
+ plan = [
155
+ [0, 1 * HOUR, true], # initial request
156
+ [5.5 * DAY, 1 * DAY, false], # second request during expiry window
157
+ [6.5 * DAY, 1 * DAY, true], # request extending past current expiry
158
+ [10.5 * DAY, 1 * DAY, false] # second request within new expiry
159
+ ]
165
160
 
166
- # initial request
167
- stubbed_times << ref_time
168
- requested_expiries << stubbed_times.last + 1 * HOUR
169
- expected_user_delegation_key_starts << stubbed_times.last
170
-
171
- # second request during expiry window
172
- stubbed_times << ref_time + 5.5 * DAY
173
- requested_expiries << stubbed_times.last + 1 * DAY
174
- # no additonal expected_user_delegation_key_starts
175
-
176
- # request extending past current expiry
177
- stubbed_times << ref_time + 6.5 * DAY
178
- requested_expiries << stubbed_times.last + 1 * DAY
179
- expected_user_delegation_key_starts << stubbed_times.last
180
-
181
- # second request within new expiry
182
- stubbed_times << ref_time + 10.5 * DAY
183
- requested_expiries << stubbed_times.last + 1 * DAY
184
- # no additional expected_user_delegation_key_starts
161
+ stubbed_times = plan.map { |now_offset,| ref_time + now_offset }
162
+ requested_expiries = stubbed_times.each_with_index.map { |time, i| time + plan[i][1] }
163
+ expected_user_delegation_key_starts = stubbed_times.select.with_index { |_, i| plan[i][2] }
185
164
 
186
165
  user_delegation_key_starts = []
187
166
  mock_user_delegation_key = lambda do |start, expiry|
@@ -190,7 +169,7 @@ class TestGetBlobHttpsUrl < Minitest::Test
190
169
 
191
170
  key = Azure::Storage::Common::Service::UserDelegationKey.new
192
171
  key.signed_start = "start-#{start.to_i}"
193
- key.signed_expiry = 'test-expiry'
172
+ key.signed_expiry = expiry.utc.iso8601 # Azure grants the full requested window here
194
173
  key.value = 'delegation-key'
195
174
  key
196
175
  end
@@ -201,7 +180,7 @@ class TestGetBlobHttpsUrl < Minitest::Test
201
180
  end
202
181
 
203
182
  requested_expiries.each do
204
- mock_token_signer.expect(:sign, 'test-signature', [/\Ar\n.+test_blob\n.+\nstart-\d+\ntest-expiry/m])
183
+ mock_token_signer.expect(:sign, 'test-signature', [/\Ar\n.+test_blob\n.+\nstart-\d+\n\S+/m])
205
184
  end
206
185
 
207
186
  Time.stub :now, -> { stubbed_times.first } do
@@ -220,6 +199,42 @@ class TestGetBlobHttpsUrl < Minitest::Test
220
199
  assert_equal expected_user_delegation_key_starts, user_delegation_key_starts
221
200
  end
222
201
 
202
+ # Regression: the delegation-key cache keys on the expiry Azure granted
203
+ # (signed_expiry), not the requested one. Keying on the request would serve a
204
+ # cached key for a window Azure already ended, so this asserts a refetch.
205
+ def test_delegation_key_cache_uses_signed_expiry_not_requested
206
+ service = Fog::AzureRM::Storage.new(storage_account_credentials_with_token_signer)
207
+ blob_client = service.instance_variable_get(:@blob_client)
208
+
209
+ ref_time = Time.now
210
+ granted_expiry = (ref_time + 2 * DAY).utc # Azure grants far less than the 1-week request
211
+
212
+ starts = []
213
+ mock_key = lambda do |start, _expiry|
214
+ starts << start
215
+ key = Azure::Storage::Common::Service::UserDelegationKey.new
216
+ key.signed_start = "start-#{start.to_i}"
217
+ key.signed_expiry = granted_expiry.iso8601
218
+ key.value = 'delegation-key'
219
+ key
220
+ end
221
+ mock_new_signer = ->(_token) { mock_token_signer }
222
+ 2.times { mock_token_signer.expect(:sign, 'test-signature', [String]) }
223
+
224
+ Time.stub :now, -> { ref_time } do
225
+ blob_client.stub :get_user_delegation_key, mock_key do
226
+ Azure::Core::Auth::Signer.stub :new, mock_new_signer do
227
+ service.get_blob_https_url('test_container', 'test_blob', ref_time + 1 * HOUR)
228
+ # Requested expiry is past Azure's granted window but within start+MAX.
229
+ service.get_blob_https_url('test_container', 'test_blob', ref_time + 3 * DAY)
230
+ end
231
+ end
232
+ end
233
+
234
+ assert_equal 2, starts.length,
235
+ 'expected a refetch once the requested expiry exceeded the granted signed_expiry'
236
+ end
237
+
223
238
  def test_get_blob_https_url_mock
224
239
  assert_equal "#{@url}?#{@token}", @mock_service.get_blob_https_url('test_container', 'test_blob', Time.now.utc + 3600)
225
240
  assert_equal "#{@url}?#{@token}", @mock_service.get_object_url('test_container', 'test_blob', Time.now.utc + 3600)
@@ -247,18 +262,9 @@ class TestGetBlobHttpsUrl < Minitest::Test
247
262
  )
248
263
 
249
264
  # Setup user delegation key responses
250
- delegation_response = <<~MSG
251
- <UserDelegationKey>
252
- <SignedOid>f81d4fae-7dec-11d0-a765-00a0c91e6bf6</SignedOid>
253
- <SignedTid>72f988bf-86f1-41af-91ab-2d7cd011db47</SignedTid>
254
- <SignedStart>2024-09-19T00:00:00Z</SignedStart>
255
- <SignedExpiry>2024-09-26T00:00:00Z</SignedExpiry>
256
- <SignedService>b</SignedService>
257
- <SignedVersion>2020-02-10</SignedVersion>
258
- <Value>UDELEGATIONKEY_INITIAL</Value>
259
- <SignedKey>rL7...INITIAL</SignedKey>
260
- </UserDelegationKey>
261
- MSG
265
+ delegation_response = user_delegation_key_response(
266
+ value: 'UDELEGATIONKEY_INITIAL', signed_key: 'rL7...INITIAL'
267
+ )
262
268
 
263
269
  stub_request(:post, 'https://mockaccount.blob.core.windows.net?comp=userdelegationkey&restype=service')
264
270
  .to_return(
@@ -0,0 +1,138 @@
1
+ require File.expand_path '../../test_helper', __dir__
2
+ require 'uri'
3
+ require 'azure/storage/common'
4
+ require 'azure/storage/blob'
5
+ require 'azure/core/auth/signer'
6
+
7
+ # Tests for the concurrency guarantees of SAS signing. Each drives the public
8
+ # get_blob_https_url (which calls signature_client and maybe_refresh_credentials)
9
+ # and checks one property in isolation, so no private methods or stress loops
10
+ # are needed.
11
+ class TestStorageThreadSafety < Minitest::Test
12
+ CONTAINER = 'test_container'.freeze
13
+ BLOB = 'test_blob'.freeze
14
+
15
+ # A blob client stand-in that needs no network. Optionally parks on its first
16
+ # with_filter call so a test can inspect state while a rebuild is in progress.
17
+ class FakeBlobClient
18
+ def initialize(&on_first_filter)
19
+ @filters = []
20
+ @on_first_filter = on_first_filter
21
+ end
22
+
23
+ def with_filter(filter)
24
+ @on_first_filter.call if @filters.empty? && @on_first_filter
25
+ @filters << filter
26
+ self
27
+ end
28
+
29
+ def generate_uri(path, *)
30
+ URI.parse("https://mockaccount.blob.core.windows.net/#{path}")
31
+ end
32
+
33
+ def get_user_delegation_key(*)
34
+ key = Azure::Storage::Common::Service::UserDelegationKey.new
35
+ key.value = 'ZGVsZWdhdGlvbg==' # any string; Base64.decode64 is lenient
36
+ key.signed_expiry = '2999-12-31T23:59:59Z' # far future so the cached key covers the request
37
+ key
38
+ end
39
+ end
40
+
41
+ # Stands in for @signature_mutex and fails the test if the lock is taken.
42
+ class RaisingMutex
43
+ def synchronize
44
+ raise 'signature_client took the lock when it should have hit the cache'
45
+ end
46
+ end
47
+
48
+ def setup
49
+ @service = Fog::AzureRM::Storage.new(storage_account_credentials_with_token_signer)
50
+ end
51
+
52
+ def in_one_hour
53
+ Time.now.utc + 3600
54
+ end
55
+
56
+ def credentials(expires_at)
57
+ Fog::AzureRM::Identity::Credentials.new('token', expires_at)
58
+ end
59
+
60
+ def credential_client(&fetch)
61
+ client = Object.new
62
+ client.define_singleton_method(:fetch_credentials_if_needed, &fetch)
63
+ client
64
+ end
65
+
66
+ # A cached signature that still covers the requested expiry is returned
67
+ # without taking @signature_mutex, so signing never waits on a refresh.
68
+ def test_cached_signature_is_returned_without_locking
69
+ @service.instance_variable_set(:@blob_client, FakeBlobClient.new)
70
+ @service.get_blob_https_url(CONTAINER, BLOB, in_one_hour) # builds and caches it
71
+
72
+ @service.instance_variable_set(:@signature_mutex, RaisingMutex.new)
73
+
74
+ url = @service.get_blob_https_url(CONTAINER, BLOB, in_one_hour)
75
+ assert_match %r{\Ahttps://mockaccount\.blob\.core\.windows\.net/#{CONTAINER}/#{BLOB}\?}, url
76
+ end
77
+
78
+ # While one thread is stuck in a slow token fetch, another caller must still
79
+ # return instead of blocking on the credential lock.
80
+ def test_a_slow_credential_refresh_does_not_block_other_callers
81
+ entered_fetch = Queue.new
82
+ finish_fetch = Queue.new
83
+ fresh = credentials(Time.now + 3600)
84
+
85
+ slow_client = credential_client do
86
+ entered_fetch << true
87
+ finish_fetch.pop # simulate a slow or hanging token endpoint
88
+ fresh
89
+ end
90
+
91
+ Azure::Storage::Blob::BlobService.stub(:new, ->(*, **) { FakeBlobClient.new }) do
92
+ @service.instance_variable_set(:@blob_client, FakeBlobClient.new)
93
+ @service.instance_variable_set(:@credential_client, slow_client)
94
+ @service.instance_variable_set(:@credentials, credentials(Time.now - 1))
95
+
96
+ refreshing = Thread.new { @service.get_blob_https_url(CONTAINER, BLOB, in_one_hour) }
97
+ entered_fetch.pop # the refresh now holds @credential_mutex inside the fetch
98
+
99
+ other = Thread.new { @service.get_blob_https_url(CONTAINER, BLOB, in_one_hour) }
100
+ returned = other.join(5)
101
+
102
+ begin
103
+ refute_nil returned, 'a second caller blocked while one thread was refreshing credentials'
104
+ ensure
105
+ finish_fetch << true
106
+ [refreshing, other].each(&:join)
107
+ end
108
+ end
109
+ end
110
+
111
+ # A rebuilt blob client is published only once it is fully built, so a
112
+ # concurrent reader never sees one that is missing its filters.
113
+ def test_rebuilt_blob_client_is_published_only_when_fully_built
114
+ original = @service.instance_variable_get(:@blob_client)
115
+ fresh = credentials(Time.now + 3600)
116
+ @service.instance_variable_set(:@credential_client, credential_client { fresh })
117
+ @service.instance_variable_set(:@credentials, credentials(Time.now - 1))
118
+
119
+ mid_build = Queue.new
120
+ resume = Queue.new
121
+ half_built = FakeBlobClient.new do
122
+ mid_build << true
123
+ resume.pop
124
+ end
125
+
126
+ Azure::Storage::Blob::BlobService.stub(:new, ->(*, **) { half_built }) do
127
+ builder = Thread.new { @service.get_blob_https_url(CONTAINER, BLOB, in_one_hour) }
128
+ mid_build.pop # the new client exists but its filters are not applied yet
129
+ observed = @service.instance_variable_get(:@blob_client)
130
+ resume << true
131
+ builder.join
132
+
133
+ refute_same half_built, observed, 'the half-built client was published before its filters were applied'
134
+ assert_same original, observed
135
+ assert_same half_built, @service.instance_variable_get(:@blob_client)
136
+ end
137
+ end
138
+ end
data/test/test_helper.rb CHANGED
@@ -79,6 +79,21 @@ def mock_token_signer
79
79
  @mock_token_signer ||= Minitest::Mock.new(Azure::Core::Auth::Signer.new('access-token'))
80
80
  end
81
81
 
82
+ def user_delegation_key_response(value: 'UDELEGATIONKEYXYZ....', signed_key: 'rL7...ABC')
83
+ <<~MSG
84
+ <UserDelegationKey>
85
+ <SignedOid>f81d4fae-7dec-11d0-a765-00a0c91e6bf6</SignedOid>
86
+ <SignedTid>72f988bf-86f1-41af-91ab-2d7cd011db47</SignedTid>
87
+ <SignedStart>2024-09-19T00:00:00Z</SignedStart>
88
+ <SignedExpiry>2024-09-26T00:00:00Z</SignedExpiry>
89
+ <SignedService>b</SignedService>
90
+ <SignedVersion>2020-02-10</SignedVersion>
91
+ <Value>#{value}</Value>
92
+ <SignedKey>#{signed_key}</SignedKey>
93
+ </UserDelegationKey>
94
+ MSG
95
+ end
96
+
82
97
  # Mock Class for Blob
83
98
  class MockBlob
84
99
  def initialize
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-fog-azure-rm
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.5.0
4
+ version: 2.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shaffan Chaudhry
@@ -18,7 +18,7 @@ authors:
18
18
  autorequire:
19
19
  bindir: bin
20
20
  cert_chain: []
21
- date: 2026-03-31 00:00:00.000000000 Z
21
+ date: 2026-09-10 00:00:00.000000000 Z
22
22
  dependencies:
23
23
  - !ruby/object:Gem::Dependency
24
24
  name: codeclimate-test-reporter
@@ -476,6 +476,7 @@ files:
476
476
  - test/requests/storage/test_release_blob_lease.rb
477
477
  - test/requests/storage/test_release_container_lease.rb
478
478
  - test/requests/storage/test_save_page_blob.rb
479
+ - test/requests/storage/test_storage_thread_safety.rb
479
480
  - test/requests/storage/test_wait_blob_copy_operation_to_finish.rb
480
481
  - test/test_helper.rb
481
482
  - test/unit/test_credentials.rb