up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1 +1 @@
|
||||
// Intentionally left blank; types moved into dedicated files.
|
||||
// Intentionally left blank; types moved into dedicated files.
|
||||
|
||||
@@ -1,232 +1,232 @@
|
||||
namespace StellaOps.Concelier.Merge.Comparers;
|
||||
|
||||
using System;
|
||||
using StellaOps.Concelier.Normalization.Distro;
|
||||
|
||||
public sealed class DebianEvrComparer : IComparer<DebianEvr>, IComparer<string>
|
||||
{
|
||||
public static DebianEvrComparer Instance { get; } = new();
|
||||
|
||||
private DebianEvrComparer()
|
||||
{
|
||||
}
|
||||
|
||||
public int Compare(string? x, string? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (y is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var xParsed = DebianEvr.TryParse(x, out var xEvr);
|
||||
var yParsed = DebianEvr.TryParse(y, out var yEvr);
|
||||
|
||||
if (xParsed && yParsed)
|
||||
{
|
||||
return Compare(xEvr, yEvr);
|
||||
}
|
||||
|
||||
if (xParsed)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (yParsed)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return string.Compare(x, y, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public int Compare(DebianEvr? x, DebianEvr? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (y is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var compare = x.Epoch.CompareTo(y.Epoch);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
compare = CompareSegment(x.Version, y.Version);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
compare = CompareSegment(x.Revision, y.Revision);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
return string.Compare(x.Original, y.Original, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int CompareSegment(string left, string right)
|
||||
{
|
||||
var i = 0;
|
||||
var j = 0;
|
||||
|
||||
while (i < left.Length || j < right.Length)
|
||||
{
|
||||
while (i < left.Length && !IsAlphaNumeric(left[i]) && left[i] != '~')
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
while (j < right.Length && !IsAlphaNumeric(right[j]) && right[j] != '~')
|
||||
{
|
||||
j++;
|
||||
}
|
||||
|
||||
var leftChar = i < left.Length ? left[i] : '\0';
|
||||
var rightChar = j < right.Length ? right[j] : '\0';
|
||||
|
||||
if (leftChar == '~' || rightChar == '~')
|
||||
{
|
||||
if (leftChar != rightChar)
|
||||
{
|
||||
return leftChar == '~' ? -1 : 1;
|
||||
}
|
||||
|
||||
i += leftChar == '~' ? 1 : 0;
|
||||
j += rightChar == '~' ? 1 : 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
var leftIsDigit = char.IsDigit(leftChar);
|
||||
var rightIsDigit = char.IsDigit(rightChar);
|
||||
|
||||
if (leftIsDigit && rightIsDigit)
|
||||
{
|
||||
var leftStart = i;
|
||||
while (i < left.Length && char.IsDigit(left[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
var rightStart = j;
|
||||
while (j < right.Length && char.IsDigit(right[j]))
|
||||
{
|
||||
j++;
|
||||
}
|
||||
|
||||
var leftTrimmed = leftStart;
|
||||
while (leftTrimmed < i && left[leftTrimmed] == '0')
|
||||
{
|
||||
leftTrimmed++;
|
||||
}
|
||||
|
||||
var rightTrimmed = rightStart;
|
||||
while (rightTrimmed < j && right[rightTrimmed] == '0')
|
||||
{
|
||||
rightTrimmed++;
|
||||
}
|
||||
|
||||
var leftLength = i - leftTrimmed;
|
||||
var rightLength = j - rightTrimmed;
|
||||
|
||||
if (leftLength != rightLength)
|
||||
{
|
||||
return leftLength.CompareTo(rightLength);
|
||||
}
|
||||
|
||||
var comparison = left.AsSpan(leftTrimmed, leftLength)
|
||||
.CompareTo(right.AsSpan(rightTrimmed, rightLength), StringComparison.Ordinal);
|
||||
if (comparison != 0)
|
||||
{
|
||||
return comparison;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (leftIsDigit)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (rightIsDigit)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var leftOrder = CharOrder(leftChar);
|
||||
var rightOrder = CharOrder(rightChar);
|
||||
|
||||
var orderComparison = leftOrder.CompareTo(rightOrder);
|
||||
if (orderComparison != 0)
|
||||
{
|
||||
return orderComparison;
|
||||
}
|
||||
|
||||
if (leftChar != rightChar)
|
||||
{
|
||||
return leftChar.CompareTo(rightChar);
|
||||
}
|
||||
|
||||
if (leftChar == '\0')
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
i++;
|
||||
j++;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool IsAlphaNumeric(char value)
|
||||
=> char.IsLetterOrDigit(value);
|
||||
|
||||
private static int CharOrder(char value)
|
||||
{
|
||||
if (value == '\0')
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (value == '~')
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (char.IsDigit(value))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (char.IsLetter(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value + 256;
|
||||
}
|
||||
}
|
||||
namespace StellaOps.Concelier.Merge.Comparers;
|
||||
|
||||
using System;
|
||||
using StellaOps.Concelier.Normalization.Distro;
|
||||
|
||||
public sealed class DebianEvrComparer : IComparer<DebianEvr>, IComparer<string>
|
||||
{
|
||||
public static DebianEvrComparer Instance { get; } = new();
|
||||
|
||||
private DebianEvrComparer()
|
||||
{
|
||||
}
|
||||
|
||||
public int Compare(string? x, string? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (y is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var xParsed = DebianEvr.TryParse(x, out var xEvr);
|
||||
var yParsed = DebianEvr.TryParse(y, out var yEvr);
|
||||
|
||||
if (xParsed && yParsed)
|
||||
{
|
||||
return Compare(xEvr, yEvr);
|
||||
}
|
||||
|
||||
if (xParsed)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (yParsed)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return string.Compare(x, y, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public int Compare(DebianEvr? x, DebianEvr? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (y is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var compare = x.Epoch.CompareTo(y.Epoch);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
compare = CompareSegment(x.Version, y.Version);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
compare = CompareSegment(x.Revision, y.Revision);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
return string.Compare(x.Original, y.Original, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int CompareSegment(string left, string right)
|
||||
{
|
||||
var i = 0;
|
||||
var j = 0;
|
||||
|
||||
while (i < left.Length || j < right.Length)
|
||||
{
|
||||
while (i < left.Length && !IsAlphaNumeric(left[i]) && left[i] != '~')
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
while (j < right.Length && !IsAlphaNumeric(right[j]) && right[j] != '~')
|
||||
{
|
||||
j++;
|
||||
}
|
||||
|
||||
var leftChar = i < left.Length ? left[i] : '\0';
|
||||
var rightChar = j < right.Length ? right[j] : '\0';
|
||||
|
||||
if (leftChar == '~' || rightChar == '~')
|
||||
{
|
||||
if (leftChar != rightChar)
|
||||
{
|
||||
return leftChar == '~' ? -1 : 1;
|
||||
}
|
||||
|
||||
i += leftChar == '~' ? 1 : 0;
|
||||
j += rightChar == '~' ? 1 : 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
var leftIsDigit = char.IsDigit(leftChar);
|
||||
var rightIsDigit = char.IsDigit(rightChar);
|
||||
|
||||
if (leftIsDigit && rightIsDigit)
|
||||
{
|
||||
var leftStart = i;
|
||||
while (i < left.Length && char.IsDigit(left[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
var rightStart = j;
|
||||
while (j < right.Length && char.IsDigit(right[j]))
|
||||
{
|
||||
j++;
|
||||
}
|
||||
|
||||
var leftTrimmed = leftStart;
|
||||
while (leftTrimmed < i && left[leftTrimmed] == '0')
|
||||
{
|
||||
leftTrimmed++;
|
||||
}
|
||||
|
||||
var rightTrimmed = rightStart;
|
||||
while (rightTrimmed < j && right[rightTrimmed] == '0')
|
||||
{
|
||||
rightTrimmed++;
|
||||
}
|
||||
|
||||
var leftLength = i - leftTrimmed;
|
||||
var rightLength = j - rightTrimmed;
|
||||
|
||||
if (leftLength != rightLength)
|
||||
{
|
||||
return leftLength.CompareTo(rightLength);
|
||||
}
|
||||
|
||||
var comparison = left.AsSpan(leftTrimmed, leftLength)
|
||||
.CompareTo(right.AsSpan(rightTrimmed, rightLength), StringComparison.Ordinal);
|
||||
if (comparison != 0)
|
||||
{
|
||||
return comparison;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (leftIsDigit)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (rightIsDigit)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var leftOrder = CharOrder(leftChar);
|
||||
var rightOrder = CharOrder(rightChar);
|
||||
|
||||
var orderComparison = leftOrder.CompareTo(rightOrder);
|
||||
if (orderComparison != 0)
|
||||
{
|
||||
return orderComparison;
|
||||
}
|
||||
|
||||
if (leftChar != rightChar)
|
||||
{
|
||||
return leftChar.CompareTo(rightChar);
|
||||
}
|
||||
|
||||
if (leftChar == '\0')
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
i++;
|
||||
j++;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool IsAlphaNumeric(char value)
|
||||
=> char.IsLetterOrDigit(value);
|
||||
|
||||
private static int CharOrder(char value)
|
||||
{
|
||||
if (value == '\0')
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (value == '~')
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (char.IsDigit(value))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (char.IsLetter(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value + 256;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,264 +1,264 @@
|
||||
namespace StellaOps.Concelier.Merge.Comparers;
|
||||
|
||||
using System;
|
||||
using StellaOps.Concelier.Normalization.Distro;
|
||||
|
||||
public sealed class NevraComparer : IComparer<Nevra>, IComparer<string>
|
||||
{
|
||||
public static NevraComparer Instance { get; } = new();
|
||||
|
||||
private NevraComparer()
|
||||
{
|
||||
}
|
||||
|
||||
public int Compare(string? x, string? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (y is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var xParsed = Nevra.TryParse(x, out var xNevra);
|
||||
var yParsed = Nevra.TryParse(y, out var yNevra);
|
||||
|
||||
if (xParsed && yParsed)
|
||||
{
|
||||
return Compare(xNevra, yNevra);
|
||||
}
|
||||
|
||||
if (xParsed)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (yParsed)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return string.Compare(x, y, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public int Compare(Nevra? x, Nevra? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (y is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var compare = string.Compare(x.Name, y.Name, StringComparison.Ordinal);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
compare = string.Compare(x.Architecture ?? string.Empty, y.Architecture ?? string.Empty, StringComparison.Ordinal);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
compare = x.Epoch.CompareTo(y.Epoch);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
compare = RpmVersionComparer.Compare(x.Version, y.Version);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
compare = RpmVersionComparer.Compare(x.Release, y.Release);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
return string.Compare(x.Original, y.Original, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class RpmVersionComparer
|
||||
{
|
||||
public static int Compare(string? left, string? right)
|
||||
{
|
||||
left ??= string.Empty;
|
||||
right ??= string.Empty;
|
||||
|
||||
var i = 0;
|
||||
var j = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var leftHasTilde = SkipToNextSegment(left, ref i);
|
||||
var rightHasTilde = SkipToNextSegment(right, ref j);
|
||||
|
||||
if (leftHasTilde || rightHasTilde)
|
||||
{
|
||||
if (leftHasTilde && rightHasTilde)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return leftHasTilde ? -1 : 1;
|
||||
}
|
||||
|
||||
var leftEnd = i >= left.Length;
|
||||
var rightEnd = j >= right.Length;
|
||||
if (leftEnd || rightEnd)
|
||||
{
|
||||
if (leftEnd && rightEnd)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return leftEnd ? -1 : 1;
|
||||
}
|
||||
|
||||
var leftDigit = char.IsDigit(left[i]);
|
||||
var rightDigit = char.IsDigit(right[j]);
|
||||
|
||||
if (leftDigit && !rightDigit)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!leftDigit && rightDigit)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
int compare;
|
||||
if (leftDigit)
|
||||
{
|
||||
compare = CompareNumericSegment(left, ref i, right, ref j);
|
||||
}
|
||||
else
|
||||
{
|
||||
compare = CompareAlphaSegment(left, ref i, right, ref j);
|
||||
}
|
||||
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SkipToNextSegment(string value, ref int index)
|
||||
{
|
||||
var sawTilde = false;
|
||||
while (index < value.Length)
|
||||
{
|
||||
var current = value[index];
|
||||
if (current == '~')
|
||||
{
|
||||
sawTilde = true;
|
||||
index++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (char.IsLetterOrDigit(current))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
return sawTilde;
|
||||
}
|
||||
|
||||
private static int CompareNumericSegment(string value, ref int index, string other, ref int otherIndex)
|
||||
{
|
||||
var start = index;
|
||||
while (index < value.Length && char.IsDigit(value[index]))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
var otherStart = otherIndex;
|
||||
while (otherIndex < other.Length && char.IsDigit(other[otherIndex]))
|
||||
{
|
||||
otherIndex++;
|
||||
}
|
||||
|
||||
var trimmedStart = start;
|
||||
while (trimmedStart < index && value[trimmedStart] == '0')
|
||||
{
|
||||
trimmedStart++;
|
||||
}
|
||||
|
||||
var otherTrimmedStart = otherStart;
|
||||
while (otherTrimmedStart < otherIndex && other[otherTrimmedStart] == '0')
|
||||
{
|
||||
otherTrimmedStart++;
|
||||
}
|
||||
|
||||
var length = index - trimmedStart;
|
||||
var otherLength = otherIndex - otherTrimmedStart;
|
||||
|
||||
if (length != otherLength)
|
||||
{
|
||||
return length.CompareTo(otherLength);
|
||||
}
|
||||
|
||||
var comparison = value.AsSpan(trimmedStart, length)
|
||||
.CompareTo(other.AsSpan(otherTrimmedStart, otherLength), StringComparison.Ordinal);
|
||||
if (comparison != 0)
|
||||
{
|
||||
return comparison;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int CompareAlphaSegment(string value, ref int index, string other, ref int otherIndex)
|
||||
{
|
||||
var start = index;
|
||||
while (index < value.Length && char.IsLetter(value[index]))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
var otherStart = otherIndex;
|
||||
while (otherIndex < other.Length && char.IsLetter(other[otherIndex]))
|
||||
{
|
||||
otherIndex++;
|
||||
}
|
||||
|
||||
var length = index - start;
|
||||
var otherLength = otherIndex - otherStart;
|
||||
|
||||
var comparison = value.AsSpan(start, length)
|
||||
.CompareTo(other.AsSpan(otherStart, otherLength), StringComparison.Ordinal);
|
||||
if (comparison != 0)
|
||||
{
|
||||
return comparison;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
namespace StellaOps.Concelier.Merge.Comparers;
|
||||
|
||||
using System;
|
||||
using StellaOps.Concelier.Normalization.Distro;
|
||||
|
||||
public sealed class NevraComparer : IComparer<Nevra>, IComparer<string>
|
||||
{
|
||||
public static NevraComparer Instance { get; } = new();
|
||||
|
||||
private NevraComparer()
|
||||
{
|
||||
}
|
||||
|
||||
public int Compare(string? x, string? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (y is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var xParsed = Nevra.TryParse(x, out var xNevra);
|
||||
var yParsed = Nevra.TryParse(y, out var yNevra);
|
||||
|
||||
if (xParsed && yParsed)
|
||||
{
|
||||
return Compare(xNevra, yNevra);
|
||||
}
|
||||
|
||||
if (xParsed)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (yParsed)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return string.Compare(x, y, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public int Compare(Nevra? x, Nevra? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (y is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var compare = string.Compare(x.Name, y.Name, StringComparison.Ordinal);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
compare = string.Compare(x.Architecture ?? string.Empty, y.Architecture ?? string.Empty, StringComparison.Ordinal);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
compare = x.Epoch.CompareTo(y.Epoch);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
compare = RpmVersionComparer.Compare(x.Version, y.Version);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
compare = RpmVersionComparer.Compare(x.Release, y.Release);
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
|
||||
return string.Compare(x.Original, y.Original, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class RpmVersionComparer
|
||||
{
|
||||
public static int Compare(string? left, string? right)
|
||||
{
|
||||
left ??= string.Empty;
|
||||
right ??= string.Empty;
|
||||
|
||||
var i = 0;
|
||||
var j = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var leftHasTilde = SkipToNextSegment(left, ref i);
|
||||
var rightHasTilde = SkipToNextSegment(right, ref j);
|
||||
|
||||
if (leftHasTilde || rightHasTilde)
|
||||
{
|
||||
if (leftHasTilde && rightHasTilde)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return leftHasTilde ? -1 : 1;
|
||||
}
|
||||
|
||||
var leftEnd = i >= left.Length;
|
||||
var rightEnd = j >= right.Length;
|
||||
if (leftEnd || rightEnd)
|
||||
{
|
||||
if (leftEnd && rightEnd)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return leftEnd ? -1 : 1;
|
||||
}
|
||||
|
||||
var leftDigit = char.IsDigit(left[i]);
|
||||
var rightDigit = char.IsDigit(right[j]);
|
||||
|
||||
if (leftDigit && !rightDigit)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!leftDigit && rightDigit)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
int compare;
|
||||
if (leftDigit)
|
||||
{
|
||||
compare = CompareNumericSegment(left, ref i, right, ref j);
|
||||
}
|
||||
else
|
||||
{
|
||||
compare = CompareAlphaSegment(left, ref i, right, ref j);
|
||||
}
|
||||
|
||||
if (compare != 0)
|
||||
{
|
||||
return compare;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SkipToNextSegment(string value, ref int index)
|
||||
{
|
||||
var sawTilde = false;
|
||||
while (index < value.Length)
|
||||
{
|
||||
var current = value[index];
|
||||
if (current == '~')
|
||||
{
|
||||
sawTilde = true;
|
||||
index++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (char.IsLetterOrDigit(current))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
return sawTilde;
|
||||
}
|
||||
|
||||
private static int CompareNumericSegment(string value, ref int index, string other, ref int otherIndex)
|
||||
{
|
||||
var start = index;
|
||||
while (index < value.Length && char.IsDigit(value[index]))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
var otherStart = otherIndex;
|
||||
while (otherIndex < other.Length && char.IsDigit(other[otherIndex]))
|
||||
{
|
||||
otherIndex++;
|
||||
}
|
||||
|
||||
var trimmedStart = start;
|
||||
while (trimmedStart < index && value[trimmedStart] == '0')
|
||||
{
|
||||
trimmedStart++;
|
||||
}
|
||||
|
||||
var otherTrimmedStart = otherStart;
|
||||
while (otherTrimmedStart < otherIndex && other[otherTrimmedStart] == '0')
|
||||
{
|
||||
otherTrimmedStart++;
|
||||
}
|
||||
|
||||
var length = index - trimmedStart;
|
||||
var otherLength = otherIndex - otherTrimmedStart;
|
||||
|
||||
if (length != otherLength)
|
||||
{
|
||||
return length.CompareTo(otherLength);
|
||||
}
|
||||
|
||||
var comparison = value.AsSpan(trimmedStart, length)
|
||||
.CompareTo(other.AsSpan(otherTrimmedStart, otherLength), StringComparison.Ordinal);
|
||||
if (comparison != 0)
|
||||
{
|
||||
return comparison;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int CompareAlphaSegment(string value, ref int index, string other, ref int otherIndex)
|
||||
{
|
||||
var start = index;
|
||||
while (index < value.Length && char.IsLetter(value[index]))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
var otherStart = otherIndex;
|
||||
while (otherIndex < other.Length && char.IsLetter(other[otherIndex]))
|
||||
{
|
||||
otherIndex++;
|
||||
}
|
||||
|
||||
var length = index - start;
|
||||
var otherLength = otherIndex - otherStart;
|
||||
|
||||
var comparison = value.AsSpan(start, length)
|
||||
.CompareTo(other.AsSpan(otherStart, otherLength), StringComparison.Ordinal);
|
||||
if (comparison != 0)
|
||||
{
|
||||
return comparison;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +1,73 @@
|
||||
namespace StellaOps.Concelier.Merge.Comparers;
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Semver;
|
||||
|
||||
/// <summary>
|
||||
/// Provides helpers to interpret introduced/fixed/lastAffected SemVer ranges and compare versions.
|
||||
/// </summary>
|
||||
public static class SemanticVersionRangeResolver
|
||||
{
|
||||
public static bool TryParse(string? value, [NotNullWhen(true)] out SemVersion? result)
|
||||
=> SemVersion.TryParse(value, SemVersionStyles.Any, out result);
|
||||
|
||||
public static SemVersion Parse(string value)
|
||||
=> SemVersion.Parse(value, SemVersionStyles.Any);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective start and end versions using introduced/fixed/lastAffected semantics.
|
||||
/// </summary>
|
||||
public static (SemVersion? introduced, SemVersion? exclusiveUpperBound, SemVersion? inclusiveUpperBound) ResolveWindows(
|
||||
string? introduced,
|
||||
string? fixedVersion,
|
||||
string? lastAffected)
|
||||
{
|
||||
var introducedVersion = TryParse(introduced, out var parsedIntroduced) ? parsedIntroduced : null;
|
||||
var fixedVersionParsed = TryParse(fixedVersion, out var parsedFixed) ? parsedFixed : null;
|
||||
var lastAffectedVersion = TryParse(lastAffected, out var parsedLast) ? parsedLast : null;
|
||||
|
||||
SemVersion? exclusiveUpper = null;
|
||||
SemVersion? inclusiveUpper = null;
|
||||
|
||||
if (fixedVersionParsed is not null)
|
||||
{
|
||||
exclusiveUpper = fixedVersionParsed;
|
||||
}
|
||||
else if (lastAffectedVersion is not null)
|
||||
{
|
||||
inclusiveUpper = lastAffectedVersion;
|
||||
exclusiveUpper = NextPatch(lastAffectedVersion);
|
||||
}
|
||||
|
||||
return (introducedVersion, exclusiveUpper, inclusiveUpper);
|
||||
}
|
||||
|
||||
|
||||
public static int Compare(string? left, string? right)
|
||||
{
|
||||
var leftParsed = TryParse(left, out var leftSemver);
|
||||
var rightParsed = TryParse(right, out var rightSemver);
|
||||
|
||||
if (leftParsed && rightParsed)
|
||||
{
|
||||
return SemVersion.CompareSortOrder(leftSemver, rightSemver);
|
||||
}
|
||||
|
||||
if (leftParsed)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (rightParsed)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return string.Compare(left, right, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static SemVersion NextPatch(SemVersion version)
|
||||
{
|
||||
return new SemVersion(version.Major, version.Minor, version.Patch + 1);
|
||||
}
|
||||
}
|
||||
namespace StellaOps.Concelier.Merge.Comparers;
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Semver;
|
||||
|
||||
/// <summary>
|
||||
/// Provides helpers to interpret introduced/fixed/lastAffected SemVer ranges and compare versions.
|
||||
/// </summary>
|
||||
public static class SemanticVersionRangeResolver
|
||||
{
|
||||
public static bool TryParse(string? value, [NotNullWhen(true)] out SemVersion? result)
|
||||
=> SemVersion.TryParse(value, SemVersionStyles.Any, out result);
|
||||
|
||||
public static SemVersion Parse(string value)
|
||||
=> SemVersion.Parse(value, SemVersionStyles.Any);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective start and end versions using introduced/fixed/lastAffected semantics.
|
||||
/// </summary>
|
||||
public static (SemVersion? introduced, SemVersion? exclusiveUpperBound, SemVersion? inclusiveUpperBound) ResolveWindows(
|
||||
string? introduced,
|
||||
string? fixedVersion,
|
||||
string? lastAffected)
|
||||
{
|
||||
var introducedVersion = TryParse(introduced, out var parsedIntroduced) ? parsedIntroduced : null;
|
||||
var fixedVersionParsed = TryParse(fixedVersion, out var parsedFixed) ? parsedFixed : null;
|
||||
var lastAffectedVersion = TryParse(lastAffected, out var parsedLast) ? parsedLast : null;
|
||||
|
||||
SemVersion? exclusiveUpper = null;
|
||||
SemVersion? inclusiveUpper = null;
|
||||
|
||||
if (fixedVersionParsed is not null)
|
||||
{
|
||||
exclusiveUpper = fixedVersionParsed;
|
||||
}
|
||||
else if (lastAffectedVersion is not null)
|
||||
{
|
||||
inclusiveUpper = lastAffectedVersion;
|
||||
exclusiveUpper = NextPatch(lastAffectedVersion);
|
||||
}
|
||||
|
||||
return (introducedVersion, exclusiveUpper, inclusiveUpper);
|
||||
}
|
||||
|
||||
|
||||
public static int Compare(string? left, string? right)
|
||||
{
|
||||
var leftParsed = TryParse(left, out var leftSemver);
|
||||
var rightParsed = TryParse(right, out var rightSemver);
|
||||
|
||||
if (leftParsed && rightParsed)
|
||||
{
|
||||
return SemVersion.CompareSortOrder(leftSemver, rightSemver);
|
||||
}
|
||||
|
||||
if (leftParsed)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (rightParsed)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return string.Compare(left, right, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static SemVersion NextPatch(SemVersion version)
|
||||
{
|
||||
return new SemVersion(version.Major, version.Minor, version.Patch + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.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; }
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.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; }
|
||||
}
|
||||
|
||||
@@ -1,303 +1,303 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.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);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Concelier.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; }
|
||||
}
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Concelier.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,6 +1,6 @@
|
||||
namespace StellaOps.Concelier.Merge.Jobs;
|
||||
|
||||
internal static class MergeJobKinds
|
||||
{
|
||||
public const string Reconcile = "merge:reconcile";
|
||||
}
|
||||
namespace StellaOps.Concelier.Merge.Jobs;
|
||||
|
||||
internal static class MergeJobKinds
|
||||
{
|
||||
public const string Reconcile = "merge:reconcile";
|
||||
}
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Merge.Services;
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Merge.Services;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Jobs;
|
||||
|
||||
[Obsolete("MergeReconcileJob is deprecated; Link-Not-Merge supersedes merge scheduling. Disable via concelier:features:noMergeEnabled. Tracking MERGE-LNM-21-002.", DiagnosticId = "CONCELIER0001", UrlFormat = "https://stella-ops.org/docs/migration/no-merge")]
|
||||
public sealed class MergeReconcileJob : IJob
|
||||
{
|
||||
private readonly AdvisoryMergeService _mergeService;
|
||||
private readonly ILogger<MergeReconcileJob> _logger;
|
||||
|
||||
public MergeReconcileJob(AdvisoryMergeService mergeService, ILogger<MergeReconcileJob> logger)
|
||||
{
|
||||
_mergeService = mergeService ?? throw new ArgumentNullException(nameof(mergeService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!context.Parameters.TryGetValue("seed", out var seedValue) || seedValue is not string seed || string.IsNullOrWhiteSpace(seed))
|
||||
{
|
||||
context.Logger.LogWarning("merge:reconcile job requires a non-empty 'seed' parameter.");
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await _mergeService.MergeAsync(seed, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Merged is null)
|
||||
{
|
||||
_logger.LogInformation("No advisories available to merge for alias component seeded by {Seed}", seed);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Merged alias component seeded by {Seed} into canonical {Canonical} using {Count} advisories; collisions={Collisions}",
|
||||
seed,
|
||||
result.CanonicalAdvisoryKey,
|
||||
result.Inputs.Count,
|
||||
result.Component.Collisions.Count);
|
||||
}
|
||||
}
|
||||
{
|
||||
private readonly AdvisoryMergeService _mergeService;
|
||||
private readonly ILogger<MergeReconcileJob> _logger;
|
||||
|
||||
public MergeReconcileJob(AdvisoryMergeService mergeService, ILogger<MergeReconcileJob> logger)
|
||||
{
|
||||
_mergeService = mergeService ?? throw new ArgumentNullException(nameof(mergeService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!context.Parameters.TryGetValue("seed", out var seedValue) || seedValue is not string seed || string.IsNullOrWhiteSpace(seed))
|
||||
{
|
||||
context.Logger.LogWarning("merge:reconcile job requires a non-empty 'seed' parameter.");
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await _mergeService.MergeAsync(seed, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Merged is null)
|
||||
{
|
||||
_logger.LogInformation("No advisories available to merge for alias component seeded by {Seed}", seed);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Merged alias component seeded by {Seed} into canonical {Canonical} using {Count} advisories; collisions={Collisions}",
|
||||
seed,
|
||||
result.CanonicalAdvisoryKey,
|
||||
result.Inputs.Count,
|
||||
result.Component.Collisions.Count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,96 +1,96 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Provides the built-in precedence table used by the merge engine when no overrides are supplied.
|
||||
/// </summary>
|
||||
internal static class AdvisoryPrecedenceDefaults
|
||||
{
|
||||
public static IReadOnlyDictionary<string, int> Rankings { get; } = CreateDefaultTable();
|
||||
|
||||
private static IReadOnlyDictionary<string, int> CreateDefaultTable()
|
||||
{
|
||||
var table = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 0 – distro PSIRTs/OVAL feeds (authoritative for OS packages).
|
||||
Add(table, 0,
|
||||
"redhat",
|
||||
"ubuntu",
|
||||
"distro-ubuntu",
|
||||
"debian",
|
||||
"distro-debian",
|
||||
"suse",
|
||||
"distro-suse");
|
||||
|
||||
// 1 – vendor PSIRTs (authoritative product advisories).
|
||||
Add(table, 1,
|
||||
"msrc",
|
||||
"vndr-msrc",
|
||||
"vndr-oracle",
|
||||
"vndr_oracle",
|
||||
"oracle",
|
||||
"vndr-adobe",
|
||||
"adobe",
|
||||
"vndr-apple",
|
||||
"apple",
|
||||
"vndr-cisco",
|
||||
"cisco",
|
||||
"vmware",
|
||||
"vndr-vmware",
|
||||
"vndr_vmware",
|
||||
"vndr-chromium",
|
||||
"chromium",
|
||||
"vendor");
|
||||
|
||||
// 2 – ecosystem registries (OSS package maintainers).
|
||||
Add(table, 2,
|
||||
"ghsa",
|
||||
"osv",
|
||||
"cve");
|
||||
|
||||
// 3 – regional CERT / ICS enrichment feeds.
|
||||
Add(table, 3,
|
||||
"jvn",
|
||||
"acsc",
|
||||
"cccs",
|
||||
"cert-fr",
|
||||
"certfr",
|
||||
"cert-in",
|
||||
"certin",
|
||||
"cert-cc",
|
||||
"certcc",
|
||||
"certbund",
|
||||
"cert-bund",
|
||||
"ru-bdu",
|
||||
"ru-nkcki",
|
||||
"kisa",
|
||||
"ics-cisa",
|
||||
"ics-kaspersky");
|
||||
|
||||
// 4 – KEV / exploit catalogue annotations (flag only).
|
||||
Add(table, 4,
|
||||
"kev",
|
||||
"cisa-kev");
|
||||
|
||||
// 5 – public registries (baseline data).
|
||||
Add(table, 5,
|
||||
"nvd");
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static void Add(IDictionary<string, int> table, int rank, params string[] sources)
|
||||
{
|
||||
foreach (var source in sources)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
table[source] = rank;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Provides the built-in precedence table used by the merge engine when no overrides are supplied.
|
||||
/// </summary>
|
||||
internal static class AdvisoryPrecedenceDefaults
|
||||
{
|
||||
public static IReadOnlyDictionary<string, int> Rankings { get; } = CreateDefaultTable();
|
||||
|
||||
private static IReadOnlyDictionary<string, int> CreateDefaultTable()
|
||||
{
|
||||
var table = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 0 – distro PSIRTs/OVAL feeds (authoritative for OS packages).
|
||||
Add(table, 0,
|
||||
"redhat",
|
||||
"ubuntu",
|
||||
"distro-ubuntu",
|
||||
"debian",
|
||||
"distro-debian",
|
||||
"suse",
|
||||
"distro-suse");
|
||||
|
||||
// 1 – vendor PSIRTs (authoritative product advisories).
|
||||
Add(table, 1,
|
||||
"msrc",
|
||||
"vndr-msrc",
|
||||
"vndr-oracle",
|
||||
"vndr_oracle",
|
||||
"oracle",
|
||||
"vndr-adobe",
|
||||
"adobe",
|
||||
"vndr-apple",
|
||||
"apple",
|
||||
"vndr-cisco",
|
||||
"cisco",
|
||||
"vmware",
|
||||
"vndr-vmware",
|
||||
"vndr_vmware",
|
||||
"vndr-chromium",
|
||||
"chromium",
|
||||
"vendor");
|
||||
|
||||
// 2 – ecosystem registries (OSS package maintainers).
|
||||
Add(table, 2,
|
||||
"ghsa",
|
||||
"osv",
|
||||
"cve");
|
||||
|
||||
// 3 – regional CERT / ICS enrichment feeds.
|
||||
Add(table, 3,
|
||||
"jvn",
|
||||
"acsc",
|
||||
"cccs",
|
||||
"cert-fr",
|
||||
"certfr",
|
||||
"cert-in",
|
||||
"certin",
|
||||
"cert-cc",
|
||||
"certcc",
|
||||
"certbund",
|
||||
"cert-bund",
|
||||
"ru-bdu",
|
||||
"ru-nkcki",
|
||||
"kisa",
|
||||
"ics-cisa",
|
||||
"ics-kaspersky");
|
||||
|
||||
// 4 – KEV / exploit catalogue annotations (flag only).
|
||||
Add(table, 4,
|
||||
"kev",
|
||||
"cisa-kev");
|
||||
|
||||
// 5 – public registries (baseline data).
|
||||
Add(table, 5,
|
||||
"nvd");
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static void Add(IDictionary<string, int> table, int rank, params string[] sources)
|
||||
{
|
||||
foreach (var source in sources)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
table[source] = rank;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Configurable precedence overrides for advisory sources.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryPrecedenceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Mapping of provenance source identifiers to precedence ranks. Lower numbers take precedence.
|
||||
/// </summary>
|
||||
public IDictionary<string, int> Ranks { get; init; } = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Configurable precedence overrides for advisory sources.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryPrecedenceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Mapping of provenance source identifiers to precedence ranks. Lower numbers take precedence.
|
||||
/// </summary>
|
||||
public IDictionary<string, int> Ranks { get; init; } = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Options;
|
||||
|
||||
internal static class AdvisoryPrecedenceTable
|
||||
{
|
||||
public static IReadOnlyDictionary<string, int> Merge(
|
||||
IReadOnlyDictionary<string, int> defaults,
|
||||
AdvisoryPrecedenceOptions? options)
|
||||
{
|
||||
if (defaults is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(defaults));
|
||||
}
|
||||
|
||||
if (options?.Ranks is null || options.Ranks.Count == 0)
|
||||
{
|
||||
return defaults;
|
||||
}
|
||||
|
||||
var merged = new Dictionary<string, int>(defaults, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kvp in options.Ranks)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kvp.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
merged[kvp.Key.Trim()] = kvp.Value;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Options;
|
||||
|
||||
internal static class AdvisoryPrecedenceTable
|
||||
{
|
||||
public static IReadOnlyDictionary<string, int> Merge(
|
||||
IReadOnlyDictionary<string, int> defaults,
|
||||
AdvisoryPrecedenceOptions? options)
|
||||
{
|
||||
if (defaults is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(defaults));
|
||||
}
|
||||
|
||||
if (options?.Ranks is null || options.Ranks.Count == 0)
|
||||
{
|
||||
return defaults;
|
||||
}
|
||||
|
||||
var merged = new Dictionary<string, int>(defaults, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kvp in options.Ranks)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kvp.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
merged[kvp.Key.Trim()] = kvp.Value;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Core;
|
||||
using StellaOps.Concelier.Core.Events;
|
||||
using StellaOps.Concelier.Models;
|
||||
@@ -13,144 +13,144 @@ using StellaOps.Concelier.Storage.Advisories;
|
||||
using StellaOps.Concelier.Storage.Aliases;
|
||||
using StellaOps.Concelier.Storage.MergeEvents;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Provenance.Mongo;
|
||||
|
||||
using StellaOps.Provenance;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
[Obsolete("AdvisoryMergeService is deprecated. Transition callers to Link-Not-Merge observation/linkset APIs (MERGE-LNM-21-002) and enable concelier:features:noMergeEnabled when ready.", DiagnosticId = "CONCELIER0001", UrlFormat = "https://stella-ops.org/docs/migration/no-merge")]
|
||||
public sealed class AdvisoryMergeService
|
||||
{
|
||||
private static readonly Meter MergeMeter = new("StellaOps.Concelier.Merge");
|
||||
private static readonly Counter<long> AliasCollisionCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.identity_conflicts",
|
||||
unit: "count",
|
||||
description: "Number of alias collisions detected during merge.");
|
||||
|
||||
private static readonly string[] PreferredAliasSchemes =
|
||||
{
|
||||
AliasSchemes.Cve,
|
||||
AliasSchemes.Ghsa,
|
||||
AliasSchemes.OsV,
|
||||
AliasSchemes.Msrc,
|
||||
};
|
||||
|
||||
private readonly AliasGraphResolver _aliasResolver;
|
||||
private readonly IAdvisoryStore _advisoryStore;
|
||||
private readonly AdvisoryPrecedenceMerger _precedenceMerger;
|
||||
private readonly MergeEventWriter _mergeEventWriter;
|
||||
private readonly IAdvisoryEventLog _eventLog;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly CanonicalMerger _canonicalMerger;
|
||||
private readonly ILogger<AdvisoryMergeService> _logger;
|
||||
|
||||
public AdvisoryMergeService(
|
||||
AliasGraphResolver aliasResolver,
|
||||
IAdvisoryStore advisoryStore,
|
||||
AdvisoryPrecedenceMerger precedenceMerger,
|
||||
MergeEventWriter mergeEventWriter,
|
||||
CanonicalMerger canonicalMerger,
|
||||
IAdvisoryEventLog eventLog,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AdvisoryMergeService> logger)
|
||||
{
|
||||
_aliasResolver = aliasResolver ?? throw new ArgumentNullException(nameof(aliasResolver));
|
||||
_advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore));
|
||||
_precedenceMerger = precedenceMerger ?? throw new ArgumentNullException(nameof(precedenceMerger));
|
||||
_mergeEventWriter = mergeEventWriter ?? throw new ArgumentNullException(nameof(mergeEventWriter));
|
||||
_canonicalMerger = canonicalMerger ?? throw new ArgumentNullException(nameof(canonicalMerger));
|
||||
_eventLog = eventLog ?? throw new ArgumentNullException(nameof(eventLog));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<AdvisoryMergeResult> MergeAsync(string seedAdvisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(seedAdvisoryKey);
|
||||
|
||||
var component = await _aliasResolver.BuildComponentAsync(seedAdvisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
var inputs = new List<Advisory>();
|
||||
|
||||
foreach (var advisoryKey in component.AdvisoryKeys)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var advisory = await _advisoryStore.FindAsync(advisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
if (advisory is not null)
|
||||
{
|
||||
inputs.Add(advisory);
|
||||
}
|
||||
}
|
||||
|
||||
if (inputs.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Alias component seeded by {Seed} contains no persisted advisories", seedAdvisoryKey);
|
||||
return AdvisoryMergeResult.Empty(seedAdvisoryKey, component);
|
||||
}
|
||||
|
||||
var canonicalKey = SelectCanonicalKey(component) ?? seedAdvisoryKey;
|
||||
var canonicalMerge = ApplyCanonicalMergeIfNeeded(canonicalKey, inputs);
|
||||
var before = await _advisoryStore.FindAsync(canonicalKey, cancellationToken).ConfigureAwait(false);
|
||||
var normalizedInputs = NormalizeInputs(inputs, canonicalKey).ToList();
|
||||
|
||||
PrecedenceMergeResult precedenceResult;
|
||||
try
|
||||
{
|
||||
precedenceResult = _precedenceMerger.Merge(normalizedInputs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to merge alias component seeded by {Seed}", seedAdvisoryKey);
|
||||
throw;
|
||||
}
|
||||
|
||||
var merged = precedenceResult.Advisory;
|
||||
var conflictDetails = precedenceResult.Conflicts;
|
||||
|
||||
if (component.Collisions.Count > 0)
|
||||
{
|
||||
foreach (var collision in component.Collisions)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("scheme", collision.Scheme ?? string.Empty),
|
||||
new("alias_value", collision.Value ?? string.Empty),
|
||||
new("advisory_count", collision.AdvisoryKeys.Count),
|
||||
};
|
||||
|
||||
AliasCollisionCounter.Add(1, tags);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Alias collision {Scheme}:{Value} involves advisories {Advisories}",
|
||||
collision.Scheme,
|
||||
collision.Value,
|
||||
string.Join(", ", collision.AdvisoryKeys));
|
||||
}
|
||||
}
|
||||
|
||||
await _advisoryStore.UpsertAsync(merged, cancellationToken).ConfigureAwait(false);
|
||||
await _mergeEventWriter.AppendAsync(
|
||||
canonicalKey,
|
||||
before,
|
||||
merged,
|
||||
Array.Empty<Guid>(),
|
||||
ConvertFieldDecisions(canonicalMerge?.Decisions),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var conflictSummaries = await AppendEventLogAsync(canonicalKey, normalizedInputs, merged, conflictDetails, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new AdvisoryMergeResult(seedAdvisoryKey, canonicalKey, component, inputs, before, merged, conflictSummaries);
|
||||
}
|
||||
|
||||
{
|
||||
private static readonly Meter MergeMeter = new("StellaOps.Concelier.Merge");
|
||||
private static readonly Counter<long> AliasCollisionCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.identity_conflicts",
|
||||
unit: "count",
|
||||
description: "Number of alias collisions detected during merge.");
|
||||
|
||||
private static readonly string[] PreferredAliasSchemes =
|
||||
{
|
||||
AliasSchemes.Cve,
|
||||
AliasSchemes.Ghsa,
|
||||
AliasSchemes.OsV,
|
||||
AliasSchemes.Msrc,
|
||||
};
|
||||
|
||||
private readonly AliasGraphResolver _aliasResolver;
|
||||
private readonly IAdvisoryStore _advisoryStore;
|
||||
private readonly AdvisoryPrecedenceMerger _precedenceMerger;
|
||||
private readonly MergeEventWriter _mergeEventWriter;
|
||||
private readonly IAdvisoryEventLog _eventLog;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly CanonicalMerger _canonicalMerger;
|
||||
private readonly ILogger<AdvisoryMergeService> _logger;
|
||||
|
||||
public AdvisoryMergeService(
|
||||
AliasGraphResolver aliasResolver,
|
||||
IAdvisoryStore advisoryStore,
|
||||
AdvisoryPrecedenceMerger precedenceMerger,
|
||||
MergeEventWriter mergeEventWriter,
|
||||
CanonicalMerger canonicalMerger,
|
||||
IAdvisoryEventLog eventLog,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AdvisoryMergeService> logger)
|
||||
{
|
||||
_aliasResolver = aliasResolver ?? throw new ArgumentNullException(nameof(aliasResolver));
|
||||
_advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore));
|
||||
_precedenceMerger = precedenceMerger ?? throw new ArgumentNullException(nameof(precedenceMerger));
|
||||
_mergeEventWriter = mergeEventWriter ?? throw new ArgumentNullException(nameof(mergeEventWriter));
|
||||
_canonicalMerger = canonicalMerger ?? throw new ArgumentNullException(nameof(canonicalMerger));
|
||||
_eventLog = eventLog ?? throw new ArgumentNullException(nameof(eventLog));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<AdvisoryMergeResult> MergeAsync(string seedAdvisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(seedAdvisoryKey);
|
||||
|
||||
var component = await _aliasResolver.BuildComponentAsync(seedAdvisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
var inputs = new List<Advisory>();
|
||||
|
||||
foreach (var advisoryKey in component.AdvisoryKeys)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var advisory = await _advisoryStore.FindAsync(advisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
if (advisory is not null)
|
||||
{
|
||||
inputs.Add(advisory);
|
||||
}
|
||||
}
|
||||
|
||||
if (inputs.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Alias component seeded by {Seed} contains no persisted advisories", seedAdvisoryKey);
|
||||
return AdvisoryMergeResult.Empty(seedAdvisoryKey, component);
|
||||
}
|
||||
|
||||
var canonicalKey = SelectCanonicalKey(component) ?? seedAdvisoryKey;
|
||||
var canonicalMerge = ApplyCanonicalMergeIfNeeded(canonicalKey, inputs);
|
||||
var before = await _advisoryStore.FindAsync(canonicalKey, cancellationToken).ConfigureAwait(false);
|
||||
var normalizedInputs = NormalizeInputs(inputs, canonicalKey).ToList();
|
||||
|
||||
PrecedenceMergeResult precedenceResult;
|
||||
try
|
||||
{
|
||||
precedenceResult = _precedenceMerger.Merge(normalizedInputs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to merge alias component seeded by {Seed}", seedAdvisoryKey);
|
||||
throw;
|
||||
}
|
||||
|
||||
var merged = precedenceResult.Advisory;
|
||||
var conflictDetails = precedenceResult.Conflicts;
|
||||
|
||||
if (component.Collisions.Count > 0)
|
||||
{
|
||||
foreach (var collision in component.Collisions)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("scheme", collision.Scheme ?? string.Empty),
|
||||
new("alias_value", collision.Value ?? string.Empty),
|
||||
new("advisory_count", collision.AdvisoryKeys.Count),
|
||||
};
|
||||
|
||||
AliasCollisionCounter.Add(1, tags);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Alias collision {Scheme}:{Value} involves advisories {Advisories}",
|
||||
collision.Scheme,
|
||||
collision.Value,
|
||||
string.Join(", ", collision.AdvisoryKeys));
|
||||
}
|
||||
}
|
||||
|
||||
await _advisoryStore.UpsertAsync(merged, cancellationToken).ConfigureAwait(false);
|
||||
await _mergeEventWriter.AppendAsync(
|
||||
canonicalKey,
|
||||
before,
|
||||
merged,
|
||||
Array.Empty<Guid>(),
|
||||
ConvertFieldDecisions(canonicalMerge?.Decisions),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var conflictSummaries = await AppendEventLogAsync(canonicalKey, normalizedInputs, merged, conflictDetails, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new AdvisoryMergeResult(seedAdvisoryKey, canonicalKey, component, inputs, before, merged, conflictSummaries);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<MergeConflictSummary>> AppendEventLogAsync(
|
||||
string vulnerabilityKey,
|
||||
IReadOnlyList<Advisory> inputs,
|
||||
Advisory merged,
|
||||
IReadOnlyList<MergeConflictDetail> conflicts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var recordedAt = _timeProvider.GetUtcNow();
|
||||
var statements = new List<AdvisoryStatementInput>(inputs.Count + 1);
|
||||
var statementIds = new Dictionary<Advisory, Guid>(ReferenceEqualityComparer.Instance);
|
||||
|
||||
{
|
||||
var recordedAt = _timeProvider.GetUtcNow();
|
||||
var statements = new List<AdvisoryStatementInput>(inputs.Count + 1);
|
||||
var statementIds = new Dictionary<Advisory, Guid>(ReferenceEqualityComparer.Instance);
|
||||
|
||||
foreach (var advisory in inputs)
|
||||
{
|
||||
var statementId = Guid.NewGuid();
|
||||
@@ -179,32 +179,32 @@ public sealed class AdvisoryMergeService
|
||||
AdvisoryKey: merged.AdvisoryKey,
|
||||
Provenance: canonicalProvenance,
|
||||
Trust: canonicalTrust));
|
||||
|
||||
var conflictMaterialization = BuildConflictInputs(conflicts, vulnerabilityKey, statementIds, canonicalStatementId, recordedAt);
|
||||
var conflictInputs = conflictMaterialization.Inputs;
|
||||
var conflictSummaries = conflictMaterialization.Summaries;
|
||||
|
||||
if (statements.Count == 0 && conflictInputs.Count == 0)
|
||||
{
|
||||
return conflictSummaries.Count == 0
|
||||
? Array.Empty<MergeConflictSummary>()
|
||||
: conflictSummaries.ToArray();
|
||||
}
|
||||
|
||||
var request = new AdvisoryEventAppendRequest(statements, conflictInputs.Count > 0 ? conflictInputs : null);
|
||||
|
||||
try
|
||||
{
|
||||
await _eventLog.AppendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var conflict in conflictInputs)
|
||||
{
|
||||
conflict.Details.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var conflictMaterialization = BuildConflictInputs(conflicts, vulnerabilityKey, statementIds, canonicalStatementId, recordedAt);
|
||||
var conflictInputs = conflictMaterialization.Inputs;
|
||||
var conflictSummaries = conflictMaterialization.Summaries;
|
||||
|
||||
if (statements.Count == 0 && conflictInputs.Count == 0)
|
||||
{
|
||||
return conflictSummaries.Count == 0
|
||||
? Array.Empty<MergeConflictSummary>()
|
||||
: conflictSummaries.ToArray();
|
||||
}
|
||||
|
||||
var request = new AdvisoryEventAppendRequest(statements, conflictInputs.Count > 0 ? conflictInputs : null);
|
||||
|
||||
try
|
||||
{
|
||||
await _eventLog.AppendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var conflict in conflictInputs)
|
||||
{
|
||||
conflict.Details.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
return conflictSummaries.Count == 0
|
||||
? Array.Empty<MergeConflictSummary>()
|
||||
: conflictSummaries.ToArray();
|
||||
@@ -221,234 +221,234 @@ public sealed class AdvisoryMergeService
|
||||
{
|
||||
return (advisory.Modified ?? advisory.Published ?? fallback).ToUniversalTime();
|
||||
}
|
||||
|
||||
private static ConflictMaterialization BuildConflictInputs(
|
||||
IReadOnlyList<MergeConflictDetail> conflicts,
|
||||
string vulnerabilityKey,
|
||||
IReadOnlyDictionary<Advisory, Guid> statementIds,
|
||||
Guid canonicalStatementId,
|
||||
DateTimeOffset recordedAt)
|
||||
{
|
||||
if (conflicts.Count == 0)
|
||||
{
|
||||
return new ConflictMaterialization(new List<AdvisoryConflictInput>(0), new List<MergeConflictSummary>(0));
|
||||
}
|
||||
|
||||
var inputs = new List<AdvisoryConflictInput>(conflicts.Count);
|
||||
var summaries = new List<MergeConflictSummary>(conflicts.Count);
|
||||
|
||||
foreach (var detail in conflicts)
|
||||
{
|
||||
if (!statementIds.TryGetValue(detail.Suppressed, out var suppressedId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var related = new List<Guid> { canonicalStatementId, suppressedId };
|
||||
if (statementIds.TryGetValue(detail.Primary, out var primaryId))
|
||||
{
|
||||
if (!related.Contains(primaryId))
|
||||
{
|
||||
related.Add(primaryId);
|
||||
}
|
||||
}
|
||||
|
||||
var payload = ConflictDetailPayload.FromDetail(detail);
|
||||
var explainer = payload.ToExplainer();
|
||||
|
||||
var canonicalJson = explainer.ToCanonicalJson();
|
||||
var document = JsonDocument.Parse(canonicalJson);
|
||||
var asOf = (detail.Primary.Modified ?? detail.Suppressed.Modified ?? recordedAt).ToUniversalTime();
|
||||
var conflictId = Guid.NewGuid();
|
||||
var statementIdArray = ImmutableArray.CreateRange(related);
|
||||
var conflictHash = explainer.ComputeHashHex(canonicalJson);
|
||||
|
||||
inputs.Add(new AdvisoryConflictInput(
|
||||
vulnerabilityKey,
|
||||
document,
|
||||
asOf,
|
||||
related,
|
||||
ConflictId: conflictId));
|
||||
|
||||
summaries.Add(new MergeConflictSummary(
|
||||
conflictId,
|
||||
vulnerabilityKey,
|
||||
statementIdArray,
|
||||
conflictHash,
|
||||
asOf,
|
||||
recordedAt,
|
||||
explainer));
|
||||
}
|
||||
|
||||
return new ConflictMaterialization(inputs, summaries);
|
||||
}
|
||||
|
||||
private static IEnumerable<Advisory> NormalizeInputs(IEnumerable<Advisory> advisories, string canonicalKey)
|
||||
{
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
yield return CloneWithKey(advisory, canonicalKey);
|
||||
}
|
||||
}
|
||||
|
||||
private static Advisory CloneWithKey(Advisory source, string advisoryKey)
|
||||
=> new(
|
||||
advisoryKey,
|
||||
source.Title,
|
||||
source.Summary,
|
||||
source.Language,
|
||||
source.Published,
|
||||
source.Modified,
|
||||
source.Severity,
|
||||
source.ExploitKnown,
|
||||
source.Aliases,
|
||||
source.Credits,
|
||||
source.References,
|
||||
source.AffectedPackages,
|
||||
source.CvssMetrics,
|
||||
source.Provenance,
|
||||
source.Description,
|
||||
source.Cwes,
|
||||
source.CanonicalMetricId);
|
||||
|
||||
private CanonicalMergeResult? ApplyCanonicalMergeIfNeeded(string canonicalKey, List<Advisory> inputs)
|
||||
{
|
||||
if (inputs.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ghsa = FindBySource(inputs, CanonicalSources.Ghsa);
|
||||
var nvd = FindBySource(inputs, CanonicalSources.Nvd);
|
||||
var osv = FindBySource(inputs, CanonicalSources.Osv);
|
||||
|
||||
var participatingSources = 0;
|
||||
if (ghsa is not null)
|
||||
{
|
||||
participatingSources++;
|
||||
}
|
||||
|
||||
if (nvd is not null)
|
||||
{
|
||||
participatingSources++;
|
||||
}
|
||||
|
||||
if (osv is not null)
|
||||
{
|
||||
participatingSources++;
|
||||
}
|
||||
|
||||
if (participatingSources < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = _canonicalMerger.Merge(canonicalKey, ghsa, nvd, osv);
|
||||
|
||||
inputs.RemoveAll(advisory => MatchesCanonicalSource(advisory));
|
||||
inputs.Add(result.Advisory);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Advisory? FindBySource(IEnumerable<Advisory> advisories, string source)
|
||||
=> advisories.FirstOrDefault(advisory => advisory.Provenance.Any(provenance =>
|
||||
!string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(provenance.Source, source, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
private static bool MatchesCanonicalSource(Advisory advisory)
|
||||
{
|
||||
foreach (var provenance in advisory.Provenance)
|
||||
{
|
||||
if (string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(provenance.Source, CanonicalSources.Ghsa, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(provenance.Source, CanonicalSources.Nvd, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(provenance.Source, CanonicalSources.Osv, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MergeFieldDecision> ConvertFieldDecisions(ImmutableArray<FieldDecision>? decisions)
|
||||
{
|
||||
if (decisions is null || decisions.Value.IsDefaultOrEmpty)
|
||||
{
|
||||
return Array.Empty<MergeFieldDecision>();
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<MergeFieldDecision>(decisions.Value.Length);
|
||||
foreach (var decision in decisions.Value)
|
||||
{
|
||||
builder.Add(new MergeFieldDecision(
|
||||
decision.Field,
|
||||
decision.SelectedSource,
|
||||
decision.DecisionReason,
|
||||
decision.SelectedModified,
|
||||
decision.ConsideredSources.ToArray()));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static class CanonicalSources
|
||||
{
|
||||
public const string Ghsa = "ghsa";
|
||||
public const string Nvd = "nvd";
|
||||
public const string Osv = "osv";
|
||||
}
|
||||
|
||||
private sealed record ConflictMaterialization(
|
||||
List<AdvisoryConflictInput> Inputs,
|
||||
List<MergeConflictSummary> Summaries);
|
||||
|
||||
private static string? SelectCanonicalKey(AliasComponent component)
|
||||
{
|
||||
foreach (var scheme in PreferredAliasSchemes)
|
||||
{
|
||||
var alias = component.AliasMap.Values
|
||||
.SelectMany(static aliases => aliases)
|
||||
.FirstOrDefault(record => string.Equals(record.Scheme, scheme, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(alias?.Value))
|
||||
{
|
||||
return alias.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (component.AliasMap.TryGetValue(component.SeedAdvisoryKey, out var seedAliases))
|
||||
{
|
||||
var primary = seedAliases.FirstOrDefault(record => string.Equals(record.Scheme, AliasStoreConstants.PrimaryScheme, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(primary?.Value))
|
||||
{
|
||||
return primary.Value;
|
||||
}
|
||||
}
|
||||
|
||||
var firstAlias = component.AliasMap.Values.SelectMany(static aliases => aliases).FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(firstAlias?.Value))
|
||||
{
|
||||
return firstAlias.Value;
|
||||
}
|
||||
|
||||
return component.SeedAdvisoryKey;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AdvisoryMergeResult(
|
||||
string SeedAdvisoryKey,
|
||||
string CanonicalAdvisoryKey,
|
||||
AliasComponent Component,
|
||||
IReadOnlyList<Advisory> Inputs,
|
||||
Advisory? Previous,
|
||||
Advisory? Merged,
|
||||
IReadOnlyList<MergeConflictSummary> Conflicts)
|
||||
{
|
||||
public static AdvisoryMergeResult Empty(string seed, AliasComponent component)
|
||||
=> new(seed, seed, component, Array.Empty<Advisory>(), null, null, Array.Empty<MergeConflictSummary>());
|
||||
}
|
||||
|
||||
private static ConflictMaterialization BuildConflictInputs(
|
||||
IReadOnlyList<MergeConflictDetail> conflicts,
|
||||
string vulnerabilityKey,
|
||||
IReadOnlyDictionary<Advisory, Guid> statementIds,
|
||||
Guid canonicalStatementId,
|
||||
DateTimeOffset recordedAt)
|
||||
{
|
||||
if (conflicts.Count == 0)
|
||||
{
|
||||
return new ConflictMaterialization(new List<AdvisoryConflictInput>(0), new List<MergeConflictSummary>(0));
|
||||
}
|
||||
|
||||
var inputs = new List<AdvisoryConflictInput>(conflicts.Count);
|
||||
var summaries = new List<MergeConflictSummary>(conflicts.Count);
|
||||
|
||||
foreach (var detail in conflicts)
|
||||
{
|
||||
if (!statementIds.TryGetValue(detail.Suppressed, out var suppressedId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var related = new List<Guid> { canonicalStatementId, suppressedId };
|
||||
if (statementIds.TryGetValue(detail.Primary, out var primaryId))
|
||||
{
|
||||
if (!related.Contains(primaryId))
|
||||
{
|
||||
related.Add(primaryId);
|
||||
}
|
||||
}
|
||||
|
||||
var payload = ConflictDetailPayload.FromDetail(detail);
|
||||
var explainer = payload.ToExplainer();
|
||||
|
||||
var canonicalJson = explainer.ToCanonicalJson();
|
||||
var document = JsonDocument.Parse(canonicalJson);
|
||||
var asOf = (detail.Primary.Modified ?? detail.Suppressed.Modified ?? recordedAt).ToUniversalTime();
|
||||
var conflictId = Guid.NewGuid();
|
||||
var statementIdArray = ImmutableArray.CreateRange(related);
|
||||
var conflictHash = explainer.ComputeHashHex(canonicalJson);
|
||||
|
||||
inputs.Add(new AdvisoryConflictInput(
|
||||
vulnerabilityKey,
|
||||
document,
|
||||
asOf,
|
||||
related,
|
||||
ConflictId: conflictId));
|
||||
|
||||
summaries.Add(new MergeConflictSummary(
|
||||
conflictId,
|
||||
vulnerabilityKey,
|
||||
statementIdArray,
|
||||
conflictHash,
|
||||
asOf,
|
||||
recordedAt,
|
||||
explainer));
|
||||
}
|
||||
|
||||
return new ConflictMaterialization(inputs, summaries);
|
||||
}
|
||||
|
||||
private static IEnumerable<Advisory> NormalizeInputs(IEnumerable<Advisory> advisories, string canonicalKey)
|
||||
{
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
yield return CloneWithKey(advisory, canonicalKey);
|
||||
}
|
||||
}
|
||||
|
||||
private static Advisory CloneWithKey(Advisory source, string advisoryKey)
|
||||
=> new(
|
||||
advisoryKey,
|
||||
source.Title,
|
||||
source.Summary,
|
||||
source.Language,
|
||||
source.Published,
|
||||
source.Modified,
|
||||
source.Severity,
|
||||
source.ExploitKnown,
|
||||
source.Aliases,
|
||||
source.Credits,
|
||||
source.References,
|
||||
source.AffectedPackages,
|
||||
source.CvssMetrics,
|
||||
source.Provenance,
|
||||
source.Description,
|
||||
source.Cwes,
|
||||
source.CanonicalMetricId);
|
||||
|
||||
private CanonicalMergeResult? ApplyCanonicalMergeIfNeeded(string canonicalKey, List<Advisory> inputs)
|
||||
{
|
||||
if (inputs.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ghsa = FindBySource(inputs, CanonicalSources.Ghsa);
|
||||
var nvd = FindBySource(inputs, CanonicalSources.Nvd);
|
||||
var osv = FindBySource(inputs, CanonicalSources.Osv);
|
||||
|
||||
var participatingSources = 0;
|
||||
if (ghsa is not null)
|
||||
{
|
||||
participatingSources++;
|
||||
}
|
||||
|
||||
if (nvd is not null)
|
||||
{
|
||||
participatingSources++;
|
||||
}
|
||||
|
||||
if (osv is not null)
|
||||
{
|
||||
participatingSources++;
|
||||
}
|
||||
|
||||
if (participatingSources < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = _canonicalMerger.Merge(canonicalKey, ghsa, nvd, osv);
|
||||
|
||||
inputs.RemoveAll(advisory => MatchesCanonicalSource(advisory));
|
||||
inputs.Add(result.Advisory);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Advisory? FindBySource(IEnumerable<Advisory> advisories, string source)
|
||||
=> advisories.FirstOrDefault(advisory => advisory.Provenance.Any(provenance =>
|
||||
!string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(provenance.Source, source, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
private static bool MatchesCanonicalSource(Advisory advisory)
|
||||
{
|
||||
foreach (var provenance in advisory.Provenance)
|
||||
{
|
||||
if (string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(provenance.Source, CanonicalSources.Ghsa, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(provenance.Source, CanonicalSources.Nvd, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(provenance.Source, CanonicalSources.Osv, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MergeFieldDecision> ConvertFieldDecisions(ImmutableArray<FieldDecision>? decisions)
|
||||
{
|
||||
if (decisions is null || decisions.Value.IsDefaultOrEmpty)
|
||||
{
|
||||
return Array.Empty<MergeFieldDecision>();
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<MergeFieldDecision>(decisions.Value.Length);
|
||||
foreach (var decision in decisions.Value)
|
||||
{
|
||||
builder.Add(new MergeFieldDecision(
|
||||
decision.Field,
|
||||
decision.SelectedSource,
|
||||
decision.DecisionReason,
|
||||
decision.SelectedModified,
|
||||
decision.ConsideredSources.ToArray()));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static class CanonicalSources
|
||||
{
|
||||
public const string Ghsa = "ghsa";
|
||||
public const string Nvd = "nvd";
|
||||
public const string Osv = "osv";
|
||||
}
|
||||
|
||||
private sealed record ConflictMaterialization(
|
||||
List<AdvisoryConflictInput> Inputs,
|
||||
List<MergeConflictSummary> Summaries);
|
||||
|
||||
private static string? SelectCanonicalKey(AliasComponent component)
|
||||
{
|
||||
foreach (var scheme in PreferredAliasSchemes)
|
||||
{
|
||||
var alias = component.AliasMap.Values
|
||||
.SelectMany(static aliases => aliases)
|
||||
.FirstOrDefault(record => string.Equals(record.Scheme, scheme, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(alias?.Value))
|
||||
{
|
||||
return alias.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (component.AliasMap.TryGetValue(component.SeedAdvisoryKey, out var seedAliases))
|
||||
{
|
||||
var primary = seedAliases.FirstOrDefault(record => string.Equals(record.Scheme, AliasStoreConstants.PrimaryScheme, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(primary?.Value))
|
||||
{
|
||||
return primary.Value;
|
||||
}
|
||||
}
|
||||
|
||||
var firstAlias = component.AliasMap.Values.SelectMany(static aliases => aliases).FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(firstAlias?.Value))
|
||||
{
|
||||
return firstAlias.Value;
|
||||
}
|
||||
|
||||
return component.SeedAdvisoryKey;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AdvisoryMergeResult(
|
||||
string SeedAdvisoryKey,
|
||||
string CanonicalAdvisoryKey,
|
||||
AliasComponent Component,
|
||||
IReadOnlyList<Advisory> Inputs,
|
||||
Advisory? Previous,
|
||||
Advisory? Merged,
|
||||
IReadOnlyList<MergeConflictSummary> Conflicts)
|
||||
{
|
||||
public static AdvisoryMergeResult Empty(string seed, AliasComponent component)
|
||||
=> new(seed, seed, component, Array.Empty<Advisory>(), null, null, Array.Empty<MergeConflictSummary>());
|
||||
}
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Concelier.Merge.Options;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Merges canonical advisories emitted by different sources into a single precedence-resolved advisory.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryPrecedenceMerger
|
||||
{
|
||||
private static readonly Meter MergeMeter = new("StellaOps.Concelier.Merge");
|
||||
private static readonly Counter<long> MergeCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.operations",
|
||||
unit: "count",
|
||||
description: "Number of merge invocations executed by the precedence engine.");
|
||||
|
||||
private static readonly Counter<long> OverridesCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.overrides",
|
||||
unit: "count",
|
||||
description: "Number of times lower-precedence advisories were overridden by higher-precedence sources.");
|
||||
|
||||
private static readonly Counter<long> RangeOverrideCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.range_overrides",
|
||||
unit: "count",
|
||||
description: "Number of affected-package range overrides performed during precedence merge.");
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Concelier.Merge.Options;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Merges canonical advisories emitted by different sources into a single precedence-resolved advisory.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryPrecedenceMerger
|
||||
{
|
||||
private static readonly Meter MergeMeter = new("StellaOps.Concelier.Merge");
|
||||
private static readonly Counter<long> MergeCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.operations",
|
||||
unit: "count",
|
||||
description: "Number of merge invocations executed by the precedence engine.");
|
||||
|
||||
private static readonly Counter<long> OverridesCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.overrides",
|
||||
unit: "count",
|
||||
description: "Number of times lower-precedence advisories were overridden by higher-precedence sources.");
|
||||
|
||||
private static readonly Counter<long> RangeOverrideCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.range_overrides",
|
||||
unit: "count",
|
||||
description: "Number of affected-package range overrides performed during precedence merge.");
|
||||
|
||||
private static readonly Counter<long> ConflictCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.conflicts",
|
||||
unit: "count",
|
||||
@@ -45,106 +45,106 @@ public sealed class AdvisoryPrecedenceMerger
|
||||
"concelier.merge.normalized_rules_missing",
|
||||
unit: "package",
|
||||
description: "Number of affected packages with version ranges but no normalized rules.");
|
||||
|
||||
private static readonly Action<ILogger, MergeOverrideAudit, Exception?> OverrideLogged = LoggerMessage.Define<MergeOverrideAudit>(
|
||||
LogLevel.Information,
|
||||
new EventId(1000, "AdvisoryOverride"),
|
||||
"Advisory precedence override {@Override}");
|
||||
|
||||
private static readonly Action<ILogger, PackageOverrideAudit, Exception?> RangeOverrideLogged = LoggerMessage.Define<PackageOverrideAudit>(
|
||||
LogLevel.Information,
|
||||
new EventId(1001, "PackageRangeOverride"),
|
||||
"Affected package precedence override {@Override}");
|
||||
|
||||
private static readonly Action<ILogger, MergeFieldConflictAudit, Exception?> ConflictLogged = LoggerMessage.Define<MergeFieldConflictAudit>(
|
||||
LogLevel.Information,
|
||||
new EventId(1002, "PrecedenceConflict"),
|
||||
"Precedence conflict {@Conflict}");
|
||||
|
||||
private readonly AffectedPackagePrecedenceResolver _packageResolver;
|
||||
private readonly IReadOnlyDictionary<string, int> _precedence;
|
||||
private readonly int _fallbackRank;
|
||||
private readonly System.TimeProvider _timeProvider;
|
||||
private readonly ILogger<AdvisoryPrecedenceMerger> _logger;
|
||||
|
||||
public AdvisoryPrecedenceMerger()
|
||||
: this(new AffectedPackagePrecedenceResolver(), TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public AdvisoryPrecedenceMerger(AffectedPackagePrecedenceResolver packageResolver, System.TimeProvider? timeProvider = null)
|
||||
: this(packageResolver, packageResolver?.Precedence ?? AdvisoryPrecedenceDefaults.Rankings, timeProvider ?? TimeProvider.System, NullLogger<AdvisoryPrecedenceMerger>.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public AdvisoryPrecedenceMerger(
|
||||
AffectedPackagePrecedenceResolver packageResolver,
|
||||
IReadOnlyDictionary<string, int> precedence,
|
||||
System.TimeProvider timeProvider)
|
||||
: this(packageResolver, precedence, timeProvider, NullLogger<AdvisoryPrecedenceMerger>.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public AdvisoryPrecedenceMerger(
|
||||
AffectedPackagePrecedenceResolver packageResolver,
|
||||
AdvisoryPrecedenceOptions? options,
|
||||
System.TimeProvider timeProvider,
|
||||
ILogger<AdvisoryPrecedenceMerger>? logger = null)
|
||||
: this(
|
||||
EnsureResolver(packageResolver, options, out var precedence),
|
||||
precedence,
|
||||
timeProvider,
|
||||
logger)
|
||||
{
|
||||
}
|
||||
|
||||
public AdvisoryPrecedenceMerger(
|
||||
AffectedPackagePrecedenceResolver packageResolver,
|
||||
IReadOnlyDictionary<string, int> precedence,
|
||||
System.TimeProvider timeProvider,
|
||||
ILogger<AdvisoryPrecedenceMerger>? logger)
|
||||
{
|
||||
_packageResolver = packageResolver ?? throw new ArgumentNullException(nameof(packageResolver));
|
||||
_precedence = precedence ?? throw new ArgumentNullException(nameof(precedence));
|
||||
_fallbackRank = _precedence.Count == 0 ? 10 : _precedence.Values.Max() + 1;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? NullLogger<AdvisoryPrecedenceMerger>.Instance;
|
||||
}
|
||||
|
||||
|
||||
private static readonly Action<ILogger, MergeOverrideAudit, Exception?> OverrideLogged = LoggerMessage.Define<MergeOverrideAudit>(
|
||||
LogLevel.Information,
|
||||
new EventId(1000, "AdvisoryOverride"),
|
||||
"Advisory precedence override {@Override}");
|
||||
|
||||
private static readonly Action<ILogger, PackageOverrideAudit, Exception?> RangeOverrideLogged = LoggerMessage.Define<PackageOverrideAudit>(
|
||||
LogLevel.Information,
|
||||
new EventId(1001, "PackageRangeOverride"),
|
||||
"Affected package precedence override {@Override}");
|
||||
|
||||
private static readonly Action<ILogger, MergeFieldConflictAudit, Exception?> ConflictLogged = LoggerMessage.Define<MergeFieldConflictAudit>(
|
||||
LogLevel.Information,
|
||||
new EventId(1002, "PrecedenceConflict"),
|
||||
"Precedence conflict {@Conflict}");
|
||||
|
||||
private readonly AffectedPackagePrecedenceResolver _packageResolver;
|
||||
private readonly IReadOnlyDictionary<string, int> _precedence;
|
||||
private readonly int _fallbackRank;
|
||||
private readonly System.TimeProvider _timeProvider;
|
||||
private readonly ILogger<AdvisoryPrecedenceMerger> _logger;
|
||||
|
||||
public AdvisoryPrecedenceMerger()
|
||||
: this(new AffectedPackagePrecedenceResolver(), TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public AdvisoryPrecedenceMerger(AffectedPackagePrecedenceResolver packageResolver, System.TimeProvider? timeProvider = null)
|
||||
: this(packageResolver, packageResolver?.Precedence ?? AdvisoryPrecedenceDefaults.Rankings, timeProvider ?? TimeProvider.System, NullLogger<AdvisoryPrecedenceMerger>.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public AdvisoryPrecedenceMerger(
|
||||
AffectedPackagePrecedenceResolver packageResolver,
|
||||
IReadOnlyDictionary<string, int> precedence,
|
||||
System.TimeProvider timeProvider)
|
||||
: this(packageResolver, precedence, timeProvider, NullLogger<AdvisoryPrecedenceMerger>.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public AdvisoryPrecedenceMerger(
|
||||
AffectedPackagePrecedenceResolver packageResolver,
|
||||
AdvisoryPrecedenceOptions? options,
|
||||
System.TimeProvider timeProvider,
|
||||
ILogger<AdvisoryPrecedenceMerger>? logger = null)
|
||||
: this(
|
||||
EnsureResolver(packageResolver, options, out var precedence),
|
||||
precedence,
|
||||
timeProvider,
|
||||
logger)
|
||||
{
|
||||
}
|
||||
|
||||
public AdvisoryPrecedenceMerger(
|
||||
AffectedPackagePrecedenceResolver packageResolver,
|
||||
IReadOnlyDictionary<string, int> precedence,
|
||||
System.TimeProvider timeProvider,
|
||||
ILogger<AdvisoryPrecedenceMerger>? logger)
|
||||
{
|
||||
_packageResolver = packageResolver ?? throw new ArgumentNullException(nameof(packageResolver));
|
||||
_precedence = precedence ?? throw new ArgumentNullException(nameof(precedence));
|
||||
_fallbackRank = _precedence.Count == 0 ? 10 : _precedence.Values.Max() + 1;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? NullLogger<AdvisoryPrecedenceMerger>.Instance;
|
||||
}
|
||||
|
||||
public PrecedenceMergeResult Merge(IEnumerable<Advisory> advisories)
|
||||
{
|
||||
if (advisories is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(advisories));
|
||||
}
|
||||
|
||||
var list = advisories.Where(static a => a is not null).ToList();
|
||||
if (list.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one advisory is required for merge.", nameof(advisories));
|
||||
}
|
||||
|
||||
var advisoryKey = list[0].AdvisoryKey;
|
||||
if (list.Any(advisory => !string.Equals(advisory.AdvisoryKey, advisoryKey, StringComparison.Ordinal)))
|
||||
{
|
||||
throw new ArgumentException("All advisories must share the same advisory key.", nameof(advisories));
|
||||
}
|
||||
|
||||
var ordered = list
|
||||
.Select(advisory => new AdvisoryEntry(advisory, GetRank(advisory)))
|
||||
.OrderBy(entry => entry.Rank)
|
||||
.ThenByDescending(entry => entry.Advisory.Provenance.Length)
|
||||
.ToArray();
|
||||
|
||||
MergeCounter.Add(1, new KeyValuePair<string, object?>("inputs", list.Count));
|
||||
|
||||
var primary = ordered[0].Advisory;
|
||||
|
||||
var title = PickString(ordered, advisory => advisory.Title) ?? advisoryKey;
|
||||
var summary = PickString(ordered, advisory => advisory.Summary);
|
||||
var language = PickString(ordered, advisory => advisory.Language);
|
||||
var severity = PickString(ordered, advisory => advisory.Severity);
|
||||
|
||||
{
|
||||
if (advisories is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(advisories));
|
||||
}
|
||||
|
||||
var list = advisories.Where(static a => a is not null).ToList();
|
||||
if (list.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one advisory is required for merge.", nameof(advisories));
|
||||
}
|
||||
|
||||
var advisoryKey = list[0].AdvisoryKey;
|
||||
if (list.Any(advisory => !string.Equals(advisory.AdvisoryKey, advisoryKey, StringComparison.Ordinal)))
|
||||
{
|
||||
throw new ArgumentException("All advisories must share the same advisory key.", nameof(advisories));
|
||||
}
|
||||
|
||||
var ordered = list
|
||||
.Select(advisory => new AdvisoryEntry(advisory, GetRank(advisory)))
|
||||
.OrderBy(entry => entry.Rank)
|
||||
.ThenByDescending(entry => entry.Advisory.Provenance.Length)
|
||||
.ToArray();
|
||||
|
||||
MergeCounter.Add(1, new KeyValuePair<string, object?>("inputs", list.Count));
|
||||
|
||||
var primary = ordered[0].Advisory;
|
||||
|
||||
var title = PickString(ordered, advisory => advisory.Title) ?? advisoryKey;
|
||||
var summary = PickString(ordered, advisory => advisory.Summary);
|
||||
var language = PickString(ordered, advisory => advisory.Language);
|
||||
var severity = PickString(ordered, advisory => advisory.Severity);
|
||||
|
||||
var aliases = ordered
|
||||
.SelectMany(entry => entry.Advisory.Aliases)
|
||||
.Where(static alias => !string.IsNullOrWhiteSpace(alias))
|
||||
@@ -160,39 +160,39 @@ public sealed class AdvisoryPrecedenceMerger
|
||||
.SelectMany(entry => entry.Advisory.References)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
|
||||
var packageResult = _packageResolver.Merge(ordered.SelectMany(entry => entry.Advisory.AffectedPackages));
|
||||
RecordNormalizedRuleMetrics(advisoryKey, packageResult.Packages);
|
||||
var affectedPackages = packageResult.Packages;
|
||||
var cvssMetrics = ordered
|
||||
.SelectMany(entry => entry.Advisory.CvssMetrics)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
var published = PickDateTime(ordered, static advisory => advisory.Published);
|
||||
var modified = PickDateTime(ordered, static advisory => advisory.Modified) ?? published;
|
||||
|
||||
var provenance = ordered
|
||||
.SelectMany(entry => entry.Advisory.Provenance)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var precedenceTrace = ordered
|
||||
.SelectMany(entry => entry.Sources)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static source => source, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var mergeProvenance = new AdvisoryProvenance(
|
||||
source: "merge",
|
||||
kind: "precedence",
|
||||
value: string.Join("|", precedenceTrace),
|
||||
recordedAt: _timeProvider.GetUtcNow());
|
||||
|
||||
provenance.Add(mergeProvenance);
|
||||
|
||||
var exploitKnown = ordered.Any(entry => entry.Advisory.ExploitKnown);
|
||||
|
||||
var cvssMetrics = ordered
|
||||
.SelectMany(entry => entry.Advisory.CvssMetrics)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
var published = PickDateTime(ordered, static advisory => advisory.Published);
|
||||
var modified = PickDateTime(ordered, static advisory => advisory.Modified) ?? published;
|
||||
|
||||
var provenance = ordered
|
||||
.SelectMany(entry => entry.Advisory.Provenance)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var precedenceTrace = ordered
|
||||
.SelectMany(entry => entry.Sources)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static source => source, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var mergeProvenance = new AdvisoryProvenance(
|
||||
source: "merge",
|
||||
kind: "precedence",
|
||||
value: string.Join("|", precedenceTrace),
|
||||
recordedAt: _timeProvider.GetUtcNow());
|
||||
|
||||
provenance.Add(mergeProvenance);
|
||||
|
||||
var exploitKnown = ordered.Any(entry => entry.Advisory.ExploitKnown);
|
||||
|
||||
LogOverrides(advisoryKey, ordered);
|
||||
LogPackageOverrides(advisoryKey, packageResult.Overrides);
|
||||
var conflicts = new List<MergeConflictDetail>();
|
||||
@@ -294,147 +294,147 @@ public sealed class AdvisoryPrecedenceMerger
|
||||
foreach (var entry in ordered)
|
||||
{
|
||||
var value = selector(entry.Advisory);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private DateTimeOffset? PickDateTime(IEnumerable<AdvisoryEntry> ordered, Func<Advisory, DateTimeOffset?> selector)
|
||||
{
|
||||
foreach (var entry in ordered)
|
||||
{
|
||||
var value = selector(entry.Advisory);
|
||||
if (value.HasValue)
|
||||
{
|
||||
return value.Value.ToUniversalTime();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private int GetRank(Advisory advisory)
|
||||
{
|
||||
var best = _fallbackRank;
|
||||
foreach (var provenance in advisory.Provenance)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(provenance.Source))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_precedence.TryGetValue(provenance.Source, out var rank) && rank < best)
|
||||
{
|
||||
best = rank;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private void LogOverrides(string advisoryKey, IReadOnlyList<AdvisoryEntry> ordered)
|
||||
{
|
||||
if (ordered.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var primary = ordered[0];
|
||||
var primaryRank = primary.Rank;
|
||||
|
||||
for (var i = 1; i < ordered.Count; i++)
|
||||
{
|
||||
var candidate = ordered[i];
|
||||
if (candidate.Rank <= primaryRank)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("primary_source", FormatSourceLabel(primary.Sources)),
|
||||
new("suppressed_source", FormatSourceLabel(candidate.Sources)),
|
||||
new("primary_rank", primaryRank),
|
||||
new("suppressed_rank", candidate.Rank),
|
||||
};
|
||||
|
||||
OverridesCounter.Add(1, tags);
|
||||
|
||||
var audit = new MergeOverrideAudit(
|
||||
advisoryKey,
|
||||
primary.Sources,
|
||||
primaryRank,
|
||||
candidate.Sources,
|
||||
candidate.Rank,
|
||||
primary.Advisory.Aliases.Length,
|
||||
candidate.Advisory.Aliases.Length,
|
||||
primary.Advisory.Provenance.Length,
|
||||
candidate.Advisory.Provenance.Length);
|
||||
|
||||
OverrideLogged(_logger, audit, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void LogPackageOverrides(string advisoryKey, IReadOnlyList<AffectedPackageOverride> overrides)
|
||||
{
|
||||
if (overrides.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var record in overrides)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("advisory_key", advisoryKey),
|
||||
new("package_type", record.Type),
|
||||
new("primary_source", FormatSourceLabel(record.PrimarySources)),
|
||||
new("suppressed_source", FormatSourceLabel(record.SuppressedSources)),
|
||||
new("primary_rank", record.PrimaryRank),
|
||||
new("suppressed_rank", record.SuppressedRank),
|
||||
new("primary_range_count", record.PrimaryRangeCount),
|
||||
new("suppressed_range_count", record.SuppressedRangeCount),
|
||||
};
|
||||
|
||||
RangeOverrideCounter.Add(1, tags);
|
||||
|
||||
var audit = new PackageOverrideAudit(
|
||||
advisoryKey,
|
||||
record.Type,
|
||||
record.Identifier,
|
||||
record.Platform,
|
||||
record.PrimaryRank,
|
||||
record.SuppressedRank,
|
||||
record.PrimarySources,
|
||||
record.SuppressedSources,
|
||||
record.PrimaryRangeCount,
|
||||
record.SuppressedRangeCount);
|
||||
|
||||
RangeOverrideLogged(_logger, audit, null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private DateTimeOffset? PickDateTime(IEnumerable<AdvisoryEntry> ordered, Func<Advisory, DateTimeOffset?> selector)
|
||||
{
|
||||
foreach (var entry in ordered)
|
||||
{
|
||||
var value = selector(entry.Advisory);
|
||||
if (value.HasValue)
|
||||
{
|
||||
return value.Value.ToUniversalTime();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private int GetRank(Advisory advisory)
|
||||
{
|
||||
var best = _fallbackRank;
|
||||
foreach (var provenance in advisory.Provenance)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(provenance.Source))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_precedence.TryGetValue(provenance.Source, out var rank) && rank < best)
|
||||
{
|
||||
best = rank;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private void LogOverrides(string advisoryKey, IReadOnlyList<AdvisoryEntry> ordered)
|
||||
{
|
||||
if (ordered.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var primary = ordered[0];
|
||||
var primaryRank = primary.Rank;
|
||||
|
||||
for (var i = 1; i < ordered.Count; i++)
|
||||
{
|
||||
var candidate = ordered[i];
|
||||
if (candidate.Rank <= primaryRank)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("primary_source", FormatSourceLabel(primary.Sources)),
|
||||
new("suppressed_source", FormatSourceLabel(candidate.Sources)),
|
||||
new("primary_rank", primaryRank),
|
||||
new("suppressed_rank", candidate.Rank),
|
||||
};
|
||||
|
||||
OverridesCounter.Add(1, tags);
|
||||
|
||||
var audit = new MergeOverrideAudit(
|
||||
advisoryKey,
|
||||
primary.Sources,
|
||||
primaryRank,
|
||||
candidate.Sources,
|
||||
candidate.Rank,
|
||||
primary.Advisory.Aliases.Length,
|
||||
candidate.Advisory.Aliases.Length,
|
||||
primary.Advisory.Provenance.Length,
|
||||
candidate.Advisory.Provenance.Length);
|
||||
|
||||
OverrideLogged(_logger, audit, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void LogPackageOverrides(string advisoryKey, IReadOnlyList<AffectedPackageOverride> overrides)
|
||||
{
|
||||
if (overrides.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var record in overrides)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("advisory_key", advisoryKey),
|
||||
new("package_type", record.Type),
|
||||
new("primary_source", FormatSourceLabel(record.PrimarySources)),
|
||||
new("suppressed_source", FormatSourceLabel(record.SuppressedSources)),
|
||||
new("primary_rank", record.PrimaryRank),
|
||||
new("suppressed_rank", record.SuppressedRank),
|
||||
new("primary_range_count", record.PrimaryRangeCount),
|
||||
new("suppressed_range_count", record.SuppressedRangeCount),
|
||||
};
|
||||
|
||||
RangeOverrideCounter.Add(1, tags);
|
||||
|
||||
var audit = new PackageOverrideAudit(
|
||||
advisoryKey,
|
||||
record.Type,
|
||||
record.Identifier,
|
||||
record.Platform,
|
||||
record.PrimaryRank,
|
||||
record.SuppressedRank,
|
||||
record.PrimarySources,
|
||||
record.SuppressedSources,
|
||||
record.PrimaryRangeCount,
|
||||
record.SuppressedRangeCount);
|
||||
|
||||
RangeOverrideLogged(_logger, audit, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordFieldConflicts(string advisoryKey, IReadOnlyList<AdvisoryEntry> ordered, List<MergeConflictDetail> conflicts)
|
||||
{
|
||||
if (ordered.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var primary = ordered[0];
|
||||
var primarySeverity = NormalizeSeverity(primary.Advisory.Severity);
|
||||
|
||||
for (var i = 1; i < ordered.Count; i++)
|
||||
{
|
||||
var candidate = ordered[i];
|
||||
var candidateSeverity = NormalizeSeverity(candidate.Advisory.Severity);
|
||||
|
||||
if (!string.IsNullOrEmpty(candidateSeverity))
|
||||
{
|
||||
{
|
||||
if (ordered.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var primary = ordered[0];
|
||||
var primarySeverity = NormalizeSeverity(primary.Advisory.Severity);
|
||||
|
||||
for (var i = 1; i < ordered.Count; i++)
|
||||
{
|
||||
var candidate = ordered[i];
|
||||
var candidateSeverity = NormalizeSeverity(candidate.Advisory.Severity);
|
||||
|
||||
if (!string.IsNullOrEmpty(candidateSeverity))
|
||||
{
|
||||
var reason = string.IsNullOrEmpty(primarySeverity) ? "primary_missing" : "mismatch";
|
||||
if (string.IsNullOrEmpty(primarySeverity) || !string.Equals(primarySeverity, candidateSeverity, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -474,19 +474,19 @@ public sealed class AdvisoryPrecedenceMerger
|
||||
string? primaryValue,
|
||||
string? suppressedValue,
|
||||
List<MergeConflictDetail> conflicts)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("type", conflictType),
|
||||
new("reason", reason),
|
||||
new("primary_source", FormatSourceLabel(primary.Sources)),
|
||||
new("suppressed_source", FormatSourceLabel(suppressed.Sources)),
|
||||
new("primary_rank", primary.Rank),
|
||||
new("suppressed_rank", suppressed.Rank),
|
||||
};
|
||||
|
||||
ConflictCounter.Add(1, tags);
|
||||
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("type", conflictType),
|
||||
new("reason", reason),
|
||||
new("primary_source", FormatSourceLabel(primary.Sources)),
|
||||
new("suppressed_source", FormatSourceLabel(suppressed.Sources)),
|
||||
new("primary_rank", primary.Rank),
|
||||
new("suppressed_rank", suppressed.Rank),
|
||||
};
|
||||
|
||||
ConflictCounter.Add(1, tags);
|
||||
|
||||
var audit = new MergeFieldConflictAudit(
|
||||
advisoryKey,
|
||||
conflictType,
|
||||
@@ -511,111 +511,111 @@ public sealed class AdvisoryPrecedenceMerger
|
||||
suppressed.Rank,
|
||||
primaryValue,
|
||||
suppressedValue));
|
||||
}
|
||||
|
||||
private readonly record struct AdvisoryEntry(Advisory Advisory, int Rank)
|
||||
{
|
||||
public IReadOnlyCollection<string> Sources { get; } = Advisory.Provenance
|
||||
.Select(static p => p.Source)
|
||||
.Where(static source => !string.IsNullOrWhiteSpace(source))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string? NormalizeSeverity(string? severity)
|
||||
=> SeverityNormalization.Normalize(severity);
|
||||
|
||||
private static AffectedPackagePrecedenceResolver EnsureResolver(
|
||||
AffectedPackagePrecedenceResolver? resolver,
|
||||
AdvisoryPrecedenceOptions? options,
|
||||
out IReadOnlyDictionary<string, int> precedence)
|
||||
{
|
||||
precedence = AdvisoryPrecedenceTable.Merge(AdvisoryPrecedenceDefaults.Rankings, options);
|
||||
|
||||
if (resolver is null)
|
||||
{
|
||||
return new AffectedPackagePrecedenceResolver(precedence);
|
||||
}
|
||||
|
||||
if (DictionaryEquals(resolver.Precedence, precedence))
|
||||
{
|
||||
return resolver;
|
||||
}
|
||||
|
||||
return new AffectedPackagePrecedenceResolver(precedence);
|
||||
}
|
||||
|
||||
private static bool DictionaryEquals(
|
||||
IReadOnlyDictionary<string, int> left,
|
||||
IReadOnlyDictionary<string, int> right)
|
||||
{
|
||||
if (ReferenceEquals(left, right))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (left.Count != right.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in left)
|
||||
{
|
||||
if (!right.TryGetValue(key, out var other) || other != value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatSourceLabel(IReadOnlyCollection<string> sources)
|
||||
{
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (sources.Count == 1)
|
||||
{
|
||||
return sources.First();
|
||||
}
|
||||
|
||||
return string.Join('|', sources.OrderBy(static s => s, StringComparer.OrdinalIgnoreCase).Take(3));
|
||||
}
|
||||
|
||||
private readonly record struct MergeOverrideAudit(
|
||||
string AdvisoryKey,
|
||||
IReadOnlyCollection<string> PrimarySources,
|
||||
int PrimaryRank,
|
||||
IReadOnlyCollection<string> SuppressedSources,
|
||||
int SuppressedRank,
|
||||
int PrimaryAliasCount,
|
||||
int SuppressedAliasCount,
|
||||
int PrimaryProvenanceCount,
|
||||
int SuppressedProvenanceCount);
|
||||
|
||||
private readonly record struct PackageOverrideAudit(
|
||||
string AdvisoryKey,
|
||||
string PackageType,
|
||||
string Identifier,
|
||||
string? Platform,
|
||||
int PrimaryRank,
|
||||
int SuppressedRank,
|
||||
IReadOnlyCollection<string> PrimarySources,
|
||||
IReadOnlyCollection<string> SuppressedSources,
|
||||
int PrimaryRangeCount,
|
||||
int SuppressedRangeCount);
|
||||
|
||||
private readonly record struct MergeFieldConflictAudit(
|
||||
string AdvisoryKey,
|
||||
string ConflictType,
|
||||
string Reason,
|
||||
IReadOnlyCollection<string> PrimarySources,
|
||||
int PrimaryRank,
|
||||
IReadOnlyCollection<string> SuppressedSources,
|
||||
int SuppressedRank,
|
||||
string? PrimaryValue,
|
||||
string? SuppressedValue);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct AdvisoryEntry(Advisory Advisory, int Rank)
|
||||
{
|
||||
public IReadOnlyCollection<string> Sources { get; } = Advisory.Provenance
|
||||
.Select(static p => p.Source)
|
||||
.Where(static source => !string.IsNullOrWhiteSpace(source))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string? NormalizeSeverity(string? severity)
|
||||
=> SeverityNormalization.Normalize(severity);
|
||||
|
||||
private static AffectedPackagePrecedenceResolver EnsureResolver(
|
||||
AffectedPackagePrecedenceResolver? resolver,
|
||||
AdvisoryPrecedenceOptions? options,
|
||||
out IReadOnlyDictionary<string, int> precedence)
|
||||
{
|
||||
precedence = AdvisoryPrecedenceTable.Merge(AdvisoryPrecedenceDefaults.Rankings, options);
|
||||
|
||||
if (resolver is null)
|
||||
{
|
||||
return new AffectedPackagePrecedenceResolver(precedence);
|
||||
}
|
||||
|
||||
if (DictionaryEquals(resolver.Precedence, precedence))
|
||||
{
|
||||
return resolver;
|
||||
}
|
||||
|
||||
return new AffectedPackagePrecedenceResolver(precedence);
|
||||
}
|
||||
|
||||
private static bool DictionaryEquals(
|
||||
IReadOnlyDictionary<string, int> left,
|
||||
IReadOnlyDictionary<string, int> right)
|
||||
{
|
||||
if (ReferenceEquals(left, right))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (left.Count != right.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in left)
|
||||
{
|
||||
if (!right.TryGetValue(key, out var other) || other != value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatSourceLabel(IReadOnlyCollection<string> sources)
|
||||
{
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (sources.Count == 1)
|
||||
{
|
||||
return sources.First();
|
||||
}
|
||||
|
||||
return string.Join('|', sources.OrderBy(static s => s, StringComparer.OrdinalIgnoreCase).Take(3));
|
||||
}
|
||||
|
||||
private readonly record struct MergeOverrideAudit(
|
||||
string AdvisoryKey,
|
||||
IReadOnlyCollection<string> PrimarySources,
|
||||
int PrimaryRank,
|
||||
IReadOnlyCollection<string> SuppressedSources,
|
||||
int SuppressedRank,
|
||||
int PrimaryAliasCount,
|
||||
int SuppressedAliasCount,
|
||||
int PrimaryProvenanceCount,
|
||||
int SuppressedProvenanceCount);
|
||||
|
||||
private readonly record struct PackageOverrideAudit(
|
||||
string AdvisoryKey,
|
||||
string PackageType,
|
||||
string Identifier,
|
||||
string? Platform,
|
||||
int PrimaryRank,
|
||||
int SuppressedRank,
|
||||
IReadOnlyCollection<string> PrimarySources,
|
||||
IReadOnlyCollection<string> SuppressedSources,
|
||||
int PrimaryRangeCount,
|
||||
int SuppressedRangeCount);
|
||||
|
||||
private readonly record struct MergeFieldConflictAudit(
|
||||
string AdvisoryKey,
|
||||
string ConflictType,
|
||||
string Reason,
|
||||
IReadOnlyCollection<string> PrimarySources,
|
||||
int PrimaryRank,
|
||||
IReadOnlyCollection<string> SuppressedSources,
|
||||
int SuppressedRank,
|
||||
string? PrimaryValue,
|
||||
string? SuppressedValue);
|
||||
}
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Merge.Options;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Applies source precedence rules to affected package sets so authoritative distro ranges override generic registry data.
|
||||
/// </summary>
|
||||
public sealed class AffectedPackagePrecedenceResolver
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, int> _precedence;
|
||||
private readonly int _fallbackRank;
|
||||
|
||||
public AffectedPackagePrecedenceResolver()
|
||||
: this(AdvisoryPrecedenceDefaults.Rankings)
|
||||
{
|
||||
}
|
||||
|
||||
public AffectedPackagePrecedenceResolver(AdvisoryPrecedenceOptions? options)
|
||||
: this(AdvisoryPrecedenceTable.Merge(AdvisoryPrecedenceDefaults.Rankings, options))
|
||||
{
|
||||
}
|
||||
|
||||
public AffectedPackagePrecedenceResolver(IReadOnlyDictionary<string, int> precedence)
|
||||
{
|
||||
_precedence = precedence ?? throw new ArgumentNullException(nameof(precedence));
|
||||
_fallbackRank = precedence.Count == 0 ? 10 : precedence.Values.Max() + 1;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, int> Precedence => _precedence;
|
||||
|
||||
public AffectedPackagePrecedenceResult Merge(IEnumerable<AffectedPackage> packages)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(packages);
|
||||
|
||||
var grouped = packages
|
||||
.Where(static pkg => pkg is not null)
|
||||
.GroupBy(pkg => (pkg.Type, pkg.Identifier, pkg.Platform ?? string.Empty));
|
||||
|
||||
var resolved = new List<AffectedPackage>();
|
||||
var overrides = new List<AffectedPackageOverride>();
|
||||
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var ordered = group
|
||||
.Select(pkg => new PackageEntry(pkg, GetPrecedence(pkg)))
|
||||
.OrderBy(static entry => entry.Rank)
|
||||
.ThenByDescending(static entry => entry.Package.Provenance.Length)
|
||||
.ThenByDescending(static entry => entry.Package.VersionRanges.Length)
|
||||
.ToList();
|
||||
|
||||
var primary = ordered[0];
|
||||
var provenance = ordered
|
||||
.SelectMany(static entry => entry.Package.Provenance)
|
||||
.Where(static p => p is not null)
|
||||
.Distinct()
|
||||
.ToImmutableArray();
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Merge.Options;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Applies source precedence rules to affected package sets so authoritative distro ranges override generic registry data.
|
||||
/// </summary>
|
||||
public sealed class AffectedPackagePrecedenceResolver
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, int> _precedence;
|
||||
private readonly int _fallbackRank;
|
||||
|
||||
public AffectedPackagePrecedenceResolver()
|
||||
: this(AdvisoryPrecedenceDefaults.Rankings)
|
||||
{
|
||||
}
|
||||
|
||||
public AffectedPackagePrecedenceResolver(AdvisoryPrecedenceOptions? options)
|
||||
: this(AdvisoryPrecedenceTable.Merge(AdvisoryPrecedenceDefaults.Rankings, options))
|
||||
{
|
||||
}
|
||||
|
||||
public AffectedPackagePrecedenceResolver(IReadOnlyDictionary<string, int> precedence)
|
||||
{
|
||||
_precedence = precedence ?? throw new ArgumentNullException(nameof(precedence));
|
||||
_fallbackRank = precedence.Count == 0 ? 10 : precedence.Values.Max() + 1;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, int> Precedence => _precedence;
|
||||
|
||||
public AffectedPackagePrecedenceResult Merge(IEnumerable<AffectedPackage> packages)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(packages);
|
||||
|
||||
var grouped = packages
|
||||
.Where(static pkg => pkg is not null)
|
||||
.GroupBy(pkg => (pkg.Type, pkg.Identifier, pkg.Platform ?? string.Empty));
|
||||
|
||||
var resolved = new List<AffectedPackage>();
|
||||
var overrides = new List<AffectedPackageOverride>();
|
||||
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var ordered = group
|
||||
.Select(pkg => new PackageEntry(pkg, GetPrecedence(pkg)))
|
||||
.OrderBy(static entry => entry.Rank)
|
||||
.ThenByDescending(static entry => entry.Package.Provenance.Length)
|
||||
.ThenByDescending(static entry => entry.Package.VersionRanges.Length)
|
||||
.ToList();
|
||||
|
||||
var primary = ordered[0];
|
||||
var provenance = ordered
|
||||
.SelectMany(static entry => entry.Package.Provenance)
|
||||
.Where(static p => p is not null)
|
||||
.Distinct()
|
||||
.ToImmutableArray();
|
||||
|
||||
var statuses = ordered
|
||||
.SelectMany(static entry => entry.Package.Statuses)
|
||||
.Distinct(AffectedPackageStatusEqualityComparer.Instance)
|
||||
@@ -75,21 +75,21 @@ public sealed class AffectedPackagePrecedenceResolver
|
||||
{
|
||||
if (candidate.Package.VersionRanges.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
overrides.Add(new AffectedPackageOverride(
|
||||
primary.Package.Type,
|
||||
primary.Package.Identifier,
|
||||
string.IsNullOrWhiteSpace(primary.Package.Platform) ? null : primary.Package.Platform,
|
||||
primary.Rank,
|
||||
candidate.Rank,
|
||||
ExtractSources(primary.Package),
|
||||
ExtractSources(candidate.Package),
|
||||
primary.Package.VersionRanges.Length,
|
||||
candidate.Package.VersionRanges.Length));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
overrides.Add(new AffectedPackageOverride(
|
||||
primary.Package.Type,
|
||||
primary.Package.Identifier,
|
||||
string.IsNullOrWhiteSpace(primary.Package.Platform) ? null : primary.Package.Platform,
|
||||
primary.Rank,
|
||||
candidate.Rank,
|
||||
ExtractSources(primary.Package),
|
||||
ExtractSources(candidate.Package),
|
||||
primary.Package.VersionRanges.Length,
|
||||
candidate.Package.VersionRanges.Length));
|
||||
}
|
||||
|
||||
var merged = new AffectedPackage(
|
||||
primary.Type,
|
||||
primary.Identifier,
|
||||
@@ -101,70 +101,70 @@ public sealed class AffectedPackagePrecedenceResolver
|
||||
|
||||
resolved.Add(merged);
|
||||
}
|
||||
|
||||
var packagesResult = resolved
|
||||
.OrderBy(static pkg => pkg.Type, StringComparer.Ordinal)
|
||||
.ThenBy(static pkg => pkg.Identifier, StringComparer.Ordinal)
|
||||
.ThenBy(static pkg => pkg.Platform, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new AffectedPackagePrecedenceResult(packagesResult, overrides.ToImmutableArray());
|
||||
}
|
||||
|
||||
private int GetPrecedence(AffectedPackage package)
|
||||
{
|
||||
var bestRank = _fallbackRank;
|
||||
foreach (var provenance in package.Provenance)
|
||||
{
|
||||
if (provenance is null || string.IsNullOrWhiteSpace(provenance.Source))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_precedence.TryGetValue(provenance.Source, out var rank) && rank < bestRank)
|
||||
{
|
||||
bestRank = rank;
|
||||
}
|
||||
}
|
||||
|
||||
return bestRank;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractSources(AffectedPackage package)
|
||||
{
|
||||
if (package.Provenance.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return package.Provenance
|
||||
.Select(static p => p.Source)
|
||||
.Where(static source => !string.IsNullOrWhiteSpace(source))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private readonly record struct PackageEntry(AffectedPackage Package, int Rank)
|
||||
{
|
||||
public string Type => Package.Type;
|
||||
|
||||
public string Identifier => Package.Identifier;
|
||||
|
||||
public string? Platform => string.IsNullOrWhiteSpace(Package.Platform) ? null : Package.Platform;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AffectedPackagePrecedenceResult(
|
||||
IReadOnlyList<AffectedPackage> Packages,
|
||||
IReadOnlyList<AffectedPackageOverride> Overrides);
|
||||
|
||||
public sealed record AffectedPackageOverride(
|
||||
string Type,
|
||||
string Identifier,
|
||||
string? Platform,
|
||||
int PrimaryRank,
|
||||
int SuppressedRank,
|
||||
IReadOnlyList<string> PrimarySources,
|
||||
IReadOnlyList<string> SuppressedSources,
|
||||
int PrimaryRangeCount,
|
||||
int SuppressedRangeCount);
|
||||
|
||||
var packagesResult = resolved
|
||||
.OrderBy(static pkg => pkg.Type, StringComparer.Ordinal)
|
||||
.ThenBy(static pkg => pkg.Identifier, StringComparer.Ordinal)
|
||||
.ThenBy(static pkg => pkg.Platform, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new AffectedPackagePrecedenceResult(packagesResult, overrides.ToImmutableArray());
|
||||
}
|
||||
|
||||
private int GetPrecedence(AffectedPackage package)
|
||||
{
|
||||
var bestRank = _fallbackRank;
|
||||
foreach (var provenance in package.Provenance)
|
||||
{
|
||||
if (provenance is null || string.IsNullOrWhiteSpace(provenance.Source))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_precedence.TryGetValue(provenance.Source, out var rank) && rank < bestRank)
|
||||
{
|
||||
bestRank = rank;
|
||||
}
|
||||
}
|
||||
|
||||
return bestRank;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractSources(AffectedPackage package)
|
||||
{
|
||||
if (package.Provenance.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return package.Provenance
|
||||
.Select(static p => p.Source)
|
||||
.Where(static source => !string.IsNullOrWhiteSpace(source))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private readonly record struct PackageEntry(AffectedPackage Package, int Rank)
|
||||
{
|
||||
public string Type => Package.Type;
|
||||
|
||||
public string Identifier => Package.Identifier;
|
||||
|
||||
public string? Platform => string.IsNullOrWhiteSpace(Package.Platform) ? null : Package.Platform;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AffectedPackagePrecedenceResult(
|
||||
IReadOnlyList<AffectedPackage> Packages,
|
||||
IReadOnlyList<AffectedPackageOverride> Overrides);
|
||||
|
||||
public sealed record AffectedPackageOverride(
|
||||
string Type,
|
||||
string Identifier,
|
||||
string? Platform,
|
||||
int PrimaryRank,
|
||||
int SuppressedRank,
|
||||
IReadOnlyList<string> PrimarySources,
|
||||
IReadOnlyList<string> SuppressedSources,
|
||||
int PrimaryRangeCount,
|
||||
int SuppressedRangeCount);
|
||||
|
||||
@@ -1,139 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.Storage.Aliases;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
public sealed class AliasGraphResolver
|
||||
{
|
||||
private readonly IAliasStore _aliasStore;
|
||||
|
||||
public AliasGraphResolver(IAliasStore aliasStore)
|
||||
{
|
||||
_aliasStore = aliasStore ?? throw new ArgumentNullException(nameof(aliasStore));
|
||||
}
|
||||
|
||||
public async Task<AliasIdentityResult> ResolveAsync(string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(advisoryKey);
|
||||
var aliases = await _aliasStore.GetByAdvisoryAsync(advisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
var collisions = new List<AliasCollision>();
|
||||
|
||||
foreach (var alias in aliases)
|
||||
{
|
||||
var candidates = await _aliasStore.GetByAliasAsync(alias.Scheme, alias.Value, cancellationToken).ConfigureAwait(false);
|
||||
var advisoryKeys = candidates
|
||||
.Select(static candidate => candidate.AdvisoryKey)
|
||||
.Where(static key => !string.IsNullOrWhiteSpace(key))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (advisoryKeys.Length <= 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
collisions.Add(new AliasCollision(alias.Scheme, alias.Value, advisoryKeys));
|
||||
}
|
||||
|
||||
var unique = new Dictionary<string, AliasCollision>(StringComparer.Ordinal);
|
||||
foreach (var collision in collisions)
|
||||
{
|
||||
var key = $"{collision.Scheme}\u0001{collision.Value}";
|
||||
if (!unique.ContainsKey(key))
|
||||
{
|
||||
unique[key] = collision;
|
||||
}
|
||||
}
|
||||
|
||||
var distinctCollisions = unique.Values.ToArray();
|
||||
|
||||
return new AliasIdentityResult(advisoryKey, aliases, distinctCollisions);
|
||||
}
|
||||
|
||||
public async Task<AliasComponent> BuildComponentAsync(string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(advisoryKey);
|
||||
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var queue = new Queue<string>();
|
||||
var collisionMap = new Dictionary<string, AliasCollision>(StringComparer.Ordinal);
|
||||
|
||||
var aliasCache = new Dictionary<string, IReadOnlyList<AliasRecord>>(StringComparer.OrdinalIgnoreCase);
|
||||
queue.Enqueue(advisoryKey);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var current = queue.Dequeue();
|
||||
if (!visited.Add(current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var aliases = await GetAliasesAsync(current, cancellationToken, aliasCache).ConfigureAwait(false);
|
||||
aliasCache[current] = aliases;
|
||||
foreach (var alias in aliases)
|
||||
{
|
||||
var aliasRecords = await GetAdvisoriesForAliasAsync(alias.Scheme, alias.Value, cancellationToken).ConfigureAwait(false);
|
||||
var advisoryKeys = aliasRecords
|
||||
.Select(static record => record.AdvisoryKey)
|
||||
.Where(static key => !string.IsNullOrWhiteSpace(key))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (advisoryKeys.Length <= 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var candidate in advisoryKeys)
|
||||
{
|
||||
if (!visited.Contains(candidate))
|
||||
{
|
||||
queue.Enqueue(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
var collision = new AliasCollision(alias.Scheme, alias.Value, advisoryKeys);
|
||||
var key = $"{collision.Scheme}\u0001{collision.Value}";
|
||||
collisionMap.TryAdd(key, collision);
|
||||
}
|
||||
}
|
||||
|
||||
var aliasMap = new Dictionary<string, IReadOnlyList<AliasRecord>>(aliasCache, StringComparer.OrdinalIgnoreCase);
|
||||
return new AliasComponent(advisoryKey, visited.ToArray(), collisionMap.Values.ToArray(), aliasMap);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<AliasRecord>> GetAliasesAsync(
|
||||
string advisoryKey,
|
||||
CancellationToken cancellationToken,
|
||||
IDictionary<string, IReadOnlyList<AliasRecord>> cache)
|
||||
{
|
||||
if (cache.TryGetValue(advisoryKey, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var aliases = await _aliasStore.GetByAdvisoryAsync(advisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
cache[advisoryKey] = aliases;
|
||||
return aliases;
|
||||
}
|
||||
|
||||
private Task<IReadOnlyList<AliasRecord>> GetAdvisoriesForAliasAsync(
|
||||
string scheme,
|
||||
string value,
|
||||
CancellationToken cancellationToken)
|
||||
=> _aliasStore.GetByAliasAsync(scheme, value, cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record AliasIdentityResult(string AdvisoryKey, IReadOnlyList<AliasRecord> Aliases, IReadOnlyList<AliasCollision> Collisions);
|
||||
|
||||
public sealed record AliasComponent(
|
||||
string SeedAdvisoryKey,
|
||||
IReadOnlyList<string> AdvisoryKeys,
|
||||
IReadOnlyList<AliasCollision> Collisions,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<AliasRecord>> AliasMap);
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.Storage.Aliases;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
public sealed class AliasGraphResolver
|
||||
{
|
||||
private readonly IAliasStore _aliasStore;
|
||||
|
||||
public AliasGraphResolver(IAliasStore aliasStore)
|
||||
{
|
||||
_aliasStore = aliasStore ?? throw new ArgumentNullException(nameof(aliasStore));
|
||||
}
|
||||
|
||||
public async Task<AliasIdentityResult> ResolveAsync(string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(advisoryKey);
|
||||
var aliases = await _aliasStore.GetByAdvisoryAsync(advisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
var collisions = new List<AliasCollision>();
|
||||
|
||||
foreach (var alias in aliases)
|
||||
{
|
||||
var candidates = await _aliasStore.GetByAliasAsync(alias.Scheme, alias.Value, cancellationToken).ConfigureAwait(false);
|
||||
var advisoryKeys = candidates
|
||||
.Select(static candidate => candidate.AdvisoryKey)
|
||||
.Where(static key => !string.IsNullOrWhiteSpace(key))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (advisoryKeys.Length <= 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
collisions.Add(new AliasCollision(alias.Scheme, alias.Value, advisoryKeys));
|
||||
}
|
||||
|
||||
var unique = new Dictionary<string, AliasCollision>(StringComparer.Ordinal);
|
||||
foreach (var collision in collisions)
|
||||
{
|
||||
var key = $"{collision.Scheme}\u0001{collision.Value}";
|
||||
if (!unique.ContainsKey(key))
|
||||
{
|
||||
unique[key] = collision;
|
||||
}
|
||||
}
|
||||
|
||||
var distinctCollisions = unique.Values.ToArray();
|
||||
|
||||
return new AliasIdentityResult(advisoryKey, aliases, distinctCollisions);
|
||||
}
|
||||
|
||||
public async Task<AliasComponent> BuildComponentAsync(string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(advisoryKey);
|
||||
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var queue = new Queue<string>();
|
||||
var collisionMap = new Dictionary<string, AliasCollision>(StringComparer.Ordinal);
|
||||
|
||||
var aliasCache = new Dictionary<string, IReadOnlyList<AliasRecord>>(StringComparer.OrdinalIgnoreCase);
|
||||
queue.Enqueue(advisoryKey);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var current = queue.Dequeue();
|
||||
if (!visited.Add(current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var aliases = await GetAliasesAsync(current, cancellationToken, aliasCache).ConfigureAwait(false);
|
||||
aliasCache[current] = aliases;
|
||||
foreach (var alias in aliases)
|
||||
{
|
||||
var aliasRecords = await GetAdvisoriesForAliasAsync(alias.Scheme, alias.Value, cancellationToken).ConfigureAwait(false);
|
||||
var advisoryKeys = aliasRecords
|
||||
.Select(static record => record.AdvisoryKey)
|
||||
.Where(static key => !string.IsNullOrWhiteSpace(key))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (advisoryKeys.Length <= 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var candidate in advisoryKeys)
|
||||
{
|
||||
if (!visited.Contains(candidate))
|
||||
{
|
||||
queue.Enqueue(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
var collision = new AliasCollision(alias.Scheme, alias.Value, advisoryKeys);
|
||||
var key = $"{collision.Scheme}\u0001{collision.Value}";
|
||||
collisionMap.TryAdd(key, collision);
|
||||
}
|
||||
}
|
||||
|
||||
var aliasMap = new Dictionary<string, IReadOnlyList<AliasRecord>>(aliasCache, StringComparer.OrdinalIgnoreCase);
|
||||
return new AliasComponent(advisoryKey, visited.ToArray(), collisionMap.Values.ToArray(), aliasMap);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<AliasRecord>> GetAliasesAsync(
|
||||
string advisoryKey,
|
||||
CancellationToken cancellationToken,
|
||||
IDictionary<string, IReadOnlyList<AliasRecord>> cache)
|
||||
{
|
||||
if (cache.TryGetValue(advisoryKey, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var aliases = await _aliasStore.GetByAdvisoryAsync(advisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
cache[advisoryKey] = aliases;
|
||||
return aliases;
|
||||
}
|
||||
|
||||
private Task<IReadOnlyList<AliasRecord>> GetAdvisoriesForAliasAsync(
|
||||
string scheme,
|
||||
string value,
|
||||
CancellationToken cancellationToken)
|
||||
=> _aliasStore.GetByAliasAsync(scheme, value, cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record AliasIdentityResult(string AdvisoryKey, IReadOnlyList<AliasRecord> Aliases, IReadOnlyList<AliasCollision> Collisions);
|
||||
|
||||
public sealed record AliasComponent(
|
||||
string SeedAdvisoryKey,
|
||||
IReadOnlyList<string> AdvisoryKeys,
|
||||
IReadOnlyList<AliasCollision> Collisions,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<AliasRecord>> AliasMap);
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Computes deterministic hashes over canonical advisory JSON payloads.
|
||||
/// </summary>
|
||||
public sealed class CanonicalHashCalculator
|
||||
{
|
||||
private static readonly UTF8Encoding Utf8NoBom = new(false);
|
||||
|
||||
public byte[] ComputeHash(Advisory? advisory)
|
||||
{
|
||||
if (advisory is null)
|
||||
{
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
var canonical = CanonicalJsonSerializer.Serialize(CanonicalJsonSerializer.Normalize(advisory));
|
||||
var payload = Utf8NoBom.GetBytes(canonical);
|
||||
return SHA256.HashData(payload);
|
||||
}
|
||||
}
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Computes deterministic hashes over canonical advisory JSON payloads.
|
||||
/// </summary>
|
||||
public sealed class CanonicalHashCalculator
|
||||
{
|
||||
private static readonly UTF8Encoding Utf8NoBom = new(false);
|
||||
|
||||
public byte[] ComputeHash(Advisory? advisory)
|
||||
{
|
||||
if (advisory is null)
|
||||
{
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
var canonical = CanonicalJsonSerializer.Serialize(CanonicalJsonSerializer.Normalize(advisory));
|
||||
var payload = Utf8NoBom.GetBytes(canonical);
|
||||
return SHA256.HashData(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical conflict detail used to materialize structured payloads for persistence and explainers.
|
||||
/// </summary>
|
||||
public sealed record ConflictDetailPayload(
|
||||
string Type,
|
||||
string Reason,
|
||||
IReadOnlyList<string> PrimarySources,
|
||||
int PrimaryRank,
|
||||
IReadOnlyList<string> SuppressedSources,
|
||||
int SuppressedRank,
|
||||
string? PrimaryValue,
|
||||
string? SuppressedValue)
|
||||
{
|
||||
public static ConflictDetailPayload FromDetail(MergeConflictDetail detail)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(detail);
|
||||
|
||||
return new ConflictDetailPayload(
|
||||
detail.ConflictType,
|
||||
detail.Reason,
|
||||
detail.PrimarySources,
|
||||
detail.PrimaryRank,
|
||||
detail.SuppressedSources,
|
||||
detail.SuppressedRank,
|
||||
detail.PrimaryValue,
|
||||
detail.SuppressedValue);
|
||||
}
|
||||
|
||||
public MergeConflictExplainerPayload ToExplainer() =>
|
||||
new(
|
||||
Type,
|
||||
Reason,
|
||||
PrimarySources,
|
||||
PrimaryRank,
|
||||
SuppressedSources,
|
||||
SuppressedRank,
|
||||
PrimaryValue,
|
||||
SuppressedValue);
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical conflict detail used to materialize structured payloads for persistence and explainers.
|
||||
/// </summary>
|
||||
public sealed record ConflictDetailPayload(
|
||||
string Type,
|
||||
string Reason,
|
||||
IReadOnlyList<string> PrimarySources,
|
||||
int PrimaryRank,
|
||||
IReadOnlyList<string> SuppressedSources,
|
||||
int SuppressedRank,
|
||||
string? PrimaryValue,
|
||||
string? SuppressedValue)
|
||||
{
|
||||
public static ConflictDetailPayload FromDetail(MergeConflictDetail detail)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(detail);
|
||||
|
||||
return new ConflictDetailPayload(
|
||||
detail.ConflictType,
|
||||
detail.Reason,
|
||||
detail.PrimarySources,
|
||||
detail.PrimaryRank,
|
||||
detail.SuppressedSources,
|
||||
detail.SuppressedRank,
|
||||
detail.PrimaryValue,
|
||||
detail.SuppressedValue);
|
||||
}
|
||||
|
||||
public MergeConflictExplainerPayload ToExplainer() =>
|
||||
new(
|
||||
Type,
|
||||
Reason,
|
||||
PrimarySources,
|
||||
PrimaryRank,
|
||||
SuppressedSources,
|
||||
SuppressedRank,
|
||||
PrimaryValue,
|
||||
SuppressedValue);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
public sealed record MergeConflictDetail(
|
||||
Advisory Primary,
|
||||
Advisory Suppressed,
|
||||
string ConflictType,
|
||||
string Reason,
|
||||
IReadOnlyList<string> PrimarySources,
|
||||
int PrimaryRank,
|
||||
IReadOnlyList<string> SuppressedSources,
|
||||
int SuppressedRank,
|
||||
string? PrimaryValue,
|
||||
string? SuppressedValue);
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
public sealed record MergeConflictDetail(
|
||||
Advisory Primary,
|
||||
Advisory Suppressed,
|
||||
string ConflictType,
|
||||
string Reason,
|
||||
IReadOnlyList<string> PrimarySources,
|
||||
int PrimaryRank,
|
||||
IReadOnlyList<string> SuppressedSources,
|
||||
int SuppressedRank,
|
||||
string? PrimaryValue,
|
||||
string? SuppressedValue);
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Models;
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.MergeEvents;
|
||||
|
||||
/// <summary>
|
||||
/// Persists merge events with canonical before/after hashes for auditability.
|
||||
/// </summary>
|
||||
public sealed class MergeEventWriter
|
||||
{
|
||||
private readonly IMergeEventStore _mergeEventStore;
|
||||
private readonly CanonicalHashCalculator _hashCalculator;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<MergeEventWriter> _logger;
|
||||
|
||||
public MergeEventWriter(
|
||||
IMergeEventStore mergeEventStore,
|
||||
CanonicalHashCalculator hashCalculator,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<MergeEventWriter> logger)
|
||||
{
|
||||
_mergeEventStore = mergeEventStore ?? throw new ArgumentNullException(nameof(mergeEventStore));
|
||||
_hashCalculator = hashCalculator ?? throw new ArgumentNullException(nameof(hashCalculator));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Persists merge events with canonical before/after hashes for auditability.
|
||||
/// </summary>
|
||||
public sealed class MergeEventWriter
|
||||
{
|
||||
private readonly IMergeEventStore _mergeEventStore;
|
||||
private readonly CanonicalHashCalculator _hashCalculator;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<MergeEventWriter> _logger;
|
||||
|
||||
public MergeEventWriter(
|
||||
IMergeEventStore mergeEventStore,
|
||||
CanonicalHashCalculator hashCalculator,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<MergeEventWriter> logger)
|
||||
{
|
||||
_mergeEventStore = mergeEventStore ?? throw new ArgumentNullException(nameof(mergeEventStore));
|
||||
_hashCalculator = hashCalculator ?? throw new ArgumentNullException(nameof(hashCalculator));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<MergeEventRecord> AppendAsync(
|
||||
string advisoryKey,
|
||||
Advisory? before,
|
||||
@@ -39,34 +39,34 @@ public sealed class MergeEventWriter
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryKey);
|
||||
ArgumentNullException.ThrowIfNull(after);
|
||||
|
||||
var beforeHash = _hashCalculator.ComputeHash(before);
|
||||
var afterHash = _hashCalculator.ComputeHash(after);
|
||||
var timestamp = _timeProvider.GetUtcNow();
|
||||
var documentIds = inputDocumentIds?.ToArray() ?? Array.Empty<Guid>();
|
||||
|
||||
var record = new MergeEventRecord(
|
||||
Guid.NewGuid(),
|
||||
var beforeHash = _hashCalculator.ComputeHash(before);
|
||||
var afterHash = _hashCalculator.ComputeHash(after);
|
||||
var timestamp = _timeProvider.GetUtcNow();
|
||||
var documentIds = inputDocumentIds?.ToArray() ?? Array.Empty<Guid>();
|
||||
|
||||
var record = new MergeEventRecord(
|
||||
Guid.NewGuid(),
|
||||
advisoryKey,
|
||||
beforeHash,
|
||||
afterHash,
|
||||
timestamp,
|
||||
documentIds,
|
||||
fieldDecisions ?? Array.Empty<MergeFieldDecision>());
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(beforeHash, afterHash))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Merge event for {AdvisoryKey} changed hash {BeforeHash} -> {AfterHash}",
|
||||
advisoryKey,
|
||||
Convert.ToHexString(beforeHash),
|
||||
Convert.ToHexString(afterHash));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Merge event for {AdvisoryKey} recorded without hash change", advisoryKey);
|
||||
}
|
||||
|
||||
await _mergeEventStore.AppendAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(beforeHash, afterHash))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Merge event for {AdvisoryKey} changed hash {BeforeHash} -> {AfterHash}",
|
||||
advisoryKey,
|
||||
Convert.ToHexString(beforeHash),
|
||||
Convert.ToHexString(afterHash));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Merge event for {AdvisoryKey} recorded without hash change", advisoryKey);
|
||||
}
|
||||
|
||||
await _mergeEventStore.AppendAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
public sealed record PrecedenceMergeResult(
|
||||
Advisory Advisory,
|
||||
IReadOnlyList<MergeConflictDetail> Conflicts);
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
public sealed record PrecedenceMergeResult(
|
||||
Advisory Advisory,
|
||||
IReadOnlyList<MergeConflictDetail> Conflicts);
|
||||
|
||||
Reference in New Issue
Block a user