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.
- checksums.yaml +4 -4
- data/README.md +26 -4
- data/lib/confeature/client.rb +397 -0
- data/lib/confeature/evaluation_details.rb +89 -0
- data/lib/confeature/flag_overrides.rb +186 -0
- data/lib/confeature/hooks.rb +84 -0
- data/lib/confeature/internal/cache.rb +166 -0
- data/lib/confeature/internal/comparators.rb +483 -0
- data/lib/confeature/internal/config_fetcher.rb +254 -0
- data/lib/confeature/internal/eval_log.rb +334 -0
- data/lib/confeature/internal/eval_logger.rb +140 -0
- data/lib/confeature/internal/evaluator.rb +866 -0
- data/lib/confeature/internal/hashing.rb +89 -0
- data/lib/confeature/internal/override_eval.rb +184 -0
- data/lib/confeature/internal/poll_service.rb +218 -0
- data/lib/confeature/internal/prerequisites.rb +88 -0
- data/lib/confeature/internal/sdk_key.rb +121 -0
- data/lib/confeature/internal/segments.rb +79 -0
- data/lib/confeature/internal/semver.rb +163 -0
- data/lib/confeature/log_level.rb +20 -0
- data/lib/confeature/logger.rb +59 -0
- data/lib/confeature/polling_mode.rb +62 -0
- data/lib/confeature/snapshot.rb +158 -0
- data/lib/confeature/user.rb +52 -0
- data/lib/confeature/version.rb +11 -0
- data/lib/confeature.rb +39 -1
- metadata +70 -7
|
@@ -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
|