quonfig 1.7.2 → 1.9.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.
@@ -54,6 +54,16 @@ module Quonfig
54
54
  OP_IS_PRESENT = 'IS_PRESENT'
55
55
  OP_IS_NOT_PRESENT = 'IS_NOT_PRESENT'
56
56
 
57
+ MAX_SAFE_INTEGER = (2**53) - 1
58
+
59
+ # Timestamp grammar for PROP_BEFORE / PROP_AFTER; see #date_to_millis.
60
+ INTEGER_STRING = /\A-?[0-9]+\z/
61
+ RFC3339_TIMESTAMP = /
62
+ \A(?<y>[0-9]{4})-(?<mo>[0-9]{2})-(?<d>[0-9]{2})
63
+ T(?<h>[0-9]{2}):(?<mi>[0-9]{2}):(?<s>[0-9]{2})(?:\.(?<frac>[0-9]+))?
64
+ (?:Z|(?<sign>[+-])(?<oh>[0-9]{2}):(?<om>[0-9]{2}))\z
65
+ /x
66
+
57
67
  MAGIC_CURRENT_TIME_PROPS = %w[quonfig.current-time prefab.current-time reforge.current-time].freeze
58
68
 
59
69
  # PROP_MATCHES / PROP_DOES_NOT_MATCH match timeout (qfg-goi1.2.11). Ruby
@@ -282,8 +292,8 @@ module Quonfig
282
292
  when OP_PROP_SEMVER_LESS_THAN, OP_PROP_SEMVER_EQUAL, OP_PROP_SEMVER_GREATER_THAN
283
293
  mv = hget(match_value, :value)
284
294
  if context_exists && context_value.is_a?(String) && mv.is_a?(String)
285
- sv_ctx = SemanticVersion.parse_quietly(context_value)
286
- sv_mv = SemanticVersion.parse_quietly(mv)
295
+ sv_ctx = SemanticVersion.parse_quietly(context_value, allow_abbreviated: true)
296
+ sv_mv = SemanticVersion.parse_quietly(mv, allow_abbreviated: true)
287
297
  if sv_ctx && sv_mv
288
298
  cmp = (sv_ctx <=> sv_mv)
289
299
  case operator
@@ -356,12 +366,27 @@ module Quonfig
356
366
 
357
367
  # --- Type coercion helpers ----------------------------------------
358
368
 
369
+ # Canonical string form (qfg-goi1.5 R1) for *_ONE_OF, the string
370
+ # operators and HIERARCHICAL_MATCH: strings as-is, booleans as
371
+ # "true"/"false", and an integral number below 2^53 as plain digits
372
+ # whatever its runtime type, so 1.0 is "1" and 5e9 is "5000000000"
373
+ # (Float#to_s would give "1.0" and "5.0e9").
359
374
  def to_s_nil(v)
360
375
  return '' if v.nil?
376
+ return v if v.is_a?(String)
377
+ return canonical_number(v) if v.is_a?(Numeric) && !v.is_a?(Integer)
361
378
 
362
379
  v.to_s
363
380
  end
364
381
 
382
+ def canonical_number(v)
383
+ return v.to_s unless v.real?
384
+ return v.to_s if v.respond_to?(:finite?) && !v.finite?
385
+ return v.to_s unless v == v.truncate && v.abs <= MAX_SAFE_INTEGER
386
+
387
+ v.to_i.to_s
388
+ end
389
+
365
390
  def to_string_slice(v)
366
391
  return [] if v.nil?
367
392
  return v.map { |i| to_s_nil(i) } if v.is_a?(Array)
@@ -414,21 +439,43 @@ module Quonfig
414
439
  [start_v || min, end_v || max]
415
440
  end
416
441
 
442
+ # The ONE timestamp grammar for PROP_BEFORE / PROP_AFTER (qfg-goi1.5 R5),
443
+ # applied to both the context value and the criterion: a number (epoch
444
+ # ms, truncated), an all-digit string (epoch ms), or RFC3339 with an
445
+ # uppercase T and a Z or numeric offset (fractional seconds allowed,
446
+ # truncated to ms). Date-only, offset-less and free-form strings are not
447
+ # timestamps and never match. This replaces DateTime.parse, which read
448
+ # any of those (offset-less as UTC).
417
449
  def date_to_millis(val)
418
450
  case val
419
- when Integer, Float
420
- val.to_i
451
+ when Integer
452
+ val
453
+ when Float
454
+ val.finite? ? val.to_i : nil
421
455
  when String
422
- # Try ISO-8601 / RFC3339 first, fall back to integer-string.
423
- begin
424
- t = DateTime.parse(val)
425
- return (t.to_time.to_f * 1000).to_i
426
- rescue ArgumentError, TypeError
427
- # not a date; try integer
428
- end
429
- Integer(val, exception: false)
456
+ return Integer(val, 10) if INTEGER_STRING.match?(val)
457
+
458
+ rfc3339_to_millis(val)
459
+ end
460
+ end
461
+
462
+ def rfc3339_to_millis(str)
463
+ m = RFC3339_TIMESTAMP.match(str)
464
+ return nil if m.nil?
465
+
466
+ y, mo, d, h, mi, s = %i[y mo d h mi s].map { |k| m[k].to_i }
467
+ return nil unless Date.valid_date?(y, mo, d) && h <= 23 && mi <= 59 && s <= 59
468
+
469
+ offset_seconds = 0
470
+ if m[:sign]
471
+ oh = m[:oh].to_i
472
+ om = m[:om].to_i
473
+ return nil if oh > 23 || om > 59
430
474
 
475
+ offset_seconds = ((oh * 3600) + (om * 60)) * (m[:sign] == '-' ? -1 : 1)
431
476
  end
477
+ epoch_seconds = Time.utc(y, mo, d, h, mi, s).to_i - offset_seconds
478
+ (epoch_seconds * 1000) + (m[:frac] || '').ljust(3, '0')[0, 3].to_i
432
479
  end
433
480
  end
434
481
 
@@ -540,8 +587,11 @@ module Quonfig
540
587
  when 'log_level' then raw.is_a?(Numeric) ? raw : raw.to_s
541
588
  when 'duration' then duration_to_millis(raw)
542
589
  when 'json'
543
- # JSON values must be native JS/Ruby types on the wire.
544
- raw
590
+ # JSON values must be native JS/Ruby types on the wire. Hashes and
591
+ # Arrays are copied so a caller mutating the result cannot rewrite
592
+ # the cached config for every later read (qfg-goi1.10.3, sdk-go
593
+ # qfg-9dxb.6). Leaf values (strings included) are not copied.
594
+ copy_json_containers(raw)
545
595
  else
546
596
  raw
547
597
  end
@@ -555,6 +605,14 @@ module Quonfig
555
605
 
556
606
  private
557
607
 
608
+ def copy_json_containers(value)
609
+ case value
610
+ when Hash then value.to_h { |k, v| [k, copy_json_containers(v)] }
611
+ when Array then value.map { |v| copy_json_containers(v) }
612
+ else value
613
+ end
614
+ end
615
+
558
616
  # Coerce an ISO 8601 duration value (e.g. "PT0.2S", "P1DT6H2M1.5S")
559
617
  # to integer milliseconds. The wire format may either be a bare ISO
560
618
  # string in `value` or the structured `{ seconds, nanos }` proto-style
@@ -47,26 +47,13 @@ module Quonfig
47
47
  # the legacy "return nil if absent" shape can rescue and recover (see
48
48
  # IntegrationTestHelpers.assert_resolved, which folds a missing-key
49
49
  # raise into the test's expected default).
50
+ # The whole lookup reads one store snapshot (qfg-i86q): the flag, the
51
+ # segments it references and any decryptWith key all come from the same
52
+ # envelope even if an install lands mid-evaluation.
50
53
  def get(key, context = nil)
51
- config = raw(key)
52
- raise Quonfig::Errors::MissingDefaultError, key if config.nil?
53
-
54
- eval_result = @evaluator.evaluate_config(config, context, resolver: self)
55
- return nil if eval_result.nil?
54
+ return get_unpinned(key, context) unless @store.respond_to?(:pinned)
56
55
 
57
- weighted_index = nil
58
- hash_missing = []
59
- resolved_value = resolve_value_on_path(eval_result.value, config, context, [], hash_missing) do |idx|
60
- weighted_index = idx
61
- end
62
- EvalResult.new(
63
- value: resolved_value,
64
- rule_index: eval_result.rule_index,
65
- config: config,
66
- weighted_value_index: weighted_index,
67
- reportable_value: redacted_reportable_value(eval_result.value),
68
- hash_property_missing: !hash_missing.empty?
69
- )
56
+ @store.pinned { get_unpinned(key, context) }
70
57
  end
71
58
 
72
59
  # Post-evaluation value resolution. Mirrors sdk-node Resolver#resolveValue
@@ -86,20 +73,54 @@ module Quonfig
86
73
 
87
74
  private
88
75
 
76
+ def get_unpinned(key, context)
77
+ config = raw(key)
78
+ raise Quonfig::Errors::MissingDefaultError, key if config.nil?
79
+
80
+ eval_result = @evaluator.evaluate_config(config, context, resolver: self)
81
+ return nil if eval_result.nil?
82
+
83
+ weighted_index = nil
84
+ hash_missing = []
85
+ selected_variants = []
86
+ resolved_value = resolve_value_on_path(eval_result.value, config, context, [], hash_missing,
87
+ selected_variants) do |idx|
88
+ weighted_index = idx
89
+ end
90
+ # Redact from the selected rollout variant, not only the outer
91
+ # weighted_values wrapper (which is never flagged), so a confidential or
92
+ # decryptWith variant never reaches telemetry in plaintext (qfg-goi1.2.39).
93
+ reportable = redacted_reportable_value(eval_result.value) || redacted_reportable_value(selected_variants.last)
94
+ EvalResult.new(
95
+ value: resolved_value,
96
+ rule_index: eval_result.rule_index,
97
+ config: config,
98
+ weighted_value_index: weighted_index,
99
+ reportable_value: reportable,
100
+ hash_property_missing: !hash_missing.empty?
101
+ )
102
+ end
103
+
89
104
  # resolve_value plus +key_path+: the config keys already being resolved
90
105
  # above this one through decryptWith, so a decryptWith cycle raises a
91
106
  # DecryptionError instead of recursing until SystemStackError
92
107
  # (qfg-9dxb.7, matching sdk-go qfg-9dxb.4). +hash_missing+, when given,
93
108
  # gets an entry for each weighted rollout whose hash property was missing
94
- # from the context (qfg-9dxb.8).
95
- def resolve_value_on_path(value, config, context, key_path, hash_missing = nil, &on_weighted_index)
109
+ # from the context (qfg-9dxb.8). +selected_variants+, when given, gets the
110
+ # stored (pre-resolution) Value of each weighted variant picked, so get()
111
+ # can redact a confidential/decryptWith variant (qfg-goi1.2.39).
112
+ def resolve_value_on_path(value, config, context, key_path, hash_missing = nil, selected_variants = nil,
113
+ &on_weighted_index)
96
114
  return nil if value.nil?
97
115
 
98
116
  type = vget(value, :type, 'type')
99
117
 
100
118
  return resolve_provided(value, config) if type == 'provided'
101
119
 
102
- return resolve_weighted(value, config, context, key_path, hash_missing, &on_weighted_index) if type == 'weighted_values'
120
+ if type == 'weighted_values'
121
+ return resolve_weighted(value, config, context, key_path, hash_missing, selected_variants,
122
+ &on_weighted_index)
123
+ end
103
124
 
104
125
  confidential = vget(value, :confidential, 'confidential')
105
126
  decrypt_with = vget(value, :decryptWith, 'decryptWith', :decrypt_with, 'decrypt_with')
@@ -184,7 +205,8 @@ module Quonfig
184
205
  # nil) hashes an empty value, the same bucket as a present "", and warns
185
206
  # once per config key (qfg-9dxb.8). With no hash property configured the
186
207
  # pick stays random per call.
187
- def resolve_weighted(value, config, context, key_path, hash_missing = nil, &on_weighted_index)
208
+ def resolve_weighted(value, config, context, key_path, hash_missing = nil, selected_variants = nil,
209
+ &on_weighted_index)
188
210
  payload = vget(value, :value, 'value') || {}
189
211
  weighted = vget(payload, :weightedValues, 'weightedValues', :weighted_values, 'weighted_values')
190
212
  return value unless weighted.is_a?(Array) && !weighted.empty?
@@ -213,7 +235,9 @@ module Quonfig
213
235
  variant, index = picker.resolve
214
236
  on_weighted_index&.call(index)
215
237
  variant_value = vget(variant, :value, 'value')
216
- resolve_value_on_path(variant_value, config, context, key_path, hash_missing, &on_weighted_index)
238
+ selected_variants&.push(variant_value)
239
+ resolve_value_on_path(variant_value, config, context, key_path, hash_missing, selected_variants,
240
+ &on_weighted_index)
217
241
  end
218
242
 
219
243
  def warn_hash_property_missing(cfg_key, hash_property)
@@ -18,6 +18,11 @@ class SemanticVersion
18
18
  $
19
19
  /x
20
20
 
21
+ # Abbreviated "x" and "x.y" (qfg-goi1.5 Q5), read as x.0.0 and x.y.0 by
22
+ # the PROP_SEMVER_* operators, matching LaunchDarkly. A prerelease or build
23
+ # suffix needs the full x.y.z.
24
+ ABBREVIATED_PATTERN = /\A(?<major>0|[1-9]\d*)(?:\.(?<minor>0|[1-9]\d*))?\z/
25
+
21
26
  attr_reader :major, :minor, :patch, :prerelease, :build_metadata
22
27
 
23
28
  def self.parse(version_string)
@@ -36,7 +41,12 @@ class SemanticVersion
36
41
  )
37
42
  end
38
43
 
39
- def self.parse_quietly(version_string)
44
+ def self.parse_quietly(version_string, allow_abbreviated: false)
45
+ if allow_abbreviated && version_string.is_a?(String)
46
+ match = ABBREVIATED_PATTERN.match(version_string)
47
+ return new(major: match[:major].to_i, minor: match[:minor].to_i, patch: 0) if match
48
+ end
49
+
40
50
  parse(version_string)
41
51
  rescue ArgumentError
42
52
  nil