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