From b4ca01580a82360c3bfacec728384ae59aa53cd6 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 11 Jul 2026 21:56:56 +0200 Subject: [PATCH] fix: reject invalid SemVer identifiers when parsing Per SemVer 2.0.0 the core version numbers and numeric prerelease identifiers must not include leading zeroes, and prerelease/build-metadata identifiers must be composed only of ASCII alphanumerics and hyphens. Add IsValidIdentifiers using a vectorized SearchValues lookup for the allowed character set and ulong.TryParse to detect numeric identifiers for the leading-zero check. Add failing-then-passing tests for both invalid and still-valid inputs. --- CHANGELOG.md | 4 ++ src/semver.tests/SemanticVersionTests.cs | 26 +++++++++ src/semver/SemanticVersion.Parsing.cs | 69 ++++++++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba22dd..e1784b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Changed +### Fixed + +- Reject invalid SemVer identifiers when parsing versions. + ### Removed [1.0.0]: https://code.geekeey.de/geekeey/semver/releases/tag/1.0.0 diff --git a/src/semver.tests/SemanticVersionTests.cs b/src/semver.tests/SemanticVersionTests.cs index fe09dd8..1cb7eed 100644 --- a/src/semver.tests/SemanticVersionTests.cs +++ b/src/semver.tests/SemanticVersionTests.cs @@ -186,6 +186,32 @@ internal sealed class SemanticVersionTests await Assert.That(v.Metadata).IsEqualTo("789"); } + [Test] + [Arguments("1.2.3-@#")] + [Arguments("01.2.3")] + [Arguments("1.2.3-01")] + [Arguments("1.2.3-alpha_1")] + [Arguments("1.2.3+build.@")] + [Arguments("1.2.3-")] + [Arguments("1.2.3-..")] + public async Task I_can_not_parse_invalid_versions(string input) + { + await Assert.That(SemanticVersion.TryParse(input, out _)).IsFalse(); + } + + [Test] + [Arguments("1.2.3")] + [Arguments("1.2.3-alpha.1")] + [Arguments("1.0.0-0.3.7")] + [Arguments("1.0.0-x.7.z.92")] + [Arguments("10.20.30")] + [Arguments("1.2.3+build.01")] + [Arguments("1.0.0-alpha-b")] + public async Task I_can_parse_valid_versions(string input) + { + await Assert.That(SemanticVersion.TryParse(input, out _)).IsTrue(); + } + [Test] public async Task I_can_handle_invalid_json_token() { diff --git a/src/semver/SemanticVersion.Parsing.cs b/src/semver/SemanticVersion.Parsing.cs index cb311aa..e24802e 100644 --- a/src/semver/SemanticVersion.Parsing.cs +++ b/src/semver/SemanticVersion.Parsing.cs @@ -85,6 +85,9 @@ public readonly partial record struct SemanticVersion : ISpanParsable ValidIdentifierChars = + System.Buffers.SearchValues.Create("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-"); + internal static bool TryParsePartially(ReadOnlySpan s, out SemanticVersion version, out int components) { version = default; @@ -146,10 +149,76 @@ public readonly partial record struct SemanticVersion : ISpanParsable 1 && segment[0] is '0') + { + return false; + } + component[components++] = value; } + // Per SemVer 2.0.0 the core version numbers and numeric prerelease identifiers + // MUST NOT include leading zeroes, and prerelease/metadata identifiers MUST be + // composed only of ASCII alphanumerics and hyphens. + if (metadata is not null && !IsValidIdentifiers(metadata, forbidLeadingZeroInNumerics: false)) + { + return false; + } + + if (prerelease is not null && !IsValidIdentifiers(prerelease, forbidLeadingZeroInNumerics: true)) + { + return false; + } + version = new SemanticVersion(component[0], component[1], component[2], prerelease, metadata); return true; } + + private static bool IsValidIdentifiers(ReadOnlySpan value, bool forbidLeadingZeroInNumerics) + { + if (value.IsEmpty) + { + return false; + } + + // Validation is per-identifier (dot-separated), not per-string: an empty + // segment ("a..b", "a.") is invalid, and the leading-zero ban applies only + // to numeric identifiers (so "0abc" stays valid while "02" does not). + var start = 0; + for (var i = 0; i <= value.Length; i++) + { + if (i != value.Length && value[i] is not '.') + { + continue; + } + + var identifier = value[start..i]; + + // Reject empty segments produced by adjacent or trailing dots. + if (identifier.IsEmpty) + { + return false; + } + + // Every character must be an allowed identifier character. + if (identifier.IndexOfAnyExcept(ValidIdentifierChars) >= 0) + { + return false; + } + + // Numeric identifiers must not include leading zeroes (SemVer 2.0.0 §9/§10). + // The TryParse gate ensures the rule fires only for all-digit identifiers, + // leaving an alphanumeric "0abc" (which is allowed) untouched. + if (forbidLeadingZeroInNumerics && identifier.Length > 1 && identifier[0] is '0' && + ulong.TryParse(identifier, NumberStyles.None, CultureInfo.InvariantCulture, out _)) + { + return false; + } + + start = i + 1; + } + + return true; + } }