dalli 4.3.6 → 4.3.7

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: dd77fe4e8ef65f531c5d9eb4a8e638feb6e443493cd57125bad104867399cd19
4
- data.tar.gz: 814cc63401565f306a2aa6045476579f93b028fff8802ea443cde65375328636
3
+ metadata.gz: 4b1a68fbdf9ea89024d6e9bc5803f94d3342c2f7b8bd7cfea3a97f7a873f7b76
4
+ data.tar.gz: 93d1c2fcb71fa739e6a00808cddb11567ad9fed3caa4d0751c924576bc04bfe0
5
5
  SHA512:
6
- metadata.gz: c983d9e656540f313aaea157b8d0290c3610b7c03575625a6e8cfba4ec5be99a893955506b409867689de1a5f4acc7715eee07928df8ef5c8724141ffae243b2
7
- data.tar.gz: 62d38b83c8ba58ec871613b25e5f197d14c768436cde9f3aa382189dd6af19802832c545474add6a8b8b9edb373c948cde2db15599b5bba1b15720db71d1cfed
6
+ metadata.gz: bc42cde8b6f61ba60768bf43a9ebf8b1088dc8b2efbb4e32b5ed1abedd3c3be81c66aa02dc1a2558fd319debcc8074e61471996ad8825afed32d4fd741f8a312
7
+ data.tar.gz: 3a8320685a39edef196feaa387ef954caea41650b540c7f37d32d4821b5804f41631e5346e14b21912de0963f147c7d55e8c1ad08e24d2f7d25073975ba64d72
data/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 4.3.7
8
+ ==========
9
+
10
+ Security:
11
+
12
+ - Keep the caller's key when retrying a request (GHSA-m252-9cgf-vx2w)
13
+ - With a `namespace`, a request retried after a transient network error (a timeout, or a connection closed by memcached or a proxy) applied the namespace a second time, so a retried read could return a different key's value and a retried write could overwrite a different key
14
+ - Affects `get_with_metadata` and `fetch_with_lock` since 4.1.0 (single-key operations on 4.x aren't affected); fixed in 5.2.2, 5.1.4, 5.0.10 and 4.3.7. Clients without a namespace aren't affected
15
+ - Complete the fix for per-request `raw: true` on reads (GHSA-wr87-m4jw-29x5)
16
+ - `cas` and `cas!` (both protocols) and `fetch_with_lock` (meta protocol) accepted `raw: true` but didn't pass it to the read, so the value was still deserialized. They now honor it
17
+ - A read made with `raw: true` doesn't ask for flags, but a reply carrying them anyway (from a proxy or a hostile server) still had its value deserialized. Meta protocol raw reads now ignore flags in the reply
18
+ - Affects 4.3.6 and earlier; fixed in 5.2.2, 5.1.4, 5.0.10, 4.3.7 and 3.2.13
19
+ - Complete the fork-safety fix (GHSA-w39f-xq2m-4g8x)
20
+ - Fork detection used a pid cache refreshed by a `Process._fork` hook. When another library's fork hook ran first in the child (connection_pool, loaded before Dalli as in Rails, closes its connections there), Dalli didn't know it was in a forked child and ended the parent's TLS session. It now checks `Process.pid`
21
+ - 4.3.6's write buffer kept a reference to the caller's value string with the meta protocol, so changing the string before the buffer was sent (for example, reusing one buffer inside a `quiet` block) changed the bytes sent, losing values or adding commands. The buffer now holds a copy
22
+ - Fixed in 4.3.7 and 3.2.13
23
+
24
+ Bug fixes:
25
+
26
+ - `get_with_metadata` and `fetch_with_lock` retried the final error raised when a server is marked down, so with `down_retry_delay: 0` they retried an unresponsive server forever. They now retry only retryable errors, like other operations
27
+ - Fix a regression in 4.3.6: a `get_multi` that didn't finish within `socket_timeout` counted toward `socket_max_failures`, so two slow `get_multi` calls in a row marked a healthy server down. It now just closes the connection
28
+ - Fix a regression in 4.3.6: over TLS, every request buffered in a `quiet` block was sent as its own TLS record and system call (2.5 times slower for a block of 2000 deletes). Each flush is one write again
29
+ - Handle malformed replies from a broken or hostile server or proxy: a negative value size, a hit with no key, or a hit with no `s` flag could leave the connection out of step or raise a non-Dalli error
30
+ - With `decompressed_max_bytes` in effect, data after the end of a compressed value's stream was returned as part of the value. It's now ignored, as without the limit
31
+ - With a `digest_class` whose digests aren't short hex strings, shortening a long key could loop forever. It now raises `ArgumentError`
32
+
33
+ Development:
34
+
35
+ - Run the Tests, RuboCop and Profiles workflows on pushes to `main` and the `*-stable` branches only, so a pull request's branch isn't tested twice (backport of #1198)
36
+
7
37
  4.3.6
8
38
  ==========
9
39
 
data/lib/dalli/client.rb CHANGED
@@ -134,14 +134,18 @@ module Dalli
134
134
  def get_with_metadata(key, options = {})
135
135
  raise_unless_meta_protocol!
136
136
 
137
- key = key.to_s
138
- key = @key_manager.validate_key(key)
137
+ # A retry re-runs this method with its original arguments, so `key` must
138
+ # stay as the caller gave it; reassigning it would add the namespace again.
139
+ validated_key = @key_manager.validate_key(key.to_s)
139
140
 
140
- server = ring.server_for_key(key)
141
- Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', key, server)) do
142
- server.request(:meta_get, key, options)
141
+ server = ring.server_for_key(validated_key)
142
+ Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', validated_key, server)) do
143
+ server.request(:meta_get, validated_key, options)
143
144
  end
144
- rescue NetworkError => e
145
+ # Only a retryable error is retried. The NetworkError raised when a server
146
+ # is marked down is final, as in #perform; retrying it looped forever once
147
+ # down_retry_delay let the same server be tried again.
148
+ rescue RetryableNetworkError => e
145
149
  Dalli.logger.debug { e.inspect }
146
150
  Dalli.logger.debug { 'retrying get_with_metadata with new server' }
147
151
  retry
@@ -238,14 +242,17 @@ module Dalli
238
242
  validate_integer!(:lock_ttl, lock_ttl)
239
243
  validate_integer!(:recache_threshold, recache_threshold)
240
244
 
241
- key = key.to_s
242
- key = @key_manager.validate_key(key)
245
+ # Left unchanged for the retry below; see get_with_metadata
246
+ validated_key = @key_manager.validate_key(key.to_s)
243
247
 
244
- server = ring.server_for_key(key)
245
- Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', key, server)) do
246
- fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options, &block)
248
+ server = ring.server_for_key(validated_key)
249
+ Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', validated_key, server)) do
250
+ fetch_with_lock_request(validated_key, ttl, lock_ttl, recache_threshold, req_options, &block)
247
251
  end
248
- rescue NetworkError => e
252
+ # Only a retryable error is retried. The NetworkError raised when a server
253
+ # is marked down is final, as in #perform; retrying it looped forever once
254
+ # down_retry_delay let the same server be tried again.
255
+ rescue RetryableNetworkError => e
249
256
  Dalli.logger.debug { e.inspect }
250
257
  Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
251
258
  retry
@@ -592,7 +599,8 @@ module Dalli
592
599
  end
593
600
 
594
601
  def cas_core(key, always_set, ttl = nil, req_options = nil)
595
- (value, cas) = perform(:cas, key)
602
+ # req_options reaches the read too, so raw: true returns the stored bytes
603
+ (value, cas) = perform(:cas, key, req_options)
596
604
  return if value.nil? && !always_set
597
605
 
598
606
  newvalue = yield(value)
@@ -601,7 +609,12 @@ module Dalli
601
609
 
602
610
  def fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options)
603
611
  server = ring.server_for_key(key)
604
- result = server.request(:meta_get, key, { vivify_ttl: lock_ttl, recache_ttl: recache_threshold })
612
+ # req_options (e.g. raw: true) reaches the read too. It's the base, not
613
+ # the override: fetch_with_lock's own lock_ttl/recache_threshold win.
614
+ meta_options = req_options.is_a?(Hash) ? req_options.dup : {}
615
+ meta_options[:vivify_ttl] = lock_ttl
616
+ meta_options[:recache_ttl] = recache_threshold
617
+ result = server.request(:meta_get, key, meta_options)
605
618
 
606
619
  return result[:value] unless result[:won_recache]
607
620
 
@@ -39,7 +39,12 @@ module Dalli
39
39
  out = String.new(encoding: Encoding::BINARY)
40
40
  0.step(data.bytesize - 1, INFLATE_SLICE_BYTES) do |pos|
41
41
  inflate_slice(inflater, data.byteslice(pos, INFLATE_SLICE_BYTES), out, max_bytes)
42
+ # Anything after the end of the stream isn't part of the value, as
43
+ # with Zlib::Inflate.inflate; finish would return it as output
44
+ break if inflater.finished?
42
45
  end
46
+ return out if inflater.finished?
47
+
43
48
  append_within_limit(out, inflater.finish, max_bytes)
