Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,70 @@
using StellaOps.Concelier.Normalization.Identifiers;
namespace StellaOps.Concelier.Normalization.Tests;
public sealed class CpeNormalizerTests
{
[Fact]
public void TryNormalizeCpe_Preserves2Dot3Format()
{
var input = "cpe:2.3:A:Example:Product:1.0:*:*:*:*:*:*:*";
var success = IdentifierNormalizer.TryNormalizeCpe(input, out var normalized);
Assert.True(success);
Assert.Equal("cpe:2.3:a:example:product:1.0:*:*:*:*:*:*:*", normalized);
}
[Fact]
public void TryNormalizeCpe_UpgradesUriBinding()
{
var input = "cpe:/o:RedHat:Enterprise_Linux:8";
var success = IdentifierNormalizer.TryNormalizeCpe(input, out var normalized);
Assert.True(success);
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:8:*:*:*:*:*:*:*", normalized);
}
[Fact]
public void TryNormalizeCpe_InvalidInputReturnsFalse()
{
var success = IdentifierNormalizer.TryNormalizeCpe("not-a-cpe", out var normalized);
Assert.False(success);
Assert.Null(normalized);
}
[Fact]
public void TryNormalizeCpe_DecodesPercentEncodingAndEscapes()
{
var input = "cpe:/a:Example%20Corp:Widget%2fSuite:1.0:update:%7e:%2a";
var success = IdentifierNormalizer.TryNormalizeCpe(input, out var normalized);
Assert.True(success);
Assert.Equal(@"cpe:2.3:a:example\ corp:widget\/suite:1.0:update:*:*:*:*:*:*", normalized);
}
[Fact]
public void TryNormalizeCpe_ExpandsEditionFields()
{
var input = "cpe:/a:Vendor:Product:1.0:update:~pro~~windows~~:en-US";
var success = IdentifierNormalizer.TryNormalizeCpe(input, out var normalized);
Assert.True(success);
Assert.Equal("cpe:2.3:a:vendor:product:1.0:update:*:en-us:pro:*:windows:*", normalized);
}
[Fact]
public void TryNormalizeCpe_PreservesEscapedCharactersIn23()
{
var input = @"cpe:2.3:a:example:printer\/:1.2.3:*:*:*:*:*:*:*";
var success = IdentifierNormalizer.TryNormalizeCpe(input, out var normalized);
Assert.True(success);
Assert.Equal(@"cpe:2.3:a:example:printer\/:1.2.3:*:*:*:*:*:*:*", normalized);
}
}

View File

@@ -0,0 +1,52 @@
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Normalization.Cvss;
namespace StellaOps.Concelier.Normalization.Tests;
public sealed class CvssMetricNormalizerTests
{
[Fact]
public void TryNormalize_ComputesCvss31Defaults()
{
var vector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H";
var success = CvssMetricNormalizer.TryNormalize(null, vector, null, null, out var normalized);
Assert.True(success);
Assert.Equal("3.1", normalized.Version);
Assert.Equal(vector, normalized.Vector);
Assert.Equal(9.8, normalized.BaseScore);
Assert.Equal("critical", normalized.BaseSeverity);
var provenance = new AdvisoryProvenance("nvd", "cvss", "https://example", DateTimeOffset.UnixEpoch);
var metric = normalized.ToModel(provenance);
Assert.Equal("3.1", metric.Version);
Assert.Equal(vector, metric.Vector);
Assert.Equal(9.8, metric.BaseScore);
Assert.Equal("critical", metric.BaseSeverity);
Assert.Equal(provenance, metric.Provenance);
}
[Fact]
public void TryNormalize_NormalizesCvss20Severity()
{
var vector = "AV:N/AC:M/Au:S/C:P/I:P/A:P";
var success = CvssMetricNormalizer.TryNormalize("2.0", vector, 6.4, "MEDIUM", out var normalized);
Assert.True(success);
Assert.Equal("2.0", normalized.Version);
Assert.Equal("CVSS:2.0/AV:N/AC:M/AU:S/C:P/I:P/A:P", normalized.Vector);
Assert.Equal(6.0, normalized.BaseScore);
Assert.Equal("medium", normalized.BaseSeverity);
}
[Fact]
public void TryNormalize_ReturnsFalseWhenVectorMissing()
{
var success = CvssMetricNormalizer.TryNormalize("3.1", string.Empty, 9.8, "CRITICAL", out var normalized);
Assert.False(success);
Assert.Equal(default, normalized);
}
}

View File

