tests fixes and sprints work
This commit is contained in:
@@ -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; } = [];
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user