save progress

This commit is contained in:
master
2026-01-09 18:27:36 +02:00
parent e608752924
commit a21d3dbc1f
361 changed files with 63068 additions and 1192 deletions

View File

@@ -23,6 +23,7 @@ using StellaOps.Attestor.Core.Storage;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Attestor.Infrastructure;
using StellaOps.Attestor.Spdx3;
using StellaOps.Attestor.WebService.Options;
using StellaOps.Configuration;
using StellaOps.Cryptography.DependencyInjection;
@@ -129,6 +130,9 @@ internal static class AttestorWebServiceComposition
builder.Services.AddScoped<Services.IProofChainQueryService, Services.ProofChainQueryService>();
builder.Services.AddScoped<Services.IProofVerificationService, Services.ProofVerificationService>();
// SPDX 3.0.1 Build profile support (BP-007)
builder.Services.AddSingleton<IBuildAttestationMapper, BuildAttestationMapper>();
builder.Services.AddSingleton<StellaOps.Attestor.StandardPredicates.IStandardPredicateRegistry>(sp =>
{
var registry = new StellaOps.Attestor.StandardPredicates.StandardPredicateRegistry();

View File

@@ -11,6 +11,7 @@ using StellaOps.Attestor.Core.Signing;
using StellaOps.Attestor.Core.Storage;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Attestor.Spdx3;
using StellaOps.Attestor.WebService.Contracts;
namespace StellaOps.Attestor.WebService;
@@ -394,6 +395,125 @@ internal static class AttestorWebServiceEndpoints
return Results.Ok(BulkVerificationContracts.MapJob(job));
}).RequireAuthorization("attestor:write");
// SPDX 3.0.1 Build Profile export endpoint (BP-007)
app.MapPost("/api/v1/attestations:export-build", (
Spdx3BuildExportRequestDto? requestDto,
HttpContext httpContext,
IBuildAttestationMapper mapper) =>
{
if (requestDto is null)
{
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "Request body is required.");
}
if (!IsJsonContentType(httpContext.Request.ContentType))
{
return UnsupportedMediaTypeResult();
}
if (string.IsNullOrWhiteSpace(requestDto.BuildType))
{
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "buildType is required.");
}
// Build the attestation payload from the request
var configSource = (!string.IsNullOrWhiteSpace(requestDto.ConfigSourceUri) ||
requestDto.ConfigSourceDigest?.Count > 0 ||
!string.IsNullOrWhiteSpace(requestDto.ConfigEntryPoint))
? new ConfigSource
{
Uri = requestDto.ConfigSourceUri,
Digest = requestDto.ConfigSourceDigest ?? new Dictionary<string, string>(),
EntryPoint = requestDto.ConfigEntryPoint
}
: null;
var materials = requestDto.Materials?.Select(m => new BuildMaterial
{
Uri = m.Uri,
Digest = m.Digest ?? new Dictionary<string, string>()
}).ToList() ?? new List<BuildMaterial>();
var attestationPayload = new BuildAttestationPayload
{
BuildType = requestDto.BuildType,
Builder = !string.IsNullOrWhiteSpace(requestDto.BuilderId)
? new BuilderInfo
{
Id = requestDto.BuilderId,
Version = requestDto.BuilderVersion
}
: null,
Invocation = new BuildInvocation
{
ConfigSource = configSource,
Environment = requestDto.Environment ?? new Dictionary<string, string>(),
Parameters = requestDto.Parameters ?? new Dictionary<string, string>()
},
Metadata = new BuildMetadata
{
BuildInvocationId = requestDto.BuildId,
BuildStartedOn = requestDto.BuildStartTime,
BuildFinishedOn = requestDto.BuildEndTime
},
Materials = materials
};
// Check if the payload can be mapped
if (!mapper.CanMapToSpdx3(attestationPayload))
{
return Results.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "Cannot map attestation to SPDX 3.0.1",
detail: "The provided attestation payload is missing required fields for SPDX 3.0.1 Build profile.");
}
// Map to SPDX 3.0.1 Build element
var spdx3Build = mapper.MapToSpdx3(attestationPayload, requestDto.SpdxIdPrefix);
// Build response based on requested format
var response = new Spdx3BuildExportResponseDto
{
Format = requestDto.Format,
BuildSpdxId = spdx3Build.SpdxId,
Spdx3Document = requestDto.Format is BuildAttestationFormat.Spdx3 or BuildAttestationFormat.Both
? new
{
spdxVersion = "SPDX-3.0.1",
conformsTo = new[] { "https://spdx.org/rdf/v3/Build" },
spdxId = $"{requestDto.SpdxIdPrefix}/document",
elements = new object[]
{
new
{
type = spdx3Build.Type,
spdxId = spdx3Build.SpdxId,
name = spdx3Build.Name,
build_buildType = spdx3Build.BuildType,
build_buildId = spdx3Build.BuildId,
build_buildStartTime = spdx3Build.BuildStartTime?.ToString("O", CultureInfo.InvariantCulture),
build_buildEndTime = spdx3Build.BuildEndTime?.ToString("O", CultureInfo.InvariantCulture),
build_configSourceUri = spdx3Build.ConfigSourceUri.IsEmpty ? null : spdx3Build.ConfigSourceUri.ToArray(),
build_configSourceDigest = spdx3Build.ConfigSourceDigest.IsEmpty ? null : spdx3Build.ConfigSourceDigest.Select(h => new { algorithm = h.Algorithm, hashValue = h.HashValue }).ToArray(),
build_configSourceEntrypoint = spdx3Build.ConfigSourceEntrypoint.IsEmpty ? null : spdx3Build.ConfigSourceEntrypoint.ToArray(),
build_environment = spdx3Build.Environment.Count > 0 ? spdx3Build.Environment : null,
build_parameter = spdx3Build.Parameter.Count > 0 ? spdx3Build.Parameter : null
}
}
}
: null,
// DSSE envelope generation would require signing service integration
// For now, return null for DSSE when not specifically requested or when signing is disabled
DsseEnvelope = null,
Signing = null
};
return Results.Ok(response);
})
.RequireAuthorization("attestor:write")
.RequireRateLimiting("attestor-submissions")
.Produces<Spdx3BuildExportResponseDto>(StatusCodes.Status200OK);
}
private static async Task<IResult> GetAttestationDetailResultAsync(

View File

@@ -0,0 +1,221 @@
// -----------------------------------------------------------------------------
// Spdx3BuildProfileContracts.cs
// Sprint: SPRINT_20260107_004_003_BE
// Task: BP-007 - Attestor WebService Integration for SPDX 3.0.1 Build Profile
// Description: DTOs for SPDX 3.0.1 Build profile export endpoint
// -----------------------------------------------------------------------------
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.WebService.Contracts;
/// <summary>
/// Supported export formats for build attestations.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum BuildAttestationFormat
{
/// <summary>
/// DSSE (Dead Simple Signing Envelope) format - default.
/// </summary>
Dsse = 0,
/// <summary>
/// SPDX 3.0.1 Build profile format.
/// </summary>
Spdx3 = 1,
/// <summary>
/// Both DSSE and SPDX 3.0.1 formats combined.
/// </summary>
Both = 2
}
/// <summary>
/// Request to export a build attestation in SPDX 3.0.1 format.
/// </summary>
public sealed record Spdx3BuildExportRequestDto
{
/// <summary>
/// Gets or sets the build type URI (e.g., "https://slsa.dev/provenance/v1").
/// </summary>
[Required]
public required string BuildType { get; init; }
/// <summary>
/// Gets or sets the builder ID URI.
/// </summary>
public string? BuilderId { get; init; }
/// <summary>
/// Gets or sets the builder version.
/// </summary>
public string? BuilderVersion { get; init; }
/// <summary>
/// Gets or sets the build invocation ID.
/// </summary>
public string? BuildId { get; init; }
/// <summary>
/// Gets or sets when the build started.
/// </summary>
public DateTimeOffset? BuildStartTime { get; init; }
/// <summary>
/// Gets or sets when the build finished.
/// </summary>
public DateTimeOffset? BuildEndTime { get; init; }
/// <summary>
/// Gets or sets the configuration source URI.
/// </summary>
public string? ConfigSourceUri { get; init; }
/// <summary>
/// Gets or sets the configuration source digest (algorithm:value).
/// </summary>
public Dictionary<string, string>? ConfigSourceDigest { get; init; }
/// <summary>
/// Gets or sets the configuration entry point.
/// </summary>
public string? ConfigEntryPoint { get; init; }
/// <summary>
/// Gets or sets the build environment variables.
/// </summary>
public Dictionary<string, string>? Environment { get; init; }
/// <summary>
/// Gets or sets the build parameters.
/// </summary>
public Dictionary<string, string>? Parameters { get; init; }
/// <summary>
/// Gets or sets the build materials (source inputs).
/// </summary>
public List<BuildMaterialDto>? Materials { get; init; }
/// <summary>
/// Gets or sets the output format.
/// </summary>
public BuildAttestationFormat Format { get; init; } = BuildAttestationFormat.Dsse;
/// <summary>
/// Gets or sets whether to sign the SPDX 3.0.1 document with DSSE.
/// </summary>
public bool Sign { get; init; } = true;
/// <summary>
/// Gets or sets the SPDX ID prefix for generated elements.
/// </summary>
public string SpdxIdPrefix { get; init; } = "urn:stellaops";
}
/// <summary>
/// Build material (input) DTO.
/// </summary>
public sealed record BuildMaterialDto
{
/// <summary>
/// Gets or sets the material URI.
/// </summary>
[Required]
public required string Uri { get; init; }
/// <summary>
/// Gets or sets the material digest (algorithm:value).
/// </summary>
public Dictionary<string, string>? Digest { get; init; }
}
/// <summary>
/// Response containing SPDX 3.0.1 Build profile export result.
/// </summary>
public sealed record Spdx3BuildExportResponseDto
{
/// <summary>
/// Gets or sets the format of the response.
/// </summary>
public required BuildAttestationFormat Format { get; init; }
/// <summary>
/// Gets or sets the SPDX 3.0.1 document (JSON-LD) when format is Spdx3 or Both.
/// </summary>
public object? Spdx3Document { get; init; }
/// <summary>
/// Gets or sets the DSSE envelope when format is Dsse or Both.
/// </summary>
public DsseEnvelopeDto? DsseEnvelope { get; init; }
/// <summary>
/// Gets or sets the SPDX ID of the generated Build element.
/// </summary>
public string? BuildSpdxId { get; init; }
/// <summary>
/// Gets or sets the signing information.
/// </summary>
public BuildSigningInfoDto? Signing { get; init; }
}
/// <summary>
/// DSSE envelope DTO.
/// </summary>
public sealed record DsseEnvelopeDto
{
/// <summary>
/// Gets or sets the payload type.
/// </summary>
public required string PayloadType { get; init; }
/// <summary>
/// Gets or sets the base64-encoded payload.
/// </summary>
public required string PayloadBase64 { get; init; }
/// <summary>
/// Gets or sets the signatures.
/// </summary>
public required List<DsseSignatureDto> Signatures { get; init; }
}
/// <summary>
/// DSSE signature DTO.
/// </summary>
public sealed record DsseSignatureDto
{
/// <summary>
/// Gets or sets the key ID.
/// </summary>
public required string KeyId { get; init; }
/// <summary>
/// Gets or sets the base64-encoded signature.
/// </summary>
public required string Sig { get; init; }
}
/// <summary>
/// Build signing information DTO.
/// </summary>
public sealed record BuildSigningInfoDto
{
/// <summary>
/// Gets or sets the key ID used for signing.
/// </summary>
public required string KeyId { get; init; }
/// <summary>
/// Gets or sets the signing algorithm.
/// </summary>
public required string Algorithm { get; init; }
/// <summary>
/// Gets or sets when the document was signed.
/// </summary>
public required string SignedAt { get; init; }
}

View File

@@ -29,5 +29,6 @@
<ProjectReference Include="../../__Libraries/StellaOps.Attestor.StandardPredicates/StellaOps.Attestor.StandardPredicates.csproj" />
<ProjectReference Include="../../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Spdx3\StellaOps.Attestor.Spdx3.csproj" />
</ItemGroup>
</Project>

View File

@@ -34,7 +34,7 @@ public sealed class BuildAttestationMapper : IBuildAttestationMapper
ArgumentException.ThrowIfNullOrWhiteSpace(spdxIdPrefix);
var configSourceUris = ImmutableArray<string>.Empty;
var configSourceDigests = ImmutableArray<Spdx3Hash>.Empty;
var configSourceDigests = ImmutableArray<Spdx3BuildHash>.Empty;
var configSourceEntrypoints = ImmutableArray<string>.Empty;
if (attestation.Invocation?.ConfigSource is { } configSource)
@@ -47,7 +47,7 @@ public sealed class BuildAttestationMapper : IBuildAttestationMapper
if (configSource.Digest.Count > 0)
{
configSourceDigests = configSource.Digest
.Select(kvp => new Spdx3Hash { Algorithm = kvp.Key, HashValue = kvp.Value })
.Select(kvp => new Spdx3BuildHash { Algorithm = kvp.Key, HashValue = kvp.Value })
.ToImmutableArray();
}

View File

@@ -35,7 +35,7 @@ public sealed class BuildRelationshipBuilder
public BuildRelationshipBuilder AddBuildToolOf(string toolSpdxId, string artifactSpdxId)
{
_relationships.Add(CreateRelationship(
"BUILD_TOOL_OF",
Spdx3RelationshipType.BuildToolOf,
toolSpdxId,
artifactSpdxId));
return this;
@@ -49,7 +49,7 @@ public sealed class BuildRelationshipBuilder
public BuildRelationshipBuilder AddGenerates(string buildSpdxId, string artifactSpdxId)
{
_relationships.Add(CreateRelationship(
"GENERATES",
Spdx3RelationshipType.Generates,
buildSpdxId,
artifactSpdxId));
return this;
@@ -63,7 +63,7 @@ public sealed class BuildRelationshipBuilder
public BuildRelationshipBuilder AddGeneratedFrom(string artifactSpdxId, string sourceSpdxId)
{
_relationships.Add(CreateRelationship(
"GENERATED_FROM",
Spdx3RelationshipType.GeneratedFrom,
artifactSpdxId,
sourceSpdxId));
return this;
@@ -77,7 +77,7 @@ public sealed class BuildRelationshipBuilder
public BuildRelationshipBuilder AddHasPrerequisite(string buildSpdxId, string prerequisiteSpdxId)
{
_relationships.Add(CreateRelationship(
"HAS_PREREQUISITE",
Spdx3RelationshipType.HasPrerequisite,
buildSpdxId,
prerequisiteSpdxId));
return this;
@@ -133,11 +133,11 @@ public sealed class BuildRelationshipBuilder
}
private Spdx3Relationship CreateRelationship(
string relationshipType,
Spdx3RelationshipType relationshipType,
string fromSpdxId,
string toSpdxId)
{
var relId = $"{_spdxIdPrefix}/relationship/{relationshipType.ToLowerInvariant()}/{_relationships.Count + 1}";
var relId = $"{_spdxIdPrefix}/relationship/{relationshipType.ToString().ToLowerInvariant()}/{_relationships.Count + 1}";
return new Spdx3Relationship
{

View File

@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\__Libraries\StellaOps.Spdx3\StellaOps.Spdx3.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Spdx3\StellaOps.Spdx3.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,387 @@
// -----------------------------------------------------------------------------
// BuildProfileIntegrationTests.cs
// Sprint: SPRINT_20260107_004_003_BE_spdx3_build_profile
// Task: BP-011 - Integration tests for SPDX 3.0.1 Build profile
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Spdx3.Model;
using StellaOps.Spdx3.Model.Build;
using Xunit;
namespace StellaOps.Attestor.Spdx3.Tests.Integration;
/// <summary>
/// Integration tests for SPDX 3.0.1 Build profile end-to-end flows.
/// These tests verify the complete attestation-to-SPDX 3.0.1 pipeline.
/// </summary>
[Trait("Category", "Integration")]
public sealed class BuildProfileIntegrationTests
{
private static readonly DateTimeOffset FixedTimestamp =
new(2026, 1, 9, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task EndToEnd_AttestationToSpdx3_ProducesValidBuildProfile()
{
// Arrange: Create a realistic build attestation payload
var attestation = new BuildAttestationPayload
{
Type = "https://in-toto.io/Statement/v1",
PredicateType = "https://slsa.dev/provenance/v1",
Subject = ImmutableArray.Create(new AttestationSubject
{
Name = "pkg:oci/myapp@sha256:abc123",
Digest = new Dictionary<string, string>
{
["sha256"] = "abc123def456"
}.ToImmutableDictionary()
}),
Predicate = new BuildPredicate
{
BuildDefinition = new BuildDefinitionInfo
{
BuildType = "https://stellaops.org/build/container-scan/v1",
ExternalParameters = new Dictionary<string, object>
{
["imageReference"] = "registry.io/myapp:latest"
}.ToImmutableDictionary(),
InternalParameters = ImmutableDictionary<string, object>.Empty,
ResolvedDependencies = ImmutableArray.Create(new ResourceDescriptor
{
Name = "base-image",
Uri = "pkg:oci/alpine@sha256:def789",
Digest = new Dictionary<string, string>
{
["sha256"] = "def789"
}.ToImmutableDictionary()
})
},
RunDetails = new RunDetailsInfo
{
Builder = new BuilderInfo
{
Id = "https://stellaops.org/scanner/v1.0.0",
Version = new Dictionary<string, string>
{
["stellaops"] = "1.0.0"
}.ToImmutableDictionary()
},
Metadata = new BuildMetadata
{
InvocationId = "scan-12345",
StartedOn = FixedTimestamp.AddMinutes(-5),
FinishedOn = FixedTimestamp
}
}
}
};
var mapper = new BuildAttestationMapper();
// Act: Map attestation to SPDX 3.0.1 Build element
var buildElement = mapper.MapToSpdx3(attestation);
// Assert: Verify all fields are correctly mapped
buildElement.Should().NotBeNull();
buildElement.BuildType.Should().Be("https://stellaops.org/build/container-scan/v1");
buildElement.BuildId.Should().Be("scan-12345");
buildElement.BuildStartTime.Should().Be(FixedTimestamp.AddMinutes(-5));
buildElement.BuildEndTime.Should().Be(FixedTimestamp);
buildElement.ConfigSourceUri.Should().NotBeNullOrEmpty();
}
[Fact]
public void SignatureVerification_ValidSignedDocument_Succeeds()
{
// Arrange: Create document and sign it
var timeProvider = new FakeTimeProvider(FixedTimestamp);
var serializer = new Spdx3JsonSerializer();
var signingProvider = new TestDsseSigningProvider();
var signer = new DsseSpdx3Signer(serializer, signingProvider, timeProvider);
var document = CreateTestSpdx3Document();
var options = new DsseSpdx3SigningOptions { PrimaryKeyId = "test-key-1" };
// Act: Sign the document
var envelope = signer.SignAsync(document, options).Result;
// Assert: Signature should be present
envelope.Should().NotBeNull();
envelope.Signatures.Should().HaveCount(1);
envelope.PayloadType.Should().Be("application/vnd.spdx3+json");
// Verify: Extract and verify the document
var verifier = new DsseSpdx3Verifier(serializer, signingProvider);
var verificationResult = verifier.VerifyAsync(envelope, CancellationToken.None).Result;
verificationResult.IsValid.Should().BeTrue();
verificationResult.ExtractedDocument.Should().NotBeNull();
}
[Fact]
public void ImportExternalBuildProfile_ValidDocument_ParsesCorrectly()
{
// Arrange: External SPDX 3.0.1 Build profile JSON
var externalJson = """
{
"@context": "https://spdx.org/rdf/3.0.1/terms/",
"@graph": [
{
"@type": "Build",
"spdxId": "urn:external:build:ext-build-001",
"build_buildType": "https://example.com/build/maven/v1",
"build_buildId": "maven-build-789",
"build_buildStartTime": "2026-01-09T10:00:00Z",
"build_buildEndTime": "2026-01-09T10:15:00Z",
"build_configSourceUri": ["https://github.com/example/repo"],
"build_configSourceDigest": [
{
"algorithm": "sha256",
"hashValue": "feedfacecafe"
}
],
"build_environment": {
"JAVA_VERSION": "21",
"MAVEN_VERSION": "3.9.6"
}
}
]
}
""";
// Act: Parse the external document
var parser = new Spdx3Parser();
var parseResult = parser.Parse(externalJson);
// Assert: Build element should be correctly parsed
parseResult.IsSuccess.Should().BeTrue();
parseResult.Document.Should().NotBeNull();
var buildElements = parseResult.Document!.Elements
.OfType<Spdx3Build>()
.ToList();
buildElements.Should().HaveCount(1);
var build = buildElements[0];
build.SpdxId.Should().Be("urn:external:build:ext-build-001");
build.BuildType.Should().Be("https://example.com/build/maven/v1");
build.BuildId.Should().Be("maven-build-789");
build.BuildStartTime.Should().Be(new DateTimeOffset(2026, 1, 9, 10, 0, 0, TimeSpan.Zero));
build.BuildEndTime.Should().Be(new DateTimeOffset(2026, 1, 9, 10, 15, 0, TimeSpan.Zero));
build.ConfigSourceUri.Should().Contain("https://github.com/example/repo");
build.Environment.Should().ContainKey("JAVA_VERSION");
build.Environment!["JAVA_VERSION"].Should().Be("21");
}
[Fact]
public void CombinedDocument_SoftwareAndBuildProfiles_MergesCorrectly()
{
// Arrange: Create Software profile SBOM
var sbomDocument = new Spdx3Document
{
SpdxId = "urn:stellaops:sbom:sbom-001",
Name = "MyApp SBOM",
Namespaces = ImmutableArray.Create("https://stellaops.org/spdx/"),
ProfileConformance = ImmutableArray.Create(Spdx3Profile.Software),
Elements = ImmutableArray.Create<Spdx3Element>(
new Spdx3Package
{
SpdxId = "urn:stellaops:pkg:myapp-1.0.0",
Name = "MyApp",
PackageVersion = "1.0.0",
PackageUrl = "pkg:npm/myapp@1.0.0"
}
)
};
// Arrange: Create Build profile element
var buildElement = new Spdx3Build
{
SpdxId = "urn:stellaops:build:build-001",
BuildType = "https://stellaops.org/build/scanner/v1",
BuildId = "scan-12345",
BuildStartTime = FixedTimestamp.AddMinutes(-5),
BuildEndTime = FixedTimestamp
};
// Act: Combine using CombinedDocumentBuilder
var builder = new CombinedDocumentBuilder();
var combinedDoc = builder
.WithSoftwareDocument(sbomDocument)
.WithBuildProvenance(buildElement)
.Build();
// Assert: Combined document has both profiles
combinedDoc.Should().NotBeNull();
combinedDoc.ProfileConformance.Should().Contain(Spdx3Profile.Software);
combinedDoc.ProfileConformance.Should().Contain(Spdx3Profile.Build);
// Assert: Contains both package and build elements
combinedDoc.Elements.OfType<Spdx3Package>().Should().HaveCount(1);
combinedDoc.Elements.OfType<Spdx3Build>().Should().HaveCount(1);
// Assert: GENERATES relationship exists
var relationships = combinedDoc.Elements.OfType<Spdx3Relationship>().ToList();
var generatesRel = relationships.FirstOrDefault(r =>
r.RelationshipType == Spdx3RelationshipType.Generates);
generatesRel.Should().NotBeNull();
generatesRel!.From.Should().Be(buildElement.SpdxId);
}
[Fact]
public async Task RoundTrip_SignedCombinedDocument_PreservesAllData()
{
// Arrange: Create combined document
var timeProvider = new FakeTimeProvider(FixedTimestamp);
var serializer = new Spdx3JsonSerializer();
var signingProvider = new TestDsseSigningProvider();
var signer = new DsseSpdx3Signer(serializer, signingProvider, timeProvider);
var verifier = new DsseSpdx3Verifier(serializer, signingProvider);
var originalDoc = CreateCombinedTestDocument();
var options = new DsseSpdx3SigningOptions { PrimaryKeyId = "test-key-1" };
// Act: Sign, then verify and extract
var envelope = await signer.SignAsync(originalDoc, options);
var verifyResult = await verifier.VerifyAsync(envelope, CancellationToken.None);
// Assert: Extracted document matches original
verifyResult.IsValid.Should().BeTrue();
var extractedDoc = verifyResult.ExtractedDocument;
extractedDoc.Should().NotBeNull();
extractedDoc!.SpdxId.Should().Be(originalDoc.SpdxId);
extractedDoc.Name.Should().Be(originalDoc.Name);
extractedDoc.ProfileConformance.Should().BeEquivalentTo(originalDoc.ProfileConformance);
// Verify elements preserved
extractedDoc.Elements.OfType<Spdx3Package>().Count()
.Should().Be(originalDoc.Elements.OfType<Spdx3Package>().Count());
extractedDoc.Elements.OfType<Spdx3Build>().Count()
.Should().Be(originalDoc.Elements.OfType<Spdx3Build>().Count());
}
#region Test Helpers
private static Spdx3Document CreateTestSpdx3Document()
{
return new Spdx3Document
{
SpdxId = "urn:stellaops:sbom:test-001",
Name = "Test SBOM",
Namespaces = ImmutableArray.Create("https://stellaops.org/spdx/"),
ProfileConformance = ImmutableArray.Create(Spdx3Profile.Software),
Elements = ImmutableArray.Create<Spdx3Element>(
new Spdx3Package
{
SpdxId = "urn:stellaops:pkg:test-pkg",
Name = "TestPackage",
PackageVersion = "1.0.0"
}
)
};
}
private static Spdx3Document CreateCombinedTestDocument()
{
return new Spdx3Document
{
SpdxId = "urn:stellaops:combined:test-001",
Name = "Combined Test Document",
Namespaces = ImmutableArray.Create("https://stellaops.org/spdx/"),
ProfileConformance = ImmutableArray.Create(Spdx3Profile.Software, Spdx3Profile.Build),
Elements = ImmutableArray.Create<Spdx3Element>(
new Spdx3Package
{
SpdxId = "urn:stellaops:pkg:combined-pkg",
Name = "CombinedPackage",
PackageVersion = "2.0.0"
},
new Spdx3Build
{
SpdxId = "urn:stellaops:build:combined-build",
BuildType = "https://stellaops.org/build/test/v1",
BuildId = "combined-build-001",
BuildStartTime = FixedTimestamp.AddMinutes(-10),
BuildEndTime = FixedTimestamp
},
new Spdx3Relationship
{
SpdxId = "urn:stellaops:rel:generates-001",
RelationshipType = Spdx3RelationshipType.Generates,
From = "urn:stellaops:build:combined-build",
To = ImmutableArray.Create("urn:stellaops:pkg:combined-pkg")
}
)
};
}
/// <summary>
/// Test signing provider that uses a simple HMAC for testing purposes.
/// </summary>
private sealed class TestDsseSigningProvider : IDsseSigningProvider
{
private static readonly byte[] TestKey = Encoding.UTF8.GetBytes("test-signing-key-32-bytes-long!!");
public Task<DsseSignatureResult> SignAsync(
byte[] payload,
string keyId,
string? algorithm,
CancellationToken cancellationToken)
{
using var hmac = new System.Security.Cryptography.HMACSHA256(TestKey);
var signature = hmac.ComputeHash(payload);
return Task.FromResult(new DsseSignatureResult
{
KeyId = keyId,
Algorithm = algorithm ?? "HMAC-SHA256",
SignatureBytes = signature
});
}
public Task<bool> VerifyAsync(
byte[] payload,
byte[] signature,
string keyId,
CancellationToken cancellationToken)
{
using var hmac = new System.Security.Cryptography.HMACSHA256(TestKey);
var expectedSignature = hmac.ComputeHash(payload);
return Task.FromResult(signature.SequenceEqual(expectedSignature));
}
}
#endregion
}
/// <summary>
/// Simple JSON serializer for SPDX 3.0.1 documents (test implementation).
/// </summary>
file sealed class Spdx3JsonSerializer : ISpdx3Serializer
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public byte[] SerializeToBytes(Spdx3Document document)
{
return JsonSerializer.SerializeToUtf8Bytes(document, Options);
}
public Spdx3Document? DeserializeFromBytes(byte[] bytes)
{
return JsonSerializer.Deserialize<Spdx3Document>(bytes, Options);
}
}