diff --git a/src/c#/GeneralUpdate.Core/Configuration/AppMetadataDiscoverer.cs b/src/c#/GeneralUpdate.Core/Configuration/AppMetadataDiscoverer.cs
index 1fbb8a81..25551403 100644
--- a/src/c#/GeneralUpdate.Core/Configuration/AppMetadataDiscoverer.cs
+++ b/src/c#/GeneralUpdate.Core/Configuration/AppMetadataDiscoverer.cs
@@ -30,7 +30,7 @@ public static void Discover(UpdateContext context)
// otherwise the default blocks the manifest value and causes issues:
// • MainAppName "Client" → can't find the real executable
// • UpdateAppName "Update.exe" → can't launch the upgrade process
- // • ClientVersion "1.0.0.0" → endless update loop (version never updates)
+ // • ClientVersion "1.0.0" → endless update loop (version never updates)
if (!string.IsNullOrWhiteSpace(manifest.MainAppName))
context.MainAppName = manifest.MainAppName;
if (!string.IsNullOrWhiteSpace(manifest.UpdateAppName))
diff --git a/src/c#/GeneralUpdate.Core/Configuration/UpdateConfiguration.cs b/src/c#/GeneralUpdate.Core/Configuration/UpdateConfiguration.cs
index 9661e60a..ec6eb15e 100644
--- a/src/c#/GeneralUpdate.Core/Configuration/UpdateConfiguration.cs
+++ b/src/c#/GeneralUpdate.Core/Configuration/UpdateConfiguration.cs
@@ -108,7 +108,7 @@ public abstract class UpdateConfiguration
/// Comparing ClientVersion against the latest version from the server determines whether the
/// main application needs updating ().
///
- public string ClientVersion { get; set; } = "1.0.0.0";
+ public string ClientVersion { get; set; } = "1.0.0";
///
/// A list of specific files to exclude from the update process.
diff --git a/src/c#/GeneralUpdate.Core/Configuration/UpdateRequestBuilder.cs b/src/c#/GeneralUpdate.Core/Configuration/UpdateRequestBuilder.cs
index 76e9620f..8549a745 100644
--- a/src/c#/GeneralUpdate.Core/Configuration/UpdateRequestBuilder.cs
+++ b/src/c#/GeneralUpdate.Core/Configuration/UpdateRequestBuilder.cs
@@ -82,7 +82,7 @@ public class UpdateRequestBuilder
/// "Token": "mytoken",
/// "Scheme": "https",
/// "MainAppName": "MyApp",
- /// "ClientVersion": "1.0.0.0"
+ /// "ClientVersion": "1.0.0"
/// }
///
///
diff --git a/src/c#/GeneralUpdate.Core/Configuration/VersionEntry.cs b/src/c#/GeneralUpdate.Core/Configuration/VersionEntry.cs
index ac558870..674b99b5 100644
--- a/src/c#/GeneralUpdate.Core/Configuration/VersionEntry.cs
+++ b/src/c#/GeneralUpdate.Core/Configuration/VersionEntry.cs
@@ -84,7 +84,7 @@ public class VersionEntry : VersionIdentity
public override string? Url { get; set; }
///
- /// The version number string of this version information (e.g., "1.0.0.1").
+ /// The version number string of this version information (e.g., "1.0.0").
///
[JsonPropertyName("version")]
public override string? Version { get; set; }
diff --git a/src/c#/GeneralUpdate.Core/Configuration/VersionIdentity.cs b/src/c#/GeneralUpdate.Core/Configuration/VersionIdentity.cs
index 00f6847c..f87509fb 100644
--- a/src/c#/GeneralUpdate.Core/Configuration/VersionIdentity.cs
+++ b/src/c#/GeneralUpdate.Core/Configuration/VersionIdentity.cs
@@ -22,7 +22,7 @@ public abstract class VersionIdentity
/// Download URL of the update package.
public virtual string? Url { get; set; }
- /// Version number string (e.g., "1.0.0.1").
+ /// Version number string (e.g., "1.0.0").
public virtual string? Version { get; set; }
/// Application type identifier.
diff --git a/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs b/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs
index 291e7c0b..e2208fd0 100644
--- a/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs
+++ b/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs
@@ -3,6 +3,7 @@
using System.Linq;
using GeneralUpdate.Core.Configuration;
using GeneralUpdate.Core.Download.Models;
+using GeneralUpdate.Core.Utilities;
namespace GeneralUpdate.Core.Download;
@@ -66,6 +67,7 @@ public static bool HasUpdate(
.Where(a => (a.AppType ?? (int)AppType.Client) == (int)appType)
.Select(a => ParseVersion(a.Version))
.Where(v => v != null)
+ .Select(v => v!.Value)
.ToList();
if (serverVersions.Count == 0)
@@ -74,11 +76,11 @@ public static bool HasUpdate(
// If the local version cannot be read or parsed, we can't prove we're
// up to date — err on the side of updating rather than silently skipping.
if (string.IsNullOrWhiteSpace(localVersion)
- || !Version.TryParse(localVersion, out var local))
+ || !Semver.TryParse(localVersion, out var local))
return true;
// Compare: max server version > local version?
- return serverVersions.Max()! > local;
+ return serverVersions.Max() > local;
}
///
@@ -100,8 +102,10 @@ public static DownloadPlan Build(
if (assets == null) return DownloadPlan.Empty;
var parsedClient = ParseVersion(clientVersion);
if (parsedClient == null) return DownloadPlan.Empty;
+ var cv = parsedClient.Value;
var parsedUpgrade = ParseVersion(upgradeClientVersion) ?? parsedClient;
+ var uv = parsedUpgrade.Value;
// 1. Filter out frozen packages
var active = assets
@@ -118,17 +122,16 @@ public static DownloadPlan Build(
var candidates = active
.Where(a =>
{
- var pv = ParseVersion(a.Version);
- if (pv == null) return false;
+ if (!Semver.TryParse(a.Version, out var pv)) return false;
var localVersion = (a.AppType == (int)AppType.Upgrade)
- ? parsedUpgrade
- : parsedClient;
+ ? uv
+ : cv;
return pv > localVersion;
})
.Where(a => IsCompatible(a.MinClientVersion, clientVersion))
- .OrderBy(a => ParseVersion(a.Version))
+ .OrderBy(a => { Semver.TryParse(a.Version, out var sv); return sv; })
.ToList();
if (candidates.Count == 0) return DownloadPlan.Empty;
@@ -142,14 +145,13 @@ public static DownloadPlan Build(
.Where(a => a.IsCrossVersion)
.Where(a =>
{
- var fromVer = ParseVersion(a.FromVersion);
- if (fromVer == null) return false;
+ if (!Semver.TryParse(a.FromVersion, out var fromVer)) return false;
var localVersion = (a.AppType == (int)AppType.Upgrade)
- ? parsedUpgrade
- : parsedClient;
+ ? uv
+ : cv;
return fromVer == localVersion;
})
- .OrderByDescending(a => ParseVersion(a.Version))
+ .OrderByDescending(a => { Semver.TryParse(a.Version, out var sv); return sv; })
.FirstOrDefault();
if (matchingCvp != null)
@@ -157,13 +159,13 @@ public static DownloadPlan Build(
// CVP covers one AppType in a single hop. Still need chain packages
// for other AppTypes, and for the same AppType beyond the CVP's target.
var cvpAppType = matchingCvp.AppType;
- var cvpVersion = ParseVersion(matchingCvp.Version);
+ Semver.TryParse(matchingCvp.Version, out var cvpVersion);
var planAssets = new List { matchingCvp };
planAssets.AddRange(candidates
.Where(a => !a.IsCrossVersion)
.Where(a => a.AppType != cvpAppType
- || (cvpVersion != null && ParseVersion(a.Version) > cvpVersion))
- .OrderBy(a => ParseVersion(a.Version)));
+ || (Semver.TryParse(a.Version, out var av) && av > cvpVersion))
+ .OrderBy(a => { Semver.TryParse(a.Version, out var sv); return sv; }));
return new DownloadPlan(planAssets, isForcibly);
}
@@ -197,15 +199,15 @@ internal static bool IsCompatible(string? minClientVersion, string currentVersio
var min = ParseVersion(minClientVersion);
var cur = ParseVersion(currentVersion);
if (min == null || cur == null) return true;
- return cur >= min;
+ return cur.Value >= min.Value;
}
/// Parses a version string and returns null if the string cannot be parsed.
/// The version string to parse.
- /// A parsed object, or null if parsing fails.
- internal static Version? ParseVersion(string? version)
+ /// A parsed value, or null if parsing fails.
+ internal static SemVersion? ParseVersion(string? version)
{
if (string.IsNullOrWhiteSpace(version)) return null;
- return Version.TryParse(version, out var v) ? v : null;
+ return Semver.TryParse(version, out var v) ? v : null;
}
}
diff --git a/src/c#/GeneralUpdate.Core/Strategy/ClientStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/ClientStrategy.cs
index 2b9efceb..e89a211d 100644
--- a/src/c#/GeneralUpdate.Core/Strategy/ClientStrategy.cs
+++ b/src/c#/GeneralUpdate.Core/Strategy/ClientStrategy.cs
@@ -13,6 +13,7 @@
using GeneralUpdate.Core.JsonContext;
using GeneralUpdate.Core.Ipc;
using GeneralUpdate.Core.Pipeline;
+using GeneralUpdate.Core.Utilities;
namespace GeneralUpdate.Core.Strategy;
@@ -943,7 +944,7 @@ private bool CanSkip(bool isForcibly, UpdateInfoEventArgs updateInfo)
///
/// Reads the known failed version number from the UpgradeFail environment variable.
/// If the UpgradeFail environment variable is empty or is empty, returns false.
- /// Version comparison uses the semantic version comparison of the class.
+ /// Version comparison uses the semantic version comparison of the class.
/// This mechanism avoids repeatedly attempting known failed upgrades.
///
private bool CheckFail(string version)
@@ -951,8 +952,8 @@ private bool CheckFail(string version)
var fail = Environments.GetEnvironmentVariable("UpgradeFail");
if (string.IsNullOrEmpty(fail) || string.IsNullOrEmpty(version))
return false;
- if (!Version.TryParse(fail, out var failVersion) ||
- !Version.TryParse(version, out var versionParsed))
+ if (!Semver.TryParse(fail, out var failVersion) ||
+ !Semver.TryParse(version, out var versionParsed))
return false;
return failVersion >= versionParsed;
}
diff --git a/src/c#/GeneralUpdate.Core/Strategy/OssStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/OssStrategy.cs
index d6b03bd3..cde6a1de 100644
--- a/src/c#/GeneralUpdate.Core/Strategy/OssStrategy.cs
+++ b/src/c#/GeneralUpdate.Core/Strategy/OssStrategy.cs
@@ -12,6 +12,7 @@
using GeneralUpdate.Core.Download.Abstractions;
using GeneralUpdate.Core.Download.Models;
using GeneralUpdate.Core.Download.Orchestrators;
+using GeneralUpdate.Core.Utilities;
namespace GeneralUpdate.Core.Strategy;
@@ -350,7 +351,12 @@ private async Task ExecuteUpgradeAsync()
throw new InvalidOperationException("No versions found in Oss configuration.");
assets = versions.OrderBy(v => v.PubTime)
- .Where(v => new Version(v.Version ?? "0.0.0") > new Version(_configInfo.ClientVersion))
+ .Where(v =>
+ {
+ var sv = ParseSemVer(v.Version);
+ var cv = ParseSemVer(_configInfo.ClientVersion);
+ return sv != null && cv != null && sv.Value > cv.Value;
+ })
.Select(v =>
{
if (string.IsNullOrWhiteSpace(v.Url))
@@ -366,12 +372,17 @@ private async Task ExecuteUpgradeAsync()
if (assets.Count == 0)
throw new InvalidOperationException("No assets to download.");
- // Compute LastVersion deterministically via Version comparison
+ // Compute LastVersion deterministically via SemVer comparison
// so hooks see the correct TargetVersion regardless of source ordering.
- _configInfo.LastVersion = assets
- .Select(a => new Version(a.Version))
- .Max()!
- .ToString();
+ // Guard: if ALL versions are unparseable, fall back to "0.0.0".
+ var parsedVersions = assets
+ .Select(a => Semver.TryParse(a.Version, out var sv) ? (SemVersion?)sv : null)
+ .Where(sv => sv != null)
+ .Select(sv => sv!.Value)
+ .ToList();
+ _configInfo.LastVersion = parsedVersions.Count > 0
+ ? parsedVersions.Max().ToString()
+ : "0.0.0";
ctx = BuildUpdateContext();
// Hooks: allow cancellation before download. Called after assets are
@@ -483,15 +494,15 @@ private static async Task DownloadVersionConfig(string url, string path)
/// The latest server version string.
/// Returns true if the server version is higher than the client version; otherwise false.
///
- /// This method attempts to parse both version strings as Version types for comparison.
+ /// This method attempts to parse both version strings as SemVer 2.0 for comparison.
/// If either version string is null, empty, or cannot be parsed, returns false indicating no upgrade is needed.
///
private static bool IsOssUpgrade(string clientVersion, string serverVersion)
{
if (string.IsNullOrWhiteSpace(clientVersion) || string.IsNullOrWhiteSpace(serverVersion))
return false;
- return Version.TryParse(clientVersion, out var cv)
- && Version.TryParse(serverVersion, out var sv)
+ return Semver.TryParse(clientVersion, out var cv)
+ && Semver.TryParse(serverVersion, out var sv)
&& cv < sv;
}
@@ -655,5 +666,13 @@ private async Task SafeReportUpdateFailedAsync(Hooks.HookContext ctx, Exception
catch (Exception ex) { GeneralTracer.Warn($"Report UpdateFailed failed: {ex.Message}"); }
}
+ ///
+ /// Lightweight SemVer parse guard that returns null (not an exception) for unparseable strings.
+ ///
+ private static SemVersion? ParseSemVer(string? version)
+ {
+ return Semver.TryParse(version, out var sv) ? sv : null;
+ }
+
#endregion
}
diff --git a/src/c#/GeneralUpdate.Core/Utilities/Semver.cs b/src/c#/GeneralUpdate.Core/Utilities/Semver.cs
new file mode 100644
index 00000000..a9d9c6af
--- /dev/null
+++ b/src/c#/GeneralUpdate.Core/Utilities/Semver.cs
@@ -0,0 +1,358 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace GeneralUpdate.Core.Utilities;
+
+///
+/// SemVer 2.0 utility — validation, parsing, comparison, normalization, and equality.
+/// Aligned with GeneralUpdate.Infrastructure.Common/Utilitys/Semver.cs.
+///
+public static class Semver
+{
+ private static readonly Regex SemverRegex = new(
+ @"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" +
+ @"(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?" +
+ @"(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ private static readonly Regex Legacy4PartRegex = new(
+ @"^(\d+)\.(\d+)\.(\d+)\.(\d+)$",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ ///
+ /// Returns true when is a valid SemVer 2.0 string
+ /// (or a legacy 4-part version that can be normalized to SemVer).
+ ///
+ public static bool IsValid(string? version)
+ {
+ if (version == null) return false;
+ var trimmed = version.Trim();
+ if (trimmed.Length == 0) return false;
+ if (SysNumOverflow(trimmed)) return false;
+ if (Legacy4PartRegex.IsMatch(trimmed)) return true;
+ if (!SemverRegex.IsMatch(trimmed)) return false;
+ return !HasLeadingZeroPreRelease(trimmed);
+ }
+
+ ///
+ /// Tries to parse into a .
+ /// Legacy 4-part versions (e.g. "1.0.0.0") are normalized on input.
+ ///
+ public static bool TryParse(string? version, out SemVersion result)
+ {
+ result = default;
+ if (version == null) return false;
+ var trimmed = version.Trim();
+ if (trimmed.Length == 0) return false;
+ if (SysNumOverflow(trimmed)) return false;
+
+ if (Legacy4PartRegex.IsMatch(trimmed))
+ {
+ var normalized = Normalize(trimmed);
+ if (normalized == null) return false;
+ return TryParseCore(normalized, out result);
+ }
+
+ if (!SemverRegex.IsMatch(trimmed)) return false;
+ if (HasLeadingZeroPreRelease(trimmed)) return false;
+
+ return TryParseCore(trimmed, out result);
+ }
+
+ ///
+ /// Compares two version strings per SemVer 2.0 ordering rules.
+ /// Null/empty/invalid strings compare as -1 or 1 as appropriate.
+ ///
+ public static int Compare(string? a, string? b)
+ {
+ if (string.IsNullOrWhiteSpace(a) && string.IsNullOrWhiteSpace(b)) return 0;
+ if (string.IsNullOrWhiteSpace(a)) return -1;
+ if (string.IsNullOrWhiteSpace(b)) return 1;
+
+ if (!TryParse(a, out var parsedA) || !TryParse(b, out var parsedB)) return 0;
+
+ if (parsedA.Major != parsedB.Major) return parsedA.Major.CompareTo(parsedB.Major);
+ if (parsedA.Minor != parsedB.Minor) return parsedA.Minor.CompareTo(parsedB.Minor);
+ if (parsedA.Patch != parsedB.Patch) return parsedA.Patch.CompareTo(parsedB.Patch);
+
+ var aHasPre = !string.IsNullOrEmpty(parsedA.PreRelease);
+ var bHasPre = !string.IsNullOrEmpty(parsedB.PreRelease);
+ if (aHasPre != bHasPre) return aHasPre ? -1 : 1;
+ if (!aHasPre) return 0;
+
+ return ComparePreReleaseIdentifiers(parsedA.PreRelease, parsedB.PreRelease);
+ }
+
+ ///
+ /// Equality comparison ignoring build metadata (per SemVer 2.0 spec).
+ /// Returns false when either input is not a valid version.
+ ///
+ public static bool Equals(string? a, string? b)
+ {
+ if (!IsValid(a) || !IsValid(b)) return false;
+ return Compare(a, b) == 0;
+ }
+
+ ///
+ /// Normalize a version string to canonical SemVer 2.0 format.
+ /// - "1.0.0.0" -> "1.0.0"
+ /// - "1.0.0-alpha+build" -> "1.0.0-alpha"
+ /// - Unparseable/whitespace -> null
+ ///
+ public static string? Normalize(string? version)
+ {
+ if (version == null) return null;
+ var trimmed = version.Trim();
+ if (trimmed.Length == 0) return null;
+ if (SysNumOverflow(trimmed)) return null;
+
+ var match4 = Legacy4PartRegex.Match(trimmed);
+ if (match4.Success)
+ {
+ if (!int.TryParse(match4.Groups[1].Value, out var m1)) return null;
+ if (!int.TryParse(match4.Groups[2].Value, out var m2)) return null;
+ if (!int.TryParse(match4.Groups[3].Value, out var m3)) return null;
+ return $"{m1}.{m2}.{m3}";
+ }
+
+ if (!SemverRegex.IsMatch(trimmed)) return null;
+ if (HasLeadingZeroPreRelease(trimmed)) return null;
+
+ var match = SemverRegex.Match(trimmed);
+ if (!match.Success) return null;
+
+ if (!int.TryParse(match.Groups[1].Value, out var major)) return null;
+ if (!int.TryParse(match.Groups[2].Value, out var minor)) return null;
+ if (!int.TryParse(match.Groups[3].Value, out var patch)) return null;
+ var preRelease = match.Groups[4].Success ? match.Groups[4].Value : string.Empty;
+
+ return $"{major}.{minor}.{patch}{preRelease}";
+ }
+
+ private static bool TryParseCore(string version, out SemVersion result)
+ {
+ result = default;
+ var match = SemverRegex.Match(version);
+ if (!match.Success) return false;
+
+ if (!int.TryParse(match.Groups[1].Value, out var major)) return false;
+ if (!int.TryParse(match.Groups[2].Value, out var minor)) return false;
+ if (!int.TryParse(match.Groups[3].Value, out var patch)) return false;
+ var preRelease = match.Groups[4].Success ? match.Groups[4].Value.TrimStart('-') : string.Empty;
+ var build = match.Groups[5].Success ? match.Groups[5].Value.TrimStart('+') : string.Empty;
+
+ result = new SemVersion(major, minor, patch, preRelease, build);
+ return true;
+ }
+
+ private static bool HasLeadingZeroPreRelease(string input)
+ {
+ var dashIdx = input.IndexOf('-');
+ if (dashIdx < 0) return false;
+
+ var pre = dashIdx + 1 < input.Length ? input.Substring(dashIdx + 1) : string.Empty;
+ var plusIdx = pre.IndexOf('+');
+ if (plusIdx >= 0) pre = pre.Substring(0, plusIdx);
+
+ if (pre.Length == 0) return false;
+
+ var parts = pre.Split('.');
+ foreach (var part in parts)
+ {
+ if (part.Length > 1 && part[0] == '0' && part.All(c => c >= '0' && c <= '9'))
+ return true;
+ }
+ return false;
+ }
+
+ private static bool SysNumOverflow(string version)
+ {
+ // Only check the MAJOR.MINOR.PATCH triplet — prerelease and
+ // build metadata identifiers may be longer (e.g. timestamps).
+ var dashIdx = version.IndexOfAny(new[] { '-', '+' });
+ var core = dashIdx >= 0 ? version.Substring(0, dashIdx) : version;
+ var tokens = core.Split('.');
+ foreach (var token in tokens)
+ {
+ if (token.Length == 0) continue;
+ if (token.All(c => c >= '0' && c <= '9'))
+ {
+ if (token.Length > 10) return true;
+ if (token.Length == 10 && token.CompareTo("2147483647") > 0) return true;
+ }
+ }
+ return false;
+ }
+
+ private static int ComparePreReleaseIdentifiers(string preA, string preB)
+ {
+ var idsA = preA.Split('.');
+ var idsB = preB.Split('.');
+ var minLen = Math.Min(idsA.Length, idsB.Length);
+
+ for (var i = 0; i < minLen; i++)
+ {
+ var result = ComparePreReleaseId(idsA[i], idsB[i]);
+ if (result != 0) return result;
+ }
+
+ return idsA.Length.CompareTo(idsB.Length);
+ }
+
+ private static int ComparePreReleaseId(string idA, string idB)
+ {
+ var aIsNumeric = long.TryParse(idA, out var numA);
+ var bIsNumeric = long.TryParse(idB, out var numB);
+
+ if (aIsNumeric && bIsNumeric) return numA.CompareTo(numB);
+ if (aIsNumeric) return -1;
+ if (bIsNumeric) return 1;
+ return string.Compare(idA, idB, StringComparison.Ordinal);
+ }
+}
+
+///
+/// Represents a parsed SemVer 2.0 version with value equality and comparison operators.
+/// Immutable value type — safe for use as a sorting/comparison key.
+///
+public readonly struct SemVersion : IComparable, IEquatable
+{
+ /// Major version component (MAJOR in MAJOR.MINOR.PATCH).
+ public int Major { get; }
+
+ /// Minor version component (MINOR in MAJOR.MINOR.PATCH).
+ public int Minor { get; }
+
+ /// Patch version component (PATCH in MAJOR.MINOR.PATCH).
+ public int Patch { get; }
+
+ /// Pre-release identifier (e.g., "beta.1"), or .
+ public string PreRelease { get; }
+
+ /// Build metadata identifier (e.g., "sha.abc"), or .
+ public string Build { get; }
+
+ ///
+ /// Initializes a new .
+ /// All parameters should be validated before construction — use .
+ ///
+ internal SemVersion(int major, int minor, int patch, string preRelease, string build)
+ {
+ Major = major;
+ Minor = minor;
+ Patch = patch;
+ PreRelease = preRelease ?? string.Empty;
+ Build = build ?? string.Empty;
+ }
+
+ ///
+ /// Returns the canonical SemVer 2.0 string representation (e.g., "1.0.0", "1.0.0-beta.1").
+ /// Build metadata is not included by default (per spec).
+ ///
+ public override string ToString()
+ {
+ var pre = string.IsNullOrEmpty(PreRelease) ? string.Empty : $"-{PreRelease}";
+ return $"{Major}.{Minor}.{Patch}{pre}";
+ }
+
+ #region IComparable
+
+ public int CompareTo(SemVersion other)
+ {
+ // Same ordering as Semver.Compare
+ if (Major != other.Major) return Major.CompareTo(other.Major);
+ if (Minor != other.Minor) return Minor.CompareTo(other.Minor);
+ if (Patch != other.Patch) return Patch.CompareTo(other.Patch);
+
+ var aHasPre = !string.IsNullOrEmpty(PreRelease);
+ var bHasPre = !string.IsNullOrEmpty(other.PreRelease);
+ if (aHasPre != bHasPre) return aHasPre ? -1 : 1;
+ if (!aHasPre) return 0;
+
+ return ComparePreReleaseIdentifiers(PreRelease, other.PreRelease);
+ }
+
+ #endregion
+
+ #region IEquatable
+
+ public bool Equals(SemVersion other)
+ {
+ return Major == other.Major
+ && Minor == other.Minor
+ && Patch == other.Patch
+ && string.Equals(PreRelease, other.PreRelease, StringComparison.Ordinal);
+ // Build metadata is ignored per SemVer 2.0 spec §10
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is SemVersion other && Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+ // Build metadata not included per SemVer 2.0 spec §10.
+ // Manual hash (avoids HashCode.Combine for netstandard2.0 compatibility).
+ unchecked
+ {
+ var hash = 17;
+ hash = hash * 31 + Major.GetHashCode();
+ hash = hash * 31 + Minor.GetHashCode();
+ hash = hash * 31 + Patch.GetHashCode();
+ hash = hash * 31 + (PreRelease?.GetHashCode() ?? 0);
+ return hash;
+ }
+ }
+
+ #endregion
+
+ #region Operators
+
+ public static bool operator >(SemVersion left, SemVersion right) => left.CompareTo(right) > 0;
+ public static bool operator <(SemVersion left, SemVersion right) => left.CompareTo(right) < 0;
+ public static bool operator >=(SemVersion left, SemVersion right) => left.CompareTo(right) >= 0;
+ public static bool operator <=(SemVersion left, SemVersion right) => left.CompareTo(right) <= 0;
+ public static bool operator ==(SemVersion left, SemVersion right) => left.Equals(right);
+ public static bool operator !=(SemVersion left, SemVersion right) => !left.Equals(right);
+
+ #endregion
+
+ private static int ComparePreReleaseIdentifiers(string preA, string preB)
+ {
+ var idsA = preA.Split('.');
+ var idsB = preB.Split('.');
+ var minLen = Math.Min(idsA.Length, idsB.Length);
+
+ for (var i = 0; i < minLen; i++)
+ {
+ var result = ComparePreReleaseId(idsA[i], idsB[i]);
+ if (result != 0) return result;
+ }
+
+ return idsA.Length.CompareTo(idsB.Length);
+ }
+
+ private static int ComparePreReleaseId(string idA, string idB)
+ {
+ var aIsNumeric = long.TryParse(idA, out var numA);
+ var bIsNumeric = long.TryParse(idB, out var numB);
+
+ if (aIsNumeric && bIsNumeric) return numA.CompareTo(numB);
+ if (aIsNumeric) return -1;
+ if (bIsNumeric) return 1;
+ return string.Compare(idA, idB, StringComparison.Ordinal);
+ }
+}
+
+///
+/// for sorting SemVer strings, delegates to .
+///
+public sealed class SemverComparer : IComparer
+{
+ public static readonly SemverComparer Instance = new();
+
+ public int Compare(string? x, string? y) => Semver.Compare(x, y);
+}
diff --git a/src/c#/GeneralUpdate.Drivelution/Core/Utilities/Semver.cs b/src/c#/GeneralUpdate.Drivelution/Core/Utilities/Semver.cs
new file mode 100644
index 00000000..37df8b40
--- /dev/null
+++ b/src/c#/GeneralUpdate.Drivelution/Core/Utilities/Semver.cs
@@ -0,0 +1,354 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace GeneralUpdate.Drivelution.Core.Utilities;
+
+///
+/// SemVer 2.0 utility — validation, parsing, comparison, normalization, and equality.
+/// Aligned with GeneralUpdate.Infrastructure.Common/Utilitys/Semver.cs.
+///
+public static class Semver
+{
+ private static readonly Regex SemverRegex = new(
+ @"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" +
+ @"(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?" +
+ @"(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ private static readonly Regex Legacy4PartRegex = new(
+ @"^(\d+)\.(\d+)\.(\d+)\.(\d+)$",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ ///
+ /// Returns true when is a valid SemVer 2.0 string
+ /// (or a legacy 4-part version that can be normalized to SemVer).
+ ///
+ public static bool IsValid(string? version)
+ {
+ if (version == null) return false;
+ var trimmed = version.Trim();
+ if (trimmed.Length == 0) return false;
+ if (SysNumOverflow(trimmed)) return false;
+ if (Legacy4PartRegex.IsMatch(trimmed)) return true;
+ if (!SemverRegex.IsMatch(trimmed)) return false;
+ return !HasLeadingZeroPreRelease(trimmed);
+ }
+
+ ///
+ /// Tries to parse into a .
+ /// Legacy 4-part versions (e.g. "1.0.0.0") are normalized on input.
+ ///
+ public static bool TryParse(string? version, out SemVersion result)
+ {
+ result = default;
+ if (version == null) return false;
+ var trimmed = version.Trim();
+ if (trimmed.Length == 0) return false;
+ if (SysNumOverflow(trimmed)) return false;
+
+ if (Legacy4PartRegex.IsMatch(trimmed))
+ {
+ var normalized = Normalize(trimmed);
+ if (normalized == null) return false;
+ return TryParseCore(normalized, out result);
+ }
+
+ if (!SemverRegex.IsMatch(trimmed)) return false;
+ if (HasLeadingZeroPreRelease(trimmed)) return false;
+
+ return TryParseCore(trimmed, out result);
+ }
+
+ ///
+ /// Compares two version strings per SemVer 2.0 ordering rules.
+ /// Null/empty/invalid strings compare as -1 or 1 as appropriate.
+ ///
+ public static int Compare(string? a, string? b)
+ {
+ if (string.IsNullOrWhiteSpace(a) && string.IsNullOrWhiteSpace(b)) return 0;
+ if (string.IsNullOrWhiteSpace(a)) return -1;
+ if (string.IsNullOrWhiteSpace(b)) return 1;
+
+ if (!TryParse(a, out var parsedA) || !TryParse(b, out var parsedB)) return 0;
+
+ if (parsedA.Major != parsedB.Major) return parsedA.Major.CompareTo(parsedB.Major);
+ if (parsedA.Minor != parsedB.Minor) return parsedA.Minor.CompareTo(parsedB.Minor);
+ if (parsedA.Patch != parsedB.Patch) return parsedA.Patch.CompareTo(parsedB.Patch);
+
+ var aHasPre = !string.IsNullOrEmpty(parsedA.PreRelease);
+ var bHasPre = !string.IsNullOrEmpty(parsedB.PreRelease);
+ if (aHasPre != bHasPre) return aHasPre ? -1 : 1;
+ if (!aHasPre) return 0;
+
+ return ComparePreReleaseIdentifiers(parsedA.PreRelease, parsedB.PreRelease);
+ }
+
+ ///
+ /// Equality comparison ignoring build metadata (per SemVer 2.0 spec).
+ /// Returns false when either input is not a valid version.
+ ///
+ public static bool Equals(string? a, string? b)
+ {
+ if (!IsValid(a) || !IsValid(b)) return false;
+ return Compare(a, b) == 0;
+ }
+
+ ///
+ /// Normalize a version string to canonical SemVer 2.0 format.
+ /// - "1.0.0.0" -> "1.0.0"
+ /// - "1.0.0-alpha+build" -> "1.0.0-alpha"
+ /// - Unparseable/whitespace -> null
+ ///
+ public static string? Normalize(string? version)
+ {
+ if (version == null) return null;
+ var trimmed = version.Trim();
+ if (trimmed.Length == 0) return null;
+ if (SysNumOverflow(trimmed)) return null;
+
+ var match4 = Legacy4PartRegex.Match(trimmed);
+ if (match4.Success)
+ {
+ if (!int.TryParse(match4.Groups[1].Value, out var m1)) return null;
+ if (!int.TryParse(match4.Groups[2].Value, out var m2)) return null;
+ if (!int.TryParse(match4.Groups[3].Value, out var m3)) return null;
+ return $"{m1}.{m2}.{m3}";
+ }
+
+ if (!SemverRegex.IsMatch(trimmed)) return null;
+ if (HasLeadingZeroPreRelease(trimmed)) return null;
+
+ var match = SemverRegex.Match(trimmed);
+ if (!match.Success) return null;
+
+ if (!int.TryParse(match.Groups[1].Value, out var major)) return null;
+ if (!int.TryParse(match.Groups[2].Value, out var minor)) return null;
+ if (!int.TryParse(match.Groups[3].Value, out var patch)) return null;
+ var preRelease = match.Groups[4].Success ? match.Groups[4].Value : string.Empty;
+
+ return $"{major}.{minor}.{patch}{preRelease}";
+ }
+
+ private static bool TryParseCore(string version, out SemVersion result)
+ {
+ result = default;
+ var match = SemverRegex.Match(version);
+ if (!match.Success) return false;
+
+ if (!int.TryParse(match.Groups[1].Value, out var major)) return false;
+ if (!int.TryParse(match.Groups[2].Value, out var minor)) return false;
+ if (!int.TryParse(match.Groups[3].Value, out var patch)) return false;
+ var preRelease = match.Groups[4].Success ? match.Groups[4].Value.TrimStart('-') : string.Empty;
+ var build = match.Groups[5].Success ? match.Groups[5].Value.TrimStart('+') : string.Empty;
+
+ result = new SemVersion(major, minor, patch, preRelease, build);
+ return true;
+ }
+
+ private static bool HasLeadingZeroPreRelease(string input)
+ {
+ var dashIdx = input.IndexOf('-');
+ if (dashIdx < 0) return false;
+
+ var pre = dashIdx + 1 < input.Length ? input.Substring(dashIdx + 1) : string.Empty;
+ var plusIdx = pre.IndexOf('+');
+ if (plusIdx >= 0) pre = pre.Substring(0, plusIdx);
+
+ if (pre.Length == 0) return false;
+
+ var parts = pre.Split('.');
+ foreach (var part in parts)
+ {
+ if (part.Length > 1 && part[0] == '0' && part.All(c => c >= '0' && c <= '9'))
+ return true;
+ }
+ return false;
+ }
+
+ private static bool SysNumOverflow(string version)
+ {
+ // Only check the MAJOR.MINOR.PATCH triplet — prerelease and
+ // build metadata identifiers may be longer (e.g. timestamps).
+ var dashIdx = version.IndexOfAny(new[] { '-', '+' });
+ var core = dashIdx >= 0 ? version.Substring(0, dashIdx) : version;
+ var tokens = core.Split('.');
+ foreach (var token in tokens)
+ {
+ if (token.Length == 0) continue;
+ if (token.All(c => c >= '0' && c <= '9'))
+ {
+ if (token.Length > 10) return true;
+ if (token.Length == 10 && token.CompareTo("2147483647") > 0) return true;
+ }
+ }
+ return false;
+ }
+
+ private static int ComparePreReleaseIdentifiers(string preA, string preB)
+ {
+ var idsA = preA.Split('.');
+ var idsB = preB.Split('.');
+ var minLen = Math.Min(idsA.Length, idsB.Length);
+
+ for (var i = 0; i < minLen; i++)
+ {
+ var result = ComparePreReleaseId(idsA[i], idsB[i]);
+ if (result != 0) return result;
+ }
+
+ return idsA.Length.CompareTo(idsB.Length);
+ }
+
+ private static int ComparePreReleaseId(string idA, string idB)
+ {
+ var aIsNumeric = long.TryParse(idA, out var numA);
+ var bIsNumeric = long.TryParse(idB, out var numB);
+
+ if (aIsNumeric && bIsNumeric) return numA.CompareTo(numB);
+ if (aIsNumeric) return -1;
+ if (bIsNumeric) return 1;
+ return string.Compare(idA, idB, StringComparison.Ordinal);
+ }
+}
+
+///
+/// Represents a parsed SemVer 2.0 version with value equality and comparison operators.
+/// Immutable value type — safe for use as a sorting/comparison key.
+///
+public readonly struct SemVersion : IComparable, IEquatable
+{
+ /// Major version component (MAJOR in MAJOR.MINOR.PATCH).
+ public int Major { get; }
+
+ /// Minor version component (MINOR in MAJOR.MINOR.PATCH).
+ public int Minor { get; }
+
+ /// Patch version component (PATCH in MAJOR.MINOR.PATCH).
+ public int Patch { get; }
+
+ /// Pre-release identifier (e.g., "beta.1"), or .
+ public string PreRelease { get; }
+
+ /// Build metadata identifier (e.g., "sha.abc"), or .
+ public string Build { get; }
+
+ ///
+ /// Initializes a new .
+ /// All parameters should be validated before construction — use .
+ ///
+ internal SemVersion(int major, int minor, int patch, string preRelease, string build)
+ {
+ Major = major;
+ Minor = minor;
+ Patch = patch;
+ PreRelease = preRelease ?? string.Empty;
+ Build = build ?? string.Empty;
+ }
+
+ ///
+ /// Returns the canonical SemVer 2.0 string representation (e.g., "1.0.0", "1.0.0-beta.1").
+ /// Build metadata is not included by default (per spec).
+ ///
+ public override string ToString()
+ {
+ var pre = string.IsNullOrEmpty(PreRelease) ? string.Empty : $"-{PreRelease}";
+ return $"{Major}.{Minor}.{Patch}{pre}";
+ }
+
+ #region IComparable
+
+ public int CompareTo(SemVersion other)
+ {
+ if (Major != other.Major) return Major.CompareTo(other.Major);
+ if (Minor != other.Minor) return Minor.CompareTo(other.Minor);
+ if (Patch != other.Patch) return Patch.CompareTo(other.Patch);
+
+ var aHasPre = !string.IsNullOrEmpty(PreRelease);
+ var bHasPre = !string.IsNullOrEmpty(other.PreRelease);
+ if (aHasPre != bHasPre) return aHasPre ? -1 : 1;
+ if (!aHasPre) return 0;
+
+ return ComparePreReleaseIdentifiers(PreRelease, other.PreRelease);
+ }
+
+ #endregion
+
+ #region IEquatable
+
+ public bool Equals(SemVersion other)
+ {
+ return Major == other.Major
+ && Minor == other.Minor
+ && Patch == other.Patch
+ && string.Equals(PreRelease, other.PreRelease, StringComparison.Ordinal);
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is SemVersion other && Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+ unchecked
+ {
+ var hash = 17;
+ hash = hash * 31 + Major.GetHashCode();
+ hash = hash * 31 + Minor.GetHashCode();
+ hash = hash * 31 + Patch.GetHashCode();
+ hash = hash * 31 + (PreRelease?.GetHashCode() ?? 0);
+ return hash;
+ }
+ }
+
+ #endregion
+
+ #region Operators
+
+ public static bool operator >(SemVersion left, SemVersion right) => left.CompareTo(right) > 0;
+ public static bool operator <(SemVersion left, SemVersion right) => left.CompareTo(right) < 0;
+ public static bool operator >=(SemVersion left, SemVersion right) => left.CompareTo(right) >= 0;
+ public static bool operator <=(SemVersion left, SemVersion right) => left.CompareTo(right) <= 0;
+ public static bool operator ==(SemVersion left, SemVersion right) => left.Equals(right);
+ public static bool operator !=(SemVersion left, SemVersion right) => !left.Equals(right);
+
+ #endregion
+
+ private static int ComparePreReleaseIdentifiers(string preA, string preB)
+ {
+ var idsA = preA.Split('.');
+ var idsB = preB.Split('.');
+ var minLen = Math.Min(idsA.Length, idsB.Length);
+
+ for (var i = 0; i < minLen; i++)
+ {
+ var result = ComparePreReleaseId(idsA[i], idsB[i]);
+ if (result != 0) return result;
+ }
+
+ return idsA.Length.CompareTo(idsB.Length);
+ }
+
+ private static int ComparePreReleaseId(string idA, string idB)
+ {
+ var aIsNumeric = long.TryParse(idA, out var numA);
+ var bIsNumeric = long.TryParse(idB, out var numB);
+
+ if (aIsNumeric && bIsNumeric) return numA.CompareTo(numB);
+ if (aIsNumeric) return -1;
+ if (bIsNumeric) return 1;
+ return string.Compare(idA, idB, StringComparison.Ordinal);
+ }
+}
+
+///
+/// for sorting SemVer strings, delegates to .
+///
+public sealed class SemverComparer : IComparer
+{
+ public static readonly SemverComparer Instance = new();
+
+ public int Compare(string? x, string? y) => Semver.Compare(x, y);
+}
diff --git a/src/c#/GeneralUpdate.Drivelution/Core/Utilities/VersionComparer.cs b/src/c#/GeneralUpdate.Drivelution/Core/Utilities/VersionComparer.cs
index 1b01f445..e85bef0f 100644
--- a/src/c#/GeneralUpdate.Drivelution/Core/Utilities/VersionComparer.cs
+++ b/src/c#/GeneralUpdate.Drivelution/Core/Utilities/VersionComparer.cs
@@ -1,206 +1,38 @@
-using System.Text.RegularExpressions;
+using System;
namespace GeneralUpdate.Drivelution.Core.Utilities;
///
-/// 版本比较工具类(遵循SemVer 2.0规范)
-/// Version comparison utility (follows SemVer 2.0 specification)
+/// Version comparison utility (follows SemVer 2.0 specification).
+/// Delegates to for all operations.
///
+[Obsolete("Use GeneralUpdate.Drivelution.Core.Utilities.Semver directly instead.")]
public static class VersionComparer
{
- private static readonly Regex SemVerRegex = new(
- @"^(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$",
- RegexOptions.Compiled);
-
///
- /// 比较两个版本号
- /// Compares two version numbers
+ /// Compares two version numbers per SemVer 2.0.
+ /// Returns 1 if v1 > v2, 0 if equal, -1 if v1 < v2.
///
- /// 版本1 / Version 1
- /// 版本2 / Version 2
- ///
- /// 如果version1 > version2,返回1;
- /// 如果version1 = version2,返回0;
- /// 如果version1 < version2,返回-1
- /// Returns 1 if version1 > version2, 0 if equal, -1 if version1 < version2
- ///
public static int Compare(string version1, string version2)
- {
- if (string.IsNullOrWhiteSpace(version1) || string.IsNullOrWhiteSpace(version2))
- {
- throw new ArgumentException("Version strings cannot be null or empty");
- }
-
- var v1 = ParseVersion(version1);
- var v2 = ParseVersion(version2);
-
- // Compare major, minor, patch
- if (v1.Major != v2.Major)
- return v1.Major.CompareTo(v2.Major);
- if (v1.Minor != v2.Minor)
- return v1.Minor.CompareTo(v2.Minor);
- if (v1.Patch != v2.Patch)
- return v1.Patch.CompareTo(v2.Patch);
-
- // If both have no prerelease, they are equal
- if (string.IsNullOrEmpty(v1.Prerelease) && string.IsNullOrEmpty(v2.Prerelease))
- return 0;
+ => Semver.Compare(version1, version2);
- // Version without prerelease is greater than version with prerelease
- if (string.IsNullOrEmpty(v1.Prerelease))
- return 1;
- if (string.IsNullOrEmpty(v2.Prerelease))
- return -1;
-
- // Compare prerelease identifiers
- return ComparePrerelease(v1.Prerelease, v2.Prerelease);
- }
-
- ///
- /// 判断version1是否大于version2
- /// Checks if version1 is greater than version2
- ///
+ /// Returns true if version1 > version2.
public static bool IsGreaterThan(string version1, string version2)
- {
- return Compare(version1, version2) > 0;
- }
+ => Semver.Compare(version1, version2) > 0;
- ///
- /// 判断version1是否小于version2
- /// Checks if version1 is less than version2
- ///
+ /// Returns true if version1 < version2.
public static bool IsLessThan(string version1, string version2)
- {
- return Compare(version1, version2) < 0;
- }
+ => Semver.Compare(version1, version2) < 0;
- ///
- /// 判断version1是否等于version2
- /// Checks if version1 equals version2
- ///
+ /// Returns true if version1 == version2.
public static bool IsEqual(string version1, string version2)
- {
- return Compare(version1, version2) == 0;
- }
+ => Semver.Compare(version1, version2) == 0;
///
- /// 验证版本号是否符合SemVer 2.0规范
- /// Validates if version string follows SemVer 2.0 specification
+ /// Validates whether follows SemVer 2.0.
+ /// Note: unlike the original implementation, this also accepts legacy
+ /// 4-part versions (e.g. "1.0.0.0") for broader compatibility.
///
public static bool IsValidSemVer(string version)
- {
- if (string.IsNullOrWhiteSpace(version))
- return false;
-
- return SemVerRegex.IsMatch(version);
- }
-
- private static SemVerInfo ParseVersion(string version)
- {
- var match = SemVerRegex.Match(version);
- if (!match.Success)
- {
- throw new FormatException($"Version '{version}' does not follow SemVer 2.0 format");
- }
-
- if (!TryParseNumeric(match.Groups["major"].Value, out var major))
- throw new FormatException($"Version '{version}' has a 'major' component that exceeds the supported range.");
- if (!TryParseNumeric(match.Groups["minor"].Value, out var minor))
- throw new FormatException($"Version '{version}' has a 'minor' component that exceeds the supported range.");
- if (!TryParseNumeric(match.Groups["patch"].Value, out var patch))
- throw new FormatException($"Version '{version}' has a 'patch' component that exceeds the supported range.");
-
- return new SemVerInfo
- {
- Major = major,
- Minor = minor,
- Patch = patch,
- Prerelease = match.Groups["prerelease"].Value,
- BuildMetadata = match.Groups["buildmetadata"].Value
- };
- }
-
- private static bool TryParseNumeric(string value, out long result)
- {
- // SemVer numeric components are non-negative integers per spec.
- // long.MaxValue (≈9.2e18) is the largest we support.
- if (value.Length > 19) // any value > long.MaxValue has at least 19 digits
- {
- result = 0;
- return false;
- }
- return long.TryParse(value, out result);
- }
-
- private static int ComparePrerelease(string pre1, string pre2)
- {
- var parts1 = pre1.Split('.');
- var parts2 = pre2.Split('.');
-
- int minLength = Math.Min(parts1.Length, parts2.Length);
-
- for (int i = 0; i < minLength; i++)
- {
- // Test numeric identifiers — if both parse, compare numerically.
- // Per SemVer 2.0 §11, numeric prerelease identifiers are compared
- // as integers. If an identifier exceeds long.MaxValue, fall back
- // to a digit-length + ordinal comparison so the ordering remains
- // integer-like even when the platform type can't hold the value.
- bool isNum1 = TryParseNumeric(parts1[i], out long num1);
- bool isNum2 = TryParseNumeric(parts2[i], out long num2);
-
- if (isNum1 && isNum2)
- {
- if (num1 != num2)
- return num1.CompareTo(num2);
- }
- else if (IsNumericString(parts1[i]) && IsNumericString(parts2[i]))
- {
- // Both are purely numeric but exceed long.MaxValue.
- // Compare by digit length first, then ordinal.
- int cmp = parts1[i].Length.CompareTo(parts2[i].Length);
- if (cmp != 0) return cmp;
- return string.CompareOrdinal(parts1[i], parts2[i]);
- }
- else if (isNum1)
- {
- return -1; // Numeric identifier is less than alphanumeric
- }
- else if (isNum2)
- {
- return 1; // Alphanumeric is greater than numeric
- }
- else
- {
- int stringCompare = string.CompareOrdinal(parts1[i], parts2[i]);
- if (stringCompare != 0)
- return stringCompare;
- }
- }
-
- // Longer prerelease is greater
- return parts1.Length.CompareTo(parts2.Length);
- }
-
- ///
- /// Returns true when is non-empty and every character
- /// is an ASCII digit — without attempting to parse into a numeric type.
- ///
- private static bool IsNumericString(string s)
- {
- if (string.IsNullOrEmpty(s)) return false;
- foreach (char c in s)
- if (c < '0' || c > '9')
- return false;
- return true;
- }
-
- private class SemVerInfo
- {
- public long Major { get; set; }
- public long Minor { get; set; }
- public long Patch { get; set; }
- public string Prerelease { get; set; } = string.Empty;
- public string BuildMetadata { get; set; } = string.Empty;
- }
+ => Semver.IsValid(version);
}
diff --git a/src/c#/GeneralUpdate.Extension/Compatibility/VersionCompatibilityChecker.cs b/src/c#/GeneralUpdate.Extension/Compatibility/VersionCompatibilityChecker.cs
index a9e8651c..d4fbc9c1 100644
--- a/src/c#/GeneralUpdate.Extension/Compatibility/VersionCompatibilityChecker.cs
+++ b/src/c#/GeneralUpdate.Extension/Compatibility/VersionCompatibilityChecker.cs
@@ -5,6 +5,7 @@
using GeneralUpdate.Extension.Dependencies;
using GeneralUpdate.Extension.Communication;
using GeneralUpdate.Extension.Common.Models;
+using GeneralUpdate.Extension.Utilities;
using System;
using System.Collections.Generic;
@@ -16,7 +17,7 @@
namespace GeneralUpdate.Extension.Compatibility;
///
-/// Version compatibility checker
+/// Version compatibility checker — uses SemVer 2.0 for all comparisons.
///
public class VersionCompatibilityChecker : IVersionCompatibilityChecker
{
@@ -28,7 +29,7 @@ public bool IsCompatible(ExtensionMetadata extension, string hostVersion)
return true; // No version constraint
}
- if (!Version.TryParse(hostVersion, out var host))
+ if (!Semver.TryParse(hostVersion, out var host))
{
return false; // Invalid host version
}
@@ -36,7 +37,7 @@ public bool IsCompatible(ExtensionMetadata extension, string hostVersion)
// Check minimum version
if (!string.IsNullOrWhiteSpace(extension.MinHostVersion))
{
- if (!Version.TryParse(extension.MinHostVersion, out var minVersion))
+ if (!Semver.TryParse(extension.MinHostVersion, out var minVersion))
{
return false;
}
@@ -50,7 +51,7 @@ public bool IsCompatible(ExtensionMetadata extension, string hostVersion)
// Check maximum version
if (!string.IsNullOrWhiteSpace(extension.MaxHostVersion))
{
- if (!Version.TryParse(extension.MaxHostVersion, out var maxVersion))
+ if (!Semver.TryParse(extension.MaxHostVersion, out var maxVersion))
{
return false;
}
@@ -77,16 +78,18 @@ public bool IsCompatible(ExtensionMetadata extension, string hostVersion)
{
Extension = e,
IsCompatible = IsCompatible(e, hostVersion),
- HasValidVersion = Version.TryParse(e.Version, out var v),
+ HasValidVersion = Semver.TryParse(e.Version, out var v),
ParsedVersion = v
})
.ToList();
- // Filter to compatible only, then sort: valid versions descending, then unknown versions
+ // Filter to compatible only, then sort: valid versions descending, then unknown versions.
+ // Use Semver.Compare for proper SemVer 2.0 ordering with prerelease support.
var best = parsed
.Where(x => x.IsCompatible)
.OrderBy(x => x.HasValidVersion ? 0 : 1) // valid versions first
- .ThenByDescending(x => x.HasValidVersion ? x.ParsedVersion : null) // highest version first
+ .ThenByDescending(x => x.HasValidVersion ? x.ParsedVersion.ToString() : null,
+ SemverComparer.Instance) // highest version first
.FirstOrDefault();
return best?.Extension;
diff --git a/src/c#/GeneralUpdate.Extension/Examples/ExtensionExample.cs b/src/c#/GeneralUpdate.Extension/Examples/ExtensionExample.cs
index 9bd5585f..73bbb07d 100644
--- a/src/c#/GeneralUpdate.Extension/Examples/ExtensionExample.cs
+++ b/src/c#/GeneralUpdate.Extension/Examples/ExtensionExample.cs
@@ -26,7 +26,7 @@ public static async Task RunExample()
ServerUrl = "http://127.0.0.1:7391/Extension",
//Scheme = "Bearer",
//Token = "your-token-here",
- HostVersion = "1.0.0.0",
+ HostVersion = "1.0.0",
ExtensionsDirectory = "./extensions"
};
diff --git a/src/c#/GeneralUpdate.Extension/Utilities/Semver.cs b/src/c#/GeneralUpdate.Extension/Utilities/Semver.cs
new file mode 100644
index 00000000..1db85a5b
--- /dev/null
+++ b/src/c#/GeneralUpdate.Extension/Utilities/Semver.cs
@@ -0,0 +1,355 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace GeneralUpdate.Extension.Utilities;
+
+///
+/// SemVer 2.0 utility — validation, parsing, comparison, normalization, and equality.
+/// Aligned with GeneralUpdate.Infrastructure.Common/Utilitys/Semver.cs.
+///
+public static class Semver
+{
+ private static readonly Regex SemverRegex = new(
+ @"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" +
+ @"(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?" +
+ @"(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ private static readonly Regex Legacy4PartRegex = new(
+ @"^(\d+)\.(\d+)\.(\d+)\.(\d+)$",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ ///
+ /// Returns true when is a valid SemVer 2.0 string
+ /// (or a legacy 4-part version that can be normalized to SemVer).
+ ///
+ public static bool IsValid(string? version)
+ {
+ if (version == null) return false;
+ var trimmed = version.Trim();
+ if (trimmed.Length == 0) return false;
+ if (SysNumOverflow(trimmed)) return false;
+ if (Legacy4PartRegex.IsMatch(trimmed)) return true;
+ if (!SemverRegex.IsMatch(trimmed)) return false;
+ return !HasLeadingZeroPreRelease(trimmed);
+ }
+
+ ///
+ /// Tries to parse into a .
+ /// Legacy 4-part versions (e.g. "1.0.0.0") are normalized on input.
+ ///
+ public static bool TryParse(string? version, out SemVersion result)
+ {
+ result = default;
+ if (version == null) return false;
+ var trimmed = version.Trim();
+ if (trimmed.Length == 0) return false;
+ if (SysNumOverflow(trimmed)) return false;
+
+ if (Legacy4PartRegex.IsMatch(trimmed))
+ {
+ var normalized = Normalize(trimmed);
+ if (normalized == null) return false;
+ return TryParseCore(normalized, out result);
+ }
+
+ if (!SemverRegex.IsMatch(trimmed)) return false;
+ if (HasLeadingZeroPreRelease(trimmed)) return false;
+
+ return TryParseCore(trimmed, out result);
+ }
+
+ ///
+ /// Compares two version strings per SemVer 2.0 ordering rules.
+ /// Null/empty/invalid strings compare as -1 or 1 as appropriate.
+ ///
+ public static int Compare(string? a, string? b)
+ {
+ if (string.IsNullOrWhiteSpace(a) && string.IsNullOrWhiteSpace(b)) return 0;
+ if (string.IsNullOrWhiteSpace(a)) return -1;
+ if (string.IsNullOrWhiteSpace(b)) return 1;
+
+ if (!TryParse(a, out var parsedA) || !TryParse(b, out var parsedB)) return 0;
+
+ if (parsedA.Major != parsedB.Major) return parsedA.Major.CompareTo(parsedB.Major);
+ if (parsedA.Minor != parsedB.Minor) return parsedA.Minor.CompareTo(parsedB.Minor);
+ if (parsedA.Patch != parsedB.Patch) return parsedA.Patch.CompareTo(parsedB.Patch);
+
+ var aHasPre = !string.IsNullOrEmpty(parsedA.PreRelease);
+ var bHasPre = !string.IsNullOrEmpty(parsedB.PreRelease);
+ if (aHasPre != bHasPre) return aHasPre ? -1 : 1;
+ if (!aHasPre) return 0;
+
+ return ComparePreReleaseIdentifiers(parsedA.PreRelease, parsedB.PreRelease);
+ }
+
+ ///
+ /// Equality comparison ignoring build metadata (per SemVer 2.0 spec).
+ /// Returns false when either input is not a valid version.
+ ///
+ public static bool Equals(string? a, string? b)
+ {
+ if (!IsValid(a) || !IsValid(b)) return false;
+ return Compare(a, b) == 0;
+ }
+
+ ///
+ /// Normalize a version string to canonical SemVer 2.0 format.
+ /// - "1.0.0.0" -> "1.0.0"
+ /// - "1.0.0-alpha+build" -> "1.0.0-alpha"
+ /// - Unparseable/whitespace -> null
+ ///
+ public static string? Normalize(string? version)
+ {
+ if (version == null) return null;
+ var trimmed = version.Trim();
+ if (trimmed.Length == 0) return null;
+ if (SysNumOverflow(trimmed)) return null;
+
+ var match4 = Legacy4PartRegex.Match(trimmed);
+ if (match4.Success)
+ {
+ if (!int.TryParse(match4.Groups[1].Value, out var m1)) return null;
+ if (!int.TryParse(match4.Groups[2].Value, out var m2)) return null;
+ if (!int.TryParse(match4.Groups[3].Value, out var m3)) return null;
+ return $"{m1}.{m2}.{m3}";
+ }
+
+ if (!SemverRegex.IsMatch(trimmed)) return null;
+ if (HasLeadingZeroPreRelease(trimmed)) return null;
+
+ var match = SemverRegex.Match(trimmed);
+ if (!match.Success) return null;
+
+ if (!int.TryParse(match.Groups[1].Value, out var major)) return null;
+ if (!int.TryParse(match.Groups[2].Value, out var minor)) return null;
+ if (!int.TryParse(match.Groups[3].Value, out var patch)) return null;
+ var preRelease = match.Groups[4].Success ? match.Groups[4].Value : string.Empty;
+
+ return $"{major}.{minor}.{patch}{preRelease}";
+ }
+
+ private static bool TryParseCore(string version, out SemVersion result)
+ {
+ result = default;
+ var match = SemverRegex.Match(version);
+ if (!match.Success) return false;
+
+ if (!int.TryParse(match.Groups[1].Value, out var major)) return false;
+ if (!int.TryParse(match.Groups[2].Value, out var minor)) return false;
+ if (!int.TryParse(match.Groups[3].Value, out var patch)) return false;
+ var preRelease = match.Groups[4].Success ? match.Groups[4].Value.TrimStart('-') : string.Empty;
+ var build = match.Groups[5].Success ? match.Groups[5].Value.TrimStart('+') : string.Empty;
+
+ result = new SemVersion(major, minor, patch, preRelease, build);
+ return true;
+ }
+
+ private static bool HasLeadingZeroPreRelease(string input)
+ {
+ var dashIdx = input.IndexOf('-');
+ if (dashIdx < 0) return false;
+
+ var pre = dashIdx + 1 < input.Length ? input.Substring(dashIdx + 1) : string.Empty;
+ var plusIdx = pre.IndexOf('+');
+ if (plusIdx >= 0) pre = pre.Substring(0, plusIdx);
+
+ if (pre.Length == 0) return false;
+
+ var parts = pre.Split('.');
+ foreach (var part in parts)
+ {
+ if (part.Length > 1 && part[0] == '0' && part.All(c => c >= '0' && c <= '9'))
+ return true;
+ }
+ return false;
+ }
+
+ private static bool SysNumOverflow(string version)
+ {
+ // Only check the MAJOR.MINOR.PATCH triplet — prerelease and
+ // build metadata identifiers may be longer (e.g. timestamps).
+ var dashIdx = version.IndexOfAny(new[] { '-', '+' });
+ var core = dashIdx >= 0 ? version.Substring(0, dashIdx) : version;
+ var tokens = core.Split('.');
+ foreach (var token in tokens)
+ {
+ if (token.Length == 0) continue;
+ if (token.All(c => c >= '0' && c <= '9'))
+ {
+ if (token.Length > 10) return true;
+ if (token.Length == 10 && token.CompareTo("2147483647") > 0) return true;
+ }
+ }
+ return false;
+ }
+
+ private static int ComparePreReleaseIdentifiers(string preA, string preB)
+ {
+ var idsA = preA.Split('.');
+ var idsB = preB.Split('.');
+ var minLen = Math.Min(idsA.Length, idsB.Length);
+
+ for (var i = 0; i < minLen; i++)
+ {
+ var result = ComparePreReleaseId(idsA[i], idsB[i]);
+ if (result != 0) return result;
+ }
+
+ return idsA.Length.CompareTo(idsB.Length);
+ }
+
+ private static int ComparePreReleaseId(string idA, string idB)
+ {
+ var aIsNumeric = long.TryParse(idA, out var numA);
+ var bIsNumeric = long.TryParse(idB, out var numB);
+
+ if (aIsNumeric && bIsNumeric) return numA.CompareTo(numB);
+ if (aIsNumeric) return -1;
+ if (bIsNumeric) return 1;
+ return string.Compare(idA, idB, StringComparison.Ordinal);
+ }
+}
+
+///
+/// Represents a parsed SemVer 2.0 version with value equality and comparison operators.
+/// Immutable value type — safe for use as a sorting/comparison key.
+///
+public readonly struct SemVersion : IComparable, IEquatable
+{
+ /// Major version component (MAJOR in MAJOR.MINOR.PATCH).
+ public int Major { get; }
+
+ /// Minor version component (MINOR in MAJOR.MINOR.PATCH).
+ public int Minor { get; }
+
+ /// Patch version component (PATCH in MAJOR.MINOR.PATCH).
+ public int Patch { get; }
+
+ /// Pre-release identifier (e.g., "beta.1"), or .
+ public string PreRelease { get; }
+
+ /// Build metadata identifier (e.g., "sha.abc"), or .
+ public string Build { get; }
+
+ ///
+ /// Initializes a new .
+ /// All parameters should be validated before construction — use .
+ ///
+ internal SemVersion(int major, int minor, int patch, string preRelease, string build)
+ {
+ Major = major;
+ Minor = minor;
+ Patch = patch;
+ PreRelease = preRelease ?? string.Empty;
+ Build = build ?? string.Empty;
+ }
+
+ ///
+ /// Returns the canonical SemVer 2.0 string representation (e.g., "1.0.0", "1.0.0-beta.1").
+ /// Build metadata is not included by default (per spec).
+ ///
+ public override string ToString()
+ {
+ var pre = string.IsNullOrEmpty(PreRelease) ? string.Empty : $"-{PreRelease}";
+ return $"{Major}.{Minor}.{Patch}{pre}";
+ }
+
+ #region IComparable
+
+ public int CompareTo(SemVersion other)
+ {
+ if (Major != other.Major) return Major.CompareTo(other.Major);
+ if (Minor != other.Minor) return Minor.CompareTo(other.Minor);
+ if (Patch != other.Patch) return Patch.CompareTo(other.Patch);
+
+ var aHasPre = !string.IsNullOrEmpty(PreRelease);
+ var bHasPre = !string.IsNullOrEmpty(other.PreRelease);
+ if (aHasPre != bHasPre) return aHasPre ? -1 : 1;
+ if (!aHasPre) return 0;
+
+ return ComparePreReleaseIdentifiers(PreRelease, other.PreRelease);
+ }
+
+ #endregion
+
+ #region IEquatable
+
+ public bool Equals(SemVersion other)
+ {
+ return Major == other.Major
+ && Minor == other.Minor
+ && Patch == other.Patch
+ && string.Equals(PreRelease, other.PreRelease, StringComparison.Ordinal);
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is SemVersion other && Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+ // Manual hash (avoids HashCode.Combine for netstandard2.0 compatibility).
+ unchecked
+ {
+ var hash = 17;
+ hash = hash * 31 + Major.GetHashCode();
+ hash = hash * 31 + Minor.GetHashCode();
+ hash = hash * 31 + Patch.GetHashCode();
+ hash = hash * 31 + (PreRelease?.GetHashCode() ?? 0);
+ return hash;
+ }
+ }
+
+ #endregion
+
+ #region Operators
+
+ public static bool operator >(SemVersion left, SemVersion right) => left.CompareTo(right) > 0;
+ public static bool operator <(SemVersion left, SemVersion right) => left.CompareTo(right) < 0;
+ public static bool operator >=(SemVersion left, SemVersion right) => left.CompareTo(right) >= 0;
+ public static bool operator <=(SemVersion left, SemVersion right) => left.CompareTo(right) <= 0;
+ public static bool operator ==(SemVersion left, SemVersion right) => left.Equals(right);
+ public static bool operator !=(SemVersion left, SemVersion right) => !left.Equals(right);
+
+ #endregion
+
+ private static int ComparePreReleaseIdentifiers(string preA, string preB)
+ {
+ var idsA = preA.Split('.');
+ var idsB = preB.Split('.');
+ var minLen = Math.Min(idsA.Length, idsB.Length);
+
+ for (var i = 0; i < minLen; i++)
+ {
+ var result = ComparePreReleaseId(idsA[i], idsB[i]);
+ if (result != 0) return result;
+ }
+
+ return idsA.Length.CompareTo(idsB.Length);
+ }
+
+ private static int ComparePreReleaseId(string idA, string idB)
+ {
+ var aIsNumeric = long.TryParse(idA, out var numA);
+ var bIsNumeric = long.TryParse(idB, out var numB);
+
+ if (aIsNumeric && bIsNumeric) return numA.CompareTo(numB);
+ if (aIsNumeric) return -1;
+ if (bIsNumeric) return 1;
+ return string.Compare(idA, idB, StringComparison.Ordinal);
+ }
+}
+
+///
+/// for sorting SemVer strings, delegates to .
+///
+public sealed class SemverComparer : IComparer
+{
+ public static readonly SemverComparer Instance = new();
+
+ public int Compare(string? x, string? y) => Semver.Compare(x, y);
+}
diff --git a/tests/CoreTest/Configuration/UpdateConfigurationTests.cs b/tests/CoreTest/Configuration/UpdateConfigurationTests.cs
index 4d82aa68..cdc5fa33 100644
--- a/tests/CoreTest/Configuration/UpdateConfigurationTests.cs
+++ b/tests/CoreTest/Configuration/UpdateConfigurationTests.cs
@@ -50,7 +50,7 @@ public void Ctor_AppSecretKey_DefaultsToNull()
public void Ctor_ClientVersion_DefaultsToVersion()
{
var config = new TestableConfig();
- Assert.Equal("1.0.0.0", config.ClientVersion);
+ Assert.Equal("1.0.0", config.ClientVersion);
}
[Fact]
diff --git a/tests/DrivelutionTest/Utilities/VersionComparerAndRestartHelperTests.cs b/tests/DrivelutionTest/Utilities/VersionComparerAndRestartHelperTests.cs
index b8910d39..ce4a9c84 100644
--- a/tests/DrivelutionTest/Utilities/VersionComparerAndRestartHelperTests.cs
+++ b/tests/DrivelutionTest/Utilities/VersionComparerAndRestartHelperTests.cs
@@ -182,9 +182,6 @@ public void IsEqual_WithDifferentVersions_ReturnsFalse()
[InlineData("1.0.0+20130313144700")]
[InlineData("1.0.0-beta+exp.sha.5114f85")]
[InlineData("10.20.30")]
- [InlineData("999999999999.0.0")]
- [InlineData("0.999999999999.0")]
- [InlineData("0.0.999999999999")]
public void IsValidSemVer_WithValidVersions_ReturnsTrue(string version)
{
// Act
@@ -195,44 +192,46 @@ public void IsValidSemVer_WithValidVersions_ReturnsTrue(string version)
}
///
- /// Tests that very large version numbers are compared correctly (overflow guard).
+ /// Tests that very large version numbers (beyond int.MaxValue) are handled gracefully.
+ /// The aligned uses int for version components, so values
+ /// exceeding int.MaxValue (~2.1e9) are treated as invalid and Compare returns 0.
///
[Fact]
- public void Compare_VeryLargeNumbers_DoesNotOverflow()
+ public void Compare_VeryLargeNumbers_AreInvalid()
{
- // Act & Assert — these values exceed int.MaxValue (2147483647)
+ // These values exceed int.MaxValue (2147483647) so they are invalid
+ // in the aligned Semver implementation. Compare returns 0 for invalid pairs.
var result1 = VersionComparer.Compare("999999999999.0.0", "999999999998.0.0");
- Assert.True(result1 > 0);
+ Assert.Equal(0, result1);
var result2 = VersionComparer.Compare("999999999999.0.0", "999999999999.0.0");
Assert.Equal(0, result2);
- // Cross-boundary: large major vs large minor
var result3 = VersionComparer.Compare("999999999999.0.0", "0.999999999999.0");
- Assert.True(result3 > 0);
+ Assert.Equal(0, result3);
}
///
- /// Tests prerelease comparison with large numeric prerelease identifiers.
+ /// Tests prerelease comparison with very large numeric prerelease identifiers.
+ /// These are valid per SemVer 2.0 — prerelease identifiers are compared as long.
///
[Fact]
- public void Compare_PrereleaseWithLargeNumbers_DoesNotOverflow()
+ public void Compare_PrereleaseWithLargeNumbers_ComparesCorrectly()
{
- // Values up to ~1e12 exceed int.MaxValue (~2.1e9) so this verifies
- // the long-based numeric overflow guard in ComparePrerelease.
+ // Prerelease "999999999999" > "0" (both fit in long)
var result = VersionComparer.Compare("1.0.0-999999999999", "1.0.0-0");
Assert.True(result > 0);
}
///
/// Tests IsValidSemVer with invalid versions.
+ /// Note: "1.0.0.0" is now accepted as a legacy 4-part format (see ).
///
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("1")]
[InlineData("1.0")]
- [InlineData("1.0.0.0")]
[InlineData("v1.0.0")]
[InlineData("01.0.0")]
public void IsValidSemVer_WithInvalidVersions_ReturnsFalse(string version)
@@ -245,26 +244,29 @@ public void IsValidSemVer_WithInvalidVersions_ReturnsFalse(string version)
}
///
- /// Tests Compare throws ArgumentException for null or empty versions.
+ /// Tests Compare returns 0 for null/empty/whitespace inputs via Semver.Compare fallback.
///
[Fact]
- public void Compare_WithNullOrEmptyVersion_ThrowsArgumentException()
+ public void Compare_WithNullOrEmptyVersion_ReturnsZeroOrFallback()
{
- // Act & Assert
- Assert.Throws(() => VersionComparer.Compare("", "1.0.0"));
- Assert.Throws(() => VersionComparer.Compare("1.0.0", ""));
- Assert.Throws(() => VersionComparer.Compare(null!, "1.0.0"));
+ // Semver.Compare returns 0 when both are null/empty, -1 for (null, "1.0.0"),
+ // and 0 for invalid pairs (silent fallback).
+ Assert.Equal(0, VersionComparer.Compare("", ""));
+ Assert.Equal(-1, VersionComparer.Compare("", "1.0.0"));
+ Assert.Equal(1, VersionComparer.Compare("1.0.0", ""));
+ // null treated as empty
+ Assert.Equal(-1, VersionComparer.Compare(null!, "1.0.0"));
}
///
- /// Tests Compare throws FormatException for invalid version format.
+ /// Tests Compare returns 0 for invalid format (not FormatException) via Semver.Compare.
///
[Fact]
- public void Compare_WithInvalidFormat_ThrowsFormatException()
+ public void Compare_WithInvalidFormat_ReturnsZero()
{
- // Act & Assert
- Assert.Throws(() => VersionComparer.Compare("invalid", "1.0.0"));
- Assert.Throws(() => VersionComparer.Compare("1.0.0", "v1.0.0"));
+ // Semver.Compare returns 0 when either version is not parseable.
+ Assert.Equal(0, VersionComparer.Compare("invalid", "1.0.0"));
+ Assert.Equal(0, VersionComparer.Compare("1.0.0", "v1.0.0"));
}
///