using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using StellaOps.Feedser.Models; using StellaOps.Feedser.Normalization.Distro; using StellaOps.Feedser.Storage.Mongo.Documents; namespace StellaOps.Feedser.Source.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(), provenance: new[] { fetchProvenance, mapProvenance }); } private static string[] BuildAliases(SuseAdvisoryDto dto) { var aliases = new HashSet(StringComparer.OrdinalIgnoreCase) { dto.AdvisoryId }; foreach (var cve in dto.CveIds ?? Array.Empty()) { 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(); } var references = new List(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() : 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 BuildPackages(SuseAdvisoryDto dto, DateTimeOffset recordedAt) { if (dto.Packages is null || dto.Packages.Count == 0) { return Array.Empty(); } var packages = new List(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; } packages.Add(new AffectedPackage( AffectedPackageTypes.Rpm, identifier: nevra!.ToCanonicalString(), platform: package.Platform, versionRanges: ranges, statuses: BuildStatuses(package, affectedProvenance), provenance: new[] { affectedProvenance })); } return packages.Count == 0 ? Array.Empty() : packages .OrderBy(static pkg => pkg.Platform, StringComparer.OrdinalIgnoreCase) .ThenBy(static pkg => pkg.Identifier, StringComparer.OrdinalIgnoreCase) .ToArray(); } private static IReadOnlyList BuildStatuses(SusePackageStateDto package, AdvisoryProvenance provenance) { if (string.IsNullOrWhiteSpace(package.Status)) { return Array.Empty(); } return new[] { new AffectedPackageStatus(package.Status, provenance) }; } private static IReadOnlyList 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(); } var rangeProvenance = new AdvisoryProvenance( SuseConnectorPlugin.SourceName, "range", $"{package.Platform}:{nevra.ToCanonicalString()}", recordedAt); var extensions = new Dictionary(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(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); } }