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.
This commit is contained in:
Louis Seubert 2026-07-11 21:56:56 +02:00
commit b4ca01580a
3 changed files with 99 additions and 0 deletions

View file

@ -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

View file

@ -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()
{

View file

@ -85,6 +85,9 @@ public readonly partial record struct SemanticVersion : ISpanParsable<SemanticVe
// components = 1: wildcard at minor
// components = 2: wildcard at patch
// components = 3: no wildcards
private static readonly System.Buffers.SearchValues<char> ValidIdentifierChars =
System.Buffers.SearchValues.Create("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-");
internal static bool TryParsePartially(ReadOnlySpan<char> s, out SemanticVersion version, out int components)
{
version = default;
@ -146,10 +149,76 @@ public readonly partial record struct SemanticVersion : ISpanParsable<SemanticVe
return false;
}
// Leading zeroes are forbidden for the numeric core components (SemVer 2.0.0 §9).
if (segment.Length > 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<char> 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;
}
}