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,192 @@
using FluentAssertions;
using StellaOps.VersionComparison;
using StellaOps.VersionComparison.Comparers;
namespace StellaOps.VersionComparison.Tests;
public class DebianVersionComparerTests
{
private readonly DebianVersionComparer _comparer = DebianVersionComparer.Instance;
#region Basic Comparison Tests
public static TheoryData<string, string, int> DebianComparisonCases => new()
{
// Epoch precedence
{ "0:1.0-1", "1:0.1-1", -1 },
{ "1:1.0-1", "0:9.9-9", 1 },
{ "1.0-1", "0:1.0-1", 0 }, // Missing epoch = 0
{ "2:1.0-1", "1:9.9-9", 1 },
// Upstream version ordering
{ "1.9-1", "1.10-1", -1 },
{ "1.02-1", "1.2-1", 0 }, // Leading zeros ignored
{ "1.0-1", "1.0.1-1", -1 },
// Tilde pre-releases
{ "1.0~rc1-1", "1.0-1", -1 },
{ "1.0~alpha-1", "1.0~beta-1", -1 },
{ "2.0~rc1", "2.0", -1 },
{ "1.0~-1", "1.0-1", -1 },
// Debian revision
{ "1.0-1", "1.0-2", -1 },
{ "1.0-1ubuntu1", "1.0-1ubuntu2", -1 },
{ "1.0-1+deb11u1", "1.0-1+deb11u2", -1 },
// Ubuntu backport patterns
{ "1.0-1", "1.0-1ubuntu0.1", -1 },
{ "1.0-1ubuntu0.1", "1.0-1ubuntu0.2", -1 },
{ "1.0-1build1", "1.0-1build2", -1 },
// Native packages (no revision)
{ "1.0", "1.0-1", -1 },
{ "1.1", "1.0-99", 1 },
};
[Theory]
[MemberData(nameof(DebianComparisonCases))]
public void Compare_DebianVersions_ReturnsExpectedOrder(string left, string right, int expected)
{
var result = Math.Sign(_comparer.Compare(left, right));
result.Should().Be(expected, because: $"comparing '{left}' with '{right}'");
}
[Fact]
public void Compare_SameVersion_ReturnsZero()
{
_comparer.Compare("1:1.1.1k-1+deb11u1", "1:1.1.1k-1+deb11u1").Should().Be(0);
}
[Fact]
public void Compare_NullLeft_ReturnsNegative()
{
_comparer.Compare(null, "1.0-1").Should().BeNegative();
}
[Fact]
public void Compare_NullRight_ReturnsPositive()
{
_comparer.Compare("1.0-1", null).Should().BePositive();
}
#endregion
#region Proof Line Tests
[Fact]
public void CompareWithProof_EpochDifference_ReturnsEpochProof()
{
var result = _comparer.CompareWithProof("0:1.0-1", "1:0.1-1");
result.Comparison.Should().BeNegative();
result.Comparator.Should().Be(ComparatorType.Dpkg);
result.ProofLines.Should().Contain(line => line.Contains("Epoch:") && line.Contains("left is older"));
}
[Fact]
public void CompareWithProof_SameEpochDifferentVersion_ReturnsVersionProof()
{
var result = _comparer.CompareWithProof("1:1.1.1k-1", "1:1.1.1l-1");
result.Comparison.Should().BeNegative();
result.ProofLines.Should().Contain(line => line.Contains("Epoch:") && line.Contains("equal"));
result.ProofLines.Should().Contain(line => line.Contains("Upstream version:") && line.Contains("left is older"));
}
[Fact]
public void CompareWithProof_SameVersionDifferentRevision_ReturnsRevisionProof()
{
var result = _comparer.CompareWithProof("1:1.1.1k-1+deb11u1", "1:1.1.1k-1+deb11u2");
result.Comparison.Should().BeNegative();
result.ProofLines.Should().Contain(line => line.Contains("Upstream version:") && line.Contains("equal"));
result.ProofLines.Should().Contain(line => line.Contains("Debian revision:") && line.Contains("left is older"));
}
[Fact]
public void CompareWithProof_EqualVersions_ReturnsEqualProof()
{
var result = _comparer.CompareWithProof("1:1.1.1k-1+deb11u1", "1:1.1.1k-1+deb11u1");
result.Comparison.Should().Be(0);
result.IsEqual.Should().BeTrue();
result.ProofLines.Should().AllSatisfy(line => line.Should().Contain("equal"));
}
[Fact]
public void CompareWithProof_TildePreRelease_ReturnsCorrectProof()
{
var result = _comparer.CompareWithProof("2.0~rc1-1", "2.0-1");
result.Comparison.Should().BeNegative();
result.ProofLines.Should().Contain(line => line.Contains("Upstream version:") && line.Contains("left is older"));
}
[Fact]
public void CompareWithProof_NativePackage_HandlesEmptyRevision()
{
var result = _comparer.CompareWithProof("1.0", "1.0-1");
result.Comparison.Should().BeNegative();
result.ProofLines.Should().Contain(line => line.Contains("Debian revision:"));
}
#endregion
#region Edge Cases
[Theory]
[InlineData("1:1.2-1", "0:9.9-9", 1)] // Epoch jump
[InlineData("2.0~rc1", "2.0", -1)] // Tilde pre-release
[InlineData("1.2-3+deb12u1", "1.2-3+deb12u2", -1)] // Debian stable update
[InlineData("1.2-3ubuntu0.1", "1.2-3", 1)] // Ubuntu backport
public void Compare_EdgeCases_ReturnsExpectedOrder(string left, string right, int expected)
{
var result = Math.Sign(_comparer.Compare(left, right));
result.Should().Be(expected);
}
#endregion
#region Real-World Advisory Scenarios
[Fact]
public void Compare_OpenSSL_DebianBackport_CorrectlyIdentifiesVulnerable()
{
// Installed version vs fixed version from DSA
var installed = "1:1.1.1k-1+deb11u1";
var fixedVersion = "1:1.1.1k-1+deb11u2";
var result = _comparer.CompareWithProof(installed, fixedVersion);
result.IsLessThan.Should().BeTrue("installed version should be less than fixed");
result.IsGreaterThanOrEqual.Should().BeFalse("installed is VULNERABLE");
}
[Fact]
public void Compare_OpenSSL_DebianBackport_CorrectlyIdentifiesFixed()
{
// Installed version >= fixed version
var installed = "1:1.1.1k-1+deb11u2";
var fixedVersion = "1:1.1.1k-1+deb11u2";
var result = _comparer.CompareWithProof(installed, fixedVersion);
result.IsGreaterThanOrEqual.Should().BeTrue("installed version is FIXED");
}
[Fact]
public void Compare_UbuntuSecurityBackport_CorrectlyIdentifies()
{
// Ubuntu security backport pattern
var installed = "1.0-1ubuntu0.1";
var fixedVersion = "1.0-1ubuntu0.2";
var result = _comparer.CompareWithProof(installed, fixedVersion);
result.IsLessThan.Should().BeTrue("installed is older security backport");
}
#endregion
}

View File

@@ -0,0 +1,138 @@
using FluentAssertions;
using StellaOps.VersionComparison;
using StellaOps.VersionComparison.Comparers;
namespace StellaOps.VersionComparison.Tests;
public class RpmVersionComparerTests
{
private readonly RpmVersionComparer _comparer = RpmVersionComparer.Instance;
#region Basic Comparison Tests
public static TheoryData<string, string, int> RpmComparisonCases => new()
{
// Epoch precedence
{ "0:1.0-1", "1:0.1-1", -1 },
{ "1:1.0-1", "0:9.9-9", 1 },
{ "1.0-1", "0:1.0-1", 0 }, // Missing epoch = 0
{ "2:1.0-1", "1:9.9-9", 1 },
// Numeric version ordering
{ "1.9-1", "1.10-1", -1 },
{ "1.02-1", "1.2-1", 0 }, // Leading zeros ignored
{ "1.0-1", "1.0.1-1", -1 },
{ "2.0-1", "1.999-1", 1 },
// Tilde pre-releases
{ "1.0~rc1-1", "1.0-1", -1 }, // Tilde sorts before release
{ "1.0~alpha-1", "1.0~beta-1", -1 },
{ "1.0~-1", "1.0-1", -1 },
{ "1.0~~-1", "1.0~-1", -1 }, // Double tilde < single
// Release qualifiers (RHEL backports)
{ "1.0-1.el8", "1.0-1.el8_5", -1 }, // Base < security update
{ "1.0-1.el8_5", "1.0-1.el8_5.1", -1 },
{ "1.0-1.el8", "1.0-1.el9", -1 }, // el8 < el9
{ "1.0-1.el9_2", "1.0-1.el9_3", -1 },
// Alpha suffix ordering (1.0a > 1.0 because 'a' is additional segment)
{ "1.0a-1", "1.0-1", 1 }, // 1.0a > 1.0 (alpha suffix adds to version)
{ "1.0-1", "1.0a-1", -1 },
};
[Theory]
[MemberData(nameof(RpmComparisonCases))]
public void Compare_RpmVersions_ReturnsExpectedOrder(string left, string right, int expected)
{
var result = Math.Sign(_comparer.Compare(left, right));
result.Should().Be(expected, because: $"comparing '{left}' with '{right}'");
}
[Fact]
public void Compare_SameVersion_ReturnsZero()
{
_comparer.Compare("1.0-1.el8", "1.0-1.el8").Should().Be(0);
}
[Fact]
public void Compare_NullLeft_ReturnsNegative()
{
_comparer.Compare(null, "1.0-1").Should().BeNegative();
}
[Fact]
public void Compare_NullRight_ReturnsPositive()
{
_comparer.Compare("1.0-1", null).Should().BePositive();
}
#endregion
#region Proof Line Tests
[Fact]
public void CompareWithProof_EpochDifference_ReturnsEpochProof()
{
var result = _comparer.CompareWithProof("0:1.0-1", "1:0.1-1");
result.Comparison.Should().BeNegative();
result.Comparator.Should().Be(ComparatorType.RpmEvr);
result.ProofLines.Should().Contain(line => line.Contains("Epoch:") && line.Contains("left is older"));
}
[Fact]
public void CompareWithProof_SameEpochDifferentVersion_ReturnsVersionProof()
{
var result = _comparer.CompareWithProof("1:1.0-1", "1:2.0-1");
result.Comparison.Should().BeNegative();
result.ProofLines.Should().Contain(line => line.Contains("Epoch:") && line.Contains("equal"));
result.ProofLines.Should().Contain(line => line.Contains("Version:") && line.Contains("left is older"));
}
[Fact]
public void CompareWithProof_SameVersionDifferentRelease_ReturnsReleaseProof()
{
var result = _comparer.CompareWithProof("1.0-1.el8", "1.0-1.el8_5");
result.Comparison.Should().BeNegative();
result.ProofLines.Should().Contain(line => line.Contains("Release:") && line.Contains("left is older"));
}
[Fact]
public void CompareWithProof_EqualVersions_ReturnsEqualProof()
{
var result = _comparer.CompareWithProof("1.0-1.el8", "1.0-1.el8");
result.Comparison.Should().Be(0);
result.IsEqual.Should().BeTrue();
result.ProofLines.Should().AllSatisfy(line => line.Should().Contain("equal"));
}
[Fact]
public void CompareWithProof_TildePreRelease_ReturnsCorrectProof()
{
var result = _comparer.CompareWithProof("1.0~rc1-1", "1.0-1");
result.Comparison.Should().BeNegative();
result.ProofLines.Should().Contain(line => line.Contains("Version:") && line.Contains("left is older"));
}
#endregion
#region Edge Cases
[Theory]
[InlineData("1:1.2-1", "0:9.9-9", 1)] // Epoch jump
[InlineData("2.0~rc1", "2.0", -1)] // Tilde pre-release
[InlineData("1.2-3.el9_2", "1.2-3.el9_3", -1)] // Release qualifier
[InlineData("1.2-3ubuntu0.1", "1.2-3", 1)] // Ubuntu rebuild
public void Compare_EdgeCases_ReturnsExpectedOrder(string left, string right, int expected)
{
var result = Math.Sign(_comparer.Compare(left, right));
result.Should().Be(expected);
}
#endregion
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.VersionComparison\StellaOps.VersionComparison.csproj" />
</ItemGroup>
</Project>