44
49
  ensure
45
50
  inflater.close
@@ -125,15 +125,18 @@ module Dalli
125
125
  ##
126
126
  def truncated_key(key)
127
127
  digest = digest_class.hexdigest(key)
128
- prefix = key[0, prefix_length(digest)]
128
+ prefix = key[0, [prefix_length(digest), 0].max]
129
129
  truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
130
130
  # A prefix with whitespace or non-ASCII characters sends the whole key
131
131
  # base64-encoded, which can still be too long on the wire; shorten it.
132
- while wire_length(truncated) > MAX_KEY_LENGTH
132
+ while wire_length(truncated) > MAX_KEY_LENGTH && !prefix.empty?
133
133
  prefix = prefix[0...-1]
134
134
  truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
135
135
  end
136
- truncated
136
+ return truncated if wire_length(truncated) <= MAX_KEY_LENGTH
137
+
138
+ # Only possible with a digest_class whose digests aren't short hex strings
139
+ raise ArgumentError, "#{digest_class} digests are too long to fit in a truncated key"
137
140
  end
138
141
 
139
142
  # memcached's key limit applies to the key as sent: in bytes, and, with
@@ -152,10 +152,13 @@ module Dalli
152
152
  @connection_manager.abort_request!
153
153
  return true unless connected?
154
154
 
155
- # Closes the connection, which ensures that our connection
156
- # is in a clean state for future requests
157
- @connection_manager.error_on_request!('External timeout')
158
- rescue NetworkError
155
+ # Closes the connection, which ensures that our connection is in a
156
+ # clean state for future requests. The get_multi ran out of time
157
+ # overall rather than hitting a socket error, so it doesn't count
158
+ # toward socket_max_failures: two slow get_multis in a row would
159
+ # otherwise mark a healthy server down.
160
+ Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
161
+ @connection_manager.close
159
162
  true
160
163
  end
161
164
 
@@ -102,7 +102,8 @@ module Dalli
102
102
  true
103
103
  end
104
104
 
105
- def data_cas_response
105
+ # raw: true returns the value as stored; see #get
106
+ def data_cas_response(raw: false)
106
107
  resp_header, body = read_response
107
108
  return [nil, resp_header.cas] if resp_header.not_found?
108
109
  return [nil, false] if resp_header.not_stored?
@@ -110,7 +111,7 @@ module Dalli
110
111
  raise_on_not_ok!(resp_header)
111
112
  return [nil, resp_header.cas] unless body
112
113
 
113
- [unpack_response_body(resp_header, body, true).last, resp_header.cas]
114
+ [unpack_response_body(resp_header, body, !raw).last, resp_header.cas]
114
115
  end
115
116
 
116
117
  # Returns the new value for the key, if found and updated
@@ -47,11 +47,11 @@ module Dalli
47
47
 
48
48
  # TODO: This is confusing, as there's a cas command in memcached
49
49
  # and this isn't it. Maybe rename? Maybe eliminate?
50
- def cas(key)
50
+ def cas(key, options = nil)
51
51
  req = RequestFormatter.standard_request(opkey: :get, key: key)
52
52
  write(req)
53
53
  @connection_manager.flush
54
- response_processor.data_cas_response
54
+ response_processor.data_cas_response(raw: raw_request?(options))
55
55
  end
56
56
 
57
57
  # Storage Commands
@@ -58,7 +58,7 @@ module Dalli
58
58
  @sock = memcached_socket
59
59
  # Writes are buffered in @write_buffer instead; see WRITE_BUFFER_FLUSH_BYTES
60
60
  @sock.sync = true
61
- @pid = PIDCache.pid
61
+ @pid = Process.pid
62
62
  @request_in_progress = false
63
63
  rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
64
64
  # SocketError = DNS resolution failure
@@ -204,7 +204,10 @@ module Dalli
204
204
  WRITE_BUFFER_FLUSH_BYTES = 64 * 1024
205
205
 
206
206
  def write(bytes)
207
- @write_buffer << bytes
207
+ # A copy: the caller may change its string before the buffer is sent
208
+ # (the meta protocol writes a value string as its own part), as Ruby's
209
+ # IO buffer copied on write
210
+ @write_buffer << (bytes.frozen? ? bytes : bytes.dup)
208
211
  @write_buffer_bytes += bytes.bytesize
