Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

@@ -0,0 +1,88 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Distro.Alpine;
using StellaOps.Concelier.Connector.Distro.Alpine.Configuration;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.Distro.Alpine.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class AlpineConnectorTests
{
private static readonly Uri SecDbUri = new("https://secdb.alpinelinux.org/v3.20/main.json");
private readonly ConcelierPostgresFixture _fixture;
public AlpineConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task FetchParseMap_StoresAdvisoriesAndUpdatesCursor()
{
await using var harness = await BuildHarnessAsync();
harness.Handler.AddJsonResponse(SecDbUri, BuildMinimalSecDb());
var connector = harness.ServiceProvider.GetRequiredService<AlpineConnector>();
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
await connector.ParseAsync(harness.ServiceProvider, CancellationToken.None);
await connector.MapAsync(harness.ServiceProvider, CancellationToken.None);
var advisoryStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(2, advisories.Count);
var advisory = advisories.Single(item => item.AdvisoryKey == "alpine/cve-2021-36159");
var package = Assert.Single(advisory.AffectedPackages);
Assert.Equal(AffectedPackageTypes.Apk, package.Type);
Assert.Equal("apk-tools", package.Identifier);
Assert.Equal("v3.20/main", package.Platform);
var range = Assert.Single(package.VersionRanges);
Assert.Equal("apk", range.RangeKind);
Assert.Equal("2.12.6-r0", range.FixedVersion);
var stateRepository = harness.ServiceProvider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(AlpineConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs)
&& pendingDocs.AsDocumentArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings)
&& pendingMappings.AsDocumentArray.Count == 0);
harness.Handler.AssertNoPendingResponses();
}
private async Task<ConnectorTestHarness> BuildHarnessAsync()
{
var initialTime = new DateTimeOffset(2025, 12, 22, 0, 0, 0, TimeSpan.Zero);
var harness = new ConnectorTestHarness(_fixture, initialTime, AlpineOptions.HttpClientName);
await harness.EnsureServiceProviderAsync(services =>
{
services.AddAlpineConnector(options =>
{
options.BaseUri = new Uri("https://secdb.alpinelinux.org/");
options.Releases = new[] { "v3.20" };
options.Repositories = new[] { "main" };
options.MaxDocumentsPerFetch = 1;
options.FetchTimeout = TimeSpan.FromSeconds(5);
options.RequestDelay = TimeSpan.Zero;
options.UserAgent = "StellaOps.Tests.Alpine/1.0";
});
});
return harness;
}
private static string BuildMinimalSecDb()
=> "{\"distroversion\":\"v3.20\",\"reponame\":\"main\",\"urlprefix\":\"https://dl-cdn.alpinelinux.org/alpine\",\"packages\":[{\"pkg\":{\"name\":\"apk-tools\",\"secfixes\":{\"2.12.6-r0\":[\"CVE-2021-36159\"]}}},{\"pkg\":{\"name\":\"busybox\",\"secfixes\":{\"1.36.1-r29\":[\"CVE-2023-42364\"]}}}]}";
}

View File

@@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Distro.Alpine;
using StellaOps.Concelier.Connector.Distro.Alpine.Configuration;
using Xunit;
namespace StellaOps.Concelier.Connector.Distro.Alpine.Tests;
public sealed class AlpineDependencyInjectionRoutineTests
{
[Fact]
public void Register_ConfiguresOptionsAndScheduler()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddOptions();
services.AddSourceCommon();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["concelier:sources:alpine:baseUri"] = "https://secdb.alpinelinux.org/",
["concelier:sources:alpine:releases:0"] = "v3.20",
["concelier:sources:alpine:repositories:0"] = "main",
["concelier:sources:alpine:maxDocumentsPerFetch"] = "5",
["concelier:sources:alpine:fetchTimeout"] = "00:00:30",
["concelier:sources:alpine:requestDelay"] = "00:00:00.100",
["concelier:sources:alpine:userAgent"] = "StellaOps.Tests.Alpine/1.0"
})
.Build();
var routine = new AlpineDependencyInjectionRoutine();
routine.Register(services, configuration);
services.Configure<JobSchedulerOptions>(_ => { });
using var provider = services.BuildServiceProvider(validateScopes: true);
var options = provider.GetRequiredService<IOptions<AlpineOptions>>().Value;
Assert.Equal(new Uri("https://secdb.alpinelinux.org/"), options.BaseUri);
Assert.Equal(new[] { "v3.20" }, options.Releases);
Assert.Equal(new[] { "main" }, options.Repositories);
Assert.Equal(5, options.MaxDocumentsPerFetch);
Assert.Equal(TimeSpan.FromSeconds(30), options.FetchTimeout);
Assert.Equal(TimeSpan.FromMilliseconds(100), options.RequestDelay);
Assert.Equal("StellaOps.Tests.Alpine/1.0", options.UserAgent);
var schedulerOptions = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
Assert.True(schedulerOptions.Definitions.TryGetValue(AlpineJobKinds.Fetch, out var fetchDefinition));
Assert.True(schedulerOptions.Definitions.TryGetValue(AlpineJobKinds.Parse, out var parseDefinition));
Assert.True(schedulerOptions.Definitions.TryGetValue(AlpineJobKinds.Map, out var mapDefinition));
Assert.Equal(typeof(AlpineFetchJob), fetchDefinition.JobType);
Assert.Equal(TimeSpan.FromMinutes(5), fetchDefinition.Timeout);
Assert.Equal(TimeSpan.FromMinutes(4), fetchDefinition.LeaseDuration);
Assert.Equal("*/30 * * * *", fetchDefinition.CronExpression);
Assert.True(fetchDefinition.Enabled);
Assert.Equal(typeof(AlpineParseJob), parseDefinition.JobType);
Assert.Equal(TimeSpan.FromMinutes(6), parseDefinition.Timeout);
Assert.Equal(TimeSpan.FromMinutes(4), parseDefinition.LeaseDuration);
Assert.Equal("7,37 * * * *", parseDefinition.CronExpression);
Assert.True(parseDefinition.Enabled);
Assert.Equal(typeof(AlpineMapJob), mapDefinition.JobType);
Assert.Equal(TimeSpan.FromMinutes(8), mapDefinition.Timeout);
Assert.Equal(TimeSpan.FromMinutes(4), mapDefinition.LeaseDuration);
Assert.Equal("12,42 * * * *", mapDefinition.CronExpression);
Assert.True(mapDefinition.Enabled);
}
}

View File

@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using StellaOps.Concelier.Connector.Distro.Alpine.Dto;
using StellaOps.Concelier.Connector.Distro.Alpine.Internal;
using StellaOps.Concelier.Merge.Comparers;
namespace StellaOps.Concelier.Connector.Distro.Alpine.Tests;
internal static class AlpineFixtureReader
{
private static readonly StringComparer NameComparer = StringComparer.OrdinalIgnoreCase;
public static AlpineSecDbDto LoadDto(string filename)
=> AlpineSecDbParser.Parse(ReadFixture(filename));
public static AlpineSecDbDto FilterPackages(
AlpineSecDbDto dto,
IReadOnlyCollection<string> packageNames,
int maxVersionsPerPackage = 0)
{
if (packageNames is null || packageNames.Count == 0)
{
return dto;
}
var allowed = new HashSet<string>(
packageNames.Where(static name => !string.IsNullOrWhiteSpace(name))
.Select(static name => name.Trim()),
NameComparer);
var packages = dto.Packages
.Where(pkg => allowed.Contains(pkg.Name))
.Select(pkg => pkg with { Secfixes = TrimSecfixes(pkg.Secfixes, maxVersionsPerPackage) })
.OrderBy(pkg => pkg.Name, NameComparer)
.ToList();
return dto with { Packages = packages };
}
public static string NormalizeSnapshot(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd();
public static string ReadFixture(string filename)
{
var path = ResolveFixturePath(filename);
return File.ReadAllText(path);
}
public static string GetWritableFixturePath(string filename)
=> Path.Combine(GetProjectRoot(), "Source", "Distro", "Alpine", "Fixtures", filename);
private static string ResolveFixturePath(string filename)
{
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "Source", "Distro", "Alpine", "Fixtures", filename),
Path.Combine(AppContext.BaseDirectory, "Fixtures", filename),
Path.Combine(GetProjectRoot(), "Source", "Distro", "Alpine", "Fixtures", filename),
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return candidate;
}
}
throw new FileNotFoundException($"Fixture '{filename}' not found.", filename);
}
private static IReadOnlyDictionary<string, string[]> TrimSecfixes(
IReadOnlyDictionary<string, string[]> secfixes,
int maxVersions)
{
if (secfixes is null || secfixes.Count == 0)
{
return new Dictionary<string, string[]>(NameComparer);
}
if (maxVersions <= 0 || secfixes.Count <= maxVersions)
{
return new Dictionary<string, string[]>(secfixes, NameComparer);
}
var comparer = Comparer<string>.Create((left, right) => ApkVersionComparer.Instance.Compare(left, right));
var orderedKeys = secfixes.Keys.OrderBy(static key => key, comparer).ToList();
var skip = Math.Max(0, orderedKeys.Count - maxVersions);
var trimmed = new Dictionary<string, string[]>(NameComparer);
for (var i = skip; i < orderedKeys.Count; i++)
{
var key = orderedKeys[i];
trimmed[key] = secfixes[key];
}
return trimmed;
}
private static string GetProjectRoot()
=> Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", ".."));
}

View File

@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Distro.Alpine;
using StellaOps.Concelier.Connector.Distro.Alpine.Dto;
using StellaOps.Concelier.Connector.Distro.Alpine.Internal;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage;
using Xunit;
namespace StellaOps.Concelier.Connector.Distro.Alpine.Tests;
public sealed class AlpineMapperTests
{
[Fact]
public void Map_BuildsApkAdvisoriesWithRanges()
{
var dto = new AlpineSecDbDto(
DistroVersion: "v3.20",
RepoName: "main",
UrlPrefix: "https://dl-cdn.alpinelinux.org/alpine",
Packages: new[]
{
new AlpinePackageDto(
"apk-tools",
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["2.12.6-r0"] = new[] { "CVE-2021-36159" }
}),
new AlpinePackageDto(
"busybox",
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["1.36.1-r29"] = new[] { "CVE-2023-42364" }
})
});
var recordedAt = new DateTimeOffset(2025, 12, 22, 0, 0, 0, TimeSpan.Zero);
var document = BuildDocument("https://secdb.alpinelinux.org/v3.20/main.json", recordedAt);
var advisories = AlpineMapper.Map(dto, document, recordedAt);
Assert.Equal(2, advisories.Count);
var apkToolsAdvisory = advisories.Single(advisory => advisory.AdvisoryKey == "alpine/cve-2021-36159");
Assert.Contains("CVE-2021-36159", apkToolsAdvisory.Aliases);
var apkPackage = Assert.Single(apkToolsAdvisory.AffectedPackages);
Assert.Equal(AffectedPackageTypes.Apk, apkPackage.Type);
Assert.Equal("apk-tools", apkPackage.Identifier);
Assert.Equal("v3.20/main", apkPackage.Platform);
var range = Assert.Single(apkPackage.VersionRanges);
Assert.Equal("apk", range.RangeKind);
Assert.Equal("2.12.6-r0", range.FixedVersion);
Assert.Equal("fixed:2.12.6-r0", range.RangeExpression);
Assert.NotNull(range.Primitives?.VendorExtensions);
Assert.Equal("v3.20", range.Primitives!.VendorExtensions["alpine.distroversion"]);
Assert.Equal("main", range.Primitives.VendorExtensions["alpine.repo"]);
}
private static DocumentRecord BuildDocument(string uri, DateTimeOffset recordedAt)
{
return new DocumentRecord(
Guid.NewGuid(),
AlpineConnectorPlugin.SourceName,
uri,
recordedAt,
new string('0', 64),
DocumentStatuses.Mapped,
"application/json",
Headers: null,
Metadata: null,
Etag: null,
LastModified: recordedAt,
PayloadId: null);
}
}

View File

@@ -0,0 +1,26 @@
using System.Linq;
using StellaOps.Concelier.Connector.Distro.Alpine.Dto;
using Xunit;
namespace StellaOps.Concelier.Connector.Distro.Alpine.Tests;
public sealed class AlpineSecDbParserTests
{
[Fact]
public void Parse_SecDbFixture_ExtractsPackagesAndMetadata()
{
var dto = AlpineFixtureReader.LoadDto("v3.20-main.json");
Assert.Equal("v3.20", dto.DistroVersion);
Assert.Equal("main", dto.RepoName);
Assert.Equal("https://dl-cdn.alpinelinux.org/alpine", dto.UrlPrefix);
Assert.NotEmpty(dto.Packages);
var apkTools = dto.Packages.Single(pkg => pkg.Name == "apk-tools");
Assert.True(apkTools.Secfixes.ContainsKey("2.12.6-r0"));
Assert.Contains("CVE-2021-36159", apkTools.Secfixes["2.12.6-r0"]);
var busybox = dto.Packages.Single(pkg => pkg.Name == "busybox");
Assert.True(busybox.Secfixes.Keys.Any());
}
}

View File

@@ -0,0 +1,78 @@
using System;
using System.IO;
using System.Linq;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Distro.Alpine;
using StellaOps.Concelier.Connector.Distro.Alpine.Dto;
using StellaOps.Concelier.Connector.Distro.Alpine.Internal;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage;
using Xunit;
namespace StellaOps.Concelier.Connector.Distro.Alpine.Tests;
public sealed class AlpineSnapshotTests
{
[Theory]
[InlineData("v3.18-main.json", "alpine-v3.18-main.snapshot.json", "2025-12-22T00:00:00Z")]
[InlineData("v3.19-main.json", "alpine-v3.19-main.snapshot.json", "2025-12-22T00:10:00Z")]
[InlineData("v3.20-main.json", "alpine-v3.20-main.snapshot.json", "2025-12-22T00:20:00Z")]
public void Snapshot_FixturesMatchGolden(string fixtureFile, string snapshotFile, string recordedAt)
{
var dto = AlpineFixtureReader.LoadDto(fixtureFile);
var filtered = AlpineFixtureReader.FilterPackages(
dto,
new[] { "apk-tools", "busybox", "zlib" },
maxVersionsPerPackage: 2);
var recorded = DateTimeOffset.Parse(recordedAt);
var document = BuildDocument(filtered, recorded);
var advisories = AlpineMapper.Map(filtered, document, recorded);
var ordered = advisories
.OrderBy(advisory => advisory.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
.ToArray();
var snapshot = AlpineFixtureReader.NormalizeSnapshot(SnapshotSerializer.ToSnapshot(ordered));
var snapshotPath = AlpineFixtureReader.GetWritableFixturePath(snapshotFile);
if (ShouldUpdateGoldens() || !File.Exists(snapshotPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!);
File.WriteAllText(snapshotPath, snapshot);
return;
}
var expected = AlpineFixtureReader.NormalizeSnapshot(File.ReadAllText(snapshotPath));
Assert.Equal(expected, snapshot);
}
private static DocumentRecord BuildDocument(AlpineSecDbDto dto, DateTimeOffset recordedAt)
{
var uri = new Uri(new Uri("https://secdb.alpinelinux.org/"), $"{dto.DistroVersion}/{dto.RepoName}.json");
return new DocumentRecord(
Guid.NewGuid(),
AlpineConnectorPlugin.SourceName,
uri.ToString(),
recordedAt,
new string('0', 64),
DocumentStatuses.Mapped,
"application/json",
Headers: null,
Metadata: null,
Etag: null,
LastModified: recordedAt,
PayloadId: null);
}
private static bool ShouldUpdateGoldens()
=> IsTruthy(Environment.GetEnvironmentVariable("UPDATE_GOLDENS"))
|| IsTruthy(Environment.GetEnvironmentVariable("DOTNET_TEST_UPDATE_GOLDENS"));
private static bool IsTruthy(string? value)
=> !string.IsNullOrWhiteSpace(value)
&& (string.Equals(value, "1", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase));
}

View File

@@ -0,0 +1,20 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Distro.Alpine/StellaOps.Concelier.Connector.Distro.Alpine.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Merge/StellaOps.Concelier.Merge.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Source\Distro\Alpine\Fixtures\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,349 @@
using System.Globalization;
using System.IO.Compression;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Epss;
using StellaOps.Concelier.Connector.Epss.Configuration;
using StellaOps.Concelier.Connector.Epss.Internal;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Storage;
using StellaOps.Cryptography;
using StellaOps.Scanner.Storage.Epss;
using Xunit;
namespace StellaOps.Concelier.Connector.Epss.Tests;
public sealed class EpssConnectorTests
{
[Fact]
public async Task FetchAsync_StoresDocument_OnSuccess()
{
var options = CreateOptions();
var date = DateOnly.FromDateTime(DateTime.UtcNow);
var fileName = $"epss_scores-{date:yyyy-MM-dd}.csv.gz";
var uri = new Uri(options.BaseUri, fileName);
var payload = BuildSampleGzip(date);
var handler = new CannedHttpMessageHandler();
handler.AddResponse(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(payload)
};
response.Headers.ETag = new EntityTagHeaderValue("\"epss-etag\"");
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/gzip");
return response;
});
var documentStore = new InMemoryDocumentStore();
var dtoStore = new InMemoryDtoStore();
var stateRepository = new InMemorySourceStateRepository();
var connector = CreateConnector(handler, documentStore, dtoStore, stateRepository, options);
await connector.FetchAsync(new ServiceCollection().BuildServiceProvider(), CancellationToken.None);
var record = await documentStore.FindBySourceAndUriAsync(EpssConnectorPlugin.SourceName, uri.ToString(), CancellationToken.None);
Assert.NotNull(record);
Assert.Equal(DocumentStatuses.PendingParse, record!.Status);
var state = await stateRepository.TryGetAsync(EpssConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var cursor = EpssCursor.FromDocument(state!.Cursor);
Assert.Contains(record.Id, cursor.PendingDocuments);
}
[Fact]
public async Task FetchAsync_ReturnsNotModified_OnEtagMatch()
{
var options = CreateOptions();
var date = DateOnly.FromDateTime(DateTime.UtcNow);
var fileName = $"epss_scores-{date:yyyy-MM-dd}.csv.gz";
var uri = new Uri(options.BaseUri, fileName);
var documentStore = new InMemoryDocumentStore();
var dtoStore = new InMemoryDtoStore();
var stateRepository = new InMemorySourceStateRepository();
var existing = new DocumentRecord(
Guid.NewGuid(),
EpssConnectorPlugin.SourceName,
uri.ToString(),
DateTimeOffset.UtcNow,
"sha256-previous",
DocumentStatuses.Mapped,
"application/gzip",
Headers: null,
Metadata: null,
Etag: "\"epss-etag\"",
LastModified: DateTimeOffset.UtcNow,
PayloadId: null,
ExpiresAt: null,
Payload: null);
await documentStore.UpsertAsync(existing, CancellationToken.None);
await stateRepository.UpdateCursorAsync(
EpssConnectorPlugin.SourceName,
EpssCursor.Empty with { ETag = "\"epss-etag\"" }.ToDocumentObject(),
DateTimeOffset.UtcNow,
CancellationToken.None);
var handler = new CannedHttpMessageHandler();
handler.AddResponse(uri, () => new HttpResponseMessage(HttpStatusCode.NotModified));
var connector = CreateConnector(handler, documentStore, dtoStore, stateRepository, options);
await connector.FetchAsync(new ServiceCollection().BuildServiceProvider(), CancellationToken.None);
var record = await documentStore.FindBySourceAndUriAsync(EpssConnectorPlugin.SourceName, uri.ToString(), CancellationToken.None);
Assert.NotNull(record);
Assert.Equal("\"epss-etag\"", record!.Etag);
var state = await stateRepository.TryGetAsync(EpssConnectorPlugin.SourceName, CancellationToken.None);
var cursor = EpssCursor.FromDocument(state!.Cursor);
Assert.Empty(cursor.PendingDocuments);
}
[Fact]
public async Task ParseAsync_CreatesDto_AndUpdatesStatus()
{
var options = CreateOptions();
var date = DateOnly.FromDateTime(DateTime.UtcNow);
var fileName = $"epss_scores-{date:yyyy-MM-dd}.csv.gz";
var uri = new Uri(options.BaseUri, fileName);
var payload = BuildSampleGzip(date);
var documentStore = new InMemoryDocumentStore();
var dtoStore = new InMemoryDtoStore();
var stateRepository = new InMemorySourceStateRepository();
var recordId = Guid.NewGuid();
var rawStorage = new RawDocumentStorage(documentStore);
await rawStorage.UploadAsync(EpssConnectorPlugin.SourceName, uri.ToString(), payload, "application/gzip", CancellationToken.None, recordId);
var document = new DocumentRecord(
recordId,
EpssConnectorPlugin.SourceName,
uri.ToString(),
DateTimeOffset.UtcNow,
"sha256-test",
DocumentStatuses.PendingParse,
"application/gzip",
Headers: null,
Metadata: new Dictionary<string, string> { ["epss.date"] = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) },
Etag: null,
LastModified: null,
PayloadId: recordId,
ExpiresAt: null,
Payload: payload);
await documentStore.UpsertAsync(document, CancellationToken.None);
await stateRepository.UpdateCursorAsync(
EpssConnectorPlugin.SourceName,
EpssCursor.Empty with { PendingDocuments = new[] { recordId } }.ToDocumentObject(),
DateTimeOffset.UtcNow,
CancellationToken.None);
var connector = CreateConnector(rawStorage, documentStore, dtoStore, stateRepository, options);
await connector.ParseAsync(new ServiceCollection().BuildServiceProvider(), CancellationToken.None);
var dto = await dtoStore.FindByDocumentIdAsync(recordId, CancellationToken.None);
Assert.NotNull(dto);
var updated = await documentStore.FindAsync(recordId, CancellationToken.None);
Assert.Equal(DocumentStatuses.PendingMap, updated!.Status);
}
[Fact]
public async Task MapAsync_MarksDocumentMapped()
{
var options = CreateOptions();
var date = DateOnly.FromDateTime(DateTime.UtcNow);
var fileName = $"epss_scores-{date:yyyy-MM-dd}.csv.gz";
var uri = new Uri(options.BaseUri, fileName);
var payload = BuildSampleGzip(date);
var documentStore = new InMemoryDocumentStore();
var dtoStore = new InMemoryDtoStore();
var stateRepository = new InMemorySourceStateRepository();
var recordId = Guid.NewGuid();
var rawStorage = new RawDocumentStorage(documentStore);
await rawStorage.UploadAsync(EpssConnectorPlugin.SourceName, uri.ToString(), payload, "application/gzip", CancellationToken.None, recordId);
var document = new DocumentRecord(
recordId,
EpssConnectorPlugin.SourceName,
uri.ToString(),
DateTimeOffset.UtcNow,
"sha256-test",
DocumentStatuses.PendingMap,
"application/gzip",
Headers: null,
Metadata: new Dictionary<string, string> { ["epss.date"] = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) },
Etag: null,
LastModified: null,
PayloadId: recordId,
ExpiresAt: null,
Payload: payload);
await documentStore.UpsertAsync(document, CancellationToken.None);
var dtoPayload = new DocumentObject
{
["modelVersion"] = $"v{date:yyyy.MM.dd}",
["publishedDate"] = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
["rowCount"] = 2,
["contentHash"] = "sha256:placeholder"
};
await dtoStore.UpsertAsync(new DtoRecord(
Guid.NewGuid(),
recordId,
EpssConnectorPlugin.SourceName,
"epss.snapshot.v1",
dtoPayload,
DateTimeOffset.UtcNow), CancellationToken.None);
await stateRepository.UpdateCursorAsync(
EpssConnectorPlugin.SourceName,
EpssCursor.Empty with { PendingMappings = new[] { recordId } }.ToDocumentObject(),
DateTimeOffset.UtcNow,
CancellationToken.None);
var connector = CreateConnector(rawStorage, documentStore, dtoStore, stateRepository, options);
await connector.MapAsync(new ServiceCollection().BuildServiceProvider(), CancellationToken.None);
var updated = await documentStore.FindAsync(recordId, CancellationToken.None);
Assert.Equal(DocumentStatuses.Mapped, updated!.Status);
}
[Theory]
[InlineData(0.75, EpssBand.Critical)]
[InlineData(0.55, EpssBand.High)]
[InlineData(0.25, EpssBand.Medium)]
[InlineData(0.05, EpssBand.Low)]
public void ToObservation_AssignsBand(double score, EpssBand expected)
{
var row = new EpssScoreRow("CVE-2025-0001", score, 0.5);
var observation = EpssMapper.ToObservation(row, "v2025.12.21", new DateOnly(2025, 12, 21));
Assert.Equal(expected, observation.Band);
}
[Fact]
public void EpssCursor_Empty_UsesMinValue()
{
var cursor = EpssCursor.Empty;
Assert.Equal(DateTimeOffset.MinValue, cursor.UpdatedAt);
Assert.Empty(cursor.PendingDocuments);
Assert.Empty(cursor.PendingMappings);
}
private static EpssOptions CreateOptions()
=> new()
{
BaseUri = new Uri("https://epss.example/"),
FetchCurrent = true,
CatchUpDays = 0,
HttpTimeout = TimeSpan.FromSeconds(10),
MaxRetries = 0,
AirgapMode = false
};
private static EpssConnector CreateConnector(
CannedHttpMessageHandler handler,
IDocumentStore documentStore,
IDtoStore dtoStore,
ISourceStateRepository stateRepository,
EpssOptions options)
{
var client = handler.CreateClient();
var factory = new SingleClientFactory(client);
var rawStorage = new RawDocumentStorage(documentStore);
var diagnostics = new EpssDiagnostics();
var hash = DefaultCryptoHash.CreateForTests();
return new EpssConnector(
factory,
rawStorage,
documentStore,
dtoStore,
stateRepository,
Options.Create(options),
diagnostics,
hash,
TimeProvider.System,
NullLogger<EpssConnector>.Instance);
}
private static EpssConnector CreateConnector(
RawDocumentStorage rawStorage,
IDocumentStore documentStore,
IDtoStore dtoStore,
ISourceStateRepository stateRepository,
EpssOptions options)
{
var client = new HttpClient();
var factory = new SingleClientFactory(client);
var diagnostics = new EpssDiagnostics();
var hash = DefaultCryptoHash.CreateForTests();
return new EpssConnector(
factory,
rawStorage,
documentStore,
dtoStore,
stateRepository,
Options.Create(options),
diagnostics,
hash,
TimeProvider.System,
NullLogger<EpssConnector>.Instance);
}
private static byte[] BuildSampleGzip(DateOnly date)
{
var modelVersion = $"v{date:yyyy.MM.dd}";
var lines = new[]
{
$"# model {modelVersion}",
$"# date {date:yyyy-MM-dd}",
"cve,epss,percentile",
"CVE-2024-0001,0.42,0.91",
"CVE-2024-0002,0.82,0.99"
};
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true))
using (var writer = new StreamWriter(gzip, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), leaveOpen: true))
{
foreach (var line in lines)
{
writer.WriteLine(line);
}
}
return output.ToArray();
}
private sealed class SingleClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientFactory(HttpClient client)
=> _client = client;
public HttpClient CreateClient(string name) => _client;
}
}

View File

@@ -0,0 +1,16 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Epss/StellaOps.Concelier.Connector.Epss.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../../Scanner/__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using StellaOps.Concelier.Merge.Comparers;
using StellaOps.Concelier.Normalization.Distro;
using Xunit;
namespace StellaOps.Concelier.Integration.Tests;
public sealed class DistroVersionCrossCheckTests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
[IntegrationFact]
public async Task CrossCheck_InstalledVersionsMatchComparers()
{
var fixtures = LoadFixtures();
var groups = fixtures
.GroupBy(fixture => fixture.Image, StringComparer.OrdinalIgnoreCase)
.ToArray();
foreach (var group in groups)
{
await using var container = new ContainerBuilder()
.WithImage(group.Key)
.WithCommand("sh", "-c", "sleep 3600")
.Build();
await container.StartAsync();
foreach (var fixture in group)
{
var installed = await GetInstalledVersionAsync(container, fixture, CancellationToken.None);
var actual = CompareVersions(fixture, installed);
Assert.Equal(fixture.ExpectedComparison, actual);
}
}
}
private static async Task<string> GetInstalledVersionAsync(
IContainer container,
DistroVersionFixture fixture,
CancellationToken ct)
{
var output = fixture.Distro switch
{
"rpm" => await RunCommandAsync(container,
$"rpm -q --qf '%{{NAME}}-%{{EPOCHNUM}}:%{{VERSION}}-%{{RELEASE}}.%{{ARCH}}' {fixture.Package}", ct),
"deb" => await RunCommandAsync(container,
$"dpkg-query -W -f='${{Version}}' {fixture.Package}", ct),
"apk" => await RunCommandAsync(container, $"apk info -v {fixture.Package}", ct),
_ => throw new InvalidOperationException($"Unsupported distro: {fixture.Distro}")
};
return fixture.Distro switch
{
"apk" => ExtractApkVersion(fixture.Package, output),
_ => output.Trim()
};
}
private static int CompareVersions(DistroVersionFixture fixture, string installedVersion)
{
return fixture.Distro switch
{
"rpm" => Math.Sign(CompareRpm(installedVersion, fixture.FixedVersion)),
"deb" => Math.Sign(DebianEvrComparer.Instance.Compare(installedVersion, fixture.FixedVersion)),
"apk" => Math.Sign(ApkVersionComparer.Instance.Compare(installedVersion, fixture.FixedVersion)),
_ => throw new InvalidOperationException($"Unsupported distro: {fixture.Distro}")
};
}
private static int CompareRpm(string installed, string fixedEvr)
{
if (!Nevra.TryParse(installed, out var nevra) || nevra is null)
{
throw new InvalidOperationException($"Unable to parse NEVRA '{installed}'.");
}
var fixedNevra = $"{nevra.Name}-{fixedEvr}";
if (!string.IsNullOrWhiteSpace(nevra.Architecture))
{
fixedNevra = $"{fixedNevra}.{nevra.Architecture}";
}
return NevraComparer.Instance.Compare(installed, fixedNevra);
}
private static async Task<string> RunCommandAsync(IContainer container, string command, CancellationToken ct)
{
var result = await container.ExecAsync(new[] { "sh", "-c", command }, ct);
if (result.ExitCode != 0)
{
throw new InvalidOperationException($"Command failed ({result.ExitCode}): {command}\n{result.Stderr}");
}
return result.Stdout.Trim();
}
private static string ExtractApkVersion(string package, string output)
{
var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
var prefix = package + "-";
foreach (var line in lines)
{
var trimmed = line.Trim();
if (trimmed.StartsWith(prefix, StringComparison.Ordinal))
{
return trimmed[prefix.Length..];
}
}
return lines.Length > 0 ? lines[0].Trim() : string.Empty;
}
private static List<DistroVersionFixture> LoadFixtures()
{
var path = ResolveFixturePath("distro-version-crosscheck.json");
var payload = File.ReadAllText(path);
var fixtures = JsonSerializer.Deserialize<List<DistroVersionFixture>>(payload, JsonOptions)
?? new List<DistroVersionFixture>();
return fixtures;
}
private static string ResolveFixturePath(string filename)
{
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "Fixtures", filename),
Path.Combine(GetProjectRoot(), "Fixtures", filename)
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return candidate;
}
}
throw new FileNotFoundException($"Fixture '{filename}' not found.", filename);
}
private static string GetProjectRoot()
=> Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", ".."));
private sealed record DistroVersionFixture(
string Image,
string Distro,
string Package,
string FixedVersion,
int ExpectedComparison,
string? Note);
}

View File

@@ -0,0 +1,98 @@
[
{
"image": "registry.access.redhat.com/ubi9:latest",
"distro": "rpm",
"package": "glibc",
"fixedVersion": "0:0-0",
"expectedComparison": 1,
"note": "baseline floor"
},
{
"image": "registry.access.redhat.com/ubi9:latest",
"distro": "rpm",
"package": "rpm",
"fixedVersion": "0:0-0",
"expectedComparison": 1,
"note": "baseline floor"
},
{
"image": "registry.access.redhat.com/ubi9:latest",
"distro": "rpm",
"package": "openssl-libs",
"fixedVersion": "0:0-0",
"expectedComparison": 1,
"note": "baseline floor"
},
{
"image": "debian:12-slim",
"distro": "deb",
"package": "dpkg",
"fixedVersion": "0",
"expectedComparison": 1,
"note": "baseline floor"
},
{
"image": "debian:12-slim",
"distro": "deb",
"package": "libc6",
"fixedVersion": "0",
"expectedComparison": 1,
"note": "baseline floor"
},
{
"image": "debian:12-slim",
"distro": "deb",
"package": "base-files",
"fixedVersion": "0",
"expectedComparison": 1,
"note": "baseline floor"
},
{
"image": "ubuntu:22.04",
"distro": "deb",
"package": "dpkg",
"fixedVersion": "0",
"expectedComparison": 1,
"note": "baseline floor"
},
{
"image": "ubuntu:22.04",
"distro": "deb",
"package": "libc6",
"fixedVersion": "0",
"expectedComparison": 1,
"note": "baseline floor"
},
{
"image": "ubuntu:22.04",
"distro": "deb",
"package": "base-files",
"fixedVersion": "0",
"expectedComparison": 1,
"note": "baseline floor"
},
{
"image": "alpine:3.20",
"distro": "apk",
"package": "apk-tools",
"fixedVersion": "0-r0",
"expectedComparison": 1,
"note": "baseline floor"
},
{
"image": "alpine:3.20",
"distro": "apk",
"package": "busybox",
"fixedVersion": "0-r0",
"expectedComparison": 1,
"note": "baseline floor"
},
{
"image": "alpine:3.20",
"distro": "apk",
"package": "zlib",
"fixedVersion": "0-r0",
"expectedComparison": 1,
"note": "baseline floor"
}
]

View File

@@ -0,0 +1,39 @@
using Xunit;
namespace StellaOps.Concelier.Integration.Tests;
internal static class IntegrationTestSettings
{
public static bool IsEnabled
{
get
{
var value = Environment.GetEnvironmentVariable("STELLAOPS_INTEGRATION_TESTS");
return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase);
}
}
}
public sealed class IntegrationFactAttribute : FactAttribute
{
public IntegrationFactAttribute()
{
if (!IntegrationTestSettings.IsEnabled)
{
Skip = "Integration tests disabled. Set STELLAOPS_INTEGRATION_TESTS=true to enable.";
}
}
}
public sealed class IntegrationTheoryAttribute : TheoryAttribute
{
public IntegrationTheoryAttribute()
{
if (!IntegrationTestSettings.IsEnabled)
{
Skip = "Integration tests disabled. Set STELLAOPS_INTEGRATION_TESTS=true to enable.";
}
}
}

View File

@@ -0,0 +1,20 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.Merge\StellaOps.Concelier.Merge.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.Normalization\StellaOps.Concelier.Normalization.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Testcontainers" Version="4.4.0" />
</ItemGroup>
<ItemGroup>
<None Update="Fixtures\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,76 @@
using StellaOps.Concelier.Merge.Comparers;
using StellaOps.Concelier.Normalization.Distro;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class ApkVersionComparerTests
{
public static TheoryData<string, string, int, string> ComparisonCases => BuildComparisonCases();
[Theory]
[MemberData(nameof(ComparisonCases))]
public void Compare_ApkVersions_ReturnsExpectedOrder(string left, string right, int expected, string note)
{
var actual = Math.Sign(ApkVersionComparer.Instance.Compare(left, right));
Assert.True(expected == actual, $"[{note}] '{left}' vs '{right}': expected {expected}, got {actual}");
}
[Fact]
public void Compare_ParsesApkVersionComponents()
{
var result = ApkVersionComparer.Instance.Compare(
ApkVersion.Parse("3.1.4-r0"),
ApkVersion.Parse("3.1.3-r2"));
Assert.True(result > 0);
}
private static TheoryData<string, string, int, string> BuildComparisonCases()
{
var data = new TheoryData<string, string, int, string>();
// Suffix ordering.
data.Add("1.0_alpha", "1.0_beta", -1, "suffix ordering: alpha < beta");
data.Add("1.0_beta", "1.0_pre", -1, "suffix ordering: beta < pre");
data.Add("1.0_pre", "1.0_rc", -1, "suffix ordering: pre < rc");
data.Add("1.0_rc", "1.0", -1, "suffix ordering: rc < none");
data.Add("1.0", "1.0_p", -1, "suffix ordering: none < p");
data.Add("1.0_alpha", "1.0_p", -1, "suffix ordering: alpha < p");
// Suffix numeric ordering.
data.Add("1.0_alpha1", "1.0_alpha2", -1, "suffix numeric ordering");
data.Add("1.0_alpha2", "1.0_alpha10", -1, "suffix numeric ordering");
data.Add("1.0_pre1", "1.0_pre2", -1, "suffix numeric ordering");
data.Add("1.0_rc1", "1.0_rc2", -1, "suffix numeric ordering");
data.Add("1.0_beta1", "1.0_beta01", 0, "suffix numeric leading zeros ignored");
// Numeric ordering in version.
data.Add("1.2.3", "1.2.10", -1, "numeric segment ordering");
data.Add("1.10.0", "1.2.9", 1, "numeric segment ordering");
data.Add("2.0", "1.9", 1, "major version ordering");
data.Add("1.02", "1.2", 0, "leading zeros ignored");
data.Add("1.2.03", "1.2.3", 0, "leading zeros ignored");
// Alpha segment ordering.
data.Add("1.2.3a", "1.2.3b", -1, "alpha segment ordering");
data.Add("1.2.3a", "1.2.3", 1, "alpha sorts after empty");
data.Add("1.2.3", "1.2.3a", -1, "empty sorts before alpha");
data.Add("1.2.3aa", "1.2.3b", -1, "alpha lexical ordering");
// Package release ordering.
data.Add("1.2.3-r0", "1.2.3-r1", -1, "pkgrel ordering");
data.Add("1.2.3-r1", "1.2.3-r2", -1, "pkgrel ordering");
data.Add("1.2.3-r2", "1.2.3-r10", -1, "pkgrel numeric ordering");
data.Add("1.2.3-r10", "1.2.3-r2", 1, "pkgrel numeric ordering");
data.Add("1.2.3", "1.2.3-r0", -1, "implicit release sorts before explicit r0");
// Combined ordering.
data.Add("1.2.3_p1-r0", "1.2.3_p1-r1", -1, "pkgrel ordering after suffix");
data.Add("1.2.3_rc1-r1", "1.2.3-r0", -1, "rc sorts before release even with higher pkgrel");
data.Add("1.2.3_p1-r0", "1.2.3-r9", 1, "patch suffix sorts after release");
data.Add("1.2.3_pre2-r3", "1.2.3_pre10-r1", -1, "suffix numeric ordering beats pkgrel");
data.Add("1.2.3", "1.2.3", 0, "exact match");
return data;
}
}

View File

@@ -81,4 +81,70 @@ public sealed class DebianEvrComparerTests
Assert.Equal(expected, actual);
}
public static TheoryData<string, string, int, string> ComparisonCases => BuildComparisonCases();
[Theory]
[MemberData(nameof(ComparisonCases))]
public void Compare_DebianEvr_ReturnsExpectedOrder(string left, string right, int expected, string note)
{
var actual = Math.Sign(DebianEvrComparer.Instance.Compare(left, right));
Assert.True(expected == actual, $"[{note}] '{left}' vs '{right}': expected {expected}, got {actual}");
}
private static TheoryData<string, string, int, string> BuildComparisonCases()
{
var data = new TheoryData<string, string, int, string>();
// Epoch precedence.
data.Add("0:1.0-1", "1:1.0-1", -1, "epoch precedence: 0 < 1");
data.Add("1:1.0-1", "0:9.9-9", 1, "epoch precedence: 1 > 0");
data.Add("2:0.1-1", "1:9.9-9", 1, "epoch precedence: 2 > 1");
data.Add("3:1.0-1", "4:0.1-1", -1, "epoch precedence: 3 < 4");
data.Add("5:2.0-1", "4:9.9-9", 1, "epoch precedence: 5 > 4");
data.Add("1:2.0-1", "2:1.0-1", -1, "epoch precedence: 1 < 2");
// Numeric ordering in upstream version.
for (var i = 1; i <= 12; i++)
{
data.Add($"0:1.{i}-1", $"0:1.{i + 1}-1", -1, "numeric segment ordering");
}
data.Add("0:1.09-1", "0:1.9-1", 0, "leading zeros ignored");
data.Add("0:2.001-1", "0:2.1-1", 0, "leading zeros ignored");
// Tilde pre-releases.
data.Add("0:1.0~alpha1-1", "0:1.0~alpha2-1", -1, "tilde pre-release ordering");
data.Add("0:1.0~rc1-1", "0:1.0-1", -1, "tilde sorts before release");
data.Add("0:1.0~~-1", "0:1.0~-1", -1, "double tilde sorts earlier");
data.Add("0:2.0~beta-1", "0:2.0~rc-1", -1, "tilde alpha ordering");
data.Add("0:1.0~rc1-1", "0:1.0~rc2-1", -1, "tilde rc ordering");
data.Add("0:1.0~rc-1", "0:1.0~rc-2", -1, "revision breaks tilde ties");
// Debian revision ordering.
for (var i = 1; i <= 10; i++)
{
data.Add($"0:1.0-{i}", $"0:1.0-{i + 1}", -1, "revision numeric ordering");
}
data.Add("0:1.0-1", "0:1.0-1ubuntu0.1", -1, "ubuntu security backport");
data.Add("0:1.0-1ubuntu0.1", "0:1.0-1ubuntu0.2", -1, "ubuntu incremental backport");
data.Add("0:1.0-1ubuntu1", "0:1.0-1ubuntu2", -1, "ubuntu delta update");
data.Add("0:1.0-1build1", "0:1.0-1build2", -1, "ubuntu rebuild");
data.Add("0:1.0-1+deb12u1", "0:1.0-1+deb12u2", -1, "debian stable update");
data.Add("0:1.0-1ubuntu0.2", "0:1.0-1ubuntu1", -1, "ubuntu ordering baseline");
data.Add("0:1.0-1ubuntu1", "0:1.0-1ubuntu1.1", -1, "ubuntu dotted revision");
data.Add("0:1.0-1ubuntu1.1", "0:1.0-1ubuntu1.2", -1, "ubuntu dotted revision ordering");
data.Add("0:1.0-1+deb12u1", "0:1.0-1ubuntu1", -1, "debian update before ubuntu delta");
data.Add("0:1.0-1ubuntu2", "0:1.0-1ubuntu10", -1, "ubuntu numeric ordering");
// Native package handling.
data.Add("0:1.0", "0:1.0-1", -1, "native package sorts before revisioned");
data.Add("0:1.0", "0:1.0+deb12u1", -1, "native package sorts before debian update");
data.Add("1:1.0", "1:1.0-1", -1, "native package sorts before revisioned");
data.Add("0:2.0", "0:2.0-0", -1, "native package sorts before zero revision");
data.Add("0:2.0-0", "0:2.0-1", -1, "zero revision sorts before higher revision");
return data;
}
}

View File

@@ -0,0 +1,121 @@
param(
[string]$Configuration = "Debug"
)
$root = Resolve-Path (Join-Path $PSScriptRoot "..\\..\\..\\..")
$mergePath = Join-Path $root "__Libraries\\StellaOps.Concelier.Merge\\bin\\$Configuration\\net10.0\\StellaOps.Concelier.Merge.dll"
$normPath = Join-Path $root "__Libraries\\StellaOps.Concelier.Normalization\\bin\\$Configuration\\net10.0\\StellaOps.Concelier.Normalization.dll"
if (-not (Test-Path $mergePath)) {
throw "Build StellaOps.Concelier.Merge first. Missing: $mergePath"
}
[System.Reflection.Assembly]::LoadFrom($mergePath) | Out-Null
if (Test-Path $normPath) {
[System.Reflection.Assembly]::LoadFrom($normPath) | Out-Null
}
$nevraComparer = [StellaOps.Concelier.Merge.Comparers.NevraComparer]::Instance
$debComparer = [StellaOps.Concelier.Merge.Comparers.DebianEvrComparer]::Instance
$apkComparer = [StellaOps.Concelier.Merge.Comparers.ApkVersionComparer]::Instance
$rpmVersions = @(
'kernel-0:4.18.0-80.el8.x86_64',
'kernel-1:4.18.0-80.el8.x86_64',
'kernel-0:4.18.11-80.el8.x86_64',
'pkg-0:1.0-1.el9.noarch',
'pkg-0:1.0-1.el9.x86_64',
'pkg-0:1.0-2.el9.x86_64',
'pkg-0:1.0-10.el9.x86_64',
'pkg-0:1.0~rc1-1.el9.x86_64',
'pkg-0:1.0-1.fc35.x86_64',
'pkg-0:1.0-1.fc36.x86_64',
'openssl-1:1.1.1k-7.el8.x86_64',
'openssl-3:1.1.1k-7.el8.x86_64',
'podman-1:4.5.0-1.el9.x86_64',
'podman-2:4.4.0-1.el9.x86_64',
'glibc-4:2.36-9.el9.x86_64',
'glibc-5:2.36-8.el9.x86_64'
)
$debVersions = @(
'0:1.0-1',
'1.0-1',
'1.0-1ubuntu0.1',
'1.0-1ubuntu0.2',
'1.0-1ubuntu1',
'1.0-1ubuntu2',
'1.0-1+deb12u1',
'1.0-1+deb12u2',
'1:1.1.1n-0+deb11u2',
'1:1.1.1n-0+deb11u5',
'2.0~beta1-1',
'2.0~rc1-1',
'2.0-1',
'1.2.3-1',
'1.2.10-1',
'3.0.0-1'
)
$apkVersions = @(
'1.0_alpha1-r0',
'1.0_beta1-r0',
'1.0_pre1-r0',
'1.0_rc1-r0',
'1.0-r0',
'1.0_p1-r0',
'1.2.3-r0',
'1.2.10-r0',
'2.0-r0',
'1.2.3a-r0',
'1.2.3b-r0',
'1.2.3-r1',
'1.2.3-r2',
'1.2.3_p1-r1',
'3.9.1-r0',
'3.1.1-r0'
)
function New-Cases {
param(
[string[]]$Versions,
[string]$Distro,
$Comparer
)
$cases = New-Object System.Collections.Generic.List[object]
for ($i = 0; $i -lt $Versions.Count; $i++) {
for ($j = $i + 1; $j -lt $Versions.Count; $j++) {
$left = $Versions[$i]
$right = $Versions[$j]
$expected = [Math]::Sign($Comparer.Compare($left, $right))
$cases.Add([ordered]@{
left = $left
right = $right
expected = $expected
distro = $Distro
note = "pairwise"
})
}
}
return $cases
}
function Write-GoldenFile {
param(
[string]$Path,
$Cases
)
$lines = foreach ($case in $Cases) {
$case | ConvertTo-Json -Compress
}
Set-Content -Path $Path -Value ($lines -join "`n") -Encoding ascii
}
$rpmCases = New-Cases -Versions $rpmVersions -Distro "rpm" -Comparer $nevraComparer
$debCases = New-Cases -Versions $debVersions -Distro "deb" -Comparer $debComparer
$apkCases = New-Cases -Versions $apkVersions -Distro "apk" -Comparer $apkComparer
Write-GoldenFile -Path (Join-Path $PSScriptRoot "rpm_version_comparison.golden.ndjson") -Cases $rpmCases
Write-GoldenFile -Path (Join-Path $PSScriptRoot "deb_version_comparison.golden.ndjson") -Cases $debCases
Write-GoldenFile -Path (Join-Path $PSScriptRoot "apk_version_comparison.golden.ndjson") -Cases $apkCases

View File

@@ -0,0 +1,28 @@
# Distro Version Comparison Goldens
Golden files store pairwise version comparison results in NDJSON to guard
regressions in distro-specific comparers (RPM, Debian, Alpine APK).
## Format
Each line is a single JSON object:
```
{"left":"0:1.0-1.el8","right":"1:0.1-1.el8","expected":-1,"distro":"rpm","note":"pairwise"}
```
Fields:
- left/right: version strings as understood by the target comparer.
- expected: comparison result (-1, 0, 1) after Math.Sign.
- distro: rpm | deb | apk.
- note: optional human note or generation hint.
## Updating goldens
1) Build the comparers:
`dotnet build ..\..\..\..\__Libraries\StellaOps.Concelier.Merge\StellaOps.Concelier.Merge.csproj`
2) Regenerate:
`pwsh .\GenerateGoldenComparisons.ps1`
Files:
- rpm_version_comparison.golden.ndjson
- deb_version_comparison.golden.ndjson
- apk_version_comparison.golden.ndjson

View File

@@ -0,0 +1,120 @@
{"left":"1.0_alpha1-r0","right":"1.0_beta1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_alpha1-r0","right":"1.0_pre1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_alpha1-r0","right":"1.0_rc1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_alpha1-r0","right":"1.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_alpha1-r0","right":"1.0_p1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_alpha1-r0","right":"1.2.3-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_alpha1-r0","right":"1.2.10-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_alpha1-r0","right":"2.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_alpha1-r0","right":"1.2.3a-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_alpha1-r0","right":"1.2.3b-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_alpha1-r0","right":"1.2.3-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_alpha1-r0","right":"1.2.3-r2","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_alpha1-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_alpha1-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_alpha1-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_beta1-r0","right":"1.0_pre1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_beta1-r0","right":"1.0_rc1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_beta1-r0","right":"1.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_beta1-r0","right":"1.0_p1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_beta1-r0","right":"1.2.3-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_beta1-r0","right":"1.2.10-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_beta1-r0","right":"2.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_beta1-r0","right":"1.2.3a-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_beta1-r0","right":"1.2.3b-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_beta1-r0","right":"1.2.3-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_beta1-r0","right":"1.2.3-r2","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_beta1-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_beta1-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_beta1-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_pre1-r0","right":"1.0_rc1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_pre1-r0","right":"1.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_pre1-r0","right":"1.0_p1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_pre1-r0","right":"1.2.3-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_pre1-r0","right":"1.2.10-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_pre1-r0","right":"2.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_pre1-r0","right":"1.2.3a-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_pre1-r0","right":"1.2.3b-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_pre1-r0","right":"1.2.3-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_pre1-r0","right":"1.2.3-r2","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_pre1-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_pre1-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_pre1-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_rc1-r0","right":"1.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_rc1-r0","right":"1.0_p1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_rc1-r0","right":"1.2.3-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_rc1-r0","right":"1.2.10-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_rc1-r0","right":"2.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_rc1-r0","right":"1.2.3a-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_rc1-r0","right":"1.2.3b-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_rc1-r0","right":"1.2.3-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_rc1-r0","right":"1.2.3-r2","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_rc1-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_rc1-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_rc1-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0-r0","right":"1.0_p1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0-r0","right":"1.2.3-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0-r0","right":"1.2.10-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0-r0","right":"2.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0-r0","right":"1.2.3a-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0-r0","right":"1.2.3b-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0-r0","right":"1.2.3-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0-r0","right":"1.2.3-r2","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_p1-r0","right":"1.2.3-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_p1-r0","right":"1.2.10-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_p1-r0","right":"2.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_p1-r0","right":"1.2.3a-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_p1-r0","right":"1.2.3b-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_p1-r0","right":"1.2.3-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_p1-r0","right":"1.2.3-r2","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_p1-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_p1-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.0_p1-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3-r0","right":"1.2.10-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3-r0","right":"2.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3-r0","right":"1.2.3a-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3-r0","right":"1.2.3b-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3-r0","right":"1.2.3-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3-r0","right":"1.2.3-r2","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.10-r0","right":"2.0-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.10-r0","right":"1.2.3a-r0","expected":1,"distro":"apk","note":"pairwise"}
{"left":"1.2.10-r0","right":"1.2.3b-r0","expected":1,"distro":"apk","note":"pairwise"}
{"left":"1.2.10-r0","right":"1.2.3-r1","expected":1,"distro":"apk","note":"pairwise"}
{"left":"1.2.10-r0","right":"1.2.3-r2","expected":1,"distro":"apk","note":"pairwise"}
{"left":"1.2.10-r0","right":"1.2.3_p1-r1","expected":1,"distro":"apk","note":"pairwise"}
{"left":"1.2.10-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.10-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"2.0-r0","right":"1.2.3a-r0","expected":1,"distro":"apk","note":"pairwise"}
{"left":"2.0-r0","right":"1.2.3b-r0","expected":1,"distro":"apk","note":"pairwise"}
{"left":"2.0-r0","right":"1.2.3-r1","expected":1,"distro":"apk","note":"pairwise"}
{"left":"2.0-r0","right":"1.2.3-r2","expected":1,"distro":"apk","note":"pairwise"}
{"left":"2.0-r0","right":"1.2.3_p1-r1","expected":1,"distro":"apk","note":"pairwise"}
{"left":"2.0-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"2.0-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3a-r0","right":"1.2.3b-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3a-r0","right":"1.2.3-r1","expected":1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3a-r0","right":"1.2.3-r2","expected":1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3a-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3a-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3a-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3b-r0","right":"1.2.3-r1","expected":1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3b-r0","right":"1.2.3-r2","expected":1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3b-r0","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3b-r0","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3b-r0","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3-r1","right":"1.2.3-r2","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3-r1","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3-r1","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3-r1","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3-r2","right":"1.2.3_p1-r1","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3-r2","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3-r2","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3_p1-r1","right":"3.9.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"1.2.3_p1-r1","right":"3.1.1-r0","expected":-1,"distro":"apk","note":"pairwise"}
{"left":"3.9.1-r0","right":"3.1.1-r0","expected":1,"distro":"apk","note":"pairwise"}

View File

@@ -0,0 +1,120 @@
{"left":"0:1.0-1","right":"1.0-1","expected":0,"distro":"deb","note":"pairwise"}
{"left":"0:1.0-1","right":"1.0-1ubuntu0.1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"0:1.0-1","right":"1.0-1ubuntu0.2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"0:1.0-1","right":"1.0-1ubuntu1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"0:1.0-1","right":"1.0-1ubuntu2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"0:1.0-1","right":"1.0-1\u002Bdeb12u1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"0:1.0-1","right":"1.0-1\u002Bdeb12u2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"0:1.0-1","right":"1:1.1.1n-0\u002Bdeb11u2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"0:1.0-1","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"0:1.0-1","right":"2.0~beta1-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"0:1.0-1","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"0:1.0-1","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"0:1.0-1","right":"1.2.3-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"0:1.0-1","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"0:1.0-1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1","right":"1.0-1ubuntu0.1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1","right":"1.0-1ubuntu0.2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1","right":"1.0-1ubuntu1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1","right":"1.0-1ubuntu2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1","right":"1.0-1\u002Bdeb12u1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1","right":"1.0-1\u002Bdeb12u2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1","right":"1:1.1.1n-0\u002Bdeb11u2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1","right":"2.0~beta1-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1","right":"1.2.3-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.1","right":"1.0-1ubuntu0.2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.1","right":"1.0-1ubuntu1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.1","right":"1.0-1ubuntu2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.1","right":"1.0-1\u002Bdeb12u1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.1","right":"1.0-1\u002Bdeb12u2","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.1","right":"1:1.1.1n-0\u002Bdeb11u2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.1","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.1","right":"2.0~beta1-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.1","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.1","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.1","right":"1.2.3-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.1","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.2","right":"1.0-1ubuntu1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.2","right":"1.0-1ubuntu2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.2","right":"1.0-1\u002Bdeb12u1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.2","right":"1.0-1\u002Bdeb12u2","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.2","right":"1:1.1.1n-0\u002Bdeb11u2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.2","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.2","right":"2.0~beta1-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.2","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.2","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.2","right":"1.2.3-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.2","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu0.2","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu1","right":"1.0-1ubuntu2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu1","right":"1.0-1\u002Bdeb12u1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu1","right":"1.0-1\u002Bdeb12u2","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu1","right":"1:1.1.1n-0\u002Bdeb11u2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu1","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu1","right":"2.0~beta1-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu1","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu1","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu1","right":"1.2.3-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu1","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu2","right":"1.0-1\u002Bdeb12u1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu2","right":"1.0-1\u002Bdeb12u2","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu2","right":"1:1.1.1n-0\u002Bdeb11u2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu2","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu2","right":"2.0~beta1-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu2","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu2","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu2","right":"1.2.3-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu2","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1ubuntu2","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1\u002Bdeb12u1","right":"1.0-1\u002Bdeb12u2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1\u002Bdeb12u1","right":"1:1.1.1n-0\u002Bdeb11u2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1\u002Bdeb12u1","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1\u002Bdeb12u1","right":"2.0~beta1-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1\u002Bdeb12u1","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1\u002Bdeb12u1","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1\u002Bdeb12u1","right":"1.2.3-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1\u002Bdeb12u1","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1\u002Bdeb12u1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1\u002Bdeb12u2","right":"1:1.1.1n-0\u002Bdeb11u2","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1\u002Bdeb12u2","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1\u002Bdeb12u2","right":"2.0~beta1-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1\u002Bdeb12u2","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1\u002Bdeb12u2","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1\u002Bdeb12u2","right":"1.2.3-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1\u002Bdeb12u2","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.0-1\u002Bdeb12u2","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1:1.1.1n-0\u002Bdeb11u2","right":"1:1.1.1n-0\u002Bdeb11u5","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1:1.1.1n-0\u002Bdeb11u2","right":"2.0~beta1-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1:1.1.1n-0\u002Bdeb11u2","right":"2.0~rc1-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1:1.1.1n-0\u002Bdeb11u2","right":"2.0-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1:1.1.1n-0\u002Bdeb11u2","right":"1.2.3-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1:1.1.1n-0\u002Bdeb11u2","right":"1.2.10-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1:1.1.1n-0\u002Bdeb11u2","right":"3.0.0-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1:1.1.1n-0\u002Bdeb11u5","right":"2.0~beta1-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1:1.1.1n-0\u002Bdeb11u5","right":"2.0~rc1-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1:1.1.1n-0\u002Bdeb11u5","right":"2.0-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1:1.1.1n-0\u002Bdeb11u5","right":"1.2.3-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1:1.1.1n-0\u002Bdeb11u5","right":"1.2.10-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"1:1.1.1n-0\u002Bdeb11u5","right":"3.0.0-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"2.0~beta1-1","right":"2.0~rc1-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"2.0~beta1-1","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"2.0~beta1-1","right":"1.2.3-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"2.0~beta1-1","right":"1.2.10-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"2.0~beta1-1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"2.0~rc1-1","right":"2.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"2.0~rc1-1","right":"1.2.3-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"2.0~rc1-1","right":"1.2.10-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"2.0~rc1-1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"2.0-1","right":"1.2.3-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"2.0-1","right":"1.2.10-1","expected":1,"distro":"deb","note":"pairwise"}
{"left":"2.0-1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.2.3-1","right":"1.2.10-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.2.3-1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}
{"left":"1.2.10-1","right":"3.0.0-1","expected":-1,"distro":"deb","note":"pairwise"}

View File

@@ -0,0 +1,120 @@
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"kernel-1:4.18.0-80.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"kernel-0:4.18.11-80.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-1.el9.noarch","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-2.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-10.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"pkg-0:1.0~rc1-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-1.fc35.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-1.fc36.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.0-80.el8.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"kernel-0:4.18.11-80.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-1.el9.noarch","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-2.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-10.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"pkg-0:1.0~rc1-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-1.fc35.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"pkg-0:1.0-1.fc36.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-1:4.18.0-80.el8.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"pkg-0:1.0-1.el9.noarch","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"pkg-0:1.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"pkg-0:1.0-2.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"pkg-0:1.0-10.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"pkg-0:1.0~rc1-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"pkg-0:1.0-1.fc35.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"pkg-0:1.0-1.fc36.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"kernel-0:4.18.11-80.el8.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.noarch","right":"pkg-0:1.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.noarch","right":"pkg-0:1.0-2.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.noarch","right":"pkg-0:1.0-10.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.noarch","right":"pkg-0:1.0~rc1-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.noarch","right":"pkg-0:1.0-1.fc35.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.noarch","right":"pkg-0:1.0-1.fc36.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.noarch","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.noarch","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.noarch","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.noarch","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.noarch","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.noarch","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.x86_64","right":"pkg-0:1.0-2.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.x86_64","right":"pkg-0:1.0-10.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.x86_64","right":"pkg-0:1.0~rc1-1.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.x86_64","right":"pkg-0:1.0-1.fc35.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.x86_64","right":"pkg-0:1.0-1.fc36.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.el9.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-2.el9.x86_64","right":"pkg-0:1.0-10.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-2.el9.x86_64","right":"pkg-0:1.0~rc1-1.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-2.el9.x86_64","right":"pkg-0:1.0-1.fc35.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-2.el9.x86_64","right":"pkg-0:1.0-1.fc36.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-2.el9.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-2.el9.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-2.el9.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-2.el9.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-2.el9.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-2.el9.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-10.el9.x86_64","right":"pkg-0:1.0~rc1-1.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-10.el9.x86_64","right":"pkg-0:1.0-1.fc35.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-10.el9.x86_64","right":"pkg-0:1.0-1.fc36.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-10.el9.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-10.el9.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-10.el9.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-10.el9.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-10.el9.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-10.el9.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0~rc1-1.el9.x86_64","right":"pkg-0:1.0-1.fc35.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0~rc1-1.el9.x86_64","right":"pkg-0:1.0-1.fc36.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0~rc1-1.el9.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0~rc1-1.el9.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0~rc1-1.el9.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0~rc1-1.el9.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0~rc1-1.el9.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0~rc1-1.el9.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.fc35.x86_64","right":"pkg-0:1.0-1.fc36.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.fc35.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.fc35.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.fc35.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.fc35.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.fc35.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.fc35.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.fc36.x86_64","right":"openssl-1:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.fc36.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.fc36.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.fc36.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.fc36.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"pkg-0:1.0-1.fc36.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"openssl-1:1.1.1k-7.el8.x86_64","right":"openssl-3:1.1.1k-7.el8.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"openssl-1:1.1.1k-7.el8.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"openssl-1:1.1.1k-7.el8.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"openssl-1:1.1.1k-7.el8.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"openssl-1:1.1.1k-7.el8.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"openssl-3:1.1.1k-7.el8.x86_64","right":"podman-1:4.5.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"openssl-3:1.1.1k-7.el8.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"openssl-3:1.1.1k-7.el8.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"openssl-3:1.1.1k-7.el8.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"podman-1:4.5.0-1.el9.x86_64","right":"podman-2:4.4.0-1.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}
{"left":"podman-1:4.5.0-1.el9.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"podman-1:4.5.0-1.el9.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"podman-2:4.4.0-1.el9.x86_64","right":"glibc-4:2.36-9.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"podman-2:4.4.0-1.el9.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":1,"distro":"rpm","note":"pairwise"}
{"left":"glibc-4:2.36-9.el9.x86_64","right":"glibc-5:2.36-8.el9.x86_64","expected":-1,"distro":"rpm","note":"pairwise"}

View File

@@ -0,0 +1,213 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text;
using StellaOps.Concelier.Merge.Comparers;
using Xunit;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class GoldenVersionComparisonTests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
[Theory]
[InlineData("rpm_version_comparison.golden.ndjson", "rpm")]
[InlineData("deb_version_comparison.golden.ndjson", "deb")]
[InlineData("apk_version_comparison.golden.ndjson", "apk")]
public void GoldenFiles_MatchComparers(string fileName, string distro)
{
if (ShouldUpdateGoldens())
{
WriteGoldens(fileName, distro);
return;
}
var cases = LoadCases(fileName);
Assert.True(cases.Count >= 100, $"Expected at least 100 cases in {fileName}.");
var failures = new List<string>();
foreach (var testCase in cases)
{
var actual = distro switch
{
"rpm" => Math.Sign(NevraComparer.Instance.Compare(testCase.Left, testCase.Right)),
"deb" => Math.Sign(DebianEvrComparer.Instance.Compare(testCase.Left, testCase.Right)),
"apk" => Math.Sign(ApkVersionComparer.Instance.Compare(testCase.Left, testCase.Right)),
_ => throw new InvalidOperationException($"Unsupported distro: {distro}")
};
if (actual != testCase.Expected)
{
failures.Add($"FAIL {distro}: {testCase.Left} vs {testCase.Right} expected {testCase.Expected} got {actual} ({testCase.Note})");
}
}
Assert.Empty(failures);
}
private static List<GoldenComparisonCase> LoadCases(string fileName)
{
var path = ResolveGoldenPath(fileName);
var lines = File.ReadAllLines(path);
var cases = new List<GoldenComparisonCase>(lines.Length);
foreach (var line in lines.Where(static l => !string.IsNullOrWhiteSpace(l)))
{
var testCase = JsonSerializer.Deserialize<GoldenComparisonCase>(line, JsonOptions);
if (testCase is null)
{
continue;
}
cases.Add(testCase);
}
return cases;
}
private static string ResolveGoldenPath(string fileName)
{
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "Fixtures", "Golden", fileName),
Path.Combine(GetProjectRoot(), "Fixtures", "Golden", fileName)
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return candidate;
}
}
throw new FileNotFoundException($"Golden file '{fileName}' not found.", fileName);
}
private static string GetProjectRoot()
=> Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", ".."));
private static void WriteGoldens(string fileName, string distro)
{
var cases = BuildCases(distro);
var path = Path.Combine(GetProjectRoot(), "Fixtures", "Golden", fileName);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var lines = cases.Select(testCase => JsonSerializer.Serialize(testCase, JsonOptions));
File.WriteAllText(path, string.Join('\n', lines), Encoding.ASCII);
}
private static List<GoldenComparisonCase> BuildCases(string distro)
{
string[] versions;
Func<string, string, int> comparer;
switch (distro)
{
case "rpm":
versions =
[
"kernel-0:4.18.0-80.el8.x86_64",
"kernel-1:4.18.0-80.el8.x86_64",
"kernel-0:4.18.11-80.el8.x86_64",
"pkg-0:1.0-1.el9.noarch",
"pkg-0:1.0-1.el9.x86_64",
"pkg-0:1.0-2.el9.x86_64",
"pkg-0:1.0-10.el9.x86_64",
"pkg-0:1.0~rc1-1.el9.x86_64",
"pkg-0:1.0-1.fc35.x86_64",
"pkg-0:1.0-1.fc36.x86_64",
"openssl-1:1.1.1k-7.el8.x86_64",
"openssl-3:1.1.1k-7.el8.x86_64",
"podman-1:4.5.0-1.el9.x86_64",
"podman-2:4.4.0-1.el9.x86_64",
"glibc-4:2.36-9.el9.x86_64",
"glibc-5:2.36-8.el9.x86_64"
];
comparer = (left, right) => Math.Sign(NevraComparer.Instance.Compare(left, right));
break;
case "deb":
versions =
[
"0:1.0-1",
"1.0-1",
"1.0-1ubuntu0.1",
"1.0-1ubuntu0.2",
"1.0-1ubuntu1",
"1.0-1ubuntu2",
"1.0-1+deb12u1",
"1.0-1+deb12u2",
"1:1.1.1n-0+deb11u2",
"1:1.1.1n-0+deb11u5",
"2.0~beta1-1",
"2.0~rc1-1",
"2.0-1",
"1.2.3-1",
"1.2.10-1",
"3.0.0-1"
];
comparer = (left, right) => Math.Sign(DebianEvrComparer.Instance.Compare(left, right));
break;
case "apk":
versions =
[
"1.0_alpha1-r0",
"1.0_beta1-r0",
"1.0_pre1-r0",
"1.0_rc1-r0",
"1.0-r0",
"1.0_p1-r0",
"1.2.3-r0",
"1.2.10-r0",
"2.0-r0",
"1.2.3a-r0",
"1.2.3b-r0",
"1.2.3-r1",
"1.2.3-r2",
"1.2.3_p1-r1",
"3.9.1-r0",
"3.1.1-r0"
];
comparer = (left, right) => Math.Sign(ApkVersionComparer.Instance.Compare(left, right));
break;
default:
throw new InvalidOperationException($"Unsupported distro: {distro}");
}
var cases = new List<GoldenComparisonCase>(versions.Length * versions.Length);
for (var i = 0; i < versions.Length; i++)
{
for (var j = i + 1; j < versions.Length; j++)
{
var left = versions[i];
var right = versions[j];
cases.Add(new GoldenComparisonCase(left, right, comparer(left, right), distro, "pairwise"));
}
}
return cases;
}
private static bool ShouldUpdateGoldens()
=> IsTruthy(Environment.GetEnvironmentVariable("UPDATE_GOLDENS"))
|| IsTruthy(Environment.GetEnvironmentVariable("DOTNET_TEST_UPDATE_GOLDENS"));
private static bool IsTruthy(string? value)
=> !string.IsNullOrWhiteSpace(value)
&& (string.Equals(value, "1", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase));
private sealed record GoldenComparisonCase(
string Left,
string Right,
int Expected,
string? Distro,
string? Note);
}

View File

@@ -105,4 +105,81 @@ public sealed class NevraComparerTests
var actual = Math.Sign(NevraComparer.Instance.Compare(left, right));
Assert.Equal(expected, actual);
}
public static TheoryData<string, string, int, string> ComparisonCases => BuildComparisonCases();
[Theory]
[MemberData(nameof(ComparisonCases))]
public void Compare_NevraVersions_ReturnsExpectedOrder(string left, string right, int expected, string note)
{
var actual = Math.Sign(NevraComparer.Instance.Compare(left, right));
Assert.True(expected == actual, $"[{note}] '{left}' vs '{right}': expected {expected}, got {actual}");
}
private static TheoryData<string, string, int, string> BuildComparisonCases()
{
var data = new TheoryData<string, string, int, string>();
// Epoch precedence.
data.Add("kernel-0:4.18.0-80.el8.x86_64", "kernel-1:4.18.0-80.el8.x86_64", -1, "epoch precedence: 0 < 1");
data.Add("kernel-2:4.18.0-80.el8.x86_64", "kernel-1:9.9.9-1.el8.x86_64", 1, "epoch precedence: 2 > 1");
data.Add("openssl-1:1.1.1k-7.el8.x86_64", "openssl-3:1.1.1k-7.el8.x86_64", -1, "epoch precedence: 1 < 3");
data.Add("bash-10:5.1.0-1.el9.x86_64", "bash-9:9.9.9-1.el9.x86_64", 1, "epoch precedence: 10 > 9");
data.Add("podman-1:4.5.0-1.el9.x86_64", "podman-2:4.4.0-1.el9.x86_64", -1, "epoch precedence: 1 < 2");
data.Add("glibc-5:2.36-8.el9.x86_64", "glibc-4:2.36-9.el9.x86_64", 1, "epoch precedence: 5 > 4");
// Numeric ordering.
for (var i = 1; i <= 10; i++)
{
data.Add($"pkg-0:1.{i}-1.el9.x86_64", $"pkg-0:1.{i + 1}-1.el9.x86_64", -1, "numeric segment ordering");
}
data.Add("pkg-0:1.9-1.el9.x86_64", "pkg-0:1.10-1.el9.x86_64", -1, "numeric length ordering");
data.Add("pkg-0:1.02-1.el9.x86_64", "pkg-0:1.2-1.el9.x86_64", 0, "leading zeros ignored");
data.Add("pkg-0:1.002-1.el9.x86_64", "pkg-0:1.2-1.el9.x86_64", 0, "leading zeros ignored");
// Alpha ordering.
data.Add("pkg-0:1.0a-1.el9.x86_64", "pkg-0:1.0b-1.el9.x86_64", -1, "alpha segment ordering");
data.Add("pkg-0:1.0aa-1.el9.x86_64", "pkg-0:1.0b-1.el9.x86_64", -1, "alpha length ordering");
data.Add("pkg-0:1.0b-1.el9.x86_64", "pkg-0:1.0aa-1.el9.x86_64", 1, "alpha segment ordering");
data.Add("pkg-0:1.0a-1.el9.x86_64", "pkg-0:1.0-1.el9.x86_64", 1, "alpha sorts after empty");
data.Add("pkg-0:1.0-1.el9.x86_64", "pkg-0:1.0a-1.el9.x86_64", -1, "empty sorts before alpha");
data.Add("pkg-0:1.0z-1.el9.x86_64", "pkg-0:1.0aa-1.el9.x86_64", 1, "alpha lexical ordering");
// Tilde pre-releases.
data.Add("pkg-0:1.0~rc1-1.el9.x86_64", "pkg-0:1.0-1.el9.x86_64", -1, "tilde sorts before release");
data.Add("pkg-0:1.0~rc1-1.el9.x86_64", "pkg-0:1.0~rc2-1.el9.x86_64", -1, "tilde rc ordering");
data.Add("pkg-0:1.0~~-1.el9.x86_64", "pkg-0:1.0~-1.el9.x86_64", -1, "double tilde sorts earlier");
data.Add("pkg-0:1.0~beta-1.el9.x86_64", "pkg-0:1.0~rc-1.el9.x86_64", -1, "tilde alpha segment ordering");
data.Add("pkg-0:1.0~rc-1.el9.x86_64", "pkg-0:1.0~rc-1.el9.x86_64", 0, "tilde equivalence");
data.Add("pkg-0:1.0~rc-1.el9.x86_64", "pkg-0:1.0~rc-2.el9.x86_64", -1, "release breaks tilde ties");
// Release qualifiers and backports.
data.Add("pkg-0:1.0-1.el8.x86_64", "pkg-0:1.0-1.el9.x86_64", -1, "release qualifier el8 < el9");
data.Add("pkg-0:1.0-1.el8.x86_64", "pkg-0:1.0-1.el8_5.x86_64", -1, "backport suffix ordering");
data.Add("pkg-0:1.0-1.el8_5.x86_64", "pkg-0:1.0-1.el8_5.1.x86_64", -1, "incremental backport");
data.Add("pkg-0:1.0-1.el8_5.1.x86_64", "pkg-0:1.0-2.el8.x86_64", -1, "release increments beat base");
data.Add("pkg-0:1.0-2.el8.x86_64", "pkg-0:1.0-10.el8.x86_64", -1, "release numeric ordering");
data.Add("pkg-0:1.0-10.el8.x86_64", "pkg-0:1.0-2.el8.x86_64", 1, "release numeric ordering");
data.Add("pkg-0:1.0-1.fc35.x86_64", "pkg-0:1.0-1.fc36.x86_64", -1, "release qualifier fc35 < fc36");
data.Add("pkg-0:1.0-1.el8_5.x86_64", "pkg-0:1.0-1.el8_5.0.x86_64", -1, "zero suffix still sorts later");
data.Add("pkg-0:1.0-1.el8_5.0.x86_64", "pkg-0:1.0-1.el8_5.x86_64", 1, "zero suffix still sorts later");
data.Add("pkg-0:1.0-1.el8_5.1.x86_64", "pkg-0:1.0-1.el8_5.2.x86_64", -1, "backport numeric ordering");
// Architecture ordering.
data.Add("pkg-0:1.0-1.el9.noarch", "pkg-0:1.0-1.el9.x86_64", -1, "architecture lexical ordering");
data.Add("pkg-0:1.0-1.el9.aarch64", "pkg-0:1.0-1.el9.x86_64", -1, "architecture lexical ordering");
data.Add("pkg-0:1.0-1.el9.ppc64le", "pkg-0:1.0-1.el9.ppc64", 1, "architecture lexical ordering");
data.Add("pkg-0:1.0-1.el9.s390x", "pkg-0:1.0-1.el9.s390", 1, "architecture lexical ordering");
data.Add("pkg-0:1.0-1.el9.arm64", "pkg-0:1.0-1.el9.aarch64", 1, "architecture lexical ordering");
// Package name ordering.
data.Add("aaa-0:1.0-1.el9.x86_64", "bbb-0:1.0-1.el9.x86_64", -1, "name lexical ordering");
data.Add("openssl-0:1.0-1.el9.x86_64", "openssl-libs-0:1.0-1.el9.x86_64", -1, "name lexical ordering");
data.Add("zlib-0:1.0-1.el9.x86_64", "bzip2-0:1.0-1.el9.x86_64", 1, "name lexical ordering");
data.Add("kernel-0:1.0-1.el9.x86_64", "kernel-core-0:1.0-1.el9.x86_64", -1, "name lexical ordering");
data.Add("glibc-0:1.0-1.el9.x86_64", "glibc-devel-0:1.0-1.el9.x86_64", -1, "name lexical ordering");
return data;
}
}

View File

@@ -0,0 +1,22 @@
# Concelier Merge Tests
This project verifies distro version comparison logic and merge rules.
## Layout
- Comparer unit tests: `*.Tests.cs` in this project (RPM, Debian, APK).
- Golden fixtures: `Fixtures/Golden/*.golden.ndjson`.
- Integration cross-checks: `src/Concelier/__Tests/StellaOps.Concelier.Integration.Tests`.
## Golden files
Golden files capture pairwise comparison results in NDJSON.
See `Fixtures/Golden/README.md` for format and regeneration steps.
## Integration tests
Cross-check tests compare container-installed versions against fixed
versions using the same comparers. They require Docker/Testcontainers.
Enable with:
`$env:STELLAOPS_INTEGRATION_TESTS = "true"`
Run (from repo root):
`dotnet test src/Concelier/__Tests/StellaOps.Concelier.Integration.Tests/StellaOps.Concelier.Integration.Tests.csproj`

View File

@@ -10,4 +10,9 @@
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Normalization/StellaOps.Concelier.Normalization.csproj" />
</ItemGroup>
</Project>
<ItemGroup>
<None Update="Fixtures\Golden\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
# Concelier Merge Comparator Test Tasks
Local status mirror for `docs/implplan/SPRINT_2000_0003_0002_distro_version_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| T1 | DONE | NEVRA comparison corpus expanded. |
| T2 | DONE | Debian EVR comparison corpus expanded. |
| T3 | DOING | Golden NDJSON fixtures + regression runner. |
| T4 | TODO | Testcontainers real-image cross-checks. |
| T5 | TODO | Test corpus README. |
Last synced: 2025-12-22 (UTC).

View File

@@ -71,4 +71,26 @@ public sealed class AffectedVersionRangeExtensionsTests
Assert.Null(rule);
}
[Fact]
public void ToNormalizedVersionRule_FallsBackForApkRange()
{
var range = new AffectedVersionRange(
rangeKind: "apk",
introducedVersion: null,
fixedVersion: "3.1.4-r0",
lastAffectedVersion: null,
rangeExpression: "fixed:3.1.4-r0",
provenance: AdvisoryProvenance.Empty,
primitives: null);
var rule = range.ToNormalizedVersionRule("alpine:v3.20/main");
Assert.NotNull(rule);
Assert.Equal(NormalizedVersionSchemes.Apk, rule!.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.LessThan, rule.Type);
Assert.Equal("3.1.4-r0", rule.Max);
Assert.False(rule.MaxInclusive);
Assert.Equal("alpine:v3.20/main", rule.Notes);
}
}

View File

@@ -0,0 +1,34 @@
using StellaOps.Concelier.Normalization.Distro;
namespace StellaOps.Concelier.Normalization.Tests;
public sealed class ApkVersionParserTests
{
[Fact]
public void ToCanonicalString_RoundTripsExplicitPkgRel()
{
var parsed = ApkVersion.Parse(" 3.1.4-r0 ");
Assert.Equal("3.1.4-r0", parsed.Original);
Assert.Equal("3.1.4-r0", parsed.ToCanonicalString());
}
[Fact]
public void ToCanonicalString_SuppressesImplicitPkgRel()
{
var parsed = ApkVersion.Parse("1.2.3_alpha");
Assert.Equal("1.2.3_alpha", parsed.ToCanonicalString());
}
[Fact]
public void TryParse_TracksExplicitRelease()
{
var success = ApkVersion.TryParse("2.0.1-r5", out var parsed);
Assert.True(success);
Assert.NotNull(parsed);
Assert.True(parsed!.HasExplicitPkgRel);
Assert.Equal(5, parsed.PkgRel);
}
}