Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/strict-semver-leading-zeros.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 22 additions & 15 deletions lib/posthog/feature_flags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
43 changes: 40 additions & 3 deletions spec/posthog/feature_flag_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading