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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user