From 1ea561e1a8e808f9781aa82d8936a280cbc245db Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 20 May 2026 13:53:26 -0700 Subject: [PATCH] fix(flags): reject leading-zero semver values in local evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per semver 2.0.0 §2, numeric identifiers must not include leading zeros. Values like "1.07.3" are not valid semver and should not match targeting conditions. Both override values and flag values are validated; invalid inputs raise InconclusiveMatchError so the condition does not match. --- .changeset/strict-semver-leading-zeros.md | 5 +++ lib/posthog/feature_flags.rb | 37 +++++++++++-------- spec/posthog/feature_flag_spec.rb | 43 +++++++++++++++++++++-- 3 files changed, 67 insertions(+), 18 deletions(-) create mode 100644 .changeset/strict-semver-leading-zeros.md diff --git a/.changeset/strict-semver-leading-zeros.md b/.changeset/strict-semver-leading-zeros.md new file mode 100644 index 0000000..10e678a --- /dev/null +++ b/.changeset/strict-semver-leading-zeros.md @@ -0,0 +1,5 @@ +--- +'posthog-ruby': patch +--- + +Reject semver values with leading zeros (e.g. `1.07.3`, `01.02.03`) during local feature flag evaluation, per semver 2.0.0 §2. Both override values and flag values are validated; invalid inputs raise `InconclusiveMatchError` so the condition does not match. diff --git a/lib/posthog/feature_flags.rb b/lib/posthog/feature_flags.rb index 36f21da..7cbd284 100644 --- a/lib/posthog/feature_flags.rb +++ b/lib/posthog/feature_flags.rb @@ -435,6 +435,16 @@ def self.relative_date_parse_for_feature_flag_matching(value) parsed_dt end + # Parse a single semver numeric identifier, rejecting empty, non-digit, or + # leading-zero values per semver 2.0.0 §2. + def self.parse_semver_numeric(part) + raise InconclusiveMatchError, 'Invalid semver format' if part.nil? || part.empty? || part !~ /^\d+$/ + # Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros. + raise InconclusiveMatchError, 'Invalid semver format' if part.length > 1 && part[0] == '0' + + part.to_i + end + # Parse a semver string into a comparable [major, minor, patch] integer array. # Handles v-prefix, whitespace, pre-release suffixes. Defaults missing components to 0. def self.parse_semver(value) @@ -450,14 +460,9 @@ def self.parse_semver(value) raise InconclusiveMatchError, 'Invalid semver format' if parts.empty? || parts[0].to_s.empty? - # Check for leading dot or non-numeric parts - parts.each do |part| - raise InconclusiveMatchError, 'Invalid semver format' if part.empty? || part !~ /^\d+$/ - end - - major = parts[0].to_i - minor = parts.length > 1 ? parts[1].to_i : 0 - patch = parts.length > 2 ? parts[2].to_i : 0 + major = parse_semver_numeric(parts[0]) + minor = parts.length > 1 ? parse_semver_numeric(parts[1]) : 0 + patch = parts.length > 2 ? parse_semver_numeric(parts[2]) : 0 [major, minor, patch] end @@ -514,20 +519,22 @@ def self.semver_wildcard_bounds(value) raise InconclusiveMatchError, 'Invalid semver wildcard format' if parts.empty? - parts.each do |part| - raise InconclusiveMatchError, 'Invalid semver wildcard format' if part !~ /^\d+$/ + numeric = parts.map do |part| + parse_semver_numeric(part) + rescue InconclusiveMatchError + raise InconclusiveMatchError, 'Invalid semver wildcard format' end - major = parts[0].to_i - case parts.length + major = numeric[0] + case numeric.length when 1 [[major, 0, 0], [major + 1, 0, 0]] when 2 - minor = parts[1].to_i + minor = numeric[1] [[major, minor, 0], [major, minor + 1, 0]] else - minor = parts[1].to_i - patch = parts[2].to_i + minor = numeric[1] + patch = numeric[2] [[major, minor, patch], [major, minor, patch + 1]] end end diff --git a/spec/posthog/feature_flag_spec.rb b/spec/posthog/feature_flag_spec.rb index 981b1ab..093f9ad 100644 --- a/spec/posthog/feature_flag_spec.rb +++ b/spec/posthog/feature_flag_spec.rb @@ -1727,10 +1727,47 @@ module PostHog expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.2.3' })).to be true end - it 'with semver operators and leading zeros' do + it 'with semver operators rejects override values with leading zeros' do property = { 'key' => 'version', 'value' => '1.2.3', 'operator' => 'semver_eq' } - expect(FeatureFlagsPoller.match_property(property, { 'version' => '01.02.03' })).to be true - expect(FeatureFlagsPoller.match_property(property, { 'version' => '001.002.003' })).to be true + + ['01.2.3', '1.02.3', '1.2.03', '1.07.3', '001.2.3', '01.02.03', '001.002.003'].each do |bad| + expect do + FeatureFlagsPoller.match_property(property, { 'version' => bad }) + end.to raise_error(InconclusiveMatchError), "expected #{bad.inspect} to be rejected" + end + end + + it 'with semver operators rejects flag values with leading zeros' do + gt = { 'key' => 'version', 'value' => '1.07.3', 'operator' => 'semver_gt' } + expect do + FeatureFlagsPoller.match_property(gt, { 'version' => '2.0.0' }) + end.to raise_error(InconclusiveMatchError) + + caret = { 'key' => 'version', 'value' => '01.2.3', 'operator' => 'semver_caret' } + expect do + FeatureFlagsPoller.match_property(caret, { 'version' => '1.2.3' }) + end.to raise_error(InconclusiveMatchError) + + tilde = { 'key' => 'version', 'value' => '1.02.3', 'operator' => 'semver_tilde' } + expect do + FeatureFlagsPoller.match_property(tilde, { 'version' => '1.2.3' }) + end.to raise_error(InconclusiveMatchError) + + wildcard = { 'key' => 'version', 'value' => '01.*', 'operator' => 'semver_wildcard' } + expect do + FeatureFlagsPoller.match_property(wildcard, { 'version' => '1.2.3' }) + end.to raise_error(InconclusiveMatchError) + end + + it 'with semver operators accepts literal zero components' do + eq_zero = { 'key' => 'version', 'value' => '0.1.0', 'operator' => 'semver_eq' } + expect(FeatureFlagsPoller.match_property(eq_zero, { 'version' => '0.1.0' })).to be true + + eq_zero_zero = { 'key' => 'version', 'value' => '0.0.0', 'operator' => 'semver_eq' } + expect(FeatureFlagsPoller.match_property(eq_zero_zero, { 'version' => '0.0.0' })).to be true + + eq_major_zero = { 'key' => 'version', 'value' => '1.0.0', 'operator' => 'semver_eq' } + expect(FeatureFlagsPoller.match_property(eq_major_zero, { 'version' => '1.0.0' })).to be true end it 'with semver operators and partial versions' do