@@ -0,0 +1,31 @@
using StellaOps.Concelier.Normalization.Distro;
namespace StellaOps.Concelier.Normalization.Tests;
public sealed class DebianEvrParserTests
{
[Fact]
public void ToCanonicalString_RoundTripsExplicitEpoch()
{
var parsed = DebianEvr.Parse(" 1:1.2.3-1 ");
Assert.Equal("1:1.2.3-1", parsed.Original);
Assert.Equal("1:1.2.3-1", parsed.ToCanonicalString());
}
[Fact]
public void ToCanonicalString_SuppressesZeroEpochWhenMissing()
{
var parsed = DebianEvr.Parse("1.2.3-1");
Assert.Equal("1.2.3-1", parsed.ToCanonicalString());
}
[Fact]
public void ToCanonicalString_HandlesMissingRevision()
{
var parsed = DebianEvr.Parse("2:4.5");
Assert.Equal("2:4.5", parsed.ToCanonicalString());
}
}

View File

@@ -0,0 +1,44 @@
using StellaOps.Concelier.Normalization.Text;
namespace StellaOps.Concelier.Normalization.Tests;
public sealed class DescriptionNormalizerTests
{
[Fact]
public void Normalize_RemovesMarkupAndCollapsesWhitespace()
{
var candidates = new[]
{
new LocalizedText("<p>Hello\n\nworld!</p>", "en-US"),
};
var result = DescriptionNormalizer.Normalize(candidates);
Assert.Equal("hello world!", result.Text.ToLowerInvariant());
Assert.Equal("en", result.Language);
}
[Fact]
public void Normalize_FallsBackToPreferredLanguage()
{
var candidates = new[]
{
new LocalizedText("Bonjour", "fr"),
new LocalizedText("Hello", "en-GB"),
};
var result = DescriptionNormalizer.Normalize(candidates);
Assert.Equal("Hello", result.Text);
Assert.Equal("en", result.Language);
}
[Fact]
public void Normalize_ReturnsDefaultWhenEmpty()
{
var result = DescriptionNormalizer.Normalize(Array.Empty<LocalizedText>());
Assert.Equal(string.Empty, result.Text);
Assert.Equal("en", result.Language);
}
}

View File

@@ -0,0 +1,64 @@
using StellaOps.Concelier.Normalization.Distro;
namespace StellaOps.Concelier.Normalization.Tests;
public sealed class NevraParserTests
{
[Fact]
public void ToCanonicalString_RoundTripsTrimmedInput()
{
var parsed = Nevra.Parse(" kernel-0:4.18.0-80.el8.x86_64 ");
Assert.Equal("kernel-0:4.18.0-80.el8.x86_64", parsed.Original);
Assert.Equal("kernel-0:4.18.0-80.el8.x86_64", parsed.ToCanonicalString());
}
[Fact]
public void ToCanonicalString_ReconstructsKnownArchitecture()
{
var parsed = Nevra.Parse("bash-5.2.15-3.el9_4.arm64");
Assert.Equal("bash-5.2.15-3.el9_4.arm64", parsed.ToCanonicalString());
}
[Fact]
public void ToCanonicalString_HandlesMissingArchitecture()
{
var parsed = Nevra.Parse("openssl-libs-1:1.1.1k-7.el8");
Assert.Equal("openssl-libs-1:1.1.1k-7.el8", parsed.ToCanonicalString());
}
[Fact]
public void TryParse_ReturnsTrueForExplicitZeroEpoch()
{
var success = Nevra.TryParse("glibc-0:2.36-8.el9.x86_64", out var nevra);
Assert.True(success);
Assert.NotNull(nevra);
Assert.True(nevra!.HasExplicitEpoch);
Assert.Equal(0, nevra.Epoch);
Assert.Equal("glibc-0:2.36-8.el9.x86_64", nevra.ToCanonicalString());
}
[Fact]
public void TryParse_IgnoresUnknownArchitectureSuffix()
{
var success = Nevra.TryParse("package-1.0-1.el9.weirdarch", out var nevra);
Assert.True(success);
Assert.NotNull(nevra);
Assert.Null(nevra!.Architecture);
Assert.Equal("package-1.0-1.el9.weirdarch", nevra.Original);
Assert.Equal("package-1.0-1.el9.weirdarch", nevra.ToCanonicalString());
}
[Fact]
public void TryParse_ReturnsFalseForMalformedNevra()
{
var success = Nevra.TryParse("bad-format", out var nevra);
Assert.False(success);
Assert.Null(nevra);
}
}

View File

