tests fixes and sprints work

This commit is contained in:
master
2026-01-22 19:08:46 +02:00
parent c32fff8f86
commit 726d70dc7f
881 changed files with 134434 additions and 6228 deletions

View File

@@ -0,0 +1,26 @@
// -----------------------------------------------------------------------------
// ILicenseExpressionValidator.cs
// Sprint: SPRINT_20260119_015_Concelier_sbom_full_extraction
// Task: TASK-015-007c - SPDX license expression validation
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Concelier.SbomIntegration.Licensing;
public interface ILicenseExpressionValidator
{
LicenseValidationResult Validate(ParsedLicenseExpression expression);
LicenseValidationResult ValidateString(string spdxExpression);
}
public sealed record LicenseValidationResult
{
public bool IsValid { get; init; }
public ImmutableArray<string> Errors { get; init; } = [];
public ImmutableArray<string> Warnings { get; init; } = [];
public ImmutableArray<string> ReferencedLicenses { get; init; } = [];
public ImmutableArray<string> ReferencedExceptions { get; init; } = [];
public ImmutableArray<string> DeprecatedLicenses { get; init; } = [];
public ImmutableArray<string> UnknownLicenses { get; init; } = [];
}

View File

@@ -0,0 +1,518 @@
// -----------------------------------------------------------------------------
// SpdxLicenseExpressionValidator.cs
// Sprint: SPRINT_20260119_015_Concelier_sbom_full_extraction
// Task: TASK-015-007c - SPDX license expression validation
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using StellaOps.Concelier.SbomIntegration.Models;
namespace StellaOps.Concelier.SbomIntegration.Licensing;
public sealed class SpdxLicenseExpressionValidator : ILicenseExpressionValidator
{
private static readonly Lazy<SpdxLicenseCatalog> Catalog = new(LoadCatalog);
public LicenseValidationResult Validate(ParsedLicenseExpression expression)
{
if (expression is null)
{
return LicenseValidationResultBuilder.Invalid("License expression is null.");
}
var context = new LicenseValidationContext();
CollectExpression(expression, context);
return LicenseValidationResultBuilder.FromContext(context);
}
public LicenseValidationResult ValidateString(string spdxExpression)
{
if (string.IsNullOrWhiteSpace(spdxExpression))
{
return LicenseValidationResultBuilder.Invalid("License expression is empty.");
}
var parser = new LicenseExpressionParser(spdxExpression);
if (!parser.TryParse(out var parsed, out var error))
{
return LicenseValidationResultBuilder.Invalid(error ?? "Invalid SPDX license expression.");
}
if (parsed is null)
{
return LicenseValidationResultBuilder.Invalid(error ?? "Invalid SPDX license expression.");
}
return Validate(parsed);
}
private static void CollectExpression(
ParsedLicenseExpression expression,
LicenseValidationContext context)
{
switch (expression)
{
case SimpleLicense simple:
ValidateLicenseId(simple.Id, context);
break;
case OrLater orLater:
ValidateLicenseId(orLater.LicenseId, context);
break;
case WithException withException:
CollectExpression(withException.License, context);
ValidateException(withException.Exception, context);
break;
case ConjunctiveSet conjunctive:
foreach (var member in conjunctive.Members)
{
CollectExpression(member, context);
}
break;
case DisjunctiveSet disjunctive:
foreach (var member in disjunctive.Members)
{
CollectExpression(member, context);
}
break;
}
}
private static void ValidateLicenseId(string licenseId, LicenseValidationContext context)
{
var normalized = NormalizeToken(licenseId);
if (string.IsNullOrWhiteSpace(normalized))
{
context.Errors.Add("License identifier is empty.");
return;
}
context.ReferencedLicenses.Add(normalized);
if (IsSpecialLicense(normalized))
{
return;
}
var catalog = Catalog.Value;
if (IsLicenseRef(normalized))
{
context.UnknownLicenses.Add(normalized);
context.Warnings.Add($"LicenseRef identifier is allowed but not listed: {normalized}");
return;
}
if (!catalog.LicenseIds.Contains(normalized))
{
context.UnknownLicenses.Add(normalized);
context.Errors.Add($"Unknown SPDX license identifier: {normalized}");
return;
}
if (catalog.DeprecatedLicenseIds.Contains(normalized))
{
context.DeprecatedLicenses.Add(normalized);
context.Warnings.Add($"Deprecated SPDX license identifier: {normalized}");
}
}
private static void ValidateException(string exceptionId, LicenseValidationContext context)
{
var normalized = NormalizeToken(exceptionId);
if (string.IsNullOrWhiteSpace(normalized))
{
context.Errors.Add("License exception identifier is empty.");
return;
}
context.ReferencedExceptions.Add(normalized);
var catalog = Catalog.Value;
if (!catalog.ExceptionIds.Contains(normalized))
{
context.Errors.Add($"Unknown SPDX license exception: {normalized}");
}
}
private static string NormalizeToken(string? value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
private static bool IsSpecialLicense(string licenseId)
=> string.Equals(licenseId, "NONE", StringComparison.OrdinalIgnoreCase)
|| string.Equals(licenseId, "NOASSERTION", StringComparison.OrdinalIgnoreCase);
private static bool IsLicenseRef(string licenseId)
=> licenseId.StartsWith("LicenseRef-", StringComparison.Ordinal)
|| licenseId.StartsWith("DocumentRef-", StringComparison.Ordinal);
private static SpdxLicenseCatalog LoadCatalog()
{
var assembly = typeof(SpdxLicenseExpressionValidator).Assembly;
var licenseResource = "StellaOps.Concelier.SbomIntegration.Resources.spdx-license-list-3.21.json";
var exceptionResource = "StellaOps.Concelier.SbomIntegration.Resources.spdx-license-exceptions-3.21.json";
var licenseCatalog = LoadLicenses(assembly, licenseResource);
var exceptionIds = LoadExceptions(assembly, exceptionResource);
return licenseCatalog with
{
ExceptionIds = exceptionIds
};
}
private static SpdxLicenseCatalog LoadLicenses(Assembly assembly, string resourceName)
{
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Missing embedded resource: {resourceName}");
using var document = JsonDocument.Parse(stream);
var version = GetStringProperty(document.RootElement, "licenseListVersion") ?? "unknown";
if (!document.RootElement.TryGetProperty("licenses", out var licenses) ||
licenses.ValueKind != JsonValueKind.Array)
{
return new SpdxLicenseCatalog
{
Version = version,
LicenseIds = ImmutableHashSet<string>.Empty,
DeprecatedLicenseIds = ImmutableHashSet<string>.Empty,
ExceptionIds = ImmutableHashSet<string>.Empty
};
}
var licenseIds = ImmutableHashSet.CreateBuilder<string>(StringComparer.Ordinal);
var deprecated = ImmutableHashSet.CreateBuilder<string>(StringComparer.Ordinal);
foreach (var entry in licenses.EnumerateArray())
{
var id = GetStringProperty(entry, "licenseId");
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
licenseIds.Add(id);
if (GetBooleanProperty(entry, "isDeprecatedLicenseId"))
{
deprecated.Add(id);
}
}
return new SpdxLicenseCatalog
{
Version = version,
LicenseIds = licenseIds.ToImmutable(),
DeprecatedLicenseIds = deprecated.ToImmutable(),
ExceptionIds = ImmutableHashSet<string>.Empty
};
}
private static ImmutableHashSet<string> LoadExceptions(Assembly assembly, string resourceName)
{
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Missing embedded resource: {resourceName}");
using var document = JsonDocument.Parse(stream);
if (!document.RootElement.TryGetProperty("exceptions", out var exceptions) ||
exceptions.ValueKind != JsonValueKind.Array)
{
return ImmutableHashSet<string>.Empty;
}
var exceptionIds = ImmutableHashSet.CreateBuilder<string>(StringComparer.Ordinal);
foreach (var entry in exceptions.EnumerateArray())
{
var id = GetStringProperty(entry, "licenseExceptionId");
if (!string.IsNullOrWhiteSpace(id))
{
exceptionIds.Add(id);
}
}
return exceptionIds.ToImmutable();
}
private static string? GetStringProperty(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var prop) &&
prop.ValueKind == JsonValueKind.String)
{
return prop.GetString();
}
return null;
}
private static bool GetBooleanProperty(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var prop))
{
if (prop.ValueKind == JsonValueKind.True)
{
return true;
}
if (prop.ValueKind == JsonValueKind.False)
{
return false;
}
}
return false;
}
private sealed record SpdxLicenseCatalog
{
public required string Version { get; init; }
public required ImmutableHashSet<string> LicenseIds { get; init; }
public required ImmutableHashSet<string> DeprecatedLicenseIds { get; init; }
public required ImmutableHashSet<string> ExceptionIds { get; init; }
}
private sealed class LicenseValidationContext
{
public HashSet<string> ReferencedLicenses { get; } = new(StringComparer.Ordinal);
public HashSet<string> ReferencedExceptions { get; } = new(StringComparer.Ordinal);
public HashSet<string> DeprecatedLicenses { get; } = new(StringComparer.Ordinal);
public HashSet<string> UnknownLicenses { get; } = new(StringComparer.Ordinal);
public List<string> Errors { get; } = [];
public List<string> Warnings { get; } = [];
}
private static class LicenseValidationResultBuilder
{
public static LicenseValidationResult Invalid(string error)
{
return new LicenseValidationResult
{
IsValid = false,
Errors = [error]
};
}
public static LicenseValidationResult FromContext(LicenseValidationContext context)
{
var errors = ToSortedImmutable(context.Errors);
var warnings = ToSortedImmutable(context.Warnings);
return new LicenseValidationResult
{
IsValid = errors.Length == 0,
Errors = errors,
Warnings = warnings,
ReferencedLicenses = ToSortedImmutable(context.ReferencedLicenses),
ReferencedExceptions = ToSortedImmutable(context.ReferencedExceptions),
DeprecatedLicenses = ToSortedImmutable(context.DeprecatedLicenses),
UnknownLicenses = ToSortedImmutable(context.UnknownLicenses)
};
}
private static ImmutableArray<string> ToSortedImmutable(IEnumerable<string> values)
{
return values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.OrderBy(value => value, StringComparer.Ordinal)
.ToImmutableArray();
}
}
private sealed class LicenseExpressionParser
{
private readonly IReadOnlyList<Token> _tokens;
private int _index;
public LicenseExpressionParser(string expression)
{
_tokens = Tokenize(expression);
}
public bool TryParse(out ParsedLicenseExpression? expression, out string? error)
{
error = null;
expression = null;
try
{
if (_tokens.Count == 0)
{
error = "License expression is empty.";
return false;
}
expression = ParseOr();
if (!IsAtEnd())
{
error = "Unexpected trailing tokens in license expression.";
return false;
}
return true;
}
catch (FormatException ex)
{
error = ex.Message;
return false;
}
}
private ParsedLicenseExpression ParseOr()
{
var members = new List<ParsedLicenseExpression> { ParseAnd() };
while (Match(TokenKind.Or))
{
members.Add(ParseAnd());
}
return members.Count == 1
? members[0]
: new DisjunctiveSet(members.ToImmutableArray());
}
private ParsedLicenseExpression ParseAnd()
{
var members = new List<ParsedLicenseExpression> { ParseWith() };
while (Match(TokenKind.And))
{
members.Add(ParseWith());
}
return members.Count == 1
? members[0]
: new ConjunctiveSet(members.ToImmutableArray());
}
private ParsedLicenseExpression ParseWith()
{
var primary = ParsePrimary();
if (!Match(TokenKind.With))
{
return primary;
}
var exception = Expect(TokenKind.Identifier);
return new WithException(primary, exception.Value);
}
private ParsedLicenseExpression ParsePrimary()
{
if (Match(TokenKind.LeftParen))
{
var inner = ParseOr();
Expect(TokenKind.RightParen);
return inner;
}
var token = Expect(TokenKind.Identifier);
return BuildLicense(token.Value);
}
private static ParsedLicenseExpression BuildLicense(string value)
{
if (value.EndsWith("+", StringComparison.Ordinal) && value.Length > 1)
{
return new OrLater(value[..^1]);
}
return new SimpleLicense(value);
}
private bool Match(TokenKind kind)
{
if (IsAtEnd() || _tokens[_index].Kind != kind)
{
return false;
}
_index++;
return true;
}
private Token Expect(TokenKind kind)
{
if (IsAtEnd() || _tokens[_index].Kind != kind)
{
throw new FormatException("Invalid SPDX license expression.");
}
return _tokens[_index++];
}
private bool IsAtEnd() => _index >= _tokens.Count;
private static IReadOnlyList<Token> Tokenize(string expression)
{
var tokens = new List<Token>();
var span = expression.AsSpan();
var index = 0;
while (index < span.Length)
{
var current = span[index];
if (char.IsWhiteSpace(current))
{
index++;
continue;
}
if (current == '(')
{
tokens.Add(new Token(TokenKind.LeftParen, "("));
index++;
continue;
}
if (current == ')')
{
tokens.Add(new Token(TokenKind.RightParen, ")"));
index++;
continue;
}
var start = index;
while (index < span.Length &&
!char.IsWhiteSpace(span[index]) &&
span[index] != '(' &&
span[index] != ')')
{
index++;
}
var value = span[start..index].ToString();
tokens.Add(ToToken(value));
}
return tokens;
}
private static Token ToToken(string value)
{
if (string.Equals(value, "AND", StringComparison.OrdinalIgnoreCase))
{
return new Token(TokenKind.And, value);
}
if (string.Equals(value, "OR", StringComparison.OrdinalIgnoreCase))
{
return new Token(TokenKind.Or, value);
}
if (string.Equals(value, "WITH", StringComparison.OrdinalIgnoreCase))
{
return new Token(TokenKind.With, value);
}
return new Token(TokenKind.Identifier, value);
}
private readonly record struct Token(TokenKind Kind, string Value);
private enum TokenKind
{
Identifier,
And,
Or,
With,
LeftParen,
RightParen
}
}
}