stabilizaiton work - projects rework for maintenanceability and ui livening
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactController.Delete.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-007 - Query endpoint for artifacts by bom-ref
|
||||
// Description: Delete endpoint for unified artifact storage
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
public sealed partial class ArtifactController
|
||||
{
|
||||
/// <summary>
|
||||
/// Deletes an artifact (soft delete).
|
||||
/// </summary>
|
||||
[HttpDelete("{bomRef}/{serialNumber}/{artifactId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteArtifactAsync(
|
||||
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)
|
||||
.ConfigureAwait(false);
|
||||
if (!deleted)
|
||||
{
|
||||
return NotFound(BuildProblemDetails(
|
||||
"Not found",
|
||||
$"Artifact not found: {artifactId}"));
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactController.Download.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-007 - Query endpoint for artifacts by bom-ref
|
||||
// Description: Download endpoint for unified artifact storage content
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
public sealed partial class ArtifactController
|
||||
{
|
||||
/// <summary>
|
||||
/// Downloads artifact content.
|
||||
/// </summary>
|
||||
[HttpGet("{bomRef}/{serialNumber}/{artifactId}/content")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DownloadArtifactAsync(
|
||||
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)
|
||||
.ConfigureAwait(false);
|
||||
if (!result.Found || result.Content == null)
|
||||
{
|
||||
return NotFound(BuildProblemDetails(
|
||||
"Not found",
|
||||
result.ErrorMessage ?? $"Artifact not found: {artifactId}"));
|
||||
}
|
||||
|
||||
return File(result.Content, result.Metadata!.ContentType, $"{artifactId}.json");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactController.Fetch.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-005 - Create artifact submission endpoint
|
||||
// Description: Content fetch helpers for artifact submissions
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
public sealed partial class ArtifactController
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches content from a URI (S3, HTTP, file).
|
||||
/// Sprint: SPRINT_20260118_017 (AS-005) - Validates dsse_uri accessibility.
|
||||
/// </summary>
|
||||
private async Task<byte[]> FetchContentFromUriAsync(string uri, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(uri);
|
||||
|
||||
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).ConfigureAwait(false),
|
||||
"http" or "https" => await FetchFromHttpAsync(parsedUri, ct).ConfigureAwait(false),
|
||||
"file" => await FetchFromFileAsync(parsedUri, ct).ConfigureAwait(false),
|
||||
_ => throw new NotSupportedException($"URI scheme not supported: {parsedUri.Scheme}")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactController.FetchFile.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-005 - Create artifact submission endpoint
|
||||
// Description: File fetch for artifact submissions
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
public sealed partial class ArtifactController
|
||||
{
|
||||
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)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"File too large: {fileInfo.Length} bytes exceeds 100MB limit");
|
||||
}
|
||||
|
||||
return await System.IO.File.ReadAllBytesAsync(filePath, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactController.FetchHttp.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-005 - Create artifact submission endpoint
|
||||
// Description: HTTP fetch for artifact submissions
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
public sealed partial class ArtifactController
|
||||
{
|
||||
private async Task<byte[]> FetchFromHttpAsync(Uri uri, CancellationToken ct)
|
||||
{
|
||||
_logger.LogDebug("Fetching from HTTP: {Uri}", uri);
|
||||
|
||||
using var httpClient = new HttpClient();
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
try
|
||||
{
|
||||
using var headRequest = new HttpRequestMessage(HttpMethod.Head, uri);
|
||||
using var headResponse = await httpClient.SendAsync(headRequest, ct).ConfigureAwait(false);
|
||||
|
||||
if (!headResponse.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"URI not accessible: {uri} returned {headResponse.StatusCode}");
|
||||
}
|
||||
|
||||
var contentLength = headResponse.Content.Headers.ContentLength;
|
||||
if (contentLength > 100 * 1024 * 1024)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Content too large: {contentLength} bytes exceeds 100MB limit");
|
||||
}
|
||||
|
||||
return await httpClient.GetByteArrayAsync(uri, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to fetch from {uri}: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactController.FetchS3.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-005 - Create artifact submission endpoint
|
||||
// Description: S3 fetch placeholder for artifact submissions
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
public sealed partial class ArtifactController
|
||||
{
|
||||
private Task<byte[]> FetchFromS3Async(Uri uri, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var bucket = uri.Host;
|
||||
var key = uri.AbsolutePath.TrimStart('/');
|
||||
|
||||
_logger.LogDebug("Fetching from S3: bucket={Bucket}, key={Key}", bucket, key);
|
||||
|
||||
return Task.FromException<byte[]>(new NotImplementedException(
|
||||
$"S3 fetch not fully implemented. Configure S3 client. URI: s3://{bucket}/{key}"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactController.Get.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-007 - Query endpoint for artifacts by bom-ref
|
||||
// Description: Get endpoint for unified artifact storage metadata
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
public sealed partial class ArtifactController
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a specific artifact by its composite key.
|
||||
/// </summary>
|
||||
[HttpGet("{bomRef}/{serialNumber}/{artifactId}")]
|
||||
[ActionName(GetArtifactActionName)]
|
||||
[ProducesResponseType(typeof(ArtifactMetadataResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetArtifactAsync(
|
||||
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)
|
||||
.ConfigureAwait(false);
|
||||
if (metadata == null)
|
||||
{
|
||||
return NotFound(BuildProblemDetails(
|
||||
"Not found",
|
||||
$"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()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactController.Helpers.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-005 - Create artifact submission endpoint
|
||||
// Description: Shared helpers for artifact controller operations
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Artifact.Core;
|
||||
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
public sealed partial class ArtifactController
|
||||
{
|
||||
private Guid GetTenantId()
|
||||
{
|
||||
var tenantClaim = User.FindFirst("tenant_id")?.Value;
|
||||
return Guid.TryParse(tenantClaim, out var id) ? id : Guid.Empty;
|
||||
}
|
||||
|
||||
private static string GenerateSyntheticSerial(string bomRef)
|
||||
{
|
||||
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(type, ignoreCase: true, out ArtifactType 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"
|
||||
};
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactController.List.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-007 - Query endpoint for artifacts by bom-ref
|
||||
// Description: List endpoint for unified artifact storage
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
public sealed partial class ArtifactController
|
||||
{
|
||||
/// <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> ListArtifactsAsync(
|
||||
[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 = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bomRef))
|
||||
{
|
||||
return BadRequest(BuildProblemDetails("Invalid request", "bom_ref query parameter is required"));
|
||||
}
|
||||
if (limit < 1 || limit > 1000)
|
||||
{
|
||||
limit = 100;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var artifacts = await _artifactStore.ListAsync(bomRef, serialNumber, ct).ConfigureAwait(false);
|
||||
|
||||
if (from.HasValue)
|
||||
{
|
||||
artifacts = artifacts.Where(a => a.CreatedAt >= from.Value).ToList();
|
||||
}
|
||||
|
||||
if (to.HasValue)
|
||||
{
|
||||
artifacts = artifacts.Where(a => a.CreatedAt < to.Value).ToList();
|
||||
}
|
||||
|
||||
var offset = ParseContinuationToken(continuationToken);
|
||||
var totalCount = artifacts.Count;
|
||||
var pagedArtifacts = artifacts.Skip(offset).Take(limit).ToList();
|
||||
|
||||
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,
|
||||
BuildProblemDetails("Internal error", "An unexpected error occurred while listing artifacts"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactController.Submit.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-005 - Create artifact submission endpoint
|
||||
// Description: Submit endpoint for unified artifact storage
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Artifact.Core;
|
||||
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
public sealed partial class ArtifactController
|
||||
{
|
||||
/// <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> SubmitArtifactAsync(
|
||||
[FromBody] ArtifactSubmissionRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(request.BomRef))
|
||||
{
|
||||
return BadRequest(BuildProblemDetails(
|
||||
"Invalid bom_ref", "bom_ref is required and must be a valid Package URL or CycloneDX bom-ref"));
|
||||
}
|
||||
try
|
||||
{
|
||||
var serialNumber = request.CyclonedxSerial ?? GenerateSyntheticSerial(request.BomRef);
|
||||
var (content, contentError) = await ResolveContentAsync(request, ct).ConfigureAwait(false);
|
||||
if (contentError != null)
|
||||
{
|
||||
return BadRequest(contentError);
|
||||
}
|
||||
|
||||
var artifactId = request.ArtifactId ?? Guid.NewGuid().ToString();
|
||||
var contentType = request.ContentType ?? DetermineContentType(request.ArtifactType);
|
||||
var tenantId = GetTenantId();
|
||||
|
||||
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).ConfigureAwait(false);
|
||||
if (!result.Success)
|
||||
{
|
||||
return StatusCode(
|
||||
StatusCodes.Status500InternalServerError,
|
||||
BuildProblemDetails("Storage failed", result.ErrorMessage ?? "Storage failed"));
|
||||
}
|
||||
|
||||
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(
|
||||
GetArtifactActionName, new { bomRef = request.BomRef, serialNumber, artifactId }, response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to submit artifact");
|
||||
return StatusCode(
|
||||
StatusCodes.Status500InternalServerError,
|
||||
BuildProblemDetails("Internal error", "An unexpected error occurred while storing the artifact"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactController.SubmitHelpers.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-005 - Create artifact submission endpoint
|
||||
// Description: Helper utilities for artifact submission
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
public sealed partial class ArtifactController
|
||||
{
|
||||
private static ProblemDetails BuildProblemDetails(string title, string detail)
|
||||
{
|
||||
return new ProblemDetails
|
||||
{
|
||||
Title = title,
|
||||
Detail = detail
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<(byte[]? Content, ProblemDetails? Error)> ResolveContentAsync(
|
||||
ArtifactSubmissionRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(request.ContentBase64))
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Convert.FromBase64String(request.ContentBase64), null);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return (null, BuildProblemDetails(
|
||||
"Invalid content",
|
||||
"content_base64 must be valid Base64-encoded data"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(request.DsseUri))
|
||||
{
|
||||
var content = await FetchContentFromUriAsync(request.DsseUri, ct).ConfigureAwait(false);
|
||||
return (content, null);
|
||||
}
|
||||
|
||||
return (null, BuildProblemDetails(
|
||||
"Missing content",
|
||||
"Either content_base64 or dsse_uri must be provided"));
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,10 @@
|
||||
// AS-007 - Query endpoint for artifacts by bom-ref
|
||||
// Description: API controller for unified artifact storage
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Artifact.Core;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
@@ -23,589 +19,18 @@ namespace StellaOps.Artifact.Api;
|
||||
[Route("api/v1/artifacts")]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public sealed class ArtifactController : ControllerBase
|
||||
public sealed partial class ArtifactController : ControllerBase
|
||||
{
|
||||
private const string GetArtifactActionName = "GetArtifact";
|
||||
|
||||
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 = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactListItem.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-007 - Query endpoint for artifacts by bom-ref
|
||||
// Description: Item payload for artifact listing
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactListResponse.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-007 - Query endpoint for artifacts by bom-ref
|
||||
// Description: Response payload for artifact listing
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactMetadataResponse.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-007 - Query endpoint for artifacts by bom-ref
|
||||
// Description: Response payload for artifact metadata
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactSubmissionRequest.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-005 - Create artifact submission endpoint
|
||||
// Description: Request payload for artifact submissions
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactSubmissionResponse.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-005 - Create artifact submission endpoint
|
||||
// Description: Response payload for artifact submissions
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
68
src/__Libraries/StellaOps.Artifact.Core/ArtifactMetadata.cs
Normal file
68
src/__Libraries/StellaOps.Artifact.Core/ArtifactMetadata.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactMetadata.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-001 - Design unified IArtifactStore interface
|
||||
// Description: Metadata record for unified artifact store entries
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Artifact.Core;
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactReadResult.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-001 - Design unified IArtifactStore interface
|
||||
// Description: Read operation result for unified artifact store operations
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Artifact.Core;
|
||||
|
||||
/// <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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactStoreRequest.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-001 - Design unified IArtifactStore interface
|
||||
// Description: Storage request for unified artifact store operations
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Artifact.Core;
|
||||
|
||||
/// <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;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactStoreResult.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-001 - Design unified IArtifactStore interface
|
||||
// Description: Store operation result for unified artifact store operations
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Artifact.Core;
|
||||
|
||||
/// <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
|
||||
};
|
||||
}
|
||||
}
|
||||
46
src/__Libraries/StellaOps.Artifact.Core/ArtifactType.cs
Normal file
46
src/__Libraries/StellaOps.Artifact.Core/ArtifactType.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ArtifactType.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-001 - Design unified IArtifactStore interface
|
||||
// Description: Classification enum for artifact types
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Artifact.Core;
|
||||
|
||||
/// <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
|
||||
}
|
||||
62
src/__Libraries/StellaOps.Artifact.Core/BomRefEncoder.cs
Normal file
62
src/__Libraries/StellaOps.Artifact.Core/BomRefEncoder.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BomRefEncoder.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-001 - Design unified IArtifactStore interface
|
||||
// Description: Utility for encoding bom-refs for storage paths
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Artifact.Core;
|
||||
|
||||
/// <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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CycloneDxExtractor.Auto.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-004 - Implement bom-ref extraction from CycloneDX
|
||||
// Description: Auto-detection for CycloneDX JSON/XML extraction
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Artifact.Core;
|
||||
|
||||
public sealed partial class CycloneDxExtractor
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<CycloneDxMetadata> ExtractAutoAsync(Stream stream, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
var buffer = new byte[1];
|
||||
var bytesRead = await stream.ReadAsync(buffer, ct).ConfigureAwait(false);
|
||||
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
return new CycloneDxMetadata { Success = false, Error = "Empty stream" };
|
||||
}
|
||||
|
||||
stream.Position = 0;
|
||||
|
||||
var firstChar = (char)buffer[0];
|
||||
|
||||
if (firstChar == '\uFEFF')
|
||||
{
|
||||
buffer = new byte[1];
|
||||
await stream.ReadExactlyAsync(buffer, ct).ConfigureAwait(false);
|
||||
stream.Position = 0;
|
||||
if (stream.Length >= 3)
|
||||
{
|
||||
var bomBuffer = new byte[3];
|
||||
await stream.ReadExactlyAsync(bomBuffer, ct).ConfigureAwait(false);
|
||||
if (bomBuffer[0] == 0xEF && bomBuffer[1] == 0xBB && bomBuffer[2] == 0xBF)
|
||||
{
|
||||
firstChar = (char)stream.ReadByte();
|
||||
stream.Position = 3;
|
||||
}
|
||||
else
|
||||
{
|
||||
stream.Position = 0;
|
||||
firstChar = (char)buffer[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stream.Position = 0;
|
||||
|
||||
if (firstChar == '{' || firstChar == '[')
|
||||
{
|
||||
return await ExtractAsync(stream, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (firstChar == '<')
|
||||
{
|
||||
return await ExtractFromXmlAsync(stream, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await ExtractAsync(stream, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
stream.Position = 0;
|
||||
return await ExtractFromXmlAsync(stream, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CycloneDxExtractor.Json.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-004 - Implement bom-ref extraction from CycloneDX
|
||||
// Description: JSON extraction path for CycloneDX metadata
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Artifact.Core;
|
||||
|
||||
public sealed partial class CycloneDxExtractor
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public CycloneDxMetadata Extract(JsonDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
try
|
||||
{
|
||||
var root = document.RootElement;
|
||||
var version = ExtractBomVersion(root);
|
||||
var serialNumber = GetStringProperty(root, "serialNumber");
|
||||
var specVersion = GetStringProperty(root, "specVersion");
|
||||
|
||||
string? primaryBomRef = null;
|
||||
string? primaryName = null;
|
||||
string? primaryVersion = null;
|
||||
string? primaryPurl = null;
|
||||
DateTimeOffset? timestamp = null;
|
||||
if (root.TryGetProperty("metadata", out var metadata))
|
||||
{
|
||||
if (metadata.TryGetProperty("component", out var component))
|
||||
{
|
||||
primaryBomRef = GetStringProperty(component, "bom-ref");
|
||||
primaryName = GetStringProperty(component, "name");
|
||||
primaryVersion = GetStringProperty(component, "version");
|
||||
primaryPurl = GetStringProperty(component, "purl");
|
||||
}
|
||||
|
||||
var timestampValue = GetStringProperty(metadata, "timestamp");
|
||||
if (timestampValue != null &&
|
||||
DateTimeOffset.TryParse(timestampValue, out var parsedTimestamp))
|
||||
{
|
||||
timestamp = parsedTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
var bomRefs = new List<string>();
|
||||
var purls = new List<string>();
|
||||
var componentCount = 0;
|
||||
|
||||
if (root.TryGetProperty("components", out var components) &&
|
||||
components.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
ExtractNestedComponents(component, bomRefs, purls, ref componentCount);
|
||||
}
|
||||
}
|
||||
|
||||
return new CycloneDxMetadata
|
||||
{
|
||||
SerialNumber = NormalizeParsedString(serialNumber),
|
||||
Version = version,
|
||||
SpecVersion = NormalizeParsedString(specVersion),
|
||||
PrimaryBomRef = NormalizeParsedString(primaryBomRef),
|
||||
PrimaryName = NormalizeParsedString(primaryName),
|
||||
PrimaryVersion = NormalizeParsedString(primaryVersion),
|
||||
PrimaryPurl = NormalizeParsedString(primaryPurl),
|
||||
ComponentBomRefs = bomRefs,
|
||||
ComponentPurls = purls,
|
||||
ComponentCount = componentCount,
|
||||
Timestamp = timestamp,
|
||||
Success = true
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CycloneDxMetadata
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CycloneDxExtractor.JsonAsync.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-004 - Implement bom-ref extraction from CycloneDX
|
||||
// Description: Async JSON extraction path for CycloneDX metadata
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Artifact.Core;
|
||||
|
||||
public sealed partial class CycloneDxExtractor
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<CycloneDxMetadata> ExtractAsync(Stream stream, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
try
|
||||
{
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct)
|
||||
.ConfigureAwait(false);
|
||||
return Extract(document);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CycloneDxMetadata
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CycloneDxExtractor.JsonHelpers.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-004 - Implement bom-ref extraction from CycloneDX
|
||||
// Description: JSON helper utilities for CycloneDX extraction
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Artifact.Core;
|
||||
|
||||
public sealed partial class CycloneDxExtractor
|
||||
{
|
||||
private static string? GetStringProperty(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String
|
||||
? 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) ||
|
||||
nested.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var nestedComponent in nested.EnumerateArray())
|
||||
{
|
||||
count++;
|
||||
|
||||
var bomRef = GetStringProperty(nestedComponent, "bom-ref");
|
||||
if (bomRef != null)
|
||||
{
|
||||
bomRefs.Add(bomRef);
|
||||
}
|
||||
|
||||
var purl = GetStringProperty(nestedComponent, "purl");
|
||||
if (purl != null)
|
||||
{
|
||||
purls.Add(purl);
|
||||
}
|
||||
|
||||
ExtractNestedComponents(nestedComponent, bomRefs, purls, ref count);
|
||||
}
|
||||
}
|
||||
|
||||
private static int ExtractBomVersion(JsonElement root)
|
||||
{
|
||||
if (root.TryGetProperty("version", out var versionProp) &&
|
||||
versionProp.ValueKind == JsonValueKind.Number &&
|
||||
versionProp.TryGetInt32(out var version))
|
||||
{
|
||||
return version;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static string? NormalizeParsedString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CycloneDxExtractor.Parsed.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-004 - Implement bom-ref extraction from CycloneDX
|
||||
// Description: Parsed SBOM extraction for CycloneDX metadata
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
namespace StellaOps.Artifact.Core;
|
||||
|
||||
public sealed partial class CycloneDxExtractor
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<ParsedSbom> ExtractParsedAsync(Stream stream, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
if (stream.CanSeek)
|
||||
{
|
||||
stream.Position = 0;
|
||||
}
|
||||
|
||||
return await _parser.ParseAsync(stream, SbomFormat.CycloneDX, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CycloneDxExtractor.Xml.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-004 - Implement bom-ref extraction from CycloneDX
|
||||
// Description: XML extraction path for CycloneDX metadata
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace StellaOps.Artifact.Core;
|
||||
|
||||
public sealed partial class CycloneDxExtractor
|
||||
{
|
||||
/// <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" };
|
||||
}
|
||||
|
||||
var ns = DetectNamespace(root);
|
||||
|
||||
string? serialNumber = root.Attribute("serialNumber")?.Value;
|
||||
|
||||
var version = 1;
|
||||
var versionAttr = root.Attribute("version")?.Value;
|
||||
if (int.TryParse(versionAttr, out var parsedVersion))
|
||||
{
|
||||
version = parsedVersion;
|
||||
}
|
||||
|
||||
var specVersion = ExtractSpecVersion(ns);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
DateTimeOffset? timestamp = null;
|
||||
var tsElement = metadata?.Element(ns + "timestamp");
|
||||
if (tsElement != null && DateTimeOffset.TryParse(tsElement.Value, out var parsedTimestamp))
|
||||
{
|
||||
timestamp = parsedTimestamp;
|
||||
}
|
||||
|
||||
var bomRefs = new List<string>();
|
||||
var purls = new List<string>();
|
||||
var 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CycloneDxExtractor.XmlAsync.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-004 - Implement bom-ref extraction from CycloneDX
|
||||
// Description: Async XML extraction path for CycloneDX metadata
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace StellaOps.Artifact.Core;
|
||||
|
||||
public sealed partial class CycloneDxExtractor
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<CycloneDxMetadata> ExtractFromXmlAsync(Stream stream, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
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)
|
||||
.ConfigureAwait(false);
|
||||
return ExtractFromXml(document);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CycloneDxMetadata
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CycloneDxExtractor.XmlHelpers.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-004 - Implement bom-ref extraction from CycloneDX
|
||||
// Description: XML helper utilities for CycloneDX extraction
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace StellaOps.Artifact.Core;
|
||||
|
||||
public sealed partial class CycloneDxExtractor
|
||||
{
|
||||
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";
|
||||
|
||||
private static XNamespace DetectNamespace(XElement root)
|
||||
{
|
||||
var ns = root.Name.Namespace;
|
||||
|
||||
if (ns == _cdx16 || ns.NamespaceName.Contains("1.6", StringComparison.Ordinal))
|
||||
{
|
||||
return _cdx16;
|
||||
}
|
||||
|
||||
if (ns == _cdx15 || ns.NamespaceName.Contains("1.5", StringComparison.Ordinal))
|
||||
{
|
||||
return _cdx15;
|
||||
}
|
||||
|
||||
if (ns == _cdx14 || ns.NamespaceName.Contains("1.4", StringComparison.Ordinal))
|
||||
{
|
||||
return _cdx14;
|
||||
}
|
||||
|
||||
return ns;
|
||||
}
|
||||
|
||||
private static string? ExtractSpecVersion(XNamespace ns)
|
||||
{
|
||||
if (ns == _cdx16 || ns.NamespaceName.Contains("1.6", StringComparison.Ordinal))
|
||||
{
|
||||
return "1.6";
|
||||
}
|
||||
|
||||
if (ns == _cdx15 || ns.NamespaceName.Contains("1.5", StringComparison.Ordinal))
|
||||
{
|
||||
return "1.5";
|
||||
}
|
||||
|
||||
if (ns == _cdx14 || ns.NamespaceName.Contains("1.4", StringComparison.Ordinal))
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
var nested = component.Element(ns + "components");
|
||||
if (nested != null)
|
||||
{
|
||||
ExtractXmlComponents(nested, ns, bomRefs, purls, ref count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,552 +2,21 @@
|
||||
// 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
|
||||
// Description: CycloneDX metadata extractor entry point
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Concelier.SbomIntegration.Parsing;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
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 enriched SBOM data from a CycloneDX JSON document.
|
||||
/// </summary>
|
||||
ParsedSbom ExtractParsed(JsonDocument document);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts enriched SBOM data from a CycloneDX JSON stream.
|
||||
/// </summary>
|
||||
Task<ParsedSbom> ExtractParsedAsync(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
|
||||
public sealed partial class CycloneDxExtractor : ICycloneDxExtractor
|
||||
{
|
||||
private readonly IParsedSbomParser _parser;
|
||||
|
||||
public CycloneDxExtractor()
|
||||
: this(new ParsedSbomParser(NullLogger<ParsedSbomParser>.Instance))
|
||||
{
|
||||
}
|
||||
|
||||
public CycloneDxExtractor(IParsedSbomParser parser)
|
||||
{
|
||||
_parser = parser ?? throw new ArgumentNullException(nameof(parser));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ParsedSbom ExtractParsed(JsonDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes(document.RootElement.GetRawText());
|
||||
using var stream = new MemoryStream(payload);
|
||||
return _parser.ParseAsync(stream, SbomFormat.CycloneDX).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ParsedSbom> ExtractParsedAsync(Stream stream, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
if (stream.CanSeek)
|
||||
{
|
||||
stream.Position = 0;
|
||||
}
|
||||
|
||||
return await _parser.ParseAsync(stream, SbomFormat.CycloneDX, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public CycloneDxMetadata Extract(JsonDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
try
|
||||
{
|
||||
var root = document.RootElement;
|
||||
var parsed = ExtractParsed(document);
|
||||
var version = ExtractBomVersion(root);
|
||||
|
||||
var bomRefs = new List<string>();
|
||||
var purls = new List<string>();
|
||||
int componentCount = 0;
|
||||
|
||||
if (root.TryGetProperty("components", out var components) &&
|
||||
components.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
var primaryComponent = GetPrimaryComponent(parsed);
|
||||
|
||||
return new CycloneDxMetadata
|
||||
{
|
||||
SerialNumber = NormalizeParsedString(parsed.SerialNumber),
|
||||
Version = version,
|
||||
SpecVersion = NormalizeParsedString(parsed.SpecVersion),
|
||||
PrimaryBomRef = NormalizeParsedString(parsed.Metadata.RootComponentRef),
|
||||
PrimaryName = NormalizeParsedString(parsed.Metadata.Name)
|
||||
?? NormalizeParsedString(primaryComponent?.Name),
|
||||
PrimaryVersion = NormalizeParsedString(parsed.Metadata.Version)
|
||||
?? NormalizeParsedString(primaryComponent?.Version),
|
||||
PrimaryPurl = NormalizeParsedString(primaryComponent?.Purl),
|
||||
ComponentBomRefs = bomRefs,
|
||||
ComponentPurls = purls,
|
||||
ComponentCount = componentCount,
|
||||
Timestamp = parsed.Metadata.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.ReadExactlyAsync(buffer, ct);
|
||||
stream.Position = 0;
|
||||
// Skip 3-byte UTF-8 BOM
|
||||
if (stream.Length >= 3)
|
||||
{
|
||||
var bomBuffer = new byte[3];
|
||||
await stream.ReadExactlyAsync(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 int ExtractBomVersion(JsonElement root)
|
||||
{
|
||||
if (root.TryGetProperty("version", out var versionProp) &&
|
||||
versionProp.ValueKind == JsonValueKind.Number &&
|
||||
versionProp.TryGetInt32(out var version))
|
||||
{
|
||||
return version;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static ParsedComponent? GetPrimaryComponent(ParsedSbom parsed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parsed.Metadata.RootComponentRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.Components.FirstOrDefault(component =>
|
||||
string.Equals(component.BomRef, parsed.Metadata.RootComponentRef, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static string? NormalizeParsedString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
src/__Libraries/StellaOps.Artifact.Core/CycloneDxMetadata.cs
Normal file
52
src/__Libraries/StellaOps.Artifact.Core/CycloneDxMetadata.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CycloneDxMetadata.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-004 - Implement bom-ref extraction from CycloneDX
|
||||
// Description: Metadata extracted from CycloneDX documents
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Artifact.Core;
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
// Task: AS-001 - Design unified IArtifactStore interface
|
||||
// Description: Unified artifact storage interface with bom-ref support
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Artifact.Core;
|
||||
|
||||
/// <summary>
|
||||
@@ -55,325 +54,3 @@ public interface IArtifactStore
|
||||
/// </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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ICycloneDxExtractor.cs
|
||||
// Sprint: SPRINT_20260118_017_Evidence_artifact_store_unification
|
||||
// Task: AS-004 - Implement bom-ref extraction from CycloneDX
|
||||
// Description: Interface for extracting metadata from CycloneDX SBOMs
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using System.Text.Json;
|
||||
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 enriched SBOM data from a CycloneDX JSON stream.
|
||||
/// </summary>
|
||||
Task<ParsedSbom> ExtractParsedAsync(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);
|
||||
}
|
||||
@@ -4,5 +4,5 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Artifact.Core/StellaOps.Artifact.Core.md. |
|
||||
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Artifact.Core/StellaOps.Artifact.Core.md. Tests: `dotnet test src/__Libraries/StellaOps.Artifact.Core.Tests/StellaOps.Artifact.Core.Tests.csproj` (23 tests, MTP0001 warning). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
Reference in New Issue
Block a user