@@ -0,0 +1,44 @@
using System.Linq;
using StellaOps.Concelier.Normalization.Identifiers;
namespace StellaOps.Concelier.Normalization.Tests;
public sealed class PackageUrlNormalizerTests
{
[Fact]
public void TryNormalizePackageUrl_LowersTypeAndNamespace()
{
var input = "pkg:NPM/Acme/Widget@1.0.0?Arch=X86_64";
var success = IdentifierNormalizer.TryNormalizePackageUrl(input, out var normalized, out var parsed);
Assert.True(success);
Assert.Equal("pkg:npm/acme/widget@1.0.0?arch=X86_64", normalized);
Assert.NotNull(parsed);
Assert.Equal("npm", parsed!.Type);
Assert.Equal(new[] { "acme" }, parsed.NamespaceSegments.ToArray());
Assert.Equal("widget", parsed.Name);
}
[Fact]
public void TryNormalizePackageUrl_OrdersQualifiers()
{
var input = "pkg:deb/debian/openssl?distro=x%2Fy&arch=amd64";
var success = IdentifierNormalizer.TryNormalizePackageUrl(input, out var normalized, out _);
Assert.True(success);
Assert.Equal("pkg:deb/debian/openssl?arch=amd64&distro=x%2Fy", normalized);
}
[Fact]
public void TryNormalizePackageUrl_TrimsWhitespace()
{
var input = " pkg:pypi/Example/Package ";
var success = IdentifierNormalizer.TryNormalizePackageUrl(input, out var normalized, out _);
Assert.True(success);
Assert.Equal("pkg:pypi/example/package", normalized);
}
}

View File

