Skip to content

Commit be9b78b

Browse files
authored
fix: reject leading-zero semver values in local evaluation (#601)
1 parent a2aa9bc commit be9b78b

3 files changed

Lines changed: 71 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: patch
3+
---
4+
5+
Reject semver values with leading zeros in local flag evaluation. 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 now validated; invalid inputs raise `InconclusiveMatchError` so the condition does not match.

posthog/feature_flags.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -840,12 +840,22 @@ def relative_date_parse_for_feature_flag_matching(
840840
return None
841841

842842

843+
def _semver_numeric_identifier(part: str) -> int:
844+
# Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros.
845+
if not part or not part.isdigit():
846+
raise ValueError(f"Invalid semver numeric identifier: '{part}'")
847+
if len(part) > 1 and part[0] == "0":
848+
raise ValueError(f"Semver numeric identifier has leading zero: '{part}'")
849+
return int(part)
850+
851+
843852
def parse_semver(value: str) -> tuple:
844853
"""Parse a semver string into a comparable (major, minor, patch) integer tuple.
845854
846855
Matches the behavior of the sortableSemver HogQL function:
847856
- Handles v-prefix, whitespace, pre-release suffixes
848857
- Defaults missing components to 0 (e.g., 1.2 -> 1.2.0)
858+
- Rejects numeric identifiers with leading zeros per semver 2.0.0 §2
849859
Raises ValueError if parsing fails.
850860
"""
851861
text = str(value).strip().lstrip("vV")
@@ -856,9 +866,9 @@ def parse_semver(value: str) -> tuple:
856866
if not parts or not parts[0]:
857867
raise ValueError("Invalid semver format")
858868

859-
major = int(parts[0])
860-
minor = int(parts[1]) if len(parts) > 1 and parts[1] else 0
861-
patch = int(parts[2]) if len(parts) > 2 and parts[2] else 0
869+
major = _semver_numeric_identifier(parts[0])
870+
minor = _semver_numeric_identifier(parts[1]) if len(parts) > 1 and parts[1] else 0
871+
patch = _semver_numeric_identifier(parts[2]) if len(parts) > 2 and parts[2] else 0
862872

863873
return (major, minor, patch)
864874

@@ -902,11 +912,16 @@ def _wildcard_bounds(value: str) -> tuple:
902912
raise ValueError("Invalid wildcard pattern")
903913

904914
if len(parts) == 1:
905-
major = int(parts[0])
915+
major = _semver_numeric_identifier(parts[0])
906916
return (major, 0, 0), (major + 1, 0, 0)
907917
elif len(parts) == 2:
908-
major, minor = int(parts[0]), int(parts[1])
918+
major, minor = (
919+
_semver_numeric_identifier(parts[0]),
920+
_semver_numeric_identifier(parts[1]),
921+
)
909922
return (major, minor, 0), (major, minor + 1, 0)
910923
else:
911-
major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])
924+
major = _semver_numeric_identifier(parts[0])
925+
minor = _semver_numeric_identifier(parts[1])
926+
patch = _semver_numeric_identifier(parts[2])
912927
return (major, minor, patch), (major, minor, patch + 1)

posthog/test/test_feature_flags.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4495,7 +4495,7 @@ def test_match_properties_semver_with_prerelease(self):
44954495
self.assertFalse(match_property(prop_pre, {"version": "1.2.2"}))
44964496

44974497
def test_match_properties_semver_edge_cases(self):
4498-
"""Test semver parsing handles v-prefix, whitespace, leading zeros, and other common formats."""
4498+
"""Test semver parsing handles v-prefix, whitespace, and other common formats."""
44994499
prop = self.property(key="version", value="1.2.3", operator="semver_eq")
45004500

45014501
# v-prefix: "v1.2.3" -> extracts "1.2.3"
@@ -4507,9 +4507,6 @@ def test_match_properties_semver_edge_cases(self):
45074507
# Trailing space: "1.2.3 " -> extracts "1.2.3"
45084508
self.assertTrue(match_property(prop, {"version": "1.2.3 "}))
45094509

4510-
# Leading zeros: "01.02.03" -> int("01")=1, int("02")=2, int("03")=3
4511-
self.assertTrue(match_property(prop, {"version": "01.02.03"}))
4512-
45134510
# Flag value with v-prefix
45144511
prop_v = self.property(key="version", value="v1.2.3", operator="semver_eq")
45154512
self.assertTrue(match_property(prop_v, {"version": "1.2.3"}))
@@ -4570,6 +4567,50 @@ def test_match_properties_semver_invalid_values(self):
45704567
with self.assertRaises(InconclusiveMatchError):
45714568
match_property(prop_bad, {"version": "1.2.3"})
45724569

4570+
# Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros.
4571+
@parameterized.expand(
4572+
[
4573+
("major", "01.2.3"),
4574+
("minor", "1.02.3"),
4575+
("patch", "1.2.03"),
4576+
("all_components", "01.02.03"),
4577+
("two_digit_minor", "1.07.3"),
4578+
("triple_zero_major", "001.2.3"),
4579+
]
4580+
)
4581+
def test_match_properties_semver_rejects_leading_zero_override_value(
4582+
self, _name, bad_value
4583+
):
4584+
prop = self.property(key="version", value="1.2.3", operator="semver_eq")
4585+
with self.assertRaises(InconclusiveMatchError):
4586+
match_property(prop, {"version": bad_value})
4587+
4588+
@parameterized.expand(
4589+
[
4590+
("zero_major", "0.1.0"),
4591+
("zero_patch", "1.0.0"),
4592+
("all_zero", "0.0.0"),
4593+
]
4594+
)
4595+
def test_match_properties_semver_literal_zero_components_match(self, _name, value):
4596+
prop = self.property(key="version", value=value, operator="semver_eq")
4597+
self.assertTrue(match_property(prop, {"version": value}))
4598+
4599+
@parameterized.expand(
4600+
[
4601+
("semver_gt", "01.2.3"),
4602+
("semver_caret", "1.07.0"),
4603+
("semver_tilde", "1.07.0"),
4604+
("semver_wildcard", "01.*"),
4605+
]
4606+
)
4607+
def test_match_properties_semver_rejects_leading_zero_flag_value(
4608+
self, operator, flag_value
4609+
):
4610+
prop = self.property(key="version", value=flag_value, operator=operator)
4611+
with self.assertRaises(InconclusiveMatchError):
4612+
match_property(prop, {"version": "1.2.0"})
4613+
45734614
def test_unknown_operator(self):
45744615
property_a = self.property(key="key", value="2022-05-01", operator="is_unknown")
45754616
with self.assertRaises(InconclusiveMatchError) as exception_context:

0 commit comments

Comments
 (0)