doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements

This commit is contained in:
master
2026-01-19 09:02:59 +02:00
parent 8c4bf54aed
commit 17419ba7c4
809 changed files with 170738 additions and 12244 deletions

View File

@@ -0,0 +1,609 @@
// -----------------------------------------------------------------------------
// ArtifactController.cs
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
// Tasks: AS-005 - Create artifact submission endpoint
// AS-007 - Query endpoint for artifacts by bom-ref
// Description: API controller for unified artifact storage
// -----------------------------------------------------------------------------
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using StellaOps.Artifact.Core;
namespace StellaOps.Artifact.Api;
/// <summary>
/// API controller for unified artifact storage operations.
/// </summary>
[ApiController]
[Route("api/v1/artifacts")]
[Produces("application/json")]
[Authorize]
public sealed class ArtifactController : ControllerBase
{
private readonly IArtifactStore _artifactStore;
private readonly ICycloneDxExtractor _cycloneDxExtractor;
private readonly ILogger<ArtifactController> _logger;
public ArtifactController(
IArtifactStore artifactStore,
ICycloneDxExtractor cycloneDxExtractor,
ILogger<ArtifactController> logger)
{
_artifactStore = artifactStore ?? throw new ArgumentNullException(nameof(artifactStore));
_cycloneDxExtractor = cycloneDxExtractor ?? throw new ArgumentNullException(nameof(cycloneDxExtractor));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Submits an artifact to the unified store.
/// </summary>
/// <param name="request">Artifact submission request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Created artifact metadata.</returns>
[HttpPost]
[ProducesResponseType(typeof(ArtifactSubmissionResponse), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> SubmitArtifact(
[FromBody] ArtifactSubmissionRequest request,
CancellationToken ct)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
try
{
// Validate bom-ref format (should be a valid purl or bom-ref)
if (string.IsNullOrWhiteSpace(request.BomRef))
{
return BadRequest(new ProblemDetails
{
Title = "Invalid bom_ref",
Detail = "bom_ref is required and must be a valid Package URL or CycloneDX bom-ref"
});
}
// Get or generate serial number
var serialNumber = request.CyclonedxSerial ?? GenerateSyntheticSerial(request.BomRef);
// Decode base64 content if provided
byte[] content;
if (!string.IsNullOrEmpty(request.ContentBase64))
{
try
{
content = Convert.FromBase64String(request.ContentBase64);
}
catch (FormatException)
{
return BadRequest(new ProblemDetails
{
Title = "Invalid content",
Detail = "content_base64 must be valid Base64-encoded data"
});
}
}
else if (request.DsseUri != null)
{
// Fetch content from URI (S3, HTTP, etc.)
content = await FetchContentFromUri(request.DsseUri, ct);
}
else
{
return BadRequest(new ProblemDetails
{
Title = "Missing content",
Detail = "Either content_base64 or dsse_uri must be provided"
});
}
// Generate artifact ID if not provided
var artifactId = request.ArtifactId ?? Guid.NewGuid().ToString();
// Determine content type
var contentType = request.ContentType ?? DetermineContentType(request.ArtifactType);
// Get tenant from context
var tenantId = GetTenantId();
// Store the artifact
using var contentStream = new MemoryStream(content);
var storeRequest = new ArtifactStoreRequest
{
BomRef = request.BomRef,
SerialNumber = serialNumber,
ArtifactId = artifactId,
Content = contentStream,
ContentType = contentType,
Type = ParseArtifactType(request.ArtifactType),
Metadata = request.Metadata,
TenantId = tenantId,
Overwrite = request.Overwrite ?? false
};
var result = await _artifactStore.StoreAsync(storeRequest, ct);
if (!result.Success)
{
return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
{
Title = "Storage failed",
Detail = result.ErrorMessage
});
}
var response = new ArtifactSubmissionResponse
{
ArtifactId = artifactId,
BomRef = request.BomRef,
SerialNumber = serialNumber,
StorageKey = result.StorageKey!,
Sha256 = result.Sha256!,
SizeBytes = result.SizeBytes!.Value,
WasCreated = result.WasCreated,
CreatedAt = DateTimeOffset.UtcNow
};
_logger.LogInformation(
"Artifact submitted: {ArtifactId} for bom-ref {BomRef}",
artifactId, request.BomRef);
return CreatedAtAction(
nameof(GetArtifact),
new { bomRef = request.BomRef, serialNumber, artifactId },
response);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to submit artifact");
return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
{
Title = "Internal error",
Detail = "An unexpected error occurred while storing the artifact"
});
}
}
/// <summary>
/// Lists artifacts by bom-ref with optional filters.
/// </summary>
/// <param name="bomRef">Required bom-ref filter.</param>
/// <param name="serialNumber">Optional serial number filter.</param>
/// <param name="from">Optional start date filter.</param>
/// <param name="to">Optional end date filter.</param>
/// <param name="limit">Maximum results (default 100).</param>
/// <param name="continuationToken">Pagination token.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of artifact metadata.</returns>
[HttpGet]
[ProducesResponseType(typeof(ArtifactListResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ListArtifacts(
[FromQuery(Name = "bom_ref"), Required] string bomRef,
[FromQuery(Name = "serial_number")] string? serialNumber,
[FromQuery] DateTimeOffset? from,
[FromQuery] DateTimeOffset? to,
[FromQuery] int limit = 100,
[FromQuery(Name = "continuation_token")] string? continuationToken,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(bomRef))
{
return BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "bom_ref query parameter is required"
});
}
if (limit < 1 || limit > 1000)
{
limit = 100;
}
try
{
var artifacts = await _artifactStore.ListAsync(bomRef, serialNumber, ct);
// Apply time filters if provided
if (from.HasValue)
{
artifacts = artifacts.Where(a => a.CreatedAt >= from.Value).ToList();
}
if (to.HasValue)
{
artifacts = artifacts.Where(a => a.CreatedAt < to.Value).ToList();
}
// Apply pagination
var offset = ParseContinuationToken(continuationToken);
var totalCount = artifacts.Count;
var pagedArtifacts = artifacts.Skip(offset).Take(limit).ToList();
// Generate next continuation token if there are more results
string? nextToken = null;
if (offset + limit < totalCount)
{
nextToken = GenerateContinuationToken(offset + limit);
}
var response = new ArtifactListResponse
{
Artifacts = pagedArtifacts.Select(a => new ArtifactListItem
{
ArtifactId = a.ArtifactId,
BomRef = a.BomRef,
SerialNumber = a.SerialNumber,
StorageKey = a.StorageKey,
ContentType = a.ContentType,
Sha256 = a.Sha256,
SizeBytes = a.SizeBytes,
CreatedAt = a.CreatedAt,
ArtifactType = a.Type.ToString()
}).ToList(),
Total = totalCount,
ContinuationToken = nextToken
};
return Ok(response);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to list artifacts for bom-ref {BomRef}", bomRef);
return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
{
Title = "Internal error",
Detail = "An unexpected error occurred while listing artifacts"
});
}
}
/// <summary>
/// Gets a specific artifact by its composite key.
/// </summary>
[HttpGet("{bomRef}/{serialNumber}/{artifactId}")]
[ProducesResponseType(typeof(ArtifactMetadataResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetArtifact(
string bomRef,
string serialNumber,
string artifactId,
CancellationToken ct)
{
var decodedBomRef = Uri.UnescapeDataString(bomRef);
var decodedSerial = Uri.UnescapeDataString(serialNumber);
var metadata = await _artifactStore.GetMetadataAsync(decodedBomRef, decodedSerial, artifactId, ct);
if (metadata == null)
{
return NotFound(new ProblemDetails
{
Title = "Not found",
Detail = $"Artifact not found: {artifactId}"
});
}
return Ok(new ArtifactMetadataResponse
{
ArtifactId = metadata.ArtifactId,
BomRef = metadata.BomRef,
SerialNumber = metadata.SerialNumber,
StorageKey = metadata.StorageKey,
ContentType = metadata.ContentType,
Sha256 = metadata.Sha256,
SizeBytes = metadata.SizeBytes,
CreatedAt = metadata.CreatedAt,
ArtifactType = metadata.Type.ToString()
});
}
/// <summary>
/// Downloads artifact content.
/// </summary>
[HttpGet("{bomRef}/{serialNumber}/{artifactId}/content")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DownloadArtifact(
string bomRef,
string serialNumber,
string artifactId,
CancellationToken ct)
{
var decodedBomRef = Uri.UnescapeDataString(bomRef);
var decodedSerial = Uri.UnescapeDataString(serialNumber);
var result = await _artifactStore.ReadAsync(decodedBomRef, decodedSerial, artifactId, ct);
if (!result.Found || result.Content == null)
{
return NotFound(new ProblemDetails
{
Title = "Not found",
Detail = result.ErrorMessage ?? $"Artifact not found: {artifactId}"
});
}
return File(result.Content, result.Metadata!.ContentType, $"{artifactId}.json");
}
/// <summary>
/// Deletes an artifact (soft delete).
/// </summary>
[HttpDelete("{bomRef}/{serialNumber}/{artifactId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteArtifact(
string bomRef,
string serialNumber,
string artifactId,
CancellationToken ct)
{
var decodedBomRef = Uri.UnescapeDataString(bomRef);
var decodedSerial = Uri.UnescapeDataString(serialNumber);
var deleted = await _artifactStore.DeleteAsync(decodedBomRef, decodedSerial, artifactId, ct);
if (!deleted)
{
return NotFound(new ProblemDetails
{
Title = "Not found",
Detail = $"Artifact not found: {artifactId}"
});
}
return NoContent();
}
private Guid GetTenantId()
{
// TODO: Extract tenant ID from authenticated user context
var tenantClaim = User.FindFirst("tenant_id")?.Value;
return Guid.TryParse(tenantClaim, out var id) ? id : Guid.Empty;
}
private static string GenerateSyntheticSerial(string bomRef)
{
// Generate a deterministic serial based on bom-ref SHA-256
using var sha = System.Security.Cryptography.SHA256.Create();
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(bomRef));
var guid = new Guid(hash.Take(16).ToArray());
return $"urn:uuid:{guid}";
}
private static ArtifactType ParseArtifactType(string? type)
{
if (string.IsNullOrEmpty(type))
return ArtifactType.Unknown;
return Enum.TryParse<ArtifactType>(type, ignoreCase: true, out var result)
? result
: ArtifactType.Unknown;
}
private static string DetermineContentType(string? artifactType)
{
return artifactType?.ToLowerInvariant() switch
{
"sbom" => "application/vnd.cyclonedx+json",
"vex" => "application/vnd.openvex+json",
"dsseenvelope" => "application/vnd.dsse+json",
"rekorproof" => "application/json",
_ => "application/json"
};
}
/// <summary>
/// Fetches content from a URI (S3, HTTP, file).
/// Sprint: SPRINT_20260118_017 (AS-005) - Validates dsse_uri accessibility
/// </summary>
private async Task<byte[]> FetchContentFromUri(string uri, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(uri);
// Validate URI format
if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsedUri))
{
throw new ArgumentException($"Invalid URI format: {uri}");
}
return parsedUri.Scheme.ToLowerInvariant() switch
{
"s3" => await FetchFromS3Async(parsedUri, ct),
"http" or "https" => await FetchFromHttpAsync(parsedUri, ct),
"file" => await FetchFromFileAsync(parsedUri, ct),
_ => throw new NotSupportedException($"URI scheme not supported: {parsedUri.Scheme}")
};
}
private async Task<byte[]> FetchFromS3Async(Uri uri, CancellationToken ct)
{
// Parse S3 URI: s3://bucket/key
var bucket = uri.Host;
var key = uri.AbsolutePath.TrimStart('/');
_logger.LogDebug("Fetching from S3: bucket={Bucket}, key={Key}", bucket, key);
// Validate accessibility by checking existence first
// This would use the S3 client from DI in a real implementation
// For now, we document the expected behavior and throw
throw new NotImplementedException(
$"S3 fetch not fully implemented. Configure S3 client. URI: s3://{bucket}/{key}");
}
private async Task<byte[]> FetchFromHttpAsync(Uri uri, CancellationToken ct)
{
_logger.LogDebug("Fetching from HTTP: {Uri}", uri);
// Use HttpClient from DI for proper lifecycle management
using var httpClient = new HttpClient();
httpClient.Timeout = TimeSpan.FromSeconds(30);
try
{
// HEAD request first to validate accessibility
using var headRequest = new HttpRequestMessage(HttpMethod.Head, uri);
using var headResponse = await httpClient.SendAsync(headRequest, ct);
if (!headResponse.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"URI not accessible: {uri} returned {headResponse.StatusCode}");
}
// Check content length to prevent fetching huge files
var contentLength = headResponse.Content.Headers.ContentLength;
if (contentLength > 100 * 1024 * 1024) // 100MB max
{
throw new InvalidOperationException(
$"Content too large: {contentLength} bytes exceeds 100MB limit");
}
// Now fetch the actual content
return await httpClient.GetByteArrayAsync(uri, ct);
}
catch (HttpRequestException ex)
{
throw new InvalidOperationException($"Failed to fetch from {uri}: {ex.Message}", ex);
}
}
private async Task<byte[]> FetchFromFileAsync(Uri uri, CancellationToken ct)
{
var filePath = uri.LocalPath;
_logger.LogDebug("Fetching from file: {Path}", filePath);
if (!System.IO.File.Exists(filePath))
{
throw new FileNotFoundException($"File not accessible: {filePath}");
}
var fileInfo = new FileInfo(filePath);
if (fileInfo.Length > 100 * 1024 * 1024) // 100MB max
{
throw new InvalidOperationException(
$"File too large: {fileInfo.Length} bytes exceeds 100MB limit");
}
return await System.IO.File.ReadAllBytesAsync(filePath, ct);
}
private static int ParseContinuationToken(string? token)
{
if (string.IsNullOrEmpty(token))
return 0;
try
{
var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(token));
return int.TryParse(decoded, out var offset) ? offset : 0;
}
catch
{
return 0;
}
}
private static string GenerateContinuationToken(int offset)
{
return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(offset.ToString()));
}
}
/// <summary>
/// Request to submit an artifact.
/// </summary>
public sealed record ArtifactSubmissionRequest
{
/// <summary>Package URL or CycloneDX bom-ref.</summary>
[Required]
public required string BomRef { get; init; }
/// <summary>CycloneDX serialNumber (optional, generated if missing).</summary>
public string? CyclonedxSerial { get; init; }
/// <summary>Artifact ID (optional, generated if missing).</summary>
public string? ArtifactId { get; init; }
/// <summary>Base64-encoded content.</summary>
public string? ContentBase64 { get; init; }
/// <summary>URI to fetch content from (S3, HTTP).</summary>
public string? DsseUri { get; init; }
/// <summary>Content type (optional, inferred from artifact_type).</summary>
public string? ContentType { get; init; }
/// <summary>Artifact type: Sbom, Vex, DsseEnvelope, etc.</summary>
public string? ArtifactType { get; init; }
/// <summary>Rekor transparency log UUID (optional).</summary>
public string? RekorUuid { get; init; }
/// <summary>Additional metadata.</summary>
public Dictionary<string, string>? Metadata { get; init; }
/// <summary>Whether to overwrite existing artifact.</summary>
public bool? Overwrite { get; init; }
}
/// <summary>
/// Response from artifact submission.
/// </summary>
public sealed record ArtifactSubmissionResponse
{
public required string ArtifactId { get; init; }
public required string BomRef { get; init; }
public required string SerialNumber { get; init; }
public required string StorageKey { get; init; }
public required string Sha256 { get; init; }
public required long SizeBytes { get; init; }
public required bool WasCreated { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Response for listing artifacts.
/// </summary>
public sealed record ArtifactListResponse
{
public required IReadOnlyList<ArtifactListItem> Artifacts { get; init; }
public required int Total { get; init; }
public string? ContinuationToken { get; init; }
}
/// <summary>
/// Item in artifact list response.
/// </summary>
public sealed record ArtifactListItem
{
public required string ArtifactId { get; init; }
public required string BomRef { get; init; }
public required string SerialNumber { get; init; }
public required string StorageKey { get; init; }
public required string ContentType { get; init; }
public required string Sha256 { get; init; }
public required long SizeBytes { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required string ArtifactType { get; init; }
}
/// <summary>
/// Response for artifact metadata.
/// </summary>
public sealed record ArtifactMetadataResponse
{
public required string ArtifactId { get; init; }
public required string BomRef { get; init; }
public required string SerialNumber { get; init; }
public required string StorageKey { get; init; }
public required string ContentType { get; init; }
public required string Sha256 { get; init; }
public required long SizeBytes { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required string ArtifactType { get; init; }
}

View File

@@ -0,0 +1,517 @@
// -----------------------------------------------------------------------------
// CycloneDxExtractor.cs
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
// Task: AS-004 - Implement bom-ref extraction from CycloneDX
// Description: Standalone service for extracting metadata from CycloneDX SBOMs
// -----------------------------------------------------------------------------
using System.Text.Json;
using System.Xml;
using System.Xml.Linq;
namespace StellaOps.Artifact.Core;
/// <summary>
/// Extracts metadata from CycloneDX SBOM documents.
/// </summary>
public interface ICycloneDxExtractor
{
/// <summary>
/// Extracts metadata from a CycloneDX JSON document.
/// </summary>
CycloneDxMetadata Extract(JsonDocument document);
/// <summary>
/// Extracts metadata from a CycloneDX JSON stream.
/// </summary>
Task<CycloneDxMetadata> ExtractAsync(Stream stream, CancellationToken ct = default);
/// <summary>
/// Extracts metadata from a CycloneDX XML document.
/// Sprint: SPRINT_20260118_017 (AS-004)
/// </summary>
CycloneDxMetadata ExtractFromXml(XDocument document);
/// <summary>
/// Extracts metadata from a CycloneDX XML stream.
/// Sprint: SPRINT_20260118_017 (AS-004)
/// </summary>
Task<CycloneDxMetadata> ExtractFromXmlAsync(Stream stream, CancellationToken ct = default);
/// <summary>
/// Auto-detects format (JSON or XML) and extracts metadata.
/// Sprint: SPRINT_20260118_017 (AS-004)
/// </summary>
Task<CycloneDxMetadata> ExtractAutoAsync(Stream stream, CancellationToken ct = default);
}
/// <summary>
/// Extracted metadata from a CycloneDX document.
/// </summary>
public sealed record CycloneDxMetadata
{
/// <summary>SBOM serial number (URN).</summary>
public string? SerialNumber { get; init; }
/// <summary>SBOM version.</summary>
public int Version { get; init; }
/// <summary>CycloneDX spec version.</summary>
public string? SpecVersion { get; init; }
/// <summary>Primary component bom-ref.</summary>
public string? PrimaryBomRef { get; init; }
/// <summary>Primary component name.</summary>
public string? PrimaryName { get; init; }
/// <summary>Primary component version.</summary>
public string? PrimaryVersion { get; init; }
/// <summary>Primary component purl.</summary>
public string? PrimaryPurl { get; init; }
/// <summary>All component bom-refs.</summary>
public IReadOnlyList<string> ComponentBomRefs { get; init; } = [];
/// <summary>All component purls.</summary>
public IReadOnlyList<string> ComponentPurls { get; init; } = [];
/// <summary>Total component count.</summary>
public int ComponentCount { get; init; }
/// <summary>Timestamp from metadata.</summary>
public DateTimeOffset? Timestamp { get; init; }
/// <summary>Extraction succeeded.</summary>
public bool Success { get; init; }
/// <summary>Extraction error if failed.</summary>
public string? Error { get; init; }
}
/// <summary>
/// Default implementation of CycloneDX extractor.
/// </summary>
public sealed class CycloneDxExtractor : ICycloneDxExtractor
{
/// <inheritdoc />
public CycloneDxMetadata Extract(JsonDocument document)
{
ArgumentNullException.ThrowIfNull(document);
try
{
var root = document.RootElement;
// Extract serial number
string? serialNumber = null;
if (root.TryGetProperty("serialNumber", out var serialProp))
{
serialNumber = serialProp.GetString();
}
// Extract version
int version = 1;
if (root.TryGetProperty("version", out var versionProp))
{
version = versionProp.GetInt32();
}
// Extract spec version
string? specVersion = null;
if (root.TryGetProperty("specVersion", out var specProp))
{
specVersion = specProp.GetString();
}
// Extract primary component from metadata
string? primaryBomRef = null;
string? primaryName = null;
string? primaryVersion = null;
string? primaryPurl = null;
if (root.TryGetProperty("metadata", out var metadata))
{
if (metadata.TryGetProperty("component", out var primaryComponent))
{
primaryBomRef = GetStringProperty(primaryComponent, "bom-ref");
primaryName = GetStringProperty(primaryComponent, "name");
primaryVersion = GetStringProperty(primaryComponent, "version");
primaryPurl = GetStringProperty(primaryComponent, "purl");
}
}
// Extract timestamp
DateTimeOffset? timestamp = null;
if (root.TryGetProperty("metadata", out var meta2) &&
meta2.TryGetProperty("timestamp", out var tsProp))
{
if (DateTimeOffset.TryParse(tsProp.GetString(), out var ts))
{
timestamp = ts;
}
}
// Extract all component bom-refs and purls
var bomRefs = new List<string>();
var purls = new List<string>();
int componentCount = 0;
if (root.TryGetProperty("components", out var components))
{
foreach (var component in components.EnumerateArray())
{
componentCount++;
var bomRef = GetStringProperty(component, "bom-ref");
if (bomRef != null)
{
bomRefs.Add(bomRef);
}
var purl = GetStringProperty(component, "purl");
if (purl != null)
{
purls.Add(purl);
}
// Recursively extract from nested components
ExtractNestedComponents(component, bomRefs, purls, ref componentCount);
}
}
return new CycloneDxMetadata
{
SerialNumber = serialNumber,
Version = version,
SpecVersion = specVersion,
PrimaryBomRef = primaryBomRef,
PrimaryName = primaryName,
PrimaryVersion = primaryVersion,
PrimaryPurl = primaryPurl,
ComponentBomRefs = bomRefs,
ComponentPurls = purls,
ComponentCount = componentCount,
Timestamp = timestamp,
Success = true
};
}
catch (Exception ex)
{
return new CycloneDxMetadata
{
Success = false,
Error = ex.Message
};
}
}
/// <inheritdoc />
public async Task<CycloneDxMetadata> ExtractAsync(Stream stream, CancellationToken ct = default)
{
try
{
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
return Extract(document);
}
catch (Exception ex)
{
return new CycloneDxMetadata
{
Success = false,
Error = ex.Message
};
}
}
private static string? GetStringProperty(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out var prop) ? prop.GetString() : null;
}
private static void ExtractNestedComponents(
JsonElement component,
List<string> bomRefs,
List<string> purls,
ref int count)
{
if (!component.TryGetProperty("components", out var nested))
return;
foreach (var child in nested.EnumerateArray())
{
count++;
var bomRef = GetStringProperty(child, "bom-ref");
if (bomRef != null)
{
bomRefs.Add(bomRef);
}
var purl = GetStringProperty(child, "purl");
if (purl != null)
{
purls.Add(purl);
}
// Recurse
ExtractNestedComponents(child, bomRefs, purls, ref count);
}
}
// -------------------------------------------------------------------------
// XML Parsing - Sprint: SPRINT_20260118_017 (AS-004)
// -------------------------------------------------------------------------
private static readonly XNamespace Cdx14 = "http://cyclonedx.org/schema/bom/1.4";
private static readonly XNamespace Cdx15 = "http://cyclonedx.org/schema/bom/1.5";
private static readonly XNamespace Cdx16 = "http://cyclonedx.org/schema/bom/1.6";
/// <inheritdoc />
public CycloneDxMetadata ExtractFromXml(XDocument document)
{
ArgumentNullException.ThrowIfNull(document);
try
{
var root = document.Root;
if (root == null)
{
return new CycloneDxMetadata { Success = false, Error = "Empty XML document" };
}
// Detect namespace
var ns = DetectNamespace(root);
// Extract serial number (attribute on root)
string? serialNumber = root.Attribute("serialNumber")?.Value;
// Extract version
int version = 1;
var versionAttr = root.Attribute("version")?.Value;
if (int.TryParse(versionAttr, out var v))
{
version = v;
}
// Extract spec version from namespace
string? specVersion = ExtractSpecVersion(ns);
// Extract primary component from metadata
string? primaryBomRef = null;
string? primaryName = null;
string? primaryVersion = null;
string? primaryPurl = null;
var metadata = root.Element(ns + "metadata");
if (metadata != null)
{
var primaryComponent = metadata.Element(ns + "component");
if (primaryComponent != null)
{
primaryBomRef = primaryComponent.Attribute("bom-ref")?.Value;
primaryName = primaryComponent.Element(ns + "name")?.Value;
primaryVersion = primaryComponent.Element(ns + "version")?.Value;
primaryPurl = primaryComponent.Element(ns + "purl")?.Value;
}
}
// Extract timestamp
DateTimeOffset? timestamp = null;
var tsElement = metadata?.Element(ns + "timestamp");
if (tsElement != null && DateTimeOffset.TryParse(tsElement.Value, out var ts))
{
timestamp = ts;
}
// Extract all components
var bomRefs = new List<string>();
var purls = new List<string>();
int componentCount = 0;
var componentsElement = root.Element(ns + "components");
if (componentsElement != null)
{
ExtractXmlComponents(componentsElement, ns, bomRefs, purls, ref componentCount);
}
return new CycloneDxMetadata
{
SerialNumber = serialNumber,
Version = version,
SpecVersion = specVersion,
PrimaryBomRef = primaryBomRef,
PrimaryName = primaryName,
PrimaryVersion = primaryVersion,
PrimaryPurl = primaryPurl,
ComponentBomRefs = bomRefs,
ComponentPurls = purls,
ComponentCount = componentCount,
Timestamp = timestamp,
Success = true
};
}
catch (Exception ex)
{
return new CycloneDxMetadata
{
Success = false,
Error = ex.Message
};
}
}
/// <inheritdoc />
public async Task<CycloneDxMetadata> ExtractFromXmlAsync(Stream stream, CancellationToken ct = default)
{
try
{
var settings = new XmlReaderSettings
{
Async = true,
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null
};
using var reader = XmlReader.Create(stream, settings);
var document = await XDocument.LoadAsync(reader, LoadOptions.None, ct);
return ExtractFromXml(document);
}
catch (Exception ex)
{
return new CycloneDxMetadata
{
Success = false,
Error = ex.Message
};
}
}
/// <inheritdoc />
public async Task<CycloneDxMetadata> ExtractAutoAsync(Stream stream, CancellationToken ct = default)
{
// Read first bytes to detect format
var buffer = new byte[1];
var bytesRead = await stream.ReadAsync(buffer, ct);
if (bytesRead == 0)
{
return new CycloneDxMetadata { Success = false, Error = "Empty stream" };
}
// Reset stream
stream.Position = 0;
// Detect format by first character
char firstChar = (char)buffer[0];
// Skip BOM if present
if (firstChar == '\uFEFF')
{
buffer = new byte[1];
await stream.ReadAsync(buffer, ct);
stream.Position = 0;
// Skip 3-byte UTF-8 BOM
if (stream.Length >= 3)
{
var bomBuffer = new byte[3];
await stream.ReadAsync(bomBuffer, ct);
if (bomBuffer[0] == 0xEF && bomBuffer[1] == 0xBB && bomBuffer[2] == 0xBF)
{
firstChar = (char)stream.ReadByte();
stream.Position = 3; // After BOM
}
else
{
stream.Position = 0;
firstChar = (char)buffer[0];
}
}
}
stream.Position = 0;
if (firstChar == '{' || firstChar == '[')
{
// JSON format
return await ExtractAsync(stream, ct);
}
else if (firstChar == '<')
{
// XML format
return await ExtractFromXmlAsync(stream, ct);
}
else
{
// Try JSON first, fallback to XML
try
{
return await ExtractAsync(stream, ct);
}
catch
{
stream.Position = 0;
return await ExtractFromXmlAsync(stream, ct);
}
}
}
private static XNamespace DetectNamespace(XElement root)
{
var ns = root.Name.Namespace;
if (ns == Cdx16 || ns.NamespaceName.Contains("1.6"))
return Cdx16;
if (ns == Cdx15 || ns.NamespaceName.Contains("1.5"))
return Cdx15;
if (ns == Cdx14 || ns.NamespaceName.Contains("1.4"))
return Cdx14;
// Default to detected namespace
return ns;
}
private static string? ExtractSpecVersion(XNamespace ns)
{
if (ns == Cdx16 || ns.NamespaceName.Contains("1.6"))
return "1.6";
if (ns == Cdx15 || ns.NamespaceName.Contains("1.5"))
return "1.5";
if (ns == Cdx14 || ns.NamespaceName.Contains("1.4"))
return "1.4";
return null;
}
private static void ExtractXmlComponents(
XElement componentsElement,
XNamespace ns,
List<string> bomRefs,
List<string> purls,
ref int count)
{
foreach (var component in componentsElement.Elements(ns + "component"))
{
count++;
var bomRef = component.Attribute("bom-ref")?.Value;
if (bomRef != null)
{
bomRefs.Add(bomRef);
}
var purl = component.Element(ns + "purl")?.Value;
if (purl != null)
{
purls.Add(purl);
}
// Recurse into nested components
var nested = component.Element(ns + "components");
if (nested != null)
{
ExtractXmlComponents(nested, ns, bomRefs, purls, ref count);
}
}
}
}

View File

@@ -0,0 +1,379 @@
// -----------------------------------------------------------------------------
// IArtifactStore.cs
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
// Task: AS-001 - Design unified IArtifactStore interface
// Description: Unified artifact storage interface with bom-ref support
// -----------------------------------------------------------------------------
namespace StellaOps.Artifact.Core;
/// <summary>
/// Unified artifact store interface supporting bom-ref based storage and retrieval.
/// Path convention: /artifacts/{bom-ref-encoded}/{serialNumber}/{artifactId}.json
/// </summary>
public interface IArtifactStore
{
/// <summary>
/// Stores an artifact.
/// </summary>
/// <param name="request">Storage request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Storage result.</returns>
Task<ArtifactStoreResult> StoreAsync(ArtifactStoreRequest request, CancellationToken ct = default);
/// <summary>
/// Reads an artifact.
/// </summary>
/// <param name="bomRef">Package URL or component reference.</param>
/// <param name="serialNumber">CycloneDX serialNumber (optional).</param>
/// <param name="artifactId">Artifact ID (optional, returns first match if null).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Read result with content stream.</returns>
Task<ArtifactReadResult> ReadAsync(string bomRef, string? serialNumber, string? artifactId, CancellationToken ct = default);
/// <summary>
/// Lists artifacts for a bom-ref.
/// </summary>
/// <param name="bomRef">Package URL or component reference.</param>
/// <param name="serialNumber">CycloneDX serialNumber (optional filter).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of artifact metadata.</returns>
Task<IReadOnlyList<ArtifactMetadata>> ListAsync(string bomRef, string? serialNumber = null, CancellationToken ct = default);
/// <summary>
/// Checks if an artifact exists.
/// </summary>
Task<bool> ExistsAsync(string bomRef, string serialNumber, string artifactId, CancellationToken ct = default);
/// <summary>
/// Gets artifact metadata without reading content.
/// </summary>
Task<ArtifactMetadata?> GetMetadataAsync(string bomRef, string serialNumber, string artifactId, CancellationToken ct = default);
/// <summary>
/// Deletes an artifact (soft delete, preserves for audit).
/// </summary>
Task<bool> DeleteAsync(string bomRef, string serialNumber, string artifactId, CancellationToken ct = default);
}
/// <summary>
/// Request to store an artifact.
/// </summary>
public sealed record ArtifactStoreRequest
{
/// <summary>
/// Package URL (purl) or CycloneDX bom-ref.
/// </summary>
public required string BomRef { get; init; }
/// <summary>
/// CycloneDX serialNumber URN (e.g., urn:uuid:...).
/// </summary>
public required string SerialNumber { get; init; }
/// <summary>
/// Unique artifact identifier (e.g., DSSE UUID, hash).
/// </summary>
public required string ArtifactId { get; init; }
/// <summary>
/// Artifact content stream.
/// </summary>
public required Stream Content { get; init; }
/// <summary>
/// Content type (MIME type).
/// </summary>
public required string ContentType { get; init; }
/// <summary>
/// Artifact type classification.
/// </summary>
public ArtifactType Type { get; init; } = ArtifactType.Unknown;
/// <summary>
/// Additional metadata.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
/// <summary>
/// Tenant ID for multi-tenancy.
/// </summary>
public Guid TenantId { get; init; }
/// <summary>
/// Whether to overwrite existing artifact.
/// </summary>
public bool Overwrite { get; init; } = false;
}
/// <summary>
/// Result of storing an artifact.
/// </summary>
public sealed record ArtifactStoreResult
{
/// <summary>
/// Whether storage was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Storage key (full path).
/// </summary>
public string? StorageKey { get; init; }
/// <summary>
/// SHA-256 hash of stored content.
/// </summary>
public string? Sha256 { get; init; }
/// <summary>
/// Size in bytes.
/// </summary>
public long? SizeBytes { get; init; }
/// <summary>
/// Error message if failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Whether this was a new artifact or an update.
/// </summary>
public bool WasCreated { get; init; }
/// <summary>
/// Creates a success result.
/// </summary>
public static ArtifactStoreResult Succeeded(string storageKey, string sha256, long sizeBytes, bool wasCreated = true)
{
return new ArtifactStoreResult
{
Success = true,
StorageKey = storageKey,
Sha256 = sha256,
SizeBytes = sizeBytes,
WasCreated = wasCreated
};
}
/// <summary>
/// Creates a failure result.
/// </summary>
public static ArtifactStoreResult Failed(string errorMessage)
{
return new ArtifactStoreResult
{
Success = false,
ErrorMessage = errorMessage
};
}
}
/// <summary>
/// Result of reading an artifact.
/// </summary>
public sealed record ArtifactReadResult : IDisposable
{
/// <summary>
/// Whether the artifact was found.
/// </summary>
public required bool Found { get; init; }
/// <summary>
/// Content stream (caller must dispose).
/// </summary>
public Stream? Content { get; init; }
/// <summary>
/// Artifact metadata.
/// </summary>
public ArtifactMetadata? Metadata { get; init; }
/// <summary>
/// Error message if not found.
/// </summary>
public string? ErrorMessage { get; init; }
/// <inheritdoc />
public void Dispose()
{
Content?.Dispose();
}
/// <summary>
/// Creates a found result.
/// </summary>
public static ArtifactReadResult Succeeded(Stream content, ArtifactMetadata metadata)
{
return new ArtifactReadResult
{
Found = true,
Content = content,
Metadata = metadata
};
}
/// <summary>
/// Creates a not found result.
/// </summary>
public static ArtifactReadResult NotFound(string? message = null)
{
return new ArtifactReadResult
{
Found = false,
ErrorMessage = message ?? "Artifact not found"
};
}
}
/// <summary>
/// Artifact metadata.
/// </summary>
public sealed record ArtifactMetadata
{
/// <summary>
/// Full storage key/path.
/// </summary>
public required string StorageKey { get; init; }
/// <summary>
/// Package URL or bom-ref.
/// </summary>
public required string BomRef { get; init; }
/// <summary>
/// CycloneDX serialNumber.
/// </summary>
public required string SerialNumber { get; init; }
/// <summary>
/// Artifact ID.
/// </summary>
public required string ArtifactId { get; init; }
/// <summary>
/// Content type (MIME).
/// </summary>
public required string ContentType { get; init; }
/// <summary>
/// Size in bytes.
/// </summary>
public required long SizeBytes { get; init; }
/// <summary>
/// SHA-256 hash.
/// </summary>
public required string Sha256 { get; init; }
/// <summary>
/// Creation timestamp.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Artifact type.
/// </summary>
public ArtifactType Type { get; init; }
/// <summary>
/// Tenant ID.
/// </summary>
public Guid TenantId { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
public IReadOnlyDictionary<string, string>? ExtraMetadata { get; init; }
}
/// <summary>
/// Artifact type classification.
/// </summary>
public enum ArtifactType
{
/// <summary>Unknown type.</summary>
Unknown,
/// <summary>SBOM (CycloneDX or SPDX).</summary>
Sbom,
/// <summary>VEX document.</summary>
Vex,
/// <summary>DSSE envelope/attestation.</summary>
DsseEnvelope,
/// <summary>Rekor transparency log proof.</summary>
RekorProof,
/// <summary>Verdict record.</summary>
Verdict,
/// <summary>Policy bundle.</summary>
PolicyBundle,
/// <summary>Provenance attestation.</summary>
Provenance,
/// <summary>Build log.</summary>
BuildLog,
/// <summary>Test results.</summary>
TestResults,
/// <summary>Scan results.</summary>
ScanResults
}
/// <summary>
/// Utility for encoding bom-refs for path usage.
/// </summary>
public static class BomRefEncoder
{
/// <summary>
/// Encodes a bom-ref/purl for use in storage paths.
/// Handles special characters in purls (/, @, :, etc.).
/// </summary>
public static string Encode(string bomRef)
{
if (string.IsNullOrWhiteSpace(bomRef))
return "unknown";
// Replace path-unsafe characters
return bomRef
.Replace("/", "_")
.Replace(":", "_")
.Replace("@", "_at_")
.Replace("?", "_q_")
.Replace("#", "_h_")
.Replace("%", "_p_");
}
/// <summary>
/// Decodes an encoded bom-ref back to original form.
/// </summary>
public static string Decode(string encoded)
{
if (string.IsNullOrWhiteSpace(encoded))
return string.Empty;
return encoded
.Replace("_at_", "@")
.Replace("_q_", "?")
.Replace("_h_", "#")
.Replace("_p_", "%");
// Note: / and : remain as _ since they're ambiguous
}
/// <summary>
/// Builds the storage path for an artifact.
/// </summary>
public static string BuildPath(string bomRef, string serialNumber, string artifactId)
{
var encodedBomRef = Encode(bomRef);
var encodedSerial = Encode(serialNumber);
return $"artifacts/{encodedBomRef}/{encodedSerial}/{artifactId}.json";
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Artifact.Core</RootNamespace>
<AssemblyName>StellaOps.Artifact.Core</AssemblyName>
<Description>Unified artifact storage interfaces and models for StellaOps evidence management</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>