From 68094a26a32812ce639c855de178dd81b659f96d Mon Sep 17 00:00:00 2001 From: Kwame Efah <37164746+efahk@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:46:55 +0000 Subject: [PATCH 1/4] Implement semver and date custom ops for flags runtime props --- lib/mixpanel-ruby/flags/custom_operators.rb | 130 ++++++++ .../flags/local_flags_provider.rb | 6 +- mixpanel-ruby.gemspec | 3 +- .../flags/custom_operators_spec.rb | 286 ++++++++++++++++++ spec/mixpanel-ruby/flags/local_flags_spec.rb | 62 ++++ 5 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 lib/mixpanel-ruby/flags/custom_operators.rb create mode 100644 spec/mixpanel-ruby/flags/custom_operators_spec.rb diff --git a/lib/mixpanel-ruby/flags/custom_operators.rb b/lib/mixpanel-ruby/flags/custom_operators.rb new file mode 100644 index 00000000..03069820 --- /dev/null +++ b/lib/mixpanel-ruby/flags/custom_operators.rb @@ -0,0 +1,130 @@ +require 'time' +require 'json_logic' +require 'semantic_range' + +module Mixpanel + module Flags + module CustomOperators + # Using the official semantic versioning 2.0.0 regular expression to handle cross-platform validation + # differences on other SDK's. For example, some platforms allow leading zeros even though it is not valid + # as part of the Semver 2.0.0 spec. See https://semver.org/ + SEMVER_STRICT = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?\z/ + + # Strict RFC3339 guard for datetime strings. + RFC3339_STRICT = /\A\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})\z/ + + # SemVer 2.0.0 requires major.minor.patch; partial versions are zero-padded to this. + SEMVER_PARTS = 3 + + # Epoch milliseconds are compared as int64 elsewhere, so anything at or beyond this is out of range. + MAX_EPOCH_MS = 2**63 + + module_function + + # Implements a custom operation for semantic versioning comparison that conforms to the + # semver 2.0.0 standard. Prior to comparison, any leading version prefix is stripped. + def semver_compare(values) + unpacked = operands(values) + return false unless unpacked + + actual, symbol, target = unpacked + return false unless actual.is_a?(String) && target.is_a?(String) + + actual_version = normalize_semver(actual) + target_version = normalize_semver(target) + return false unless actual_version.match?(SEMVER_STRICT) && target_version.match?(SEMVER_STRICT) + + cmp = SemanticRange.compare(actual_version, target_version) + comparator_matches?(cmp, symbol) + end + + # Implements a custom operation for datetime comparison. The target value stored on the + # feature flag is the millisecond epoch, whereas the actual value provided at evaluation + # time must be RFC-3339 formatted. + def datetime_compare(values) + unpacked = operands(values) + return false unless unpacked + + actual, symbol, target = unpacked + actual_sec = convert_rfc3339_to_unix_seconds(actual) + target_sec = convert_unix_milliseconds_to_seconds(target) + return false unless actual_sec && target_sec + + cmp = actual_sec - target_sec + comparator_matches?(cmp, symbol) + end + + def operands(values) + return nil unless values.length == 3 + + actual, symbol, target = values + return nil unless symbol.is_a?(String) + + [actual, symbol, target] + end + + def comparator_matches?(cmp, symbol) + case symbol + when '=' then cmp.zero? + when '!=' then !cmp.zero? + when '<' then cmp < 0 + when '<=' then cmp <= 0 + when '>' then cmp > 0 + when '>=' then cmp >= 0 + else false + end + end + + def normalize_semver(str) + stripped = str.strip + stripped = stripped[1..] if stripped =~ /\Av/i + + suffix_start = stripped.length + ['-', '+'].each do |separator| + index = stripped.index(separator) + suffix_start = index if index && index < suffix_start + end + + core = stripped[0, suffix_start] + suffix = stripped[suffix_start..] || '' + + # split(-1) keeps trailing empty fields, so "1." and "1.2.3." stay malformed instead of + # silently padding to a valid version. Returning the input unchanged lets the validator reject it. + segments = core.split('.', -1) + return stripped unless segments.length.between?(1, SEMVER_PARTS) && segments.all? { |seg| seg.match?(/\A\d+\z/) } + + segments += ['0'] * (SEMVER_PARTS - segments.length) + segments.join('.') + suffix + end + + def convert_rfc3339_to_unix_seconds(value) + return nil unless value.is_a?(String) + + normalized = value.strip.upcase + return nil unless normalized =~ RFC3339_STRICT + + parsed = Time.iso8601(normalized) + parsed.to_i + rescue ArgumentError + nil + end + + def convert_unix_milliseconds_to_seconds(value) + return nil unless value.is_a?(Numeric) + # A value int64 cannot represent is not a real timestamp; treating one as a bound would let a + # nonsense target define a rollout window. NaN fails this comparison too. + return nil unless value.abs < MAX_EPOCH_MS + + value.to_i.fdiv(1000).truncate + end + end + end +end + +JsonLogic.add_operation('semver_compare') do |values, _data| + Mixpanel::Flags::CustomOperators.semver_compare(values) +end + +JsonLogic.add_operation('datetime_compare') do |values, _data| + Mixpanel::Flags::CustomOperators.datetime_compare(values) +end diff --git a/lib/mixpanel-ruby/flags/local_flags_provider.rb b/lib/mixpanel-ruby/flags/local_flags_provider.rb index c0189c7e..4adf319f 100644 --- a/lib/mixpanel-ruby/flags/local_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/local_flags_provider.rb @@ -1,6 +1,7 @@ require 'thread' require 'json_logic' require 'mixpanel-ruby/flags/flags_provider' +require 'mixpanel-ruby/flags/custom_operators' module Mixpanel module Flags @@ -377,7 +378,10 @@ def is_runtime_evaluation_satisfied?(rollout, context) begin rule = lowercase_only_leaf_nodes(runtime_rule) result = JsonLogic.apply(rule, parameters) - !!result + # A well-formed runtime rule evaluates to a boolean. Anything else — + # notably an unrecognized operator, which the engine echoes back as a + # (truthy) hash rather than raising — fails closed. + result == true rescue StandardError => e @error_handler.handle(e) if @error_handler false diff --git a/mixpanel-ruby.gemspec b/mixpanel-ruby.gemspec index a04e6176..33d3c195 100644 --- a/mixpanel-ruby.gemspec +++ b/mixpanel-ruby.gemspec @@ -15,7 +15,8 @@ spec = Gem::Specification.new do |spec| spec.required_ruby_version = '>= 3.0.0' spec.add_runtime_dependency 'mutex_m' spec.add_runtime_dependency "base64" - spec.add_runtime_dependency 'json-logic-rb', '~> 0.1.5' + spec.add_runtime_dependency 'json-logic-rb', '~> 0.2' + spec.add_runtime_dependency 'semantic_range', '~> 3.1' spec.add_development_dependency 'activesupport', '~> 4.0' spec.add_development_dependency 'rake', '~> 13' diff --git a/spec/mixpanel-ruby/flags/custom_operators_spec.rb b/spec/mixpanel-ruby/flags/custom_operators_spec.rb new file mode 100644 index 00000000..cccf76bf --- /dev/null +++ b/spec/mixpanel-ruby/flags/custom_operators_spec.rb @@ -0,0 +1,286 @@ +require 'rspec' +require 'json_logic' +require 'mixpanel-ruby/flags/custom_operators' + +# Rule builders defined at the top level so the case tables below can be built +# while the describe blocks are being collected. +def var_node(key) + { 'var' => key } +end + +def semver_rule(key, sym, target) + { 'semver_compare' => [var_node(key), sym, target] } +end + +def datetime_rule(key, sym, target) + { 'datetime_compare' => [var_node(key), sym, target] } +end + +def custom_between(op, key, lo, hi) + { 'and' => [ + { op => [var_node(key), '>=', lo] }, + { op => [var_node(key), '<=', hi] } + ] } +end + +def datetime_between(key, lo, hi) + { 'and' => [ + { 'datetime_compare' => [var_node(key), '>=', lo] }, + { 'datetime_compare' => [var_node(key), '<=', hi] } + ] } +end + +describe Mixpanel::Flags::CustomOperators do + def apply(rule, data) + JsonLogic.apply(rule, data) + end + + describe 'semver_compare' do + [ + ['is, equal', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3' }, true], + ['is, not equal', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.4' }, false], + ['is not', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.4' }, true], + ['less than, patch', semver_rule('app_version', '<', '1.2.3'), { 'app_version' => '1.2.2' }, true], + ['less than, false', semver_rule('app_version', '<', '1.2.3'), { 'app_version' => '1.2.3' }, false], + ['less or equal, boundary', semver_rule('app_version', '<=', '1.2.3'), { 'app_version' => '1.2.3' }, true], + ['greater than, minor', semver_rule('app_version', '>', '1.2.3'), { 'app_version' => '1.3.0' }, true], + ['greater or equal, boundary', semver_rule('app_version', '>=', '1.2.3'), { 'app_version' => '1.2.3' }, true], + ['double-digit ordering (not lexical)', semver_rule('app_version', '>', '1.9.0'), { 'app_version' => '1.10.0' }, true], + ['prerelease precedes release', semver_rule('app_version', '<', '1.0.0'), { 'app_version' => '1.0.0-alpha' }, true], + ['lenient v-prefix', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => 'v1.2.3' }, true], + ['lenient uppercase V-prefix', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => 'V1.2.3' }, true], + ['v-prefix keeps prerelease', semver_rule('app_version', '<', '1.0.0'), { 'app_version' => 'v1.0.0-alpha' }, true], + ['v-prefix, not equal', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => 'v1.2.4' }, true], + ['v-prefix, at or below', semver_rule('app_version', '<=', '1.2.3'), { 'app_version' => 'v1.2.3' }, true], + ['v-prefix, greater', semver_rule('app_version', '>', '1.2.3'), { 'app_version' => 'v1.2.4' }, true], + ['v-prefix, at or above', semver_rule('app_version', '>=', '1.2.3'), { 'app_version' => 'v1.2.3' }, true], + ['lenient minor-only target', semver_rule('app_version', '=', '1.2'), { 'app_version' => '1.2.0' }, true], + # Every symbol is asserted in both directions. + ['is not, equal', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3' }, false], + ['less or equal, above', semver_rule('app_version', '<=', '1.2.3'), { 'app_version' => '1.2.4' }, false], + ['greater than, below', semver_rule('app_version', '>', '1.2.3'), { 'app_version' => '1.2.2' }, false], + ['greater or equal, below', semver_rule('app_version', '>=', '1.2.3'), { 'app_version' => '1.2.2' }, false], + # Prerelease precedence, SemVer 2.0.0 section 11. + ['prerelease alpha before beta', semver_rule('app_version', '<', '1.0.0-beta'), { 'app_version' => '1.0.0-alpha' }, true], + ['prerelease beta before rc1', semver_rule('app_version', '<', '1.0.0-rc1'), { 'app_version' => '1.0.0-beta' }, true], + ['prerelease rc1 before rc2', semver_rule('app_version', '<', '1.0.0-rc2'), { 'app_version' => '1.0.0-rc1' }, true], + ['more prerelease fields wins', semver_rule('app_version', '<', '1.0.0-alpha.1'), { 'app_version' => '1.0.0-alpha' }, true], + ['numeric identifier below alphanumeric', semver_rule('app_version', '<', '1.0.0-alpha.beta'), { 'app_version' => '1.0.0-alpha.1' }, true], + ['fewer fields below alphanumeric', semver_rule('app_version', '<', '1.0.0-alpha.beta'), { 'app_version' => '1.0.0-alpha' }, true], + ['numeric identifiers compare numerically', semver_rule('app_version', '<', '1.0.0-beta.11'), { 'app_version' => '1.0.0-beta.2' }, true], + ['dotted identifier ordering, letters', semver_rule('app_version', '<', '1.0.0-b.1'), { 'app_version' => '1.0.0-a.1' }, true], + ['dotted identifier ordering, digits', semver_rule('app_version', '<', '1.0.0-a.2'), { 'app_version' => '1.0.0-a.1' }, true], + ['identical prereleases are equal', semver_rule('app_version', '=', '1.0.0-rc1'), { 'app_version' => '1.0.0-rc1' }, true], + ['rc1 outranks dotted rc.1', semver_rule('app_version', '>', '1.0.0-rc.1'), { 'app_version' => '1.0.0-rc1' }, true], + ['core version dominates prerelease', semver_rule('app_version', '>', '1.9.9'), { 'app_version' => '2.0.0-alpha' }, true], + # A release outranks its own prerelease, asserted from both sides and under every symbol. + ['release outranks its prerelease', semver_rule('app_version', '>', '1.0.0-alpha'), { 'app_version' => '1.0.0' }, true], + ['release at or above its prerelease', semver_rule('app_version', '>=', '1.0.0-rc1'), { 'app_version' => '1.0.0' }, true], + ['release differs from its prerelease', semver_rule('app_version', '!=', '1.0.0-alpha'), { 'app_version' => '1.0.0' }, true], + ['prerelease differs from its release', semver_rule('app_version', '!=', '1.0.0'), { 'app_version' => '1.0.0-alpha' }, true], + ['prerelease at or below its release', semver_rule('app_version', '<=', '1.0.0'), { 'app_version' => '1.0.0-alpha' }, true], + ['prerelease of a higher core still wins', semver_rule('app_version', '>', '0.9.9'), { 'app_version' => '1.0.0-alpha' }, true], + ['prerelease below the next patch', semver_rule('app_version', '<', '1.0.1'), { 'app_version' => '1.0.0-rc1' }, true], + # Prerelease identifier comparison, SemVer 2.0.0 section 11.4. + ['numeric identifiers are not compared lexically', semver_rule('app_version', '<', '1.0.0-10'), { 'app_version' => '1.0.0-2' }, true], + ['numeric identifier ranks below alphanumeric', semver_rule('app_version', '<', '1.0.0-alpha'), { 'app_version' => '1.0.0-1' }, true], + ['hyphen inside an identifier sorts by ascii', semver_rule('app_version', '<', '1.0.0-alpha-1'), { 'app_version' => '1.0.0-alpha' }, true], + ['beta ranks below rc', semver_rule('app_version', '<', '1.0.0-rc.1'), { 'app_version' => '1.0.0-beta.11' }, true], + ['last prerelease ranks below the release', semver_rule('app_version', '<', '1.0.0'), { 'app_version' => '1.0.0-rc.1' }, true], + # Build metadata carries no precedence. + ['build metadata ignored', semver_rule('app_version', '=', '1.0.0+build2'), { 'app_version' => '1.0.0+build1' }, true], + ['build metadata ignored with prerelease', semver_rule('app_version', '=', '1.0.0-alpha'), { 'app_version' => '1.0.0-alpha+build' }, true], + ['build metadata with hyphen ignored', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3+build.1-2' }, true], + # Ignored means equal, so every symbol has to agree with that. + ['build metadata leaves versions equal', semver_rule('app_version', '!=', '1.0.0+build2'), { 'app_version' => '1.0.0+build1' }, false], + ['build metadata is not less', semver_rule('app_version', '<', '1.0.0+build2'), { 'app_version' => '1.0.0+build1' }, false], + ['build metadata is not greater', semver_rule('app_version', '>', '1.0.0+build2'), { 'app_version' => '1.0.0+build1' }, false], + ['build metadata at or below', semver_rule('app_version', '<=', '1.0.0+build2'), { 'app_version' => '1.0.0+build1' }, true], + ['build metadata at or above', semver_rule('app_version', '>=', '1.0.0+build2'), { 'app_version' => '1.0.0+build1' }, true], + ['build metadata does not block ordering', semver_rule('app_version', '<', '1.0.1+build1'), { 'app_version' => '1.0.0+build9' }, true], + ['build metadata does not block reverse ordering', semver_rule('app_version', '>', '1.0.0+build9'), { 'app_version' => '1.0.1+build1' }, true], + # Partial versions keep their prerelease once zero-padded. + ['partial version with prerelease', semver_rule('app_version', '=', '1.2.0-alpha'), { 'app_version' => '1.2-alpha' }, true], + ['partial prerelease below later minor', semver_rule('app_version', '<', '1.3.1'), { 'app_version' => '1.2-alpha' }, true], + ['partial prerelease below its release', semver_rule('app_version', '<', '1.2.0'), { 'app_version' => '1.2-alpha' }, true], + ['major-only with prerelease', semver_rule('app_version', '<', '1.0.0'), { 'app_version' => '1-rc1' }, true], + # An empty prerelease is invalid, so it is rejected rather than treated as the bare release. + ['empty prerelease, no match', semver_rule('app_version', '=', '1.0.0'), { 'app_version' => '1.0.0-' }, false], + ['empty prerelease, not-equal also false', semver_rule('app_version', '!=', '1.0.0'), { 'app_version' => '1.0.0-' }, false], + ['empty prerelease on partial version, no match', semver_rule('app_version', '=', '1.2.0'), { 'app_version' => '1.2-' }, false], + ['empty prerelease on partial version, not-equal also false', semver_rule('app_version', '!=', '1.2.0'), { 'app_version' => '1.2-' }, false], + # Hyphens are legal inside a prerelease identifier, so these are NOT empty prereleases. + ['trailing hyphen inside identifier', semver_rule('app_version', '<', '1.0.0'), { 'app_version' => '1.0.0-alpha-' }, true], + # SemVer 2.0.0 forbids leading zeros in the core, so these are rejected rather than normalized. + ['leading zero in major, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '01.2.3' }, false], + ['leading zero in major, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '01.2.3' }, false], + ['leading zero in minor, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.02.3' }, false], + ['leading zero in minor, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.02.3' }, false], + ['leading zero in patch, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.03' }, false], + ['leading zero in patch, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.03' }, false], + ['leading zeros throughout, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '01.02.03' }, false], + ['leading zeros throughout, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '01.02.03' }, false], + # A numeric prerelease identifier may not carry a leading zero either (section 9). + ['numeric prerelease with leading zero, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3-01' }, false], + ['numeric prerelease with leading zero, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3-01' }, false], + ['dotted numeric prerelease with leading zero, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3-rc.01' }, false], + ['dotted numeric prerelease with leading zero, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3-rc.01' }, false], + # An alphanumeric identifier may contain digits, so this one stays valid. + ['alphanumeric prerelease with digits', semver_rule('app_version', '<', '1.2.3'), { 'app_version' => '1.2.3-rc01' }, true], + ['between, inside', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => '1.5.0' }, true], + ['between, low boundary inclusive', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => '1.2.3' }, true], + ['between, high boundary inclusive', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => '2.0.0' }, true], + ['between, below', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => '1.0.0' }, false], + ['between, above', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => '2.0.1' }, false], + # A prerelease sits below its own release, which decides both boundary cases. + ['between, prerelease inside', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => '1.5.0-rc1' }, true], + ['between, prerelease below the high bound', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => '2.0.0-rc1' }, true], + ['between, prerelease of the low bound falls out', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => '1.2.3-rc1' }, false], + ['between, invalid version', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => 'not-a-version' }, false], + ['between, single-point range', custom_between('semver_compare', 'app_version', '1.2.3', '1.2.3'), { 'app_version' => '1.2.3' }, true], + # Fail-closed: unparseable or missing values never match. + ['invalid actual, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => 'not-a-version' }, false], + ['non-string actual, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => 123 }, false], + ['missing property, no match', semver_rule('app_version', '=', '1.2.3'), {}, false], + # A malformed version must never be padded or coerced into a real one. Both symbols are + # asserted so that "accepted at all" is observable rather than masked by a single false. + ['empty version, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '' }, false], + ['empty version, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '' }, false], + ['bare v, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => 'v' }, false], + ['bare v, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => 'v' }, false], + ['leading separator, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '-1.2.3' }, false], + ['leading separator, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '-1.2.3' }, false], + ['trailing dot, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.' }, false], + ['trailing dot, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.' }, false], + ['trailing dot after patch, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3.' }, false], + ['trailing dot after patch, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3.' }, false], + ['empty middle segment, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1..2' }, false], + ['empty middle segment, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1..2' }, false], + ['four components, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3.4' }, false], + ['four components, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3.4' }, false], + ['range prefix, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '^1.2.3' }, false], + ['range prefix, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '^1.2.3' }, false], + ['version inside text, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => 'abc1.2.3' }, false], + ['version inside text, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => 'abc1.2.3' }, false], + ['empty build metadata, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3+' }, false], + ['empty build metadata, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3+' }, false], + ['empty prerelease identifier, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3-alpha..1' }, false], + ['empty prerelease identifier, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3-alpha..1' }, false], + ['lone dot prerelease, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3-.' }, false], + ['lone dot prerelease, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3-.' }, false], + ['underscore in prerelease, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3-ALPHA_BETA' }, false], + ['underscore in prerelease, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3-ALPHA_BETA' }, false], + ['doubled v-prefix, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => 'vv1.2.3' }, false], + ['doubled v-prefix, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => 'vv1.2.3' }, false] + ].each do |name, rule, data, want| + it name do + expect(apply(rule, data)).to eq(want) + end + end + end + + # Epoch-millisecond constants (UTC instants) used as datetime targets, + # matching the UI's emitted format. + JUL16_MS = 1_784_160_000_000 # 2026-07-16T00:00:00Z + JAN1_MS = 1_767_225_600_000 # 2026-01-01T00:00:00Z + DEC31_MS = 1_798_675_200_000 # 2026-12-31T00:00:00Z + JUL16_END_MS = 1_784_246_399_999 # 2026-07-16T23:59:59.999Z + LEAP_DAY_MS = 1_709_164_800_000 # 2024-02-29T00:00:00Z + JUL16_INDIA_MS = 1_784_140_200_000 # 2026-07-16T00:00:00+05:30 + JUL16_PACIFIC_MS = 1_784_188_800_000 # 2026-07-16T00:00:00-08:00 + + describe 'datetime_compare' do + [ + # Asymmetric contract: subject (runtime var) is a strict RFC3339 string, target is epoch ms. + ['before, true', datetime_rule('signup', '<', JUL16_MS), { 'signup' => '2026-07-15T00:00:00Z' }, true], + ['before, false', datetime_rule('signup', '<', JUL16_MS), { 'signup' => '2026-07-16T00:00:00Z' }, false], + ['on (equal), true', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00Z' }, true], + ['not on, true', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-17T00:00:00Z' }, true], + ['since (>=), boundary', datetime_rule('signup', '>=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00Z' }, true], + ['after (>), true', datetime_rule('signup', '>', JUL16_MS), { 'signup' => '2026-07-17T00:00:00Z' }, true], + ['after (>), false', datetime_rule('signup', '>', JUL16_MS), { 'signup' => '2026-07-15T00:00:00Z' }, false], + # Every symbol is asserted in both directions. + ['at or before, boundary', datetime_rule('signup', '<=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00Z' }, true], + ['at or before, after', datetime_rule('signup', '<=', JUL16_MS), { 'signup' => '2026-07-17T00:00:00Z' }, false], + ['on (equal), false', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-17T00:00:00Z' }, false], + ['not on, equal', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00Z' }, false], + ['since (>=), before', datetime_rule('signup', '>=', JUL16_MS), { 'signup' => '2026-07-15T00:00:00Z' }, false], + ['between, inside', datetime_between('signup', JAN1_MS, DEC31_MS), { 'signup' => '2026-06-15T00:00:00Z' }, true], + ['between, low boundary inclusive', datetime_between('signup', JAN1_MS, DEC31_MS), { 'signup' => '2026-01-01T00:00:00Z' }, true], + ['between, high boundary inclusive', datetime_between('signup', JAN1_MS, DEC31_MS), { 'signup' => '2026-12-31T00:00:00Z' }, true], + ['between, before range', datetime_between('signup', JAN1_MS, DEC31_MS), { 'signup' => '2025-12-31T00:00:00Z' }, false], + ['between, after range', datetime_between('signup', JAN1_MS, DEC31_MS), { 'signup' => '2027-01-01T00:00:00Z' }, false], + ['negative epoch-ms target resolves to -1s', datetime_rule('signup', '=', -1500), { 'signup' => '1969-12-31T23:59:59Z' }, true], + ['negative epoch-ms target, not equal', datetime_rule('signup', '!=', -1500), { 'signup' => '1969-12-31T23:59:59Z' }, false], + ['negative epoch-ms target, at or after', datetime_rule('signup', '>=', -1500), { 'signup' => '1969-12-31T23:59:59Z' }, true], + ['negative epoch-ms target, before', datetime_rule('signup', '<', -1500), { 'signup' => '1969-12-31T23:59:58Z' }, true], + ['negative epoch-ms target, after', datetime_rule('signup', '>', -2500), { 'signup' => '1969-12-31T23:59:59Z' }, true], + ['subject floors, it does not truncate', datetime_rule('signup', '=', -2000), { 'signup' => '1969-12-31T23:59:58.500Z' }, true], + ['subject floors, not to -1s', datetime_rule('signup', '!=', -1000), { 'signup' => '1969-12-31T23:59:58.500Z' }, true], + # A leap day is a real date. + ['leap day', datetime_rule('signup', '=', LEAP_DAY_MS), { 'signup' => '2024-02-29T00:00:00Z' }, true], + # Time-zone offsets change the instant. + ['offset with half-hour minutes', datetime_rule('signup', '=', JUL16_INDIA_MS), { 'signup' => '2026-07-16T00:00:00+05:30' }, true], + ['rfc3339 subject with offset', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T02:00:00+02:00' }, true], + ['positive offset precedes utc midnight', datetime_rule('signup', '<', JUL16_MS), { 'signup' => '2026-07-16T00:00:00+05:30' }, true], + ['negative offset', datetime_rule('signup', '=', JUL16_PACIFIC_MS), { 'signup' => '2026-07-16T00:00:00-08:00' }, true], + ['negative offset follows utc midnight', datetime_rule('signup', '>', JUL16_MS), { 'signup' => '2026-07-16T00:00:00-08:00' }, true], + ['zero offset equals Z', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00+00:00' }, true], + # Sub-second precision is dropped, on both sides. The end-of-day rows are the window the UI + # emits for a single date, whose upper bound carries .999. + ['one-digit fraction', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00.5Z' }, true], + ['three-digit fraction', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00.500Z' }, true], + ['six-digit fraction', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00.123456Z' }, true], + ['nine-digit fraction', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00.999999999Z' }, true], + ['zero fraction', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00.0Z' }, true], + ['fractional seconds truncated', datetime_rule('signup', '>=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00.500Z' }, true], + ['end-of-day target drops its .999', datetime_rule('signup', '=', JUL16_END_MS), { 'signup' => '2026-07-16T23:59:59Z' }, true], + ['end-of-day target is an inclusive bound', datetime_rule('signup', '<=', JUL16_END_MS), { 'signup' => '2026-07-16T23:59:59Z' }, true], + ['end-of-day, fractional subject too', datetime_rule('signup', '=', JUL16_END_MS), { 'signup' => '2026-07-16T23:59:59.999Z' }, true], + ['end-of-day inclusive, fractional subject', datetime_rule('signup', '<=', JUL16_END_MS), { 'signup' => '2026-07-16T23:59:59.999Z' }, true], + # Fractional on both sides: the shape the UI actually round-trips. + # Trimming and lowercasing. + ['lowercased subject with fraction', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16t00:00:00.500z' }, true], + ['lowercased subject with offset', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16t02:00:00+02:00' }, true], + ['whitespace-padded subject', datetime_rule('signup', '=', JUL16_MS), { 'signup' => ' 2026-07-16T00:00:00Z ' }, true], + ['lowercased rfc3339 subject', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16t00:00:00z' }, true], + # Shape violations, asserted under both = and != so that "accepted at all" is observable. + # RFC 3339 also permits 24:00:00 as end-of-day. Platforms disagree on it, so no vector + # asserts it either way. + ['one-digit month, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-7-16T00:00:00Z' }, false], + ['one-digit month, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-7-16T00:00:00Z' }, false], + ['space separator, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16 00:00:00Z' }, false], + ['space separator, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16 00:00:00Z' }, false], + ['missing zone, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00' }, false], + ['missing zone, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00' }, false], + ['empty fraction, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00.Z' }, false], + ['empty fraction, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00.Z' }, false], + ['offset without colon, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00+0200' }, false], + ['offset without colon, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00+0200' }, false], + ['short offset, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00+02' }, false], + ['short offset, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00+02' }, false], + ['trailing junk, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00Zextra' }, false], + ['trailing junk, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00Zextra' }, false], + ['basic format, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '20260716T000000Z' }, false], + ['basic format, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '20260716T000000Z' }, false], + ['zone after lowercase z, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00z00:00' }, false], + ['zone after lowercase z, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00z00:00' }, false], + ['comma fractional separator, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00,5Z' }, false], + ['comma fractional separator, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00,5Z' }, false], + # Fail-closed: subject must be an RFC3339 string, target must be an epoch-ms number. + ['numeric subject, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => JUL16_MS }, false], + ['target beyond representable range, no match', datetime_rule('signup', '=', 1e308), { 'signup' => '2026-07-16T00:00:00Z' }, false], + ['target beyond representable range, greater-than also false', datetime_rule('signup', '>', 1e308), { 'signup' => '2026-07-16T00:00:00Z' }, false], + ['target beyond representable range, less-than also false', datetime_rule('signup', '<', 1e308), { 'signup' => '2026-07-16T00:00:00Z' }, false], + ['bare date subject, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16' }, false], + ['bare date subject, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16' }, false], + ['zoneless datetime subject, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00' }, false], + ['non-datetime string, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => 'yesterday' }, false], + ['missing property, no match', datetime_rule('signup', '=', JUL16_MS), {}, false] + ].each do |name, rule, data, want| + it name do + expect(apply(rule, data)).to eq(want) + end + end + end +end diff --git a/spec/mixpanel-ruby/flags/local_flags_spec.rb b/spec/mixpanel-ruby/flags/local_flags_spec.rb index 066b26b8..e42ba5d7 100644 --- a/spec/mixpanel-ruby/flags/local_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/local_flags_spec.rb @@ -486,6 +486,68 @@ def user_context_with_properties(properties) expect(result).to eq('fallback') end + it 'respects runtime evaluation rule with semver_compare operator when satisfied' do + runtime_eval = { + 'semver_compare' => [{'var' => 'app_version'}, '>=', '1.2.0'] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'app_version' => '1.5.0'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).not_to eq('fallback') + expect(['control', 'treatment']).to include(result) + end + + it 'respects runtime evaluation rule with semver_compare operator when not satisfied' do + runtime_eval = { + 'semver_compare' => [{'var' => 'app_version'}, '>=', '1.2.0'] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'app_version' => '1.0.0'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).to eq('fallback') + end + + it 'respects runtime evaluation rule with datetime_compare operator when satisfied' do + runtime_eval = { + 'datetime_compare' => [{'var' => 'signup'}, '>=', 1_784_160_000_000] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'signup' => '2026-07-17T00:00:00Z'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).not_to eq('fallback') + expect(['control', 'treatment']).to include(result) + end + + it 'respects runtime evaluation rule with datetime_compare operator when not satisfied' do + runtime_eval = { + 'datetime_compare' => [{'var' => 'signup'}, '>=', 1_784_160_000_000] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'signup' => '2026-07-15T00:00:00Z'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).to eq('fallback') + end + it 'picks correct variant with hundred percent split' do variants = [ { 'key' => 'A', 'value' => 'variant_a', 'is_control' => false, 'split' => 100.0 }, From c53896026dec576ad8ab1833f72ee3ecd7f304e0 Mon Sep 17 00:00:00 2001 From: Kwame Efah <37164746+efahk@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:05:01 +0000 Subject: [PATCH 2/4] hand roll semver and update tests --- lib/mixpanel-ruby/flags/custom_operators.rb | 67 +++- mixpanel-ruby.gemspec | 1 - spec/fixtures/datetime_compare_tests.json | 89 ++++++ spec/fixtures/semver_compare_tests.json | 155 +++++++++ .../flags/custom_operators_spec.rb | 298 +++--------------- 5 files changed, 350 insertions(+), 260 deletions(-) create mode 100644 spec/fixtures/datetime_compare_tests.json create mode 100644 spec/fixtures/semver_compare_tests.json diff --git a/lib/mixpanel-ruby/flags/custom_operators.rb b/lib/mixpanel-ruby/flags/custom_operators.rb index 03069820..6ce82984 100644 --- a/lib/mixpanel-ruby/flags/custom_operators.rb +++ b/lib/mixpanel-ruby/flags/custom_operators.rb @@ -1,6 +1,5 @@ require 'time' require 'json_logic' -require 'semantic_range' module Mixpanel module Flags @@ -34,10 +33,70 @@ def semver_compare(values) target_version = normalize_semver(target) return false unless actual_version.match?(SEMVER_STRICT) && target_version.match?(SEMVER_STRICT) - cmp = SemanticRange.compare(actual_version, target_version) + cmp = compare_semver(actual_version, target_version) comparator_matches?(cmp, symbol) end + # Strip optional build metadata and separate the core version from pre-release identifiers + def split_semver(version) + plus = version.index('+') + version = version[0, plus] if plus + dash = version.index('-') + return [version.split('.'), []] unless dash + + [version[0, dash].split('.'), version[(dash + 1)..-1].split('.')] + end + + def numeric_identifier?(identifier) + identifier.match?(/\A[0-9]+\z/) + end + + # Numeric identifiers carry no leading zeros, so the longer run of digits is the larger number. + # Comparing them as digits rather than parsing to a fixed-width integer keeps versions that + # overflow a 64-bit integer ordered correctly. + def compare_numeric(a, b) + return a.length <=> b.length unless a.length == b.length + + a <=> b + end + + # SemVer 2.0.0 section 11.4: digits compare numerically, a numeric identifier ranks below an + # alphanumeric one, and anything else compares by ASCII order. + def compare_prerelease_identifier(a, b) + a_numeric = numeric_identifier?(a) + b_numeric = numeric_identifier?(b) + return compare_numeric(a, b) if a_numeric && b_numeric + return -1 if a_numeric + return 1 if b_numeric + + a <=> b + end + + # Ordering per SemVer 2.0.0 section 11. Both operands have already been normalized and matched + # against the official regex, so the core holds exactly three numeric identifiers and every + # prerelease field is well-formed; the split needs no error path. + def compare_semver(actual, target) + actual_core, actual_prerelease = split_semver(actual) + target_core, target_prerelease = split_semver(target) + + actual_core.each_with_index do |part, index| + result = compare_numeric(part, target_core[index]) + return result unless result.zero? + end + + # A prerelease ranks below the release it belongs to (section 11.3). + return 0 if actual_prerelease.empty? && target_prerelease.empty? + return 1 if actual_prerelease.empty? + return -1 if target_prerelease.empty? + + [actual_prerelease.length, target_prerelease.length].min.times do |index| + result = compare_prerelease_identifier(actual_prerelease[index], target_prerelease[index]) + return result unless result.zero? + end + # Every field so far is equal, so the longer list wins (section 11.4.4). + actual_prerelease.length <=> target_prerelease.length + end + # Implements a custom operation for datetime comparison. The target value stored on the # feature flag is the millisecond epoch, whereas the actual value provided at evaluation # time must be RFC-3339 formatted. @@ -65,8 +124,8 @@ def operands(values) def comparator_matches?(cmp, symbol) case symbol - when '=' then cmp.zero? - when '!=' then !cmp.zero? + when '===' then cmp.zero? + when '!==' then !cmp.zero? when '<' then cmp < 0 when '<=' then cmp <= 0 when '>' then cmp > 0 diff --git a/mixpanel-ruby.gemspec b/mixpanel-ruby.gemspec index 33d3c195..630cad74 100644 --- a/mixpanel-ruby.gemspec +++ b/mixpanel-ruby.gemspec @@ -16,7 +16,6 @@ spec = Gem::Specification.new do |spec| spec.add_runtime_dependency 'mutex_m' spec.add_runtime_dependency "base64" spec.add_runtime_dependency 'json-logic-rb', '~> 0.2' - spec.add_runtime_dependency 'semantic_range', '~> 3.1' spec.add_development_dependency 'activesupport', '~> 4.0' spec.add_development_dependency 'rake', '~> 13' diff --git a/spec/fixtures/datetime_compare_tests.json b/spec/fixtures/datetime_compare_tests.json new file mode 100644 index 00000000..48fb8f5a --- /dev/null +++ b/spec/fixtures/datetime_compare_tests.json @@ -0,0 +1,89 @@ +[ + "A list of golden vectors for custom operators, to ensure logic parity across platforms", + + "# Ordering and the six symbols", + ["2026-07-15T00:00:00Z", "<", 1784160000000, true], + ["2026-07-16T00:00:00Z", "<", 1784160000000, false], + ["2026-07-16T00:00:00Z", "===", 1784160000000, true], + ["2026-07-17T00:00:00Z", "!==", 1784160000000, true], + ["2026-07-16T00:00:00Z", ">=", 1784160000000, true], + ["2026-07-17T00:00:00Z", ">", 1784160000000, true], + ["2026-07-15T00:00:00Z", ">", 1784160000000, false], + ["2026-07-16T00:00:00Z", "<=", 1784160000000, true], + ["2026-07-17T00:00:00Z", "<=", 1784160000000, false], + ["2026-07-17T00:00:00Z", "===", 1784160000000, false], + ["2026-07-16T00:00:00Z", "!==", 1784160000000, false], + ["2026-07-15T00:00:00Z", ">=", 1784160000000, false], + + "# Leap day", + ["2024-02-29T00:00:00Z", "===", 1709164800000, true], + + "# Time-zone offsets change the instant", + ["2026-07-16T00:00:00+05:30", "===", 1784140200000, true], + ["2026-07-16T02:00:00+02:00", "===", 1784160000000, true], + ["2026-07-16T00:00:00+05:30", "<", 1784160000000, true], + ["2026-07-16T00:00:00-08:00", "===", 1784188800000, true], + ["2026-07-16T00:00:00-08:00", ">", 1784160000000, true], + ["2026-07-16T00:00:00+00:00", "===", 1784160000000, true], + + "# Sub-second precision is dropped", + ["2026-07-16T00:00:00.5Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.500Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.123456Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.999999999Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.0Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.500Z", ">=", 1784160000000, true], + ["2026-07-16T23:59:59Z", "===", 1784246399999, true], + ["2026-07-16T23:59:59Z", "<=", 1784246399999, true], + ["2026-07-16T23:59:59.999Z", "===", 1784246399999, true], + ["2026-07-16T23:59:59.999Z", "<=", 1784246399999, true], + + "# Trimming and lowercasing", + ["2026-07-16t00:00:00.500z", "===", 1784160000000, true], + ["2026-07-16t02:00:00+02:00", "===", 1784160000000, true], + [" 2026-07-16T00:00:00Z ", "===", 1784160000000, true], + ["2026-07-16t00:00:00z", "===", 1784160000000, true], + + "# Wrong shapes, checked under both symbols", + ["2026-7-16T00:00:00Z", "===", 1784160000000, false], + ["2026-7-16T00:00:00Z", "!==", 1784160000000, false], + ["2026-07-16 00:00:00Z", "===", 1784160000000, false], + ["2026-07-16 00:00:00Z", "!==", 1784160000000, false], + ["2026-07-16T00:00:00", "===", 1784160000000, false], + ["2026-07-16T00:00:00", "!==", 1784160000000, false], + ["2026-07-16T00:00:00.Z", "===", 1784160000000, false], + ["2026-07-16T00:00:00.Z", "!==", 1784160000000, false], + ["2026-07-16T00:00:00+0200", "===", 1784160000000, false], + ["2026-07-16T00:00:00+0200", "!==", 1784160000000, false], + ["2026-07-16T00:00:00+02", "===", 1784160000000, false], + ["2026-07-16T00:00:00+02", "!==", 1784160000000, false], + ["2026-07-16T00:00:00Zextra", "===", 1784160000000, false], + ["2026-07-16T00:00:00Zextra", "!==", 1784160000000, false], + ["2026-07-16", "===", 1784160000000, false], + ["2026-07-16", "!==", 1784160000000, false], + ["20260716T000000Z", "===", 1784160000000, false], + ["20260716T000000Z", "!==", 1784160000000, false], + ["2026-07-16T00:00:00z00:00", "===", 1784160000000, false], + ["2026-07-16T00:00:00z00:00", "!==", 1784160000000, false], + ["2026-07-16T00:00:00,5Z", "===", 1784160000000, false], + ["2026-07-16T00:00:00,5Z", "!==", 1784160000000, false], + + "# Missing or wrong-typed values", + [1784160000000, "===", 1784160000000, false], + ["2026-07-16T00:00:00Z", "===", 1e308, false], + ["2026-07-16T00:00:00Z", ">", 1e308, false], + ["2026-07-16T00:00:00Z", "<", 1e308, false], + ["2026-07-16", "===", 1784160000000, false], + ["2026-07-16T00:00:00", "===", 1784160000000, false], + ["yesterday", "===", 1784160000000, false], + [null, "===", 1784160000000, false], + + "# Targets before 1970", + ["1969-12-31T23:59:59Z", "===", -1500, true], + ["1969-12-31T23:59:59Z", "!==", -1500, false], + ["1969-12-31T23:59:59Z", ">=", -1500, true], + ["1969-12-31T23:59:58Z", "<", -1500, true], + ["1969-12-31T23:59:59Z", ">", -2500, true], + ["1969-12-31T23:59:58.500Z", "===", -2000, true], + ["1969-12-31T23:59:58.500Z", "!==", -1000, true] +] diff --git a/spec/fixtures/semver_compare_tests.json b/spec/fixtures/semver_compare_tests.json new file mode 100644 index 00000000..90549639 --- /dev/null +++ b/spec/fixtures/semver_compare_tests.json @@ -0,0 +1,155 @@ +[ + "A list of golden vectors for custom operators, to ensure logic parity across platforms", + + "# Ordering and the six symbols", + ["1.2.3", "===", "1.2.3", true], + ["1.2.4", "===", "1.2.3", false], + ["1.2.4", "!==", "1.2.3", true], + ["1.2.2", "<", "1.2.3", true], + ["1.2.3", "<", "1.2.3", false], + ["1.2.3", "<=", "1.2.3", true], + ["1.3.0", ">", "1.2.3", true], + ["1.2.3", ">=", "1.2.3", true], + ["1.10.0", ">", "1.9.0", true], + ["10.0.0", ">", "9.0.0", true], + ["1.0.10", ">", "1.0.9", true], + ["2.0.0", ">", "1.9.9", true], + ["1.0.0-alpha", "<", "1.0.0", true], + ["v1.2.3", "===", "1.2.3", true], + ["1.2.0", "===", "1.2", true], + [" 1.2.3 ", "===", "1.2.3", true], + ["1.2.3", "!==", "1.2.3", false], + ["1.2.4", "<=", "1.2.3", false], + ["1.2.2", ">", "1.2.3", false], + ["1.2.2", ">=", "1.2.3", false], + + "# Pre-release ordering", + ["1.0.0-alpha", "<", "1.0.0-beta", true], + ["1.0.0-beta", "<", "1.0.0-rc1", true], + ["1.0.0-rc1", "<", "1.0.0-rc2", true], + ["1.0.0-alpha", "<", "1.0.0-alpha.1", true], + ["1.0.0-alpha.1", "<", "1.0.0-alpha.beta", true], + ["1.0.0-alpha", "<", "1.0.0-alpha.beta", true], + ["1.0.0-beta.2", "<", "1.0.0-beta.11", true], + ["1.0.0-a.1", "<", "1.0.0-b.1", true], + ["1.0.0-a.1", "<", "1.0.0-a.2", true], + ["1.0.0-rc1", "===", "1.0.0-rc1", true], + ["1.0.0-rc1", ">", "1.0.0-rc.1", true], + ["2.0.0-alpha", ">", "1.9.9", true], + + "# A pre-release and a plain release, compared directly", + ["1.0.0", ">", "1.0.0-alpha", true], + ["1.0.0", ">=", "1.0.0-rc1", true], + ["1.0.0", "!==", "1.0.0-alpha", true], + ["1.0.0-alpha", "!==", "1.0.0", true], + ["1.0.0-alpha", "<=", "1.0.0", true], + ["1.0.0-alpha", ">", "0.9.9", true], + ["1.0.0-rc1", "<", "1.0.1", true], + + "# How pre-release identifiers compare, SemVer 2.0.0 item 11", + ["1.0.0-2", "<", "1.0.0-10", true], + ["1.0.0-1", "<", "1.0.0-alpha", true], + ["1.0.0-alpha", "<", "1.0.0-alpha-1", true], + ["1.0.0-beta.11", "<", "1.0.0-rc.1", true], + ["1.0.0-rc.1", "<", "1.0.0", true], + ["1.0.0-alpha.1.2.3", "<", "1.0.0-beta", true], + ["1.0.0-beta", ">", "1.0.0-alpha.1", true], + + "# Build metadata is ignored", + ["1.0.0+build1", "===", "1.0.0+build2", true], + ["1.0.0-alpha+build", "===", "1.0.0-alpha", true], + ["1.2.3+build.1-2", "===", "1.2.3", true], + ["1.0.0+build1", "!==", "1.0.0+build2", false], + ["1.0.0+build1", "<", "1.0.0+build2", false], + ["1.0.0+build1", ">", "1.0.0+build2", false], + ["1.0.0+build1", "<=", "1.0.0+build2", true], + ["1.0.0+build1", ">=", "1.0.0+build2", true], + ["1.0.0+build9", "<", "1.0.1+build1", true], + ["1.0.1+build1", ">", "1.0.0+build9", true], + + "# Partial versions", + ["1.2-alpha", "===", "1.2.0-alpha", true], + ["1.2-alpha", "<", "1.3.1", true], + ["1.2-alpha", "<", "1.2.0", true], + ["1-rc1", "<", "1.0.0", true], + ["1.2+build", "===", "1.2.0", true], + + "# Zero versions", + ["0.0.0", "===", "0.0.0", true], + ["0.0.0", "<", "0.0.1", true], + ["0", "===", "0.0.0", true], + + "# A version ending in a bare hyphen is rejected", + ["1.0.0-", "===", "1.0.0", false], + ["1.0.0-", "!==", "1.0.0", false], + ["1.2-", "===", "1.2.0", false], + ["1.2-", "!==", "1.2.0", false], + + "# Hyphens inside a pre-release are fine, since they are part of the pre-release identifier", + ["1.0.0-alpha-", "<", "1.0.0", true], + + "# Leading zeros are rejected", + ["01.2.3", "===", "1.2.3", false], + ["01.2.3", "!==", "1.2.3", false], + ["1.02.3", "===", "1.2.3", false], + ["1.02.3", "!==", "1.2.3", false], + ["1.2.03", "===", "1.2.3", false], + ["1.2.03", "!==", "1.2.3", false], + ["01.02.03", "===", "1.2.3", false], + ["01.02.03", "!==", "1.2.3", false], + + "# Leading zeros in a numeric pre-release are rejected too, SemVer 2.0.0 item 9", + ["1.2.3-01", "===", "1.2.3", false], + ["1.2.3-01", "!==", "1.2.3", false], + ["1.2.3-rc.01", "===", "1.2.3", false], + ["1.2.3-rc.01", "!==", "1.2.3", false], + + "# A lone zero is a legal pre-release identifier", + ["1.2.3-0", "<", "1.2.3", true], + + "# Digits inside a word are still valid", + ["1.2.3-rc01", "<", "1.2.3", true], + + "# A leading v is accepted, in either case", + ["V1.2.3", "===", "1.2.3", true], + ["v1.0.0-alpha", "<", "1.0.0", true], + ["v1.2.4", "!==", "1.2.3", true], + ["v1.2.3", "<=", "1.2.3", true], + ["v1.2.4", ">", "1.2.3", true], + ["v1.2.3", ">=", "1.2.3", true], + + "# Missing or wrong-typed values", + ["not-a-version", "===", "1.2.3", false], + [123, "===", "1.2.3", false], + [null, "===", "1.2.3", false], + + "# Malformed versions, checked under both symbols", + ["", "===", "1.2.3", false], + ["", "!==", "1.2.3", false], + ["v", "===", "1.2.3", false], + ["v", "!==", "1.2.3", false], + ["-1.2.3", "===", "1.2.3", false], + ["-1.2.3", "!==", "1.2.3", false], + ["1.", "===", "1.2.3", false], + ["1.", "!==", "1.2.3", false], + ["1.2.3.", "===", "1.2.3", false], + ["1.2.3.", "!==", "1.2.3", false], + ["1..2", "===", "1.2.3", false], + ["1..2", "!==", "1.2.3", false], + ["1.2.3.4", "===", "1.2.3", false], + ["1.2.3.4", "!==", "1.2.3", false], + ["^1.2.3", "===", "1.2.3", false], + ["^1.2.3", "!==", "1.2.3", false], + ["abc1.2.3", "===", "1.2.3", false], + ["abc1.2.3", "!==", "1.2.3", false], + ["1.2.3+", "===", "1.2.3", false], + ["1.2.3+", "!==", "1.2.3", false], + ["1.2.3-alpha..1", "===", "1.2.3", false], + ["1.2.3-alpha..1", "!==", "1.2.3", false], + ["1.2.3-.", "===", "1.2.3", false], + ["1.2.3-.", "!==", "1.2.3", false], + ["1.2.3-ALPHA_BETA", "===", "1.2.3", false], + ["1.2.3-ALPHA_BETA", "!==", "1.2.3", false], + ["vv1.2.3", "===", "1.2.3", false], + ["vv1.2.3", "!==", "1.2.3", false] +] diff --git a/spec/mixpanel-ruby/flags/custom_operators_spec.rb b/spec/mixpanel-ruby/flags/custom_operators_spec.rb index cccf76bf..64a20fb2 100644 --- a/spec/mixpanel-ruby/flags/custom_operators_spec.rb +++ b/spec/mixpanel-ruby/flags/custom_operators_spec.rb @@ -1,286 +1,74 @@ +require 'json' require 'rspec' require 'json_logic' require 'mixpanel-ruby/flags/custom_operators' -# Rule builders defined at the top level so the case tables below can be built -# while the describe blocks are being collected. -def var_node(key) - { 'var' => key } -end +# The golden vectors are the cross-SDK contract for the custom operators; the canonical copy and its +# README live in the analytics monorepo. Cases run through JsonLogic.apply so that operator +# registration is covered alongside the comparison itself. +# +# Defined at the top level so the case tables can be built while the describe blocks are collected. +FIXTURES = File.expand_path('../../fixtures', __dir__) -def semver_rule(key, sym, target) - { 'semver_compare' => [var_node(key), sym, target] } -end +# The property key the vectors are evaluated against. It is plumbing the spec supplies, so any name +# works as long as the rule and the data agree on it. +VECTOR_KEY = 'value'.freeze -def datetime_rule(key, sym, target) - { 'datetime_compare' => [var_node(key), sym, target] } +def rule_for(operator, symbol, target) + { "#{operator}_compare" => [{ 'var' => VECTOR_KEY }, symbol, target] } end -def custom_between(op, key, lo, hi) - { 'and' => [ - { op => [var_node(key), '>=', lo] }, - { op => [var_node(key), '<=', hi] } - ] } +# Build the event the rule reads from, omitting the key entirely for an unset property. +def data_for(subject) + subject.nil? ? {} : { VECTOR_KEY => subject } end -def datetime_between(key, lo, hi) - { 'and' => [ - { 'datetime_compare' => [var_node(key), '>=', lo] }, - { 'datetime_compare' => [var_node(key), '<=', hi] } - ] } +# Read a golden-vector file. String entries are headings, array entries are cases. +def load_vectors(operator) + entries = JSON.parse(File.read(File.join(FIXTURES, "#{operator}_compare_tests.json"))) + + section = '' + cases = [] + entries.each_with_index do |entry, index| + if entry.is_a?(String) + section = entry + next + end + subject, symbol, target, want = entry + name = "#{index} #{section}: #{subject.to_json} #{symbol} #{target.to_json}" + cases << [name, rule_for(operator, symbol, target), data_for(subject), want] + end + cases end +SEMVER_CASES = load_vectors('semver') +DATETIME_CASES = load_vectors('datetime') + describe Mixpanel::Flags::CustomOperators do def apply(rule, data) JsonLogic.apply(rule, data) end describe 'semver_compare' do - [ - ['is, equal', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3' }, true], - ['is, not equal', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.4' }, false], - ['is not', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.4' }, true], - ['less than, patch', semver_rule('app_version', '<', '1.2.3'), { 'app_version' => '1.2.2' }, true], - ['less than, false', semver_rule('app_version', '<', '1.2.3'), { 'app_version' => '1.2.3' }, false], - ['less or equal, boundary', semver_rule('app_version', '<=', '1.2.3'), { 'app_version' => '1.2.3' }, true], - ['greater than, minor', semver_rule('app_version', '>', '1.2.3'), { 'app_version' => '1.3.0' }, true], - ['greater or equal, boundary', semver_rule('app_version', '>=', '1.2.3'), { 'app_version' => '1.2.3' }, true], - ['double-digit ordering (not lexical)', semver_rule('app_version', '>', '1.9.0'), { 'app_version' => '1.10.0' }, true], - ['prerelease precedes release', semver_rule('app_version', '<', '1.0.0'), { 'app_version' => '1.0.0-alpha' }, true], - ['lenient v-prefix', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => 'v1.2.3' }, true], - ['lenient uppercase V-prefix', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => 'V1.2.3' }, true], - ['v-prefix keeps prerelease', semver_rule('app_version', '<', '1.0.0'), { 'app_version' => 'v1.0.0-alpha' }, true], - ['v-prefix, not equal', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => 'v1.2.4' }, true], - ['v-prefix, at or below', semver_rule('app_version', '<=', '1.2.3'), { 'app_version' => 'v1.2.3' }, true], - ['v-prefix, greater', semver_rule('app_version', '>', '1.2.3'), { 'app_version' => 'v1.2.4' }, true], - ['v-prefix, at or above', semver_rule('app_version', '>=', '1.2.3'), { 'app_version' => 'v1.2.3' }, true], - ['lenient minor-only target', semver_rule('app_version', '=', '1.2'), { 'app_version' => '1.2.0' }, true], - # Every symbol is asserted in both directions. - ['is not, equal', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3' }, false], - ['less or equal, above', semver_rule('app_version', '<=', '1.2.3'), { 'app_version' => '1.2.4' }, false], - ['greater than, below', semver_rule('app_version', '>', '1.2.3'), { 'app_version' => '1.2.2' }, false], - ['greater or equal, below', semver_rule('app_version', '>=', '1.2.3'), { 'app_version' => '1.2.2' }, false], - # Prerelease precedence, SemVer 2.0.0 section 11. - ['prerelease alpha before beta', semver_rule('app_version', '<', '1.0.0-beta'), { 'app_version' => '1.0.0-alpha' }, true], - ['prerelease beta before rc1', semver_rule('app_version', '<', '1.0.0-rc1'), { 'app_version' => '1.0.0-beta' }, true], - ['prerelease rc1 before rc2', semver_rule('app_version', '<', '1.0.0-rc2'), { 'app_version' => '1.0.0-rc1' }, true], - ['more prerelease fields wins', semver_rule('app_version', '<', '1.0.0-alpha.1'), { 'app_version' => '1.0.0-alpha' }, true], - ['numeric identifier below alphanumeric', semver_rule('app_version', '<', '1.0.0-alpha.beta'), { 'app_version' => '1.0.0-alpha.1' }, true], - ['fewer fields below alphanumeric', semver_rule('app_version', '<', '1.0.0-alpha.beta'), { 'app_version' => '1.0.0-alpha' }, true], - ['numeric identifiers compare numerically', semver_rule('app_version', '<', '1.0.0-beta.11'), { 'app_version' => '1.0.0-beta.2' }, true], - ['dotted identifier ordering, letters', semver_rule('app_version', '<', '1.0.0-b.1'), { 'app_version' => '1.0.0-a.1' }, true], - ['dotted identifier ordering, digits', semver_rule('app_version', '<', '1.0.0-a.2'), { 'app_version' => '1.0.0-a.1' }, true], - ['identical prereleases are equal', semver_rule('app_version', '=', '1.0.0-rc1'), { 'app_version' => '1.0.0-rc1' }, true], - ['rc1 outranks dotted rc.1', semver_rule('app_version', '>', '1.0.0-rc.1'), { 'app_version' => '1.0.0-rc1' }, true], - ['core version dominates prerelease', semver_rule('app_version', '>', '1.9.9'), { 'app_version' => '2.0.0-alpha' }, true], - # A release outranks its own prerelease, asserted from both sides and under every symbol. - ['release outranks its prerelease', semver_rule('app_version', '>', '1.0.0-alpha'), { 'app_version' => '1.0.0' }, true], - ['release at or above its prerelease', semver_rule('app_version', '>=', '1.0.0-rc1'), { 'app_version' => '1.0.0' }, true], - ['release differs from its prerelease', semver_rule('app_version', '!=', '1.0.0-alpha'), { 'app_version' => '1.0.0' }, true], - ['prerelease differs from its release', semver_rule('app_version', '!=', '1.0.0'), { 'app_version' => '1.0.0-alpha' }, true], - ['prerelease at or below its release', semver_rule('app_version', '<=', '1.0.0'), { 'app_version' => '1.0.0-alpha' }, true], - ['prerelease of a higher core still wins', semver_rule('app_version', '>', '0.9.9'), { 'app_version' => '1.0.0-alpha' }, true], - ['prerelease below the next patch', semver_rule('app_version', '<', '1.0.1'), { 'app_version' => '1.0.0-rc1' }, true], - # Prerelease identifier comparison, SemVer 2.0.0 section 11.4. - ['numeric identifiers are not compared lexically', semver_rule('app_version', '<', '1.0.0-10'), { 'app_version' => '1.0.0-2' }, true], - ['numeric identifier ranks below alphanumeric', semver_rule('app_version', '<', '1.0.0-alpha'), { 'app_version' => '1.0.0-1' }, true], - ['hyphen inside an identifier sorts by ascii', semver_rule('app_version', '<', '1.0.0-alpha-1'), { 'app_version' => '1.0.0-alpha' }, true], - ['beta ranks below rc', semver_rule('app_version', '<', '1.0.0-rc.1'), { 'app_version' => '1.0.0-beta.11' }, true], - ['last prerelease ranks below the release', semver_rule('app_version', '<', '1.0.0'), { 'app_version' => '1.0.0-rc.1' }, true], - # Build metadata carries no precedence. - ['build metadata ignored', semver_rule('app_version', '=', '1.0.0+build2'), { 'app_version' => '1.0.0+build1' }, true], - ['build metadata ignored with prerelease', semver_rule('app_version', '=', '1.0.0-alpha'), { 'app_version' => '1.0.0-alpha+build' }, true], - ['build metadata with hyphen ignored', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3+build.1-2' }, true], - # Ignored means equal, so every symbol has to agree with that. - ['build metadata leaves versions equal', semver_rule('app_version', '!=', '1.0.0+build2'), { 'app_version' => '1.0.0+build1' }, false], - ['build metadata is not less', semver_rule('app_version', '<', '1.0.0+build2'), { 'app_version' => '1.0.0+build1' }, false], - ['build metadata is not greater', semver_rule('app_version', '>', '1.0.0+build2'), { 'app_version' => '1.0.0+build1' }, false], - ['build metadata at or below', semver_rule('app_version', '<=', '1.0.0+build2'), { 'app_version' => '1.0.0+build1' }, true], - ['build metadata at or above', semver_rule('app_version', '>=', '1.0.0+build2'), { 'app_version' => '1.0.0+build1' }, true], - ['build metadata does not block ordering', semver_rule('app_version', '<', '1.0.1+build1'), { 'app_version' => '1.0.0+build9' }, true], - ['build metadata does not block reverse ordering', semver_rule('app_version', '>', '1.0.0+build9'), { 'app_version' => '1.0.1+build1' }, true], - # Partial versions keep their prerelease once zero-padded. - ['partial version with prerelease', semver_rule('app_version', '=', '1.2.0-alpha'), { 'app_version' => '1.2-alpha' }, true], - ['partial prerelease below later minor', semver_rule('app_version', '<', '1.3.1'), { 'app_version' => '1.2-alpha' }, true], - ['partial prerelease below its release', semver_rule('app_version', '<', '1.2.0'), { 'app_version' => '1.2-alpha' }, true], - ['major-only with prerelease', semver_rule('app_version', '<', '1.0.0'), { 'app_version' => '1-rc1' }, true], - # An empty prerelease is invalid, so it is rejected rather than treated as the bare release. - ['empty prerelease, no match', semver_rule('app_version', '=', '1.0.0'), { 'app_version' => '1.0.0-' }, false], - ['empty prerelease, not-equal also false', semver_rule('app_version', '!=', '1.0.0'), { 'app_version' => '1.0.0-' }, false], - ['empty prerelease on partial version, no match', semver_rule('app_version', '=', '1.2.0'), { 'app_version' => '1.2-' }, false], - ['empty prerelease on partial version, not-equal also false', semver_rule('app_version', '!=', '1.2.0'), { 'app_version' => '1.2-' }, false], - # Hyphens are legal inside a prerelease identifier, so these are NOT empty prereleases. - ['trailing hyphen inside identifier', semver_rule('app_version', '<', '1.0.0'), { 'app_version' => '1.0.0-alpha-' }, true], - # SemVer 2.0.0 forbids leading zeros in the core, so these are rejected rather than normalized. - ['leading zero in major, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '01.2.3' }, false], - ['leading zero in major, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '01.2.3' }, false], - ['leading zero in minor, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.02.3' }, false], - ['leading zero in minor, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.02.3' }, false], - ['leading zero in patch, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.03' }, false], - ['leading zero in patch, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.03' }, false], - ['leading zeros throughout, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '01.02.03' }, false], - ['leading zeros throughout, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '01.02.03' }, false], - # A numeric prerelease identifier may not carry a leading zero either (section 9). - ['numeric prerelease with leading zero, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3-01' }, false], - ['numeric prerelease with leading zero, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3-01' }, false], - ['dotted numeric prerelease with leading zero, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3-rc.01' }, false], - ['dotted numeric prerelease with leading zero, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3-rc.01' }, false], - # An alphanumeric identifier may contain digits, so this one stays valid. - ['alphanumeric prerelease with digits', semver_rule('app_version', '<', '1.2.3'), { 'app_version' => '1.2.3-rc01' }, true], - ['between, inside', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => '1.5.0' }, true], - ['between, low boundary inclusive', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => '1.2.3' }, true], - ['between, high boundary inclusive', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => '2.0.0' }, true], - ['between, below', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => '1.0.0' }, false], - ['between, above', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => '2.0.1' }, false], - # A prerelease sits below its own release, which decides both boundary cases. - ['between, prerelease inside', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => '1.5.0-rc1' }, true], - ['between, prerelease below the high bound', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => '2.0.0-rc1' }, true], - ['between, prerelease of the low bound falls out', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => '1.2.3-rc1' }, false], - ['between, invalid version', custom_between('semver_compare', 'app_version', '1.2.3', '2.0.0'), { 'app_version' => 'not-a-version' }, false], - ['between, single-point range', custom_between('semver_compare', 'app_version', '1.2.3', '1.2.3'), { 'app_version' => '1.2.3' }, true], - # Fail-closed: unparseable or missing values never match. - ['invalid actual, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => 'not-a-version' }, false], - ['non-string actual, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => 123 }, false], - ['missing property, no match', semver_rule('app_version', '=', '1.2.3'), {}, false], - # A malformed version must never be padded or coerced into a real one. Both symbols are - # asserted so that "accepted at all" is observable rather than masked by a single false. - ['empty version, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '' }, false], - ['empty version, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '' }, false], - ['bare v, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => 'v' }, false], - ['bare v, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => 'v' }, false], - ['leading separator, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '-1.2.3' }, false], - ['leading separator, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '-1.2.3' }, false], - ['trailing dot, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.' }, false], - ['trailing dot, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.' }, false], - ['trailing dot after patch, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3.' }, false], - ['trailing dot after patch, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3.' }, false], - ['empty middle segment, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1..2' }, false], - ['empty middle segment, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1..2' }, false], - ['four components, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3.4' }, false], - ['four components, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3.4' }, false], - ['range prefix, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '^1.2.3' }, false], - ['range prefix, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '^1.2.3' }, false], - ['version inside text, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => 'abc1.2.3' }, false], - ['version inside text, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => 'abc1.2.3' }, false], - ['empty build metadata, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3+' }, false], - ['empty build metadata, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3+' }, false], - ['empty prerelease identifier, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3-alpha..1' }, false], - ['empty prerelease identifier, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3-alpha..1' }, false], - ['lone dot prerelease, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3-.' }, false], - ['lone dot prerelease, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3-.' }, false], - ['underscore in prerelease, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => '1.2.3-ALPHA_BETA' }, false], - ['underscore in prerelease, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => '1.2.3-ALPHA_BETA' }, false], - ['doubled v-prefix, no match', semver_rule('app_version', '=', '1.2.3'), { 'app_version' => 'vv1.2.3' }, false], - ['doubled v-prefix, not-equal also false', semver_rule('app_version', '!=', '1.2.3'), { 'app_version' => 'vv1.2.3' }, false] - ].each do |name, rule, data, want| + SEMVER_CASES.each do |name, rule, data, want| it name do expect(apply(rule, data)).to eq(want) end end end - # Epoch-millisecond constants (UTC instants) used as datetime targets, - # matching the UI's emitted format. - JUL16_MS = 1_784_160_000_000 # 2026-07-16T00:00:00Z - JAN1_MS = 1_767_225_600_000 # 2026-01-01T00:00:00Z - DEC31_MS = 1_798_675_200_000 # 2026-12-31T00:00:00Z - JUL16_END_MS = 1_784_246_399_999 # 2026-07-16T23:59:59.999Z - LEAP_DAY_MS = 1_709_164_800_000 # 2024-02-29T00:00:00Z - JUL16_INDIA_MS = 1_784_140_200_000 # 2026-07-16T00:00:00+05:30 - JUL16_PACIFIC_MS = 1_784_188_800_000 # 2026-07-16T00:00:00-08:00 - describe 'datetime_compare' do - [ - # Asymmetric contract: subject (runtime var) is a strict RFC3339 string, target is epoch ms. - ['before, true', datetime_rule('signup', '<', JUL16_MS), { 'signup' => '2026-07-15T00:00:00Z' }, true], - ['before, false', datetime_rule('signup', '<', JUL16_MS), { 'signup' => '2026-07-16T00:00:00Z' }, false], - ['on (equal), true', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00Z' }, true], - ['not on, true', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-17T00:00:00Z' }, true], - ['since (>=), boundary', datetime_rule('signup', '>=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00Z' }, true], - ['after (>), true', datetime_rule('signup', '>', JUL16_MS), { 'signup' => '2026-07-17T00:00:00Z' }, true], - ['after (>), false', datetime_rule('signup', '>', JUL16_MS), { 'signup' => '2026-07-15T00:00:00Z' }, false], - # Every symbol is asserted in both directions. - ['at or before, boundary', datetime_rule('signup', '<=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00Z' }, true], - ['at or before, after', datetime_rule('signup', '<=', JUL16_MS), { 'signup' => '2026-07-17T00:00:00Z' }, false], - ['on (equal), false', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-17T00:00:00Z' }, false], - ['not on, equal', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00Z' }, false], - ['since (>=), before', datetime_rule('signup', '>=', JUL16_MS), { 'signup' => '2026-07-15T00:00:00Z' }, false], - ['between, inside', datetime_between('signup', JAN1_MS, DEC31_MS), { 'signup' => '2026-06-15T00:00:00Z' }, true], - ['between, low boundary inclusive', datetime_between('signup', JAN1_MS, DEC31_MS), { 'signup' => '2026-01-01T00:00:00Z' }, true], - ['between, high boundary inclusive', datetime_between('signup', JAN1_MS, DEC31_MS), { 'signup' => '2026-12-31T00:00:00Z' }, true], - ['between, before range', datetime_between('signup', JAN1_MS, DEC31_MS), { 'signup' => '2025-12-31T00:00:00Z' }, false], - ['between, after range', datetime_between('signup', JAN1_MS, DEC31_MS), { 'signup' => '2027-01-01T00:00:00Z' }, false], - ['negative epoch-ms target resolves to -1s', datetime_rule('signup', '=', -1500), { 'signup' => '1969-12-31T23:59:59Z' }, true], - ['negative epoch-ms target, not equal', datetime_rule('signup', '!=', -1500), { 'signup' => '1969-12-31T23:59:59Z' }, false], - ['negative epoch-ms target, at or after', datetime_rule('signup', '>=', -1500), { 'signup' => '1969-12-31T23:59:59Z' }, true], - ['negative epoch-ms target, before', datetime_rule('signup', '<', -1500), { 'signup' => '1969-12-31T23:59:58Z' }, true], - ['negative epoch-ms target, after', datetime_rule('signup', '>', -2500), { 'signup' => '1969-12-31T23:59:59Z' }, true], - ['subject floors, it does not truncate', datetime_rule('signup', '=', -2000), { 'signup' => '1969-12-31T23:59:58.500Z' }, true], - ['subject floors, not to -1s', datetime_rule('signup', '!=', -1000), { 'signup' => '1969-12-31T23:59:58.500Z' }, true], - # A leap day is a real date. - ['leap day', datetime_rule('signup', '=', LEAP_DAY_MS), { 'signup' => '2024-02-29T00:00:00Z' }, true], - # Time-zone offsets change the instant. - ['offset with half-hour minutes', datetime_rule('signup', '=', JUL16_INDIA_MS), { 'signup' => '2026-07-16T00:00:00+05:30' }, true], - ['rfc3339 subject with offset', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T02:00:00+02:00' }, true], - ['positive offset precedes utc midnight', datetime_rule('signup', '<', JUL16_MS), { 'signup' => '2026-07-16T00:00:00+05:30' }, true], - ['negative offset', datetime_rule('signup', '=', JUL16_PACIFIC_MS), { 'signup' => '2026-07-16T00:00:00-08:00' }, true], - ['negative offset follows utc midnight', datetime_rule('signup', '>', JUL16_MS), { 'signup' => '2026-07-16T00:00:00-08:00' }, true], - ['zero offset equals Z', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00+00:00' }, true], - # Sub-second precision is dropped, on both sides. The end-of-day rows are the window the UI - # emits for a single date, whose upper bound carries .999. - ['one-digit fraction', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00.5Z' }, true], - ['three-digit fraction', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00.500Z' }, true], - ['six-digit fraction', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00.123456Z' }, true], - ['nine-digit fraction', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00.999999999Z' }, true], - ['zero fraction', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00.0Z' }, true], - ['fractional seconds truncated', datetime_rule('signup', '>=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00.500Z' }, true], - ['end-of-day target drops its .999', datetime_rule('signup', '=', JUL16_END_MS), { 'signup' => '2026-07-16T23:59:59Z' }, true], - ['end-of-day target is an inclusive bound', datetime_rule('signup', '<=', JUL16_END_MS), { 'signup' => '2026-07-16T23:59:59Z' }, true], - ['end-of-day, fractional subject too', datetime_rule('signup', '=', JUL16_END_MS), { 'signup' => '2026-07-16T23:59:59.999Z' }, true], - ['end-of-day inclusive, fractional subject', datetime_rule('signup', '<=', JUL16_END_MS), { 'signup' => '2026-07-16T23:59:59.999Z' }, true], - # Fractional on both sides: the shape the UI actually round-trips. - # Trimming and lowercasing. - ['lowercased subject with fraction', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16t00:00:00.500z' }, true], - ['lowercased subject with offset', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16t02:00:00+02:00' }, true], - ['whitespace-padded subject', datetime_rule('signup', '=', JUL16_MS), { 'signup' => ' 2026-07-16T00:00:00Z ' }, true], - ['lowercased rfc3339 subject', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16t00:00:00z' }, true], - # Shape violations, asserted under both = and != so that "accepted at all" is observable. - # RFC 3339 also permits 24:00:00 as end-of-day. Platforms disagree on it, so no vector - # asserts it either way. - ['one-digit month, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-7-16T00:00:00Z' }, false], - ['one-digit month, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-7-16T00:00:00Z' }, false], - ['space separator, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16 00:00:00Z' }, false], - ['space separator, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16 00:00:00Z' }, false], - ['missing zone, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00' }, false], - ['missing zone, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00' }, false], - ['empty fraction, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00.Z' }, false], - ['empty fraction, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00.Z' }, false], - ['offset without colon, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00+0200' }, false], - ['offset without colon, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00+0200' }, false], - ['short offset, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00+02' }, false], - ['short offset, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00+02' }, false], - ['trailing junk, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00Zextra' }, false], - ['trailing junk, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00Zextra' }, false], - ['basic format, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '20260716T000000Z' }, false], - ['basic format, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '20260716T000000Z' }, false], - ['zone after lowercase z, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00z00:00' }, false], - ['zone after lowercase z, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00z00:00' }, false], - ['comma fractional separator, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00,5Z' }, false], - ['comma fractional separator, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00,5Z' }, false], - # Fail-closed: subject must be an RFC3339 string, target must be an epoch-ms number. - ['numeric subject, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => JUL16_MS }, false], - ['target beyond representable range, no match', datetime_rule('signup', '=', 1e308), { 'signup' => '2026-07-16T00:00:00Z' }, false], - ['target beyond representable range, greater-than also false', datetime_rule('signup', '>', 1e308), { 'signup' => '2026-07-16T00:00:00Z' }, false], - ['target beyond representable range, less-than also false', datetime_rule('signup', '<', 1e308), { 'signup' => '2026-07-16T00:00:00Z' }, false], - ['bare date subject, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16' }, false], - ['bare date subject, not-equal also false', datetime_rule('signup', '!=', JUL16_MS), { 'signup' => '2026-07-16' }, false], - ['zoneless datetime subject, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => '2026-07-16T00:00:00' }, false], - ['non-datetime string, no match', datetime_rule('signup', '=', JUL16_MS), { 'signup' => 'yesterday' }, false], - ['missing property, no match', datetime_rule('signup', '=', JUL16_MS), {}, false] - ].each do |name, rule, data, want| + DATETIME_CASES.each do |name, rule, data, want| it name do expect(apply(rule, data)).to eq(want) end end end + + # An unset property must produce an event with no key at all, rather than a key holding a nil. + # Both spellings fail closed, so the vectors alone cannot tell them apart. + it 'omits the property for an unset subject' do + expect(data_for(nil)).to eq({}) + expect(data_for('1.2.3')).to eq({ VECTOR_KEY => '1.2.3' }) + end end From 190aaa0cc9a69ce0763de671ffaba7492b99edda Mon Sep 17 00:00:00 2001 From: Kwame Efah <37164746+efahk@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:01:59 +0000 Subject: [PATCH 3/4] Date validation update --- lib/mixpanel-ruby/flags/custom_operators.rb | 18 +++++++++++++++--- spec/fixtures/datetime_compare_tests.json | 13 ++++++++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/lib/mixpanel-ruby/flags/custom_operators.rb b/lib/mixpanel-ruby/flags/custom_operators.rb index 6ce82984..4efd446b 100644 --- a/lib/mixpanel-ruby/flags/custom_operators.rb +++ b/lib/mixpanel-ruby/flags/custom_operators.rb @@ -1,3 +1,4 @@ +require 'date' require 'time' require 'json_logic' @@ -9,8 +10,9 @@ module CustomOperators # as part of the Semver 2.0.0 spec. See https://semver.org/ SEMVER_STRICT = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?\z/ - # Strict RFC3339 guard for datetime strings. - RFC3339_STRICT = /\A\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})\z/ + # Strict RFC3339 guard for datetime strings. The date and hour fields are captured so the + # calendar can be validated separately; the pattern only constrains their shape. + RFC3339_STRICT = /\A(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})\z/ # SemVer 2.0.0 requires major.minor.patch; partial versions are zero-padded to this. SEMVER_PARTS = 3 @@ -156,11 +158,21 @@ def normalize_semver(str) segments.join('.') + suffix end + # The pattern constrains each field to two digits, which still admits a date that cannot exist, + # such as 2026-02-30 or 29 February in a common year. Time.iso8601 rolls those forward into a + # real instant instead of raising, and hour 24 likewise becomes the following midnight, so the + # calendar is checked here. RFC 3339 section 5.6 allows hours 00 through 23. + def real_calendar_date?(year, month, day, hour) + hour <= 23 && Date.valid_date?(year, month, day) + end + def convert_rfc3339_to_unix_seconds(value) return nil unless value.is_a?(String) normalized = value.strip.upcase - return nil unless normalized =~ RFC3339_STRICT + fields = RFC3339_STRICT.match(normalized) + return nil unless fields + return nil unless real_calendar_date?(fields[1].to_i, fields[2].to_i, fields[3].to_i, fields[4].to_i) parsed = Time.iso8601(normalized) parsed.to_i diff --git a/spec/fixtures/datetime_compare_tests.json b/spec/fixtures/datetime_compare_tests.json index 48fb8f5a..f6517b6d 100644 --- a/spec/fixtures/datetime_compare_tests.json +++ b/spec/fixtures/datetime_compare_tests.json @@ -85,5 +85,16 @@ ["1969-12-31T23:59:58Z", "<", -1500, true], ["1969-12-31T23:59:59Z", ">", -2500, true], ["1969-12-31T23:59:58.500Z", "===", -2000, true], - ["1969-12-31T23:59:58.500Z", "!==", -1000, true] + ["1969-12-31T23:59:58.500Z", "!==", -1000, true], + + "# Impossible dates and out-of-range fields", + ["2026-02-30T00:00:00Z", "===", 1784160000000, false], + ["2026-02-30T00:00:00Z", "!==", 1784160000000, false], + ["2026-02-29T00:00:00Z", "!==", 1784160000000, false], + ["2025-02-29T00:00:00Z", "!==", 1784160000000, false], + ["2026-04-31T00:00:00Z", "!==", 1784160000000, false], + ["2026-06-31T00:00:00Z", "!==", 1784160000000, false], + ["2026-07-16T24:00:00Z", "!==", 1784160000000, false], + ["2026-13-01T00:00:00Z", "!==", 1784160000000, false], + ["2026-01-32T00:00:00Z", "!==", 1784160000000, false] ] From 8c41ae4365ce0f15bd5c665a7887dd1b5268b3fe Mon Sep 17 00:00:00 2001 From: Kwame Efah <37164746+efahk@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:02:19 +0000 Subject: [PATCH 4/4] updating tests and adding semver limits --- lib/mixpanel-ruby/flags/custom_operators.rb | 6 ++++++ spec/fixtures/datetime_compare_tests.json | 24 ++++++++++----------- spec/fixtures/semver_compare_tests.json | 8 ++++++- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/lib/mixpanel-ruby/flags/custom_operators.rb b/lib/mixpanel-ruby/flags/custom_operators.rb index 4efd446b..63a19d88 100644 --- a/lib/mixpanel-ruby/flags/custom_operators.rb +++ b/lib/mixpanel-ruby/flags/custom_operators.rb @@ -17,6 +17,11 @@ module CustomOperators # SemVer 2.0.0 requires major.minor.patch; partial versions are zero-padded to this. SEMVER_PARTS = 3 + # Longest operand the semver regex is allowed to see. A real version never approaches this; the + # bound matches MAX_LENGTH in node-semver, and keeps an arbitrarily long property value off the + # regex regardless of how the engine schedules backtracking. + MAX_SEMVER_LENGTH = 256 + # Epoch milliseconds are compared as int64 elsewhere, so anything at or beyond this is out of range. MAX_EPOCH_MS = 2**63 @@ -30,6 +35,7 @@ def semver_compare(values) actual, symbol, target = unpacked return false unless actual.is_a?(String) && target.is_a?(String) + return false if actual.length > MAX_SEMVER_LENGTH || target.length > MAX_SEMVER_LENGTH actual_version = normalize_semver(actual) target_version = normalize_semver(target) diff --git a/spec/fixtures/datetime_compare_tests.json b/spec/fixtures/datetime_compare_tests.json index f6517b6d..5e8633d2 100644 --- a/spec/fixtures/datetime_compare_tests.json +++ b/spec/fixtures/datetime_compare_tests.json @@ -33,10 +33,10 @@ ["2026-07-16T00:00:00.999999999Z", "===", 1784160000000, true], ["2026-07-16T00:00:00.0Z", "===", 1784160000000, true], ["2026-07-16T00:00:00.500Z", ">=", 1784160000000, true], - ["2026-07-16T23:59:59Z", "===", 1784246399999, true], - ["2026-07-16T23:59:59Z", "<=", 1784246399999, true], - ["2026-07-16T23:59:59.999Z", "===", 1784246399999, true], - ["2026-07-16T23:59:59.999Z", "<=", 1784246399999, true], + ["2026-07-16T23:59:59Z", "===", 1784246399000, true], + ["2026-07-16T23:59:59Z", "<=", 1784246399000, true], + ["2026-07-16T23:59:59.999Z", "===", 1784246399000, true], + ["2026-07-16T23:59:59.999Z", "<=", 1784246399000, true], "# Trimming and lowercasing", ["2026-07-16t00:00:00.500z", "===", 1784160000000, true], @@ -70,20 +70,20 @@ "# Missing or wrong-typed values", [1784160000000, "===", 1784160000000, false], - ["2026-07-16T00:00:00Z", "===", 1e308, false], - ["2026-07-16T00:00:00Z", ">", 1e308, false], - ["2026-07-16T00:00:00Z", "<", 1e308, false], + ["2026-07-16T00:00:00Z", "===", 1e19, false], + ["2026-07-16T00:00:00Z", ">", 1e19, false], + ["2026-07-16T00:00:00Z", "<", 1e19, false], ["2026-07-16", "===", 1784160000000, false], ["2026-07-16T00:00:00", "===", 1784160000000, false], ["yesterday", "===", 1784160000000, false], [null, "===", 1784160000000, false], "# Targets before 1970", - ["1969-12-31T23:59:59Z", "===", -1500, true], - ["1969-12-31T23:59:59Z", "!==", -1500, false], - ["1969-12-31T23:59:59Z", ">=", -1500, true], - ["1969-12-31T23:59:58Z", "<", -1500, true], - ["1969-12-31T23:59:59Z", ">", -2500, true], + ["1969-12-31T23:59:59Z", "===", -1000, true], + ["1969-12-31T23:59:59Z", "!==", -1000, false], + ["1969-12-31T23:59:59Z", ">=", -1000, true], + ["1969-12-31T23:59:58Z", "<", -1000, true], + ["1969-12-31T23:59:59Z", ">", -2000, true], ["1969-12-31T23:59:58.500Z", "===", -2000, true], ["1969-12-31T23:59:58.500Z", "!==", -1000, true], diff --git a/spec/fixtures/semver_compare_tests.json b/spec/fixtures/semver_compare_tests.json index 90549639..42c6c755 100644 --- a/spec/fixtures/semver_compare_tests.json +++ b/spec/fixtures/semver_compare_tests.json @@ -151,5 +151,11 @@ ["1.2.3-ALPHA_BETA", "===", "1.2.3", false], ["1.2.3-ALPHA_BETA", "!==", "1.2.3", false], ["vv1.2.3", "===", "1.2.3", false], - ["vv1.2.3", "!==", "1.2.3", false] + ["vv1.2.3", "!==", "1.2.3", false], + + "# Operand length cap", + ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "===", "1.2.3", false], + ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "!==", "1.2.3", false], + ["1.2.3", "!==", "1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false], + ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "!==", "1.2.3", true] ]