@@ -0,0 +1,183 @@
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Normalization.SemVer;
using Xunit;
namespace StellaOps.Concelier.Normalization.Tests;
public sealed class SemVerRangeRuleBuilderTests
{
private const string Note = "spec:test";
[Theory]
[InlineData("< 1.5.0", null, NormalizedVersionRuleTypes.LessThan, null, true, "1.5.0", false, null, false)]
[InlineData(">= 1.0.0, < 2.0.0", null, NormalizedVersionRuleTypes.Range, "1.0.0", true, "2.0.0", false, null, false)]
[InlineData(">1.2.3, <=1.3.0", null, NormalizedVersionRuleTypes.Range, "1.2.3", false, null, false, "1.3.0", true)]
public void Build_ParsesCommonRanges(
string range,
string? patched,
string expectedNormalizedType,
string? expectedIntroduced,
bool expectedIntroducedInclusive,
string? expectedFixed,
bool expectedFixedInclusive,
string? expectedLastAffected,
bool expectedLastInclusive)
{
var results = SemVerRangeRuleBuilder.Build(range, patched, Note);
var result = Assert.Single(results);
var primitive = result.Primitive;
Assert.Equal(expectedIntroduced, primitive.Introduced);
Assert.Equal(expectedIntroducedInclusive, primitive.IntroducedInclusive);
Assert.Equal(expectedFixed, primitive.Fixed);
Assert.Equal(expectedFixedInclusive, primitive.FixedInclusive);
Assert.Equal(expectedLastAffected, primitive.LastAffected);
Assert.Equal(expectedLastInclusive, primitive.LastAffectedInclusive);
var normalized = result.NormalizedRule;
Assert.Equal(NormalizedVersionSchemes.SemVer, normalized.Scheme);
Assert.Equal(expectedNormalizedType, normalized.Type);
Assert.Equal(expectedIntroduced, normalized.Min);
Assert.Equal(expectedIntroduced is null ? (bool?)null : expectedIntroducedInclusive, normalized.MinInclusive);
Assert.Equal(expectedFixed ?? expectedLastAffected, normalized.Max);
Assert.Equal(
expectedFixed is not null ? expectedFixedInclusive : expectedLastInclusive,
normalized.MaxInclusive);
Assert.Equal(patched is null && expectedIntroduced is null && expectedFixed is null && expectedLastAffected is null ? null : Note, normalized.Notes);
}
[Fact]
public void Build_UsesPatchedVersionWhenUpperBoundMissing()
{
var results = SemVerRangeRuleBuilder.Build(">= 4.0.0", "4.3.6", Note);
var result = Assert.Single(results);
Assert.Equal("4.0.0", result.Primitive.Introduced);
Assert.Equal("4.3.6", result.Primitive.Fixed);
Assert.False(result.Primitive.FixedInclusive);
var normalized = result.NormalizedRule;
Assert.Equal(NormalizedVersionRuleTypes.Range, normalized.Type);
Assert.Equal("4.0.0", normalized.Min);
Assert.True(normalized.MinInclusive);
Assert.Equal("4.3.6", normalized.Max);
Assert.False(normalized.MaxInclusive);
Assert.Equal(Note, normalized.Notes);
}
[Theory]
[InlineData("^1.2.3", "1.2.3", "2.0.0")]
[InlineData("~1.2.3", "1.2.3", "1.3.0")]
[InlineData("~> 1.2", "1.2.0", "1.3.0")]
public void Build_HandlesCaretAndTilde(string range, string expectedMin, string expectedMax)
{
var results = SemVerRangeRuleBuilder.Build(range, null, Note);
var result = Assert.Single(results);
var normalized = result.NormalizedRule;
Assert.Equal(expectedMin, normalized.Min);
Assert.True(normalized.MinInclusive);
Assert.Equal(expectedMax, normalized.Max);
Assert.False(normalized.MaxInclusive);
Assert.Equal(NormalizedVersionRuleTypes.Range, normalized.Type);
}
[Theory]
[InlineData("1.2.x", "1.2.0", "1.3.0")]
[InlineData("1.x", "1.0.0", "2.0.0")]
public void Build_HandlesWildcardNotation(string range, string expectedMin, string expectedMax)
{
var results = SemVerRangeRuleBuilder.Build(range, null, Note);
var result = Assert.Single(results);
Assert.Equal(expectedMin, result.Primitive.Introduced);
Assert.Equal(expectedMax, result.Primitive.Fixed);
var normalized = result.NormalizedRule;
Assert.Equal(expectedMin, normalized.Min);
Assert.Equal(expectedMax, normalized.Max);
Assert.Equal(NormalizedVersionRuleTypes.Range, normalized.Type);
}
[Fact]
public void Build_PreservesPreReleaseAndMetadataInExactRule()
{
var results = SemVerRangeRuleBuilder.Build("= 2.5.1-alpha.1+build.7", null, Note);
var result = Assert.Single(results);
Assert.Equal("2.5.1-alpha.1+build.7", result.Primitive.ExactValue);
var normalized = result.NormalizedRule;
Assert.Equal(NormalizedVersionRuleTypes.Exact, normalized.Type);
Assert.Equal("2.5.1-alpha.1+build.7", normalized.Value);
Assert.Equal(Note, normalized.Notes);
}
[Fact]
public void Build_ParsesComparatorWithoutCommaSeparators()
{
var results = SemVerRangeRuleBuilder.Build(">=1.0.0 <1.2.0", null, Note);
var result = Assert.Single(results);
var primitive = result.Primitive;
Assert.Equal("1.0.0", primitive.Introduced);
Assert.True(primitive.IntroducedInclusive);
Assert.Equal("1.2.0", primitive.Fixed);
Assert.False(primitive.FixedInclusive);
Assert.Equal(">= 1.0.0, < 1.2.0", primitive.ConstraintExpression);
var normalized = result.NormalizedRule;
Assert.Equal(NormalizedVersionRuleTypes.Range, normalized.Type);
Assert.Equal("1.0.0", normalized.Min);
Assert.True(normalized.MinInclusive);
Assert.Equal("1.2.0", normalized.Max);
Assert.False(normalized.MaxInclusive);
Assert.Equal(Note, normalized.Notes);
}
[Fact]
public void Build_HandlesMultipleSegmentsSeparatedByOr()
{
var results = SemVerRangeRuleBuilder.Build(">=1.0.0 <1.2.0 || >=2.0.0 <2.2.0", null, Note);
Assert.Equal(2, results.Count);
var first = results[0];
Assert.Equal("1.0.0", first.Primitive.Introduced);
Assert.Equal("1.2.0", first.Primitive.Fixed);
Assert.Equal(NormalizedVersionRuleTypes.Range, first.NormalizedRule.Type);
Assert.Equal("1.0.0", first.NormalizedRule.Min);
Assert.Equal("1.2.0", first.NormalizedRule.Max);
var second = results[1];
Assert.Equal("2.0.0", second.Primitive.Introduced);
Assert.Equal("2.2.0", second.Primitive.Fixed);
Assert.Equal(NormalizedVersionRuleTypes.Range, second.NormalizedRule.Type);
Assert.Equal("2.0.0", second.NormalizedRule.Min);
Assert.Equal("2.2.0", second.NormalizedRule.Max);
foreach (var result in results)
{
Assert.Equal(Note, result.NormalizedRule.Notes);
}
}
[Fact]
public void BuildNormalizedRules_ProjectsNormalizedRules()
{
var rules = SemVerRangeRuleBuilder.BuildNormalizedRules(">=1.0.0 <1.2.0", null, Note);
var rule = Assert.Single(rules);
Assert.Equal(NormalizedVersionSchemes.SemVer, rule.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.Range, rule.Type);
Assert.Equal("1.0.0", rule.Min);
Assert.True(rule.MinInclusive);
Assert.Equal("1.2.0", rule.Max);
Assert.False(rule.MaxInclusive);
Assert.Equal(Note, rule.Notes);
}
[Fact]
public void BuildNormalizedRules_ReturnsEmptyWhenNoRules()
{
var rules = SemVerRangeRuleBuilder.BuildNormalizedRules(" ", null, Note);
Assert.Empty(rules);
}
}

View File

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