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:
@@ -32,6 +32,35 @@
|
||||
- **Cross-module edits:** none without sprint note; if needed, log in sprint Execution Log and Decisions & Risks.
|
||||
- **CVSS v4.0 ingest:** when vendor advisories ship CVSS v4.0 vectors, parse without mutation, store provenance (source id + observation path), and emit vectors unchanged to Policy receipts. Do not derive fields; attach DSSE/observation refs for Policy reuse.
|
||||
|
||||
## Distro Backport Version Handling
|
||||
|
||||
> **Reference:** `docs/product-advisories/archived/22-Dec-2025 - Getting Distro Backport Logic Right.md`
|
||||
|
||||
When working with OS package advisories, follow these rules:
|
||||
|
||||
### Version Comparators
|
||||
- **RPM (RHEL/CentOS/Fedora/openSUSE):** Use `NevraComparer` in `StellaOps.Concelier.Merge.Comparers`. Compares EVR (`epoch:version-release`) using `rpmvercmp` semantics. Tilde `~` sorts before anything.
|
||||
- **Debian/Ubuntu:** Use `DebianEvrComparer`. Compares `epoch:upstream_version-debian_revision` using dpkg rules. Tilde `~` sorts lower than empty.
|
||||
- **Alpine (APK):** Use `ApkVersionComparer` (via SPRINT_2000_0003_0001). Handles `-r<pkgrel>` and suffix ordering (`_alpha` < `_beta` < `_pre` < `_rc` < none < `_p`).
|
||||
|
||||
### Key Rules
|
||||
1. **Never convert distro versions to SemVer.** Preserve native EVR/NEVRA/APK strings in `AffectedVersionRange`.
|
||||
2. **Use `RangeKind` correctly:** `nevra` for RPM, `evr` for Debian/Ubuntu, `apk` for Alpine.
|
||||
3. **Preserve release qualifiers:** `.el8_5`, `+deb12u2`, `-r0` encode backport information.
|
||||
4. **Distro advisories take precedence:** When upstream says "fixed in 1.4.4" but distro claims "fixed in 1.4.3-5~deb12u2", prefer distro channel if source is trusted.
|
||||
5. **Record evidence:** Store source (DSA/RHSA/USN), comparator used, installed version, fixed threshold, and result.
|
||||
|
||||
### Edge Cases to Handle
|
||||
- Epoch jumps: `1:1.2-1` > `0:9.9-9`
|
||||
- Tilde pre-releases: `2.0~rc1` < `2.0`
|
||||
- Release qualifiers: `1.2-3.el9_2` < `1.2-3.el9_3`
|
||||
- Rebuilds/backports: `1.2-3ubuntu0.1` vs `1.2-3`
|
||||
|
||||
### Test Corpus
|
||||
Version comparators must be tested with 50+ cases per distro. See:
|
||||
- `src/Concelier/__Tests/StellaOps.Concelier.Merge.Tests/Comparers/`
|
||||
- SPRINT_2000_0003_0002 for comprehensive test requirements
|
||||
|
||||
## Coding & Observability Standards
|
||||
- Target **.NET 10**; prefer latest C# preview features already enabled in repo.
|
||||
- Npgsql driver for PostgreSQL; canonical JSON mapping in Storage.Postgres.
|
||||
|
||||
@@ -110,6 +110,53 @@ Compute vulnerability surfaces by diffing vulnerable vs fixed package versions:
|
||||
- Confidence tiers: Confirmed (trigger reachable) > Likely (API reachable) > Present (dep only)
|
||||
- Path witnesses include surface evidence for audit trail
|
||||
|
||||
## Binary + Call-Stack Reachability (Sprint 3800 Series)
|
||||
|
||||
Layered binary reachability with attestable slices for CVE triage:
|
||||
|
||||
### Sprint Summary
|
||||
- **3800**: Binary call-edge enhancement (disassembly, PLT/IAT, dynamic loading)
|
||||
- **3810**: CVE→Symbol mapping and slice format
|
||||
- **3820**: Slice query and replay APIs
|
||||
- **3830**: VEX integration and policy binding
|
||||
- **3840**: Runtime trace merge (eBPF/ETW)
|
||||
- **3850**: OCI storage and CLI commands
|
||||
|
||||
See: `docs/implplan/SPRINT_3800_SUMMARY.md`
|
||||
|
||||
### Libraries
|
||||
- `StellaOps.Scanner.Reachability.Slices` - Slice extraction, DSSE signing, verdict computation
|
||||
- `StellaOps.Scanner.Advisory` - CVE→symbol mapping integration with Concelier
|
||||
- `StellaOps.Scanner.Runtime` - eBPF/ETW runtime trace collectors
|
||||
- `StellaOps.Scanner.Storage.Oci` - OCI artifact storage for slices
|
||||
|
||||
### Key Types
|
||||
- `ReachabilitySlice` - Minimal attestable proof unit for CVE reachability
|
||||
- `SliceQuery` - Query parameters (CVE, symbols, entrypoints, policy)
|
||||
- `SliceVerdict` - Result status (reachable/unreachable/unknown/gated)
|
||||
- `VulnSurfaceResult` - CVE→symbol mapping result with confidence
|
||||
|
||||
### Predicate Schema
|
||||
- URI: `stellaops.dev/predicates/reachability-slice@v1`
|
||||
- Schema: `docs/schemas/stellaops-slice.v1.schema.json`
|
||||
- DSSE-signed slices for audit trail
|
||||
|
||||
### Slice API Endpoints
|
||||
- `POST /api/slices/query` - Query reachability for CVE/symbols
|
||||
- `GET /api/slices/{digest}` - Retrieve attested slice
|
||||
- `POST /api/slices/replay` - Verify slice reproducibility
|
||||
|
||||
### CLI Commands (Sprint 3850)
|
||||
- `stella binary submit` - Submit binary graph
|
||||
- `stella binary info` - Display graph info
|
||||
- `stella binary symbols` - List symbols
|
||||
- `stella binary verify` - Verify attestation
|
||||
|
||||
### Documentation
|
||||
- `docs/reachability/slice-schema.md` - Slice format specification
|
||||
- `docs/reachability/cve-symbol-mapping.md` - CVE→symbol service design
|
||||
- `docs/reachability/replay-verification.md` - Replay workflow guide
|
||||
|
||||
## Engineering Rules
|
||||
- Target `net10.0`; prefer latest C# preview allowed in repo.
|
||||
- Offline-first: no new external network calls; use cached feeds (`/local-nugets`).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.VersionComparison;
|
||||
|
||||
/// <summary>
|
||||
/// Comparator type identifier for UI display and evidence recording.
|
||||
/// </summary>
|
||||
public enum ComparatorType
|
||||
{
|
||||
/// <summary>RPM EVR comparison using rpmvercmp semantics.</summary>
|
||||
RpmEvr,
|
||||
|
||||
/// <summary>Debian/Ubuntu version comparison using dpkg semantics.</summary>
|
||||
Dpkg,
|
||||
|
||||
/// <summary>Alpine APK version comparison.</summary>
|
||||
Apk,
|
||||
|
||||
/// <summary>Semantic versioning (SemVer 2.0).</summary>
|
||||
SemVer
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a version comparison with optional proof lines for explainability.
|
||||
/// </summary>
|
||||
/// <param name="Comparison">Negative if left < right, zero if equal, positive if left > right.</param>
|
||||
/// <param name="ProofLines">Human-readable explanation of comparison steps.</param>
|
||||
/// <param name="Comparator">The comparator type used.</param>
|
||||
public readonly record struct VersionComparisonResult(
|
||||
int Comparison,
|
||||
ImmutableArray<string> ProofLines,
|
||||
ComparatorType Comparator)
|
||||
{
|
||||
/// <summary>
|
||||
/// True if the left version is less than the right version.
|
||||
/// </summary>
|
||||
public bool IsLessThan => Comparison < 0;
|
||||
|
||||
/// <summary>
|
||||
/// True if the left version equals the right version.
|
||||
/// </summary>
|
||||
public bool IsEqual => Comparison == 0;
|
||||
|
||||
/// <summary>
|
||||
/// True if the left version is greater than the right version.
|
||||
/// </summary>
|
||||
public bool IsGreaterThan => Comparison > 0;
|
||||
|
||||
/// <summary>
|
||||
/// True if the left version is greater than or equal to the right version.
|
||||
/// Useful for checking if installed >= fixed.
|
||||
/// </summary>
|
||||
public bool IsGreaterThanOrEqual => Comparison >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for distro-native version comparison with proof-line generation.
|
||||
/// </summary>
|
||||
public interface IVersionComparator
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of comparator (for UI display and evidence recording).
|
||||
/// </summary>
|
||||
ComparatorType ComparatorType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Compare two version strings using distro-native semantics.
|
||||
/// </summary>
|
||||
/// <param name="left">First version string.</param>
|
||||
/// <param name="right">Second version string.</param>
|
||||
/// <returns>Negative if left < right, zero if equal, positive if left > right.</returns>
|
||||
int Compare(string? left, string? right);
|
||||
|
||||
/// <summary>
|
||||
/// Compare two version strings with proof-line generation for explainability.
|
||||
/// </summary>
|
||||
/// <param name="left">First version string (typically installed version).</param>
|
||||
/// <param name="right">Second version string (typically fixed version).</param>
|
||||
/// <returns>Comparison result with human-readable proof lines.</returns>
|
||||
VersionComparisonResult CompareWithProof(string? left, string? right);
|
||||
}
|
||||
158
src/__Libraries/StellaOps.VersionComparison/Models/ApkVersion.cs
Normal file
158
src/__Libraries/StellaOps.VersionComparison/Models/ApkVersion.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.VersionComparison.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a parsed Alpine APK version (version-r<pkgrel>).
|
||||
/// Handles Alpine-specific suffixes: _alpha, _beta, _pre, _rc, (none), _p
|
||||
/// </summary>
|
||||
public sealed partial class ApkVersion
|
||||
{
|
||||
private ApkVersion(string version, string? suffix, int suffixNum, int pkgRel, string original)
|
||||
{
|
||||
Version = version;
|
||||
Suffix = suffix;
|
||||
SuffixNum = suffixNum;
|
||||
PkgRel = pkgRel;
|
||||
Original = original;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base version component (without suffix or pkgrel).
|
||||
/// </summary>
|
||||
public string Version { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional suffix (_alpha, _beta, _pre, _rc, _p, or null for release).
|
||||
/// </summary>
|
||||
public string? Suffix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Numeric value after suffix (e.g., 2 in _rc2).
|
||||
/// </summary>
|
||||
public int SuffixNum { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Package release number (after -r).
|
||||
/// </summary>
|
||||
public int PkgRel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Original version string supplied to TryParse.
|
||||
/// </summary>
|
||||
public string Original { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Suffix ordering for Alpine versions.
|
||||
/// _alpha < _beta < _pre < _rc < (none/release) < _p (patch)
|
||||
/// </summary>
|
||||
public static int GetSuffixOrder(string? suffix) => suffix switch
|
||||
{
|
||||
"_alpha" => -4,
|
||||
"_beta" => -3,
|
||||
"_pre" => -2,
|
||||
"_rc" => -1,
|
||||
null or "" => 0, // Release version
|
||||
"_p" => 1, // Patch (post-release)
|
||||
_ => 0 // Unknown suffix treated as release
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns human-readable suffix name.
|
||||
/// </summary>
|
||||
public static string GetSuffixName(string? suffix) => suffix switch
|
||||
{
|
||||
"_alpha" => "alpha",
|
||||
"_beta" => "beta",
|
||||
"_pre" => "pre-release",
|
||||
"_rc" => "release candidate",
|
||||
"_p" => "patch",
|
||||
null or "" => "release",
|
||||
_ => suffix
|
||||
};
|
||||
|
||||
// Regex to parse APK version: <version>[_<suffix>[<num>]][-r<pkgrel>]
|
||||
[GeneratedRegex(@"^(?<version>[^_-]+(?:\.[^_-]+)*)(?:_(?<suffix>alpha|beta|pre|rc|p)(?<suffixnum>\d+)?)?(?:-r(?<pkgrel>\d+))?$", RegexOptions.Compiled)]
|
||||
private static partial Regex ApkVersionRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse the provided APK version string.
|
||||
/// </summary>
|
||||
public static bool TryParse(string? value, out ApkVersion? result)
|
||||
{
|
||||
result = null;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
|
||||
// Try regex match first
|
||||
var match = ApkVersionRegex().Match(trimmed);
|
||||
if (match.Success)
|
||||
{
|
||||
var version = match.Groups["version"].Value;
|
||||
var suffix = match.Groups["suffix"].Success ? "_" + match.Groups["suffix"].Value : null;
|
||||
var suffixNum = match.Groups["suffixnum"].Success
|
||||
? int.Parse(match.Groups["suffixnum"].Value, CultureInfo.InvariantCulture)
|
||||
: 0;
|
||||
var pkgRel = match.Groups["pkgrel"].Success
|
||||
? int.Parse(match.Groups["pkgrel"].Value, CultureInfo.InvariantCulture)
|
||||
: 0;
|
||||
|
||||
result = new ApkVersion(version, suffix, suffixNum, pkgRel, trimmed);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: simple parsing for versions without standard suffix format
|
||||
var pkgRelIndex = trimmed.LastIndexOf("-r", StringComparison.Ordinal);
|
||||
var versionPart = trimmed;
|
||||
var parsedPkgRel = 0;
|
||||
|
||||
if (pkgRelIndex > 0)
|
||||
{
|
||||
var pkgRelStr = trimmed[(pkgRelIndex + 2)..];
|
||||
if (int.TryParse(pkgRelStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsedPkgRel))
|
||||
{
|
||||
versionPart = trimmed[..pkgRelIndex];
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(versionPart))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = new ApkVersion(versionPart, null, 0, parsedPkgRel, trimmed);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the provided APK version string or throws FormatException.
|
||||
/// </summary>
|
||||
public static ApkVersion Parse(string value)
|
||||
{
|
||||
if (!TryParse(value, out var result))
|
||||
{
|
||||
throw new FormatException($"Input '{value}' is not a valid APK version string.");
|
||||
}
|
||||
return result!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a canonical APK version string.
|
||||
/// </summary>
|
||||
public string ToCanonicalString()
|
||||
{
|
||||
var suffixPart = Suffix != null
|
||||
? (SuffixNum > 0 ? $"{Suffix}{SuffixNum}" : Suffix)
|
||||
: string.Empty;
|
||||
var pkgRelPart = PkgRel > 0 ? $"-r{PkgRel}" : string.Empty;
|
||||
return $"{Version}{suffixPart}{pkgRelPart}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Original;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.VersionComparison.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a parsed Debian epoch:version-revision tuple.
|
||||
/// </summary>
|
||||
public sealed class DebianVersion
|
||||
{
|
||||
private DebianVersion(int epoch, bool hasExplicitEpoch, string version, string revision, string original)
|
||||
{
|
||||
Epoch = epoch;
|
||||
HasExplicitEpoch = hasExplicitEpoch;
|
||||
Version = version;
|
||||
Revision = revision;
|
||||
Original = original;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Epoch segment (defaults to 0 when omitted).
|
||||
/// </summary>
|
||||
public int Epoch { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether an epoch segment was present explicitly.
|
||||
/// </summary>
|
||||
public bool HasExplicitEpoch { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Upstream version portion (without revision).
|
||||
/// </summary>
|
||||
public string Version { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Debian revision portion (after the last dash). Empty when omitted.
|
||||
/// </summary>
|
||||
public string Revision { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Original EVR string supplied to TryParse.
|
||||
/// </summary>
|
||||
public string Original { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse the provided EVR string.
|
||||
/// </summary>
|
||||
public static bool TryParse(string? value, out DebianVersion? result)
|
||||
{
|
||||
result = null;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var epoch = 0;
|
||||
var hasExplicitEpoch = false;
|
||||
var remainder = trimmed;
|
||||
|
||||
var colonIndex = remainder.IndexOf(':');
|
||||
if (colonIndex >= 0)
|
||||
{
|
||||
if (colonIndex == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var epochPart = remainder[..colonIndex];
|
||||
if (!int.TryParse(epochPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out epoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
hasExplicitEpoch = true;
|
||||
remainder = colonIndex < remainder.Length - 1 ? remainder[(colonIndex + 1)..] : string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(remainder))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var version = remainder;
|
||||
var revision = string.Empty;
|
||||
|
||||
var dashIndex = remainder.LastIndexOf('-');
|
||||
if (dashIndex > 0)
|
||||
{
|
||||
version = remainder[..dashIndex];
|
||||
revision = dashIndex < remainder.Length - 1 ? remainder[(dashIndex + 1)..] : string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(version))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = new DebianVersion(epoch, hasExplicitEpoch, version, revision, trimmed);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the provided EVR string or throws FormatException.
|
||||
/// </summary>
|
||||
public static DebianVersion Parse(string value)
|
||||
{
|
||||
if (!TryParse(value, out var result))
|
||||
{
|
||||
throw new FormatException($"Input '{value}' is not a valid Debian EVR string.");
|
||||
}
|
||||
return result!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a canonical EVR string.
|
||||
/// </summary>
|
||||
public string ToCanonicalString()
|
||||
{
|
||||
var epochSegment = HasExplicitEpoch || Epoch > 0 ? $"{Epoch}:" : string.Empty;
|
||||
var revisionSegment = string.IsNullOrEmpty(Revision) ? string.Empty : $"-{Revision}";
|
||||
return $"{epochSegment}{Version}{revisionSegment}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Original;
|
||||
}
|
||||
130
src/__Libraries/StellaOps.VersionComparison/Models/RpmVersion.cs
Normal file
130
src/__Libraries/StellaOps.VersionComparison/Models/RpmVersion.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.VersionComparison.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a parsed RPM EVR (Epoch:Version-Release) identifier.
|
||||
/// </summary>
|
||||
public sealed class RpmVersion
|
||||
{
|
||||
private RpmVersion(int epoch, bool hasExplicitEpoch, string version, string release, string original)
|
||||
{
|
||||
Epoch = epoch;
|
||||
HasExplicitEpoch = hasExplicitEpoch;
|
||||
Version = version;
|
||||
Release = release;
|
||||
Original = original;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Epoch segment (defaults to 0 when omitted).
|
||||
/// </summary>
|
||||
public int Epoch { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether an epoch segment was present explicitly.
|
||||
/// </summary>
|
||||
public bool HasExplicitEpoch { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Version component (without epoch or release).
|
||||
/// </summary>
|
||||
public string Version { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Release component.
|
||||
/// </summary>
|
||||
public string Release { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Original EVR string supplied to TryParse.
|
||||
/// </summary>
|
||||
public string Original { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse the provided EVR string.
|
||||
/// </summary>
|
||||
public static bool TryParse(string? value, out RpmVersion? result)
|
||||
{
|
||||
result = null;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var epoch = 0;
|
||||
var hasExplicitEpoch = false;
|
||||
var remainder = trimmed;
|
||||
|
||||
// Parse epoch
|
||||
var colonIndex = remainder.IndexOf(':');
|
||||
if (colonIndex >= 0)
|
||||
{
|
||||
if (colonIndex == 0)
|
||||
{
|
||||
hasExplicitEpoch = true;
|
||||
remainder = remainder[1..];
|
||||
}
|
||||
else
|
||||
{
|
||||
var epochPart = remainder[..colonIndex];
|
||||
if (!int.TryParse(epochPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out epoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
hasExplicitEpoch = true;
|
||||
remainder = colonIndex < remainder.Length - 1 ? remainder[(colonIndex + 1)..] : string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(remainder))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse version and release
|
||||
var version = remainder;
|
||||
var release = string.Empty;
|
||||
|
||||
var dashIndex = remainder.LastIndexOf('-');
|
||||
if (dashIndex > 0)
|
||||
{
|
||||
version = remainder[..dashIndex];
|
||||
release = dashIndex < remainder.Length - 1 ? remainder[(dashIndex + 1)..] : string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(version))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = new RpmVersion(epoch, hasExplicitEpoch, version, release, trimmed);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the provided EVR string or throws FormatException.
|
||||
/// </summary>
|
||||
public static RpmVersion Parse(string value)
|
||||
{
|
||||
if (!TryParse(value, out var result))
|
||||
{
|
||||
throw new FormatException($"Input '{value}' is not a valid RPM EVR string.");
|
||||
}
|
||||
return result!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a canonical EVR string.
|
||||
/// </summary>
|
||||
public string ToCanonicalString()
|
||||
{
|
||||
var epochSegment = HasExplicitEpoch || Epoch > 0 ? $"{Epoch}:" : string.Empty;
|
||||
var releaseSegment = string.IsNullOrEmpty(Release) ? string.Empty : $"-{Release}";
|
||||
return $"{epochSegment}{Version}{releaseSegment}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Original;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>Distro-native version comparison with proof-line generation for RPM, Debian, and Alpine packages.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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