Skip to content

fix: reject leading-zero semver values in local evaluation#206

Merged
dmarticus merged 5 commits into
mainfrom
dmarticus/strict-semver-leading-zeros
May 22, 2026
Merged

fix: reject leading-zero semver values in local evaluation#206
dmarticus merged 5 commits into
mainfrom
dmarticus/strict-semver-leading-zeros

Conversation

@dmarticus
Copy link
Copy Markdown
Contributor

Summary

Per semver 2.0.0 §2, numeric identifiers MUST NOT include leading zeros. Previously, SemanticVersion.TryParse and TryParseWildcard used int.TryParse directly, which accepted strings like "01.02.03" and "1.07.3" and quietly converted them to 1.2.3 / 1.7.3. That meant feature flag targeting conditions could match on invalid semver inputs, producing incorrect rollout results.

This brings posthog-dotnet in line with the matching fixes in posthog-python (#601) and posthog-go (#200).

Changes

  • Added TryParseSemverNumeric helper in SemanticVersion that rejects empty input, non-digit characters, and any value with a leading zero (except literal "0").
  • Used the helper inside TryParse (major/minor/patch) and TryParseWildcard (each numeric segment).
  • Removed the now-redundant negative-component check from TryParse — non-digits are rejected up front.
  • Inverted the existing leading-zero acceptance test cases and added new rejection cases covering all semver operators (semver_eq, semver_neq, semver_gt, semver_gte, semver_lt, semver_lte, semver_tilde, semver_caret, semver_wildcard) for both override values and flag values.
  • Literal "0" components (e.g. "0.1.0", "1.0.0", "0.0.0") remain valid.

Invalid inputs cause TryParse to return false, which causes the higher-level evaluator to raise InconclusiveMatchException for that condition — matching the existing behavior for any other unparseable semver value.

Test plan

  • dotnet test -f net8.0 — all previously passing tests still pass (2 pre-existing failures in PostHogClientTests.TheCaptureExceptionMethod are unrelated and reproduce on main)
  • New SemanticVersionTests cases cover leading zeros in major/minor/patch, multi-zero, and v-prefixed inputs
  • New TryParseWildcard cases reject "01.*", "1.02.*", "01.2.*", "01", "01.2", "1.02"
  • TheSemverOperators cases verify rejection across every semver operator for both override and flag values
  • Literal-zero components (0.0.0, 0.1.0, 1.0.0, 1.2.0) still parse and match

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 cause TryParse to return false so the condition does not match.
@dmarticus dmarticus requested review from a team and haacked as code owners May 20, 2026 20:39
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 20, 2026

posthog-dotnet Compliance Report

Date: 2026-05-22 19:25:30 UTC
Duration: 539ms

⚠️ Some Tests Failed

2/16 tests passed, 14 failed


Feature_Flags Tests

⚠️ 2/16 tests passed, 14 failed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 44ms
Request Payload.Flags Request Uses V2 Query Param 22ms
Request Payload.Flags Request Hits Flags Path Not Decide 5ms
Request Payload.Flags Request Omits Authorization Header 5ms
Request Payload.Token In Flags Body Matches Init 5ms
Request Payload.Groups Round Trip 5ms
Request Payload.Groups Default To Empty Object 5ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It 4ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 4ms
Request Payload.Disable Geoip Omitted Defaults To False 4ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 5ms
Request Lifecycle.No Flags Request On Init Alone 3ms
Request Lifecycle.No Flags Request On Normal Capture 180ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 7ms
Request Lifecycle.Mock Response Value Is Returned To Caller 4ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 5ms

Failures

request_payload.request_with_person_properties_device_id

404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'

request_payload.flags_request_uses_v2_query_param

404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'

request_payload.flags_request_hits_flags_path_not_decide

404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'

request_payload.flags_request_omits_authorization_header

404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'

request_payload.token_in_flags_body_matches_init

404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'

request_payload.groups_round_trip

404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'

request_payload.groups_default_to_empty_object

404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'

request_payload.person_properties_distinct_id_auto_populated_when_caller_omits_it

404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'

request_payload.disable_geoip_false_propagates_as_geoip_disable_false

404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'

request_payload.disable_geoip_omitted_defaults_to_false

404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'

request_payload.flag_keys_to_evaluate_contains_only_requested_key

404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'

request_lifecycle.two_flag_calls_produce_two_remote_requests

404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'

request_lifecycle.mock_response_value_is_returned_to_caller

404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'

side_effect_events.get_feature_flag_captures_feature_flag_called_event

404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 20, 2026

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
src/PostHog/Library/SemanticVersion.cs:149-156
The manual `foreach` digit-check followed by `int.TryParse` validates twice. More importantly, `char.IsDigit` returns `true` for non-ASCII Unicode decimal digits (e.g., Arabic-Indic `١٢٣`), so the loop is both redundant and slightly looser than intended. Using `int.TryParse` with `NumberStyles.None` and `CultureInfo.InvariantCulture` rejects signs, whitespace, and non-ASCII digits in a single pass — no separate loop needed.

```suggestion
        return int.TryParse(part, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out value);
```

### Issue 2 of 2
tests/UnitTests/Features/LocalEvaluatorTests.cs:2404-2437
**Duplicate test body violates OnceAndOnlyOnce**

`ThrowsInconclusiveMatchExceptionForLeadingZeroFlagValueAcrossOperators` has an identical method body to `ThrowsInconclusiveMatchExceptionForInvalidFilterVersion` — the only difference is that the new test also parametrises the `ComparisonOperator`. The existing `ThrowsInconclusiveMatchExceptionForInvalidFilterVersion` could be extended to accept an operator parameter (defaulting to `SemverEquals` for the current cases), exactly like `ThrowsInconclusiveMatchExceptionForInvalidOverrideVersion` already does. That would let all the leading-zero + multi-operator cases be added as `[InlineData]` rows to the existing test instead of duplicating the body in a new method.

Reviews (1): Last reviewed commit: "Reject semver values with leading zeros ..." | Re-trigger Greptile

Comment thread src/PostHog/Library/SemanticVersion.cs Outdated
Comment thread tests/UnitTests/Features/LocalEvaluatorTests.cs Outdated
@dmarticus dmarticus changed the title Reject leading-zero semver values in local evaluation fix: reject leading-zero semver values in local evaluation May 20, 2026
- TryParseSemverNumeric: replace the manual char.IsDigit loop with
  int.TryParse(part, NumberStyles.None, CultureInfo.InvariantCulture, ...),
  which is single-pass and correctly rejects non-ASCII decimal digits.
- Merge ThrowsInconclusiveMatchExceptionForLeadingZeroFlagValueAcrossOperators
  into ThrowsInconclusiveMatchExceptionForInvalidFilterVersion by adding
  a ComparisonOperator parameter, matching the existing
  ThrowsInconclusiveMatchExceptionForInvalidOverrideVersion pattern.
@marandaneto
Copy link
Copy Markdown
Member

missing changelog entry

Copy link
Copy Markdown
Contributor

@haacked haacked left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me. Have some non-blocking test case suggestions, but other than that, 🚢

[InlineData("1.2.03")] // Leading zero in patch
[InlineData("01.2.3")] // Leading zero in major
[InlineData("00.0.0")] // Leading zero on a zero major
[InlineData("v01.2.3")] // Leading zero with v-prefix
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The new ReturnsFalseForInvalidInput rows cover bare leading-zero triples and "v01.2.3", but nothing pins the contract that the leading-zero check survives the upstream pre-release, build-metadata, and outer-whitespace strips. If a future change reorders TryParse so the leading-zero check runs before those strips, today's tests still pass while "01.2.3-alpha" silently goes back to being accepted as 1.2.3. A few rows lock the composition in:

[InlineData("01.2.3-alpha")]      // Leading zero + pre-release
[InlineData("01.2.3+build")]      // Leading zero + build metadata
[InlineData("  01.2.3  ")]        // Leading zero + outer whitespace

[InlineData("1.-2.3")] // Negative minor
[InlineData("-1.2.3")] // Negative major
[InlineData("1.2.-3")] // Negative patch
[InlineData("01.02.03")] // Leading zeros (all components)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: TryParseSemverNumeric relies on NumberStyles.None + InvariantCulture to reject signs, embedded whitespace, and non-ASCII digits, plus on int.TryParse returning false on overflow. The new leading-zero rows exercise the explicit part[0] == '0' guard; the rest of the helper's contract is only covered by the pre-existing -1.2.3/1.-2.3 sign cases. If someone later swaps in int.Parse(part) or relaxes the styles, every present test still passes. Three rows close the gap:

[InlineData("1. 2.3")]              // Embedded whitespace in minor (NumberStyles.None)
[InlineData("١.2.3")]              // Arabic-Indic digit in major (InvariantCulture)
[InlineData("99999999999999.0.0")]  // Overflows int.MaxValue

[InlineData("not-a-version", ComparisonOperator.SemverEquals)]
[InlineData("", ComparisonOperator.SemverEquals)]
[InlineData("abc.def.ghi", ComparisonOperator.SemverEquals)]
[InlineData(".1.2.3", ComparisonOperator.SemverEquals)]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The signature change to this test opens the door to running each invalid value under every operator, but the four pre-existing rows ("not-a-version", "", "abc.def.ghi", ".1.2.3") only get SemverEquals. The override-version twin test above already pairs "not-a-version" and "" with SemverGreaterThan. Mirror that here so the non-equals operators stay covered for non-leading-zero malformed input:

[InlineData("not-a-version", ComparisonOperator.SemverGreaterThan)]
[InlineData("", ComparisonOperator.SemverGreaterThan)]
[InlineData("abc.def.ghi", ComparisonOperator.SemverTilde)]
[InlineData(".1.2.3", ComparisonOperator.SemverCaret)]


version = new SemanticVersion(major, minor, patch);
return true;
// NumberStyles.None + InvariantCulture rejects signs, whitespace, and non-ASCII digits.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The deletion of the explicit if (major < 0 || minor < 0 || patch < 0) check is safe only because NumberStyles.None rejects sign characters during parse. That coupling is load-bearing and invisible. The existing comment names the behavior; spelling out the dependency keeps the next person from "tightening" to NumberStyles.Integer and silently re-opening negative-component acceptance.

Suggested change
// NumberStyles.None + InvariantCulture rejects signs, whitespace, and non-ASCII digits.
// NumberStyles.None + InvariantCulture rejects signs, whitespace, and non-ASCII digits.
// The sign rejection is what lets TryParse drop the explicit negative-component check above.

[InlineData("01.2.*")] // Leading zero in major (X.Y.*)
[InlineData("01")] // Leading zero in implicit major
[InlineData("01.2")] // Leading zero in implicit X.Y
[InlineData("1.02")] // Leading zero in implicit minor
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: ReturnsFalseForInvalidPatterns covers leading zeros in major/minor and the implicit X/X.Y forms but is missing two cases that ReturnsFalseForInvalidInput does cover for TryParse: a v-prefixed leading zero and a multiple-leading-zero variant. Both paths go through TryParseSemverNumeric, so behavior should already be correct, but the symmetry protects against someone later short-circuiting the wildcard path or moving the strip-v logic:

[InlineData("v01.*")]    // Leading zero with v-prefix
[InlineData("001.*")]    // Multiple leading zeros

Comment thread src/PostHog/Library/SemanticVersion.cs Outdated
static bool TryParseSemverNumeric(string part, out int value)
{
value = 0;
if (string.IsNullOrEmpty(part) || (part.Length > 1 && part[0] == '0'))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This guard packs two independent failure modes into one boolean: empty input and leading zero. A reader has to parse the precedence of && inside || and remember the Length > 1 exception exists for the literal "0" case. Splitting reads more like the spec rules in the <remarks> block:

if (string.IsNullOrEmpty(part)) return false;
// Semver 2.0.0 §2: literal "0" is allowed; "01", "00", "001" are not.
if (part.Length > 1 && part[0] == '0') return false;
return int.TryParse(part, NumberStyles.None, CultureInfo.InvariantCulture, out value);

[InlineData("001.002.003", 1, 2, 3)]
public void ParsesLeadingZeros(string input, int expectedMajor, int expectedMinor, int expectedPatch)
// Literal "0" components are valid per semver 2.0.0
[InlineData("0.0.0", 0, 0, 0)]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: [InlineData("0.0.0", 0, 0, 0)] here is already covered verbatim by ParsesValidVersions higher up in the file. Drop this row or swap it for a case that isn't already pinned (e.g., [InlineData("0.0.1", 0, 0, 1)]). The other three new rows do pull their weight.

Comment thread src/PostHog/Library/SemanticVersion.cs Outdated
}

// Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros.
static bool TryParseSemverNumeric(string part, out int value)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: TryParseSemverNumeric reads like "parse a full semver string" but actually parses a single numeric identifier. TryParseSemverNumericIdentifier or TryParseSemverComponent matches the doc comment ("numeric identifiers MUST NOT include leading zeros") more directly. Stylistic.

Split combined empty/leading-zero guard into two ifs, rename helper to
TryParseSemverNumericIdentifier to match its doc-comment terminology, and
document that NumberStyles.None's sign rejection is what lets us drop the
explicit negative-component check.

Lock in helper-contract and composition cases the existing rows didn't
pin: leading zero combined with pre-release, build metadata, and outer
whitespace; embedded whitespace, Arabic-Indic digits, and int overflow
inputs; v-prefixed and multi-leading-zero wildcard rejection. Mirror
the override-version test by running non-leading-zero malformed inputs
under non-equals operators (SemverGreaterThan, SemverTilde, SemverCaret),
and swap the duplicate ParsesLiteralZeroComponents("0.0.0") row for "0.0.1".
@dmarticus dmarticus disabled auto-merge May 22, 2026 18:09
@dmarticus dmarticus enabled auto-merge (squash) May 22, 2026 18:09
Copy link
Copy Markdown
Contributor

@haacked haacked left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:shipit:

@dmarticus dmarticus merged commit 2caf86e into main May 22, 2026
14 checks passed
@dmarticus dmarticus deleted the dmarticus/strict-semver-leading-zeros branch May 22, 2026 19:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants