feat: Implement distro-native version comparison for RPM, Debian, and Alpine packages

- Add RpmVersionComparer for RPM version comparison with epoch, version, and release handling.
- Introduce DebianVersion for parsing Debian EVR (Epoch:Version-Release) strings.
- Create ApkVersion for parsing Alpine APK version strings with suffix support.
- Define IVersionComparator interface for version comparison with proof-line generation.
- Implement VersionComparisonResult struct to encapsulate comparison results and proof lines.
- Add tests for Debian and RPM version comparers to ensure correct functionality and edge case handling.
- Create project files for the version comparison library and its tests.
This commit is contained in:
StellaOps Bot
2025-12-22 09:49:38 +02:00
parent aff0ceb2fe
commit 634233dfed
112 changed files with 31925 additions and 1813 deletions

View File

@@ -0,0 +1,261 @@
using System.Collections.Immutable;
using StellaOps.VersionComparison.Models;
namespace StellaOps.VersionComparison.Comparers;
/// <summary>
/// Compares Debian/Ubuntu package versions using dpkg semantics.
/// Handles epoch:upstream_version-debian_revision with tilde pre-release support.
/// </summary>
public sealed class DebianVersionComparer : IVersionComparator, IComparer<DebianVersion>, IComparer<string>
{
/// <summary>
/// Singleton instance.
/// </summary>
public static DebianVersionComparer Instance { get; } = new();
private DebianVersionComparer() { }
/// <inheritdoc />
public ComparatorType ComparatorType => ComparatorType.Dpkg;
/// <inheritdoc />
public int Compare(string? x, string? y)
{
if (ReferenceEquals(x, y)) return 0;
if (x is null) return -1;
if (y is null) return 1;
var xParsed = DebianVersion.TryParse(x, out var xVer);
var yParsed = DebianVersion.TryParse(y, out var yVer);
if (xParsed && yParsed)
{
return Compare(xVer!, yVer!);
}
if (xParsed) return 1;
if (yParsed) return -1;
return string.Compare(x, y, StringComparison.Ordinal);
}
/// <summary>
/// Compare two parsed Debian versions.
/// </summary>
public int Compare(DebianVersion? x, DebianVersion? y)
{
if (ReferenceEquals(x, y)) return 0;
if (x is null) return -1;
if (y is null) return 1;
// Epoch first
var compare = x.Epoch.CompareTo(y.Epoch);
if (compare != 0) return compare;
// Upstream version
compare = CompareSegment(x.Version, y.Version);
if (compare != 0) return compare;
// Debian revision
compare = CompareSegment(x.Revision, y.Revision);
return compare;
}
/// <inheritdoc />
public VersionComparisonResult CompareWithProof(string? left, string? right)
{
var proofLines = new List<string>();
if (left is null && right is null)
{
proofLines.Add("Both versions are null: equal");
return new VersionComparisonResult(0, [.. proofLines], ComparatorType.Dpkg);
}
if (left is null)
{
proofLines.Add("Left version is null: less than right");
return new VersionComparisonResult(-1, [.. proofLines], ComparatorType.Dpkg);
}
if (right is null)
{
proofLines.Add("Right version is null: left is greater");
return new VersionComparisonResult(1, [.. proofLines], ComparatorType.Dpkg);
}
var leftParsed = DebianVersion.TryParse(left, out var leftVer);
var rightParsed = DebianVersion.TryParse(right, out var rightVer);
if (!leftParsed || !rightParsed)
{
if (!leftParsed && !rightParsed)
{
var cmp = string.Compare(left, right, StringComparison.Ordinal);
proofLines.Add($"Both versions invalid, fallback to string comparison: {ResultString(cmp)}");
return new VersionComparisonResult(cmp, [.. proofLines], ComparatorType.Dpkg);
}
if (!leftParsed)
{
proofLines.Add("Left version invalid, right valid: left is less");
return new VersionComparisonResult(-1, [.. proofLines], ComparatorType.Dpkg);
}
proofLines.Add("Right version invalid, left valid: left is greater");
return new VersionComparisonResult(1, [.. proofLines], ComparatorType.Dpkg);
}
// Compare epoch
var epochCmp = leftVer!.Epoch.CompareTo(rightVer!.Epoch);
if (epochCmp != 0)
{
proofLines.Add($"Epoch: {leftVer.Epoch} {CompareSymbol(epochCmp)} {rightVer.Epoch} ({ResultString(epochCmp)})");
return new VersionComparisonResult(epochCmp, [.. proofLines], ComparatorType.Dpkg);
}
proofLines.Add($"Epoch: {leftVer.Epoch} == {rightVer.Epoch} (equal)");
// Compare upstream version
var versionCmp = CompareSegmentWithProof(leftVer.Version, rightVer.Version, "Upstream version", proofLines);
if (versionCmp != 0)
{
return new VersionComparisonResult(versionCmp, [.. proofLines], ComparatorType.Dpkg);
}
// Compare revision
var revisionCmp = CompareSegmentWithProof(leftVer.Revision, rightVer.Revision, "Debian revision", proofLines);
return new VersionComparisonResult(revisionCmp, [.. proofLines], ComparatorType.Dpkg);
}
private static int CompareSegmentWithProof(string left, string right, string segmentName, List<string> proofLines)
{
var cmp = CompareSegment(left, right);
if (cmp == 0)
{
if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right))
{
proofLines.Add($"{segmentName}: (empty) == (empty) (equal)");
}
else
{
proofLines.Add($"{segmentName}: {left} == {right} (equal)");
}
}
else
{
var leftDisplay = string.IsNullOrEmpty(left) ? "(empty)" : left;
var rightDisplay = string.IsNullOrEmpty(right) ? "(empty)" : right;
proofLines.Add($"{segmentName}: {leftDisplay} {CompareSymbol(cmp)} {rightDisplay} ({ResultString(cmp)})");
}
return cmp;
}
private static string CompareSymbol(int cmp) => cmp < 0 ? "<" : cmp > 0 ? ">" : "==";
private static string ResultString(int cmp) => cmp < 0 ? "left is older" : cmp > 0 ? "left is newer" : "equal";
/// <summary>
/// Compare two version/revision segments using dpkg semantics.
/// </summary>
internal static int CompareSegment(string left, string right)
{
var i = 0;
var j = 0;
while (i < left.Length || j < right.Length)
{
// Skip non-alphanumeric (except tilde)
while (i < left.Length && !IsAlphaNumeric(left[i]) && left[i] != '~')
{
i++;
}
while (j < right.Length && !IsAlphaNumeric(right[j]) && right[j] != '~')
{
j++;
}
var leftChar = i < left.Length ? left[i] : '\0';
var rightChar = j < right.Length ? right[j] : '\0';
// Tilde sorts before everything (including empty)
if (leftChar == '~' || rightChar == '~')
{
if (leftChar != rightChar)
{
return leftChar == '~' ? -1 : 1;
}
if (leftChar == '~') i++;
if (rightChar == '~') j++;
continue;
}
var leftIsDigit = char.IsDigit(leftChar);
var rightIsDigit = char.IsDigit(rightChar);
// Both numeric
if (leftIsDigit && rightIsDigit)
{
var leftStart = i;
while (i < left.Length && char.IsDigit(left[i])) i++;
var rightStart = j;
while (j < right.Length && char.IsDigit(right[j])) j++;
// Trim leading zeros
var leftTrimmed = leftStart;
while (leftTrimmed < i && left[leftTrimmed] == '0') leftTrimmed++;
var rightTrimmed = rightStart;
while (rightTrimmed < j && right[rightTrimmed] == '0') rightTrimmed++;
var leftLength = i - leftTrimmed;
var rightLength = j - rightTrimmed;
if (leftLength != rightLength)
{
return leftLength.CompareTo(rightLength);
}
var comparison = left.AsSpan(leftTrimmed, leftLength)
.CompareTo(right.AsSpan(rightTrimmed, rightLength), StringComparison.Ordinal);
if (comparison != 0) return comparison;
continue;
}
// Digits sort after letters
if (leftIsDigit) return 1;
if (rightIsDigit) return -1;
// Character ordering
var leftOrder = CharOrder(leftChar);
var rightOrder = CharOrder(rightChar);
var orderComparison = leftOrder.CompareTo(rightOrder);
if (orderComparison != 0) return orderComparison;
if (leftChar != rightChar) return leftChar.CompareTo(rightChar);
if (leftChar == '\0') return 0;
i++;
j++;
}
return 0;
}
private static bool IsAlphaNumeric(char value) => char.IsLetterOrDigit(value);
private static int CharOrder(char value)
{
if (value == '\0') return 0;
if (value == '~') return -1;
if (char.IsDigit(value)) return 0;
if (char.IsLetter(value)) return value;
return value + 256;
}
}

View File

@@ -0,0 +1,259 @@
using System.Collections.Immutable;
using StellaOps.VersionComparison.Models;
namespace StellaOps.VersionComparison.Comparers;
/// <summary>
/// Compares RPM package versions using rpmvercmp semantics.
/// Handles epoch, version, release with tilde pre-release support.
/// </summary>
public sealed class RpmVersionComparer : IVersionComparator, IComparer<RpmVersion>, IComparer<string>
{
/// <summary>
/// Singleton instance.
/// </summary>
public static RpmVersionComparer Instance { get; } = new();
private RpmVersionComparer() { }
/// <inheritdoc />
public ComparatorType ComparatorType => ComparatorType.RpmEvr;
/// <inheritdoc />
public int Compare(string? x, string? y)
{
if (ReferenceEquals(x, y)) return 0;
if (x is null) return -1;
if (y is null) return 1;
var xParsed = RpmVersion.TryParse(x, out var xVer);
var yParsed = RpmVersion.TryParse(y, out var yVer);
if (xParsed && yParsed)
{
return Compare(xVer!, yVer!);
}
if (xParsed) return 1;
if (yParsed) return -1;
return string.Compare(x, y, StringComparison.Ordinal);
}
/// <summary>
/// Compare two parsed RPM versions.
/// </summary>
public int Compare(RpmVersion? x, RpmVersion? y)
{
if (ReferenceEquals(x, y)) return 0;
if (x is null) return -1;
if (y is null) return 1;
// Epoch first
var compare = x.Epoch.CompareTo(y.Epoch);
if (compare != 0) return compare;
// Version
compare = CompareSegment(x.Version, y.Version);
if (compare != 0) return compare;
// Release
compare = CompareSegment(x.Release, y.Release);
return compare;
}
/// <inheritdoc />
public VersionComparisonResult CompareWithProof(string? left, string? right)
{
var proofLines = new List<string>();
if (left is null && right is null)
{
proofLines.Add("Both versions are null: equal");
return new VersionComparisonResult(0, [.. proofLines], ComparatorType.RpmEvr);
}
if (left is null)
{
proofLines.Add("Left version is null: less than right");
return new VersionComparisonResult(-1, [.. proofLines], ComparatorType.RpmEvr);
}
if (right is null)
{
proofLines.Add("Right version is null: left is greater");
return new VersionComparisonResult(1, [.. proofLines], ComparatorType.RpmEvr);
}
var leftParsed = RpmVersion.TryParse(left, out var leftVer);
var rightParsed = RpmVersion.TryParse(right, out var rightVer);
if (!leftParsed || !rightParsed)
{
if (!leftParsed && !rightParsed)
{
var cmp = string.Compare(left, right, StringComparison.Ordinal);
proofLines.Add($"Both versions invalid, fallback to string comparison: {ResultString(cmp)}");
return new VersionComparisonResult(cmp, [.. proofLines], ComparatorType.RpmEvr);
}
if (!leftParsed)
{
proofLines.Add("Left version invalid, right valid: left is less");
return new VersionComparisonResult(-1, [.. proofLines], ComparatorType.RpmEvr);
}
proofLines.Add("Right version invalid, left valid: left is greater");
return new VersionComparisonResult(1, [.. proofLines], ComparatorType.RpmEvr);
}
// Compare epoch
var epochCmp = leftVer!.Epoch.CompareTo(rightVer!.Epoch);
if (epochCmp != 0)
{
proofLines.Add($"Epoch: {leftVer.Epoch} {CompareSymbol(epochCmp)} {rightVer.Epoch} ({ResultString(epochCmp)})");
return new VersionComparisonResult(epochCmp, [.. proofLines], ComparatorType.RpmEvr);
}
proofLines.Add($"Epoch: {leftVer.Epoch} == {rightVer.Epoch} (equal)");
// Compare version
var versionCmp = CompareSegmentWithProof(leftVer.Version, rightVer.Version, "Version", proofLines);
if (versionCmp != 0)
{
return new VersionComparisonResult(versionCmp, [.. proofLines], ComparatorType.RpmEvr);
}
// Compare release
var releaseCmp = CompareSegmentWithProof(leftVer.Release, rightVer.Release, "Release", proofLines);
return new VersionComparisonResult(releaseCmp, [.. proofLines], ComparatorType.RpmEvr);
}
private static int CompareSegmentWithProof(string left, string right, string segmentName, List<string> proofLines)
{
var cmp = CompareSegment(left, right);
if (cmp == 0)
{
proofLines.Add($"{segmentName}: {left} == {right} (equal)");
}
else
{
proofLines.Add($"{segmentName}: {left} {CompareSymbol(cmp)} {right} ({ResultString(cmp)})");
}
return cmp;
}
private static string CompareSymbol(int cmp) => cmp < 0 ? "<" : cmp > 0 ? ">" : "==";
private static string ResultString(int cmp) => cmp < 0 ? "left is older" : cmp > 0 ? "left is newer" : "equal";
/// <summary>
/// Compare two version/release segments using rpmvercmp semantics.
/// </summary>
internal static int CompareSegment(string? left, string? right)
{
left ??= string.Empty;
right ??= string.Empty;
var i = 0;
var j = 0;
while (true)
{
var leftHasTilde = SkipToNextSegment(left, ref i);
var rightHasTilde = SkipToNextSegment(right, ref j);
if (leftHasTilde || rightHasTilde)
{
if (leftHasTilde && rightHasTilde) continue;
return leftHasTilde ? -1 : 1;
}
var leftEnd = i >= left.Length;
var rightEnd = j >= right.Length;
if (leftEnd || rightEnd)
{
if (leftEnd && rightEnd) return 0;
return leftEnd ? -1 : 1;
}
var leftDigit = char.IsDigit(left[i]);
var rightDigit = char.IsDigit(right[j]);
if (leftDigit && !rightDigit) return 1;
if (!leftDigit && rightDigit) return -1;
int compare;
if (leftDigit)
{
compare = CompareNumericSegment(left, ref i, right, ref j);
}
else
{
compare = CompareAlphaSegment(left, ref i, right, ref j);
}
if (compare != 0) return compare;
}
}
private static bool SkipToNextSegment(string value, ref int index)
{
var sawTilde = false;
while (index < value.Length)
{
var current = value[index];
if (current == '~')
{
sawTilde = true;
index++;
break;
}
if (char.IsLetterOrDigit(current)) break;
index++;
}
return sawTilde;
}
private static int CompareNumericSegment(string value, ref int index, string other, ref int otherIndex)
{
var start = index;
while (index < value.Length && char.IsDigit(value[index])) index++;
var otherStart = otherIndex;
while (otherIndex < other.Length && char.IsDigit(other[otherIndex])) otherIndex++;
// Trim leading zeros
var trimmedStart = start;
while (trimmedStart < index && value[trimmedStart] == '0') trimmedStart++;
var otherTrimmedStart = otherStart;
while (otherTrimmedStart < otherIndex && other[otherTrimmedStart] == '0') otherTrimmedStart++;
var length = index - trimmedStart;
var otherLength = otherIndex - otherTrimmedStart;
// Longer number is greater
if (length != otherLength) return length.CompareTo(otherLength);
// Same length: compare lexicographically
return value.AsSpan(trimmedStart, length)
.CompareTo(other.AsSpan(otherTrimmedStart, otherLength), StringComparison.Ordinal);
}
private static int CompareAlphaSegment(string value, ref int index, string other, ref int otherIndex)
{
var start = index;
while (index < value.Length && char.IsLetter(value[index])) index++;
var otherStart = otherIndex;
while (otherIndex < other.Length && char.IsLetter(other[otherIndex])) otherIndex++;
var length = index - start;
var otherLength = otherIndex - otherStart;
return value.AsSpan(start, length)
.CompareTo(other.AsSpan(otherStart, otherLength), StringComparison.Ordinal);
}
}