featureflip 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74310b5e1a7545b5d289a4f09d32c7e396f3f03bcb5af9265fe970667f2aedfd
4
- data.tar.gz: 6f0fd700429f63dbba04b3311c88700e9281113f0b114a6c635797745ca1cddc
3
+ metadata.gz: 2fdefb980d91435aca01a6f6344804e172dedef867fd826c5ac9a017ca6416ca
4
+ data.tar.gz: d98b0cda83055b8b984b2ea370b9717f6800dd44202ddacf21944fa19b0dc194
5
5
  SHA512:
6
- metadata.gz: 6eb646f7ba550b44639e2d294ad8b171d2a9931f5004afcdb7621db46abc18dfa047732da544e74a9167a509da1fd5018e71e49f7126e0753ee87f8ac9e2bccf
7
- data.tar.gz: ca59f44a28682ac0fcc5924d4a19e3bb9df8ae76809dfa133f34a57d4bf24243e3cf4b2b7a7f82a54efc0249653a6d54a1fd6ca4c5d0b685fbe2438282e27776
6
+ metadata.gz: 360a193a8d0557ba71c45ce1c3f341d008f34f34996434328f7aa2c1da3eee9ef7cc531953ffbd21b8f83fa2674e750139c456854e59a322c0099fe04f76cc0f
7
+ data.tar.gz: f4f292e068e49a8688b94c530d5cce50a060f97c57742e5aaee3f4db02aa469b0623c8b3c95065c6293e0452b71a58f3dc57212b649bc78e66044dad025c24a6
@@ -1,13 +1,35 @@
1
+ require "time"
2
+
1
3
  module Featureflip
2
4
  module Evaluation
3
5
  class ConditionEvaluator
6
+ # Per-regex match timeout (seconds) for MatchesRegex, mirroring the
7
+ # engine's 100ms RegexMatchTimeout guard against catastrophic
8
+ # backtracking / ReDoS (#1460). Requires Ruby >= 3.2 (gemspec floor),
9
+ # which added the Regexp `timeout:` keyword.
10
+ REGEX_TIMEOUT_SECONDS = 0.1
11
+
4
12
  def evaluate_condition(condition, context)
5
13
  attr_value = context[condition.attribute]
6
14
 
7
15
  return condition.negate if attr_value.nil?
8
16
 
9
- str_value = attr_value.to_s.downcase
10
- targets = condition.values.map { |v| v.to_s.downcase }
17
+ # Issue #1458: when the attribute is a native numeric (Integer/Float —
18
+ # Ruby's `true`/`false` are NOT Numeric, so booleans are naturally
19
+ # excluded), the equality-family operators coerce the condition values to
20
+ # numbers and compare numerically, so 1.0 matches "1". This mirrors the
21
+ # engine's type-aware path and runs BEFORE stringification — a String
22
+ # attribute (even "1.0") stays on the string path below.
23
+ if attr_value.is_a?(Numeric) && NUMERIC_EQUALITY_OPERATORS.include?(condition.operator)
24
+ return evaluate_numeric_equality(condition, attr_value)
25
+ end
26
+
27
+ # Pass the raw (case-preserved) strings to the operator dispatcher.
28
+ # Most operators compare case-insensitively and downcase internally, but
29
+ # the semver operators rely on case-sensitive prerelease precedence
30
+ # (semver §11), so the original casing must survive to that point.
31
+ str_value = attr_value.to_s
32
+ targets = condition.values.map(&:to_s)
11
33
 
12
34
  result = evaluate_operator(condition.operator, str_value, targets)
13
35
  condition.negate ? !result : result
@@ -33,49 +55,136 @@ module Featureflip
33
55
 
34
56
  private
35
57
 
58
+ # The equality-family operators that get type-aware numeric coercion when
59
+ # the attribute is a native Numeric (Issue #1458). Relational/string ops
60
+ # are deliberately excluded — only these four coerce.
61
+ NUMERIC_EQUALITY_OPERATORS = %w[Equals NotEquals In NotIn].freeze
62
+ private_constant :NUMERIC_EQUALITY_OPERATORS
63
+
64
+ # A parsed semantic version: the release core as dot-separated numeric
65
+ # segments plus an optional dot-separated prerelease identifier list.
66
+ SemverVersion = Struct.new(:release, :prerelease)
67
+ private_constant :SemverVersion
68
+
69
+ # An ISO-8601 date-time with no timezone offset (no trailing "Z"/±hh:mm),
70
+ # so it must be assumed UTC before parsing.
71
+ ISO_NO_OFFSET = /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?\z/
72
+ private_constant :ISO_NO_OFFSET
73
+
74
+ # A bare ISO-8601 calendar date ("2024-01-01") with no time component. The
75
+ # engine's DateTimeOffset.TryParse accepts these (midnight, assumed UTC),
76
+ # but Time.iso8601 rejects them, so they need their own midnight-UTC path.
77
+ ISO_DATE_ONLY = /\A\d{4}-\d{2}-\d{2}\z/
78
+ private_constant :ISO_DATE_ONLY
79
+
36
80
  def evaluate_operator(operator, value, targets)
81
+ # Case-insensitive views for the string/relational/date operators.
82
+ ci_value = value.downcase
83
+ ci_targets = targets.map(&:downcase)
84
+
37
85
  case operator
38
86
  when "Equals"
39
- targets.any? { |t| value == t }
87
+ ci_targets.any? { |t| ci_value == t }
40
88
  when "NotEquals"
41
- targets.all? { |t| value != t }
89
+ ci_targets.all? { |t| ci_value != t }
42
90
  when "Contains"
43
- targets.any? { |t| value.include?(t) }
91
+ ci_targets.any? { |t| ci_value.include?(t) }
44
92
  when "NotContains"
45
- targets.all? { |t| !value.include?(t) }
93
+ ci_targets.all? { |t| !ci_value.include?(t) }
46
94
  when "StartsWith"
47
- targets.any? { |t| value.start_with?(t) }
95
+ ci_targets.any? { |t| ci_value.start_with?(t) }
48
96
  when "EndsWith"
49
- targets.any? { |t| value.end_with?(t) }
97
+ ci_targets.any? { |t| ci_value.end_with?(t) }
50
98
  when "In"
51
- targets.include?(value)
99
+ ci_targets.include?(ci_value)
52
100
  when "NotIn"
53
- !targets.include?(value)
101
+ !ci_targets.include?(ci_value)
54
102
  when "MatchesRegex"
103
+ # Case-sensitive matching on the original-case value and pattern,
104
+ # mirroring the engine (RegexOptions.None). Case-insensitivity is
105
+ # opt-in via the (?i) inline flag in the pattern.
106
+ #
107
+ # A per-regex timeout bounds catastrophic backtracking / ReDoS like
108
+ # the engine's 100ms guard (#1460). rescue RegexpError covers BOTH an
109
+ # invalid pattern AND Regexp::TimeoutError (a RegexpError subclass),
110
+ # so either fails safe to no-match.
55
111
  targets.any? do |t|
56
- Regexp.new(t, Regexp::IGNORECASE).match?(value)
112
+ Regexp.new(t, timeout: REGEX_TIMEOUT_SECONDS).match?(value)
57
113
  rescue RegexpError
58
114
  false
59
115
  end
116
+ # Relational operators match if the value satisfies the comparison
117
+ # against ANY condition value (mirroring the server engine), not just
118
+ # values[0]. `.any?` over an empty array is false, so empty values
119
+ # returns false without error.
60
120
  when "GreaterThan"
61
- compare_numeric(value, targets[0], :>)
121
+ ci_targets.any? { |t| compare_numeric(ci_value, t, :>) }
62
122
  when "GreaterThanOrEqual"
63
- compare_numeric(value, targets[0], :>=)
123
+ ci_targets.any? { |t| compare_numeric(ci_value, t, :>=) }
64
124
  when "LessThan"
65
- compare_numeric(value, targets[0], :<)
125
+ ci_targets.any? { |t| compare_numeric(ci_value, t, :<) }
66
126
  when "LessThanOrEqual"
67
- compare_numeric(value, targets[0], :<=)
127
+ ci_targets.any? { |t| compare_numeric(ci_value, t, :<=) }
128
+ # Date operators compare against the RAW value/targets, not the
129
+ # lowercased copies: downcasing breaks ISO-8601 parsing (the "Z" UTC
130
+ # designator becomes "z", which is not valid). Both operands are parsed
131
+ # to UTC instants — offsets are honored, no-offset strings are assumed
132
+ # UTC, and a bare integer is treated as Unix seconds — so an unparseable
133
+ # operand matches nothing instead of falling back to a string compare.
68
134
  when "Before"
69
- return false if targets.empty?
70
- value < targets[0]
135
+ targets.any? { |t| compare_datetime(value, t, :<) }
71
136
  when "After"
72
- return false if targets.empty?
73
- value > targets[0]
137
+ targets.any? { |t| compare_datetime(value, t, :>) }
138
+ # Semantic-version operators compare against the RAW value/targets:
139
+ # prerelease precedence is case-sensitive (semver §11), so the casing
140
+ # preserved by `evaluate_condition` must not be folded here. An
141
+ # unparseable version matches nothing, like the numeric/date operators.
142
+ when "SemverEquals"
143
+ targets.any? { |t| compare_semver(value, t, :==) }
144
+ when "SemverGreaterThan"
145
+ targets.any? { |t| compare_semver(value, t, :>) }
146
+ when "SemverGreaterThanOrEqual"
147
+ targets.any? { |t| compare_semver(value, t, :>=) }
148
+ when "SemverLessThan"
149
+ targets.any? { |t| compare_semver(value, t, :<) }
150
+ when "SemverLessThanOrEqual"
151
+ targets.any? { |t| compare_semver(value, t, :<=) }
74
152
  else
75
153
  false
76
154
  end
77
155
  end
78
156
 
157
+ # Type-aware numeric equality for a native-Numeric attribute (Issue #1458).
158
+ # Coerces each condition value with a strict literal parse and compares it
159
+ # numerically against the attribute. Equals/In match if ANY value is equal;
160
+ # NotEquals/NotIn are their negation. The `negate` flag is then applied,
161
+ # mirroring `evaluate_condition`'s tail.
162
+ def evaluate_numeric_equality(condition, attr_value)
163
+ target = attr_value.to_f
164
+ any_equal = condition.values.any? do |v|
165
+ n = parse_numeric(v)
166
+ n && n == target
167
+ end
168
+
169
+ positive = NUMERIC_EQUALITY_POSITIVE_OPERATORS.include?(condition.operator)
170
+ result = positive ? any_equal : !any_equal
171
+ condition.negate ? !result : result
172
+ end
173
+
174
+ # Equals/In are the "positive" members of the equality family (match on
175
+ # equality); NotEquals/NotIn negate the same any-equal test.
176
+ NUMERIC_EQUALITY_POSITIVE_OPERATORS = %w[Equals In].freeze
177
+ private_constant :NUMERIC_EQUALITY_POSITIVE_OPERATORS
178
+
179
+ # Strict literal parse of a condition value to a Float, reusing the same
180
+ # `Float()` approach as `compare_numeric`: a partial number like "1abc"
181
+ # raises and yields nil (no match), matching the engine's strict parse.
182
+ def parse_numeric(value)
183
+ Float(value)
184
+ rescue ArgumentError, TypeError
185
+ nil
186
+ end
187
+
79
188
  def compare_numeric(value, target, op)
80
189
  val = Float(value)
81
190
  tgt = Float(target)
@@ -83,6 +192,164 @@ module Featureflip
83
192
  rescue ArgumentError, TypeError
84
193
  false
85
194
  end
