diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index dda34053..845f1d04 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -840,12 +840,22 @@ def relative_date_parse_for_feature_flag_matching( return None +def _semver_numeric_identifier(part: str) -> int: + # Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros. + if not part or not part.isdigit(): + raise ValueError(f"Invalid semver numeric identifier: '{part}'") + if len(part) > 1 and part[0] == "0": + raise ValueError(f"Semver numeric identifier has leading zero: '{part}'") + return int(part) + + def parse_semver(value: str) -> tuple: """Parse a semver string into a comparable (major, minor, patch) integer tuple. Matches the behavior of the sortableSemver HogQL function: - Handles v-prefix, whitespace, pre-release suffixes - Defaults missing components to 0 (e.g., 1.2 -> 1.2.0) + - Rejects numeric identifiers with leading zeros per semver 2.0.0 §2 Raises ValueError if parsing fails. """ text = str(value).strip().lstrip("vV") @@ -856,9 +866,9 @@ def parse_semver(value: str) -> tuple: if not parts or not parts[0]: raise ValueError("Invalid semver format") - major = int(parts[0]) - minor = int(parts[1]) if len(parts) > 1 and parts[1] else 0 - patch = int(parts[2]) if len(parts) > 2 and parts[2] else 0 + major = _semver_numeric_identifier(parts[0]) + minor = _semver_numeric_identifier(parts[1]) if len(parts) > 1 and parts[1] else 0 + patch = _semver_numeric_identifier(parts[2]) if len(parts) > 2 and parts[2] else 0 return (major, minor, patch) @@ -902,11 +912,16 @@ def _wildcard_bounds(value: str) -> tuple: raise ValueError("Invalid wildcard pattern") if len(parts) == 1: - major = int(parts[0]) + major = _semver_numeric_identifier(parts[0]) return (major, 0, 0), (major + 1, 0, 0) elif len(parts) == 2: - major, minor = int(parts[0]), int(parts[1]) + major, minor = ( + _semver_numeric_identifier(parts[0]), + _semver_numeric_identifier(parts[1]), + ) return (major, minor, 0), (major, minor + 1, 0) else: - major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2]) + major = _semver_numeric_identifier(parts[0]) + minor = _semver_numeric_identifier(parts[1]) + patch = _semver_numeric_identifier(parts[2]) return (major, minor, patch), (major, minor, patch + 1) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 2170ce16..ffc3efd0 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -4495,7 +4495,7 @@ def test_match_properties_semver_with_prerelease(self): self.assertFalse(match_property(prop_pre, {"version": "1.2.2"})) def test_match_properties_semver_edge_cases(self): - """Test semver parsing handles v-prefix, whitespace, leading zeros, and other common formats.""" + """Test semver parsing handles v-prefix, whitespace, and other common formats.""" prop = self.property(key="version", value="1.2.3", operator="semver_eq") # v-prefix: "v1.2.3" -> extracts "1.2.3" @@ -4507,9 +4507,6 @@ def test_match_properties_semver_edge_cases(self): # Trailing space: "1.2.3 " -> extracts "1.2.3" self.assertTrue(match_property(prop, {"version": "1.2.3 "})) - # Leading zeros: "01.02.03" -> int("01")=1, int("02")=2, int("03")=3 - self.assertTrue(match_property(prop, {"version": "01.02.03"})) - # Flag value with v-prefix prop_v = self.property(key="version", value="v1.2.3", operator="semver_eq") self.assertTrue(match_property(prop_v, {"version": "1.2.3"})) @@ -4570,6 +4567,50 @@ def test_match_properties_semver_invalid_values(self): with self.assertRaises(InconclusiveMatchError): match_property(prop_bad, {"version": "1.2.3"}) + # Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros. + @parameterized.expand( + [ + ("major", "01.2.3"), + ("minor", "1.02.3"), + ("patch", "1.2.03"), + ("all_components", "01.02.03"), + ("two_digit_minor", "1.07.3"), + ("triple_zero_major", "001.2.3"), + ] + ) + def test_match_properties_semver_rejects_leading_zero_override_value( + self, _name, bad_value + ): + prop = self.property(key="version", value="1.2.3", operator="semver_eq") + with self.assertRaises(InconclusiveMatchError): + match_property(prop, {"version": bad_value}) + + @parameterized.expand( + [ + ("zero_major", "0.1.0"), + ("zero_patch", "1.0.0"), + ("all_zero", "0.0.0"), + ] + ) + def test_match_properties_semver_literal_zero_components_match(self, _name, value): + prop = self.property(key="version", value=value, operator="semver_eq") + self.assertTrue(match_property(prop, {"version": value})) + + @parameterized.expand( + [ + ("semver_gt", "01.2.3"), + ("semver_caret", "1.07.0"), + ("semver_tilde", "1.07.0"), + ("semver_wildcard", "01.*"), + ] + ) + def test_match_properties_semver_rejects_leading_zero_flag_value( + self, operator, flag_value + ): + prop = self.property(key="version", value=flag_value, operator=operator) + with self.assertRaises(InconclusiveMatchError): + match_property(prop, {"version": "1.2.0"}) + def test_unknown_operator(self): property_a = self.property(key="key", value="2022-05-01", operator="is_unknown") with self.assertRaises(InconclusiveMatchError) as exception_context: