audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories

This commit is contained in:
master
2026-01-07 18:49:59 +02:00
parent 04ec098046
commit 608a7f85c0
866 changed files with 56323 additions and 6231 deletions

View File

@@ -0,0 +1,25 @@
# SPDX3 Library Charter
## Mission
- Provide SPDX 3.0.1 parsing, validation, and profile support.
## Responsibilities
- Parse SPDX JSON-LD and surface deterministic models.
- Validate profile conformance and identifiers.
- Resolve contexts with offline-friendly defaults.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/sbom-service/architecture.md
- docs/modules/sbom-service/spdx3-profile-support.md
## Working Agreement
- Deterministic parsing and invariant formatting.
- Use TimeProvider and IGuidGenerator where timestamps or IDs are created.
- Avoid network dependencies unless explicitly enabled.
## Testing Strategy
- Unit tests for parser/validator behavior and error paths.
- Determinism tests for stable ordering and output.

View File

@@ -0,0 +1,142 @@
// <copyright file="ISpdx3Parser.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using StellaOps.Spdx3.Model;
namespace StellaOps.Spdx3;
/// <summary>
/// Interface for SPDX 3.0.1 document parsing.
/// </summary>
public interface ISpdx3Parser
{
/// <summary>
/// Parses an SPDX 3.0.1 document from a stream.
/// </summary>
/// <param name="stream">The input stream containing JSON-LD.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The parse result.</returns>
Task<Spdx3ParseResult> ParseAsync(
Stream stream,
CancellationToken cancellationToken = default);
/// <summary>
/// Parses an SPDX 3.0.1 document from a file path.
/// </summary>
/// <param name="filePath">The path to the JSON-LD file.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The parse result.</returns>
Task<Spdx3ParseResult> ParseAsync(
string filePath,
CancellationToken cancellationToken = default);
/// <summary>
/// Parses an SPDX 3.0.1 document from JSON text.
/// </summary>
/// <param name="json">The JSON-LD text.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The parse result.</returns>
Task<Spdx3ParseResult> ParseFromJsonAsync(
string json,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of parsing an SPDX 3.0.1 document.
/// </summary>
public sealed record Spdx3ParseResult
{
/// <summary>
/// Gets whether parsing was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Gets the parsed document (if successful).
/// </summary>
public Spdx3Document? Document { get; init; }
/// <summary>
/// Gets any parsing errors.
/// </summary>
public IReadOnlyList<Spdx3ParseError> Errors { get; init; } = [];
/// <summary>
/// Gets any parsing warnings.
/// </summary>
public IReadOnlyList<Spdx3ParseWarning> Warnings { get; init; } = [];
/// <summary>
/// Creates a successful parse result.
/// </summary>
/// <param name="document">The parsed document.</param>
/// <param name="warnings">Any warnings.</param>
/// <returns>The result.</returns>
public static Spdx3ParseResult Succeeded(
Spdx3Document document,
IReadOnlyList<Spdx3ParseWarning>? warnings = null)
{
return new Spdx3ParseResult
{
Success = true,
Document = document,
Warnings = warnings ?? []
};
}
/// <summary>
/// Creates a failed parse result.
/// </summary>
/// <param name="errors">The errors.</param>
/// <param name="warnings">Any warnings.</param>
/// <returns>The result.</returns>
public static Spdx3ParseResult Failed(
IReadOnlyList<Spdx3ParseError> errors,
IReadOnlyList<Spdx3ParseWarning>? warnings = null)
{
return new Spdx3ParseResult
{
Success = false,
Errors = errors,
Warnings = warnings ?? []
};
}
/// <summary>
/// Creates a failed parse result from a single error.
/// </summary>
/// <param name="code">The error code.</param>
/// <param name="message">The error message.</param>
/// <param name="path">The JSON path where the error occurred.</param>
/// <returns>The result.</returns>
public static Spdx3ParseResult Failed(
string code,
string message,
string? path = null)
{
return Failed([new Spdx3ParseError(code, message, path)]);
}
}
/// <summary>
/// Represents a parsing error.
/// </summary>
/// <param name="Code">Error code.</param>
/// <param name="Message">Error message.</param>
/// <param name="Path">JSON path where the error occurred.</param>
public sealed record Spdx3ParseError(
string Code,
string Message,
string? Path = null);
/// <summary>
/// Represents a parsing warning.
/// </summary>
/// <param name="Code">Warning code.</param>
/// <param name="Message">Warning message.</param>
/// <param name="Path">JSON path where the warning occurred.</param>
public sealed record Spdx3ParseWarning(
string Code,
string Message,
string? Path = null);

View File

@@ -0,0 +1,247 @@
// <copyright file="Spdx3ContextResolver.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Spdx3.JsonLd;
/// <summary>
/// Resolves JSON-LD contexts for SPDX 3.0.1 documents.
/// </summary>
public interface ISpdx3ContextResolver
{
/// <summary>
/// Resolves a context from a URL or embedded reference.
/// </summary>
/// <param name="contextRef">The context reference (URL or inline).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The resolved context as a JSON element.</returns>
Task<Spdx3Context?> ResolveAsync(
string contextRef,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Represents a resolved JSON-LD context.
/// </summary>
public sealed record Spdx3Context
{
/// <summary>
/// Gets the context URI.
/// </summary>
public required string Uri { get; init; }
/// <summary>
/// Gets the context document.
/// </summary>
public required JsonDocument Document { get; init; }
/// <summary>
/// Gets when the context was resolved.
/// </summary>
public required DateTimeOffset ResolvedAt { get; init; }
}
/// <summary>
/// Options for the SPDX 3 context resolver.
/// </summary>
public sealed class Spdx3ContextResolverOptions
{
/// <summary>
/// Gets or sets the cache TTL for resolved contexts.
/// </summary>
public TimeSpan CacheTtl { get; set; } = TimeSpan.FromHours(24);
/// <summary>
/// Gets or sets the maximum cache size.
/// </summary>
public int MaxCacheSize { get; set; } = 100;
/// <summary>
/// Gets or sets whether to allow remote context resolution.
/// Set to false for air-gapped environments.
/// </summary>
public bool AllowRemoteContexts { get; set; } = true;
/// <summary>
/// Gets or sets the base path for local context files.
/// </summary>
public string? LocalContextPath { get; set; }
/// <summary>
/// Gets or sets the HTTP timeout for remote contexts.
/// </summary>
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(30);
}
/// <summary>
/// Implementation of context resolver with caching.
/// </summary>
public sealed class Spdx3ContextResolver : ISpdx3ContextResolver, IDisposable
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly ILogger<Spdx3ContextResolver> _logger;
private readonly Spdx3ContextResolverOptions _options;
private readonly TimeProvider _timeProvider;
private static readonly Dictionary<string, string> EmbeddedContexts = new(StringComparer.OrdinalIgnoreCase)
{
["https://spdx.org/rdf/3.0.1/spdx-context.jsonld"] = GetEmbeddedContext("spdx-context.jsonld"),
["https://spdx.org/rdf/3.0.1/terms/Core"] = GetEmbeddedContext("core-profile.jsonld"),
["https://spdx.org/rdf/3.0.1/terms/Software"] = GetEmbeddedContext("software-profile.jsonld"),
["https://spdx.org/rdf/3.0.1/terms/Security"] = GetEmbeddedContext("security-profile.jsonld"),
["https://spdx.org/rdf/3.0.1/terms/Build"] = GetEmbeddedContext("build-profile.jsonld"),
["https://spdx.org/rdf/3.0.1/terms/Lite"] = GetEmbeddedContext("lite-profile.jsonld")
};
/// <summary>
/// Initializes a new instance of the <see cref="Spdx3ContextResolver"/> class.
/// </summary>
public Spdx3ContextResolver(
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
ILogger<Spdx3ContextResolver> logger,
IOptions<Spdx3ContextResolverOptions> options,
TimeProvider timeProvider)
{
_httpClientFactory = httpClientFactory;
_cache = cache;
_logger = logger;
_options = options.Value;
_timeProvider = timeProvider;
}
/// <inheritdoc />
public async Task<Spdx3Context?> ResolveAsync(
string contextRef,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(contextRef))
{
return null;
}
// Check cache first
var cacheKey = $"spdx3-context:{contextRef}";
if (_cache.TryGetValue(cacheKey, out Spdx3Context? cached))
{
_logger.LogDebug("Context cache hit for {ContextRef}", contextRef);
return cached;
}
// Try embedded contexts first (for air-gap support)
if (EmbeddedContexts.TryGetValue(contextRef, out var embedded))
{
var context = CreateContext(contextRef, embedded);
CacheContext(cacheKey, context);
return context;
}
// Try local file if configured
if (!string.IsNullOrEmpty(_options.LocalContextPath))
{
var localPath = Path.Combine(_options.LocalContextPath, GetContextFileName(contextRef));
if (File.Exists(localPath))
{
var content = await File.ReadAllTextAsync(localPath, cancellationToken)
.ConfigureAwait(false);
var context = CreateContext(contextRef, content);
CacheContext(cacheKey, context);
return context;
}
}
// Fetch remote if allowed
if (!_options.AllowRemoteContexts)
{
_logger.LogWarning("Remote context resolution disabled, cannot resolve {ContextRef}", contextRef);
return null;
}
return await FetchRemoteContextAsync(contextRef, cacheKey, cancellationToken)
.ConfigureAwait(false);
}
private async Task<Spdx3Context?> FetchRemoteContextAsync(
string contextRef,
string cacheKey,
CancellationToken cancellationToken)
{
try
{
var client = _httpClientFactory.CreateClient("Spdx3Context");
client.Timeout = _options.HttpTimeout;
_logger.LogInformation("Fetching remote context {ContextRef}", contextRef);
var content = await client.GetStringAsync(contextRef, cancellationToken)
.ConfigureAwait(false);
var context = CreateContext(contextRef, content);
CacheContext(cacheKey, context);
return context;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch remote context {ContextRef}", contextRef);
return null;
}
}
private Spdx3Context CreateContext(string uri, string content)
{
return new Spdx3Context
{
Uri = uri,
Document = JsonDocument.Parse(content),
ResolvedAt = _timeProvider.GetUtcNow()
};
}
private void CacheContext(string cacheKey, Spdx3Context context)
{
var cacheOptions = new MemoryCacheEntryOptions
{
Size = 1,
SlidingExpiration = _options.CacheTtl
};
_cache.Set(cacheKey, context, cacheOptions);
}
private static string GetContextFileName(string uri)
{
var lastSlash = uri.LastIndexOf('/');
return lastSlash >= 0 ? uri[(lastSlash + 1)..] : uri;
}
private static string GetEmbeddedContext(string name)
{
// In a real implementation, this would load from embedded resources
// For now, return a minimal stub that allows parsing
return name switch
{
"spdx-context.jsonld" => """
{
"@context": {
"spdx": "https://spdx.org/rdf/3.0.1/terms/",
"Core": "spdx:Core/",
"Software": "spdx:Software/",
"spdxId": "@id",
"@type": "@type"
}
}
""",
_ => "{}"
};
}
/// <inheritdoc />
public void Dispose()
{
// IMemoryCache is typically managed by DI
}
}

View File

@@ -0,0 +1,319 @@
// <copyright file="Spdx3Package.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Spdx3.Model.Software;
/// <summary>
/// Represents an SPDX 3.0.1 Package (Software Profile).
/// </summary>
public sealed record Spdx3Package : Spdx3Element
{
/// <summary>
/// Gets the package version.
/// </summary>
[JsonPropertyName("packageVersion")]
public string? PackageVersion { get; init; }
/// <summary>
/// Gets the download location URL.
/// </summary>
[JsonPropertyName("downloadLocation")]
public string? DownloadLocation { get; init; }
/// <summary>
/// Gets the Package URL (PURL).
/// </summary>
[JsonPropertyName("packageUrl")]
public string? PackageUrl { get; init; }
/// <summary>
/// Gets the home page URL.
/// </summary>
[JsonPropertyName("homePage")]
public string? HomePage { get; init; }
/// <summary>
/// Gets source information.
/// </summary>
[JsonPropertyName("sourceInfo")]
public string? SourceInfo { get; init; }
/// <summary>
/// Gets the primary purpose of the package.
/// </summary>
[JsonPropertyName("primaryPurpose")]
public Spdx3SoftwarePurpose? PrimaryPurpose { get; init; }
/// <summary>
/// Gets additional purposes.
/// </summary>
[JsonPropertyName("additionalPurpose")]
public ImmutableArray<Spdx3SoftwarePurpose> AdditionalPurpose { get; init; } = [];
/// <summary>
/// Gets the copyright text.
/// </summary>
[JsonPropertyName("copyrightText")]
public string? CopyrightText { get; init; }
/// <summary>
/// Gets attribution text.
/// </summary>
[JsonPropertyName("attributionText")]
public ImmutableArray<string> AttributionText { get; init; } = [];
/// <summary>
/// Gets the originator of the package.
/// </summary>
[JsonPropertyName("originatedBy")]
public ImmutableArray<string> OriginatedBy { get; init; } = [];
/// <summary>
/// Gets the supplier of the package.
/// </summary>
[JsonPropertyName("suppliedBy")]
public string? SuppliedBy { get; init; }
/// <summary>
/// Gets the release time of the package.
/// </summary>
[JsonPropertyName("releaseTime")]
public DateTimeOffset? ReleaseTime { get; init; }
/// <summary>
/// Gets the build time of the package.
/// </summary>
[JsonPropertyName("buildTime")]
public DateTimeOffset? BuildTime { get; init; }
/// <summary>
/// Gets the valid until time.
/// </summary>
[JsonPropertyName("validUntilTime")]
public DateTimeOffset? ValidUntilTime { get; init; }
}
/// <summary>
/// Represents an SPDX 3.0.1 File (Software Profile).
/// </summary>
public sealed record Spdx3File : Spdx3Element
{
/// <summary>
/// Gets the file name.
/// </summary>
[JsonPropertyName("name")]
public new required string Name { get; init; }
/// <summary>
/// Gets the primary purpose.
/// </summary>
[JsonPropertyName("primaryPurpose")]
public Spdx3SoftwarePurpose? PrimaryPurpose { get; init; }
/// <summary>
/// Gets the content type (MIME type).
/// </summary>
[JsonPropertyName("contentType")]
public string? ContentType { get; init; }
/// <summary>
/// Gets the copyright text.
/// </summary>
[JsonPropertyName("copyrightText")]
public string? CopyrightText { get; init; }
}
/// <summary>
/// Represents an SPDX 3.0.1 Snippet (Software Profile).
/// </summary>
public sealed record Spdx3Snippet : Spdx3Element
{
/// <summary>
/// Gets the file containing this snippet.
/// </summary>
[JsonPropertyName("snippetFromFile")]
public required string SnippetFromFile { get; init; }
/// <summary>
/// Gets the byte range.
/// </summary>
[JsonPropertyName("byteRange")]
public Spdx3PositiveIntegerRange? ByteRange { get; init; }
/// <summary>
/// Gets the line range.
/// </summary>
[JsonPropertyName("lineRange")]
public Spdx3PositiveIntegerRange? LineRange { get; init; }
/// <summary>
/// Gets the primary purpose.
/// </summary>
[JsonPropertyName("primaryPurpose")]
public Spdx3SoftwarePurpose? PrimaryPurpose { get; init; }
/// <summary>
/// Gets the copyright text.
/// </summary>
[JsonPropertyName("copyrightText")]
public string? CopyrightText { get; init; }
}
/// <summary>
/// Represents a positive integer range.
/// </summary>
public sealed record Spdx3PositiveIntegerRange
{
/// <summary>
/// Gets the begin value.
/// </summary>
[JsonPropertyName("beginIntegerRange")]
public required int Begin { get; init; }
/// <summary>
/// Gets the end value.
/// </summary>
[JsonPropertyName("endIntegerRange")]
public required int End { get; init; }
}
/// <summary>
/// Software purpose types.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Spdx3SoftwarePurpose
{
/// <summary>
/// Application software.
/// </summary>
Application,
/// <summary>
/// Archive (zip, tar, etc.).
/// </summary>
Archive,
/// <summary>
/// BOM (Bill of Materials).
/// </summary>
Bom,
/// <summary>
/// Configuration file.
/// </summary>
Configuration,
/// <summary>
/// Container image.
/// </summary>
Container,
/// <summary>
/// Data file.
/// </summary>
Data,
/// <summary>
/// Device driver.
/// </summary>
Device,
/// <summary>
/// Device driver (alternative).
/// </summary>
DeviceDriver,
/// <summary>
/// Documentation.
/// </summary>
Documentation,
/// <summary>
/// Evidence (compliance).
/// </summary>
Evidence,
/// <summary>
/// Executable.
/// </summary>
Executable,
/// <summary>
/// File.
/// </summary>
File,
/// <summary>
/// Firmware.
/// </summary>
Firmware,
/// <summary>
/// Framework.
/// </summary>
Framework,
/// <summary>
/// Install script.
/// </summary>
Install,
/// <summary>
/// Library.
/// </summary>
Library,
/// <summary>
/// Machine learning model.
/// </summary>
Model,
/// <summary>
/// Module.
/// </summary>
Module,
/// <summary>
/// Operating system.
/// </summary>
OperatingSystem,
/// <summary>
/// Other purpose.
/// </summary>
Other,
/// <summary>
/// Patch.
/// </summary>
Patch,
/// <summary>
/// Platform.
/// </summary>
Platform,
/// <summary>
/// Requirement.
/// </summary>
Requirement,
/// <summary>
/// Source code.
/// </summary>
Source,
/// <summary>
/// Specification.
/// </summary>
Specification,
/// <summary>
/// Test.
/// </summary>
Test
}

View File

@@ -0,0 +1,81 @@
// <copyright file="Spdx3SpdxDocument.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Spdx3.Model.Software;
/// <summary>
/// Represents an SPDX 3.0.1 SpdxDocument (Software Profile).
/// The SpdxDocument is the root element that describes the SBOM itself.
/// </summary>
public sealed record Spdx3SpdxDocument : Spdx3Element
{
/// <summary>
/// Gets the namespace for this document.
/// </summary>
[JsonPropertyName("namespaceMap")]
public ImmutableArray<Spdx3NamespaceMap> NamespaceMap { get; init; } = [];
/// <summary>
/// Gets the elements described by this document.
/// </summary>
[JsonPropertyName("element")]
public ImmutableArray<string> Element { get; init; } = [];
/// <summary>
/// Gets the root elements of this document.
/// </summary>
[JsonPropertyName("rootElement")]
public ImmutableArray<string> RootElement { get; init; } = [];
/// <summary>
/// Gets the imports (external document references).
/// </summary>
[JsonPropertyName("import")]
public ImmutableArray<Spdx3ExternalMap> Import { get; init; } = [];
}
/// <summary>
/// Represents a namespace mapping.
/// </summary>
public sealed record Spdx3NamespaceMap
{
/// <summary>
/// Gets the prefix.
/// </summary>
[JsonPropertyName("prefix")]
public required string Prefix { get; init; }
/// <summary>
/// Gets the namespace URI.
/// </summary>
[JsonPropertyName("namespace")]
public required string Namespace { get; init; }
}
/// <summary>
/// Represents an external map (import).
/// </summary>
public sealed record Spdx3ExternalMap
{
/// <summary>
/// Gets the external SPDX ID.
/// </summary>
[JsonPropertyName("externalSpdxId")]
public required string ExternalSpdxId { get; init; }
/// <summary>
/// Gets the verified using integrity methods.
/// </summary>
[JsonPropertyName("verifiedUsing")]
public ImmutableArray<Spdx3IntegrityMethod> VerifiedUsing { get; init; } = [];
/// <summary>
/// Gets the location hint.
/// </summary>
[JsonPropertyName("locationHint")]
public string? LocationHint { get; init; }
}

View File

@@ -0,0 +1,139 @@
// <copyright file="Spdx3CreationInfo.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Spdx3.Model;
/// <summary>
/// Represents the creation information for SPDX 3.0.1 elements.
/// This captures who created the document, when, and with what tools.
/// </summary>
public sealed record Spdx3CreationInfo
{
/// <summary>
/// Gets the unique identifier for this CreationInfo.
/// </summary>
[JsonPropertyName("@id")]
public string? Id { get; init; }
/// <summary>
/// Gets the type.
/// </summary>
[JsonPropertyName("@type")]
public string? Type { get; init; }
/// <summary>
/// Gets the SPDX specification version.
/// Must be "3.0.1" for SPDX 3.0.1 documents.
/// </summary>
[Required]
[JsonPropertyName("specVersion")]
public required string SpecVersion { get; init; }
/// <summary>
/// Gets the creation timestamp in ISO 8601 format.
/// </summary>
[Required]
[JsonPropertyName("created")]
public required DateTimeOffset Created { get; init; }
/// <summary>
/// Gets the agents (persons/organizations) who created this.
/// </summary>
[JsonPropertyName("createdBy")]
public ImmutableArray<string> CreatedBy { get; init; } = [];
/// <summary>
/// Gets the tools used to create this.
/// </summary>
[JsonPropertyName("createdUsing")]
public ImmutableArray<string> CreatedUsing { get; init; } = [];
/// <summary>
/// Gets the profiles this document conforms to.
/// </summary>
[JsonPropertyName("profile")]
public ImmutableArray<Spdx3ProfileIdentifier> Profile { get; init; } = [];
/// <summary>
/// Gets the data license.
/// Must be CC0-1.0 for SPDX documents.
/// </summary>
[JsonPropertyName("dataLicense")]
public string? DataLicense { get; init; }
/// <summary>
/// Gets an optional comment about the creation.
/// </summary>
[JsonPropertyName("comment")]
public string? Comment { get; init; }
/// <summary>
/// The standard SPDX 3.0.1 spec version string.
/// </summary>
public const string Spdx301Version = "3.0.1";
/// <summary>
/// The required data license for SPDX documents.
/// </summary>
public const string Spdx301DataLicense = "CC0-1.0";
/// <summary>
/// Validates that the spec version is 3.0.1.
/// </summary>
/// <returns>True if valid SPDX 3.0.1 spec version.</returns>
public bool IsValidSpecVersion() =>
string.Equals(SpecVersion, Spdx301Version, StringComparison.Ordinal);
}
/// <summary>
/// Represents an Agent (person, organization, or software agent).
/// </summary>
public sealed record Spdx3Agent : Spdx3Element
{
/// <summary>
/// Gets the agent's name.
/// </summary>
[JsonPropertyName("name")]
public new required string Name { get; init; }
}
/// <summary>
/// Represents a Person agent.
/// </summary>
public sealed record Spdx3Person : Spdx3Element
{
/// <summary>
/// Gets the person's name.
/// </summary>
[JsonPropertyName("name")]
public new required string Name { get; init; }
}
/// <summary>
/// Represents an Organization agent.
/// </summary>
public sealed record Spdx3Organization : Spdx3Element
{
/// <summary>
/// Gets the organization's name.
/// </summary>
[JsonPropertyName("name")]
public new required string Name { get; init; }
}
/// <summary>
/// Represents a Tool agent.
/// </summary>
public sealed record Spdx3Tool : Spdx3Element
{
/// <summary>
/// Gets the tool's name.
/// </summary>
[JsonPropertyName("name")]
public new required string Name { get; init; }
}

View File

@@ -0,0 +1,218 @@
// <copyright file="Spdx3Document.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using StellaOps.Spdx3.Model.Software;
namespace StellaOps.Spdx3.Model;
/// <summary>
/// Represents a parsed SPDX 3.0.1 document containing all elements.
/// </summary>
public sealed class Spdx3Document
{
private readonly Dictionary<string, Spdx3Element> _elementsById;
private readonly Dictionary<string, Spdx3CreationInfo> _creationInfoById;
/// <summary>
/// Initializes a new instance of the <see cref="Spdx3Document"/> class.
/// </summary>
/// <param name="elements">All elements in the document.</param>
/// <param name="creationInfos">All CreationInfo objects.</param>
/// <param name="profiles">Detected profile conformance.</param>
/// <param name="spdxDocument">The root SpdxDocument element if present.</param>
public Spdx3Document(
IEnumerable<Spdx3Element> elements,
IEnumerable<Spdx3CreationInfo> creationInfos,
IEnumerable<Spdx3ProfileIdentifier> profiles,
Spdx3SpdxDocument? spdxDocument = null)
{
var elementList = elements.ToList();
// Use GroupBy to handle duplicates - last element wins for lookup but keeps all for validation
_elementsById = elementList
.GroupBy(e => e.SpdxId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Last(), StringComparer.Ordinal);
_creationInfoById = creationInfos
.Where(c => c.Id != null)
.GroupBy(c => c.Id!, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Last(), StringComparer.Ordinal);
Profiles = profiles.ToImmutableHashSet();
SpdxDocument = spdxDocument;
// Categorize elements by type - use original list to preserve duplicates for counting
Packages = elementList.OfType<Spdx3Package>().ToImmutableArray();
Files = elementList.OfType<Spdx3File>().ToImmutableArray();
Snippets = elementList.OfType<Spdx3Snippet>().ToImmutableArray();
Relationships = elementList.OfType<Spdx3Relationship>().ToImmutableArray();
AllElements = elementList.ToImmutableArray();
}
/// <summary>
/// Gets all elements including duplicates (for validation).
/// </summary>
public ImmutableArray<Spdx3Element> AllElements { get; }
/// <summary>
/// Gets the root SpdxDocument element if present.
/// </summary>
public Spdx3SpdxDocument? SpdxDocument { get; }
/// <summary>
/// Gets all elements in the document.
/// </summary>
public IReadOnlyCollection<Spdx3Element> Elements => _elementsById.Values;
/// <summary>
/// Gets all packages in the document.
/// </summary>
public ImmutableArray<Spdx3Package> Packages { get; }
/// <summary>
/// Gets all files in the document.
/// </summary>
public ImmutableArray<Spdx3File> Files { get; }
/// <summary>
/// Gets all snippets in the document.
/// </summary>
public ImmutableArray<Spdx3Snippet> Snippets { get; }
/// <summary>
/// Gets all relationships in the document.
/// </summary>
public ImmutableArray<Spdx3Relationship> Relationships { get; }
/// <summary>
/// Gets the detected profile conformance.
/// </summary>
public ImmutableHashSet<Spdx3ProfileIdentifier> Profiles { get; }
/// <summary>
/// Gets an element by its SPDX ID.
/// </summary>
/// <param name="spdxId">The SPDX ID.</param>
/// <returns>The element, or null if not found.</returns>
public Spdx3Element? GetById(string spdxId)
{
return _elementsById.TryGetValue(spdxId, out var element) ? element : null;
}
/// <summary>
/// Gets an element by its SPDX ID as a specific type.
/// </summary>
/// <typeparam name="T">The element type.</typeparam>
/// <param name="spdxId">The SPDX ID.</param>
/// <returns>The element, or null if not found or wrong type.</returns>
public T? GetById<T>(string spdxId) where T : Spdx3Element
{
return GetById(spdxId) as T;
}
/// <summary>
/// Gets a CreationInfo by its ID.
/// </summary>
/// <param name="id">The CreationInfo ID.</param>
/// <returns>The CreationInfo, or null if not found.</returns>
public Spdx3CreationInfo? GetCreationInfo(string id)
{
return _creationInfoById.TryGetValue(id, out var info) ? info : null;
}
/// <summary>
/// Gets relationships where the given element is the source.
/// </summary>
/// <param name="spdxId">The source element ID.</param>
/// <returns>Matching relationships.</returns>
public IEnumerable<Spdx3Relationship> GetRelationshipsFrom(string spdxId)
{
return Relationships.Where(r => r.From == spdxId);
}
/// <summary>
/// Gets relationships where the given element is a target.
/// </summary>
/// <param name="spdxId">The target element ID.</param>
/// <returns>Matching relationships.</returns>
public IEnumerable<Spdx3Relationship> GetRelationshipsTo(string spdxId)
{
return Relationships.Where(r => r.To.Contains(spdxId));
}
/// <summary>
/// Gets direct dependencies of a package.
/// </summary>
/// <param name="packageId">The package SPDX ID.</param>
/// <returns>Dependent packages.</returns>
public IEnumerable<Spdx3Package> GetDependencies(string packageId)
{
return GetRelationshipsFrom(packageId)
.Where(r => r.RelationshipType == Spdx3RelationshipType.DependsOn)
.SelectMany(r => r.To)
.Select(GetById<Spdx3Package>)
.Where(p => p != null)
.Cast<Spdx3Package>();
}
/// <summary>
/// Gets all files contained in a package.
/// </summary>
/// <param name="packageId">The package SPDX ID.</param>
/// <returns>Contained files.</returns>
public IEnumerable<Spdx3File> GetContainedFiles(string packageId)
{
return GetRelationshipsFrom(packageId)
.Where(r => r.RelationshipType == Spdx3RelationshipType.Contains)
.SelectMany(r => r.To)
.Select(GetById<Spdx3File>)
.Where(f => f != null)
.Cast<Spdx3File>();
}
/// <summary>
/// Checks if the document conforms to a specific profile.
/// </summary>
/// <param name="profile">The profile to check.</param>
/// <returns>True if the document conforms.</returns>
public bool ConformsTo(Spdx3ProfileIdentifier profile)
{
return Profiles.Contains(profile);
}
/// <summary>
/// Gets all PURLs from packages in the document.
/// </summary>
/// <returns>Package URLs.</returns>
public IEnumerable<string> GetAllPurls()
{
return Packages
.SelectMany(p => p.ExternalIdentifier)
.Where(i => i.ExternalIdentifierType == Spdx3ExternalIdentifierType.PackageUrl)
.Select(i => i.Identifier)
.Distinct(StringComparer.Ordinal);
}
/// <summary>
/// Gets the root package (if any).
/// </summary>
/// <returns>The root package, or null.</returns>
public Spdx3Package? GetRootPackage()
{
if (SpdxDocument?.RootElement.Length > 0)
{
var rootId = SpdxDocument.RootElement[0];
return GetById<Spdx3Package>(rootId);
}
// Fallback: find package with no incoming Contains relationships
var containedIds = Relationships
.Where(r => r.RelationshipType == Spdx3RelationshipType.Contains)
.SelectMany(r => r.To)
.ToHashSet(StringComparer.Ordinal);
return Packages.FirstOrDefault(p => !containedIds.Contains(p.SpdxId));
}
}

View File

@@ -0,0 +1,113 @@
// <copyright file="Spdx3Element.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Spdx3.Model;
/// <summary>
/// Base class for all SPDX 3.0.1 elements.
/// Every element in SPDX 3.0 derives from this abstract type.
/// </summary>
public abstract record Spdx3Element
{
/// <summary>
/// Gets the unique IRI identifier for this element.
/// </summary>
/// <remarks>
/// This must be globally unique and serves as the element's identity.
/// Format: URN, URL, or document-scoped ID with #fragment.
/// </remarks>
[Required]
[JsonPropertyName("spdxId")]
public required string SpdxId { get; init; }
/// <summary>
/// Gets the element type from JSON-LD.
/// </summary>
[JsonPropertyName("@type")]
public string? Type { get; init; }
/// <summary>
/// Gets the reference to creation information.
/// Used when CreationInfo is shared across elements.
/// </summary>
[JsonPropertyName("creationInfo")]
public string? CreationInfoRef { get; init; }
/// <summary>
/// Gets the inline creation information.
/// Used when CreationInfo is specific to this element.
/// </summary>
[JsonIgnore]
public Spdx3CreationInfo? CreationInfo { get; init; }
/// <summary>
/// Gets the human-readable name.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
/// <summary>
/// Gets a brief summary (short description).
/// </summary>
[JsonPropertyName("summary")]
public string? Summary { get; init; }
/// <summary>
/// Gets a detailed description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; init; }
/// <summary>
/// Gets an optional comment about this element.
/// </summary>
[JsonPropertyName("comment")]
public string? Comment { get; init; }
/// <summary>
/// Gets integrity verification methods (hashes, signatures).
/// </summary>
[JsonPropertyName("verifiedUsing")]
public ImmutableArray<Spdx3IntegrityMethod> VerifiedUsing { get; init; } = [];
/// <summary>
/// Gets external references (security advisories, etc.).
/// </summary>
[JsonPropertyName("externalRef")]
public ImmutableArray<Spdx3ExternalRef> ExternalRef { get; init; } = [];
/// <summary>
/// Gets external identifiers (PURL, CPE, SWID, etc.).
/// </summary>
[JsonPropertyName("externalIdentifier")]
public ImmutableArray<Spdx3ExternalIdentifier> ExternalIdentifier { get; init; } = [];
/// <summary>
/// Gets profile-specific extensions.
/// </summary>
[JsonPropertyName("extension")]
public ImmutableArray<Spdx3Extension> Extension { get; init; } = [];
}
/// <summary>
/// Represents a profile-specific extension.
/// </summary>
public sealed record Spdx3Extension
{
/// <summary>
/// Gets the extension type.
/// </summary>
[JsonPropertyName("@type")]
public string? Type { get; init; }
/// <summary>
/// Gets the extension data as raw JSON.
/// </summary>
[JsonExtensionData]
public Dictionary<string, object?>? ExtensionData { get; init; }
}

View File

@@ -0,0 +1,148 @@
// <copyright file="Spdx3ExternalIdentifier.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Text.Json.Serialization;
namespace StellaOps.Spdx3.Model;
/// <summary>
/// Represents an external identifier in SPDX 3.0.1.
/// Used for PURL, CPE, SWID, and other identifiers.
/// </summary>
public sealed record Spdx3ExternalIdentifier
{
/// <summary>
/// Gets the type.
/// </summary>
[JsonPropertyName("@type")]
public string? Type { get; init; }
/// <summary>
/// Gets the identifier type.
/// </summary>
[JsonPropertyName("externalIdentifierType")]
public Spdx3ExternalIdentifierType? ExternalIdentifierType { get; init; }
/// <summary>
/// Gets the identifier value.
/// </summary>
[JsonPropertyName("identifier")]
public required string Identifier { get; init; }
/// <summary>
/// Gets an optional comment.
/// </summary>
[JsonPropertyName("comment")]
public string? Comment { get; init; }
/// <summary>
/// Gets the identifying organization.
/// </summary>
[JsonPropertyName("issuingAuthority")]
public string? IssuingAuthority { get; init; }
}
/// <summary>
/// External identifier types in SPDX 3.0.1.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Spdx3ExternalIdentifierType
{
/// <summary>
/// CPE 2.2 identifier.
/// </summary>
Cpe22,
/// <summary>
/// CPE 2.3 identifier.
/// </summary>
Cpe23,
/// <summary>
/// CVE identifier.
/// </summary>
Cve,
/// <summary>
/// Email identifier.
/// </summary>
Email,
/// <summary>
/// Git object identifier (SHA).
/// </summary>
GitOid,
/// <summary>
/// Other identifier type.
/// </summary>
Other,
/// <summary>
/// Package URL (PURL).
/// </summary>
PackageUrl,
/// <summary>
/// Security advisory identifier.
/// </summary>
SecurityOther,
/// <summary>
/// SWHID (Software Heritage ID).
/// </summary>
Swhid,
/// <summary>
/// SWID tag identifier.
/// </summary>
Swid,
/// <summary>
/// URL identifier.
/// </summary>
UrlScheme
}
/// <summary>
/// Extension methods for external identifiers.
/// </summary>
public static class Spdx3ExternalIdentifierExtensions
{
/// <summary>
/// Extracts PURL from external identifiers.
/// </summary>
/// <param name="identifiers">The identifiers to search.</param>
/// <returns>The PURL if found, otherwise null.</returns>
public static string? GetPurl(this IEnumerable<Spdx3ExternalIdentifier> identifiers)
{
return identifiers
.FirstOrDefault(i => i.ExternalIdentifierType == Spdx3ExternalIdentifierType.PackageUrl)
?.Identifier;
}
/// <summary>
/// Extracts CPE 2.3 from external identifiers.
/// </summary>
/// <param name="identifiers">The identifiers to search.</param>
/// <returns>The CPE 2.3 if found, otherwise null.</returns>
public static string? GetCpe23(this IEnumerable<Spdx3ExternalIdentifier> identifiers)
{
return identifiers
.FirstOrDefault(i => i.ExternalIdentifierType == Spdx3ExternalIdentifierType.Cpe23)
?.Identifier;
}
/// <summary>
/// Extracts SWHID from external identifiers.
/// </summary>
/// <param name="identifiers">The identifiers to search.</param>
/// <returns>The SWHID if found, otherwise null.</returns>
public static string? GetSwhid(this IEnumerable<Spdx3ExternalIdentifier> identifiers)
{
return identifiers
.FirstOrDefault(i => i.ExternalIdentifierType == Spdx3ExternalIdentifierType.Swhid)
?.Identifier;
}
}

View File

@@ -0,0 +1,291 @@
// <copyright file="Spdx3ExternalRef.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Spdx3.Model;
/// <summary>
/// Represents an external reference in SPDX 3.0.1.
/// </summary>
public sealed record Spdx3ExternalRef
{
/// <summary>
/// Gets the type.
/// </summary>
[JsonPropertyName("@type")]
public string? Type { get; init; }
/// <summary>
/// Gets the external reference type.
/// </summary>
[JsonPropertyName("externalRefType")]
public Spdx3ExternalRefType? ExternalRefType { get; init; }
/// <summary>
/// Gets the locator (URI or identifier).
/// </summary>
[JsonPropertyName("locator")]
public ImmutableArray<string> Locator { get; init; } = [];
/// <summary>
/// Gets the content type (MIME type).
/// </summary>
[JsonPropertyName("contentType")]
public string? ContentType { get; init; }
/// <summary>
/// Gets an optional comment.
/// </summary>
[JsonPropertyName("comment")]
public string? Comment { get; init; }
}
/// <summary>
/// External reference types in SPDX 3.0.1.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Spdx3ExternalRefType
{
/// <summary>
/// Alternate web page.
/// </summary>
AltWebPage,
/// <summary>
/// Alternate download location.
/// </summary>
AltDownloadLocation,
/// <summary>
/// Binary artifact.
/// </summary>
BinaryArtifact,
/// <summary>
/// Bower package reference.
/// </summary>
Bower,
/// <summary>
/// Build metadata.
/// </summary>
BuildMeta,
/// <summary>
/// Build system reference.
/// </summary>
BuildSystem,
/// <summary>
/// Certification reference.
/// </summary>
Certification,
/// <summary>
/// Chat reference.
/// </summary>
Chat,
/// <summary>
/// Component analysis report.
/// </summary>
ComponentAnalysisReport,
/// <summary>
/// CPE 2.2 identifier.
/// </summary>
Cpe22Type,
/// <summary>
/// CPE 2.3 identifier.
/// </summary>
Cpe23Type,
/// <summary>
/// CWE reference.
/// </summary>
Cwe,
/// <summary>
/// Documentation reference.
/// </summary>
Documentation,
/// <summary>
/// Dynamic analysis report.
/// </summary>
DynamicAnalysisReport,
/// <summary>
/// End of life information.
/// </summary>
EolNotice,
/// <summary>
/// Export control classification.
/// </summary>
ExportControlClassification,
/// <summary>
/// Funding reference.
/// </summary>
Funding,
/// <summary>
/// Issue tracker.
/// </summary>
IssueTracker,
/// <summary>
/// License reference.
/// </summary>
License,
/// <summary>
/// Mailing list reference.
/// </summary>
MailingList,
/// <summary>
/// Maven Central reference.
/// </summary>
MavenCentral,
/// <summary>
/// Metrics reference.
/// </summary>
Metrics,
/// <summary>
/// NPM package reference.
/// </summary>
Npm,
/// <summary>
/// NuGet package reference.
/// </summary>
Nuget,
/// <summary>
/// Other reference type.
/// </summary>
Other,
/// <summary>
/// Privacy assessment reference.
/// </summary>
PrivacyAssessment,
/// <summary>
/// Product metadata reference.
/// </summary>
ProductMetadata,
/// <summary>
/// Purchase order reference.
/// </summary>
PurchaseOrder,
/// <summary>
/// Quality assessment report.
/// </summary>
QualityAssessmentReport,
/// <summary>
/// Release history reference.
/// </summary>
ReleaseHistory,
/// <summary>
/// Release notes reference.
/// </summary>
ReleaseNotes,
/// <summary>
/// Risk assessment reference.
/// </summary>
RiskAssessment,
/// <summary>
/// Runtime analysis report.
/// </summary>
RuntimeAnalysisReport,
/// <summary>
/// Secure software development attestation.
/// </summary>
SecureSoftwareAttestation,
/// <summary>
/// Security adversary model.
/// </summary>
SecurityAdversaryModel,
/// <summary>
/// Security advisory.
/// </summary>
SecurityAdvisory,
/// <summary>
/// Security fix reference.
/// </summary>
SecurityFix,
/// <summary>
/// Security other reference.
/// </summary>
SecurityOther,
/// <summary>
/// Security penetration test.
/// </summary>
SecurityPenTestReport,
/// <summary>
/// Security policy.
/// </summary>
SecurityPolicy,
/// <summary>
/// Security threat model.
/// </summary>
SecurityThreatModel,
/// <summary>
/// Social media reference.
/// </summary>
SocialMedia,
/// <summary>
/// Source artifact.
/// </summary>
SourceArtifact,
/// <summary>
/// Static analysis report.
/// </summary>
StaticAnalysisReport,
/// <summary>
/// Support reference.
/// </summary>
Support,
/// <summary>
/// VCS (version control system) reference.
/// </summary>
Vcs,
/// <summary>
/// Vulnerability disclosure report.
/// </summary>
VulnerabilityDisclosureReport,
/// <summary>
/// Vulnerability exploitability assessment.
/// </summary>
VulnerabilityExploitabilityAssessment
}

View File

@@ -0,0 +1,225 @@
// <copyright file="Spdx3IntegrityMethod.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Globalization;
using System.Text.Json.Serialization;
namespace StellaOps.Spdx3.Model;
/// <summary>
/// Base class for integrity verification methods in SPDX 3.0.1.
/// </summary>
public abstract record Spdx3IntegrityMethod
{
/// <summary>
/// Gets the type.
/// </summary>
[JsonPropertyName("@type")]
public string? Type { get; init; }
/// <summary>
/// Gets an optional comment.
/// </summary>
[JsonPropertyName("comment")]
public string? Comment { get; init; }
}
/// <summary>
/// Represents a hash integrity method.
/// </summary>
public sealed record Spdx3Hash : Spdx3IntegrityMethod
{
/// <summary>
/// Gets the hash algorithm.
/// </summary>
[JsonPropertyName("algorithm")]
public required Spdx3HashAlgorithm Algorithm { get; init; }
/// <summary>
/// Gets the hash value (lowercase hex).
/// </summary>
[JsonPropertyName("hashValue")]
public required string HashValue { get; init; }
/// <summary>
/// Gets the normalized hash value (lowercase).
/// </summary>
[JsonIgnore]
public string NormalizedHashValue => HashValue.ToLowerInvariant();
/// <summary>
/// Validates that the hash value is valid hex.
/// </summary>
/// <returns>True if valid hex.</returns>
public bool IsValidHex()
{
return HashValue.All(c => char.IsAsciiHexDigit(c));
}
/// <summary>
/// Gets the expected hash length for the algorithm.
/// </summary>
/// <returns>Expected length in hex characters.</returns>
public int GetExpectedLength() => Algorithm switch
{
Spdx3HashAlgorithm.Sha256 => 64,
Spdx3HashAlgorithm.Sha384 => 96,
Spdx3HashAlgorithm.Sha512 => 128,
Spdx3HashAlgorithm.Sha3_256 => 64,
Spdx3HashAlgorithm.Sha3_384 => 96,
Spdx3HashAlgorithm.Sha3_512 => 128,
Spdx3HashAlgorithm.Blake2b256 => 64,
Spdx3HashAlgorithm.Blake2b384 => 96,
Spdx3HashAlgorithm.Blake2b512 => 128,
Spdx3HashAlgorithm.Blake3 => 64,
Spdx3HashAlgorithm.Md5 => 32,
Spdx3HashAlgorithm.Sha1 => 40,
Spdx3HashAlgorithm.Adler32 => 8,
_ => -1
};
/// <summary>
/// Validates the hash length matches the algorithm.
/// </summary>
/// <returns>True if valid length.</returns>
public bool IsValidLength()
{
var expected = GetExpectedLength();
return expected == -1 || HashValue.Length == expected;
}
}
/// <summary>
/// Hash algorithms supported by SPDX 3.0.1.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Spdx3HashAlgorithm
{
/// <summary>
/// Adler-32 checksum.
/// </summary>
Adler32,
/// <summary>
/// BLAKE2b-256.
/// </summary>
Blake2b256,
/// <summary>
/// BLAKE2b-384.
/// </summary>
Blake2b384,
/// <summary>
/// BLAKE2b-512.
/// </summary>
Blake2b512,
/// <summary>
/// BLAKE3.
/// </summary>
Blake3,
/// <summary>
/// MD2 (deprecated).
/// </summary>
Md2,
/// <summary>
/// MD4 (deprecated).
/// </summary>
Md4,
/// <summary>
/// MD5 (deprecated).
/// </summary>
Md5,
/// <summary>
/// MD6.
/// </summary>
Md6,
/// <summary>
/// SHA-1 (deprecated).
/// </summary>
Sha1,
/// <summary>
/// SHA-224.
/// </summary>
Sha224,
/// <summary>
/// SHA-256 (recommended).
/// </summary>
Sha256,
/// <summary>
/// SHA-384.
/// </summary>
Sha384,
/// <summary>
/// SHA-512 (recommended).
/// </summary>
Sha512,
/// <summary>
/// SHA3-224.
/// </summary>
Sha3_224,
/// <summary>
/// SHA3-256 (recommended).
/// </summary>
Sha3_256,
/// <summary>
/// SHA3-384.
/// </summary>
Sha3_384,
/// <summary>
/// SHA3-512 (recommended).
/// </summary>
Sha3_512
}
/// <summary>
/// Extension methods for hash algorithms.
/// </summary>
public static class Spdx3HashAlgorithmExtensions
{
/// <summary>
/// Gets whether this algorithm is recommended for use.
/// </summary>
/// <param name="algorithm">The algorithm.</param>
/// <returns>True if recommended.</returns>
public static bool IsRecommended(this Spdx3HashAlgorithm algorithm) => algorithm switch
{
Spdx3HashAlgorithm.Sha256 => true,
Spdx3HashAlgorithm.Sha512 => true,
Spdx3HashAlgorithm.Sha3_256 => true,
Spdx3HashAlgorithm.Sha3_512 => true,
Spdx3HashAlgorithm.Blake2b256 => true,
Spdx3HashAlgorithm.Blake2b512 => true,
Spdx3HashAlgorithm.Blake3 => true,
_ => false
};
/// <summary>
/// Gets whether this algorithm is deprecated.
/// </summary>
/// <param name="algorithm">The algorithm.</param>
/// <returns>True if deprecated.</returns>
public static bool IsDeprecated(this Spdx3HashAlgorithm algorithm) => algorithm switch
{
Spdx3HashAlgorithm.Md2 => true,
Spdx3HashAlgorithm.Md4 => true,
Spdx3HashAlgorithm.Md5 => true,
Spdx3HashAlgorithm.Sha1 => true,
_ => false
};
}

View File

@@ -0,0 +1,179 @@
// <copyright file="Spdx3ProfileIdentifier.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Text.Json.Serialization;
namespace StellaOps.Spdx3.Model;
/// <summary>
/// SPDX 3.0.1 profile identifiers.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Spdx3ProfileIdentifier
{
/// <summary>
/// Core profile (required for all documents).
/// </summary>
Core,
/// <summary>
/// Software profile (packages, files, snippets).
/// </summary>
Software,
/// <summary>
/// Security profile (vulnerabilities, VEX).
/// </summary>
Security,
/// <summary>
/// Licensing profile (license expressions).
/// </summary>
Licensing,
/// <summary>
/// Build profile (build metadata, attestation).
/// </summary>
Build,
/// <summary>
/// AI profile (ML models, datasets).
/// </summary>
AI,
/// <summary>
/// Dataset profile (data catalogs).
/// </summary>
Dataset,
/// <summary>
/// Lite profile (minimal SBOM).
/// </summary>
Lite,
/// <summary>
/// Extension profile (custom extensions).
/// </summary>
Extension
}
/// <summary>
/// Profile URI constants for SPDX 3.0.1.
/// </summary>
public static class Spdx3ProfileUris
{
/// <summary>
/// Base URI for SPDX 3.0.1 profiles.
/// </summary>
public const string BaseUri = "https://spdx.org/rdf/3.0.1/terms/";
/// <summary>
/// Core profile URI.
/// </summary>
public const string Core = "https://spdx.org/rdf/3.0.1/terms/Core";
/// <summary>
/// Software profile URI.
/// </summary>
public const string Software = "https://spdx.org/rdf/3.0.1/terms/Software";
/// <summary>
/// Security profile URI.
/// </summary>
public const string Security = "https://spdx.org/rdf/3.0.1/terms/Security";
/// <summary>
/// Licensing profile URI.
/// </summary>
public const string Licensing = "https://spdx.org/rdf/3.0.1/terms/Licensing";
/// <summary>
/// Build profile URI.
/// </summary>
public const string Build = "https://spdx.org/rdf/3.0.1/terms/Build";
/// <summary>
/// AI profile URI.
/// </summary>
public const string AI = "https://spdx.org/rdf/3.0.1/terms/AI";
/// <summary>
/// Dataset profile URI.
/// </summary>
public const string Dataset = "https://spdx.org/rdf/3.0.1/terms/Dataset";
/// <summary>
/// Lite profile URI.
/// </summary>
public const string Lite = "https://spdx.org/rdf/3.0.1/terms/Lite";
/// <summary>
/// Extension profile URI.
/// </summary>
public const string Extension = "https://spdx.org/rdf/3.0.1/terms/Extension";
/// <summary>
/// Gets the URI for a profile identifier.
/// </summary>
/// <param name="profile">The profile identifier.</param>
/// <returns>The profile URI.</returns>
public static string GetUri(Spdx3ProfileIdentifier profile) => profile switch
{
Spdx3ProfileIdentifier.Core => Core,
Spdx3ProfileIdentifier.Software => Software,
Spdx3ProfileIdentifier.Security => Security,
Spdx3ProfileIdentifier.Licensing => Licensing,
Spdx3ProfileIdentifier.Build => Build,
Spdx3ProfileIdentifier.AI => AI,
Spdx3ProfileIdentifier.Dataset => Dataset,
Spdx3ProfileIdentifier.Lite => Lite,
Spdx3ProfileIdentifier.Extension => Extension,
_ => throw new ArgumentOutOfRangeException(nameof(profile), profile, null)
};
/// <summary>
/// Parses a profile URI to a profile identifier.
/// </summary>
/// <param name="uri">The URI to parse.</param>
/// <returns>The profile identifier, or null if not recognized.</returns>
public static Spdx3ProfileIdentifier? ParseUri(string uri)
{
return uri switch
{
Core => Spdx3ProfileIdentifier.Core,
Software => Spdx3ProfileIdentifier.Software,
Security => Spdx3ProfileIdentifier.Security,
Licensing => Spdx3ProfileIdentifier.Licensing,
Build => Spdx3ProfileIdentifier.Build,
AI => Spdx3ProfileIdentifier.AI,
Dataset => Spdx3ProfileIdentifier.Dataset,
Lite => Spdx3ProfileIdentifier.Lite,
Extension => Spdx3ProfileIdentifier.Extension,
_ => null
};
}
/// <summary>
/// Parses a profile string (name or URI) to a profile identifier.
/// </summary>
/// <param name="value">The value to parse.</param>
/// <returns>The profile identifier, or null if not recognized.</returns>
public static Spdx3ProfileIdentifier? Parse(string value)
{
// Try as URI first
var fromUri = ParseUri(value);
if (fromUri.HasValue)
{
return fromUri;
}
// Try as enum name
if (Enum.TryParse<Spdx3ProfileIdentifier>(value, ignoreCase: true, out var result))
{
return result;
}
return null;
}
}

View File

@@ -0,0 +1,263 @@
// <copyright file="Spdx3Relationship.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Spdx3.Model;
/// <summary>
/// Represents a relationship between SPDX 3.0.1 elements.
/// </summary>
public sealed record Spdx3Relationship : Spdx3Element
{
/// <summary>
/// Gets the source element of the relationship.
/// </summary>
[Required]
[JsonPropertyName("from")]
public required string From { get; init; }
/// <summary>
/// Gets the target element(s) of the relationship.
/// </summary>
[Required]
[JsonPropertyName("to")]
public required ImmutableArray<string> To { get; init; }
/// <summary>
/// Gets the type of relationship.
/// </summary>
[Required]
[JsonPropertyName("relationshipType")]
public required Spdx3RelationshipType RelationshipType { get; init; }
/// <summary>
/// Gets the completeness of the relationship.
/// </summary>
[JsonPropertyName("completeness")]
public Spdx3RelationshipCompleteness? Completeness { get; init; }
/// <summary>
/// Gets the start time of the relationship (temporal scope).
/// </summary>
[JsonPropertyName("startTime")]
public DateTimeOffset? StartTime { get; init; }
/// <summary>
/// Gets the end time of the relationship (temporal scope).
/// </summary>
[JsonPropertyName("endTime")]
public DateTimeOffset? EndTime { get; init; }
}
/// <summary>
/// SPDX 3.0.1 relationship types.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Spdx3RelationshipType
{
/// <summary>
/// Element A contains Element B.
/// </summary>
Contains,
/// <summary>
/// Element A is contained by Element B.
/// </summary>
ContainedBy,
/// <summary>
/// Element A depends on Element B.
/// </summary>
DependsOn,
/// <summary>
/// Element A is a dependency of Element B.
/// </summary>
DependencyOf,
/// <summary>
/// Element A is a build tool of Element B.
/// </summary>
BuildToolOf,
/// <summary>
/// Element A is a dev tool of Element B.
/// </summary>
DevToolOf,
/// <summary>
/// Element A is a test tool of Element B.
/// </summary>
TestToolOf,
/// <summary>
/// Element A is documentation of Element B.
/// </summary>
DocumentationOf,
/// <summary>
/// Element A is an optional component of Element B.
/// </summary>
OptionalComponentOf,
/// <summary>
/// Element A is a provided dependency of Element B.
/// </summary>
ProvidedDependencyOf,
/// <summary>
/// Element A is a test of Element B.
/// </summary>
TestOf,
/// <summary>
/// Element A is a test case of Element B.
/// </summary>
TestCaseOf,
/// <summary>
/// Element A is a copy of Element B.
/// </summary>
CopyOf,
/// <summary>
/// Element A is a file added to Element B.
/// </summary>
FileAddedTo,
/// <summary>
/// Element A is a file deleted from Element B.
/// </summary>
FileDeletedFrom,
/// <summary>
/// Element A is a file modified in Element B.
/// </summary>
FileModified,
/// <summary>
/// Element A was expanded from archive Element B.
/// </summary>
ExpandedFromArchive,
/// <summary>
/// Element A dynamically links to Element B.
/// </summary>
DynamicLink,
/// <summary>
/// Element A statically links to Element B.
/// </summary>
StaticLink,
/// <summary>
/// Element A is a data file of Element B.
/// </summary>
DataFileOf,
/// <summary>
/// Element A was generated from Element B.
/// </summary>
GeneratedFrom,
/// <summary>
/// Element A generates Element B.
/// </summary>
Generates,
/// <summary>
/// Element A is an ancestor of Element B.
/// </summary>
AncestorOf,
/// <summary>
/// Element A is a descendant of Element B.
/// </summary>
DescendantOf,
/// <summary>
/// Element A is a variant of Element B.
/// </summary>
VariantOf,
/// <summary>
/// Element A is a distribution artifact of Element B.
/// </summary>
DistributionArtifact,
/// <summary>
/// Element A is a patch for Element B.
/// </summary>
PatchFor,
/// <summary>
/// Element A is a requirement for Element B.
/// </summary>
RequirementFor,
/// <summary>
/// Element A is a specification for Element B.
/// </summary>
SpecificationFor,
/// <summary>
/// Element A is amended by Element B.
/// </summary>
AmendedBy,
/// <summary>
/// Element A describes Element B.
/// </summary>
Describes,
/// <summary>
/// Element A is described by Element B.
/// </summary>
DescribedBy,
/// <summary>
/// Element A has a prerequisite Element B.
/// </summary>
HasPrerequisite,
/// <summary>
/// Element A is a prerequisite of Element B.
/// </summary>
PrerequisiteFor,
/// <summary>
/// Element A has evidence of Element B.
/// </summary>
HasEvidence,
/// <summary>
/// Other relationship type (requires comment).
/// </summary>
Other
}
/// <summary>
/// Completeness of a relationship.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Spdx3RelationshipCompleteness
{
/// <summary>
/// The relationship is complete.
/// </summary>
Complete,
/// <summary>
/// The relationship is incomplete.
/// </summary>
Incomplete,
/// <summary>
/// No assertion about completeness.
/// </summary>
NoAssertion
}

View File

@@ -0,0 +1,587 @@
// <copyright file="Spdx3Parser.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Spdx3.JsonLd;
using StellaOps.Spdx3.Model;
using StellaOps.Spdx3.Model.Software;
namespace StellaOps.Spdx3;
/// <summary>
/// SPDX 3.0.1 JSON-LD parser implementation.
/// </summary>
public sealed class Spdx3Parser : ISpdx3Parser
{
private readonly ISpdx3ContextResolver _contextResolver;
private readonly ILogger<Spdx3Parser> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
/// <summary>
/// Initializes a new instance of the <see cref="Spdx3Parser"/> class.
/// </summary>
public Spdx3Parser(
ISpdx3ContextResolver contextResolver,
ILogger<Spdx3Parser> logger)
{
_contextResolver = contextResolver;
_logger = logger;
}
/// <inheritdoc />
public async Task<Spdx3ParseResult> ParseAsync(
Stream stream,
CancellationToken cancellationToken = default)
{
try
{
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken)
.ConfigureAwait(false);
return await ParseDocumentAsync(document, cancellationToken)
.ConfigureAwait(false);
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse SPDX 3.0.1 JSON");
return Spdx3ParseResult.Failed("JSON_PARSE_ERROR", ex.Message, ex.Path);
}
}
/// <inheritdoc />
public async Task<Spdx3ParseResult> ParseAsync(
string filePath,
CancellationToken cancellationToken = default)
{
if (!File.Exists(filePath))
{
return Spdx3ParseResult.Failed("FILE_NOT_FOUND", $"File not found: {filePath}");
}
await using var stream = File.OpenRead(filePath);
return await ParseAsync(stream, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<Spdx3ParseResult> ParseFromJsonAsync(
string json,
CancellationToken cancellationToken = default)
{
using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json));
return await ParseAsync(stream, cancellationToken).ConfigureAwait(false);
}
private async Task<Spdx3ParseResult> ParseDocumentAsync(
JsonDocument document,
CancellationToken cancellationToken)
{
var root = document.RootElement;
var errors = new List<Spdx3ParseError>();
var warnings = new List<Spdx3ParseWarning>();
// Check for @context (JSON-LD indicator)
if (!root.TryGetProperty("@context", out var contextElement))
{
return Spdx3ParseResult.Failed("MISSING_CONTEXT", "No @context found. Is this an SPDX 3.0.1 JSON-LD document?");
}
// Resolve context (for future expansion support)
var contextRef = GetContextReference(contextElement);
if (!string.IsNullOrEmpty(contextRef))
{
await _contextResolver.ResolveAsync(contextRef, cancellationToken).ConfigureAwait(false);
}
// Parse @graph (main element array)
var elements = new List<Spdx3Element>();
var creationInfos = new List<Spdx3CreationInfo>();
var profiles = new HashSet<Spdx3ProfileIdentifier>();
Spdx3SpdxDocument? spdxDocument = null;
if (root.TryGetProperty("@graph", out var graphElement) && graphElement.ValueKind == JsonValueKind.Array)
{
foreach (var element in graphElement.EnumerateArray())
{
var parsed = ParseElement(element, errors, warnings);
if (parsed != null)
{
elements.Add(parsed);
if (parsed is Spdx3SpdxDocument doc)
{
spdxDocument = doc;
}
}
// Also extract CreationInfo
var creationInfo = ExtractCreationInfo(element, errors);
if (creationInfo != null)
{
creationInfos.Add(creationInfo);
foreach (var profile in creationInfo.Profile)
{
profiles.Add(profile);
}
}
}
}
else
{
// Single document format (no @graph)
var parsed = ParseElement(root, errors, warnings);
if (parsed != null)
{
elements.Add(parsed);
if (parsed is Spdx3SpdxDocument doc)
{
spdxDocument = doc;
}
}
}
if (errors.Count > 0)
{
return Spdx3ParseResult.Failed(errors, warnings);
}
var result = new Spdx3Document(elements, creationInfos, profiles, spdxDocument);
return Spdx3ParseResult.Succeeded(result, warnings);
}
private Spdx3Element? ParseElement(
JsonElement element,
List<Spdx3ParseError> errors,
List<Spdx3ParseWarning> warnings)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
// Get @type to determine element type
var type = GetStringProperty(element, "@type") ?? GetStringProperty(element, "type");
if (string.IsNullOrEmpty(type))
{
return null;
}
// Get spdxId (required)
var spdxId = GetStringProperty(element, "spdxId") ?? GetStringProperty(element, "@id");
if (string.IsNullOrEmpty(spdxId))
{
warnings.Add(new Spdx3ParseWarning("MISSING_SPDX_ID", $"Element of type {type} has no spdxId"));
return null;
}
return type switch
{
"software_Package" or "Package" or "spdx:software_Package" =>
ParsePackage(element, spdxId),
"software_File" or "File" or "spdx:software_File" =>
ParseFile(element, spdxId),
"software_Snippet" or "Snippet" or "spdx:software_Snippet" =>
ParseSnippet(element, spdxId),
"software_SpdxDocument" or "SpdxDocument" or "spdx:software_SpdxDocument" =>
ParseSpdxDocument(element, spdxId),
"Relationship" or "spdx:Relationship" =>
ParseRelationship(element, spdxId),
"Person" or "spdx:Person" =>
ParseAgent<Spdx3Person>(element, spdxId),
"Organization" or "spdx:Organization" =>
ParseAgent<Spdx3Organization>(element, spdxId),
"Tool" or "spdx:Tool" =>
ParseAgent<Spdx3Tool>(element, spdxId),
_ => ParseGenericElement(element, spdxId, type, warnings)
};
}
private Spdx3Package ParsePackage(JsonElement element, string spdxId)
{
return new Spdx3Package
{
SpdxId = spdxId,
Type = GetStringProperty(element, "@type"),
Name = GetStringProperty(element, "name"),
Summary = GetStringProperty(element, "summary"),
Description = GetStringProperty(element, "description"),
Comment = GetStringProperty(element, "comment"),
PackageVersion = GetStringProperty(element, "packageVersion"),
DownloadLocation = GetStringProperty(element, "downloadLocation"),
PackageUrl = GetStringProperty(element, "packageUrl"),
HomePage = GetStringProperty(element, "homePage"),
SourceInfo = GetStringProperty(element, "sourceInfo"),
CopyrightText = GetStringProperty(element, "copyrightText"),
SuppliedBy = GetStringProperty(element, "suppliedBy"),
VerifiedUsing = ParseIntegrityMethods(element),
ExternalRef = ParseExternalRefs(element),
ExternalIdentifier = ParseExternalIdentifiers(element),
OriginatedBy = GetStringArrayProperty(element, "originatedBy"),
AttributionText = GetStringArrayProperty(element, "attributionText")
};
}
private Spdx3File ParseFile(JsonElement element, string spdxId)
{
return new Spdx3File
{
SpdxId = spdxId,
Type = GetStringProperty(element, "@type"),
Name = GetStringProperty(element, "name") ?? string.Empty,
Summary = GetStringProperty(element, "summary"),
Description = GetStringProperty(element, "description"),
Comment = GetStringProperty(element, "comment"),
ContentType = GetStringProperty(element, "contentType"),
CopyrightText = GetStringProperty(element, "copyrightText"),
VerifiedUsing = ParseIntegrityMethods(element),
ExternalRef = ParseExternalRefs(element),
ExternalIdentifier = ParseExternalIdentifiers(element)
};
}
private Spdx3Snippet ParseSnippet(JsonElement element, string spdxId)
{
return new Spdx3Snippet
{
SpdxId = spdxId,
Type = GetStringProperty(element, "@type"),
Name = GetStringProperty(element, "name"),
Summary = GetStringProperty(element, "summary"),
Description = GetStringProperty(element, "description"),
Comment = GetStringProperty(element, "comment"),
SnippetFromFile = GetStringProperty(element, "snippetFromFile") ?? string.Empty,
CopyrightText = GetStringProperty(element, "copyrightText"),
VerifiedUsing = ParseIntegrityMethods(element),
ExternalRef = ParseExternalRefs(element),
ExternalIdentifier = ParseExternalIdentifiers(element)
};
}
private Spdx3SpdxDocument ParseSpdxDocument(JsonElement element, string spdxId)
{
return new Spdx3SpdxDocument
{
SpdxId = spdxId,
Type = GetStringProperty(element, "@type"),
Name = GetStringProperty(element, "name"),
Summary = GetStringProperty(element, "summary"),
Description = GetStringProperty(element, "description"),
Comment = GetStringProperty(element, "comment"),
Element = GetStringArrayProperty(element, "element"),
RootElement = GetStringArrayProperty(element, "rootElement"),
VerifiedUsing = ParseIntegrityMethods(element),
ExternalRef = ParseExternalRefs(element),
ExternalIdentifier = ParseExternalIdentifiers(element)
};
}
private Spdx3Relationship ParseRelationship(JsonElement element, string spdxId)
{
var toValue = GetStringProperty(element, "to");
var toArray = toValue != null
? [toValue]
: GetStringArrayProperty(element, "to");
var relationshipType = GetStringProperty(element, "relationshipType") ?? "Other";
if (!Enum.TryParse<Spdx3RelationshipType>(relationshipType, ignoreCase: true, out var relType))
{
relType = Spdx3RelationshipType.Other;
}
return new Spdx3Relationship
{
SpdxId = spdxId,
Type = GetStringProperty(element, "@type"),
From = GetStringProperty(element, "from") ?? string.Empty,
To = toArray,
RelationshipType = relType,
Comment = GetStringProperty(element, "comment"),
VerifiedUsing = ParseIntegrityMethods(element),
ExternalRef = ParseExternalRefs(element),
ExternalIdentifier = ParseExternalIdentifiers(element)
};
}
private T ParseAgent<T>(JsonElement element, string spdxId) where T : Spdx3Element
{
var name = GetStringProperty(element, "name") ?? string.Empty;
if (typeof(T) == typeof(Spdx3Person))
{
return (T)(object)new Spdx3Person
{
SpdxId = spdxId,
Type = GetStringProperty(element, "@type"),
Name = name
};
}
if (typeof(T) == typeof(Spdx3Organization))
{
return (T)(object)new Spdx3Organization
{
SpdxId = spdxId,
Type = GetStringProperty(element, "@type"),
Name = name
};
}
if (typeof(T) == typeof(Spdx3Tool))
{
return (T)(object)new Spdx3Tool
{
SpdxId = spdxId,
Type = GetStringProperty(element, "@type"),
Name = name
};
}
throw new InvalidOperationException($"Unsupported agent type: {typeof(T)}");
}
private Spdx3Element? ParseGenericElement(
JsonElement element,
string spdxId,
string type,
List<Spdx3ParseWarning> warnings)
{
// For unknown types, we could create a generic element
// For now, log a warning and skip
warnings.Add(new Spdx3ParseWarning("UNKNOWN_TYPE", $"Unknown element type: {type}", spdxId));
return null;
}
private Spdx3CreationInfo? ExtractCreationInfo(
JsonElement element,
List<Spdx3ParseError> errors)
{
if (!element.TryGetProperty("creationInfo", out var ciElement))
{
return null;
}
if (ciElement.ValueKind == JsonValueKind.String)
{
// Reference to CreationInfo - will be resolved later
return null;
}
if (ciElement.ValueKind != JsonValueKind.Object)
{
return null;
}
var specVersion = GetStringProperty(ciElement, "specVersion");
if (string.IsNullOrEmpty(specVersion))
{
return null;
}
var createdStr = GetStringProperty(ciElement, "created");
if (!DateTimeOffset.TryParse(createdStr, out var created))
{
created = DateTimeOffset.UtcNow;
}
var profileStrings = GetStringArrayProperty(ciElement, "profile");
var profiles = profileStrings
.Select(p => Spdx3ProfileUris.Parse(p))
.Where(p => p.HasValue)
.Select(p => p!.Value)
.ToImmutableArray();
return new Spdx3CreationInfo
{
Id = GetStringProperty(ciElement, "@id"),
Type = GetStringProperty(ciElement, "@type"),
SpecVersion = specVersion,
Created = created,
CreatedBy = GetStringArrayProperty(ciElement, "createdBy"),
CreatedUsing = GetStringArrayProperty(ciElement, "createdUsing"),
Profile = profiles,
DataLicense = GetStringProperty(ciElement, "dataLicense"),
Comment = GetStringProperty(ciElement, "comment")
};
}
private ImmutableArray<Spdx3IntegrityMethod> ParseIntegrityMethods(JsonElement element)
{
if (!element.TryGetProperty("verifiedUsing", out var verifiedUsing))
{
return [];
}
if (verifiedUsing.ValueKind != JsonValueKind.Array)
{
return [];
}
var methods = new List<Spdx3IntegrityMethod>();
foreach (var item in verifiedUsing.EnumerateArray())
{
var algorithm = GetStringProperty(item, "algorithm");
var hashValue = GetStringProperty(item, "hashValue");
if (!string.IsNullOrEmpty(algorithm) && !string.IsNullOrEmpty(hashValue))
{
if (Enum.TryParse<Spdx3HashAlgorithm>(algorithm.Replace("-", "_"), ignoreCase: true, out var algo))
{
methods.Add(new Spdx3Hash
{
Type = GetStringProperty(item, "@type"),
Algorithm = algo,
HashValue = hashValue.ToLowerInvariant(),
Comment = GetStringProperty(item, "comment")
});
}
}
}
return [.. methods];
}
private ImmutableArray<Spdx3ExternalRef> ParseExternalRefs(JsonElement element)
{
if (!element.TryGetProperty("externalRef", out var externalRef))
{
return [];
}
if (externalRef.ValueKind != JsonValueKind.Array)
{
return [];
}
var refs = new List<Spdx3ExternalRef>();
foreach (var item in externalRef.EnumerateArray())
{
refs.Add(new Spdx3ExternalRef
{
Type = GetStringProperty(item, "@type"),
Locator = GetStringArrayProperty(item, "locator"),
ContentType = GetStringProperty(item, "contentType"),
Comment = GetStringProperty(item, "comment")
});
}
return [.. refs];
}
private ImmutableArray<Spdx3ExternalIdentifier> ParseExternalIdentifiers(JsonElement element)
{
if (!element.TryGetProperty("externalIdentifier", out var externalId))
{
return [];
}
if (externalId.ValueKind != JsonValueKind.Array)
{
return [];
}
var identifiers = new List<Spdx3ExternalIdentifier>();
foreach (var item in externalId.EnumerateArray())
{
var identifier = GetStringProperty(item, "identifier");
if (string.IsNullOrEmpty(identifier))
{
continue;
}
var typeStr = GetStringProperty(item, "externalIdentifierType");
Spdx3ExternalIdentifierType? idType = null;
if (!string.IsNullOrEmpty(typeStr) &&
Enum.TryParse<Spdx3ExternalIdentifierType>(typeStr, ignoreCase: true, out var parsed))
{
idType = parsed;
}
identifiers.Add(new Spdx3ExternalIdentifier
{
Type = GetStringProperty(item, "@type"),
ExternalIdentifierType = idType,
Identifier = identifier,
Comment = GetStringProperty(item, "comment"),
IssuingAuthority = GetStringProperty(item, "issuingAuthority")
});
}
return [.. identifiers];
}
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 ImmutableArray<string> GetStringArrayProperty(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var prop))
{
return [];
}
if (prop.ValueKind == JsonValueKind.String)
{
var value = prop.GetString();
return value != null ? [value] : [];
}
if (prop.ValueKind == JsonValueKind.Array)
{
var list = new List<string>();
foreach (var item in prop.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var value = item.GetString();
if (value != null)
{
list.Add(value);
}
}
}
return [.. list];
}
return [];
}
private static string? GetContextReference(JsonElement contextElement)
{
if (contextElement.ValueKind == JsonValueKind.String)
{
return contextElement.GetString();
}
if (contextElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in contextElement.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
return item.GetString();
}
}
}
return null;
}
}

View File

@@ -0,0 +1,203 @@
// <copyright file="Spdx3VersionDetector.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Text.Json;
namespace StellaOps.Spdx3;
/// <summary>
/// Detects the SPDX version of a document.
/// </summary>
public static class Spdx3VersionDetector
{
/// <summary>
/// Detected SPDX version.
/// </summary>
public enum SpdxVersion
{
/// <summary>
/// Unknown version.
/// </summary>
Unknown,
/// <summary>
/// SPDX 2.2.
/// </summary>
Spdx22,
/// <summary>
/// SPDX 2.3.
/// </summary>
Spdx23,
/// <summary>
/// SPDX 3.0.1.
/// </summary>
Spdx301
}
/// <summary>
/// Version detection result.
/// </summary>
/// <param name="Version">The detected version.</param>
/// <param name="VersionString">The raw version string if found.</param>
/// <param name="IsJsonLd">Whether the document uses JSON-LD format.</param>
public readonly record struct DetectionResult(
SpdxVersion Version,
string? VersionString,
bool IsJsonLd);
/// <summary>
/// Detects the SPDX version from a JSON document.
/// </summary>
/// <param name="json">The JSON content.</param>
/// <returns>The detection result.</returns>
public static DetectionResult Detect(string json)
{
using var document = JsonDocument.Parse(json);
return Detect(document.RootElement);
}
/// <summary>
/// Detects the SPDX version from a JSON element.
/// </summary>
/// <param name="root">The root JSON element.</param>
/// <returns>The detection result.</returns>
public static DetectionResult Detect(JsonElement root)
{
// Check for JSON-LD @context (SPDX 3.x indicator)
if (root.TryGetProperty("@context", out var context))
{
var contextStr = GetContextString(context);
if (!string.IsNullOrEmpty(contextStr))
{
// Check for specific 3.0.1 context
if (contextStr.Contains("3.0.1", StringComparison.OrdinalIgnoreCase) ||
contextStr.Contains("spdx.org/rdf/3", StringComparison.OrdinalIgnoreCase))
{
return new DetectionResult(SpdxVersion.Spdx301, "3.0.1", true);
}
// Generic 3.x detection
if (contextStr.Contains("spdx.org/rdf", StringComparison.OrdinalIgnoreCase))
{
return new DetectionResult(SpdxVersion.Spdx301, null, true);
}
}
// Has @context but couldn't determine specific version
return new DetectionResult(SpdxVersion.Spdx301, null, true);
}
// Check for SPDX 2.x spdxVersion field
if (root.TryGetProperty("spdxVersion", out var spdxVersion) &&
spdxVersion.ValueKind == JsonValueKind.String)
{
var versionStr = spdxVersion.GetString();
if (!string.IsNullOrEmpty(versionStr))
{
if (versionStr.Contains("2.3", StringComparison.OrdinalIgnoreCase))
{
return new DetectionResult(SpdxVersion.Spdx23, versionStr, false);
}
if (versionStr.Contains("2.2", StringComparison.OrdinalIgnoreCase))
{
return new DetectionResult(SpdxVersion.Spdx22, versionStr, false);
}
// Older 2.x versions
if (versionStr.StartsWith("SPDX-2", StringComparison.OrdinalIgnoreCase))
{
return new DetectionResult(SpdxVersion.Spdx22, versionStr, false);
}
}
}
// Check for creationInfo.specVersion (SPDX 3.x in @graph format)
if (root.TryGetProperty("@graph", out var graph) && graph.ValueKind == JsonValueKind.Array)
{
foreach (var element in graph.EnumerateArray())
{
if (element.TryGetProperty("creationInfo", out var creationInfo) &&
creationInfo.ValueKind == JsonValueKind.Object)
{
if (creationInfo.TryGetProperty("specVersion", out var specVersion) &&
specVersion.ValueKind == JsonValueKind.String)
{
var specVersionStr = specVersion.GetString();
if (specVersionStr == "3.0.1")
{
return new DetectionResult(SpdxVersion.Spdx301, specVersionStr, true);
}
}
}
}
}
return new DetectionResult(SpdxVersion.Unknown, null, false);
}
/// <summary>
/// Detects the SPDX version from a stream.
/// </summary>
/// <param name="stream">The input stream.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The detection result.</returns>
public static async Task<DetectionResult> DetectAsync(
Stream stream,
CancellationToken cancellationToken = default)
{
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken)
.ConfigureAwait(false);
return Detect(document.RootElement);
}
/// <summary>
/// Gets the recommended parser for the detected version.
/// </summary>
/// <param name="version">The detected version.</param>
/// <returns>Parser recommendation.</returns>
public static string GetParserRecommendation(SpdxVersion version) => version switch
{
SpdxVersion.Spdx22 => "Use SpdxParser (SPDX 2.x parser)",
SpdxVersion.Spdx23 => "Use SpdxParser (SPDX 2.x parser)",
SpdxVersion.Spdx301 => "Use Spdx3Parser (SPDX 3.0.1 parser)",
_ => "Unknown format - manual inspection required"
};
private static string? GetContextString(JsonElement context)
{
if (context.ValueKind == JsonValueKind.String)
{
return context.GetString();
}
if (context.ValueKind == JsonValueKind.Array)
{
foreach (var item in context.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var str = item.GetString();
if (!string.IsNullOrEmpty(str) && str.Contains("spdx", StringComparison.OrdinalIgnoreCase))
{
return str;
}
}
}
// Return first string if no spdx-specific one found
foreach (var item in context.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
return item.GetString();
}
}
}
return null;
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Spdx3</RootNamespace>
<AssemblyName>StellaOps.Spdx3</AssemblyName>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Description>SPDX 3.0.1 parsing library with full profile support for StellaOps</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,155 @@
// <copyright file="ISpdx3Validator.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using StellaOps.Spdx3.Model;
namespace StellaOps.Spdx3.Validation;
/// <summary>
/// Interface for SPDX 3.0.1 document validation.
/// </summary>
public interface ISpdx3Validator
{
/// <summary>
/// Validates an SPDX 3.0.1 document.
/// </summary>
/// <param name="document">The document to validate.</param>
/// <param name="options">Validation options.</param>
/// <returns>The validation result.</returns>
Spdx3ValidationResult Validate(
Spdx3Document document,
Spdx3ValidationOptions? options = null);
}
/// <summary>
/// Options for SPDX 3.0.1 validation.
/// </summary>
public sealed class Spdx3ValidationOptions
{
/// <summary>
/// Gets or sets whether to validate profile-specific requirements.
/// </summary>
public bool ValidateProfiles { get; set; } = true;
/// <summary>
/// Gets or sets whether to validate relationships.
/// </summary>
public bool ValidateRelationships { get; set; } = true;
/// <summary>
/// Gets or sets whether to validate external identifiers (PURL format, etc.).
/// </summary>
public bool ValidateExternalIdentifiers { get; set; } = true;
/// <summary>
/// Gets or sets whether to validate hash values.
/// </summary>
public bool ValidateHashes { get; set; } = true;
/// <summary>
/// Gets or sets whether warnings should be treated as errors.
/// </summary>
public bool TreatWarningsAsErrors { get; set; } = false;
/// <summary>
/// Gets or sets the minimum required profiles.
/// </summary>
public ImmutableHashSet<Spdx3ProfileIdentifier> RequiredProfiles { get; set; } =
ImmutableHashSet<Spdx3ProfileIdentifier>.Empty;
}
/// <summary>
/// Result of SPDX 3.0.1 validation.
/// </summary>
public sealed record Spdx3ValidationResult
{
/// <summary>
/// Gets whether validation passed.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Gets validation errors.
/// </summary>
public IReadOnlyList<Spdx3ValidationIssue> Errors { get; init; } = [];
/// <summary>
/// Gets validation warnings.
/// </summary>
public IReadOnlyList<Spdx3ValidationIssue> Warnings { get; init; } = [];
/// <summary>
/// Gets validation information.
/// </summary>
public IReadOnlyList<Spdx3ValidationIssue> Info { get; init; } = [];
/// <summary>
/// Creates a valid result.
/// </summary>
public static Spdx3ValidationResult Valid(
IReadOnlyList<Spdx3ValidationIssue>? warnings = null,
IReadOnlyList<Spdx3ValidationIssue>? info = null)
{
return new Spdx3ValidationResult
{
IsValid = true,
Warnings = warnings ?? [],
Info = info ?? []
};
}
/// <summary>
/// Creates an invalid result.
/// </summary>
public static Spdx3ValidationResult Invalid(
IReadOnlyList<Spdx3ValidationIssue> errors,
IReadOnlyList<Spdx3ValidationIssue>? warnings = null,
IReadOnlyList<Spdx3ValidationIssue>? info = null)
{
return new Spdx3ValidationResult
{
IsValid = false,
Errors = errors,
Warnings = warnings ?? [],
Info = info ?? []
};
}
}
/// <summary>
/// Represents a validation issue.
/// </summary>
/// <param name="Code">Issue code.</param>
/// <param name="Message">Issue message.</param>
/// <param name="Severity">Issue severity.</param>
/// <param name="ElementId">The SPDX ID of the affected element.</param>
/// <param name="Path">JSON path or property name.</param>
public sealed record Spdx3ValidationIssue(
string Code,
string Message,
Spdx3ValidationSeverity Severity,
string? ElementId = null,
string? Path = null);
/// <summary>
/// Validation issue severity.
/// </summary>
public enum Spdx3ValidationSeverity
{
/// <summary>
/// Informational message.
/// </summary>
Info,
/// <summary>
/// Warning (non-fatal).
/// </summary>
Warning,
/// <summary>
/// Error (fatal).
/// </summary>
Error
}

