From 5c9b63a3de5c7d9bd7922e76bd96137751aa0de3 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 20 May 2026 13:19:10 -0700 Subject: [PATCH 1/3] Reject semver values with leading zeros 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 simply doesn't match. Adds explicit rejection in parse_semver and the wildcard parser, and updates the edge-case test that previously asserted "01.02.03" matched "1.2.3". --- posthog/feature_flags.py | 27 ++++++++++---- posthog/test/test_feature_flags.py | 56 +++++++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 10 deletions(-) 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..f7306397 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,57 @@ def test_match_properties_semver_invalid_values(self): with self.assertRaises(InconclusiveMatchError): match_property(prop_bad, {"version": "1.2.3"}) + def test_match_properties_semver_rejects_leading_zeros(self): + """Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros.""" + prop = self.property(key="version", value="1.2.3", operator="semver_eq") + + # Leading zero in any numeric component is invalid + for bad_value in [ + "01.2.3", + "1.02.3", + "1.2.03", + "01.02.03", + "1.07.3", + "001.2.3", + ]: + with self.assertRaises(InconclusiveMatchError): + match_property(prop, {"version": bad_value}) + + # A literal "0" component is fine + prop_zero = self.property(key="version", value="0.1.0", operator="semver_eq") + self.assertTrue(match_property(prop_zero, {"version": "0.1.0"})) + + prop_zero_patch = self.property( + key="version", value="1.0.0", operator="semver_eq" + ) + self.assertTrue(match_property(prop_zero_patch, {"version": "1.0.0"})) + + # Leading zeros in the flag value also raise (flag values are also validated) + prop_bad_flag = self.property( + key="version", value="01.2.3", operator="semver_gt" + ) + with self.assertRaises(InconclusiveMatchError): + match_property(prop_bad_flag, {"version": "2.0.0"}) + + # Range operators reject leading-zero flag values + prop_caret = self.property( + key="version", value="1.07.0", operator="semver_caret" + ) + with self.assertRaises(InconclusiveMatchError): + match_property(prop_caret, {"version": "1.2.0"}) + + prop_tilde = self.property( + key="version", value="1.07.0", operator="semver_tilde" + ) + with self.assertRaises(InconclusiveMatchError): + match_property(prop_tilde, {"version": "1.2.0"}) + + prop_wild = self.property( + key="version", value="01.*", operator="semver_wildcard" + ) + with self.assertRaises(InconclusiveMatchError): + match_property(prop_wild, {"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: From b9927db2ef1e39dafd3e7c8a547e7dac7f344eaa Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 20 May 2026 14:12:58 -0700 Subject: [PATCH 2/3] Parametrize semver leading-zero rejection tests Split the single bundled test into three @parameterized.expand-driven tests (override-value rejection, literal-zero acceptance, flag-value rejection per operator) so failures point at the specific case that broke. Matches the project's preference for parameterised tests. --- posthog/test/test_feature_flags.py | 83 ++++++++++++++---------------- 1 file changed, 38 insertions(+), 45 deletions(-) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index f7306397..ffc3efd0 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -4567,56 +4567,49 @@ def test_match_properties_semver_invalid_values(self): with self.assertRaises(InconclusiveMatchError): match_property(prop_bad, {"version": "1.2.3"}) - def test_match_properties_semver_rejects_leading_zeros(self): - """Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros.""" + # 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") - - # Leading zero in any numeric component is invalid - for bad_value in [ - "01.2.3", - "1.02.3", - "1.2.03", - "01.02.03", - "1.07.3", - "001.2.3", - ]: - with self.assertRaises(InconclusiveMatchError): - match_property(prop, {"version": bad_value}) - - # A literal "0" component is fine - prop_zero = self.property(key="version", value="0.1.0", operator="semver_eq") - self.assertTrue(match_property(prop_zero, {"version": "0.1.0"})) - - prop_zero_patch = self.property( - key="version", value="1.0.0", operator="semver_eq" - ) - self.assertTrue(match_property(prop_zero_patch, {"version": "1.0.0"})) - - # Leading zeros in the flag value also raise (flag values are also validated) - prop_bad_flag = self.property( - key="version", value="01.2.3", operator="semver_gt" - ) - with self.assertRaises(InconclusiveMatchError): - match_property(prop_bad_flag, {"version": "2.0.0"}) - - # Range operators reject leading-zero flag values - prop_caret = self.property( - key="version", value="1.07.0", operator="semver_caret" - ) with self.assertRaises(InconclusiveMatchError): - match_property(prop_caret, {"version": "1.2.0"}) + match_property(prop, {"version": bad_value}) - prop_tilde = self.property( - key="version", value="1.07.0", operator="semver_tilde" - ) - with self.assertRaises(InconclusiveMatchError): - match_property(prop_tilde, {"version": "1.2.0"}) + @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})) - prop_wild = self.property( - key="version", value="01.*", operator="semver_wildcard" - ) + @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_wild, {"version": "1.2.0"}) + 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") From 3c1d583774aa26455e72d5566c654c79172045e5 Mon Sep 17 00:00:00 2001 From: dylan Date: Thu, 21 May 2026 07:44:33 -0700 Subject: [PATCH 3/3] chore: add sampo changeset for semver leading-zero fix --- .sampo/changesets/strict-semver-leading-zeros.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .sampo/changesets/strict-semver-leading-zeros.md diff --git a/.sampo/changesets/strict-semver-leading-zeros.md b/.sampo/changesets/strict-semver-leading-zeros.md new file mode 100644 index 00000000..3a68acfa --- /dev/null +++ b/.sampo/changesets/strict-semver-leading-zeros.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +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.