dalli 4.3.2 → 4.3.4

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: 7eda4c85eb715e06ef3d8b34ea8e33a3cde89f19379a758f105363457451a034
4
- data.tar.gz: 36ae8b603f8f778edbbf3bed9b7504ff1a440382bf5520423f49c39961f04a55
3
+ metadata.gz: d296cbb71a7d6d7f57969a942e7270bd3b7145160c2ba4c971426dfbdb352368
4
+ data.tar.gz: 14929b5d79fa9d709dd7ccf5b633a44a91fdb2cbc7139eecaa1687e0e63751db
5
5
  SHA512:
6
- metadata.gz: b9a37ecfc73734a141979a34f2e155baa40cea5b22b016c192514c9d5847335d949ee642620c4508cc2ec8a2ceeb4dcec381715e633893623c42454975999d1a
7
- data.tar.gz: 2a5f38c78c371e0710354a2b915a074512a87225f33fca95c9088ded76cad0dfc22e454fc9f64bddd40d8b0c0a6ab65bb108f467cd5b2b2c39d651285665f845
6
+ metadata.gz: 04d254a2ea7d37a29634aab3441634ec660fe430efcdf64e3ae547bd456f53a2d9a0c47eb17ae8b8a5407d0fdd6c811d8e4b681213f5089011b217465265388f
7
+ data.tar.gz: 55928535640b642c258efecfbede5a423638d0a45b1c814b7aaff03af67ae65feb5d344e9ab31fa963c25fcb318a25538c7e6e495dec1ae9e2b85c6bcfcc06aa
data/CHANGELOG.md CHANGED
@@ -1,6 +1,36 @@
1
1
  Dalli Changelog
2
2
  =====================
3
3
 
