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:
parent
98f4a2165e
commit
b4ca01580a
3 changed files with 99 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue