confeature 0.0.0 → 0.1.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.
@@ -0,0 +1,483 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ require "json"
6
+ require "date"
7
+ require_relative "hashing"
8
+ require_relative "semver"
9
+
10
+ module Confeature
11
+ module Internal
12
+ # The 36 ConfigCat `UserComparator` operations + attribute conversion.
13
+ # Ported field-for-field from the spec-117 TS reference
14
+ # (`packages/sdk-shared/src/comparators.ts`) and the spec-118 Python port
15
+ # (`apps/sdk-python/.../comparators.py`), itself verified against common-js
16
+ # `RolloutEvaluator.ts` (`configcat-common@9.4.1`).
17
+ #
18
+ # Each comparator returns a three-valued outcome: `true` (match), `false`
19
+ # (no match), or a `ValidationError` (a validation / conversion failure —
20
+ # bad semver, non-numeric number, non-array array). An error short-circuits
21
+ # the AND group like `false` but is surfaced rather than silently swallowed
22
+ # (FR-010).
23
+ #
24
+ # The six families: text (cleartext, case-SENSITIVE, no trim); sensitive
25
+ # text (SHA-256 SALTED, length-prefixed slice variants); semver (on
26
+ # `Semver.parse`); number (strict whole-string parse, `,`→`.`); datetime
27
+ # (Before/After, Unix seconds); array (contains/notContains, cleartext +
28
+ # sensitive).
29
+ module Comparators
30
+ # UserComparator discriminators (mirror config-json.ts UserComparator).
31
+ TEXT_IS_ONE_OF = 0
32
+ TEXT_IS_NOT_ONE_OF = 1
33
+ TEXT_CONTAINS_ANY_OF = 2
34
+ TEXT_NOT_CONTAINS_ANY_OF = 3
35
+ SEMVER_IS_ONE_OF = 4
36
+ SEMVER_IS_NOT_ONE_OF = 5
37
+ SEMVER_LESS = 6
38
+ SEMVER_LESS_OR_EQUALS = 7
39
+ SEMVER_GREATER = 8
40
+ SEMVER_GREATER_OR_EQUALS = 9
41
+ NUMBER_EQUALS = 10
42
+ NUMBER_NOT_EQUALS = 11
43
+ NUMBER_LESS = 12
44
+ NUMBER_LESS_OR_EQUALS = 13
45
+ NUMBER_GREATER = 14
46
+ NUMBER_GREATER_OR_EQUALS = 15
47
+ SENSITIVE_TEXT_IS_ONE_OF = 16
48
+ SENSITIVE_TEXT_IS_NOT_ONE_OF = 17
49
+ DATETIME_BEFORE = 18
50
+ DATETIME_AFTER = 19
51
+ SENSITIVE_TEXT_EQUALS = 20
52
+ SENSITIVE_TEXT_NOT_EQUALS = 21
53
+ SENSITIVE_TEXT_STARTS_WITH_ANY_OF = 22
54
+ SENSITIVE_TEXT_NOT_STARTS_WITH_ANY_OF = 23
55
+ SENSITIVE_TEXT_ENDS_WITH_ANY_OF = 24
56
+ SENSITIVE_TEXT_NOT_ENDS_WITH_ANY_OF = 25
57
+ SENSITIVE_ARRAY_CONTAINS_ANY_OF = 26
58
+ SENSITIVE_ARRAY_NOT_CONTAINS_ANY_OF = 27
59
+ TEXT_EQUALS = 28
60
+ TEXT_NOT_EQUALS = 29
61
+ TEXT_STARTS_WITH_ANY_OF = 30
62
+ TEXT_NOT_STARTS_WITH_ANY_OF = 31
63
+ TEXT_ENDS_WITH_ANY_OF = 32
64
+ TEXT_NOT_ENDS_WITH_ANY_OF = 33
65
+ ARRAY_CONTAINS_ANY_OF = 34
66
+ ARRAY_NOT_CONTAINS_ANY_OF = 35
67
+
68
+ # A validation / conversion failure outcome (surfaced, not silent).
69
+ # The condition dispatch returns one of these instead of `true`/`false`
70
+ # when a typed-attribute conversion fails (FR-010).
71
+ ValidationError = Struct.new(:message)
72
+
73
+ # A malformed CONFIG comparison value (#509). configcat-common validates
74
+ # the operand BEFORE the user attribute for the semver relations, number
75
+ # relations, and datetime comparators; a malformed operand surfaces this
76
+ # distinct `comparison value '<v>' is invalid (<reason>)` outcome (NO
77
+ # "please check the User.<attr>…" suffix — the fault is in the config, not
78
+ # the user), which wins even when the attribute is ALSO malformed. It
79
+ # short-circuits the AND group like `false`. A distinct subclass so the
80
+ # evaluator routes it to the WARN 3004 `comparisonValueIsInvalid` site.
81
+ class ComparisonValueError < ValidationError; end
82
+
83
+ module_function
84
+
85
+ def error(message)
86
+ ValidationError.new(message)
87
+ end
88
+
89
+ def comparison_value_error(value, reason)
90
+ ComparisonValueError.new("comparison value '#{value}' is invalid (#{reason})")
91
+ end
92
+
93
+ # Config semver operand → semver, or a comparison-value error. The operand
94
+ # is trimmed before parsing (parity with `evaluate_semver_relation`); the
95
+ # error reports the UNTRIMMED operand.
96
+ def comparison_value_as_semver(value)
97
+ version = Semver.parse(value.strip)
98
+ return version unless version.nil?
99
+
100
+ comparison_value_error(value, "'#{value}' is not a valid semantic version")
101
+ end
102
+
103
+ # Render a numeric operand for the comparison-value error message. Mirrors
104
+ # TS's `String(value)`: an absent operand prints "undefined", otherwise the
105
+ # canonical number string.
106
+ def comparison_value_number_string(value)
107
+ return "undefined" if value.nil?
108
+
109
+ number_to_canonical_string(value)
110
+ end
111
+
112
+ # Config number operand → number, or a comparison-value error. `value` is
113
+ # the condition's `d` slot (nil when absent).
114
+ def comparison_value_as_number(value)
115
+ return value if !value.nil? && !value.to_f.nan?
116
+
117
+ s = comparison_value_number_string(value)
118
+ comparison_value_error(s, "'#{s}' is not a valid decimal number")
119
+ end
120
+
121
+ # Config datetime operand → Unix seconds, or a comparison-value error.
122
+ def comparison_value_as_unix_time_seconds(value)
123
+ return value if !value.nil? && !value.to_f.nan?
124
+
125
+ s = comparison_value_number_string(value)
126
+ comparison_value_error(
127
+ s,
128
+ "'#{s}' is not a valid Unix timestamp (number of seconds elapsed since Unix epoch)"
129
+ )
130
+ end
131
+
132
+ def string_array?(value)
133
+ value.is_a?(Array) && value.all?(String)
134
+ end
135
+
136
+ # Convert a `Time` / `DateTime` to a second-based Unix timestamp
137
+ # (fractional seconds preserved). Mirrors upstream `configcat-ruby`'s
138
+ # datetime → epoch-seconds conversion and the Python port's
139
+ # `datetime_to_seconds_since_epoch`: a naive `DateTime` (no offset) is
140
+ # treated as UTC. `1680307199.999` round-trips exactly.
141
+ def datetime_to_seconds_since_epoch(value)
142
+ if value.is_a?(::Time)
143
+ value.to_r.to_f
144
+ else
145
+ # `DateTime#to_time` honours the offset; a naive DateTime is assumed
146
+ # UTC by Date/Time, matching the upstream "naive → UTC" rule.
147
+ value.to_time.to_r.to_f
148
+ end
149
+ end
150
+
151
+ def datetime?(value)
152
+ value.is_a?(::Time) || value.is_a?(::DateTime)
153
+ end
154
+
155
+ # Stringify a user-attribute value (common-js `userAttributeValueToString`).
156
+ # String verbatim; bool → "true"/"false" (JS lowercase); datetime → its
157
+ # second-based Unix timestamp rendered as a canonical number (checked
158
+ # before the numeric branch so a Date renders as its epoch seconds —
159
+ # upstream parity); string array → JSON.stringify (compact, comma+colon
160
+ # separators); number → JS canonical string; else `to_s`.
161
+ def user_attribute_value_to_string(value)
162
+ return value if value.is_a?(String)
163
+ return value ? "true" : "false" if [true, false].include?(value)
164
+ return number_to_canonical_string(datetime_to_seconds_since_epoch(value)) if datetime?(value)
165
+ return JSON.generate(value) if string_array?(value)
166
+ return number_to_canonical_string(value) if value.is_a?(Numeric)
167
+
168
+ value.to_s
169
+ end
170
+
171
+ # Stringify a number as ConfigCat's canonical string (JS `Number::toString`).
172
+ # NaN / ±Infinity print as "NaN" / "Infinity" / "-Infinity"; otherwise the
173
+ # SHORTEST round-tripping digit sequence, fixed-point unless the magnitude
174
+ # warrants exponential. The corpus `numberToStringConversion*` cases hinge
175
+ # on this — e.g. `-1.23456789e+96` must NOT expand to its full integer form.
176
+ def number_to_canonical_string(value)
177
+ f = value.to_f
178
+ return "NaN" if f.nan?
179
+ return f.positive? ? "Infinity" : "-Infinity" if f.infinite?
180
+ return "0" if f.zero?
181
+
182
+ js_number_to_string(f)
183
+ end
184
+
185
+ # Render a finite non-zero float exactly as ECMAScript `Number::toString`.
186
+ def js_number_to_string(value)
187
+ sign = value.negative? ? "-" : ""
188
+ av = value.abs
189
+ digits, point = shortest_digits_and_point(av)
190
+ k = digits.length
191
+ n = point
192
+
193
+ if n.between?(k, 21)
194
+ sign + digits + ("0" * (n - k))
195
+ elsif n.positive? && n <= 21
196
+ "#{sign}#{digits[0, n]}.#{digits[n..]}"
197
+ elsif n > -6 && n <= 0
198
+ "#{sign}0.#{"0" * (-n)}#{digits}"
199
+ else
200
+ e = n - 1
201
+ exp_sign = e >= 0 ? "+" : "-"
202
+ mant = k == 1 ? digits[0] : "#{digits[0]}.#{digits[1..]}"
203
+ "#{sign}#{mant}e#{exp_sign}#{e.abs}"
204
+ end
205
+ end
206
+
207
+ # Decompose the shortest round-tripping repr of a positive float into
208
+ # (digits, point) — `point` = the count of significant digits before the
209
+ # decimal point in normalized form (the ECMAScript `n`). Ruby's
210
+ # `Float#to_s` gives the shortest round-tripping representation.
211
+ def shortest_digits_and_point(av)
212
+ shortest = av.to_s
213
+ if shortest.include?("e") || shortest.include?("E")
214
+ mant, _, exp = shortest.downcase.partition("e")
215
+ exp_val = exp.to_i
216
+ else
217
+ mant = shortest
218
+ exp_val = 0
219
+ end
220
+ if mant.include?(".")
221
+ int_part, frac_part = mant.split(".")
222
+ else
223
+ int_part = mant
224
+ frac_part = ""
225
+ end
226
+ raw = int_part + frac_part
227
+ point = int_part.length + exp_val
228
+ # Strip leading zeros (adjusting point), then trailing zeros.
229
+ lead = 0
230
+ while lead < raw.length - 1 && raw[lead] == "0"
231
+ lead += 1
232
+ point -= 1
233
+ end
234
+ raw = raw[lead..]
235
+ raw = raw.sub(/0+\z/, "")
236
+ raw = "0" if raw.empty?
237
+ [raw, point]
238
+ end
239
+
240
+ # Strict whole-string float parse (common-js `parseFloatStrict`). Rejects
241
+ # empty / all-whitespace and a leading `0` followed by a non-digit/non-`.`/
242
+ # non-`e` (octal / hex). Returns Float::NAN on rejection. JS's `parseFloat`
243
+ # is too forgiving (prefix parse), so "12abc" is NaN.
244
+ def parse_float_strict(value)
245
+ return Float::NAN if value.empty? || value =~ /\A\s*\z|\A\s*0[^\d.eE]/
246
+
247
+ # JS `+value` accepts "Infinity"/"-Infinity" and full-string floats only.
248
+ stripped = value.strip
249
+ return Float::INFINITY if stripped == "Infinity"
250
+ return -Float::INFINITY if stripped == "-Infinity"
251
+ return Float::NAN if stripped == "NaN"
252
+ return Float::NAN unless stripped =~ /\A[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?\z/
253
+
254
+ Float(stripped)
255
+ rescue ArgumentError, TypeError
256
+ Float::NAN
257
+ end
258
+
259
+ # -----------------------------------------------------------------------
260
+ # Text — cleartext (case-sensitive, no trim)
261
+ # -----------------------------------------------------------------------
262
+
263
+ def evaluate_text_equals(text, comparison_value, negate)
264
+ (text == comparison_value) != negate
265
+ end
266
+
267
+ def evaluate_text_is_one_of(text, comparison_values, negate)
268
+ comparison_values.include?(text) != negate
269
+ end
270
+
271
+ def evaluate_text_contains_any_of(text, comparison_values, negate)
272
+ comparison_values.each { |item| return !negate if text.include?(item) }
273
+ negate
274
+ end
275
+
276
+ # Cleartext startsWith/endsWith-any-of. Items longer than text are skipped.
277
+ def evaluate_text_slice_equals_any_of(text, comparison_values, starts_with, negate)
278
+ comparison_values.each do |item|
279
+ next if text.length < item.length
280
+
281
+ matched = starts_with ? text.start_with?(item) : text.end_with?(item)
282
+ return !negate if matched
283
+ end
284
+ negate
285
+ end
286
+
287
+ # -----------------------------------------------------------------------
288
+ # Text — sensitive (SHA-256 salted)
289
+ # -----------------------------------------------------------------------
290
+
291
+ def evaluate_sensitive_text_equals(text, comparison_value, config_salt, context_salt, negate)
292
+ h = Hashing.hash_comparison_value(text, config_salt, context_salt)
293
+ (h == comparison_value) != negate
294
+ end
295
+
296
+ def evaluate_sensitive_text_is_one_of(text, comparison_values, config_salt, context_salt, negate)
297
+ h = Hashing.hash_comparison_value(text, config_salt, context_salt)
298
+ comparison_values.include?(h) != negate
299
+ end
300
+
301
+ # Sensitive startsWith/endsWith-any-of. Each operand is "<byteLen>_<hash>";
302
+ # the SDK hashes the matching-length BYTE prefix/suffix of the attribute
303
+ # and compares to <hash>. Slicing is over the UTF-8 byte sequence (not code
304
+ # points), matching common-js.
305
+ def evaluate_sensitive_text_slice_equals_any_of(
306
+ text, comparison_values, config_salt, context_salt, starts_with, negate
307
+ )
308
+ text_utf8 = text.encode(Encoding::UTF_8).b
309
+ comparison_values.each do |item|
310
+ index = item.index("_")
311
+ next if index.nil?
312
+
313
+ slice_length = Integer(item[0, index], exception: false)
314
+ next if slice_length.nil?
315
+ next if text_utf8.bytesize < slice_length
316
+
317
+ slice_utf8 =
318
+ if starts_with
319
+ text_utf8[0, slice_length]
320
+ else
321
+ text_utf8[text_utf8.bytesize - slice_length, slice_length]
322
+ end
323
+ h = Hashing.hash_comparison_value_slice(slice_utf8, config_salt, context_salt)
324
+ return !negate if h == item[(index + 1)..]
325
+ end
326
+ negate
327
+ end
328
+
329
+ # -----------------------------------------------------------------------
330
+ # Semver
331
+ # -----------------------------------------------------------------------
332
+
333
+ # Semver IS-ONE-OF / IS-NOT-ONE-OF (comparators 4/5). Each operand is
334
+ # trimmed first; an operand that is empty AFTER trimming (incl.
335
+ # whitespace-only) is skipped — matching ConfigCat, which trims before the
336
+ # empty check. A non-empty but unparseable operand makes the whole
337
+ # comparator `false` (backward-compat). Iterates ALL operands (no
338
+ # early-out on match).
339
+ def evaluate_semver_is_one_of(version, comparison_values, negate)
340
+ result = false
341
+ comparison_values.each do |item|
342
+ item = item.strip
343
+ next if item.empty?
344
+
345
+ version2 = Semver.parse(item)
346
+ return false if version2.nil?
347
+
348
+ result = true if !result && version.compare(version2).zero?
349
+ end
350
+ result != negate
351
+ end
352
+
353
+ # Semver relations (comparators 6-9). Unparseable operand -> `false`.
354
+ def evaluate_semver_relation(version, comparator, comparison_value)
355
+ version2 = Semver.parse(comparison_value.strip)
356
+ return false if version2.nil?
357
+
358
+ cmp = version.compare(version2)
359
+ case comparator
360
+ when SEMVER_LESS then cmp.negative?
361
+ when SEMVER_LESS_OR_EQUALS then cmp <= 0
362
+ when SEMVER_GREATER then cmp.positive?
363
+ when SEMVER_GREATER_OR_EQUALS then cmp >= 0
364
+ else false
365
+ end
366
+ end
367
+
368
+ # -----------------------------------------------------------------------
369
+ # Number
370
+ # -----------------------------------------------------------------------
371
+
372
+ def evaluate_number_relation(num, comparator, comparison_value)
373
+ case comparator
374
+ when NUMBER_EQUALS then num == comparison_value
375
+ when NUMBER_NOT_EQUALS then num != comparison_value
376
+ when NUMBER_LESS then num < comparison_value
377
+ when NUMBER_LESS_OR_EQUALS then num <= comparison_value
378
+ when NUMBER_GREATER then num > comparison_value
379
+ when NUMBER_GREATER_OR_EQUALS then num >= comparison_value
380
+ else false
381
+ end
382
+ end
383
+
384
+ # -----------------------------------------------------------------------
385
+ # Datetime (Unix seconds)
386
+ # -----------------------------------------------------------------------
387
+
388
+ def evaluate_datetime_relation(seconds, comparison_value, before)
389
+ before ? seconds < comparison_value : seconds > comparison_value
390
+ end
391
+
392
+ # -----------------------------------------------------------------------
393
+ # Array (cleartext + sensitive)
394
+ # -----------------------------------------------------------------------
395
+
396
+ def evaluate_array_contains_any_of(array, comparison_values, negate)
397
+ array.each { |item| return !negate if comparison_values.include?(item) }
398
+ negate
399
+ end
400
+
401
+ def evaluate_sensitive_array_contains_any_of(array, comparison_values, config_salt, context_salt, negate)
402
+ array.each do |item|
403
+ h = Hashing.hash_comparison_value(item, config_salt, context_salt)
404
+ return !negate if comparison_values.include?(h)
405
+ end
406
+ negate
407
+ end
408
+
409
+ # -----------------------------------------------------------------------
410
+ # Attribute → typed value, with three-valued error outcome (FR-010)
411
+ # -----------------------------------------------------------------------
412
+
413
+ def invalid_attr(attr, reason)
414
+ error("cannot evaluate, the User.#{attr} attribute is invalid (#{reason})")
415
+ end
416
+
417
+ # Attribute → text (string verbatim; else stringified — never errors).
418
+ def attribute_as_text(value)
419
+ value.is_a?(String) ? value : user_attribute_value_to_string(value)
420
+ end
421
+
422
+ # Attribute → semver, or a validation error.
423
+ def attribute_as_semver(attr, value)
424
+ if value.is_a?(String)
425
+ version = Semver.parse(value.strip)
426
+ return version unless version.nil?
427
+ end
428
+ invalid_attr(attr, "'#{user_attribute_value_to_string(value)}' is not a valid semantic version")
429
+ end
430
+
431
+ # Attribute → number, or a validation error.
432
+ def attribute_as_number(attr, value)
433
+ return value.to_f if numeric_non_bool?(value)
434
+
435
+ if value.is_a?(String)
436
+ num = parse_float_strict(value.tr(",", "."))
437
+ return num if !num.nan? || value.strip == "NaN"
438
+ end
439
+ invalid_attr(attr, "'#{user_attribute_value_to_string(value)}' is not a valid decimal number")
440
+ end
441
+
442
+ # Attribute → Unix seconds, or a validation error. A datetime attribute is
443
+ # converted to fractional epoch seconds; a numeric attribute is already
444
+ # seconds; a string is parsed strictly.
445
+ def attribute_as_unix_time_seconds(attr, value)
446
+ return datetime_to_seconds_since_epoch(value) if datetime?(value)
447
+ return value.to_f if numeric_non_bool?(value)
448
+
449
+ if value.is_a?(String)
450
+ num = parse_float_strict(value.tr(",", "."))
451
+ return num if !num.nan? || value.strip == "NaN"
452
+ end
453
+ invalid_attr(
454
+ attr,
455
+ "'#{user_attribute_value_to_string(value)}' is not a valid Unix timestamp " \
456
+ "(number of seconds elapsed since Unix epoch)"
457
+ )
458
+ end
459
+
460
+ # Attribute → string array, or a validation error. A string attribute is
461
+ # JSON-parsed; the result must be a string array.
462
+ def attribute_as_string_array(attr, value)
463
+ arr = value
464
+ if arr.is_a?(String)
465
+ begin
466
+ arr = JSON.parse(arr)
467
+ rescue JSON::ParserError
468
+ # A non-JSON string is simply not a valid array (matches common-js
469
+ # no-op catch); falls through to the validation error below.
470
+ arr = value
471
+ end
472
+ end
473
+ return arr if string_array?(arr)
474
+
475
+ invalid_attr(attr, "'#{user_attribute_value_to_string(value)}' is not a valid string array")
476
+ end
477
+
478
+ def numeric_non_bool?(value)
479
+ value.is_a?(Numeric) && value != true && value != false
480
+ end
481
+ end
482
+ end
483
+ end