209
212
  flush_write_buffer if @write_buffer_bytes >= WRITE_BUFFER_FLUSH_BYTES
210
213
  bytes.bytesize
@@ -282,7 +285,11 @@ module Dalli
282
285
  end
283
286
 
284
287
  def fork_detected?
285
- @pid && @pid != PIDCache.pid
288
+ # Process.pid rather than PIDCache: PIDCache is refreshed by a
289
+ # Process._fork hook, and another library's fork hook (such as
290
+ # connection_pool closing its connections) can run in the child before
291
+ # it, which would close the parent's TLS session as if it were ours.
292
+ @pid && @pid != Process.pid
286
293
  end
287
294
 
288
295
  def log_down_detected
@@ -306,13 +313,32 @@ module Dalli
306
313
 
307
314
  private
308
315
 
316
+ # One write per flush. A TLS socket sends each argument of
317
+ # write(*parts) as its own record and system call, so the parts are
318
+ # joined first.
309
319
  def flush_write_buffer
310
320
  return if @write_buffer.empty?
311
321
 
312
- @sock.write(*@write_buffer)
322
+ @sock.write(@write_buffer.size == 1 ? @write_buffer.first : joined_write_buffer)
313
323
  discard_write_buffer
314
324
  end
315
325
 
326
+ # Joined as bytes: requests can mix UTF-8 and binary strings, which
327
+ # String#<< refuses to combine when both hold non-ASCII bytes
328
+ if String.method_defined?(:append_as_bytes)
329
+ def joined_write_buffer
330
+ out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
331
+ @write_buffer.each { |part| out.append_as_bytes(part) }
332
+ out
333
+ end
334
+ else
335
+ def joined_write_buffer
336
+ out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
337
+ @write_buffer.each { |part| out << part.b }
338
+ out
339
+ end
340
+ end
341
+
316
342
  def discard_write_buffer
317
343
  @write_buffer.clear
318
344
  @write_buffer_bytes = 0
@@ -28,22 +28,25 @@ module Dalli
28
28
  @value_marshaller = value_marshaller
29
29
  end
30
30
 
31
- def meta_get_with_value(cache_nils: false)
31
+ # raw: true (a request made with raw: true) returns the value as stored,
32
+ # ignoring any flags in the reply. The request didn't ask for flags, so
33
+ # a reply carrying them anyway must not get the value deserialized.
34
+ def meta_get_with_value(cache_nils: false, raw: false)
32
35
  tokens = error_on_unexpected!([VA, EN, HD])
33
36
  return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
34
37
  return true unless tokens.first == VA
35
38
 
36
- @value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens))
39
+ retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
37
40
  end
38
41
 
39
- def meta_get_with_value_and_cas
42
+ def meta_get_with_value_and_cas(raw: false)
40
43
  tokens = error_on_unexpected!([VA, EN, HD])
41
44
  return [nil, 0] if tokens.first == EN
42
45
 
43
46
  cas = cas_from_tokens(tokens)
44
47
  return [nil, cas] unless tokens.first == VA
45
48
 
46
- [@value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens)), cas]
49
+ [retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw), cas]
47
50
  end
48
51
 
49
52
  def meta_get_without_value
@@ -62,12 +65,13 @@ module Dalli
62
65
  #
63
66
  # Used by meta_get for comprehensive metadata retrieval.
64
67
  # Supports thundering herd protection (N/R flags) and metadata flags (h/l/u).
65
- def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false)
68
+ def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false,
69
+ raw: false)
66
70
  tokens = error_on_unexpected!([VA, EN, HD])
67
71
  result = build_metadata_result(tokens)
68
72
  result[:hit_before] = hit_status_from_tokens(tokens) if return_hit_status
69
73
  result[:last_access] = last_access_from_tokens(tokens) if return_last_access
70
- result[:value] = parse_value_from_tokens(tokens, cache_nils)
74
+ result[:value] = parse_value_from_tokens(tokens, cache_nils, raw)
71
75
  result
72
76
  end
73
77
 
@@ -79,11 +83,16 @@ module Dalli
79
83
  }
80
84
  end
81
85
 
82
- def parse_value_from_tokens(tokens, cache_nils)
86
+ def parse_value_from_tokens(tokens, cache_nils, raw = false) # rubocop:disable Style/OptionalBooleanParameter
83
87
  return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
84
88
  return unless tokens.first == VA
85
89
 
86
- @value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens))
90
+ retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
91
+ end
92
+
93
+ # Values from a raw request are returned as stored
94
+ def retrieve(value, bitflags, raw)
95
+ raw ? value : @value_marshaller.retrieve(value, bitflags)
87
96
  end
