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

@@ -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.

View File

@@ -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`).

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);
}
}

View File

@@ -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 &lt; right, zero if equal, positive if left &gt; 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 &lt; right, zero if equal, positive if left &gt; 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);
}

View 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&lt;pkgrel&gt;).
/// 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 &lt; _beta &lt; _pre &lt; _rc &lt; (none/release) &lt; _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;
}

View File

@@ -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;
}

View 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;
}

View File

@@ -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>

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>