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
|
### Changed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Reject invalid SemVer identifiers when parsing versions.
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
[1.0.0]: https://code.geekeey.de/geekeey/semver/releases/tag/1.0.0
|
[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");
|
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]
|
[Test]
|
||||||
public async Task I_can_handle_invalid_json_token()
|
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 = 1: wildcard at minor
|
||||||
// components = 2: wildcard at patch
|
// components = 2: wildcard at patch
|
||||||
// components = 3: no wildcards
|
// 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)
|
internal static bool TryParsePartially(ReadOnlySpan<char> s, out SemanticVersion version, out int components)
|
||||||
{
|
{
|
||||||
version = default;
|
version = default;
|
||||||
|
|
@ -146,10 +149,76 @@ public readonly partial record struct SemanticVersion : ISpanParsable<SemanticVe
|
||||||
return false;
|
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;
|
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);
|
version = new SemanticVersion(component[0], component[1], component[2], prerelease, metadata);
|
||||||
return true;
|
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