diff --git a/.changeset/strict-semver-leading-zeros.md b/.changeset/strict-semver-leading-zeros.md new file mode 100644 index 00000000..d554fa7a --- /dev/null +++ b/.changeset/strict-semver-leading-zeros.md @@ -0,0 +1,5 @@ +--- +"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 cause `SemanticVersion.TryParse` to return false so the condition does not match. diff --git a/src/PostHog/Library/SemanticVersion.cs b/src/PostHog/Library/SemanticVersion.cs index 7d40088d..c4517cbd 100644 --- a/src/PostHog/Library/SemanticVersion.cs +++ b/src/PostHog/Library/SemanticVersion.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Globalization; namespace PostHog.Library; @@ -47,6 +48,7 @@ public SemanticVersion(int major, int minor, int patch) /// 5. Default missing components to 0 (e.g., "1.2" → (1, 2, 0), "1" → (1, 0, 0)) /// 6. Ignore extra components beyond the third (e.g., "1.2.3.4" → (1, 2, 3)) /// 7. Return false for truly invalid input (empty string, non-numeric parts, leading dot) + /// 8. Reject components with leading zeros per semver 2.0.0 §2 (e.g., "01.02.03") /// public static bool TryParse(string? value, [NotNullWhen(returnValue: true)] out SemanticVersion? version) { @@ -110,33 +112,44 @@ public static bool TryParse(string? value, [NotNullWhen(returnValue: true)] out var parts = trimmed.Split('.'); // Parse major (required) - if (!int.TryParse(parts[0], out var major)) + if (!TryParseSemverNumericIdentifier(parts[0], out var major)) { return false; } // Parse minor (optional, defaults to 0) var minor = 0; - if (parts.Length > 1 && !string.IsNullOrEmpty(parts[1]) && !int.TryParse(parts[1], out minor)) + if (parts.Length > 1 && !string.IsNullOrEmpty(parts[1]) && !TryParseSemverNumericIdentifier(parts[1], out minor)) { return false; } // Parse patch (optional, defaults to 0) var patch = 0; - if (parts.Length > 2 && !string.IsNullOrEmpty(parts[2]) && !int.TryParse(parts[2], out patch)) + if (parts.Length > 2 && !string.IsNullOrEmpty(parts[2]) && !TryParseSemverNumericIdentifier(parts[2], out patch)) { return false; } - // Reject negative version components - if (major < 0 || minor < 0 || patch < 0) + version = new SemanticVersion(major, minor, patch); + return true; + } + + static bool TryParseSemverNumericIdentifier(string part, out int value) + { + value = 0; + if (string.IsNullOrEmpty(part)) { return false; } - - version = new SemanticVersion(major, minor, patch); - return true; + // Semver 2.0.0 §2: literal "0" is allowed; "01", "00", "001" are not. + if (part.Length > 1 && part[0] == '0') + { + return false; + } + // NumberStyles.None + InvariantCulture rejects signs, whitespace, and non-ASCII digits. + // The sign rejection is what lets TryParse drop the explicit negative-component check. + return int.TryParse(part, NumberStyles.None, CultureInfo.InvariantCulture, out value); } /// @@ -252,7 +265,7 @@ public static bool TryParseWildcard( { // "X" pattern - treat as "X.*" // Bare wildcards like "*" and non-numeric values are invalid - if (!int.TryParse(parts[0], out var major)) + if (!TryParseSemverNumericIdentifier(parts[0], out var major)) { return false; } @@ -264,7 +277,7 @@ public static bool TryParseWildcard( else if (parts.Length == 2) { // "X.Y" or "X.*" pattern - if (!int.TryParse(parts[0], out var major)) + if (!TryParseSemverNumericIdentifier(parts[0], out var major)) { return false; } @@ -277,7 +290,7 @@ public static bool TryParseWildcard( return true; } - if (!int.TryParse(parts[1], out var minor)) + if (!TryParseSemverNumericIdentifier(parts[1], out var minor)) { return false; } @@ -290,12 +303,12 @@ public static bool TryParseWildcard( else if (parts.Length >= 3) { // "X.Y.Z" or "X.Y.*" pattern - if (!int.TryParse(parts[0], out var major)) + if (!TryParseSemverNumericIdentifier(parts[0], out var major)) { return false; } - if (!int.TryParse(parts[1], out var minor)) + if (!TryParseSemverNumericIdentifier(parts[1], out var minor)) { return false; } diff --git a/tests/UnitTests/Features/LocalEvaluatorTests.cs b/tests/UnitTests/Features/LocalEvaluatorTests.cs index 758efce4..eaf5bf5d 100644 --- a/tests/UnitTests/Features/LocalEvaluatorTests.cs +++ b/tests/UnitTests/Features/LocalEvaluatorTests.cs @@ -2289,7 +2289,6 @@ public void HandlesSemverWildcardMinorOperator(string overrideValue, ComparisonO [InlineData("1.2.3-alpha", ComparisonOperator.SemverEquals, "1.2.3", true)] // Pre-release stripped [InlineData("1.2.3+build", ComparisonOperator.SemverEquals, "1.2.3", true)] // Build metadata stripped [InlineData(" 1.2.3 ", ComparisonOperator.SemverEquals, "1.2.3", true)] // Whitespace stripped - [InlineData("01.02.03", ComparisonOperator.SemverEquals, "1.2.3", true)] // Leading zeros [InlineData("1.2", ComparisonOperator.SemverEquals, "1.2.0", true)] // Partial version [InlineData("1", ComparisonOperator.SemverEquals, "1.0.0", true)] // Partial version [InlineData("1.2.3.4", ComparisonOperator.SemverEquals, "1.2.3", true)] // Extra parts ignored @@ -2329,6 +2328,14 @@ public void HandlesSpecialVersionFormats(string overrideValue, ComparisonOperato [InlineData(".1.2.3", ComparisonOperator.SemverEquals)] [InlineData("not-a-version", ComparisonOperator.SemverGreaterThan)] [InlineData("", ComparisonOperator.SemverGreaterThan)] + // Leading-zero override values are invalid per semver 2.0.0 §2 + [InlineData("01.02.03", ComparisonOperator.SemverEquals)] + [InlineData("1.07.3", ComparisonOperator.SemverEquals)] + [InlineData("01.02.03", ComparisonOperator.SemverNotEquals)] + [InlineData("1.07.3", ComparisonOperator.SemverGreaterThan)] + [InlineData("01.2.3", ComparisonOperator.SemverGreaterThanOrEquals)] + [InlineData("1.2.03", ComparisonOperator.SemverLessThan)] + [InlineData("1.07.3", ComparisonOperator.SemverLessThanOrEquals)] public void ThrowsInconclusiveMatchExceptionForInvalidOverrideVersion(string overrideValue, ComparisonOperator comparison) { var flags = CreateFlags( @@ -2358,11 +2365,28 @@ public void ThrowsInconclusiveMatchExceptionForInvalidOverrideVersion(string ove [Theory] // Invalid version in filter value - should throw InconclusiveMatchException - [InlineData("not-a-version")] - [InlineData("")] - [InlineData("abc.def.ghi")] - [InlineData(".1.2.3")] - public void ThrowsInconclusiveMatchExceptionForInvalidFilterVersion(string filterValue) + [InlineData("not-a-version", ComparisonOperator.SemverEquals)] + [InlineData("", ComparisonOperator.SemverEquals)] + [InlineData("abc.def.ghi", ComparisonOperator.SemverEquals)] + [InlineData(".1.2.3", ComparisonOperator.SemverEquals)] + // Mirror the override-version test: non-leading-zero malformed inputs must also fail + // under non-equals operators, not just SemverEquals. + [InlineData("not-a-version", ComparisonOperator.SemverGreaterThan)] + [InlineData("", ComparisonOperator.SemverGreaterThan)] + [InlineData("abc.def.ghi", ComparisonOperator.SemverTilde)] + [InlineData(".1.2.3", ComparisonOperator.SemverCaret)] + // Leading-zero filter values are invalid per semver 2.0.0 §2, across all operators + [InlineData("01.02.03", ComparisonOperator.SemverEquals)] + [InlineData("1.07.3", ComparisonOperator.SemverEquals)] + [InlineData("01.2.3", ComparisonOperator.SemverEquals)] + [InlineData("1.2.03", ComparisonOperator.SemverEquals)] + [InlineData("1.07.3", ComparisonOperator.SemverGreaterThan)] + [InlineData("01.2.3", ComparisonOperator.SemverGreaterThanOrEquals)] + [InlineData("1.2.03", ComparisonOperator.SemverLessThan)] + [InlineData("01.02.03", ComparisonOperator.SemverLessThanOrEquals)] + [InlineData("1.07.3", ComparisonOperator.SemverTilde)] + [InlineData("01.2.3", ComparisonOperator.SemverCaret)] + public void ThrowsInconclusiveMatchExceptionForInvalidFilterVersion(string filterValue, ComparisonOperator comparison) { var flags = CreateFlags( key: "version", @@ -2372,7 +2396,7 @@ public void ThrowsInconclusiveMatchExceptionForInvalidFilterVersion(string filte Type = FilterType.Person, Key = "app_version", Value = new PropertyFilterValue(filterValue), - Operator = ComparisonOperator.SemverEquals + Operator = comparison } ] ); @@ -2394,6 +2418,10 @@ public void ThrowsInconclusiveMatchExceptionForInvalidFilterVersion(string filte [InlineData("*")] [InlineData("1.2.3")] // Not a wildcard pattern [InlineData("abc.*")] + // Leading-zero wildcard patterns are invalid per semver 2.0.0 §2 + [InlineData("01.*")] + [InlineData("1.02.*")] + [InlineData("01.2.*")] public void ThrowsInconclusiveMatchExceptionForInvalidWildcardPattern(string filterValue) { var flags = CreateFlags( diff --git a/tests/UnitTests/Library/SemanticVersionTests.cs b/tests/UnitTests/Library/SemanticVersionTests.cs index 981b678b..9a7e5bf4 100644 --- a/tests/UnitTests/Library/SemanticVersionTests.cs +++ b/tests/UnitTests/Library/SemanticVersionTests.cs @@ -107,10 +107,12 @@ public void IgnoresExtraComponents(string input, int expectedMajor, int expected } [Theory] - // Leading zeros are parsed as integers - [InlineData("01.02.03", 1, 2, 3)] - [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.1", 0, 0, 1)] + [InlineData("0.1.0", 0, 1, 0)] + [InlineData("1.0.0", 1, 0, 0)] + [InlineData("1.2.0", 1, 2, 0)] + public void ParsesLiteralZeroComponents(string input, int expectedMajor, int expectedMinor, int expectedPatch) { var result = SemanticVersion.TryParse(input, out var version); @@ -139,6 +141,19 @@ public void ParsesLeadingZeros(string input, int expectedMajor, int expectedMino [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) + [InlineData("001.002.003")] // Multiple leading zeros + [InlineData("1.07.3")] // Leading zero in minor + [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 + [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")] // Embedded whitespace in minor (NumberStyles.None rejects) + [InlineData("١.2.3")] // Arabic-Indic digit in major (InvariantCulture rejects) + [InlineData("99999999999999.0.0")] // Overflows int.MaxValue public void ReturnsFalseForInvalidInput(string? input) { var result = SemanticVersion.TryParse(input, out var version); @@ -470,6 +485,14 @@ public void ParsesImplicitMinorWildcard(string pattern, string expectedLower, st [InlineData("1.2.3.*")] // Too specific [InlineData(".1.*")] // Leading dot [InlineData("abc.*")] // Non-numeric + [InlineData("01.*")] // Leading zero in major + [InlineData("1.02.*")] // Leading zero in minor + [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 + [InlineData("v01.*")] // Leading zero with v-prefix + [InlineData("001.*")] // Multiple leading zeros public void ReturnsFalseForInvalidPatterns(string? pattern) { var result = SemanticVersion.TryParseWildcard(pattern, out var lower, out var upper);