View File

@@ -0,0 +1,344 @@
// <copyright file="Spdx3Validator.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Text.RegularExpressions;
using StellaOps.Spdx3.Model;
using StellaOps.Spdx3.Model.Software;
namespace StellaOps.Spdx3.Validation;
/// <summary>
/// SPDX 3.0.1 document validator implementation.
/// </summary>
public sealed partial class Spdx3Validator : ISpdx3Validator
{
[GeneratedRegex(@"^pkg:[a-z]+/.+", RegexOptions.IgnoreCase)]
private static partial Regex PurlPattern();
[GeneratedRegex(@"^cpe:2\.3:[aho\*\-]", RegexOptions.IgnoreCase)]
private static partial Regex Cpe23Pattern();
[GeneratedRegex(@"^[a-fA-F0-9]+$")]
private static partial Regex HexPattern();
/// <inheritdoc />
public Spdx3ValidationResult Validate(
Spdx3Document document,
Spdx3ValidationOptions? options = null)
{
options ??= new Spdx3ValidationOptions();
var errors = new List<Spdx3ValidationIssue>();
var warnings = new List<Spdx3ValidationIssue>();
var info = new List<Spdx3ValidationIssue>();
// Core validation
ValidateCore(document, errors, warnings, info);
// Profile validation
if (options.ValidateProfiles)
{
ValidateProfiles(document, options, errors, warnings, info);
}
// Relationship validation
if (options.ValidateRelationships)
{
ValidateRelationships(document, errors, warnings, info);
}
// External identifier validation
if (options.ValidateExternalIdentifiers)
{
ValidateExternalIdentifiers(document, errors, warnings, info);
}
// Hash validation
if (options.ValidateHashes)
{
ValidateHashes(document, errors, warnings, info);
}
// Treat warnings as errors if configured
if (options.TreatWarningsAsErrors && warnings.Count > 0)
{
errors.AddRange(warnings.Select(w => w with { Severity = Spdx3ValidationSeverity.Error }));
warnings.Clear();
}
if (errors.Count > 0)
{
return Spdx3ValidationResult.Invalid(errors, warnings, info);
}
return Spdx3ValidationResult.Valid(warnings, info);
}
private static void ValidateCore(
Spdx3Document document,
List<Spdx3ValidationIssue> errors,
List<Spdx3ValidationIssue> warnings,
List<Spdx3ValidationIssue> info)
{
// Must have at least one element
if (document.AllElements.Length == 0)
{
errors.Add(new Spdx3ValidationIssue(
"EMPTY_DOCUMENT",
"Document contains no elements",
Spdx3ValidationSeverity.Error));
return;
}
// Validate each element has required fields
foreach (var element in document.AllElements)
{
if (string.IsNullOrWhiteSpace(element.SpdxId))
{
errors.Add(new Spdx3ValidationIssue(
"MISSING_SPDX_ID",
"Element is missing spdxId",
Spdx3ValidationSeverity.Error,
Path: element.Type));
}
}
// Check for duplicate spdxIds
var duplicates = document.AllElements
.GroupBy(e => e.SpdxId)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();
foreach (var dup in duplicates)
{
errors.Add(new Spdx3ValidationIssue(
"DUPLICATE_SPDX_ID",
$"Duplicate spdxId found: {dup}",
Spdx3ValidationSeverity.Error,
ElementId: dup));
}
// Validate SpdxDocument if present
if (document.SpdxDocument != null)
{
if (document.SpdxDocument.RootElement.IsEmpty)
{
warnings.Add(new Spdx3ValidationIssue(
"NO_ROOT_ELEMENT",
"SpdxDocument has no rootElement specified",
Spdx3ValidationSeverity.Warning,
ElementId: document.SpdxDocument.SpdxId));
}
}
// Info about document contents
info.Add(new Spdx3ValidationIssue(
"DOCUMENT_STATS",
$"Document contains {document.Packages.Length} packages, " +
$"{document.Files.Length} files, " +
$"{document.Relationships.Length} relationships",
Spdx3ValidationSeverity.Info));
}
private static void ValidateProfiles(
Spdx3Document document,
Spdx3ValidationOptions options,
List<Spdx3ValidationIssue> errors,
List<Spdx3ValidationIssue> warnings,
List<Spdx3ValidationIssue> info)
{
// Check required profiles
foreach (var required in options.RequiredProfiles)
{
if (!document.ConformsTo(required))
{
errors.Add(new Spdx3ValidationIssue(
"MISSING_REQUIRED_PROFILE",
$"Document does not conform to required profile: {required}",
Spdx3ValidationSeverity.Error));
}
}
// Validate Software profile requirements
if (document.ConformsTo(Spdx3ProfileIdentifier.Software))
{
foreach (var package in document.Packages)
{
// Software profile packages should have a name
if (string.IsNullOrWhiteSpace(package.Name))
{
warnings.Add(new Spdx3ValidationIssue(
"PACKAGE_MISSING_NAME",
"Package is missing name (recommended for Software profile)",
Spdx3ValidationSeverity.Warning,
ElementId: package.SpdxId));
}
}
}
// Validate Lite profile requirements
if (document.ConformsTo(Spdx3ProfileIdentifier.Lite))
{
foreach (var package in document.Packages)
{
// Lite profile requires name and version
if (string.IsNullOrWhiteSpace(package.Name))
{
errors.Add(new Spdx3ValidationIssue(
"LITE_PROFILE_MISSING_NAME",
"Lite profile requires package name",
Spdx3ValidationSeverity.Error,
ElementId: package.SpdxId));
}
}
}
// Report detected profiles
var profileNames = string.Join(", ", document.Profiles.Select(p => p.ToString()));
if (!string.IsNullOrEmpty(profileNames))
{
info.Add(new Spdx3ValidationIssue(
"DETECTED_PROFILES",
$"Document conforms to profiles: {profileNames}",
Spdx3ValidationSeverity.Info));
}
}
private static void ValidateRelationships(
Spdx3Document document,
List<Spdx3ValidationIssue> errors,
List<Spdx3ValidationIssue> warnings,
List<Spdx3ValidationIssue> info)
{
var elementIds = document.Elements.Select(e => e.SpdxId).ToHashSet(StringComparer.Ordinal);
foreach (var relationship in document.Relationships)
{
// Validate 'from' reference
if (!elementIds.Contains(relationship.From))
{
warnings.Add(new Spdx3ValidationIssue(
"DANGLING_RELATIONSHIP_FROM",
$"Relationship 'from' references unknown element: {relationship.From}",
Spdx3ValidationSeverity.Warning,
ElementId: relationship.SpdxId));
}
// Validate 'to' references
foreach (var to in relationship.To)
{
if (!elementIds.Contains(to))
{
warnings.Add(new Spdx3ValidationIssue(
"DANGLING_RELATIONSHIP_TO",
$"Relationship 'to' references unknown element: {to}",
Spdx3ValidationSeverity.Warning,
ElementId: relationship.SpdxId));
}
}
// Validate relationship has targets
if (relationship.To.IsEmpty)
{
errors.Add(new Spdx3ValidationIssue(
"EMPTY_RELATIONSHIP_TO",
"Relationship has no 'to' targets",
Spdx3ValidationSeverity.Error,
ElementId: relationship.SpdxId));
}
}
}
private static void ValidateExternalIdentifiers(
Spdx3Document document,
List<Spdx3ValidationIssue> errors,
List<Spdx3ValidationIssue> warnings,
List<Spdx3ValidationIssue> info)
{
foreach (var element in document.Elements)
{
foreach (var extId in element.ExternalIdentifier)
{
switch (extId.ExternalIdentifierType)
{
case Spdx3ExternalIdentifierType.PackageUrl:
if (!PurlPattern().IsMatch(extId.Identifier))
{
warnings.Add(new Spdx3ValidationIssue(
"INVALID_PURL_FORMAT",
$"Invalid PURL format: {extId.Identifier}",
Spdx3ValidationSeverity.Warning,
ElementId: element.SpdxId,
Path: "externalIdentifier/identifier"));
}
break;
case Spdx3ExternalIdentifierType.Cpe23:
if (!Cpe23Pattern().IsMatch(extId.Identifier))
{
warnings.Add(new Spdx3ValidationIssue(
"INVALID_CPE23_FORMAT",
$"Invalid CPE 2.3 format: {extId.Identifier}",
Spdx3ValidationSeverity.Warning,
ElementId: element.SpdxId,
Path: "externalIdentifier/identifier"));
}
break;
}
}
}
}
private static void ValidateHashes(
Spdx3Document document,
List<Spdx3ValidationIssue> errors,
List<Spdx3ValidationIssue> warnings,
List<Spdx3ValidationIssue> info)
{
foreach (var element in document.Elements)
{
foreach (var integrity in element.VerifiedUsing)
{
if (integrity is Spdx3Hash hash)
{
// Validate hex format
if (!HexPattern().IsMatch(hash.HashValue))
{
errors.Add(new Spdx3ValidationIssue(
"INVALID_HASH_FORMAT",
$"Hash value is not valid hex: {hash.HashValue}",
Spdx3ValidationSeverity.Error,
ElementId: element.SpdxId,
Path: "verifiedUsing/hashValue"));
}
// Validate hash length
if (!hash.IsValidLength())
{
warnings.Add(new Spdx3ValidationIssue(
"INVALID_HASH_LENGTH",
$"Hash value has unexpected length for {hash.Algorithm}: expected {hash.GetExpectedLength()}, got {hash.HashValue.Length}",
Spdx3ValidationSeverity.Warning,
ElementId: element.SpdxId,
Path: "verifiedUsing/hashValue"));
}
// Warn about deprecated algorithms
if (hash.Algorithm.IsDeprecated())
{
warnings.Add(new Spdx3ValidationIssue(
"DEPRECATED_HASH_ALGORITHM",
$"Hash algorithm {hash.Algorithm} is deprecated",
Spdx3ValidationSeverity.Warning,
ElementId: element.SpdxId,
Path: "verifiedUsing/algorithm"));
}
}
}
}
}
}