88
97
 
89
98
  def meta_set_with_cas
@@ -148,6 +157,10 @@ module Dalli
148
157
  end
149
158
 
150
159
  def full_response_from_buffer(tokens, body, resp_size)
160
+ # A hit with no key can't be matched to a request: skip it (false
161
+ # status) rather than yield it, or take it for the end of the pipeline
162
+ return [resp_size, false, nil, nil, nil] unless tokens.any? { |t| t.start_with?('k') }
163
+
151
164
  value = @value_marshaller.retrieve(body, bitflags_from_tokens(tokens))
152
165
  [resp_size, tokens.first == VA, cas_from_tokens(tokens), key_from_tokens(tokens), value]
153
166
  end
@@ -197,6 +210,11 @@ module Dalli
197
210
  # buffer wait for (and accumulate) that many bytes.
198
211
  MAX_VALUE_BYTES = 1024 * 1024 * 1024
199
212
 
213
+ # The size given right after "VA", or 0 when it isn't a number
214
+ def positional_size(tokens)
215
+ tokens.first == VA && tokens[1]&.match?(/\A\d+\z/) ? tokens[1].to_i : 0
216
+ end
217
+
200
218
  def check_value_size!(size)
201
219
  return if size.between?(0, MAX_VALUE_BYTES)
202
220
 
@@ -248,7 +266,9 @@ module Dalli
248
266
  end
249
267
 
250
268
  def body_len_from_tokens(tokens)
251
- size = value_from_tokens(tokens, 's')&.to_i
269
+ # memcached also gives the size positionally ("VA <size> ..."); use it
270
+ # when there's no s flag rather than reading the value as replies
271
+ size = tokens.any? { |t| t.start_with?('s') } ? value_from_tokens(tokens, 's')&.to_i : positional_size(tokens)
252
272
  check_value_size!(size) if size
253
273
  size
254
274
  end
@@ -270,6 +290,9 @@ module Dalli
270
290
  end
271
291
 
272
292
  def read_data(data_size)
293
+ # Checked before the terminator is added: -1 or -2 would otherwise
294
+ # read 1 or 0 bytes and leave the connection out of step
295
+ check_value_size!(data_size)
273
296
  @io_source.read(data_size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
274
297
  end
275
298
  end
@@ -30,7 +30,7 @@ module Dalli
30
30
  req = RequestFormatter.meta_get(key: encoded_key, base64: base64, skip_flags: skip_flags)
31
31
  write(req)
32
32
  @connection_manager.flush
33
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
33
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
34
34
  end
35
35
 
36
36
  def quiet_get_request(key)
@@ -47,7 +47,7 @@ module Dalli
47
47
  req = RequestFormatter.meta_get(key: encoded_key, ttl: ttl, base64: base64, skip_flags: skip_flags)
48
48
  write(req)
49
49
  @connection_manager.flush
50
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
50
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
51
51
  end
52
52
 
53
53
  def touch(key, ttl)
@@ -61,12 +61,13 @@ module Dalli
61
61
 
62
62
  # TODO: This is confusing, as there's a cas command in memcached
63
63
  # and this isn't it. Maybe rename? Maybe eliminate?
64
- def cas(key)
64
+ def cas(key, options = nil)
65
65
  encoded_key, base64 = KeyRegularizer.encode(key)
66
- req = RequestFormatter.meta_get(key: encoded_key, value: true, return_cas: true, base64: base64)
66
+ req = RequestFormatter.meta_get(key: encoded_key, value: true, return_cas: true, base64: base64,
67
+ skip_flags: raw_request?(options))
67
68
  write(req)
68
69
  @connection_manager.flush
69
- response_processor.meta_get_with_value_and_cas
70
+ response_processor.meta_get_with_value_and_cas(raw: raw_request?(options))
70
71
  end
71
72
 
72
73
  # Comprehensive meta get with support for all metadata flags.
@@ -105,7 +106,7 @@ module Dalli
105
106
  @connection_manager.flush
106
107
  response_processor.meta_get_with_metadata(
107
108
  cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
108
- return_last_access: options[:return_last_access]
109
+ return_last_access: options[:return_last_access], raw: raw_request?(options)
109
110
  )
110
111
  end
111
112
 
data/lib/dalli/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dalli
4
- VERSION = '4.3.6'
4
+ VERSION = '4.3.7'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.4'
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dalli
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.3.6
4
+ version: 4.3.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein