interim commit
This commit is contained in:
@@ -71,6 +71,17 @@ public sealed class JsonFeedExporter : IFeedExporter
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var resetBaseline = existingState is null
|
||||||
|
|| string.IsNullOrWhiteSpace(existingState.BaseExportId)
|
||||||
|
|| string.IsNullOrWhiteSpace(existingState.BaseDigest);
|
||||||
|
|
||||||
|
if (existingState is not null
|
||||||
|
&& !string.IsNullOrWhiteSpace(_options.TargetRepository)
|
||||||
|
&& !string.Equals(existingState.TargetRepository, _options.TargetRepository, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
resetBaseline = true;
|
||||||
|
}
|
||||||
|
|
||||||
await _stateManager.StoreFullExportAsync(
|
await _stateManager.StoreFullExportAsync(
|
||||||
ExporterId,
|
ExporterId,
|
||||||
exportId,
|
exportId,
|
||||||
@@ -78,6 +89,7 @@ public sealed class JsonFeedExporter : IFeedExporter
|
|||||||
cursor: digest,
|
cursor: digest,
|
||||||
targetRepository: _options.TargetRepository,
|
targetRepository: _options.TargetRepository,
|
||||||
exporterVersion: _exporterVersion,
|
exporterVersion: _exporterVersion,
|
||||||
|
resetBaseline: resetBaseline,
|
||||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
await JsonExportManifestWriter.WriteAsync(result, digest, _exporterVersion, cancellationToken).ConfigureAwait(false);
|
await JsonExportManifestWriter.WriteAsync(result, digest, _exporterVersion, cancellationToken).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed class TrivyDbExportPlannerTests
|
|||||||
Assert.Equal("sha256:abcd", plan.TreeDigest);
|
Assert.Equal("sha256:abcd", plan.TreeDigest);
|
||||||
Assert.Null(plan.BaseExportId);
|
Assert.Null(plan.BaseExportId);
|
||||||
Assert.Null(plan.BaseManifestDigest);
|
Assert.Null(plan.BaseManifestDigest);
|
||||||
|
Assert.True(plan.ResetBaseline);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -39,6 +40,7 @@ public sealed class TrivyDbExportPlannerTests
|
|||||||
Assert.Equal("sha256:unchanged", plan.TreeDigest);
|
Assert.Equal("sha256:unchanged", plan.TreeDigest);
|
||||||
Assert.Equal("20240810T000000Z", plan.BaseExportId);
|
Assert.Equal("20240810T000000Z", plan.BaseExportId);
|
||||||
Assert.Equal("sha256:base", plan.BaseManifestDigest);
|
Assert.Equal("sha256:base", plan.BaseManifestDigest);
|
||||||
|
Assert.False(plan.ResetBaseline);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -62,5 +64,12 @@ public sealed class TrivyDbExportPlannerTests
|
|||||||
Assert.Equal("sha256:new", plan.TreeDigest);
|
Assert.Equal("sha256:new", plan.TreeDigest);
|
||||||
Assert.Equal("20240810T000000Z", plan.BaseExportId);
|
Assert.Equal("20240810T000000Z", plan.BaseExportId);
|
||||||
Assert.Equal("sha256:base", plan.BaseManifestDigest);
|
Assert.Equal("sha256:base", plan.BaseManifestDigest);
|
||||||
|
Assert.False(plan.ResetBaseline);
|
||||||
|
|
||||||
|
var deltaState = state with { LastDeltaDigest = "sha256:delta" };
|
||||||
|
var deltaPlan = planner.CreatePlan(deltaState, "sha256:newer");
|
||||||
|
|
||||||
|
Assert.Equal(TrivyDbExportMode.Full, deltaPlan.Mode);
|
||||||
|
Assert.True(deltaPlan.ResetBaseline);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,6 +281,77 @@ public sealed class TrivyDbFeedExporterTests : IDisposable
|
|||||||
Assert.Empty(orasPusher.Pushes);
|
Assert.Empty(orasPusher.Pushes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExportAsync_ResetsBaselineWhenDeltaChainExists()
|
||||||
|
{
|
||||||
|
var advisory = CreateSampleAdvisory("CVE-2024-5000", "Baseline reset");
|
||||||
|
var advisoryStore = new StubAdvisoryStore(advisory);
|
||||||
|
|
||||||
|
var optionsValue = new TrivyDbExportOptions
|
||||||
|
{
|
||||||
|
OutputRoot = _root,
|
||||||
|
ReferencePrefix = "example/trivy",
|
||||||
|
Json = new JsonExportOptions
|
||||||
|
{
|
||||||
|
OutputRoot = _jsonRoot,
|
||||||
|
MaintainLatestSymlink = false,
|
||||||
|
},
|
||||||
|
KeepWorkingTree = false,
|
||||||
|
TargetRepository = "registry.example/trivy",
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = Options.Create(optionsValue);
|
||||||
|
var packageBuilder = new TrivyDbPackageBuilder();
|
||||||
|
var ociWriter = new TrivyDbOciWriter();
|
||||||
|
var planner = new TrivyDbExportPlanner();
|
||||||
|
var stateStore = new InMemoryExportStateStore();
|
||||||
|
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2024-09-22T00:00:00Z", CultureInfo.InvariantCulture));
|
||||||
|
var existingRecord = new ExportStateRecord(
|
||||||
|
TrivyDbFeedExporter.ExporterId,
|
||||||
|
BaseExportId: "20240919T120000Z",
|
||||||
|
BaseDigest: "sha256:base",
|
||||||
|
LastFullDigest: "sha256:base",
|
||||||
|
LastDeltaDigest: "sha256:delta",
|
||||||
|
ExportCursor: "sha256:old",
|
||||||
|
TargetRepository: "registry.example/trivy",
|
||||||
|
ExporterVersion: "0.9.0",
|
||||||
|
UpdatedAt: timeProvider.GetUtcNow().AddMinutes(-30));
|
||||||
|
await stateStore.UpsertAsync(existingRecord, CancellationToken.None);
|
||||||
|
|
||||||
|
var stateManager = new ExportStateManager(stateStore, timeProvider);
|
||||||
|
var builderMetadata = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
Version = 2,
|
||||||
|
NextUpdate = "2024-09-23T00:00:00Z",
|
||||||
|
UpdatedAt = "2024-09-22T00:00:00Z",
|
||||||
|
});
|
||||||
|
var builder = new StubTrivyDbBuilder(_root, builderMetadata);
|
||||||
|
var orasPusher = new StubTrivyDbOrasPusher();
|
||||||
|
var exporter = new TrivyDbFeedExporter(
|
||||||
|
advisoryStore,
|
||||||
|
new VulnListJsonExportPathResolver(),
|
||||||
|
options,
|
||||||
|
packageBuilder,
|
||||||
|
ociWriter,
|
||||||
|
stateManager,
|
||||||
|
planner,
|
||||||
|
builder,
|
||||||
|
orasPusher,
|
||||||
|
NullLogger<TrivyDbFeedExporter>.Instance,
|
||||||
|
timeProvider);
|
||||||
|
|
||||||
|
using var provider = new ServiceCollection().BuildServiceProvider();
|
||||||
|
await exporter.ExportAsync(provider, CancellationToken.None);
|
||||||
|
|
||||||
|
var updated = await stateStore.FindAsync(TrivyDbFeedExporter.ExporterId, CancellationToken.None);
|
||||||
|
Assert.NotNull(updated);
|
||||||
|
Assert.Equal("20240922T000000Z", updated!.BaseExportId);
|
||||||
|
Assert.Equal(updated.BaseDigest, updated.LastFullDigest);
|
||||||
|
Assert.Null(updated.LastDeltaDigest);
|
||||||
|
Assert.NotEqual("sha256:old", updated.ExportCursor);
|
||||||
|
Assert.Equal("registry.example/trivy", updated.TargetRepository);
|
||||||
|
}
|
||||||
|
|
||||||
private static Advisory CreateSampleAdvisory(
|
private static Advisory CreateSampleAdvisory(
|
||||||
string advisoryKey = "CVE-2024-9999",
|
string advisoryKey = "CVE-2024-9999",
|
||||||
string title = "Trivy Export Test")
|
string title = "Trivy Export Test")
|
||||||
|
|||||||
@@ -8,6 +8,6 @@
|
|||||||
|Offline bundle toggle|BE-Export|Exporters|DONE – Deterministic OCI layout bundle emitted when enabled.|
|
|Offline bundle toggle|BE-Export|Exporters|DONE – Deterministic OCI layout bundle emitted when enabled.|
|
||||||
|Deterministic ordering of advisories|BE-Export|Models|DONE – exporter now loads advisories, sorts by advisoryKey, and emits sorted JSON trees with deterministic OCI payloads.|
|
|Deterministic ordering of advisories|BE-Export|Models|DONE – exporter now loads advisories, sorts by advisoryKey, and emits sorted JSON trees with deterministic OCI payloads.|
|
||||||
|End-to-end tests with small dataset|QA|Exporters|DONE – added deterministic round-trip test covering OCI layout, media types, and digest stability w/ repeated inputs.|
|
|End-to-end tests with small dataset|QA|Exporters|DONE – added deterministic round-trip test covering OCI layout, media types, and digest stability w/ repeated inputs.|
|
||||||
|ExportState persistence & idempotence|BE-Export|Storage.Mongo|DOING – `ExportStateManager` keeps stable base export metadata; delta reset remains pending.|
|
|ExportState persistence & idempotence|BE-Export|Storage.Mongo|DONE – baseline resets wired into `ExportStateManager`, planner signals resets after delta runs, and exporters update state w/ repository-aware baseline rotation + tests.|
|
||||||
|Streamed package building to avoid large copies|BE-Export|Exporters|DONE – metadata/config now reuse backing arrays and OCI writer streams directly without double buffering.|
|
|Streamed package building to avoid large copies|BE-Export|Exporters|DONE – metadata/config now reuse backing arrays and OCI writer streams directly without double buffering.|
|
||||||
|Plan incremental/delta exports|BE-Export|Exporters|TODO – design reuse of existing blobs/layers when inputs unchanged instead of rewriting full trees each run.|
|
|Plan incremental/delta exports|BE-Export|Exporters|TODO – design reuse of existing blobs/layers when inputs unchanged instead of rewriting full trees each run.|
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ public sealed record TrivyDbExportPlan(
|
|||||||
TrivyDbExportMode Mode,
|
TrivyDbExportMode Mode,
|
||||||
string TreeDigest,
|
string TreeDigest,
|
||||||
string? BaseExportId,
|
string? BaseExportId,
|
||||||
string? BaseManifestDigest);
|
string? BaseManifestDigest,
|
||||||
|
bool ResetBaseline);
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ public sealed class TrivyDbExportPlanner
|
|||||||
|
|
||||||
if (existingState is null)
|
if (existingState is null)
|
||||||
{
|
{
|
||||||
return new TrivyDbExportPlan(TrivyDbExportMode.Full, treeDigest, BaseExportId: null, BaseManifestDigest: null);
|
return new TrivyDbExportPlan(
|
||||||
|
TrivyDbExportMode.Full,
|
||||||
|
treeDigest,
|
||||||
|
BaseExportId: null,
|
||||||
|
BaseManifestDigest: null,
|
||||||
|
ResetBaseline: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(existingState.ExportCursor, treeDigest, StringComparison.Ordinal))
|
if (string.Equals(existingState.ExportCursor, treeDigest, StringComparison.Ordinal))
|
||||||
@@ -20,14 +25,18 @@ public sealed class TrivyDbExportPlanner
|
|||||||
TrivyDbExportMode.Skip,
|
TrivyDbExportMode.Skip,
|
||||||
treeDigest,
|
treeDigest,
|
||||||
existingState.BaseExportId,
|
existingState.BaseExportId,
|
||||||
existingState.LastFullDigest);
|
existingState.LastFullDigest,
|
||||||
|
ResetBaseline: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var resetBaseline = existingState.LastDeltaDigest is not null;
|
||||||
|
|
||||||
// Placeholder for future delta support – current behavior always rebuilds when tree changes.
|
// Placeholder for future delta support – current behavior always rebuilds when tree changes.
|
||||||
return new TrivyDbExportPlan(
|
return new TrivyDbExportPlan(
|
||||||
TrivyDbExportMode.Full,
|
TrivyDbExportMode.Full,
|
||||||
treeDigest,
|
treeDigest,
|
||||||
existingState.BaseExportId,
|
existingState.BaseExportId,
|
||||||
existingState.LastFullDigest);
|
existingState.LastFullDigest,
|
||||||
|
resetBaseline);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,18 @@ public sealed class TrivyDbFeedExporter : IFeedExporter
|
|||||||
exportId,
|
exportId,
|
||||||
ociResult.ManifestDigest);
|
ociResult.ManifestDigest);
|
||||||
|
|
||||||
|
var resetBaseline = plan.ResetBaseline
|
||||||
|
|| existingState is null
|
||||||
|
|| string.IsNullOrWhiteSpace(existingState.BaseExportId)
|
||||||
|
|| string.IsNullOrWhiteSpace(existingState.BaseDigest);
|
||||||
|
|
||||||
|
if (existingState is not null
|
||||||
|
&& !string.IsNullOrWhiteSpace(_options.TargetRepository)
|
||||||
|
&& !string.Equals(existingState.TargetRepository, _options.TargetRepository, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
resetBaseline = true;
|
||||||
|
}
|
||||||
|
|
||||||
await _stateManager.StoreFullExportAsync(
|
await _stateManager.StoreFullExportAsync(
|
||||||
ExporterId,
|
ExporterId,
|
||||||
exportId,
|
exportId,
|
||||||
@@ -137,6 +149,7 @@ public sealed class TrivyDbFeedExporter : IFeedExporter
|
|||||||
cursor: treeDigest,
|
cursor: treeDigest,
|
||||||
targetRepository: _options.TargetRepository,
|
targetRepository: _options.TargetRepository,
|
||||||
exporterVersion: _exporterVersion,
|
exporterVersion: _exporterVersion,
|
||||||
|
resetBaseline: resetBaseline,
|
||||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
await CreateOfflineBundleAsync(destination, exportId, exportedAt, cancellationToken).ConfigureAwait(false);
|
await CreateOfflineBundleAsync(destination, exportId, exportedAt, cancellationToken).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using StellaOps.Feedser.Merge.Identity;
|
||||||
|
using StellaOps.Feedser.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace StellaOps.Feedser.Merge.Tests;
|
||||||
|
|
||||||
|
public sealed class AdvisoryIdentityResolverTests
|
||||||
|
{
|
||||||
|
private readonly AdvisoryIdentityResolver _resolver = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_GroupsBySharedCveAlias()
|
||||||
|
{
|
||||||
|
var nvd = CreateAdvisory("CVE-2025-1234", aliases: new[] { "CVE-2025-1234" }, source: "nvd");
|
||||||
|
var vendor = CreateAdvisory("VSA-2025-01", aliases: new[] { "CVE-2025-1234", "VSA-2025-01" }, source: "vendor");
|
||||||
|
|
||||||
|
var clusters = _resolver.Resolve(new[] { nvd, vendor });
|
||||||
|
|
||||||
|
var cluster = Assert.Single(clusters);
|
||||||
|
Assert.Equal("CVE-2025-1234", cluster.AdvisoryKey);
|
||||||
|
Assert.Equal(2, cluster.Advisories.Length);
|
||||||
|
Assert.True(cluster.Aliases.Any(alias => alias.Value == "CVE-2025-1234"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_PrefersPsirtAliasWhenNoCve()
|
||||||
|
{
|
||||||
|
var vendor = CreateAdvisory("VMSA-2025-0001", aliases: new[] { "VMSA-2025-0001" }, source: "vmware");
|
||||||
|
var osv = CreateAdvisory("OSV-2025-1", aliases: new[] { "OSV-2025-1", "GHSA-xxxx-yyyy-zzzz", "VMSA-2025-0001" }, source: "osv");
|
||||||
|
|
||||||
|
var clusters = _resolver.Resolve(new[] { vendor, osv });
|
||||||
|
|
||||||
|
var cluster = Assert.Single(clusters);
|
||||||
|
Assert.Equal("VMSA-2025-0001", cluster.AdvisoryKey);
|
||||||
|
Assert.Equal(2, cluster.Advisories.Length);
|
||||||
|
Assert.True(cluster.Aliases.Any(alias => alias.Value == "VMSA-2025-0001"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_FallsBackToGhsaWhenOnlyGhsaPresent()
|
||||||
|
{
|
||||||
|
var ghsa = CreateAdvisory("GHSA-aaaa-bbbb-cccc", aliases: new[] { "GHSA-aaaa-bbbb-cccc" }, source: "ghsa");
|
||||||
|
var osv = CreateAdvisory("OSV-2025-99", aliases: new[] { "OSV-2025-99", "GHSA-aaaa-bbbb-cccc" }, source: "osv");
|
||||||
|
|
||||||
|
var clusters = _resolver.Resolve(new[] { ghsa, osv });
|
||||||
|
|
||||||
|
var cluster = Assert.Single(clusters);
|
||||||
|
Assert.Equal("GHSA-aaaa-bbbb-cccc", cluster.AdvisoryKey);
|
||||||
|
Assert.Equal(2, cluster.Advisories.Length);
|
||||||
|
Assert.True(cluster.Aliases.Any(alias => alias.Value == "GHSA-aaaa-bbbb-cccc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_GroupsByKeyWhenNoAliases()
|
||||||
|
{
|
||||||
|
var first = CreateAdvisory("custom-1", aliases: Array.Empty<string>(), source: "source-a");
|
||||||
|
var second = CreateAdvisory("custom-1", aliases: Array.Empty<string>(), source: "source-b");
|
||||||
|
|
||||||
|
var clusters = _resolver.Resolve(new[] { first, second });
|
||||||
|
|
||||||
|
var cluster = Assert.Single(clusters);
|
||||||
|
Assert.Equal("custom-1", cluster.AdvisoryKey);
|
||||||
|
Assert.Equal(2, cluster.Advisories.Length);
|
||||||
|
Assert.Contains(cluster.Aliases, alias => alias.Value == "custom-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Advisory CreateAdvisory(string key, string[] aliases, string source)
|
||||||
|
{
|
||||||
|
var provenance = new[]
|
||||||
|
{
|
||||||
|
new AdvisoryProvenance(source, "mapping", key, DateTimeOffset.UtcNow),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Advisory(
|
||||||
|
key,
|
||||||
|
$"{key} title",
|
||||||
|
$"{key} summary",
|
||||||
|
"en",
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
null,
|
||||||
|
exploitKnown: false,
|
||||||
|
aliases,
|
||||||
|
Array.Empty<AdvisoryReference>(),
|
||||||
|
Array.Empty<AffectedPackage>(),
|
||||||
|
Array.Empty<CvssMetric>(),
|
||||||
|
provenance);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Linq;
|
||||||
|
using StellaOps.Feedser.Models;
|
||||||
|
|
||||||
|
namespace StellaOps.Feedser.Merge.Identity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a connected component of advisories that refer to the same vulnerability.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AdvisoryIdentityCluster
|
||||||
|
{
|
||||||
|
public AdvisoryIdentityCluster(string advisoryKey, IEnumerable<Advisory> advisories, IEnumerable<AliasIdentity> aliases)
|
||||||
|
{
|
||||||
|
AdvisoryKey = !string.IsNullOrWhiteSpace(advisoryKey)
|
||||||
|
? advisoryKey.Trim()
|
||||||
|
: throw new ArgumentException("Canonical advisory key must be provided.", nameof(advisoryKey));
|
||||||
|
|
||||||
|
var advisoriesArray = (advisories ?? throw new ArgumentNullException(nameof(advisories)))
|
||||||
|
.Where(static advisory => advisory is not null)
|
||||||
|
.OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(static advisory => advisory.Provenance.Length)
|
||||||
|
.ThenBy(static advisory => advisory.Title, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToImmutableArray();
|
||||||
|
|
||||||
|
if (advisoriesArray.IsDefaultOrEmpty)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("At least one advisory is required for a cluster.", nameof(advisories));
|
||||||
|
}
|
||||||
|
|
||||||
|
var aliasArray = (aliases ?? throw new ArgumentNullException(nameof(aliases)))
|
||||||
|
.Where(static alias => alias is not null && !string.IsNullOrWhiteSpace(alias.Value))
|
||||||
|
.GroupBy(static alias => alias.Value, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(static group =>
|
||||||
|
{
|
||||||
|
var representative = group
|
||||||
|
.OrderBy(static entry => entry.Scheme ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(static entry => entry.Value, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.First();
|
||||||
|
return representative;
|
||||||
|
})
|
||||||
|
.OrderBy(static alias => alias.Scheme ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(static alias => alias.Value, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToImmutableArray();
|
||||||
|
|
||||||
|
Advisories = advisoriesArray;
|
||||||
|
Aliases = aliasArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string AdvisoryKey { get; }
|
||||||
|
|
||||||
|
public ImmutableArray<Advisory> Advisories { get; }
|
||||||
|
|
||||||
|
public ImmutableArray<AliasIdentity> Aliases { get; }
|
||||||
|
}
|
||||||
303
src/StellaOps.Feedser.Merge/Identity/AdvisoryIdentityResolver.cs
Normal file
303
src/StellaOps.Feedser.Merge/Identity/AdvisoryIdentityResolver.cs
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using StellaOps.Feedser.Models;
|
||||||
|
|
||||||
|
namespace StellaOps.Feedser.Merge.Identity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an alias-driven identity graph that groups advisories referring to the same vulnerability.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AdvisoryIdentityResolver
|
||||||
|
{
|
||||||
|
private static readonly string[] CanonicalAliasPriority =
|
||||||
|
{
|
||||||
|
AliasSchemes.Cve,
|
||||||
|
AliasSchemes.Rhsa,
|
||||||
|
AliasSchemes.Usn,
|
||||||
|
AliasSchemes.Dsa,
|
||||||
|
AliasSchemes.SuseSu,
|
||||||
|
AliasSchemes.Msrc,
|
||||||
|
AliasSchemes.CiscoSa,
|
||||||
|
AliasSchemes.OracleCpu,
|
||||||
|
AliasSchemes.Vmsa,
|
||||||
|
AliasSchemes.Apsb,
|
||||||
|
AliasSchemes.Apa,
|
||||||
|
AliasSchemes.AppleHt,
|
||||||
|
AliasSchemes.ChromiumPost,
|
||||||
|
AliasSchemes.Icsa,
|
||||||
|
AliasSchemes.Jvndb,
|
||||||
|
AliasSchemes.Jvn,
|
||||||
|
AliasSchemes.Bdu,
|
||||||
|
AliasSchemes.Vu,
|
||||||
|
AliasSchemes.Ghsa,
|
||||||
|
AliasSchemes.OsV,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Groups the provided advisories into identity clusters using normalized aliases.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<AdvisoryIdentityCluster> Resolve(IEnumerable<Advisory> advisories)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(advisories);
|
||||||
|
|
||||||
|
var materialized = advisories
|
||||||
|
.Where(static advisory => advisory is not null)
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (materialized.Length == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<AdvisoryIdentityCluster>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var aliasIndex = BuildAliasIndex(materialized);
|
||||||
|
var visited = new HashSet<Advisory>();
|
||||||
|
var clusters = new List<AdvisoryIdentityCluster>();
|
||||||
|
|
||||||
|
foreach (var advisory in materialized)
|
||||||
|
{
|
||||||
|
if (!visited.Add(advisory))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var component = TraverseComponent(advisory, visited, aliasIndex);
|
||||||
|
var key = DetermineCanonicalKey(component);
|
||||||
|
var aliases = component
|
||||||
|
.SelectMany(static entry => entry.Aliases)
|
||||||
|
.Select(static alias => new AliasIdentity(alias.Normalized, alias.Scheme));
|
||||||
|
clusters.Add(new AdvisoryIdentityCluster(key, component.Select(static entry => entry.Advisory), aliases));
|
||||||
|
}
|
||||||
|
|
||||||
|
return clusters
|
||||||
|
.OrderBy(static cluster => cluster.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToImmutableArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, List<AdvisoryAliasEntry>> BuildAliasIndex(IEnumerable<Advisory> advisories)
|
||||||
|
{
|
||||||
|
var index = new Dictionary<string, List<AdvisoryAliasEntry>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var advisory in advisories)
|
||||||
|
{
|
||||||
|
foreach (var alias in ExtractAliases(advisory))
|
||||||
|
{
|
||||||
|
if (!index.TryGetValue(alias.Normalized, out var list))
|
||||||
|
{
|
||||||
|
list = new List<AdvisoryAliasEntry>();
|
||||||
|
index[alias.Normalized] = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(new AdvisoryAliasEntry(advisory, alias.Normalized, alias.Scheme));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<AliasBinding> TraverseComponent(
|
||||||
|
Advisory root,
|
||||||
|
HashSet<Advisory> visited,
|
||||||
|
Dictionary<string, List<AdvisoryAliasEntry>> aliasIndex)
|
||||||
|
{
|
||||||
|
var stack = new Stack<Advisory>();
|
||||||
|
stack.Push(root);
|
||||||
|
|
||||||
|
var bindings = new Dictionary<Advisory, AliasBinding>(ReferenceEqualityComparer<Advisory>.Instance);
|
||||||
|
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
var advisory = stack.Pop();
|
||||||
|
|
||||||
|
if (!bindings.TryGetValue(advisory, out var binding))
|
||||||
|
{
|
||||||
|
binding = new AliasBinding(advisory);
|
||||||
|
bindings[advisory] = binding;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var alias in ExtractAliases(advisory))
|
||||||
|
{
|
||||||
|
binding.AddAlias(alias.Normalized, alias.Scheme);
|
||||||
|
|
||||||
|
if (!aliasIndex.TryGetValue(alias.Normalized, out var neighbors))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var neighbor in neighbors.Select(static entry => entry.Advisory))
|
||||||
|
{
|
||||||
|
if (visited.Add(neighbor))
|
||||||
|
{
|
||||||
|
stack.Push(neighbor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bindings.TryGetValue(neighbor, out var neighborBinding))
|
||||||
|
{
|
||||||
|
neighborBinding = new AliasBinding(neighbor);
|
||||||
|
bindings[neighbor] = neighborBinding;
|
||||||
|
}
|
||||||
|
|
||||||
|
neighborBinding.AddAlias(alias.Normalized, alias.Scheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bindings.Values
|
||||||
|
.OrderBy(static binding => binding.Advisory.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToImmutableArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DetermineCanonicalKey(IReadOnlyList<AliasBinding> component)
|
||||||
|
{
|
||||||
|
var aliases = component
|
||||||
|
.SelectMany(static binding => binding.Aliases)
|
||||||
|
.Where(static alias => !string.IsNullOrWhiteSpace(alias.Normalized))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var scheme in CanonicalAliasPriority)
|
||||||
|
{
|
||||||
|
var candidate = aliases
|
||||||
|
.Where(alias => string.Equals(alias.Scheme, scheme, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(static alias => alias.Normalized)
|
||||||
|
.OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (candidate is not null)
|
||||||
|
{
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fallbackAlias = aliases
|
||||||
|
.Select(static alias => alias.Normalized)
|
||||||
|
.OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(fallbackAlias))
|
||||||
|
{
|
||||||
|
return fallbackAlias;
|
||||||
|
}
|
||||||
|
|
||||||
|
var advisoryKey = component
|
||||||
|
.Select(static binding => binding.Advisory.AdvisoryKey)
|
||||||
|
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(advisoryKey))
|
||||||
|
{
|
||||||
|
return advisoryKey.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Unable to determine canonical advisory key for cluster.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<AliasProjection> ExtractAliases(Advisory advisory)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var candidate in EnumerateAliasCandidates(advisory))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(candidate))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = candidate.Trim();
|
||||||
|
if (!seen.Add(trimmed))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AliasSchemeRegistry.TryNormalize(trimmed, out var normalized, out var scheme) &&
|
||||||
|
!string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
yield return new AliasProjection(normalized.Trim(), string.IsNullOrWhiteSpace(scheme) ? null : scheme);
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
yield return new AliasProjection(normalized.Trim(), null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> EnumerateAliasCandidates(Advisory advisory)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(advisory.AdvisoryKey))
|
||||||
|
{
|
||||||
|
yield return advisory.AdvisoryKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!advisory.Aliases.IsDefaultOrEmpty)
|
||||||
|
{
|
||||||
|
foreach (var alias in advisory.Aliases)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(alias))
|
||||||
|
{
|
||||||
|
yield return alias;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct AdvisoryAliasEntry(Advisory Advisory, string Normalized, string? Scheme);
|
||||||
|
|
||||||
|
private readonly record struct AliasProjection(string Normalized, string? Scheme);
|
||||||
|
|
||||||
|
private sealed class AliasBinding
|
||||||
|
{
|
||||||
|
private readonly HashSet<AliasProjection> _aliases = new(HashSetAliasComparer.Instance);
|
||||||
|
|
||||||
|
public AliasBinding(Advisory advisory)
|
||||||
|
{
|
||||||
|
Advisory = advisory ?? throw new ArgumentNullException(nameof(advisory));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Advisory Advisory { get; }
|
||||||
|
|
||||||
|
public IReadOnlyCollection<AliasProjection> Aliases => _aliases;
|
||||||
|
|
||||||
|
public void AddAlias(string normalized, string? scheme)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_aliases.Add(new AliasProjection(normalized.Trim(), scheme is null ? null : scheme.Trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class HashSetAliasComparer : IEqualityComparer<AliasProjection>
|
||||||
|
{
|
||||||
|
public static readonly HashSetAliasComparer Instance = new();
|
||||||
|
|
||||||
|
public bool Equals(AliasProjection x, AliasProjection y)
|
||||||
|
=> string.Equals(x.Normalized, y.Normalized, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(x.Scheme, y.Scheme, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public int GetHashCode(AliasProjection obj)
|
||||||
|
{
|
||||||
|
var hash = StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Normalized);
|
||||||
|
if (!string.IsNullOrWhiteSpace(obj.Scheme))
|
||||||
|
{
|
||||||
|
hash = HashCode.Combine(hash, StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Scheme));
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ReferenceEqualityComparer<T> : IEqualityComparer<T>
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
public static readonly ReferenceEqualityComparer<T> Instance = new();
|
||||||
|
|
||||||
|
public bool Equals(T? x, T? y) => ReferenceEquals(x, y);
|
||||||
|
|
||||||
|
public int GetHashCode(T obj) => RuntimeHelpers.GetHashCode(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/StellaOps.Feedser.Merge/Identity/AliasIdentity.cs
Normal file
24
src/StellaOps.Feedser.Merge/Identity/AliasIdentity.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace StellaOps.Feedser.Merge.Identity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalized alias representation used within identity clusters.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AliasIdentity
|
||||||
|
{
|
||||||
|
public AliasIdentity(string value, string? scheme)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Alias value must be provided.", nameof(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Value = value.Trim();
|
||||||
|
Scheme = string.IsNullOrWhiteSpace(scheme) ? null : scheme.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Value { get; }
|
||||||
|
|
||||||
|
public string? Scheme { get; }
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# TASKS
|
# TASKS
|
||||||
| Task | Owner(s) | Depends on | Notes |
|
| Task | Owner(s) | Depends on | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|Identity graph and alias resolver|BE-Merge|Models, Storage.Mongo|Deterministic key choice; cycle-safe.|
|
|Identity graph and alias resolver|BE-Merge|Models, Storage.Mongo|DONE – `AdvisoryIdentityResolver` builds alias-driven clusters with canonical key selection + unit coverage.|
|
||||||
|Precedence policy engine|BE-Merge|Architecture|PSIRT/OVAL > NVD; CERTs enrich; KEV flag.|
|
|Precedence policy engine|BE-Merge|Architecture|PSIRT/OVAL > NVD; CERTs enrich; KEV flag.|
|
||||||
|NEVRA comparer plus tests|BE-Merge (Distro WG)|Source.Distro fixtures|DONE – Added Nevra parser/comparer with tilde-aware rpm ordering and unit coverage.|
|
|NEVRA comparer plus tests|BE-Merge (Distro WG)|Source.Distro fixtures|DONE – Added Nevra parser/comparer with tilde-aware rpm ordering and unit coverage.|
|
||||||
|Debian EVR comparer plus tests|BE-Merge (Distro WG)|Debian fixtures|DONE – DebianEvr comparer mirrors dpkg ordering with tilde/epoch handling and unit coverage.|
|
|Debian EVR comparer plus tests|BE-Merge (Distro WG)|Debian fixtures|DONE – DebianEvr comparer mirrors dpkg ordering with tilde/epoch handling and unit coverage.|
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ public sealed class AffectedPackageStatusTests
|
|||||||
[InlineData("KNOWN-NOT-AFFECTED", AffectedPackageStatusCatalog.KnownNotAffected)]
|
[InlineData("KNOWN-NOT-AFFECTED", AffectedPackageStatusCatalog.KnownNotAffected)]
|
||||||
[InlineData("Under Investigation", AffectedPackageStatusCatalog.UnderInvestigation)]
|
[InlineData("Under Investigation", AffectedPackageStatusCatalog.UnderInvestigation)]
|
||||||
[InlineData("Fixed", AffectedPackageStatusCatalog.Fixed)]
|
[InlineData("Fixed", AffectedPackageStatusCatalog.Fixed)]
|
||||||
|
[InlineData("Known not vulnerable", AffectedPackageStatusCatalog.KnownNotAffected)]
|
||||||
|
[InlineData("Impacted", AffectedPackageStatusCatalog.Affected)]
|
||||||
|
[InlineData("Not Vulnerable", AffectedPackageStatusCatalog.NotAffected)]
|
||||||
|
[InlineData("Analysis in progress", AffectedPackageStatusCatalog.UnderInvestigation)]
|
||||||
|
[InlineData("Patch Available", AffectedPackageStatusCatalog.Fixed)]
|
||||||
|
[InlineData("Workaround available", AffectedPackageStatusCatalog.Mitigated)]
|
||||||
|
[InlineData("Does Not Apply", AffectedPackageStatusCatalog.NotApplicable)]
|
||||||
|
[InlineData("Awaiting fix", AffectedPackageStatusCatalog.Pending)]
|
||||||
|
[InlineData("TBD", AffectedPackageStatusCatalog.Unknown)]
|
||||||
public void Constructor_NormalizesStatus(string input, string expected)
|
public void Constructor_NormalizesStatus(string input, string expected)
|
||||||
{
|
{
|
||||||
var provenance = new AdvisoryProvenance("test", "status", "value", DateTimeOffset.UtcNow);
|
var provenance = new AdvisoryProvenance("test", "status", "value", DateTimeOffset.UtcNow);
|
||||||
@@ -25,4 +34,42 @@ public sealed class AffectedPackageStatusTests
|
|||||||
var provenance = new AdvisoryProvenance("test", "status", "value", DateTimeOffset.UtcNow);
|
var provenance = new AdvisoryProvenance("test", "status", "value", DateTimeOffset.UtcNow);
|
||||||
Assert.Throws<ArgumentOutOfRangeException>(() => new AffectedPackageStatus("unsupported", provenance));
|
Assert.Throws<ArgumentOutOfRangeException>(() => new AffectedPackageStatus("unsupported", provenance));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Not Impacted", AffectedPackageStatusCatalog.NotAffected)]
|
||||||
|
[InlineData("Resolved", AffectedPackageStatusCatalog.Fixed)]
|
||||||
|
[InlineData("Mitigation provided", AffectedPackageStatusCatalog.Mitigated)]
|
||||||
|
[InlineData("Out of scope", AffectedPackageStatusCatalog.NotApplicable)]
|
||||||
|
public void TryNormalize_ReturnsExpectedValue(string input, string expected)
|
||||||
|
{
|
||||||
|
Assert.True(AffectedPackageStatusCatalog.TryNormalize(input, out var normalized));
|
||||||
|
Assert.Equal(expected, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryNormalize_ReturnsFalseForUnknown()
|
||||||
|
{
|
||||||
|
Assert.False(AffectedPackageStatusCatalog.TryNormalize("unsupported", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Allowed_ReturnsCanonicalStatuses()
|
||||||
|
{
|
||||||
|
var expected = new[]
|
||||||
|
{
|
||||||
|
AffectedPackageStatusCatalog.KnownAffected,
|
||||||
|
AffectedPackageStatusCatalog.KnownNotAffected,
|
||||||
|
AffectedPackageStatusCatalog.UnderInvestigation,
|
||||||
|
AffectedPackageStatusCatalog.Fixed,
|
||||||
|
AffectedPackageStatusCatalog.FirstFixed,
|
||||||
|
AffectedPackageStatusCatalog.Mitigated,
|
||||||
|
AffectedPackageStatusCatalog.NotApplicable,
|
||||||
|
AffectedPackageStatusCatalog.Affected,
|
||||||
|
AffectedPackageStatusCatalog.NotAffected,
|
||||||
|
AffectedPackageStatusCatalog.Pending,
|
||||||
|
AffectedPackageStatusCatalog.Unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(expected, AffectedPackageStatusCatalog.Allowed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ public sealed class SeverityNormalizationTests
|
|||||||
[InlineData("Info", "informational")]
|
[InlineData("Info", "informational")]
|
||||||
[InlineData("negligible", "none")]
|
[InlineData("negligible", "none")]
|
||||||
[InlineData("unknown", "unknown")]
|
[InlineData("unknown", "unknown")]
|
||||||
|
[InlineData("Sev Critical", "critical")]
|
||||||
|
[InlineData("HIGH vendor", "high")]
|
||||||
|
[InlineData("Informative", "informational")]
|
||||||
|
[InlineData("Not Applicable", "none")]
|
||||||
|
[InlineData("Undetermined", "unknown")]
|
||||||
|
[InlineData("Priority 0", "critical")]
|
||||||
|
[InlineData("Priority-2", "medium")]
|
||||||
|
[InlineData("N/A", "none")]
|
||||||
[InlineData("custom-level", "custom-level")]
|
[InlineData("custom-level", "custom-level")]
|
||||||
public void Normalize_ReturnsExpectedCanonicalValue(string input, string expected)
|
public void Normalize_ReturnsExpectedCanonicalValue(string input, string expected)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,4 +7,9 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../StellaOps.Feedser.Models/StellaOps.Feedser.Models.csproj" />
|
<ProjectReference Include="../StellaOps.Feedser.Models/StellaOps.Feedser.Models.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup Condition="'$(UpdateGoldens)' == 'true'">
|
||||||
|
<EnvironmentVariables Include="UPDATE_GOLDENS">
|
||||||
|
<Value>1</Value>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
namespace StellaOps.Feedser.Models;
|
namespace StellaOps.Feedser.Models;
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ public static class AffectedPackageStatusCatalog
|
|||||||
public const string Pending = "pending";
|
public const string Pending = "pending";
|
||||||
public const string Unknown = "unknown";
|
public const string Unknown = "unknown";
|
||||||
|
|
||||||
private static readonly HashSet<string> AllowedStatuses = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly string[] CanonicalStatuses =
|
||||||
{
|
{
|
||||||
KnownAffected,
|
KnownAffected,
|
||||||
KnownNotAffected,
|
KnownNotAffected,
|
||||||
@@ -35,21 +36,121 @@ public static class AffectedPackageStatusCatalog
|
|||||||
Unknown,
|
Unknown,
|
||||||
};
|
};
|
||||||
|
|
||||||
public static IReadOnlyCollection<string> Allowed => AllowedStatuses;
|
private static readonly IReadOnlyList<string> AllowedStatuses = Array.AsReadOnly(CanonicalStatuses);
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> StatusMap = BuildStatusMap();
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> Allowed => AllowedStatuses;
|
||||||
|
|
||||||
public static string Normalize(string status)
|
public static string Normalize(string status)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(status))
|
if (!TryNormalize(status, out var normalized))
|
||||||
{
|
|
||||||
throw new ArgumentException("Status must be provided.", nameof(status));
|
|
||||||
}
|
|
||||||
|
|
||||||
var token = status.Trim().ToLowerInvariant().Replace(' ', '_').Replace('-', '_');
|
|
||||||
if (!AllowedStatuses.Contains(token))
|
|
||||||
{
|
{
|
||||||
throw new ArgumentOutOfRangeException(nameof(status), status, "Status is not part of the allowed affected-package status glossary.");
|
throw new ArgumentOutOfRangeException(nameof(status), status, "Status is not part of the allowed affected-package status glossary.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return token;
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryNormalize(string? status, [NotNullWhen(true)] out string? normalized)
|
||||||
|
{
|
||||||
|
normalized = null;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(status))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = Sanitize(status);
|
||||||
|
if (token.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!StatusMap.TryGetValue(token, out normalized))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsAllowed(string? status)
|
||||||
|
=> TryNormalize(status, out _);
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, string> BuildStatusMap()
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (var status in CanonicalStatuses)
|
||||||
|
{
|
||||||
|
map[Sanitize(status)] = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
Add(map, "known not vulnerable", KnownNotAffected);
|
||||||
|
Add(map, "known unaffected", KnownNotAffected);
|
||||||
|
Add(map, "known not impacted", KnownNotAffected);
|
||||||
|
Add(map, "vulnerable", Affected);
|
||||||
|
Add(map, "impacted", Affected);
|
||||||
|
Add(map, "impacting", Affected);
|
||||||
|
Add(map, "not vulnerable", NotAffected);
|
||||||
|
Add(map, "unaffected", NotAffected);
|
||||||
|
Add(map, "not impacted", NotAffected);
|
||||||
|
Add(map, "no impact", NotAffected);
|
||||||
|
Add(map, "impact free", NotAffected);
|
||||||
|
Add(map, "investigating", UnderInvestigation);
|
||||||
|
Add(map, "analysis in progress", UnderInvestigation);
|
||||||
|
Add(map, "analysis pending", UnderInvestigation);
|
||||||
|
Add(map, "patch available", Fixed);
|
||||||
|
Add(map, "fix available", Fixed);
|
||||||
|
Add(map, "patched", Fixed);
|
||||||
|
Add(map, "resolved", Fixed);
|
||||||
|
Add(map, "remediated", Fixed);
|
||||||
|
Add(map, "workaround available", Mitigated);
|
||||||
|
Add(map, "mitigation available", Mitigated);
|
||||||
|
Add(map, "mitigation provided", Mitigated);
|
||||||
|
Add(map, "not applicable", NotApplicable);
|
||||||
|
Add(map, "n/a", NotApplicable);
|
||||||
|
Add(map, "na", NotApplicable);
|
||||||
|
Add(map, "does not apply", NotApplicable);
|
||||||
|
Add(map, "out of scope", NotApplicable);
|
||||||
|
Add(map, "pending fix", Pending);
|
||||||
|
Add(map, "awaiting fix", Pending);
|
||||||
|
Add(map, "awaiting patch", Pending);
|
||||||
|
Add(map, "scheduled", Pending);
|
||||||
|
Add(map, "planned", Pending);
|
||||||
|
Add(map, "tbd", Unknown);
|
||||||
|
Add(map, "to be determined", Unknown);
|
||||||
|
Add(map, "undetermined", Unknown);
|
||||||
|
Add(map, "not yet known", Unknown);
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Add(IDictionary<string, string> map, string alias, string canonical)
|
||||||
|
{
|
||||||
|
var key = Sanitize(alias);
|
||||||
|
if (key.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
map[key] = canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Sanitize(string value)
|
||||||
|
{
|
||||||
|
var span = value.AsSpan();
|
||||||
|
var buffer = new char[span.Length];
|
||||||
|
var index = 0;
|
||||||
|
|
||||||
|
foreach (var ch in span)
|
||||||
|
{
|
||||||
|
if (char.IsLetterOrDigit(ch))
|
||||||
|
{
|
||||||
|
buffer[index++] = char.ToLowerInvariant(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return index == 0 ? string.Empty : new string(buffer, 0, index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace StellaOps.Feedser.Models;
|
namespace StellaOps.Feedser.Models;
|
||||||
|
|
||||||
@@ -8,28 +9,76 @@ namespace StellaOps.Feedser.Models;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class SeverityNormalization
|
public static class SeverityNormalization
|
||||||
{
|
{
|
||||||
private static readonly IReadOnlyDictionary<string, string> SeverityMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
private static readonly IReadOnlyDictionary<string, string> SeverityMap = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["critical"] = "critical",
|
["critical"] = "critical",
|
||||||
["sev_critical"] = "critical",
|
["crit"] = "critical",
|
||||||
["sev-critical"] = "critical",
|
["sevcritical"] = "critical",
|
||||||
|
["extreme"] = "critical",
|
||||||
|
["verycritical"] = "critical",
|
||||||
|
["veryhigh"] = "critical",
|
||||||
|
["p0"] = "critical",
|
||||||
|
["priority0"] = "critical",
|
||||||
["high"] = "high",
|
["high"] = "high",
|
||||||
["sev_high"] = "high",
|
["sevhigh"] = "high",
|
||||||
["sev-high"] = "high",
|
|
||||||
["important"] = "high",
|
["important"] = "high",
|
||||||
["severe"] = "high",
|
["severe"] = "high",
|
||||||
|
["major"] = "high",
|
||||||
|
["urgent"] = "high",
|
||||||
|
["elevated"] = "high",
|
||||||
|
["p1"] = "high",
|
||||||
|
["priority1"] = "high",
|
||||||
["medium"] = "medium",
|
["medium"] = "medium",
|
||||||
["moderate"] = "medium",
|
["moderate"] = "medium",
|
||||||
["normal"] = "medium",
|
["normal"] = "medium",
|
||||||
["avg"] = "medium",
|
["avg"] = "medium",
|
||||||
|
["average"] = "medium",
|
||||||
|
["standard"] = "medium",
|
||||||
|
["p2"] = "medium",
|
||||||
|
["priority2"] = "medium",
|
||||||
["low"] = "low",
|
["low"] = "low",
|
||||||
["minor"] = "low",
|
["minor"] = "low",
|
||||||
["info"] = "informational",
|
["minimal"] = "low",
|
||||||
|
["limited"] = "low",
|
||||||
|
["p3"] = "low",
|
||||||
|
["priority3"] = "low",
|
||||||
["informational"] = "informational",
|
["informational"] = "informational",
|
||||||
|
["info"] = "informational",
|
||||||
|
["informative"] = "informational",
|
||||||
["notice"] = "informational",
|
["notice"] = "informational",
|
||||||
|
["advisory"] = "informational",
|
||||||
["none"] = "none",
|
["none"] = "none",
|
||||||
["negligible"] = "none",
|
["negligible"] = "none",
|
||||||
|
["insignificant"] = "none",
|
||||||
|
["notapplicable"] = "none",
|
||||||
|
["na"] = "none",
|
||||||
["unknown"] = "unknown",
|
["unknown"] = "unknown",
|
||||||
|
["undetermined"] = "unknown",
|
||||||
|
["notdefined"] = "unknown",
|
||||||
|
["notspecified"] = "unknown",
|
||||||
|
["pending"] = "unknown",
|
||||||
|
["tbd"] = "unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly char[] TokenSeparators =
|
||||||
|
{
|
||||||
|
' ',
|
||||||
|
'/',
|
||||||
|
'\\',
|
||||||
|
'-',
|
||||||
|
'_',
|
||||||
|
',',
|
||||||
|
';',
|
||||||
|
':',
|
||||||
|
'(',
|
||||||
|
')',
|
||||||
|
'[',
|
||||||
|
']',
|
||||||
|
'{',
|
||||||
|
'}',
|
||||||
|
'|',
|
||||||
|
'+',
|
||||||
|
'&'
|
||||||
};
|
};
|
||||||
|
|
||||||
public static readonly IReadOnlyCollection<string> CanonicalLevels = new[]
|
public static readonly IReadOnlyCollection<string> CanonicalLevels = new[]
|
||||||
@@ -51,18 +100,53 @@ public static class SeverityNormalization
|
|||||||
}
|
}
|
||||||
|
|
||||||
var trimmed = severity.Trim();
|
var trimmed = severity.Trim();
|
||||||
if (SeverityMap.TryGetValue(trimmed, out var mapped))
|
|
||||||
|
if (TryNormalizeToken(trimmed, out var mapped))
|
||||||
{
|
{
|
||||||
return mapped;
|
return mapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
var lowered = trimmed.ToLowerInvariant();
|
foreach (var token in trimmed.Split(TokenSeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
|
||||||
if (SeverityMap.TryGetValue(lowered, out mapped))
|
|
||||||
{
|
{
|
||||||
return mapped;
|
if (TryNormalizeToken(token, out mapped))
|
||||||
|
{
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lowered;
|
return trimmed.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryNormalizeToken(string value, out string mapped)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeToken(value);
|
||||||
|
if (normalized.Length == 0)
|
||||||
|
{
|
||||||
|
mapped = string.Empty;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SeverityMap.TryGetValue(normalized, out var mappedValue))
|
||||||
|
{
|
||||||
|
mapped = string.Empty;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
mapped = mappedValue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeToken(string value)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder(value.Length);
|
||||||
|
foreach (var ch in value)
|
||||||
|
{
|
||||||
|
if (char.IsLetterOrDigit(ch))
|
||||||
|
{
|
||||||
|
builder.Append(char.ToLowerInvariant(ch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|Range primitives for SemVer/EVR/NEVRA metadata|BE-Merge|Models|TODO – keep structured values without parsing logic; ensure merge/export parity.|
|
|Range primitives for SemVer/EVR/NEVRA metadata|BE-Merge|Models|TODO – keep structured values without parsing logic; ensure merge/export parity.|
|
||||||
|Provenance envelope field masks|BE-Merge|Models|TODO – guarantee traceability for each mapped field.|
|
|Provenance envelope field masks|BE-Merge|Models|TODO – guarantee traceability for each mapped field.|
|
||||||
|Backward-compatibility playbook|BE-Merge, QA|Models|DONE – see `BACKWARD_COMPATIBILITY.md` for evolution policy/test checklist.|
|
|Backward-compatibility playbook|BE-Merge, QA|Models|DONE – see `BACKWARD_COMPATIBILITY.md` for evolution policy/test checklist.|
|
||||||
|Golden canonical examples|QA|Models|DOING – `CanonicalExampleFactory` + tests added; fixtures need regeneration hookup to enable easy updates.|
|
|Golden canonical examples|QA|Models|DONE – added `/p:UpdateGoldens=true` test hook wiring `UPDATE_GOLDENS=1` so canonical fixtures regenerate via `dotnet test`; docs/tests unchanged.|
|
||||||
|Serialization determinism regression tests|QA|Models|TODO – automate hash comparisons across locales/runs.|
|
|Serialization determinism regression tests|QA|Models|TODO – automate hash comparisons across locales/runs.|
|
||||||
|Severity normalization helpers|BE-Merge|Models|DOING – helper introduced to normalize vendor severities; refine synonym coverage.|
|
|Severity normalization helpers|BE-Merge|Models|DONE – helper now normalizes compound vendor labels/priority tiers with expanded synonym coverage and regression tests.|
|
||||||
|AffectedPackage status glossary & guardrails|BE-Merge|Models|DOING – status catalog with validation added; monitor for additional vendor values.|
|
|AffectedPackage status glossary & guardrails|BE-Merge|Models|DONE – catalog now exposes deterministic listing, TryNormalize helpers, and synonym coverage for vendor phrases (not vulnerable, workaround available, etc.).|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
"url": "https://www.cve.org/CVERecord?id=CVE-2024-9991"
|
"url": "https://www.cve.org/CVERecord?id=CVE-2024-9991"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"severity": "high vendor",
|
"severity": "high",
|
||||||
"summary": "Example Gateway devices vulnerable to remote code execution (CVE-2024-9990).",
|
"summary": "Example Gateway devices vulnerable to remote code execution (CVE-2024-9990).",
|
||||||
"title": "Multiple vulnerabilities in Example Gateway"
|
"title": "Multiple vulnerabilities in Example Gateway"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public sealed class ExportStateManagerTests
|
|||||||
cursor: "cursor-1",
|
cursor: "cursor-1",
|
||||||
targetRepository: "registry.local/json",
|
targetRepository: "registry.local/json",
|
||||||
exporterVersion: "1.0.0",
|
exporterVersion: "1.0.0",
|
||||||
|
resetBaseline: true,
|
||||||
cancellationToken: CancellationToken.None);
|
cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal("export:json", record.Id);
|
Assert.Equal("export:json", record.Id);
|
||||||
@@ -35,6 +36,93 @@ public sealed class ExportStateManagerTests
|
|||||||
Assert.Equal(timeProvider.Now, record.UpdatedAt);
|
Assert.Equal(timeProvider.Now, record.UpdatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StoreFullExport_ResetBaselineOverridesExisting()
|
||||||
|
{
|
||||||
|
var store = new InMemoryExportStateStore();
|
||||||
|
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2024-07-20T12:00:00Z"));
|
||||||
|
var manager = new ExportStateManager(store, timeProvider);
|
||||||
|
|
||||||
|
await manager.StoreFullExportAsync(
|
||||||
|
exporterId: "export:json",
|
||||||
|
exportId: "20240720T120000Z",
|
||||||
|
exportDigest: "sha256:base",
|
||||||
|
cursor: "cursor-base",
|
||||||
|
targetRepository: null,
|
||||||
|
exporterVersion: "1.0.0",
|
||||||
|
resetBaseline: true,
|
||||||
|
cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||||
|
var withoutReset = await manager.StoreFullExportAsync(
|
||||||
|
exporterId: "export:json",
|
||||||
|
exportId: "20240720T120500Z",
|
||||||
|
exportDigest: "sha256:new",
|
||||||
|
cursor: "cursor-new",
|
||||||
|
targetRepository: null,
|
||||||
|
exporterVersion: "1.0.1",
|
||||||
|
resetBaseline: false,
|
||||||
|
cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("20240720T120000Z", withoutReset.BaseExportId);
|
||||||
|
Assert.Equal("sha256:base", withoutReset.BaseDigest);
|
||||||
|
Assert.Equal("sha256:new", withoutReset.LastFullDigest);
|
||||||
|
Assert.Equal("cursor-new", withoutReset.ExportCursor);
|
||||||
|
Assert.Equal(timeProvider.Now, withoutReset.UpdatedAt);
|
||||||
|
|
||||||
|
timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||||
|
var reset = await manager.StoreFullExportAsync(
|
||||||
|
exporterId: "export:json",
|
||||||
|
exportId: "20240720T121000Z",
|
||||||
|
exportDigest: "sha256:final",
|
||||||
|
cursor: "cursor-final",
|
||||||
|
targetRepository: null,
|
||||||
|
exporterVersion: "1.0.2",
|
||||||
|
resetBaseline: true,
|
||||||
|
cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("20240720T121000Z", reset.BaseExportId);
|
||||||
|
Assert.Equal("sha256:final", reset.BaseDigest);
|
||||||
|
Assert.Equal("sha256:final", reset.LastFullDigest);
|
||||||
|
Assert.Null(reset.LastDeltaDigest);
|
||||||
|
Assert.Equal("cursor-final", reset.ExportCursor);
|
||||||
|
Assert.Equal(timeProvider.Now, reset.UpdatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StoreFullExport_ResetsBaselineWhenRepositoryChanges()
|
||||||
|
{
|
||||||
|
var store = new InMemoryExportStateStore();
|
||||||
|
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2024-07-21T08:00:00Z"));
|
||||||
|
var manager = new ExportStateManager(store, timeProvider);
|
||||||
|
|
||||||
|
await manager.StoreFullExportAsync(
|
||||||
|
exporterId: "export:json",
|
||||||
|
exportId: "20240721T080000Z",
|
||||||
|
exportDigest: "sha256:base",
|
||||||
|
cursor: "cursor-base",
|
||||||
|
targetRepository: "registry/v1/json",
|
||||||
|
exporterVersion: "1.0.0",
|
||||||
|
resetBaseline: true,
|
||||||
|
cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
timeProvider.Advance(TimeSpan.FromMinutes(10));
|
||||||
|
var updated = await manager.StoreFullExportAsync(
|
||||||
|
exporterId: "export:json",
|
||||||
|
exportId: "20240721T081000Z",
|
||||||
|
exportDigest: "sha256:new",
|
||||||
|
cursor: "cursor-new",
|
||||||
|
targetRepository: "registry/v2/json",
|
||||||
|
exporterVersion: "1.1.0",
|
||||||
|
resetBaseline: false,
|
||||||
|
cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("20240721T081000Z", updated.BaseExportId);
|
||||||
|
Assert.Equal("sha256:new", updated.BaseDigest);
|
||||||
|
Assert.Equal("sha256:new", updated.LastFullDigest);
|
||||||
|
Assert.Equal("registry/v2/json", updated.TargetRepository);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StoreDeltaExportRequiresBaseline()
|
public async Task StoreDeltaExportRequiresBaseline()
|
||||||
{
|
{
|
||||||
@@ -63,6 +151,7 @@ public sealed class ExportStateManagerTests
|
|||||||
cursor: "cursor-1",
|
cursor: "cursor-1",
|
||||||
targetRepository: null,
|
targetRepository: null,
|
||||||
exporterVersion: "1.0.0",
|
exporterVersion: "1.0.0",
|
||||||
|
resetBaseline: true,
|
||||||
cancellationToken: CancellationToken.None);
|
cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
timeProvider.Advance(TimeSpan.FromMinutes(10));
|
timeProvider.Advance(TimeSpan.FromMinutes(10));
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public sealed class ExportStateManager
|
|||||||
string? cursor,
|
string? cursor,
|
||||||
string? targetRepository,
|
string? targetRepository,
|
||||||
string exporterVersion,
|
string exporterVersion,
|
||||||
|
bool resetBaseline,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrEmpty(exporterId);
|
ArgumentException.ThrowIfNullOrEmpty(exporterId);
|
||||||
@@ -39,36 +40,59 @@ public sealed class ExportStateManager
|
|||||||
ArgumentException.ThrowIfNullOrEmpty(exporterVersion);
|
ArgumentException.ThrowIfNullOrEmpty(exporterVersion);
|
||||||
|
|
||||||
var existing = await _store.FindAsync(exporterId, cancellationToken).ConfigureAwait(false);
|
var existing = await _store.FindAsync(exporterId, cancellationToken).ConfigureAwait(false);
|
||||||
var repository = string.IsNullOrWhiteSpace(targetRepository) ? existing?.TargetRepository : targetRepository;
|
|
||||||
var now = _timeProvider.GetUtcNow();
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
var baseExportId = existing?.BaseExportId ?? exportId;
|
if (existing is null)
|
||||||
var baseDigest = existing?.BaseDigest ?? exportDigest;
|
{
|
||||||
|
var resolvedRepository = string.IsNullOrWhiteSpace(targetRepository) ? null : targetRepository;
|
||||||
|
return await _store.UpsertAsync(
|
||||||
|
new ExportStateRecord(
|
||||||
|
exporterId,
|
||||||
|
BaseExportId: exportId,
|
||||||
|
BaseDigest: exportDigest,
|
||||||
|
LastFullDigest: exportDigest,
|
||||||
|
LastDeltaDigest: null,
|
||||||
|
ExportCursor: cursor ?? exportDigest,
|
||||||
|
TargetRepository: resolvedRepository,
|
||||||
|
ExporterVersion: exporterVersion,
|
||||||
|
UpdatedAt: now),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
var record = existing is null
|
var repositorySpecified = !string.IsNullOrWhiteSpace(targetRepository);
|
||||||
? new ExportStateRecord(
|
var resolvedRepo = repositorySpecified ? targetRepository : existing.TargetRepository;
|
||||||
exporterId,
|
var repositoryChanged = repositorySpecified
|
||||||
baseExportId,
|
&& !string.Equals(existing.TargetRepository, targetRepository, StringComparison.Ordinal);
|
||||||
baseDigest,
|
|
||||||
exportDigest,
|
var shouldResetBaseline =
|
||||||
LastDeltaDigest: null,
|
resetBaseline
|
||||||
ExportCursor: cursor ?? exportDigest,
|
|| string.IsNullOrWhiteSpace(existing.BaseExportId)
|
||||||
TargetRepository: repository,
|
|| string.IsNullOrWhiteSpace(existing.BaseDigest)
|
||||||
ExporterVersion: exporterVersion,
|
|| repositoryChanged;
|
||||||
UpdatedAt: now)
|
|
||||||
|
var updatedRecord = shouldResetBaseline
|
||||||
|
? existing with
|
||||||
|
{
|
||||||
|
BaseExportId = exportId,
|
||||||
|
BaseDigest = exportDigest,
|
||||||
|
LastFullDigest = exportDigest,
|
||||||
|
LastDeltaDigest = null,
|
||||||
|
ExportCursor = cursor ?? exportDigest,
|
||||||
|
TargetRepository = resolvedRepo,
|
||||||
|
ExporterVersion = exporterVersion,
|
||||||
|
UpdatedAt = now,
|
||||||
|
}
|
||||||
: existing with
|
: existing with
|
||||||
{
|
{
|
||||||
BaseExportId = baseExportId,
|
|
||||||
BaseDigest = baseDigest,
|
|
||||||
LastFullDigest = exportDigest,
|
LastFullDigest = exportDigest,
|
||||||
LastDeltaDigest = null,
|
LastDeltaDigest = null,
|
||||||
ExportCursor = cursor ?? existing.ExportCursor,
|
ExportCursor = cursor ?? existing.ExportCursor,
|
||||||
TargetRepository = repository,
|
TargetRepository = resolvedRepo,
|
||||||
ExporterVersion = exporterVersion,
|
ExporterVersion = exporterVersion,
|
||||||
UpdatedAt = now,
|
UpdatedAt = now,
|
||||||
};
|
};
|
||||||
|
|
||||||
return await _store.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
return await _store.UpsertAsync(updatedRecord, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ExportStateRecord> StoreDeltaExportAsync(
|
public async Task<ExportStateRecord> StoreDeltaExportAsync(
|
||||||
|
|||||||
Reference in New Issue
Block a user