195
+
196
+ # Compares `value` and `target` as UTC date-time instants and tests the
197
+ # ordering against `op` (:< for Before, :> for After). Returns false when
198
+ # either side is not parseable, mirroring how `compare_numeric` treats
199
+ # non-numeric input (no lexical-string fallback).
200
+ def compare_datetime(value, target, op)
201
+ left = parse_datetime(value)
202
+ right = parse_datetime(target)
203
+ return false if left.nil? || right.nil?
204
+
205
+ left.send(op, right)
206
+ end
207
+
208
+ # Parses a date-time to a UTC `Time`, mirroring the engine's
209
+ # TryParseDateTime. ISO-8601 strings honor any timezone offset; a string
210
+ # without an offset is assumed UTC. A bare integer is treated as Unix time
211
+ # in seconds. Returns nil when the input parses as neither.
212
+ def parse_datetime(value)
213
+ s = value.to_s.strip
214
+
215
+ begin
216
+ # `Time.iso8601` honors offsets/"Z" but raises on a no-offset string;
217
+ # append the missing time/offset to assume midnight UTC for bare dates
218
+ # and UTC for offset-less date-times (mirroring DateTimeOffset.TryParse
219
+ # with AssumeUniversal).
220
+ iso =
221
+ if s.match?(ISO_DATE_ONLY)
222
+ "#{s}T00:00:00Z"
223
+ elsif s.match?(ISO_NO_OFFSET)
224
+ "#{s}Z"
225
+ else
226
+ s
227
+ end
228
+ return Time.iso8601(iso).utc
229
+ rescue ArgumentError
230
+ # Fall through to the Unix-seconds fallback.
231
+ end
232
+
233
+ # Integer fallback: treat a bare integer as Unix time in seconds.
234
+ if s.match?(/\A-?\d+\z/)
235
+ begin
236
+ return Time.at(Integer(s)).utc
237
+ rescue RangeError, ArgumentError
238
+ return nil
239
+ end
240
+ end
241
+
242
+ nil
243
+ end
244
+
245
+ # Compares `value` and `target` as semantic versions and tests the
246
+ # resulting precedence sign (-1/0/1) against `op`. Returns false when
247
+ # either side is not a parseable version, mirroring how `compare_numeric`
248
+ # treats non-numeric input.
249
+ def compare_semver(value, target, op)
250
+ left = parse_semver(value)
251
+ right = parse_semver(target)
252
+ return false if left.nil? || right.nil?
253
+
254
+ compare_semver_parts(left, right).send(op, 0)
255
+ end
256
+
257
+ # Parses a semantic version (https://semver.org). Returns a SemverVersion,
258
+ # or nil when the release core is missing or any release segment is
259
+ # non-numeric. Tolerant of an optional leading "v"/"V", "+build" metadata
260
+ # (ignored for precedence), and an optional "-prerelease" suffix.
261
+ def parse_semver(value)
262
+ s = value.strip
263
+ return nil if s.empty?
264
+
265
+ # Optional leading "v"/"V".
266
+ s = s[1..] if s.start_with?("v", "V")
267
+
268
+ # Build metadata ("+...") does not affect precedence.
269
+ plus = s.index("+")
270
+ s = s[0...plus] if plus
271
+
272
+ # Split the release core from the optional "-prerelease" suffix.
273
+ core = s
274
+ prerelease = []
275
+ dash = s.index("-")
276
+ if dash
277
+ core = s[0...dash]
278
+ pre = s[(dash + 1)..]
279
+ return nil if pre.empty? # trailing "-" with no identifiers is malformed
280
+
281
+ # `split(".", -1)` keeps trailing empty fields so "rc." / "1.0.-x" are
282
+ # rejected; the default `split` would silently drop them.
283
+ prerelease = pre.split(".", -1)
284
+ return nil if prerelease.any?(&:empty?)
285
+ end
286
+
287
+ return nil if core.empty?
288
+
289
+ release = core.split(".", -1)
290
+ return nil unless release.all? { |seg| all_digits?(seg) }
291
+
292
+ SemverVersion.new(release, prerelease)
293
+ end
294
+
295
+ # Compares two parsed versions by semantic-version precedence: release
296
+ # segments first (missing trailing segments treated as 0), then
297
+ # prerelease. Returns -1, 0, or 1.
298
+ def compare_semver_parts(a, b)
299
+ [a.release.length, b.release.length].max.times do |i|
300
+ seg_a = a.release[i] || "0"
301
+ seg_b = b.release[i] || "0"
302
+ cmp = compare_numeric_string(seg_a, seg_b)
303
+ return cmp unless cmp.zero?
304
+ end
305
+
306
+ compare_prerelease(a.prerelease, b.prerelease)
307
+ end
308
+
309
+ # Compares two prerelease identifier lists per semver §11. A version with
310
+ # no prerelease ranks above one that has a prerelease.
311
+ def compare_prerelease(a, b)
312
+ return 0 if a.empty? && b.empty?
313
+ return 1 if a.empty?
314
+ return -1 if b.empty?
315
+
316
+ [a.length, b.length].min.times do |i|
317
+ cmp = compare_prerelease_id(a[i], b[i])
318
+ return cmp unless cmp.zero?
319
+ end
320
+
321
+ # All shared identifiers equal: the longer prerelease has higher precedence.
322
+ a.length <=> b.length
323
+ end
324
+
325
+ # Compares two prerelease identifiers: numeric identifiers compare
326
+ # numerically and rank below alphanumeric ones; alphanumeric identifiers
327
+ # compare in ASCII sort order (case-sensitive) per semver §11.
328
+ def compare_prerelease_id(a, b)
329
+ a_num = all_digits?(a)
330
+ b_num = all_digits?(b)
331
+ return compare_numeric_string(a, b) if a_num && b_num
332
+ return -1 if a_num
333
+ return 1 if b_num
334
+
335
+ a <=> b
336
+ end
337
+
338
+ # Compares two all-digit strings as non-negative integers without parsing
339
+ # (overflow-free): strip leading zeros, then the longer string is the
340
+ # larger number; equal lengths compare ordinally. Returns -1, 0, or 1.
341
+ def compare_numeric_string(a, b)
342
+ a = a.sub(/\A0+/, "")
343
+ b = b.sub(/\A0+/, "")
344
+ return a.length <=> b.length unless a.length == b.length
345
+
346
+ a <=> b
347
+ end
348
+
349
+ # Reports whether `s` is non-empty and contains only ASCII digits.
350
+ def all_digits?(s)
351
+ s.match?(/\A[0-9]+\z/)
352
+ end
86
353
  end
87
354
  end
88
355
  end
@@ -37,12 +37,21 @@ module Featureflip
37
37
 
38
38
  sorted_rules = flag.rules.sort_by(&:priority)
39
39
  sorted_rules.each do |rule|
40
- conditions_match = if rule.segment_key && get_segment
41
- segment = get_segment.call(rule.segment_key)
42
- if segment
43
- @condition_evaluator.evaluate_conditions(
44
- segment.conditions, segment.condition_logic, context
45
- )
40
+ conditions_match = if rule.segment_key && !rule.segment_key.empty?
41
+ # A segment-keyed rule must resolve its segment to match. If the
42
+ # segment source isn't wired (get_segment is nil), or the segment
43
+ # can't be found, fail closed (no match) -- mirroring the engine +
44
+ # C# SDK -- rather than falling through to the rule's condition
45
+ # groups (which match unconditionally when empty). See #1459.
46
+ if get_segment
47
+ segment = get_segment.call(rule.segment_key)
48
+ if segment
49
+ @condition_evaluator.evaluate_conditions(
50
+ segment.conditions, segment.condition_logic, context
51
+ )
52
+ else
53
+ false
54
+ end
46
55
  else
47
56
  false
48
57
  end
@@ -138,6 +147,21 @@ module Featureflip
138
147
  bucket_value = context["userId"] if bucket_value.nil? && bucket_by == "user_id"
139
148
  bucket_value_str = bucket_value.nil? ? "" : bucket_value.to_s
140
149
 
150
+ # A Rollout serve can arrive with no weighted variations -- env-level PercentageRollout
151
+ # has nowhere to store per-variation weights, so the mapper emits type=Rollout with no
152
+ # variations (#1469). Degrade to the default fixed variation instead of returning an
153
+ # empty key. Mirrors the engine + C#/Java SDK evaluators.
154
+ return serve.variation || "" if (serve.variations || []).empty?
155
+
156
+ # Keyless user contexts can't be bucketed. Rather than hashing the empty value
157
+ # into an arbitrary salt-dependent bucket, serve the control (first) variation
158
+ # deterministically. The engine assigns a random GUID per eval (spreading
159
+ # anonymous users over HTTP); local SDK eval is deterministic, so parity is
160
+ # guaranteed only for keyed contexts (#1457).
161
+ if bucket_value_str == "" && %w[userId user_id].include?(bucket_by) && !(serve.variations || []).empty?
162
+ return serve.variations[0].key
163
+ end
164
+
141
165
  bucket = Bucketing.compute_bucket(serve.salt || "", bucket_value_str)
142
166
 
143
167
  cumulative = 0
@@ -146,7 +170,8 @@ module Featureflip
146
170
  return wv.key if bucket < cumulative
147
171
  end
148
172
 
149
- serve.variations&.last&.key || ""
173
+ # Guaranteed non-empty: the no-variations case returned the default above.
174
+ serve.variations.last.key
150
175
  end
151
176
  end
152
177
  end
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "2.1.0"
2
+ VERSION = "2.2.0"
3
3
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featureflip
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Featureflip
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-27 00:00:00.000000000 Z
11
+ date: 2026-06-19 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: logger
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: rspec
15
29
  requirement: !ruby/object:Gem::Requirement