4
+ Unreleased
5
+ ==========
6
+
7
+ 4.3.4
8
+ ==========
9
+
10
+ Security:
11
+
12
+ - Fix memcached command injection through numeric arguments with the meta protocol (GHSA-6wmv-xq9m-fmp7)
13
+ - With `protocol: :meta`, the `default` argument of `incr`/`decr`, and `fetch_with_lock`'s `lock_ttl` and `recache_threshold`, were written into the command without conversion, so a String containing CRLF injected additional memcached commands (e.g. `set`, `flush_all`) on the connection. The default binary protocol is not affected
14
+ - These arguments must now be Integers, or Strings of decimal digits; anything else raises `ArgumentError` before a request is sent
15
+ - As defense in depth, the meta `RequestFormatter` now converts every numeric flag it writes (`D`, `J`, `N`, `R`, `T`) to an Integer
16
+ - Thanks to oss-security-shop for the report
17
+
18
+ 4.3.3
19
+ ==========
20
+
21
+ Performance:
22
+
23
+ - Reduce object allocations in pipelined get response processing (#1072)
24
+ - Offset-based `ResponseBuffer`: track a read offset instead of slicing a new string after every parsed response; compact only when the consumed portion exceeds 4KB and more than half the buffer
25
+ - Inline response processor parsing: avoid intermediate array allocations from `split`-based header parsing in both binary and meta protocols
26
+ - Block-based `pipeline_next_responses`: yield `(key, value, cas)` directly when a block is given, avoiding per-call Hash allocation
27
+ - `PipelinedGetter`: replace Hash-based socket-to-server mapping with linear scan (faster for typical 1-5 server counts); use `Process.clock_gettime(CLOCK_MONOTONIC)` instead of `Time.now`
28
+ - Add cross-version benchmark script (`bin/compare_versions`) for reproducible performance comparisons across Dalli versions
29
+
30
+ Bug Fixes:
31
+
32
+ - Skip OTel integration tests when meta protocol is unavailable (#1072)
33
+
4
34
  4.3.2
5
35
  ==========
6
36
 
data/lib/dalli/client.rb CHANGED
@@ -237,6 +237,8 @@ module Dalli
237
237
  raise ArgumentError, 'Block is required for fetch_with_lock' unless block_given?
238
238
 
239
239
  raise_unless_meta_protocol!
240
+ validate_integer!(:lock_ttl, lock_ttl)
241
+ validate_integer!(:recache_threshold, recache_threshold)
240
242
 
241
243
  key = key.to_s
242
244
  key = @key_manager.validate_key(key)
@@ -402,6 +404,8 @@ module Dalli
402
404
  ##
403
405
  # Incr adds the given amount to the counter on the memcached server.
404
406
  # Amt must be a positive integer value.
407
+ # Default, if given, must be an Integer (or a String of decimal digits);
408
+ # anything else raises ArgumentError.
405
409
  #
406
410
  # If default is nil, the counter must already exist or the operation
407
411
  # will fail and will return nil. Otherwise this method will return
@@ -414,6 +418,7 @@ module Dalli
414
418
  # If the value already exists, it must have been set with raw: true
415
419
  def incr(key, amt = 1, ttl = nil, default = nil)
416
420
  check_positive!(amt)
421
+ validate_integer!(:default, default)
417
422
 
418
423
  perform(:incr, key, amt.to_i, ttl_or_default(ttl), default)
419
424
  end
@@ -421,6 +426,8 @@ module Dalli
421
426
  ##
422
427
  # Decr subtracts the given amount from the counter on the memcached server.
423
428
  # Amt must be a positive integer value.
429
+ # Default, if given, must be an Integer (or a String of decimal digits);
430
+ # anything else raises ArgumentError.
424
431
  #
425
432
  # memcached counters are unsigned and cannot hold negative values. Calling
426
433
  # decr on a counter which is 0 will just return 0.
@@ -436,6 +443,7 @@ module Dalli
436
443
  # If the value already exists, it must have been set with raw: true
437
444
  def decr(key, amt = 1, ttl = nil, default = nil)
438
445
  check_positive!(amt)
446
+ validate_integer!(:default, default)
439
447
 
440
448
  perform(:decr, key, amt.to_i, ttl_or_default(ttl), default)
441
449
  end
@@ -577,6 +585,14 @@ module Dalli
577
585
  raise ArgumentError, "Positive values only: #{amt}" if amt.negative?
578
586
  end
579
587
 
588
+ # Numeric arguments that become meta protocol flags (GHSA-6wmv-xq9m-fmp7).
589
+ # Checked before the request starts so a bad value raises a clean
590
+ # ArgumentError, instead of Protocol::Base#request logging it as
591
+ # unexpected and closing the connection.
592
+ def validate_integer!(name, value)
593
+ Dalli::Protocol::Meta::RequestFormatter.integer_flag(name, value) unless value.nil?
594
+ end
595
+
580
596
  def cas_core(key, always_set, ttl = nil, req_options = nil)
581
597
  (value, cas) = perform(:cas, key)
582
598
  return if value.nil? && !always_set
@@ -29,7 +29,7 @@ module Dalli
29
29
  # Stores partial results collected during interleaved send phase
30
30
  @partial_results = {}
31
31
  servers = setup_requests(keys)
32
- start_time = Time.now
32
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
33
 
34
34
  # First yield any partial results collected during interleaved send
35
35
  yield_partial_results(&block)
@@ -149,7 +149,7 @@ module Dalli
149
149
  end
150
150
 
151
151
  def remaining_time(start, timeout)
152
- elapsed = Time.now - start
152
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
153
153
  return 0 if elapsed > timeout
154
154
 
155
155
  timeout - elapsed
@@ -168,8 +168,8 @@ module Dalli
168
168
  # Processes responses from a server. Returns true if there are no
169
169
  # additional responses from this server.
170
170
  def process_server(server)
171
- server.pipeline_next_responses.each_pair do |key, value_list|
172
- yield @key_manager.key_without_namespace(key), value_list
171
+ server.pipeline_next_responses do |key, value, cas|
172
+ yield @key_manager.key_without_namespace(key), [value, cas]
173
173
  end
174
174
 
175
175
  server.pipeline_complete?
@@ -178,18 +178,13 @@ module Dalli
178
178
  def servers_with_response(servers, timeout)
179
179
  return [] if servers.empty?
180
180
 
181
- # TODO: - This is a bit challenging. Essentially the PipelinedGetter
182
- # is a reactor, but without the benefit of a Fiber or separate thread.
183
- # My suspicion is that we may want to try and push this down into the
184
- # individual servers, but I'm not sure. For now, we keep the
185
- # mapping between the alerted object (the socket) and the
186
- # corrresponding server here.
187
- server_map = servers.each_with_object({}) { |s, h| h[s.sock] = s }
188
-
189
- readable, = IO.select(server_map.keys, nil, nil, timeout)
181
+ sockets = servers.map(&:sock)
182
+ readable, = IO.select(sockets, nil, nil, timeout)
190
183
  return [] if readable.nil?
191
184
 
192
- readable.map { |sock| server_map[sock] }
185
+ # For typical server counts (1-5), linear scan is faster than
186
+ # building and looking up a hash map
187
+ readable.filter_map { |sock| servers.find { |s| s.sock == sock } }
193
188
  end
194
189
 
195
190
  def groups_for_keys(*keys)
@@ -91,10 +91,13 @@ module Dalli
91
91
  # repeatedly whenever this server's socket is readable until
92
92
  # #pipeline_complete?.
93
93
  #
94
- # Returns a Hash of kv pairs received.
95
- def pipeline_next_responses
94
+ # When a block is given, yields (key, value, cas) for each response,
95
+ # avoiding intermediate Hash allocation. Returns nil.
96
+ # Without a block, returns a Hash of { key => [value, cas] }.
97
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
98
+ def pipeline_next_responses(&block)
96
99
  reconnect_on_pipeline_complete!
97
- values = {}
100
+ values = nil
98
101
 
99
102
  response_buffer.read
100
103
 
@@ -108,16 +111,24 @@ module Dalli
108
111
 
109
112
  # If the status is ok and the key is not nil, then this is a
110
113
  # getkq response with a value that we want to set in the response hash
111
- values[key] = [value, cas] unless key.nil?
114
+ unless key.nil?
115
+ if block
116
+ yield key, value, cas
117
+ else
118
+ values ||= {}
119
+ values[key] = [value, cas]
120
+ end
121
+ end
112
122
 
113
123
  # Get the next response from the buffer
114
124
  status, cas, key, value = response_buffer.process_single_getk_response
115
125
  end
116
126
 
117
- values
127
+ values || {}
118
128
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
119
129
  @connection_manager.error_on_request!(e)
120
130
  end
131
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
121
132
 
122
133
  # Abort current pipelined get. Generally used to signal an external
123
134
  # timeout during pipelined get. The underlying socket is
@@ -189,16 +189,6 @@ module Dalli
189
189
  [resp_header.status, content]
190
190
  end
191
191
 
192
- def contains_header?(buf)
193
- return false unless buf
194
-
195
- buf.bytesize >= ResponseHeader::SIZE
196
- end
197
-
198
- def response_header_from_buffer(buf)
199
- ResponseHeader.new(buf)
200
- end
201
-
202
192
  ##
203
193
  # This method returns an array of values used in a pipelined
204
194
  # getk process. The first value is the number of bytes by
@@ -209,11 +199,11 @@ module Dalli
209
199
  # The remaining three values in the array are the ResponseHeader,
210
200
  # key, and value.
211
201
  ##
212
- def getk_response_from_buffer(buf)
202
+ def getk_response_from_buffer(buf, offset = 0)
213
203
  # There's no header in the buffer, so don't advance
214
- return [0, nil, nil, nil, nil] unless contains_header?(buf)
204
+ return [0, nil, nil, nil, nil] unless buf && buf.bytesize >= offset + ResponseHeader::SIZE
215
205
 
216
- resp_header = response_header_from_buffer(buf)
206
+ resp_header = ResponseHeader.new(buf.byteslice(offset, ResponseHeader::SIZE))
217
207
  body_len = resp_header.body_len
218
208
 
219
209
  # We have a complete response that has no body.
@@ -225,11 +215,11 @@ module Dalli
225
215
  resp_size = ResponseHeader::SIZE + body_len
226
216
  # The header is in the buffer, but the body is not. As we don't have
227
217
  # a complete response, don't advance the buffer
228
- return [0, nil, nil, nil, nil] unless buf.bytesize >= resp_size
218
+ return [0, nil, nil, nil, nil] unless buf.bytesize >= offset + resp_size
229
219
 
230
220
  # The full response is in our buffer, so parse it and return
231
221
  # the values
232
- body = buf.byteslice(ResponseHeader::SIZE, body_len)
222
+ body = buf.byteslice(offset + ResponseHeader::SIZE, body_len)
233
223
  key, value = unpack_response_body(resp_header, body, true)
234
224
  [resp_size, resp_header.ok?, resp_header.cas, key, value]
235
225
  end
@@ -45,10 +45,11 @@ module Dalli
45
45
  cmd << (skip_flags ? ' v' : ' v f') if value
46
46
  cmd << ' c' if return_cas
47
47
  cmd << ' b' if base64
48
- cmd << " T#{ttl}" if ttl
48
+ cmd << " T#{integer_flag(:ttl, ttl)}" if ttl
49
49
  cmd << ' k q s' if quiet # Return the key in the response if quiet
50
- cmd << " N#{vivify_ttl}" if vivify_ttl # Thundering herd: vivify on miss
51
- cmd << " R#{recache_ttl}" if recache_ttl # Thundering herd: win recache if TTL below threshold
50
+ cmd << " N#{integer_flag(:vivify_ttl, vivify_ttl)}" if vivify_ttl # Thundering herd: vivify on miss
51
+ # Thundering herd: win recache if TTL below threshold
52
+ cmd << " R#{integer_flag(:recache_ttl, recache_ttl)}" if recache_ttl
52
53
  cmd << ' h' if return_hit_status # Return hit status (0 or 1)
53
54
  cmd << ' l' if return_last_access # Return seconds since last access
54
55
  cmd << ' u' if skip_lru_bump # Don't bump LRU or update access stats
@@ -61,7 +62,7 @@ module Dalli
61
62
  cmd << ' b' if base64
62
63
  cmd << " F#{bitflags}" if bitflags
63
64
  cmd << cas_string(cas)
64
- cmd << " T#{ttl}" if ttl
65
+ cmd << " T#{integer_flag(:ttl, ttl)}" if ttl
65
66
  cmd << " M#{mode_to_token(mode)}"
66
67
  cmd << ' q' if quiet
67
68
  cmd << TERMINATOR
@@ -74,7 +75,7 @@ module Dalli
74
75
  cmd = "md #{key}"
75
76
  cmd << ' b' if base64
76
77
  cmd << cas_string(cas)
77
- cmd << " T#{ttl}" if ttl
78
+ cmd << " T#{integer_flag(:ttl, ttl)}" if ttl
78
79
  cmd << ' I' if stale # Mark stale instead of deleting
79
80
  cmd << ' q' if quiet
80
81
  cmd + TERMINATOR
@@ -83,10 +84,10 @@ module Dalli
83
84
  def self.meta_arithmetic(key:, delta:, initial:, incr: true, cas: nil, ttl: nil, base64: false, quiet: false)
84
85
  cmd = "ma #{key} v"
85
86
  cmd << ' b' if base64
86
- cmd << " D#{delta}" if delta
87
- cmd << " J#{initial}" if initial
87
+ cmd << " D#{integer_flag(:delta, delta)}" if delta
88
+ cmd << " J#{integer_flag(:initial, initial)}" if initial
88
89
  # Always set a TTL if an initial value is specified
89
- cmd << " N#{ttl || 0}" if ttl || initial
90
+ cmd << " N#{ttl ? integer_flag(:ttl, ttl) : 0}" if ttl || initial
90
91
  cmd << cas_string(cas)
91
92
  cmd << ' q' if quiet
92
93
  cmd << " M#{incr ? 'I' : 'D'}"
@@ -136,6 +137,18 @@ module Dalli
136
137
  end
137
138
  end
138
139
 
140
+ # Numeric flag values are written straight into the command line, so a
141
+ # value that isn't an integer -- e.g. a String carrying CRLF -- would let
142
+ # the caller inject further memcached commands (GHSA-6wmv-xq9m-fmp7).
143
+ # Converting to Integer means only digits ever reach the wire. Strings
144
+ # are parsed as base 10 so "010" means 10, as memcached would read it,
145
+ # rather than octal 8.
146
+ def self.integer_flag(name, value)
147
+ value.is_a?(String) ? Integer(value, 10) : Integer(value)
148
+ rescue ArgumentError, TypeError, FloatDomainError
149
+ raise ArgumentError, "#{name} must be an Integer, got #{value.inspect}"
150
+ end
151
+
139
152
  def self.cas_string(cas)
140
153
  cas = parse_to_64_bit_int(cas, nil)
141
154
  cas.nil? || cas.zero? ? '' : " C#{cas}"
@@ -147,14 +147,6 @@ module Dalli
147
147
  true
148
148
  end
149
149
 
150
- def tokens_from_header_buffer(buf)
151
- header = header_from_buffer(buf)
152
- tokens = header.split
153
- header_len = header.bytesize + TERMINATOR.length
154
- body_len = body_len_from_tokens(tokens)
155
- [tokens, header_len, body_len]
156
- end
157
-
158
150
  def full_response_from_buffer(tokens, body, resp_size)
159
151
  value = @value_marshaller.retrieve(body, bitflags_from_tokens(tokens))
160
152
  [resp_size, tokens.first == VA, cas_from_tokens(tokens), key_from_tokens(tokens), value]
@@ -170,11 +162,15 @@ module Dalli
170
162
  # The remaining three values in the array are the ResponseHeader,
171
163
  # key, and value.
172
164
  ##
173
- def getk_response_from_buffer(buf)
174
- # There's no header in the buffer, so don't advance
175
- return [0, nil, nil, nil, nil] unless contains_header?(buf)
165
+ def getk_response_from_buffer(buf, offset = 0)
166
+ # Find the header terminator starting from offset
167
+ term_idx = buf.index(TERMINATOR, offset)
168
+ return [0, nil, nil, nil, nil] unless term_idx
176
169
 
177
- tokens, header_len, body_len = tokens_from_header_buffer(buf)
170
+ header = buf.byteslice(offset, term_idx - offset)
171
+ tokens = header.split
172
+ header_len = header.bytesize + TERMINATOR.length
173
+ body_len = body_len_from_tokens(tokens)
178
174
 
179
175
  # We have a complete response that has no body.
180
176
  # This is either the response to the terminating
@@ -185,22 +181,14 @@ module Dalli
185
181
  resp_size = header_len + body_len + TERMINATOR.length
186
182
  # The header is in the buffer, but the body is not. As we don't have
187
183
  # a complete response, don't advance the buffer
188
- return [0, nil, nil, nil, nil] unless buf.bytesize >= resp_size
184
+ return [0, nil, nil, nil, nil] unless buf.bytesize >= offset + resp_size
189
185
 
190
186
  # The full response is in our buffer, so parse it and return
191
187
  # the values
192
- body = buf.slice(header_len, body_len)
188
+ body = buf.byteslice(offset + header_len, body_len)
193
189
  full_response_from_buffer(tokens, body, resp_size)
194
190
  end
195
191
 
196
- def contains_header?(buf)
197
- buf.include?(TERMINATOR)
198
- end
199
-
200
- def header_from_buffer(buf)
201
- buf.split(TERMINATOR, 2).first
202
- end
203
-
204
192
  def error_on_unexpected!(expected_codes)
205
193
  tokens = next_line_to_tokens
206
194
 
@@ -7,38 +7,39 @@ module Dalli
7
7
  module Protocol
8
8
  ##
9
9
  # Manages the buffer for responses from memcached.
10
+ # Uses an offset-based approach to avoid string allocations
11
+ # when advancing through parsed responses.
10
12
  ##
11
13
  class ResponseBuffer
14
+ # Compact the buffer when the consumed portion exceeds this
15
+ # threshold and represents more than half the buffer
16
+ COMPACT_THRESHOLD = 4096
17
+
12
18
  def initialize(io_source, response_processor)
13
19
  @io_source = io_source
14
20
  @response_processor = response_processor
15
21
  @buffer = nil
22
+ @offset = 0
16
23
  end
17
24
 
18
25
  def read
19
26
  @buffer << @io_source.read_nonblock
20
27
  end
21
28
 
22
- # Attempts to process a single response from the buffer. Starts
23
- # by advancing the buffer to the specified start position
29
+ # Attempts to process a single response from the buffer,
30
+ # advancing the offset past the consumed bytes.
24
31
  def process_single_getk_response
25
- bytes, status, cas, key, value = @response_processor.getk_response_from_buffer(@buffer)
26
- advance(bytes)
32
+ bytes, status, cas, key, value = @response_processor.getk_response_from_buffer(@buffer, @offset)
33
+ @offset += bytes
34
+ compact_if_needed
27
35
  [status, cas, key, value]
28
36
  end
29
37
 
30
- # Advances the internal response buffer by bytes_to_advance
31
- # bytes. The
32
- def advance(bytes_to_advance)
33
- return unless bytes_to_advance.positive?
34
-
35
- @buffer = @buffer.byteslice(bytes_to_advance..-1)
36
- end
37
-
38
38
  # Resets the internal buffer to an empty state,
39
39
  # so that we're ready to read pipelined responses
40
40
  def reset
41
41
  @buffer = ''.b
42
+ @offset = 0
42
43
  end
43
44
 
44
45
  # Ensures the buffer is initialized for reading without discarding
@@ -48,16 +49,30 @@ module Dalli
48
49
  return if in_progress?
49
50
 
50
51
  @buffer = ''.b
52
+ @offset = 0
51
53
  end
52
54
 
53
55
  # Clear the internal response buffer
54
56
  def clear
55
57
  @buffer = nil
58
+ @offset = 0
56
59
  end
57
60
 
58
61
  def in_progress?
59
62
  !@buffer.nil?
60
63
  end
64
+
65
+ private
66
+
67
+ # Only compact when we've consumed a significant portion of the buffer.
68
+ # This avoids per-response string allocation while preventing unbounded
69
+ # memory growth for large pipelines.
70
+ def compact_if_needed
71
+ return unless @offset > COMPACT_THRESHOLD && @offset > @buffer.bytesize / 2
72
+
73
+ @buffer = @buffer.byteslice(@offset..)
74
+ @offset = 0
75
+ end
61
76
  end
62
77
  end
63
78
  end
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.2'
4
+ VERSION = '4.3.4'
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.2
4
+ version: 4.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -77,7 +77,7 @@ licenses:
77
77
  - MIT
78
78
  metadata:
79
79
  bug_tracker_uri: https://github.com/petergoldstein/dalli/issues
80
- changelog_uri: https://github.com/petergoldstein/dalli/blob/main/CHANGELOG.md
80
+ changelog_uri: https://github.com/petergoldstein/dalli/blob/v4.3/CHANGELOG.md
81
81
  rubygems_mfa_required: 'true'
82
82
  rdoc_options: []
83
83
  require_paths:
@@ -93,7 +93,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
93
93
  - !ruby/object:Gem::Version
94
94
  version: '0'
95
95
  requirements: []
96
- rubygems_version: 4.0.6
96
+ rubygems_version: 4.0.21
97
97
  specification_version: 4
98
98
  summary: High performance memcached client for Ruby
99
99
  test_files: []