Restructure solution layout by module
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Docs CI / lint-and-preview (push) Has been cancelled
				
			This commit is contained in:
		@@ -0,0 +1,28 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Concelier.Connector.Distro.Suse.Internal;
 | 
			
		||||
 | 
			
		||||
internal sealed record SuseAdvisoryDto(
 | 
			
		||||
    string AdvisoryId,
 | 
			
		||||
    string Title,
 | 
			
		||||
    string? Summary,
 | 
			
		||||
    DateTimeOffset Published,
 | 
			
		||||
    IReadOnlyList<string> CveIds,
 | 
			
		||||
    IReadOnlyList<SusePackageStateDto> Packages,
 | 
			
		||||
    IReadOnlyList<SuseReferenceDto> References);
 | 
			
		||||
 | 
			
		||||
internal sealed record SusePackageStateDto(
 | 
			
		||||
    string Package,
 | 
			
		||||
    string Platform,
 | 
			
		||||
    string? Architecture,
 | 
			
		||||
    string CanonicalNevra,
 | 
			
		||||
    string? IntroducedVersion,
 | 
			
		||||
    string? FixedVersion,
 | 
			
		||||
    string? LastAffectedVersion,
 | 
			
		||||
    string Status);
 | 
			
		||||
 | 
			
		||||
internal sealed record SuseReferenceDto(
 | 
			
		||||
    string Url,
 | 
			
		||||
    string? Kind,
 | 
			
		||||
    string? Title);
 | 
			
		||||
@@ -0,0 +1,5 @@
 | 
			
		||||
using System;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Concelier.Connector.Distro.Suse.Internal;
 | 
			
		||||
 | 
			
		||||
internal sealed record SuseChangeRecord(string FileName, DateTimeOffset ModifiedAt);
 | 
			
		||||
@@ -0,0 +1,81 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Globalization;
 | 
			
		||||
using System.IO;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Concelier.Connector.Distro.Suse.Internal;
 | 
			
		||||
 | 
			
		||||
internal static class SuseChangesParser
 | 
			
		||||
{
 | 
			
		||||
    public static IReadOnlyList<SuseChangeRecord> Parse(string csv)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(csv))
 | 
			
		||||
        {
 | 
			
		||||
            return Array.Empty<SuseChangeRecord>();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var records = new List<SuseChangeRecord>();
 | 
			
		||||
        using var reader = new StringReader(csv);
 | 
			
		||||
        string? line;
 | 
			
		||||
        while ((line = reader.ReadLine()) is not null)
 | 
			
		||||
        {
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(line))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var parts = SplitCsvLine(line);
 | 
			
		||||
            if (parts.Length < 2)
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var fileName = parts[0].Trim();
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(fileName))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!DateTimeOffset.TryParse(parts[1], CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var modifiedAt))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            records.Add(new SuseChangeRecord(fileName, modifiedAt.ToUniversalTime()));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return records;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string[] SplitCsvLine(string line)
 | 
			
		||||
    {
 | 
			
		||||
        var values = new List<string>(2);
 | 
			
		||||
        var current = string.Empty;
 | 
			
		||||
        var insideQuotes = false;
 | 
			
		||||
 | 
			
		||||
        foreach (var ch in line)
 | 
			
		||||
        {
 | 
			
		||||
            if (ch == '"')
 | 
			
		||||
            {
 | 
			
		||||
                insideQuotes = !insideQuotes;
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (ch == ',' && !insideQuotes)
 | 
			
		||||
            {
 | 
			
		||||
                values.Add(current);
 | 
			
		||||
                current = string.Empty;
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            current += ch;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrEmpty(current))
 | 
			
		||||
        {
 | 
			
		||||
            values.Add(current);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return values.ToArray();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,422 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Buffers.Text;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Globalization;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using StellaOps.Concelier.Normalization.Distro;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Concelier.Connector.Distro.Suse.Internal;
 | 
			
		||||
 | 
			
		||||
internal static class SuseCsafParser
 | 
			
		||||
{
 | 
			
		||||
    public static SuseAdvisoryDto Parse(string json)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentException.ThrowIfNullOrEmpty(json);
 | 
			
		||||
 | 
			
		||||
        using var document = JsonDocument.Parse(json);
 | 
			
		||||
        var root = document.RootElement;
 | 
			
		||||
 | 
			
		||||
        if (!root.TryGetProperty("document", out var documentElement))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("CSAF payload missing 'document' element.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var trackingElement = documentElement.GetProperty("tracking");
 | 
			
		||||
        var advisoryId = trackingElement.TryGetProperty("id", out var idElement)
 | 
			
		||||
            ? idElement.GetString()
 | 
			
		||||
            : null;
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(advisoryId))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("CSAF payload missing tracking.id.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var title = documentElement.TryGetProperty("title", out var titleElement)
 | 
			
		||||
            ? titleElement.GetString()
 | 
			
		||||
            : advisoryId;
 | 
			
		||||
 | 
			
		||||
        var summary = ExtractSummary(documentElement);
 | 
			
		||||
        var published = ParseDate(trackingElement, "initial_release_date")
 | 
			
		||||
            ?? ParseDate(trackingElement, "current_release_date")
 | 
			
		||||
            ?? DateTimeOffset.UtcNow;
 | 
			
		||||
 | 
			
		||||
        var references = new List<SuseReferenceDto>();
 | 
			
		||||
        if (documentElement.TryGetProperty("references", out var referencesElement) &&
 | 
			
		||||
            referencesElement.ValueKind == JsonValueKind.Array)
 | 
			
		||||
        {
 | 
			
		||||
            foreach (var referenceElement in referencesElement.EnumerateArray())
 | 
			
		||||
            {
 | 
			
		||||
                var url = referenceElement.TryGetProperty("url", out var urlElement)
 | 
			
		||||
                    ? urlElement.GetString()
 | 
			
		||||
                    : null;
 | 
			
		||||
 | 
			
		||||
                if (string.IsNullOrWhiteSpace(url))
 | 
			
		||||
                {
 | 
			
		||||
                    continue;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                references.Add(new SuseReferenceDto(
 | 
			
		||||
                    url.Trim(),
 | 
			
		||||
                    referenceElement.TryGetProperty("category", out var categoryElement) ? categoryElement.GetString() : null,
 | 
			
		||||
                    referenceElement.TryGetProperty("summary", out var summaryElement) ? summaryElement.GetString() : null));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var productLookup = BuildProductLookup(root);
 | 
			
		||||
        var packageBuilders = new Dictionary<string, PackageStateBuilder>(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
        var cveIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
 | 
			
		||||
        if (root.TryGetProperty("vulnerabilities", out var vulnerabilitiesElement) &&
 | 
			
		||||
            vulnerabilitiesElement.ValueKind == JsonValueKind.Array)
 | 
			
		||||
        {
 | 
			
		||||
            foreach (var vulnerability in vulnerabilitiesElement.EnumerateArray())
 | 
			
		||||
            {
 | 
			
		||||
                if (vulnerability.TryGetProperty("cve", out var cveElement))
 | 
			
		||||
                {
 | 
			
		||||
                    var cve = cveElement.GetString();
 | 
			
		||||
                    if (!string.IsNullOrWhiteSpace(cve))
 | 
			
		||||
                    {
 | 
			
		||||
                        cveIds.Add(cve.Trim());
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (vulnerability.TryGetProperty("references", out var vulnReferences) &&
 | 
			
		||||
                    vulnReferences.ValueKind == JsonValueKind.Array)
 | 
			
		||||
                {
 | 
			
		||||
                    foreach (var referenceElement in vulnReferences.EnumerateArray())
 | 
			
		||||
                    {
 | 
			
		||||
                        var url = referenceElement.TryGetProperty("url", out var urlElement)
 | 
			
		||||
                            ? urlElement.GetString()
 | 
			
		||||
                            : null;
 | 
			
		||||
                        if (string.IsNullOrWhiteSpace(url))
 | 
			
		||||
                        {
 | 
			
		||||
                            continue;
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        references.Add(new SuseReferenceDto(
 | 
			
		||||
                            url.Trim(),
 | 
			
		||||
                            referenceElement.TryGetProperty("category", out var categoryElement) ? categoryElement.GetString() : null,
 | 
			
		||||
                            referenceElement.TryGetProperty("summary", out var summaryElement) ? summaryElement.GetString() : null));
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (!vulnerability.TryGetProperty("product_status", out var statusElement) ||
 | 
			
		||||
                    statusElement.ValueKind != JsonValueKind.Object)
 | 
			
		||||
                {
 | 
			
		||||
                    continue;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                foreach (var property in statusElement.EnumerateObject())
 | 
			
		||||
                {
 | 
			
		||||
                    var category = property.Name;
 | 
			
		||||
                    var idArray = property.Value;
 | 
			
		||||
                    if (idArray.ValueKind != JsonValueKind.Array)
 | 
			
		||||
                    {
 | 
			
		||||
                        continue;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    foreach (var productIdElement in idArray.EnumerateArray())
 | 
			
		||||
                    {
 | 
			
		||||
                        var productId = productIdElement.GetString();
 | 
			
		||||
                        if (string.IsNullOrWhiteSpace(productId))
 | 
			
		||||
                        {
 | 
			
		||||
                            continue;
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        if (!productLookup.TryGetValue(productId, out var product))
 | 
			
		||||
                        {
 | 
			
		||||
                            continue;
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        if (!packageBuilders.TryGetValue(productId, out var builder))
 | 
			
		||||
                        {
 | 
			
		||||
                            builder = new PackageStateBuilder(product);
 | 
			
		||||
                            packageBuilders[productId] = builder;
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        builder.ApplyStatus(category, product);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var packages = new List<SusePackageStateDto>(packageBuilders.Count);
 | 
			
		||||
        foreach (var builder in packageBuilders.Values)
 | 
			
		||||
        {
 | 
			
		||||
            if (builder.ShouldEmit)
 | 
			
		||||
            {
 | 
			
		||||
                packages.Add(builder.ToDto());
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        packages.Sort(static (left, right) =>
 | 
			
		||||
        {
 | 
			
		||||
            var compare = string.Compare(left.Platform, right.Platform, StringComparison.OrdinalIgnoreCase);
 | 
			
		||||
            if (compare != 0)
 | 
			
		||||
            {
 | 
			
		||||
                return compare;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            compare = string.Compare(left.Package, right.Package, StringComparison.OrdinalIgnoreCase);
 | 
			
		||||
            if (compare != 0)
 | 
			
		||||
            {
 | 
			
		||||
                return compare;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return string.Compare(left.Architecture, right.Architecture, StringComparison.OrdinalIgnoreCase);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        var cveList = cveIds.Count == 0
 | 
			
		||||
            ? Array.Empty<string>()
 | 
			
		||||
            : cveIds.OrderBy(static cve => cve, StringComparer.OrdinalIgnoreCase).ToArray();
 | 
			
		||||
 | 
			
		||||
        return new SuseAdvisoryDto(
 | 
			
		||||
            advisoryId.Trim(),
 | 
			
		||||
            string.IsNullOrWhiteSpace(title) ? advisoryId : title!,
 | 
			
		||||
            summary,
 | 
			
		||||
            published,
 | 
			
		||||
            cveList,
 | 
			
		||||
            packages,
 | 
			
		||||
            references);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string? ExtractSummary(JsonElement documentElement)
 | 
			
		||||
    {
 | 
			
		||||
        if (!documentElement.TryGetProperty("notes", out var notesElement) || notesElement.ValueKind != JsonValueKind.Array)
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        foreach (var note in notesElement.EnumerateArray())
 | 
			
		||||
        {
 | 
			
		||||
            var category = note.TryGetProperty("category", out var categoryElement)
 | 
			
		||||
                ? categoryElement.GetString()
 | 
			
		||||
                : null;
 | 
			
		||||
 | 
			
		||||
            if (string.Equals(category, "summary", StringComparison.OrdinalIgnoreCase)
 | 
			
		||||
                || string.Equals(category, "description", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
            {
 | 
			
		||||
                return note.TryGetProperty("text", out var textElement) ? textElement.GetString() : null;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static DateTimeOffset? ParseDate(JsonElement element, string propertyName)
 | 
			
		||||
    {
 | 
			
		||||
        if (!element.TryGetProperty(propertyName, out var dateElement))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (dateElement.ValueKind == JsonValueKind.String &&
 | 
			
		||||
            DateTimeOffset.TryParse(dateElement.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed))
 | 
			
		||||
        {
 | 
			
		||||
            return parsed.ToUniversalTime();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static Dictionary<string, SuseProduct> BuildProductLookup(JsonElement root)
 | 
			
		||||
    {
 | 
			
		||||
        var lookup = new Dictionary<string, SuseProduct>(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
 | 
			
		||||
        if (!root.TryGetProperty("product_tree", out var productTree))
 | 
			
		||||
        {
 | 
			
		||||
            return lookup;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (productTree.TryGetProperty("branches", out var branches) && branches.ValueKind == JsonValueKind.Array)
 | 
			
		||||
        {
 | 
			
		||||
            TraverseBranches(branches, null, null, lookup);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return lookup;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static void TraverseBranches(JsonElement branches, string? platform, string? architecture, IDictionary<string, SuseProduct> lookup)
 | 
			
		||||
    {
 | 
			
		||||
        foreach (var branch in branches.EnumerateArray())
 | 
			
		||||
        {
 | 
			
		||||
            var category = branch.TryGetProperty("category", out var categoryElement)
 | 
			
		||||
                ? categoryElement.GetString()
 | 
			
		||||
                : null;
 | 
			
		||||
 | 
			
		||||
            var name = branch.TryGetProperty("name", out var nameElement)
 | 
			
		||||
                ? nameElement.GetString()
 | 
			
		||||
                : null;
 | 
			
		||||
 | 
			
		||||
            var nextPlatform = platform;
 | 
			
		||||
            var nextArchitecture = architecture;
 | 
			
		||||
 | 
			
		||||
            if (string.Equals(category, "product_family", StringComparison.OrdinalIgnoreCase) ||
 | 
			
		||||
                string.Equals(category, "product_name", StringComparison.OrdinalIgnoreCase) ||
 | 
			
		||||
                string.Equals(category, "product_version", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
            {
 | 
			
		||||
                if (!string.IsNullOrWhiteSpace(name))
 | 
			
		||||
                {
 | 
			
		||||
                    nextPlatform = name;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (string.Equals(category, "architecture", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
            {
 | 
			
		||||
                nextArchitecture = string.IsNullOrWhiteSpace(name) ? null : name;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (branch.TryGetProperty("product", out var productElement) && productElement.ValueKind == JsonValueKind.Object)
 | 
			
		||||
            {
 | 
			
		||||
                var productId = productElement.TryGetProperty("product_id", out var idElement)
 | 
			
		||||
                    ? idElement.GetString()
 | 
			
		||||
                    : null;
 | 
			
		||||
 | 
			
		||||
                if (!string.IsNullOrWhiteSpace(productId))
 | 
			
		||||
                {
 | 
			
		||||
                    var productName = productElement.TryGetProperty("name", out var productNameElement)
 | 
			
		||||
                        ? productNameElement.GetString()
 | 
			
		||||
                        : productId;
 | 
			
		||||
 | 
			
		||||
                    var (platformName, packageSegment) = SplitProductId(productId!, nextPlatform);
 | 
			
		||||
                    if (string.IsNullOrWhiteSpace(packageSegment))
 | 
			
		||||
                    {
 | 
			
		||||
                        packageSegment = productName;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    if (string.IsNullOrWhiteSpace(packageSegment))
 | 
			
		||||
                    {
 | 
			
		||||
                        continue;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    if (!Nevra.TryParse(packageSegment, out var nevra) && !Nevra.TryParse(productName ?? packageSegment, out nevra))
 | 
			
		||||
                    {
 | 
			
		||||
                        continue;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    lookup[productId!] = new SuseProduct(
 | 
			
		||||
                        productId!,
 | 
			
		||||
                        platformName ?? "SUSE",
 | 
			
		||||
                        nevra!,
 | 
			
		||||
                        nextArchitecture ?? nevra!.Architecture);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (branch.TryGetProperty("branches", out var childBranches) && childBranches.ValueKind == JsonValueKind.Array)
 | 
			
		||||
            {
 | 
			
		||||
                TraverseBranches(childBranches, nextPlatform, nextArchitecture, lookup);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static (string? Platform, string? Package) SplitProductId(string productId, string? currentPlatform)
 | 
			
		||||
    {
 | 
			
		||||
        var separatorIndex = productId.IndexOf(':');
 | 
			
		||||
        if (separatorIndex < 0)
 | 
			
		||||
        {
 | 
			
		||||
            return (currentPlatform, productId);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var platform = productId[..separatorIndex];
 | 
			
		||||
        var package = separatorIndex < productId.Length - 1 ? productId[(separatorIndex + 1)..] : string.Empty;
 | 
			
		||||
        var platformNormalized = string.IsNullOrWhiteSpace(platform) ? currentPlatform : platform;
 | 
			
		||||
        var packageNormalized = string.IsNullOrWhiteSpace(package) ? null : package;
 | 
			
		||||
        return (platformNormalized, packageNormalized);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string FormatNevraVersion(Nevra nevra)
 | 
			
		||||
    {
 | 
			
		||||
        var epochSegment = nevra.HasExplicitEpoch || nevra.Epoch > 0 ? $"{nevra.Epoch}:" : string.Empty;
 | 
			
		||||
        return $"{epochSegment}{nevra.Version}-{nevra.Release}";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private sealed record SuseProduct(string ProductId, string Platform, Nevra Nevra, string? Architecture)
 | 
			
		||||
    {
 | 
			
		||||
        public string Package => Nevra.Name;
 | 
			
		||||
 | 
			
		||||
        public string Version => FormatNevraVersion(Nevra);
 | 
			
		||||
 | 
			
		||||
        public string CanonicalNevra => Nevra.ToCanonicalString();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private sealed class PackageStateBuilder
 | 
			
		||||
    {
 | 
			
		||||
        private readonly SuseProduct _product;
 | 
			
		||||
 | 
			
		||||
        public PackageStateBuilder(SuseProduct product)
 | 
			
		||||
        {
 | 
			
		||||
            _product = product;
 | 
			
		||||
            Status = null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public string Package => _product.Package;
 | 
			
		||||
        public string Platform => _product.Platform;
 | 
			
		||||
        public string? Architecture => _product.Architecture;
 | 
			
		||||
        public string? IntroducedVersion { get; private set; }
 | 
			
		||||
        public string? FixedVersion { get; private set; }
 | 
			
		||||
        public string? LastAffectedVersion { get; private set; }
 | 
			
		||||
        public string? Status { get; private set; }
 | 
			
		||||
 | 
			
		||||
        public bool ShouldEmit => !string.IsNullOrWhiteSpace(Status) && !string.Equals(Status, "not_affected", StringComparison.OrdinalIgnoreCase);
 | 
			
		||||
 | 
			
		||||
        public void ApplyStatus(string category, SuseProduct product)
 | 
			
		||||
        {
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(category))
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            switch (category.ToLowerInvariant())
 | 
			
		||||
            {
 | 
			
		||||
                case "recommended":
 | 
			
		||||
                case "fixed":
 | 
			
		||||
                    FixedVersion = product.Version;
 | 
			
		||||
                    Status = "resolved";
 | 
			
		||||
                    break;
 | 
			
		||||
 | 
			
		||||
                case "known_affected":
 | 
			
		||||
                case "known_vulnerable":
 | 
			
		||||
                    LastAffectedVersion = product.Version;
 | 
			
		||||
                    Status ??= "open";
 | 
			
		||||
                    break;
 | 
			
		||||
 | 
			
		||||
                case "first_affected":
 | 
			
		||||
                    IntroducedVersion ??= product.Version;
 | 
			
		||||
                    Status ??= "open";
 | 
			
		||||
                    break;
 | 
			
		||||
 | 
			
		||||
                case "under_investigation":
 | 
			
		||||
                    Status ??= "investigating";
 | 
			
		||||
                    break;
 | 
			
		||||
 | 
			
		||||
                case "known_not_affected":
 | 
			
		||||
                    Status = "not_affected";
 | 
			
		||||
                    IntroducedVersion = null;
 | 
			
		||||
                    FixedVersion = null;
 | 
			
		||||
                    LastAffectedVersion = null;
 | 
			
		||||
                    break;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public SusePackageStateDto ToDto()
 | 
			
		||||
        {
 | 
			
		||||
            var status = Status ?? "unknown";
 | 
			
		||||
            var introduced = IntroducedVersion;
 | 
			
		||||
            var lastAffected = LastAffectedVersion;
 | 
			
		||||
 | 
			
		||||
            if (string.Equals(status, "resolved", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(FixedVersion))
 | 
			
		||||
            {
 | 
			
		||||
                status = "open";
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return new SusePackageStateDto(
 | 
			
		||||
                Package,
 | 
			
		||||
                Platform,
 | 
			
		||||
                Architecture,
 | 
			
		||||
                _product.CanonicalNevra,
 | 
			
		||||
                introduced,
 | 
			
		||||
                FixedVersion,
 | 
			
		||||
                lastAffected,
 | 
			
		||||
                status);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,177 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Concelier.Connector.Distro.Suse.Internal;
 | 
			
		||||
 | 
			
		||||
internal sealed record SuseCursor(
 | 
			
		||||
    DateTimeOffset? LastModified,
 | 
			
		||||
    IReadOnlyCollection<string> ProcessedIds,
 | 
			
		||||
    IReadOnlyCollection<Guid> PendingDocuments,
 | 
			
		||||
    IReadOnlyCollection<Guid> PendingMappings,
 | 
			
		||||
    IReadOnlyDictionary<string, SuseFetchCacheEntry> FetchCache)
 | 
			
		||||
{
 | 
			
		||||
    private static readonly IReadOnlyCollection<string> EmptyStringList = Array.Empty<string>();
 | 
			
		||||
    private static readonly IReadOnlyCollection<Guid> EmptyGuidList = Array.Empty<Guid>();
 | 
			
		||||
    private static readonly IReadOnlyDictionary<string, SuseFetchCacheEntry> EmptyCache =
 | 
			
		||||
        new Dictionary<string, SuseFetchCacheEntry>(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
 | 
			
		||||
    public static SuseCursor Empty { get; } = new(null, EmptyStringList, EmptyGuidList, EmptyGuidList, EmptyCache);
 | 
			
		||||
 | 
			
		||||
    public static SuseCursor FromBson(BsonDocument? document)
 | 
			
		||||
    {
 | 
			
		||||
        if (document is null || document.ElementCount == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return Empty;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        DateTimeOffset? lastModified = null;
 | 
			
		||||
        if (document.TryGetValue("lastModified", out var lastValue))
 | 
			
		||||
        {
 | 
			
		||||
            lastModified = lastValue.BsonType switch
 | 
			
		||||
            {
 | 
			
		||||
                BsonType.DateTime => DateTime.SpecifyKind(lastValue.ToUniversalTime(), DateTimeKind.Utc),
 | 
			
		||||
                BsonType.String when DateTimeOffset.TryParse(lastValue.AsString, out var parsed) => parsed.ToUniversalTime(),
 | 
			
		||||
                _ => null,
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var processed = ReadStringSet(document, "processedIds");
 | 
			
		||||
        var pendingDocs = ReadGuidSet(document, "pendingDocuments");
 | 
			
		||||
        var pendingMappings = ReadGuidSet(document, "pendingMappings");
 | 
			
		||||
        var cache = ReadCache(document);
 | 
			
		||||
 | 
			
		||||
        return new SuseCursor(lastModified, processed, pendingDocs, pendingMappings, cache);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public BsonDocument ToBsonDocument()
 | 
			
		||||
    {
 | 
			
		||||
        var document = new BsonDocument
 | 
			
		||||
        {
 | 
			
		||||
            ["pendingDocuments"] = new BsonArray(PendingDocuments.Select(static id => id.ToString())),
 | 
			
		||||
            ["pendingMappings"] = new BsonArray(PendingMappings.Select(static id => id.ToString())),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (LastModified.HasValue)
 | 
			
		||||
        {
 | 
			
		||||
            document["lastModified"] = LastModified.Value.UtcDateTime;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (ProcessedIds.Count > 0)
 | 
			
		||||
        {
 | 
			
		||||
            document["processedIds"] = new BsonArray(ProcessedIds);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (FetchCache.Count > 0)
 | 
			
		||||
        {
 | 
			
		||||
            var cacheDocument = new BsonDocument();
 | 
			
		||||
            foreach (var (key, entry) in FetchCache)
 | 
			
		||||
            {
 | 
			
		||||
                cacheDocument[key] = entry.ToBsonDocument();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            document["fetchCache"] = cacheDocument;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return document;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public SuseCursor WithPendingDocuments(IEnumerable<Guid> ids)
 | 
			
		||||
        => this with { PendingDocuments = ids?.Distinct().ToArray() ?? EmptyGuidList };
 | 
			
		||||
 | 
			
		||||
    public SuseCursor WithPendingMappings(IEnumerable<Guid> ids)
 | 
			
		||||
        => this with { PendingMappings = ids?.Distinct().ToArray() ?? EmptyGuidList };
 | 
			
		||||
 | 
			
		||||
    public SuseCursor WithFetchCache(IDictionary<string, SuseFetchCacheEntry>? cache)
 | 
			
		||||
    {
 | 
			
		||||
        if (cache is null || cache.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return this with { FetchCache = EmptyCache };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return this with { FetchCache = new Dictionary<string, SuseFetchCacheEntry>(cache, StringComparer.OrdinalIgnoreCase) };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public SuseCursor WithProcessed(DateTimeOffset modified, IEnumerable<string> ids)
 | 
			
		||||
        => this with
 | 
			
		||||
        {
 | 
			
		||||
            LastModified = modified.ToUniversalTime(),
 | 
			
		||||
            ProcessedIds = ids?.Where(static id => !string.IsNullOrWhiteSpace(id))
 | 
			
		||||
                .Select(static id => id.Trim())
 | 
			
		||||
                .Distinct(StringComparer.OrdinalIgnoreCase)
 | 
			
		||||
                .ToArray() ?? EmptyStringList
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
    public bool TryGetCache(string key, out SuseFetchCacheEntry entry)
 | 
			
		||||
    {
 | 
			
		||||
        if (FetchCache.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            entry = SuseFetchCacheEntry.Empty;
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return FetchCache.TryGetValue(key, out entry!);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IReadOnlyCollection<string> ReadStringSet(BsonDocument document, string field)
 | 
			
		||||
    {
 | 
			
		||||
        if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
 | 
			
		||||
        {
 | 
			
		||||
            return EmptyStringList;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var list = new List<string>(array.Count);
 | 
			
		||||
        foreach (var element in array)
 | 
			
		||||
        {
 | 
			
		||||
            if (element.BsonType == BsonType.String)
 | 
			
		||||
            {
 | 
			
		||||
                var str = element.AsString.Trim();
 | 
			
		||||
                if (!string.IsNullOrWhiteSpace(str))
 | 
			
		||||
                {
 | 
			
		||||
                    list.Add(str);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return list;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IReadOnlyCollection<Guid> ReadGuidSet(BsonDocument document, string field)
 | 
			
		||||
    {
 | 
			
		||||
        if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
 | 
			
		||||
        {
 | 
			
		||||
            return EmptyGuidList;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var list = new List<Guid>(array.Count);
 | 
			
		||||
        foreach (var element in array)
 | 
			
		||||
        {
 | 
			
		||||
            if (Guid.TryParse(element.ToString(), out var guid))
 | 
			
		||||
            {
 | 
			
		||||
                list.Add(guid);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return list;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IReadOnlyDictionary<string, SuseFetchCacheEntry> ReadCache(BsonDocument document)
 | 
			
		||||
    {
 | 
			
		||||
        if (!document.TryGetValue("fetchCache", out var value) || value is not BsonDocument cacheDocument || cacheDocument.ElementCount == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return EmptyCache;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var cache = new Dictionary<string, SuseFetchCacheEntry>(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
        foreach (var element in cacheDocument.Elements)
 | 
			
		||||
        {
 | 
			
		||||
            if (element.Value is BsonDocument entry)
 | 
			
		||||
            {
 | 
			
		||||
                cache[element.Name] = SuseFetchCacheEntry.FromBson(entry);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return cache;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,76 @@
 | 
			
		||||
using System;
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Concelier.Connector.Distro.Suse.Internal;
 | 
			
		||||
 | 
			
		||||
internal sealed record SuseFetchCacheEntry(string? ETag, DateTimeOffset? LastModified)
 | 
			
		||||
{
 | 
			
		||||
    public static SuseFetchCacheEntry Empty { get; } = new(null, null);
 | 
			
		||||
 | 
			
		||||
    public static SuseFetchCacheEntry FromDocument(StellaOps.Concelier.Storage.Mongo.Documents.DocumentRecord document)
 | 
			
		||||
        => new(document.Etag, document.LastModified);
 | 
			
		||||
 | 
			
		||||
    public static SuseFetchCacheEntry FromBson(BsonDocument document)
 | 
			
		||||
    {
 | 
			
		||||
        if (document is null || document.ElementCount == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return Empty;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        string? etag = null;
 | 
			
		||||
        DateTimeOffset? lastModified = null;
 | 
			
		||||
 | 
			
		||||
        if (document.TryGetValue("etag", out var etagValue) && etagValue.BsonType == BsonType.String)
 | 
			
		||||
        {
 | 
			
		||||
            etag = etagValue.AsString;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (document.TryGetValue("lastModified", out var modifiedValue))
 | 
			
		||||
        {
 | 
			
		||||
            lastModified = modifiedValue.BsonType switch
 | 
			
		||||
            {
 | 
			
		||||
                BsonType.DateTime => DateTime.SpecifyKind(modifiedValue.ToUniversalTime(), DateTimeKind.Utc),
 | 
			
		||||
                BsonType.String when DateTimeOffset.TryParse(modifiedValue.AsString, out var parsed) => parsed.ToUniversalTime(),
 | 
			
		||||
                _ => null,
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new SuseFetchCacheEntry(etag, lastModified);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public BsonDocument ToBsonDocument()
 | 
			
		||||
    {
 | 
			
		||||
        var document = new BsonDocument();
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(ETag))
 | 
			
		||||
        {
 | 
			
		||||
            document["etag"] = ETag;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (LastModified.HasValue)
 | 
			
		||||
        {
 | 
			
		||||
            document["lastModified"] = LastModified.Value.UtcDateTime;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return document;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public bool Matches(StellaOps.Concelier.Storage.Mongo.Documents.DocumentRecord document)
 | 
			
		||||
    {
 | 
			
		||||
        if (document is null)
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.Equals(ETag, document.Etag, StringComparison.Ordinal))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (LastModified.HasValue && document.LastModified.HasValue)
 | 
			
		||||
        {
 | 
			
		||||
            return LastModified.Value.UtcDateTime == document.LastModified.Value.UtcDateTime;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return !LastModified.HasValue && !document.LastModified.HasValue;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,342 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Globalization;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using StellaOps.Concelier.Models;
 | 
			
		||||
using StellaOps.Concelier.Normalization.Distro;
 | 
			
		||||
using StellaOps.Concelier.Storage.Mongo.Documents;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Concelier.Connector.Distro.Suse.Internal;
 | 
			
		||||
 | 
			
		||||
internal static class SuseMapper
 | 
			
		||||
{
 | 
			
		||||
    public static Advisory Map(SuseAdvisoryDto dto, DocumentRecord document, DateTimeOffset recordedAt)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(dto);
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(document);
 | 
			
		||||
 | 
			
		||||
        var aliases = BuildAliases(dto);
 | 
			
		||||
        var references = BuildReferences(dto, recordedAt);
 | 
			
		||||
        var packages = BuildPackages(dto, recordedAt);
 | 
			
		||||
 | 
			
		||||
        var fetchProvenance = new AdvisoryProvenance(
 | 
			
		||||
            SuseConnectorPlugin.SourceName,
 | 
			
		||||
            "document",
 | 
			
		||||
            document.Uri,
 | 
			
		||||
            document.FetchedAt.ToUniversalTime());
 | 
			
		||||
 | 
			
		||||
        var mapProvenance = new AdvisoryProvenance(
 | 
			
		||||
            SuseConnectorPlugin.SourceName,
 | 
			
		||||
            "mapping",
 | 
			
		||||
            dto.AdvisoryId,
 | 
			
		||||
            recordedAt);
 | 
			
		||||
 | 
			
		||||
        var published = dto.Published;
 | 
			
		||||
        var modified = DateTimeOffset.Compare(recordedAt, dto.Published) >= 0 ? recordedAt : dto.Published;
 | 
			
		||||
 | 
			
		||||
        return new Advisory(
 | 
			
		||||
            advisoryKey: dto.AdvisoryId,
 | 
			
		||||
            title: dto.Title ?? dto.AdvisoryId,
 | 
			
		||||
            summary: dto.Summary,
 | 
			
		||||
            language: "en",
 | 
			
		||||
            published: published,
 | 
			
		||||
            modified: modified,
 | 
			
		||||
            severity: null,
 | 
			
		||||
            exploitKnown: false,
 | 
			
		||||
            aliases: aliases,
 | 
			
		||||
            references: references,
 | 
			
		||||
            affectedPackages: packages,
 | 
			
		||||
            cvssMetrics: Array.Empty<CvssMetric>(),
 | 
			
		||||
            provenance: new[] { fetchProvenance, mapProvenance });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string[] BuildAliases(SuseAdvisoryDto dto)
 | 
			
		||||
    {
 | 
			
		||||
        var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
 | 
			
		||||
        {
 | 
			
		||||
            dto.AdvisoryId
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        foreach (var cve in dto.CveIds ?? Array.Empty<string>())
 | 
			
		||||
        {
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(cve))
 | 
			
		||||
            {
 | 
			
		||||
                aliases.Add(cve.Trim());
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return aliases.OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase).ToArray();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static AdvisoryReference[] BuildReferences(SuseAdvisoryDto dto, DateTimeOffset recordedAt)
 | 
			
		||||
    {
 | 
			
		||||
        if (dto.References is null || dto.References.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return Array.Empty<AdvisoryReference>();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var references = new List<AdvisoryReference>(dto.References.Count);
 | 
			
		||||
        foreach (var reference in dto.References)
 | 
			
		||||
        {
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(reference.Url))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                var provenance = new AdvisoryProvenance(
 | 
			
		||||
                    SuseConnectorPlugin.SourceName,
 | 
			
		||||
                    "reference",
 | 
			
		||||
                    reference.Url,
 | 
			
		||||
                    recordedAt);
 | 
			
		||||
 | 
			
		||||
                references.Add(new AdvisoryReference(
 | 
			
		||||
                    reference.Url.Trim(),
 | 
			
		||||
                    NormalizeReferenceKind(reference.Kind),
 | 
			
		||||
                    reference.Kind,
 | 
			
		||||
                    reference.Title,
 | 
			
		||||
                    provenance));
 | 
			
		||||
            }
 | 
			
		||||
            catch (ArgumentException)
 | 
			
		||||
            {
 | 
			
		||||
                // Ignore malformed URLs to keep advisory mapping resilient.
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return references.Count == 0
 | 
			
		||||
            ? Array.Empty<AdvisoryReference>()
 | 
			
		||||
            : references
 | 
			
		||||
                .OrderBy(static reference => reference.Url, StringComparer.OrdinalIgnoreCase)
 | 
			
		||||
                .ToArray();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string? NormalizeReferenceKind(string? kind)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(kind))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return kind.Trim().ToLowerInvariant() switch
 | 
			
		||||
        {
 | 
			
		||||
            "cve" => "cve",
 | 
			
		||||
            "self" => "advisory",
 | 
			
		||||
            "external" => "external",
 | 
			
		||||
            _ => null,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IReadOnlyList<AffectedPackage> BuildPackages(SuseAdvisoryDto dto, DateTimeOffset recordedAt)
 | 
			
		||||
    {
 | 
			
		||||
        if (dto.Packages is null || dto.Packages.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return Array.Empty<AffectedPackage>();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var packages = new List<AffectedPackage>(dto.Packages.Count);
 | 
			
		||||
        foreach (var package in dto.Packages)
 | 
			
		||||
        {
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(package.CanonicalNevra))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            Nevra? nevra;
 | 
			
		||||
            if (!Nevra.TryParse(package.CanonicalNevra, out nevra))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var affectedProvenance = new AdvisoryProvenance(
 | 
			
		||||
                SuseConnectorPlugin.SourceName,
 | 
			
		||||
                "affected",
 | 
			
		||||
                $"{package.Platform}:{package.CanonicalNevra}",
 | 
			
		||||
                recordedAt);
 | 
			
		||||
 | 
			
		||||
            var ranges = BuildVersionRanges(package, nevra!, recordedAt);
 | 
			
		||||
            if (ranges.Count == 0 && string.Equals(package.Status, "not_affected", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var normalizedVersions = BuildNormalizedVersions(package, ranges);
 | 
			
		||||
 | 
			
		||||
            packages.Add(new AffectedPackage(
 | 
			
		||||
                AffectedPackageTypes.Rpm,
 | 
			
		||||
                identifier: nevra!.ToCanonicalString(),
 | 
			
		||||
                platform: package.Platform,
 | 
			
		||||
                versionRanges: ranges,
 | 
			
		||||
                statuses: BuildStatuses(package, affectedProvenance),
 | 
			
		||||
                provenance: new[] { affectedProvenance },
 | 
			
		||||
                normalizedVersions: normalizedVersions));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return packages.Count == 0
 | 
			
		||||
            ? Array.Empty<AffectedPackage>()
 | 
			
		||||
            : packages
 | 
			
		||||
                .OrderBy(static pkg => pkg.Platform, StringComparer.OrdinalIgnoreCase)
 | 
			
		||||
                .ThenBy(static pkg => pkg.Identifier, StringComparer.OrdinalIgnoreCase)
 | 
			
		||||
                .ToArray();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IReadOnlyList<AffectedPackageStatus> BuildStatuses(SusePackageStateDto package, AdvisoryProvenance provenance)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(package.Status))
 | 
			
		||||
        {
 | 
			
		||||
            return Array.Empty<AffectedPackageStatus>();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new[]
 | 
			
		||||
        {
 | 
			
		||||
            new AffectedPackageStatus(package.Status, provenance)
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IReadOnlyList<AffectedVersionRange> BuildVersionRanges(SusePackageStateDto package, Nevra nevra, DateTimeOffset recordedAt)
 | 
			
		||||
    {
 | 
			
		||||
        var introducedComponent = ParseNevraComponent(package.IntroducedVersion, nevra);
 | 
			
		||||
        var fixedComponent = ParseNevraComponent(package.FixedVersion, nevra);
 | 
			
		||||
        var lastAffectedComponent = ParseNevraComponent(package.LastAffectedVersion, nevra);
 | 
			
		||||
 | 
			
		||||
        if (introducedComponent is null && fixedComponent is null && lastAffectedComponent is null)
 | 
			
		||||
        {
 | 
			
		||||
            return Array.Empty<AffectedVersionRange>();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var rangeProvenance = new AdvisoryProvenance(
 | 
			
		||||
            SuseConnectorPlugin.SourceName,
 | 
			
		||||
            "range",
 | 
			
		||||
            $"{package.Platform}:{nevra.ToCanonicalString()}",
 | 
			
		||||
            recordedAt);
 | 
			
		||||
 | 
			
		||||
        var extensions = new Dictionary<string, string>(StringComparer.Ordinal)
 | 
			
		||||
        {
 | 
			
		||||
            ["suse.status"] = package.Status
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var rangeExpression = BuildRangeExpression(package.IntroducedVersion, package.FixedVersion, package.LastAffectedVersion);
 | 
			
		||||
 | 
			
		||||
        var range = new AffectedVersionRange(
 | 
			
		||||
            rangeKind: "nevra",
 | 
			
		||||
            introducedVersion: package.IntroducedVersion,
 | 
			
		||||
            fixedVersion: package.FixedVersion,
 | 
			
		||||
            lastAffectedVersion: package.LastAffectedVersion,
 | 
			
		||||
            rangeExpression: rangeExpression,
 | 
			
		||||
            provenance: rangeProvenance,
 | 
			
		||||
            primitives: new RangePrimitives(
 | 
			
		||||
                SemVer: null,
 | 
			
		||||
                Nevra: new NevraPrimitive(introducedComponent, fixedComponent, lastAffectedComponent),
 | 
			
		||||
                Evr: null,
 | 
			
		||||
                VendorExtensions: extensions));
 | 
			
		||||
 | 
			
		||||
        return new[] { range };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static NevraComponent? ParseNevraComponent(string? version, Nevra nevra)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(version))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!TrySplitNevraVersion(version.Trim(), out var epoch, out var ver, out var rel))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new NevraComponent(
 | 
			
		||||
            nevra.Name,
 | 
			
		||||
            epoch,
 | 
			
		||||
            ver,
 | 
			
		||||
            rel,
 | 
			
		||||
            string.IsNullOrWhiteSpace(nevra.Architecture) ? null : nevra.Architecture);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static bool TrySplitNevraVersion(string value, out int epoch, out string version, out string release)
 | 
			
		||||
    {
 | 
			
		||||
        epoch = 0;
 | 
			
		||||
        version = string.Empty;
 | 
			
		||||
        release = string.Empty;
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(value))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var trimmed = value.Trim();
 | 
			
		||||
        var dashIndex = trimmed.LastIndexOf('-');
 | 
			
		||||
        if (dashIndex <= 0 || dashIndex >= trimmed.Length - 1)
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        release = trimmed[(dashIndex + 1)..];
 | 
			
		||||
        var versionSegment = trimmed[..dashIndex];
 | 
			
		||||
 | 
			
		||||
        var epochIndex = versionSegment.IndexOf(':');
 | 
			
		||||
        if (epochIndex >= 0)
 | 
			
		||||
        {
 | 
			
		||||
            var epochPart = versionSegment[..epochIndex];
 | 
			
		||||
            version = epochIndex < versionSegment.Length - 1 ? versionSegment[(epochIndex + 1)..] : string.Empty;
 | 
			
		||||
            if (epochPart.Length > 0 && !int.TryParse(epochPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out epoch))
 | 
			
		||||
            {
 | 
			
		||||
                epoch = 0;
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            version = versionSegment;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return !string.IsNullOrWhiteSpace(version) && !string.IsNullOrWhiteSpace(release);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string? BuildRangeExpression(string? introduced, string? fixedVersion, string? lastAffected)
 | 
			
		||||
    {
 | 
			
		||||
        var parts = new List<string>(3);
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(introduced))
 | 
			
		||||
        {
 | 
			
		||||
            parts.Add($"introduced:{introduced}");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(fixedVersion))
 | 
			
		||||
        {
 | 
			
		||||
            parts.Add($"fixed:{fixedVersion}");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(lastAffected))
 | 
			
		||||
        {
 | 
			
		||||
            parts.Add($"last:{lastAffected}");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return parts.Count == 0 ? null : string.Join(" ", parts);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IReadOnlyList<NormalizedVersionRule> BuildNormalizedVersions(
 | 
			
		||||
        SusePackageStateDto package,
 | 
			
		||||
        IReadOnlyList<AffectedVersionRange> ranges)
 | 
			
		||||
    {
 | 
			
		||||
        if (ranges.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return Array.Empty<NormalizedVersionRule>();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var note = string.IsNullOrWhiteSpace(package.Platform)
 | 
			
		||||
            ? null
 | 
			
		||||
            : $"suse:{package.Platform.Trim()}";
 | 
			
		||||
 | 
			
		||||
        var rules = new List<NormalizedVersionRule>(ranges.Count);
 | 
			
		||||
        foreach (var range in ranges)
 | 
			
		||||
        {
 | 
			
		||||
            var rule = range.ToNormalizedVersionRule(note);
 | 
			
		||||
            if (rule is not null)
 | 
			
		||||
            {
 | 
			
		||||
                rules.Add(rule);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return rules.Count == 0 ? Array.Empty<NormalizedVersionRule>() : rules;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user