sprints and audit work
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationChainBuilderTests.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T014
|
||||
// Description: Unit tests for attestation chain builder.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Attestor.Core.Chain;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Tests.Chain;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class AttestationChainBuilderTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryAttestationLinkStore _linkStore;
|
||||
private readonly AttestationChainValidator _validator;
|
||||
private readonly AttestationChainBuilder _builder;
|
||||
|
||||
public AttestationChainBuilderTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 6, 12, 0, 0, TimeSpan.Zero));
|
||||
_linkStore = new InMemoryAttestationLinkStore();
|
||||
_validator = new AttestationChainValidator(_timeProvider);
|
||||
_builder = new AttestationChainBuilder(_linkStore, _validator, _timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractLinksAsync_AttestationMaterials_CreatesLinks()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = "sha256:source";
|
||||
var materials = new[]
|
||||
{
|
||||
InTotoMaterial.ForAttestation("sha256:target1", PredicateTypes.SbomAttestation),
|
||||
InTotoMaterial.ForAttestation("sha256:target2", PredicateTypes.VexAttestation)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _builder.ExtractLinksAsync(sourceId, materials);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.LinksCreated.Should().HaveCount(2);
|
||||
result.Errors.Should().BeEmpty();
|
||||
_linkStore.Count.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractLinksAsync_NonAttestationMaterials_SkipsThem()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = "sha256:source";
|
||||
var materials = new[]
|
||||
{
|
||||
InTotoMaterial.ForAttestation("sha256:target", PredicateTypes.SbomAttestation),
|
||||
InTotoMaterial.ForImage("registry.io/image", "sha256:imagehash"),
|
||||
InTotoMaterial.ForGitCommit("https://github.com/org/repo", "abc123def456")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _builder.ExtractLinksAsync(sourceId, materials);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.LinksCreated.Should().HaveCount(1);
|
||||
result.SkippedMaterialsCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractLinksAsync_DuplicateMaterial_ReportsError()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = "sha256:source";
|
||||
var materials = new[]
|
||||
{
|
||||
InTotoMaterial.ForAttestation("sha256:target", PredicateTypes.SbomAttestation),
|
||||
InTotoMaterial.ForAttestation("sha256:target", PredicateTypes.SbomAttestation) // Duplicate
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _builder.ExtractLinksAsync(sourceId, materials);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.LinksCreated.Should().HaveCount(1);
|
||||
result.Errors.Should().HaveCount(1);
|
||||
result.Errors[0].Should().Contain("Duplicate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractLinksAsync_SelfReference_ReportsError()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = "sha256:source";
|
||||
var materials = new[]
|
||||
{
|
||||
InTotoMaterial.ForAttestation("sha256:source", PredicateTypes.SbomAttestation) // Self-link
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _builder.ExtractLinksAsync(sourceId, materials);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.LinksCreated.Should().BeEmpty();
|
||||
result.Errors.Should().NotBeEmpty();
|
||||
result.Errors.Should().Contain(e => e.Contains("Self-links"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateLinkAsync_ValidLink_CreatesSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = "sha256:source";
|
||||
var targetId = "sha256:target";
|
||||
|
||||
// Act
|
||||
var result = await _builder.CreateLinkAsync(sourceId, targetId);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.LinksCreated.Should().HaveCount(1);
|
||||
result.LinksCreated[0].SourceAttestationId.Should().Be(sourceId);
|
||||
result.LinksCreated[0].TargetAttestationId.Should().Be(targetId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateLinkAsync_WouldCreateCycle_Fails()
|
||||
{
|
||||
// Arrange - Create A -> B
|
||||
await _builder.CreateLinkAsync("sha256:A", "sha256:B");
|
||||
|
||||
// Act - Try to create B -> A (would create cycle)
|
||||
var result = await _builder.CreateLinkAsync("sha256:B", "sha256:A");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.LinksCreated.Should().BeEmpty();
|
||||
result.Errors.Should().Contain("Link would create a circular reference");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateLinkAsync_WithMetadata_IncludesMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var metadata = new LinkMetadata
|
||||
{
|
||||
Reason = "Test dependency",
|
||||
Annotations = ImmutableDictionary<string, string>.Empty.Add("key", "value")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _builder.CreateLinkAsync(
|
||||
"sha256:source",
|
||||
"sha256:target",
|
||||
metadata: metadata);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.LinksCreated[0].Metadata.Should().NotBeNull();
|
||||
result.LinksCreated[0].Metadata!.Reason.Should().Be("Test dependency");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LinkLayerAttestationsAsync_CreatesLayerLinks()
|
||||
{
|
||||
// Arrange
|
||||
var parentId = "sha256:parent";
|
||||
var layerRefs = new[]
|
||||
{
|
||||
new LayerAttestationRef
|
||||
{
|
||||
LayerIndex = 0,
|
||||
LayerDigest = "sha256:layer0",
|
||||
AttestationId = "sha256:layer0-att"
|
||||
},
|
||||
new LayerAttestationRef
|
||||
{
|
||||
LayerIndex = 1,
|
||||
LayerDigest = "sha256:layer1",
|
||||
AttestationId = "sha256:layer1-att"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _builder.LinkLayerAttestationsAsync(parentId, layerRefs);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.LinksCreated.Should().HaveCount(2);
|
||||
_linkStore.Count.Should().Be(2);
|
||||
|
||||
var links = _linkStore.GetAll().ToList();
|
||||
links.Should().AllSatisfy(l =>
|
||||
{
|
||||
l.SourceAttestationId.Should().Be(parentId);
|
||||
l.Metadata.Should().NotBeNull();
|
||||
l.Metadata!.Annotations.Should().ContainKey("layerIndex");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LinkLayerAttestationsAsync_PreservesLayerOrder()
|
||||
{
|
||||
// Arrange
|
||||
var parentId = "sha256:parent";
|
||||
var layerRefs = new[]
|
||||
{
|
||||
new LayerAttestationRef { LayerIndex = 2, LayerDigest = "sha256:l2", AttestationId = "sha256:att2" },
|
||||
new LayerAttestationRef { LayerIndex = 0, LayerDigest = "sha256:l0", AttestationId = "sha256:att0" },
|
||||
new LayerAttestationRef { LayerIndex = 1, LayerDigest = "sha256:l1", AttestationId = "sha256:att1" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _builder.LinkLayerAttestationsAsync(parentId, layerRefs);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.LinksCreated.Should().HaveCount(3);
|
||||
// Links should be created in layer order
|
||||
result.LinksCreated[0].Metadata!.Annotations["layerIndex"].Should().Be("0");
|
||||
result.LinksCreated[1].Metadata!.Annotations["layerIndex"].Should().Be("1");
|
||||
result.LinksCreated[2].Metadata!.Annotations["layerIndex"].Should().Be("2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractLinksAsync_EmptyMaterials_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = "sha256:source";
|
||||
var materials = Array.Empty<InTotoMaterial>();
|
||||
|
||||
// Act
|
||||
var result = await _builder.ExtractLinksAsync(sourceId, materials);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.LinksCreated.Should().BeEmpty();
|
||||
result.SkippedMaterialsCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractLinksAsync_DifferentLinkTypes_CreatesCorrectType()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = "sha256:source";
|
||||
var materials = new[]
|
||||
{
|
||||
InTotoMaterial.ForAttestation("sha256:target", PredicateTypes.SbomAttestation)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _builder.ExtractLinksAsync(
|
||||
sourceId,
|
||||
materials,
|
||||
linkType: AttestationLinkType.Supersedes);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.LinksCreated[0].LinkType.Should().Be(AttestationLinkType.Supersedes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationChainValidatorTests.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T006
|
||||
// Description: Unit tests for attestation chain validation.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Attestor.Core.Chain;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Tests.Chain;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class AttestationChainValidatorTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly AttestationChainValidator _validator;
|
||||
|
||||
public AttestationChainValidatorTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 6, 12, 0, 0, TimeSpan.Zero));
|
||||
_validator = new AttestationChainValidator(_timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateLink_SelfLink_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var link = CreateLink("sha256:abc123", "sha256:abc123");
|
||||
|
||||
// Act
|
||||
var result = _validator.ValidateLink(link, []);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain("Self-links are not allowed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateLink_DuplicateLink_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var existingLink = CreateLink("sha256:source", "sha256:target");
|
||||
var newLink = CreateLink("sha256:source", "sha256:target");
|
||||
|
||||
// Act
|
||||
var result = _validator.ValidateLink(newLink, [existingLink]);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain("Duplicate link already exists");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateLink_WouldCreateCycle_ReturnsInvalid()
|
||||
{
|
||||
// Arrange - A -> B exists, adding B -> A would create cycle
|
||||
var existingLinks = new List<AttestationLink>
|
||||
{
|
||||
CreateLink("sha256:A", "sha256:B")
|
||||
};
|
||||
var newLink = CreateLink("sha256:B", "sha256:A");
|
||||
|
||||
// Act
|
||||
var result = _validator.ValidateLink(newLink, existingLinks);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain("Link would create a circular reference");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateLink_WouldCreateIndirectCycle_ReturnsInvalid()
|
||||
{
|
||||
// Arrange - A -> B -> C exists, adding C -> A would create cycle
|
||||
var existingLinks = new List<AttestationLink>
|
||||
{
|
||||
CreateLink("sha256:A", "sha256:B"),
|
||||
CreateLink("sha256:B", "sha256:C")
|
||||
};
|
||||
var newLink = CreateLink("sha256:C", "sha256:A");
|
||||
|
||||
// Act
|
||||
var result = _validator.ValidateLink(newLink, existingLinks);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain("Link would create a circular reference");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateLink_ValidLink_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var existingLinks = new List<AttestationLink>
|
||||
{
|
||||
CreateLink("sha256:A", "sha256:B")
|
||||
};
|
||||
var newLink = CreateLink("sha256:B", "sha256:C");
|
||||
|
||||
// Act
|
||||
var result = _validator.ValidateLink(newLink, existingLinks);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateChain_EmptyChain_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var chain = new AttestationChain
|
||||
{
|
||||
RootAttestationId = "sha256:root",
|
||||
ArtifactDigest = "sha256:artifact",
|
||||
Nodes = [],
|
||||
Links = [],
|
||||
IsComplete = true,
|
||||
ResolvedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.ValidateChain(chain);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain("Chain has no nodes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateChain_MissingRoot_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var chain = new AttestationChain
|
||||
{
|
||||
RootAttestationId = "sha256:missing",
|
||||
ArtifactDigest = "sha256:artifact",
|
||||
Nodes = [CreateNode("sha256:other", depth: 0)],
|
||||
Links = [],
|
||||
IsComplete = true,
|
||||
ResolvedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.ValidateChain(chain);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain("Root attestation not found in chain nodes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateChain_DuplicateNodes_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var chain = new AttestationChain
|
||||
{
|
||||
RootAttestationId = "sha256:root",
|
||||
ArtifactDigest = "sha256:artifact",
|
||||
Nodes =
|
||||
[
|
||||
CreateNode("sha256:root", depth: 0),
|
||||
CreateNode("sha256:root", depth: 1) // Duplicate
|
||||
],
|
||||
Links = [],
|
||||
IsComplete = true,
|
||||
ResolvedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.ValidateChain(chain);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Duplicate nodes"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateChain_LinkToMissingNode_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var chain = new AttestationChain
|
||||
{
|
||||
RootAttestationId = "sha256:root",
|
||||
ArtifactDigest = "sha256:artifact",
|
||||
Nodes = [CreateNode("sha256:root", depth: 0)],
|
||||
Links = [CreateLink("sha256:root", "sha256:missing")],
|
||||
IsComplete = true,
|
||||
ResolvedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.ValidateChain(chain);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("not found in nodes"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateChain_ValidSimpleChain_ReturnsValid()
|
||||
{
|
||||
// Arrange - Simple chain: Policy -> VEX -> SBOM (linear)
|
||||
var chain = new AttestationChain
|
||||
{
|
||||
RootAttestationId = "sha256:policy",
|
||||
ArtifactDigest = "sha256:artifact",
|
||||
Nodes =
|
||||
[
|
||||
CreateNode("sha256:policy", depth: 0, PredicateTypes.PolicyEvaluation),
|
||||
CreateNode("sha256:vex", depth: 1, PredicateTypes.VexAttestation),
|
||||
CreateNode("sha256:sbom", depth: 2, PredicateTypes.SbomAttestation)
|
||||
],
|
||||
Links =
|
||||
[
|
||||
CreateLink("sha256:policy", "sha256:vex"),
|
||||
CreateLink("sha256:vex", "sha256:sbom")
|
||||
],
|
||||
IsComplete = true,
|
||||
ResolvedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.ValidateChain(chain);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateChain_ChainWithCycle_ReturnsInvalid()
|
||||
{
|
||||
// Arrange - A -> B -> A (cycle)
|
||||
var chain = new AttestationChain
|
||||
{
|
||||
RootAttestationId = "sha256:A",
|
||||
ArtifactDigest = "sha256:artifact",
|
||||
Nodes =
|
||||
[
|
||||
CreateNode("sha256:A", depth: 0),
|
||||
CreateNode("sha256:B", depth: 1)
|
||||
],
|
||||
Links =
|
||||
[
|
||||
CreateLink("sha256:A", "sha256:B"),
|
||||
CreateLink("sha256:B", "sha256:A") // Creates cycle
|
||||
],
|
||||
IsComplete = true,
|
||||
ResolvedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.ValidateChain(chain);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain("Chain contains circular references");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateChain_DAGStructure_ReturnsValid()
|
||||
{
|
||||
// Arrange - DAG where SBOM has multiple parents (valid)
|
||||
// Policy -> VEX -> SBOM
|
||||
// Policy -> SBOM (direct dependency too)
|
||||
var chain = new AttestationChain
|
||||
{
|
||||
RootAttestationId = "sha256:policy",
|
||||
ArtifactDigest = "sha256:artifact",
|
||||
Nodes =
|
||||
[
|
||||
CreateNode("sha256:policy", depth: 0),
|
||||
CreateNode("sha256:vex", depth: 1),
|
||||
CreateNode("sha256:sbom", depth: 1) // Same depth as VEX since it's also directly linked
|
||||
],
|
||||
Links =
|
||||
[
|
||||
CreateLink("sha256:policy", "sha256:vex"),
|
||||
CreateLink("sha256:policy", "sha256:sbom"),
|
||||
CreateLink("sha256:vex", "sha256:sbom")
|
||||
],
|
||||
IsComplete = true,
|
||||
ResolvedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.ValidateChain(chain);
|
||||
|
||||
// Assert - DAG is valid, just not a pure tree
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
private static AttestationLink CreateLink(string source, string target)
|
||||
{
|
||||
return new AttestationLink
|
||||
{
|
||||
SourceAttestationId = source,
|
||||
TargetAttestationId = target,
|
||||
LinkType = AttestationLinkType.DependsOn,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestationChainNode CreateNode(
|
||||
string attestationId,
|
||||
int depth,
|
||||
string predicateType = "Test@1")
|
||||
{
|
||||
return new AttestationChainNode
|
||||
{
|
||||
AttestationId = attestationId,
|
||||
PredicateType = predicateType,
|
||||
SubjectDigest = "sha256:subject",
|
||||
Depth = depth,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationLinkResolverTests.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T010-T012
|
||||
// Description: Unit tests for attestation chain resolution.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Attestor.Core.Chain;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Tests.Chain;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class AttestationLinkResolverTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryAttestationLinkStore _linkStore;
|
||||
private readonly InMemoryAttestationNodeProvider _nodeProvider;
|
||||
private readonly AttestationLinkResolver _resolver;
|
||||
|
||||
public AttestationLinkResolverTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 6, 12, 0, 0, TimeSpan.Zero));
|
||||
_linkStore = new InMemoryAttestationLinkStore();
|
||||
_nodeProvider = new InMemoryAttestationNodeProvider();
|
||||
_resolver = new AttestationLinkResolver(_linkStore, _nodeProvider, _timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveChainAsync_NoRootFound_ReturnsIncompleteChain()
|
||||
{
|
||||
// Arrange
|
||||
var request = new AttestationChainRequest
|
||||
{
|
||||
ArtifactDigest = "sha256:unknown"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveChainAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsComplete.Should().BeFalse();
|
||||
result.RootAttestationId.Should().BeEmpty();
|
||||
result.ValidationErrors.Should().Contain("No root attestation found for artifact");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveChainAsync_SingleNode_ReturnsCompleteChain()
|
||||
{
|
||||
// Arrange
|
||||
var artifactDigest = "sha256:artifact123";
|
||||
var rootNode = CreateNode("sha256:root", PredicateTypes.PolicyEvaluation, artifactDigest);
|
||||
_nodeProvider.AddNode(rootNode);
|
||||
_nodeProvider.SetArtifactRoot(artifactDigest, "sha256:root");
|
||||
|
||||
var request = new AttestationChainRequest { ArtifactDigest = artifactDigest };
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveChainAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsComplete.Should().BeTrue();
|
||||
result.RootAttestationId.Should().Be("sha256:root");
|
||||
result.Nodes.Should().HaveCount(1);
|
||||
result.Links.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveChainAsync_LinearChain_ResolvesAllNodes()
|
||||
{
|
||||
// Arrange - Policy -> VEX -> SBOM
|
||||
var artifactDigest = "sha256:artifact123";
|
||||
|
||||
var policyNode = CreateNode("sha256:policy", PredicateTypes.PolicyEvaluation, artifactDigest);
|
||||
var vexNode = CreateNode("sha256:vex", PredicateTypes.VexAttestation, artifactDigest);
|
||||
var sbomNode = CreateNode("sha256:sbom", PredicateTypes.SbomAttestation, artifactDigest);
|
||||
|
||||
_nodeProvider.AddNode(policyNode);
|
||||
_nodeProvider.AddNode(vexNode);
|
||||
_nodeProvider.AddNode(sbomNode);
|
||||
_nodeProvider.SetArtifactRoot(artifactDigest, "sha256:policy");
|
||||
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:policy", "sha256:vex"));
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:vex", "sha256:sbom"));
|
||||
|
||||
var request = new AttestationChainRequest { ArtifactDigest = artifactDigest };
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveChainAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsComplete.Should().BeTrue();
|
||||
result.Nodes.Should().HaveCount(3);
|
||||
result.Links.Should().HaveCount(2);
|
||||
result.Nodes[0].AttestationId.Should().Be("sha256:policy");
|
||||
result.Nodes[0].Depth.Should().Be(0);
|
||||
result.Nodes[1].AttestationId.Should().Be("sha256:vex");
|
||||
result.Nodes[1].Depth.Should().Be(1);
|
||||
result.Nodes[2].AttestationId.Should().Be("sha256:sbom");
|
||||
result.Nodes[2].Depth.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveChainAsync_DAGStructure_ResolvesAllNodes()
|
||||
{
|
||||
// Arrange - Policy -> VEX, Policy -> SBOM, VEX -> SBOM (DAG)
|
||||
var artifactDigest = "sha256:artifact123";
|
||||
|
||||
var policyNode = CreateNode("sha256:policy", PredicateTypes.PolicyEvaluation, artifactDigest);
|
||||
var vexNode = CreateNode("sha256:vex", PredicateTypes.VexAttestation, artifactDigest);
|
||||
var sbomNode = CreateNode("sha256:sbom", PredicateTypes.SbomAttestation, artifactDigest);
|
||||
|
||||
_nodeProvider.AddNode(policyNode);
|
||||
_nodeProvider.AddNode(vexNode);
|
||||
_nodeProvider.AddNode(sbomNode);
|
||||
_nodeProvider.SetArtifactRoot(artifactDigest, "sha256:policy");
|
||||
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:policy", "sha256:vex"));
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:policy", "sha256:sbom"));
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:vex", "sha256:sbom"));
|
||||
|
||||
var request = new AttestationChainRequest { ArtifactDigest = artifactDigest };
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveChainAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsComplete.Should().BeTrue();
|
||||
result.Nodes.Should().HaveCount(3);
|
||||
result.Links.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveChainAsync_MissingNode_ReturnsIncompleteWithMissingIds()
|
||||
{
|
||||
// Arrange
|
||||
var artifactDigest = "sha256:artifact123";
|
||||
|
||||
var policyNode = CreateNode("sha256:policy", PredicateTypes.PolicyEvaluation, artifactDigest);
|
||||
_nodeProvider.AddNode(policyNode);
|
||||
_nodeProvider.SetArtifactRoot(artifactDigest, "sha256:policy");
|
||||
|
||||
// Link to non-existent node
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:policy", "sha256:missing"));
|
||||
|
||||
var request = new AttestationChainRequest { ArtifactDigest = artifactDigest };
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveChainAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsComplete.Should().BeFalse();
|
||||
result.MissingAttestations.Should().Contain("sha256:missing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveChainAsync_MaxDepthReached_StopsTraversal()
|
||||
{
|
||||
// Arrange - Deep chain: A -> B -> C -> D -> E
|
||||
var artifactDigest = "sha256:artifact123";
|
||||
|
||||
var nodes = new[] { "A", "B", "C", "D", "E" }
|
||||
.Select(id => CreateNode($"sha256:{id}", "Test@1", artifactDigest))
|
||||
.ToList();
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
_nodeProvider.AddNode(node);
|
||||
}
|
||||
_nodeProvider.SetArtifactRoot(artifactDigest, "sha256:A");
|
||||
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:A", "sha256:B"));
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:B", "sha256:C"));
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:C", "sha256:D"));
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:D", "sha256:E"));
|
||||
|
||||
var request = new AttestationChainRequest
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
MaxDepth = 2 // Should stop at C
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveChainAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().HaveCount(3); // A, B, C
|
||||
result.Nodes.Select(n => n.AttestationId).Should().Contain("sha256:A");
|
||||
result.Nodes.Select(n => n.AttestationId).Should().Contain("sha256:B");
|
||||
result.Nodes.Select(n => n.AttestationId).Should().Contain("sha256:C");
|
||||
result.Nodes.Select(n => n.AttestationId).Should().NotContain("sha256:D");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveChainAsync_ExcludesLayers_WhenNotRequested()
|
||||
{
|
||||
// Arrange
|
||||
var artifactDigest = "sha256:artifact123";
|
||||
|
||||
var policyNode = CreateNode("sha256:policy", PredicateTypes.PolicyEvaluation, artifactDigest);
|
||||
var layerNode = CreateNode("sha256:layer", PredicateTypes.LayerSbom, artifactDigest) with
|
||||
{
|
||||
IsLayerAttestation = true,
|
||||
LayerIndex = 0
|
||||
};
|
||||
|
||||
_nodeProvider.AddNode(policyNode);
|
||||
_nodeProvider.AddNode(layerNode);
|
||||
_nodeProvider.SetArtifactRoot(artifactDigest, "sha256:policy");
|
||||
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:policy", "sha256:layer"));
|
||||
|
||||
var request = new AttestationChainRequest
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
IncludeLayers = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveChainAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().HaveCount(1);
|
||||
result.Nodes[0].AttestationId.Should().Be("sha256:policy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUpstreamAsync_ReturnsParentNodes()
|
||||
{
|
||||
// Arrange - Policy -> VEX -> SBOM
|
||||
var policyNode = CreateNode("sha256:policy", PredicateTypes.PolicyEvaluation, "sha256:art");
|
||||
var vexNode = CreateNode("sha256:vex", PredicateTypes.VexAttestation, "sha256:art");
|
||||
var sbomNode = CreateNode("sha256:sbom", PredicateTypes.SbomAttestation, "sha256:art");
|
||||
|
||||
_nodeProvider.AddNode(policyNode);
|
||||
_nodeProvider.AddNode(vexNode);
|
||||
_nodeProvider.AddNode(sbomNode);
|
||||
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:policy", "sha256:vex"));
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:vex", "sha256:sbom"));
|
||||
|
||||
// Act - Get upstream (parents) of SBOM
|
||||
var result = await _resolver.GetUpstreamAsync("sha256:sbom");
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Select(n => n.AttestationId).Should().Contain("sha256:vex");
|
||||
result.Select(n => n.AttestationId).Should().Contain("sha256:policy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDownstreamAsync_ReturnsChildNodes()
|
||||
{
|
||||
// Arrange - Policy -> VEX -> SBOM
|
||||
var policyNode = CreateNode("sha256:policy", PredicateTypes.PolicyEvaluation, "sha256:art");
|
||||
var vexNode = CreateNode("sha256:vex", PredicateTypes.VexAttestation, "sha256:art");
|
||||
var sbomNode = CreateNode("sha256:sbom", PredicateTypes.SbomAttestation, "sha256:art");
|
||||
|
||||
_nodeProvider.AddNode(policyNode);
|
||||
_nodeProvider.AddNode(vexNode);
|
||||
_nodeProvider.AddNode(sbomNode);
|
||||
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:policy", "sha256:vex"));
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:vex", "sha256:sbom"));
|
||||
|
||||
// Act - Get downstream (children) of Policy
|
||||
var result = await _resolver.GetDownstreamAsync("sha256:policy");
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Select(n => n.AttestationId).Should().Contain("sha256:vex");
|
||||
result.Select(n => n.AttestationId).Should().Contain("sha256:sbom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLinksAsync_ReturnsAllLinks()
|
||||
{
|
||||
// Arrange
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:A", "sha256:B"));
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:B", "sha256:C"));
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:D", "sha256:B")); // B is target
|
||||
|
||||
// Act
|
||||
var allLinks = await _resolver.GetLinksAsync("sha256:B", LinkDirection.Both);
|
||||
var outgoing = await _resolver.GetLinksAsync("sha256:B", LinkDirection.Outgoing);
|
||||
var incoming = await _resolver.GetLinksAsync("sha256:B", LinkDirection.Incoming);
|
||||
|
||||
// Assert
|
||||
allLinks.Should().HaveCount(3);
|
||||
outgoing.Should().HaveCount(1);
|
||||
outgoing[0].TargetAttestationId.Should().Be("sha256:C");
|
||||
incoming.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AreLinkedAsync_DirectLink_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:A", "sha256:B"));
|
||||
|
||||
// Act
|
||||
var result = await _resolver.AreLinkedAsync("sha256:A", "sha256:B");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AreLinkedAsync_IndirectLink_ReturnsTrue()
|
||||
{
|
||||
// Arrange - A -> B -> C
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:A", "sha256:B"));
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:B", "sha256:C"));
|
||||
|
||||
// Act
|
||||
var result = await _resolver.AreLinkedAsync("sha256:A", "sha256:C");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AreLinkedAsync_NoLink_ReturnsFalse()
|
||||
{
|
||||
// Arrange - A -> B, C -> D (separate)
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:A", "sha256:B"));
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:C", "sha256:D"));
|
||||
|
||||
// Act
|
||||
var result = await _resolver.AreLinkedAsync("sha256:A", "sha256:D");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
private static AttestationChainNode CreateNode(
|
||||
string attestationId,
|
||||
string predicateType,
|
||||
string subjectDigest)
|
||||
{
|
||||
return new AttestationChainNode
|
||||
{
|
||||
AttestationId = attestationId,
|
||||
PredicateType = predicateType,
|
||||
SubjectDigest = subjectDigest,
|
||||
Depth = 0,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestationLink CreateLink(string source, string target)
|
||||
{
|
||||
return new AttestationLink
|
||||
{
|
||||
SourceAttestationId = source,
|
||||
TargetAttestationId = target,
|
||||
LinkType = AttestationLinkType.DependsOn,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ChainResolverDirectionalTests.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T025
|
||||
// Description: Tests for directional chain resolution (upstream/downstream/full).
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Attestor.Core.Chain;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Tests.Chain;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class ChainResolverDirectionalTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryAttestationLinkStore _linkStore;
|
||||
private readonly InMemoryAttestationNodeProvider _nodeProvider;
|
||||
private readonly AttestationLinkResolver _resolver;
|
||||
|
||||
public ChainResolverDirectionalTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 6, 12, 0, 0, TimeSpan.Zero));
|
||||
_linkStore = new InMemoryAttestationLinkStore();
|
||||
_nodeProvider = new InMemoryAttestationNodeProvider();
|
||||
_resolver = new AttestationLinkResolver(_linkStore, _nodeProvider, _timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveUpstreamAsync_StartNodeNotFound_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _resolver.ResolveUpstreamAsync("sha256:unknown");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveUpstreamAsync_NoUpstreamLinks_ReturnsChainWithStartNodeOnly()
|
||||
{
|
||||
// Arrange
|
||||
var startNode = CreateNode("sha256:start", "SBOM", "sha256:artifact");
|
||||
_nodeProvider.AddNode(startNode);
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveUpstreamAsync("sha256:start");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Nodes.Should().HaveCount(1);
|
||||
result.Nodes[0].AttestationId.Should().Be("sha256:start");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveUpstreamAsync_WithUpstreamLinks_ReturnsChain()
|
||||
{
|
||||
// Arrange
|
||||
// Chain: verdict -> vex -> sbom (start)
|
||||
var sbomNode = CreateNode("sha256:sbom", "SBOM", "sha256:artifact");
|
||||
var vexNode = CreateNode("sha256:vex", "VEX", "sha256:artifact");
|
||||
var verdictNode = CreateNode("sha256:verdict", "Verdict", "sha256:artifact");
|
||||
_nodeProvider.AddNode(sbomNode);
|
||||
_nodeProvider.AddNode(vexNode);
|
||||
_nodeProvider.AddNode(verdictNode);
|
||||
|
||||
// Links: verdict depends on vex, vex depends on sbom
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:verdict", "sha256:vex"));
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:vex", "sha256:sbom"));
|
||||
|
||||
// Act - get upstream from sbom (should get vex and verdict)
|
||||
var result = await _resolver.ResolveUpstreamAsync("sha256:sbom");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Nodes.Should().HaveCount(3);
|
||||
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:sbom");
|
||||
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:vex");
|
||||
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:verdict");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveDownstreamAsync_StartNodeNotFound_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _resolver.ResolveDownstreamAsync("sha256:unknown");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveDownstreamAsync_NoDownstreamLinks_ReturnsChainWithStartNodeOnly()
|
||||
{
|
||||
// Arrange
|
||||
var startNode = CreateNode("sha256:start", "Verdict", "sha256:artifact");
|
||||
_nodeProvider.AddNode(startNode);
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveDownstreamAsync("sha256:start");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Nodes.Should().HaveCount(1);
|
||||
result.Nodes[0].AttestationId.Should().Be("sha256:start");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveDownstreamAsync_WithDownstreamLinks_ReturnsChain()
|
||||
{
|
||||
// Arrange
|
||||
// Chain: verdict -> vex -> sbom
|
||||
var verdictNode = CreateNode("sha256:verdict", "Verdict", "sha256:artifact");
|
||||
var vexNode = CreateNode("sha256:vex", "VEX", "sha256:artifact");
|
||||
var sbomNode = CreateNode("sha256:sbom", "SBOM", "sha256:artifact");
|
||||
_nodeProvider.AddNode(verdictNode);
|
||||
_nodeProvider.AddNode(vexNode);
|
||||
_nodeProvider.AddNode(sbomNode);
|
||||
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:verdict", "sha256:vex"));
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:vex", "sha256:sbom"));
|
||||
|
||||
// Act - get downstream from verdict (should get vex and sbom)
|
||||
var result = await _resolver.ResolveDownstreamAsync("sha256:verdict");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Nodes.Should().HaveCount(3);
|
||||
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:verdict");
|
||||
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:vex");
|
||||
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:sbom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveFullChainAsync_StartNodeNotFound_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _resolver.ResolveFullChainAsync("sha256:unknown");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveFullChainAsync_ReturnsAllRelatedNodes()
|
||||
{
|
||||
// Arrange
|
||||
// Chain: policy -> verdict -> vex -> sbom
|
||||
var policyNode = CreateNode("sha256:policy", "Policy", "sha256:artifact");
|
||||
var verdictNode = CreateNode("sha256:verdict", "Verdict", "sha256:artifact");
|
||||
var vexNode = CreateNode("sha256:vex", "VEX", "sha256:artifact");
|
||||
var sbomNode = CreateNode("sha256:sbom", "SBOM", "sha256:artifact");
|
||||
_nodeProvider.AddNode(policyNode);
|
||||
_nodeProvider.AddNode(verdictNode);
|
||||
_nodeProvider.AddNode(vexNode);
|
||||
_nodeProvider.AddNode(sbomNode);
|
||||
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:policy", "sha256:verdict"));
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:verdict", "sha256:vex"));
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:vex", "sha256:sbom"));
|
||||
|
||||
// Act - get full chain from vex (middle of chain)
|
||||
var result = await _resolver.ResolveFullChainAsync("sha256:vex");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Nodes.Should().HaveCount(4);
|
||||
result.Links.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveUpstreamAsync_RespectsMaxDepth()
|
||||
{
|
||||
// Arrange - create chain of depth 5
|
||||
var nodes = Enumerable.Range(0, 6)
|
||||
.Select(i => CreateNode($"sha256:node{i}", "SBOM", "sha256:artifact"))
|
||||
.ToList();
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
_nodeProvider.AddNode(node);
|
||||
}
|
||||
|
||||
// Link chain: node5 -> node4 -> node3 -> node2 -> node1 -> node0
|
||||
for (int i = 5; i > 0; i--)
|
||||
{
|
||||
await _linkStore.StoreAsync(CreateLink($"sha256:node{i}", $"sha256:node{i - 1}"));
|
||||
}
|
||||
|
||||
// Act - resolve upstream from node0 with depth 2
|
||||
var result = await _resolver.ResolveUpstreamAsync("sha256:node0", maxDepth: 2);
|
||||
|
||||
// Assert - should get node0, node1, node2 (depth 0, 1, 2)
|
||||
result.Should().NotBeNull();
|
||||
result!.Nodes.Should().HaveCount(3);
|
||||
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:node0");
|
||||
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:node1");
|
||||
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:node2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveDownstreamAsync_RespectsMaxDepth()
|
||||
{
|
||||
// Arrange - create chain of depth 5
|
||||
var nodes = Enumerable.Range(0, 6)
|
||||
.Select(i => CreateNode($"sha256:node{i}", "SBOM", "sha256:artifact"))
|
||||
.ToList();
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
_nodeProvider.AddNode(node);
|
||||
}
|
||||
|
||||
// Link chain: node0 -> node1 -> node2 -> node3 -> node4 -> node5
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _linkStore.StoreAsync(CreateLink($"sha256:node{i}", $"sha256:node{i + 1}"));
|
||||
}
|
||||
|
||||
// Act - resolve downstream from node0 with depth 2
|
||||
var result = await _resolver.ResolveDownstreamAsync("sha256:node0", maxDepth: 2);
|
||||
|
||||
// Assert - should get node0, node1, node2 (depth 0, 1, 2)
|
||||
result.Should().NotBeNull();
|
||||
result!.Nodes.Should().HaveCount(3);
|
||||
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:node0");
|
||||
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:node1");
|
||||
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:node2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveFullChainAsync_MarksRootAndLeafNodes()
|
||||
{
|
||||
// Arrange
|
||||
// Chain: root -> middle -> leaf
|
||||
var rootNode = CreateNode("sha256:root", "Verdict", "sha256:artifact");
|
||||
var middleNode = CreateNode("sha256:middle", "VEX", "sha256:artifact");
|
||||
var leafNode = CreateNode("sha256:leaf", "SBOM", "sha256:artifact");
|
||||
_nodeProvider.AddNode(rootNode);
|
||||
_nodeProvider.AddNode(middleNode);
|
||||
_nodeProvider.AddNode(leafNode);
|
||||
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:root", "sha256:middle"));
|
||||
await _linkStore.StoreAsync(CreateLink("sha256:middle", "sha256:leaf"));
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveFullChainAsync("sha256:middle");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
var root = result!.Nodes.FirstOrDefault(n => n.AttestationId == "sha256:root");
|
||||
var middle = result.Nodes.FirstOrDefault(n => n.AttestationId == "sha256:middle");
|
||||
var leaf = result.Nodes.FirstOrDefault(n => n.AttestationId == "sha256:leaf");
|
||||
|
||||
root.Should().NotBeNull();
|
||||
root!.IsRoot.Should().BeTrue();
|
||||
root.IsLeaf.Should().BeFalse();
|
||||
|
||||
leaf.Should().NotBeNull();
|
||||
leaf!.IsLeaf.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBySubjectAsync_ReturnsNodesForSubject()
|
||||
{
|
||||
// Arrange
|
||||
var node1 = CreateNode("sha256:att1", "SBOM", "sha256:artifact1");
|
||||
var node2 = CreateNode("sha256:att2", "VEX", "sha256:artifact1");
|
||||
var node3 = CreateNode("sha256:att3", "SBOM", "sha256:artifact2");
|
||||
_nodeProvider.AddNode(node1);
|
||||
_nodeProvider.AddNode(node2);
|
||||
_nodeProvider.AddNode(node3);
|
||||
|
||||
// Act
|
||||
var result = await _nodeProvider.GetBySubjectAsync("sha256:artifact1");
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().Contain(n => n.AttestationId == "sha256:att1");
|
||||
result.Should().Contain(n => n.AttestationId == "sha256:att2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBySubjectAsync_NoMatches_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var node = CreateNode("sha256:att1", "SBOM", "sha256:artifact1");
|
||||
_nodeProvider.AddNode(node);
|
||||
|
||||
// Act
|
||||
var result = await _nodeProvider.GetBySubjectAsync("sha256:unknown");
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
private AttestationChainNode CreateNode(string attestationId, string predicateType, string subjectDigest)
|
||||
{
|
||||
return new AttestationChainNode
|
||||
{
|
||||
AttestationId = attestationId,
|
||||
PredicateType = predicateType,
|
||||
SubjectDigest = subjectDigest,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Depth = 0,
|
||||
IsRoot = false,
|
||||
IsLeaf = false,
|
||||
IsLayerAttestation = false
|
||||
};
|
||||
}
|
||||
|
||||
private AttestationLink CreateLink(string sourceId, string targetId)
|
||||
{
|
||||
return new AttestationLink
|
||||
{
|
||||
SourceAttestationId = sourceId,
|
||||
TargetAttestationId = targetId,
|
||||
LinkType = AttestationLinkType.DependsOn,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InMemoryAttestationLinkStoreTests.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T011
|
||||
// Description: Unit tests for in-memory attestation link store.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.Core.Chain;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Tests.Chain;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class InMemoryAttestationLinkStoreTests
|
||||
{
|
||||
private readonly InMemoryAttestationLinkStore _store;
|
||||
|
||||
public InMemoryAttestationLinkStoreTests()
|
||||
{
|
||||
_store = new InMemoryAttestationLinkStore();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_AddsLinkToStore()
|
||||
{
|
||||
// Arrange
|
||||
var link = CreateLink("sha256:source", "sha256:target");
|
||||
|
||||
// Act
|
||||
await _store.StoreAsync(link);
|
||||
|
||||
// Assert
|
||||
_store.Count.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_DuplicateLink_DoesNotAddAgain()
|
||||
{
|
||||
// Arrange
|
||||
var link1 = CreateLink("sha256:source", "sha256:target");
|
||||
var link2 = CreateLink("sha256:source", "sha256:target");
|
||||
|
||||
// Act
|
||||
await _store.StoreAsync(link1);
|
||||
await _store.StoreAsync(link2);
|
||||
|
||||
// Assert
|
||||
_store.Count.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBySourceAsync_ReturnsLinksFromSource()
|
||||
{
|
||||
// Arrange
|
||||
await _store.StoreAsync(CreateLink("sha256:A", "sha256:B"));
|
||||
await _store.StoreAsync(CreateLink("sha256:A", "sha256:C"));
|
||||
await _store.StoreAsync(CreateLink("sha256:B", "sha256:C"));
|
||||
|
||||
// Act
|
||||
var result = await _store.GetBySourceAsync("sha256:A");
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Select(l => l.TargetAttestationId).Should().Contain("sha256:B");
|
||||
result.Select(l => l.TargetAttestationId).Should().Contain("sha256:C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBySourceAsync_NoLinks_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
var result = await _store.GetBySourceAsync("sha256:unknown");
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByTargetAsync_ReturnsLinksToTarget()
|
||||
{
|
||||
// Arrange
|
||||
await _store.StoreAsync(CreateLink("sha256:A", "sha256:C"));
|
||||
await _store.StoreAsync(CreateLink("sha256:B", "sha256:C"));
|
||||
await _store.StoreAsync(CreateLink("sha256:A", "sha256:B"));
|
||||
|
||||
// Act
|
||||
var result = await _store.GetByTargetAsync("sha256:C");
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Select(l => l.SourceAttestationId).Should().Contain("sha256:A");
|
||||
result.Select(l => l.SourceAttestationId).Should().Contain("sha256:B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsSpecificLink()
|
||||
{
|
||||
// Arrange
|
||||
var link = CreateLink("sha256:A", "sha256:B");
|
||||
await _store.StoreAsync(link);
|
||||
|
||||
// Act
|
||||
var result = await _store.GetAsync("sha256:A", "sha256:B");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.SourceAttestationId.Should().Be("sha256:A");
|
||||
result.TargetAttestationId.Should().Be("sha256:B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_NonExistent_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _store.GetAsync("sha256:A", "sha256:B");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_LinkExists_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
await _store.StoreAsync(CreateLink("sha256:A", "sha256:B"));
|
||||
|
||||
// Act
|
||||
var result = await _store.ExistsAsync("sha256:A", "sha256:B");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_LinkDoesNotExist_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = await _store.ExistsAsync("sha256:A", "sha256:B");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteByAttestationAsync_RemovesAllRelatedLinks()
|
||||
{
|
||||
// Arrange
|
||||
await _store.StoreAsync(CreateLink("sha256:A", "sha256:B"));
|
||||
await _store.StoreAsync(CreateLink("sha256:B", "sha256:C"));
|
||||
await _store.StoreAsync(CreateLink("sha256:D", "sha256:B"));
|
||||
|
||||
// Act
|
||||
await _store.DeleteByAttestationAsync("sha256:B");
|
||||
|
||||
// Assert
|
||||
_store.Count.Should().Be(0); // All links involve B
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreBatchAsync_AddsMultipleLinks()
|
||||
{
|
||||
// Arrange
|
||||
var links = new[]
|
||||
{
|
||||
CreateLink("sha256:A", "sha256:B"),
|
||||
CreateLink("sha256:B", "sha256:C"),
|
||||
CreateLink("sha256:C", "sha256:D")
|
||||
};
|
||||
|
||||
// Act
|
||||
await _store.StoreBatchAsync(links);
|
||||
|
||||
// Assert
|
||||
_store.Count.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_RemovesAllLinks()
|
||||
{
|
||||
// Arrange
|
||||
_store.StoreAsync(CreateLink("sha256:A", "sha256:B")).Wait();
|
||||
_store.StoreAsync(CreateLink("sha256:B", "sha256:C")).Wait();
|
||||
|
||||
// Act
|
||||
_store.Clear();
|
||||
|
||||
// Assert
|
||||
_store.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAll_ReturnsAllLinks()
|
||||
{
|
||||
// Arrange
|
||||
await _store.StoreAsync(CreateLink("sha256:A", "sha256:B"));
|
||||
await _store.StoreAsync(CreateLink("sha256:B", "sha256:C"));
|
||||
|
||||
// Act
|
||||
var result = _store.GetAll();
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
private static AttestationLink CreateLink(string source, string target)
|
||||
{
|
||||
return new AttestationLink
|
||||
{
|
||||
SourceAttestationId = source,
|
||||
TargetAttestationId = target,
|
||||
LinkType = AttestationLinkType.DependsOn,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LayerAttestationServiceTests.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T019
|
||||
// Description: Unit tests for layer attestation service.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Attestor.Core.Chain;
|
||||
using StellaOps.Attestor.Core.Layers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Tests.Layers;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class LayerAttestationServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryLayerAttestationSigner _signer;
|
||||
private readonly InMemoryLayerAttestationStore _store;
|
||||
private readonly InMemoryAttestationLinkStore _linkStore;
|
||||
private readonly AttestationChainValidator _validator;
|
||||
private readonly AttestationChainBuilder _chainBuilder;
|
||||
private readonly LayerAttestationService _service;
|
||||
|
||||
public LayerAttestationServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 6, 12, 0, 0, TimeSpan.Zero));
|
||||
_signer = new InMemoryLayerAttestationSigner(_timeProvider);
|
||||
_store = new InMemoryLayerAttestationStore();
|
||||
_linkStore = new InMemoryAttestationLinkStore();
|
||||
_validator = new AttestationChainValidator(_timeProvider);
|
||||
_chainBuilder = new AttestationChainBuilder(_linkStore, _validator, _timeProvider);
|
||||
_service = new LayerAttestationService(_signer, _store, _linkStore, _chainBuilder, _timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateLayerAttestationAsync_ValidRequest_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateLayerRequest("sha256:image123", "sha256:layer0", 0);
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateLayerAttestationAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.LayerDigest.Should().Be("sha256:layer0");
|
||||
result.LayerOrder.Should().Be(0);
|
||||
result.AttestationId.Should().StartWith("sha256:");
|
||||
result.EnvelopeDigest.Should().StartWith("sha256:");
|
||||
result.Error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateLayerAttestationAsync_StoresAttestation()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateLayerRequest("sha256:image123", "sha256:layer0", 0);
|
||||
|
||||
// Act
|
||||
await _service.CreateLayerAttestationAsync(request);
|
||||
var stored = await _service.GetLayerAttestationAsync("sha256:image123", 0);
|
||||
|
||||
// Assert
|
||||
stored.Should().NotBeNull();
|
||||
stored!.LayerDigest.Should().Be("sha256:layer0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBatchLayerAttestationsAsync_MultipleLayers_AllSucceed()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BatchLayerAttestationRequest
|
||||
{
|
||||
ImageDigest = "sha256:image123",
|
||||
ImageRef = "registry.io/app:latest",
|
||||
Layers =
|
||||
[
|
||||
CreateLayerRequest("sha256:image123", "sha256:layer0", 0),
|
||||
CreateLayerRequest("sha256:image123", "sha256:layer1", 1),
|
||||
CreateLayerRequest("sha256:image123", "sha256:layer2", 2)
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateBatchLayerAttestationsAsync(request);
|
||||
|
||||
// Assert
|
||||
result.AllSucceeded.Should().BeTrue();
|
||||
result.SuccessCount.Should().Be(3);
|
||||
result.FailedCount.Should().Be(0);
|
||||
result.Layers.Should().HaveCount(3);
|
||||
result.ProcessingTime.Should().BeGreaterThan(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBatchLayerAttestationsAsync_PreservesLayerOrder()
|
||||
{
|
||||
// Arrange - layers in reverse order
|
||||
var request = new BatchLayerAttestationRequest
|
||||
{
|
||||
ImageDigest = "sha256:image123",
|
||||
ImageRef = "registry.io/app:latest",
|
||||
Layers =
|
||||
[
|
||||
CreateLayerRequest("sha256:image123", "sha256:layer2", 2),
|
||||
CreateLayerRequest("sha256:image123", "sha256:layer0", 0),
|
||||
CreateLayerRequest("sha256:image123", "sha256:layer1", 1)
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateBatchLayerAttestationsAsync(request);
|
||||
|
||||
// Assert - should be processed in order
|
||||
result.Layers[0].LayerOrder.Should().Be(0);
|
||||
result.Layers[1].LayerOrder.Should().Be(1);
|
||||
result.Layers[2].LayerOrder.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBatchLayerAttestationsAsync_WithLinkToParent_CreatesLinks()
|
||||
{
|
||||
// Arrange
|
||||
var parentAttestationId = "sha256:parentattestation";
|
||||
var request = new BatchLayerAttestationRequest
|
||||
{
|
||||
ImageDigest = "sha256:image123",
|
||||
ImageRef = "registry.io/app:latest",
|
||||
Layers =
|
||||
[
|
||||
CreateLayerRequest("sha256:image123", "sha256:layer0", 0),
|
||||
CreateLayerRequest("sha256:image123", "sha256:layer1", 1)
|
||||
],
|
||||
LinkToParent = true,
|
||||
ParentAttestationId = parentAttestationId
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateBatchLayerAttestationsAsync(request);
|
||||
|
||||
// Assert
|
||||
result.LinksCreated.Should().Be(2);
|
||||
_linkStore.Count.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBatchLayerAttestationsAsync_WithoutLinkToParent_NoLinksCreated()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BatchLayerAttestationRequest
|
||||
{
|
||||
ImageDigest = "sha256:image123",
|
||||
ImageRef = "registry.io/app:latest",
|
||||
Layers =
|
||||
[
|
||||
CreateLayerRequest("sha256:image123", "sha256:layer0", 0)
|
||||
],
|
||||
LinkToParent = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateBatchLayerAttestationsAsync(request);
|
||||
|
||||
// Assert
|
||||
result.LinksCreated.Should().Be(0);
|
||||
_linkStore.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLayerAttestationsAsync_MultipleLayers_ReturnsInOrder()
|
||||
{
|
||||
// Arrange - create out of order
|
||||
await _service.CreateLayerAttestationAsync(
|
||||
CreateLayerRequest("sha256:image123", "sha256:layer2", 2));
|
||||
await _service.CreateLayerAttestationAsync(
|
||||
CreateLayerRequest("sha256:image123", "sha256:layer0", 0));
|
||||
await _service.CreateLayerAttestationAsync(
|
||||
CreateLayerRequest("sha256:image123", "sha256:layer1", 1));
|
||||
|
||||
// Act
|
||||
var results = await _service.GetLayerAttestationsAsync("sha256:image123");
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(3);
|
||||
results[0].LayerOrder.Should().Be(0);
|
||||
results[1].LayerOrder.Should().Be(1);
|
||||
results[2].LayerOrder.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLayerAttestationsAsync_NoLayers_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
var results = await _service.GetLayerAttestationsAsync("sha256:unknown");
|
||||
|
||||
// Assert
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLayerAttestationAsync_Exists_ReturnsResult()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateLayerAttestationAsync(
|
||||
CreateLayerRequest("sha256:image123", "sha256:layer1", 1));
|
||||
|
||||
// Act
|
||||
var result = await _service.GetLayerAttestationAsync("sha256:image123", 1);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.LayerOrder.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLayerAttestationAsync_NotExists_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetLayerAttestationAsync("sha256:image123", 99);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyLayerAttestationAsync_ValidAttestation_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var createResult = await _service.CreateLayerAttestationAsync(
|
||||
CreateLayerRequest("sha256:image123", "sha256:layer0", 0));
|
||||
|
||||
// Act
|
||||
var verifyResult = await _service.VerifyLayerAttestationAsync(createResult.AttestationId);
|
||||
|
||||
// Assert
|
||||
verifyResult.IsValid.Should().BeTrue();
|
||||
verifyResult.SignerIdentity.Should().Be("test-signer");
|
||||
verifyResult.Errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyLayerAttestationAsync_UnknownAttestation_ReturnsInvalid()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.VerifyLayerAttestationAsync("sha256:unknown");
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBatchLayerAttestationsAsync_EmptyLayers_ReturnsEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BatchLayerAttestationRequest
|
||||
{
|
||||
ImageDigest = "sha256:image123",
|
||||
ImageRef = "registry.io/app:latest",
|
||||
Layers = []
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateBatchLayerAttestationsAsync(request);
|
||||
|
||||
// Assert
|
||||
result.AllSucceeded.Should().BeTrue();
|
||||
result.SuccessCount.Should().Be(0);
|
||||
result.Layers.Should().BeEmpty();
|
||||
}
|
||||
|
||||
private static LayerAttestationRequest CreateLayerRequest(
|
||||
string imageDigest,
|
||||
string layerDigest,
|
||||
int layerOrder)
|
||||
{
|
||||
return new LayerAttestationRequest
|
||||
{
|
||||
ImageDigest = imageDigest,
|
||||
LayerDigest = layerDigest,
|
||||
LayerOrder = layerOrder,
|
||||
SbomDigest = $"sha256:sbom{layerOrder}",
|
||||
SbomFormat = "cyclonedx"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class InMemoryLayerAttestationStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StoreAsync_NewEntry_StoresSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryLayerAttestationStore();
|
||||
var result = CreateResult("sha256:layer0", 0);
|
||||
|
||||
// Act
|
||||
await store.StoreAsync("sha256:image", result);
|
||||
var retrieved = await store.GetAsync("sha256:image", 0);
|
||||
|
||||
// Assert
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.LayerDigest.Should().Be("sha256:layer0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByImageAsync_MultipleLayers_ReturnsOrdered()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryLayerAttestationStore();
|
||||
await store.StoreAsync("sha256:image", CreateResult("sha256:layer2", 2));
|
||||
await store.StoreAsync("sha256:image", CreateResult("sha256:layer0", 0));
|
||||
await store.StoreAsync("sha256:image", CreateResult("sha256:layer1", 1));
|
||||
|
||||
// Act
|
||||
var results = await store.GetByImageAsync("sha256:image");
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(3);
|
||||
results[0].LayerOrder.Should().Be(0);
|
||||
results[1].LayerOrder.Should().Be(1);
|
||||
results[2].LayerOrder.Should().Be(2);
|
||||
}
|
||||
|
||||
private static LayerAttestationResult CreateResult(string layerDigest, int layerOrder)
|
||||
{
|
||||
return new LayerAttestationResult
|
||||
{
|
||||
LayerDigest = layerDigest,
|
||||
LayerOrder = layerOrder,
|
||||
AttestationId = $"sha256:att{layerOrder}",
|
||||
EnvelopeDigest = $"sha256:env{layerOrder}",
|
||||
Success = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationChain.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T002
|
||||
// Description: Model for ordered attestation chains with validation.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Chain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an ordered chain of attestations forming a DAG.
|
||||
/// </summary>
|
||||
public sealed record AttestationChain
|
||||
{
|
||||
/// <summary>
|
||||
/// The root attestation ID (typically the final verdict).
|
||||
/// </summary>
|
||||
[JsonPropertyName("rootAttestationId")]
|
||||
[JsonPropertyOrder(0)]
|
||||
public required string RootAttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The artifact digest this chain attests.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifactDigest")]
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All nodes in the chain, ordered by depth (root first).
|
||||
/// </summary>
|
||||
[JsonPropertyName("nodes")]
|
||||
[JsonPropertyOrder(2)]
|
||||
public required ImmutableArray<AttestationChainNode> Nodes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All links between attestations in the chain.
|
||||
/// </summary>
|
||||
[JsonPropertyName("links")]
|
||||
[JsonPropertyOrder(3)]
|
||||
public required ImmutableArray<AttestationLink> Links { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the chain is complete (no missing dependencies).
|
||||
/// </summary>
|
||||
[JsonPropertyName("isComplete")]
|
||||
[JsonPropertyOrder(4)]
|
||||
public required bool IsComplete { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this chain was resolved.
|
||||
/// </summary>
|
||||
[JsonPropertyName("resolvedAt")]
|
||||
[JsonPropertyOrder(5)]
|
||||
public required DateTimeOffset ResolvedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum depth of the chain (0 = root only).
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxDepth")]
|
||||
[JsonPropertyOrder(6)]
|
||||
public int MaxDepth => Nodes.Length > 0 ? Nodes.Max(n => n.Depth) : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Missing attestation IDs if chain is incomplete.
|
||||
/// </summary>
|
||||
[JsonPropertyName("missingAttestations")]
|
||||
[JsonPropertyOrder(7)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ImmutableArray<string>? MissingAttestations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Chain validation errors if any.
|
||||
/// </summary>
|
||||
[JsonPropertyName("validationErrors")]
|
||||
[JsonPropertyOrder(8)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ImmutableArray<string>? ValidationErrors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets all nodes at a specific depth.
|
||||
/// </summary>
|
||||
public IEnumerable<AttestationChainNode> GetNodesAtDepth(int depth) =>
|
||||
Nodes.Where(n => n.Depth == depth);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the direct upstream (parent) attestations for a node.
|
||||
/// </summary>
|
||||
public IEnumerable<AttestationChainNode> GetUpstream(string attestationId) =>
|
||||
Links.Where(l => l.SourceAttestationId == attestationId && l.LinkType == AttestationLinkType.DependsOn)
|
||||
.Select(l => Nodes.FirstOrDefault(n => n.AttestationId == l.TargetAttestationId))
|
||||
.Where(n => n is not null)!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the direct downstream (child) attestations for a node.
|
||||
/// </summary>
|
||||
public IEnumerable<AttestationChainNode> GetDownstream(string attestationId) =>
|
||||
Links.Where(l => l.TargetAttestationId == attestationId && l.LinkType == AttestationLinkType.DependsOn)
|
||||
.Select(l => Nodes.FirstOrDefault(n => n.AttestationId == l.SourceAttestationId))
|
||||
.Where(n => n is not null)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A node in the attestation chain.
|
||||
/// </summary>
|
||||
public sealed record AttestationChainNode
|
||||
{
|
||||
/// <summary>
|
||||
/// The attestation ID.
|
||||
/// Format: sha256:{hash}
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestationId")]
|
||||
[JsonPropertyOrder(0)]
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The in-toto predicate type of this attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicateType")]
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The subject digest this attestation refers to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("subjectDigest")]
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string SubjectDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Depth in the chain (0 = root).
|
||||
/// </summary>
|
||||
[JsonPropertyName("depth")]
|
||||
[JsonPropertyOrder(3)]
|
||||
public required int Depth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this attestation was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
[JsonPropertyOrder(4)]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signer identity (if available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("signer")]
|
||||
[JsonPropertyOrder(5)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Signer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable label for display.
|
||||
/// </summary>
|
||||
[JsonPropertyName("label")]
|
||||
[JsonPropertyOrder(6)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a layer-specific attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isLayerAttestation")]
|
||||
[JsonPropertyOrder(7)]
|
||||
public bool IsLayerAttestation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Layer index if this is a layer attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("layerIndex")]
|
||||
[JsonPropertyOrder(8)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? LayerIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a root node (no incoming links).
|
||||
/// </summary>
|
||||
[JsonPropertyName("isRoot")]
|
||||
[JsonPropertyOrder(9)]
|
||||
public bool IsRoot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a leaf node (no outgoing links).
|
||||
/// </summary>
|
||||
[JsonPropertyName("isLeaf")]
|
||||
[JsonPropertyOrder(10)]
|
||||
public bool IsLeaf { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata for this node.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
[JsonPropertyOrder(11)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve an attestation chain.
|
||||
/// </summary>
|
||||
public sealed record AttestationChainRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The artifact digest to get the chain for.
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum depth to traverse (default: 10).
|
||||
/// </summary>
|
||||
public int MaxDepth { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include layer attestations.
|
||||
/// </summary>
|
||||
public bool IncludeLayers { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Specific predicate types to include (null = all).
|
||||
/// </summary>
|
||||
public ImmutableArray<string>? IncludePredicateTypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for access control.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Common predicate types for StellaOps attestations.
|
||||
/// </summary>
|
||||
public static class PredicateTypes
|
||||
{
|
||||
public const string SbomAttestation = "StellaOps.SBOMAttestation@1";
|
||||
public const string VexAttestation = "StellaOps.VEXAttestation@1";
|
||||
public const string PolicyEvaluation = "StellaOps.PolicyEvaluation@1";
|
||||
public const string GateResult = "StellaOps.GateResult@1";
|
||||
public const string ScanResult = "StellaOps.ScanResult@1";
|
||||
public const string LayerSbom = "StellaOps.LayerSBOM@1";
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationChainBuilder.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T013
|
||||
// Description: Builds attestation chains by extracting links from in-toto materials.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Chain;
|
||||
|
||||
/// <summary>
|
||||
/// Builds attestation chains by extracting and storing links from attestation materials.
|
||||
/// </summary>
|
||||
public sealed class AttestationChainBuilder
|
||||
{
|
||||
private readonly IAttestationLinkStore _linkStore;
|
||||
private readonly AttestationChainValidator _validator;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AttestationChainBuilder(
|
||||
IAttestationLinkStore linkStore,
|
||||
AttestationChainValidator validator,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_linkStore = linkStore;
|
||||
_validator = validator;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts and stores links from an attestation's materials.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The source attestation ID.</param>
|
||||
/// <param name="materials">The in-toto materials from the attestation.</param>
|
||||
/// <param name="linkType">The type of link to create.</param>
|
||||
/// <param name="metadata">Optional link metadata.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result of the link extraction.</returns>
|
||||
public async Task<ChainBuildResult> ExtractLinksAsync(
|
||||
string attestationId,
|
||||
IEnumerable<InTotoMaterial> materials,
|
||||
AttestationLinkType linkType = AttestationLinkType.DependsOn,
|
||||
LinkMetadata? metadata = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var linksCreated = new List<AttestationLink>();
|
||||
var skippedCount = 0;
|
||||
|
||||
// Get existing links for validation
|
||||
var existingLinks = await _linkStore.GetBySourceAsync(attestationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var material in materials)
|
||||
{
|
||||
// Extract attestation references from materials
|
||||
var targetId = ExtractAttestationId(material);
|
||||
if (targetId is null)
|
||||
{
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var link = new AttestationLink
|
||||
{
|
||||
SourceAttestationId = attestationId,
|
||||
TargetAttestationId = targetId,
|
||||
LinkType = linkType,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Metadata = metadata ?? ExtractMetadata(material)
|
||||
};
|
||||
|
||||
// Validate before storing
|
||||
var validationResult = _validator.ValidateLink(link, existingLinks.ToList());
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
foreach (var error in validationResult.Errors)
|
||||
{
|
||||
errors.Add($"Link {attestationId} -> {targetId}: {error}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await _linkStore.StoreAsync(link, cancellationToken).ConfigureAwait(false);
|
||||
linksCreated.Add(link);
|
||||
|
||||
// Update existing links for subsequent validations
|
||||
existingLinks = existingLinks.Add(link);
|
||||
}
|
||||
|
||||
return new ChainBuildResult
|
||||
{
|
||||
IsSuccess = errors.Count == 0,
|
||||
LinksCreated = [.. linksCreated],
|
||||
SkippedMaterialsCount = skippedCount,
|
||||
Errors = [.. errors],
|
||||
BuildCompletedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a direct link between two attestations.
|
||||
/// </summary>
|
||||
public async Task<ChainBuildResult> CreateLinkAsync(
|
||||
string sourceId,
|
||||
string targetId,
|
||||
AttestationLinkType linkType = AttestationLinkType.DependsOn,
|
||||
LinkMetadata? metadata = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get all relevant links for validation (from source for duplicates, from target for cycles)
|
||||
var existingLinks = await GetAllRelevantLinksAsync(sourceId, targetId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var link = new AttestationLink
|
||||
{
|
||||
SourceAttestationId = sourceId,
|
||||
TargetAttestationId = targetId,
|
||||
LinkType = linkType,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
var validationResult = _validator.ValidateLink(link, existingLinks);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
return new ChainBuildResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
LinksCreated = [],
|
||||
SkippedMaterialsCount = 0,
|
||||
Errors = validationResult.Errors,
|
||||
BuildCompletedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
await _linkStore.StoreAsync(link, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new ChainBuildResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
LinksCreated = [link],
|
||||
SkippedMaterialsCount = 0,
|
||||
Errors = [],
|
||||
BuildCompletedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates links for layer attestations.
|
||||
/// </summary>
|
||||
public async Task<ChainBuildResult> LinkLayerAttestationsAsync(
|
||||
string parentAttestationId,
|
||||
IEnumerable<LayerAttestationRef> layerRefs,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var linksCreated = new List<AttestationLink>();
|
||||
|
||||
var existingLinks = await _linkStore.GetBySourceAsync(parentAttestationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var layerRef in layerRefs.OrderBy(l => l.LayerIndex))
|
||||
{
|
||||
var link = new AttestationLink
|
||||
{
|
||||
SourceAttestationId = parentAttestationId,
|
||||
TargetAttestationId = layerRef.AttestationId,
|
||||
LinkType = AttestationLinkType.DependsOn,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Metadata = new LinkMetadata
|
||||
{
|
||||
Reason = $"Layer {layerRef.LayerIndex} attestation",
|
||||
Annotations = ImmutableDictionary<string, string>.Empty
|
||||
.Add("layerIndex", layerRef.LayerIndex.ToString())
|
||||
.Add("layerDigest", layerRef.LayerDigest)
|
||||
}
|
||||
};
|
||||
|
||||
var validationResult = _validator.ValidateLink(link, existingLinks.ToList());
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
errors.AddRange(validationResult.Errors.Select(e =>
|
||||
$"Layer {layerRef.LayerIndex}: {e}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
await _linkStore.StoreAsync(link, cancellationToken).ConfigureAwait(false);
|
||||
linksCreated.Add(link);
|
||||
existingLinks = existingLinks.Add(link);
|
||||
}
|
||||
|
||||
return new ChainBuildResult
|
||||
{
|
||||
IsSuccess = errors.Count == 0,
|
||||
LinksCreated = [.. linksCreated],
|
||||
SkippedMaterialsCount = 0,
|
||||
Errors = [.. errors],
|
||||
BuildCompletedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts an attestation ID from a material reference.
|
||||
/// </summary>
|
||||
private static string? ExtractAttestationId(InTotoMaterial material)
|
||||
{
|
||||
// Check if this is an attestation reference
|
||||
if (material.Uri.StartsWith(MaterialUriSchemes.Attestation, StringComparison.Ordinal))
|
||||
{
|
||||
// Format: attestation:sha256:{hash}
|
||||
return material.Uri.Substring(MaterialUriSchemes.Attestation.Length);
|
||||
}
|
||||
|
||||
// Check if digest contains attestation reference
|
||||
if (material.Digest.TryGetValue("attestationId", out var attestationId))
|
||||
{
|
||||
return attestationId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all links relevant for validating a new link (for duplicate and cycle detection).
|
||||
/// Uses BFS to gather links reachable from the target for cycle detection.
|
||||
/// </summary>
|
||||
private async Task<List<AttestationLink>> GetAllRelevantLinksAsync(
|
||||
string sourceId,
|
||||
string targetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var links = new Dictionary<(string, string), AttestationLink>();
|
||||
|
||||
// Get links from source (for duplicate detection)
|
||||
var sourceLinks = await _linkStore.GetBySourceAsync(sourceId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
foreach (var link in sourceLinks)
|
||||
{
|
||||
links[(link.SourceAttestationId, link.TargetAttestationId)] = link;
|
||||
}
|
||||
|
||||
// BFS from target to gather links for cycle detection
|
||||
var visited = new HashSet<string>();
|
||||
var queue = new Queue<string>();
|
||||
queue.Enqueue(targetId);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
if (!visited.Add(current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var outgoing = await _linkStore.GetBySourceAsync(current, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var link in outgoing)
|
||||
{
|
||||
links[(link.SourceAttestationId, link.TargetAttestationId)] = link;
|
||||
if (!visited.Contains(link.TargetAttestationId))
|
||||
{
|
||||
queue.Enqueue(link.TargetAttestationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [.. links.Values];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts metadata from a material.
|
||||
/// </summary>
|
||||
private static LinkMetadata? ExtractMetadata(InTotoMaterial material)
|
||||
{
|
||||
if (material.Annotations is null || material.Annotations.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var reason = material.Annotations.TryGetValue("predicateType", out var predType)
|
||||
? $"Depends on {predType}"
|
||||
: null;
|
||||
|
||||
return new LinkMetadata
|
||||
{
|
||||
Reason = reason,
|
||||
Annotations = material.Annotations
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of building chain links.
|
||||
/// </summary>
|
||||
public sealed record ChainBuildResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether all links were created successfully.
|
||||
/// </summary>
|
||||
public required bool IsSuccess { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Links that were created.
|
||||
/// </summary>
|
||||
public required ImmutableArray<AttestationLink> LinksCreated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of materials skipped (not attestation references).
|
||||
/// </summary>
|
||||
public required int SkippedMaterialsCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Errors encountered during link creation.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> Errors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the build completed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset BuildCompletedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a layer attestation.
|
||||
/// </summary>
|
||||
public sealed record LayerAttestationRef
|
||||
{
|
||||
/// <summary>
|
||||
/// The layer index (0-based).
|
||||
/// </summary>
|
||||
public required int LayerIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The layer digest.
|
||||
/// </summary>
|
||||
public required string LayerDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The attestation ID for this layer.
|
||||
/// </summary>
|
||||
public required string AttestationId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationChainValidator.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T005
|
||||
// Description: Validates attestation chain structure (DAG, no cycles).
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Chain;
|
||||
|
||||
/// <summary>
|
||||
/// Validates attestation chain structure.
|
||||
/// </summary>
|
||||
public sealed class AttestationChainValidator
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AttestationChainValidator(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a proposed link before insertion.
|
||||
/// </summary>
|
||||
/// <param name="link">The link to validate.</param>
|
||||
/// <param name="existingLinks">All existing links.</param>
|
||||
/// <returns>Validation result.</returns>
|
||||
public ChainValidationResult ValidateLink(
|
||||
AttestationLink link,
|
||||
IReadOnlyList<AttestationLink> existingLinks)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
// Check self-link
|
||||
if (link.SourceAttestationId == link.TargetAttestationId)
|
||||
{
|
||||
errors.Add("Self-links are not allowed");
|
||||
}
|
||||
|
||||
// Check for duplicate link
|
||||
if (existingLinks.Any(l =>
|
||||
l.SourceAttestationId == link.SourceAttestationId &&
|
||||
l.TargetAttestationId == link.TargetAttestationId))
|
||||
{
|
||||
errors.Add("Duplicate link already exists");
|
||||
}
|
||||
|
||||
// Check for circular reference
|
||||
if (WouldCreateCycle(link, existingLinks))
|
||||
{
|
||||
errors.Add("Link would create a circular reference");
|
||||
}
|
||||
|
||||
return new ChainValidationResult
|
||||
{
|
||||
IsValid = errors.Count == 0,
|
||||
Errors = [.. errors],
|
||||
ValidatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an entire chain structure.
|
||||
/// </summary>
|
||||
/// <param name="chain">The chain to validate.</param>
|
||||
/// <returns>Validation result.</returns>
|
||||
public ChainValidationResult ValidateChain(AttestationChain chain)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
// Check for empty chain
|
||||
if (chain.Nodes.Length == 0)
|
||||
{
|
||||
errors.Add("Chain has no nodes");
|
||||
return new ChainValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Errors = [.. errors],
|
||||
ValidatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
// Check root exists
|
||||
if (!chain.Nodes.Any(n => n.AttestationId == chain.RootAttestationId))
|
||||
{
|
||||
errors.Add("Root attestation not found in chain nodes");
|
||||
}
|
||||
|
||||
// Check for duplicate nodes
|
||||
var nodeIds = chain.Nodes.Select(n => n.AttestationId).ToList();
|
||||
var duplicateNodes = nodeIds.GroupBy(id => id).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
|
||||
if (duplicateNodes.Count > 0)
|
||||
{
|
||||
errors.Add($"Duplicate nodes found: {string.Join(", ", duplicateNodes)}");
|
||||
}
|
||||
|
||||
// Check all link targets exist in nodes
|
||||
var nodeIdSet = nodeIds.ToHashSet();
|
||||
foreach (var link in chain.Links)
|
||||
{
|
||||
if (!nodeIdSet.Contains(link.SourceAttestationId))
|
||||
{
|
||||
errors.Add($"Link source {link.SourceAttestationId} not found in nodes");
|
||||
}
|
||||
if (!nodeIdSet.Contains(link.TargetAttestationId))
|
||||
{
|
||||
errors.Add($"Link target {link.TargetAttestationId} not found in nodes");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for cycles in the chain
|
||||
if (HasCycles(chain.Links.ToList()))
|
||||
{
|
||||
errors.Add("Chain contains circular references");
|
||||
}
|
||||
|
||||
// Check depth consistency
|
||||
if (!ValidateDepths(chain))
|
||||
{
|
||||
errors.Add("Node depths are inconsistent with link structure");
|
||||
}
|
||||
|
||||
return new ChainValidationResult
|
||||
{
|
||||
IsValid = errors.Count == 0,
|
||||
Errors = [.. errors],
|
||||
ValidatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if adding a link would create a cycle.
|
||||
/// </summary>
|
||||
private static bool WouldCreateCycle(
|
||||
AttestationLink newLink,
|
||||
IReadOnlyList<AttestationLink> existingLinks)
|
||||
{
|
||||
// Check if there's already a path from target to source
|
||||
// If so, adding source -> target would create a cycle
|
||||
var visited = new HashSet<string>();
|
||||
var queue = new Queue<string>();
|
||||
queue.Enqueue(newLink.TargetAttestationId);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
if (current == newLink.SourceAttestationId)
|
||||
{
|
||||
return true; // Found path from target back to source
|
||||
}
|
||||
|
||||
if (!visited.Add(current))
|
||||
{
|
||||
continue; // Already visited
|
||||
}
|
||||
|
||||
// Follow outgoing links from current
|
||||
foreach (var link in existingLinks.Where(l => l.SourceAttestationId == current))
|
||||
{
|
||||
queue.Enqueue(link.TargetAttestationId);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the links contain any cycles.
|
||||
/// </summary>
|
||||
private static bool HasCycles(IReadOnlyList<AttestationLink> links)
|
||||
{
|
||||
// Build adjacency list
|
||||
var adjacency = new Dictionary<string, List<string>>();
|
||||
var allNodes = new HashSet<string>();
|
||||
|
||||
foreach (var link in links)
|
||||
{
|
||||
allNodes.Add(link.SourceAttestationId);
|
||||
allNodes.Add(link.TargetAttestationId);
|
||||
|
||||
if (!adjacency.ContainsKey(link.SourceAttestationId))
|
||||
{
|
||||
adjacency[link.SourceAttestationId] = [];
|
||||
}
|
||||
adjacency[link.SourceAttestationId].Add(link.TargetAttestationId);
|
||||
}
|
||||
|
||||
// DFS to detect cycles
|
||||
var white = new HashSet<string>(allNodes); // Not visited
|
||||
var gray = new HashSet<string>(); // In progress
|
||||
var black = new HashSet<string>(); // Completed
|
||||
|
||||
foreach (var node in allNodes)
|
||||
{
|
||||
if (white.Contains(node))
|
||||
{
|
||||
if (HasCycleDfs(node, adjacency, white, gray, black))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasCycleDfs(
|
||||
string node,
|
||||
Dictionary<string, List<string>> adjacency,
|
||||
HashSet<string> white,
|
||||
HashSet<string> gray,
|
||||
HashSet<string> black)
|
||||
{
|
||||
white.Remove(node);
|
||||
gray.Add(node);
|
||||
|
||||
if (adjacency.TryGetValue(node, out var neighbors))
|
||||
{
|
||||
foreach (var neighbor in neighbors)
|
||||
{
|
||||
if (black.Contains(neighbor))
|
||||
{
|
||||
continue; // Already fully explored
|
||||
}
|
||||
|
||||
if (gray.Contains(neighbor))
|
||||
{
|
||||
return true; // Back edge = cycle
|
||||
}
|
||||
|
||||
if (HasCycleDfs(neighbor, adjacency, white, gray, black))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gray.Remove(node);
|
||||
black.Add(node);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that node depths are consistent with link structure.
|
||||
/// </summary>
|
||||
private static bool ValidateDepths(AttestationChain chain)
|
||||
{
|
||||
// Root should be at depth 0
|
||||
var root = chain.Nodes.FirstOrDefault(n => n.AttestationId == chain.RootAttestationId);
|
||||
if (root is null || root.Depth != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build expected depths from links
|
||||
var expectedDepths = new Dictionary<string, int> { [chain.RootAttestationId] = 0 };
|
||||
var queue = new Queue<string>();
|
||||
queue.Enqueue(chain.RootAttestationId);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
var currentDepth = expectedDepths[current];
|
||||
|
||||
// Find all targets (dependencies) of current
|
||||
foreach (var link in chain.Links.Where(l =>
|
||||
l.SourceAttestationId == current &&
|
||||
l.LinkType == AttestationLinkType.DependsOn))
|
||||
{
|
||||
var targetDepth = currentDepth + 1;
|
||||
if (expectedDepths.TryGetValue(link.TargetAttestationId, out var existingDepth))
|
||||
{
|
||||
// If already assigned a depth, take the minimum
|
||||
if (targetDepth < existingDepth)
|
||||
{
|
||||
expectedDepths[link.TargetAttestationId] = targetDepth;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
expectedDepths[link.TargetAttestationId] = targetDepth;
|
||||
queue.Enqueue(link.TargetAttestationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify actual depths match expected
|
||||
foreach (var node in chain.Nodes)
|
||||
{
|
||||
if (expectedDepths.TryGetValue(node.AttestationId, out var expectedDepth))
|
||||
{
|
||||
if (node.Depth != expectedDepth)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of chain validation.
|
||||
/// </summary>
|
||||
public sealed record ChainValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether validation passed.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation errors if any.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> Errors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When validation was performed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ValidatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful validation result.
|
||||
/// </summary>
|
||||
public static ChainValidationResult Success(DateTimeOffset validatedAt) => new()
|
||||
{
|
||||
IsValid = true,
|
||||
Errors = [],
|
||||
ValidatedAt = validatedAt
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationLink.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T001
|
||||
// Description: Model for links between attestations in a chain.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Chain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a link between two attestations in an attestation chain.
|
||||
/// </summary>
|
||||
public sealed record AttestationLink
|
||||
{
|
||||
/// <summary>
|
||||
/// The attestation ID of the source (dependent) attestation.
|
||||
/// Format: sha256:{hash}
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceAttestationId")]
|
||||
[JsonPropertyOrder(0)]
|
||||
public required string SourceAttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The attestation ID of the target (dependency) attestation.
|
||||
/// Format: sha256:{hash}
|
||||
/// </summary>
|
||||
[JsonPropertyName("targetAttestationId")]
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string TargetAttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The type of relationship between the attestations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("linkType")]
|
||||
[JsonPropertyOrder(2)]
|
||||
public required AttestationLinkType LinkType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this link was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
[JsonPropertyOrder(3)]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata about the link.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
[JsonPropertyOrder(4)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public LinkMetadata? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of links between attestations.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<AttestationLinkType>))]
|
||||
public enum AttestationLinkType
|
||||
{
|
||||
/// <summary>
|
||||
/// Target is a material/dependency for source.
|
||||
/// Source attestation depends on target attestation.
|
||||
/// </summary>
|
||||
DependsOn,
|
||||
|
||||
/// <summary>
|
||||
/// Source supersedes target (version update, correction).
|
||||
/// Target is the previous version.
|
||||
/// </summary>
|
||||
Supersedes,
|
||||
|
||||
/// <summary>
|
||||
/// Source aggregates multiple targets (batch attestation).
|
||||
/// </summary>
|
||||
Aggregates,
|
||||
|
||||
/// <summary>
|
||||
/// Source is derived from target (transformation).
|
||||
/// </summary>
|
||||
DerivedFrom,
|
||||
|
||||
/// <summary>
|
||||
/// Source verifies/validates target.
|
||||
/// </summary>
|
||||
Verifies
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata for an attestation link.
|
||||
/// </summary>
|
||||
public sealed record LinkMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Human-readable description of the link.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
[JsonPropertyOrder(0)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for creating this link.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reason")]
|
||||
[JsonPropertyOrder(1)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The predicate type of the source attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourcePredicateType")]
|
||||
[JsonPropertyOrder(2)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? SourcePredicateType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The predicate type of the target attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("targetPredicateType")]
|
||||
[JsonPropertyOrder(3)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? TargetPredicateType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who or what created this link.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdBy")]
|
||||
[JsonPropertyOrder(4)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional annotations for the link.
|
||||
/// </summary>
|
||||
[JsonPropertyName("annotations")]
|
||||
[JsonPropertyOrder(5)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ImmutableDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationLinkResolver.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T008
|
||||
// Description: Resolves attestation chains by traversing links.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Chain;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves attestation chains by traversing links in storage.
|
||||
/// </summary>
|
||||
public sealed class AttestationLinkResolver : IAttestationLinkResolver
|
||||
{
|
||||
private readonly IAttestationLinkStore _linkStore;
|
||||
private readonly IAttestationNodeProvider _nodeProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AttestationLinkResolver(
|
||||
IAttestationLinkStore linkStore,
|
||||
IAttestationNodeProvider nodeProvider,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_linkStore = linkStore;
|
||||
_nodeProvider = nodeProvider;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttestationChain> ResolveChainAsync(
|
||||
AttestationChainRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Find the root attestation for this artifact
|
||||
var root = await FindRootAttestationAsync(request.ArtifactDigest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (root is null)
|
||||
{
|
||||
return new AttestationChain
|
||||
{
|
||||
RootAttestationId = string.Empty,
|
||||
ArtifactDigest = request.ArtifactDigest,
|
||||
Nodes = [],
|
||||
Links = [],
|
||||
IsComplete = false,
|
||||
ResolvedAt = _timeProvider.GetUtcNow(),
|
||||
ValidationErrors = ["No root attestation found for artifact"]
|
||||
};
|
||||
}
|
||||
|
||||
// Traverse the chain
|
||||
var nodes = new Dictionary<string, AttestationChainNode>();
|
||||
var links = new List<AttestationLink>();
|
||||
var missingIds = new List<string>();
|
||||
var queue = new Queue<(string AttestationId, int Depth)>();
|
||||
|
||||
nodes[root.AttestationId] = root;
|
||||
queue.Enqueue((root.AttestationId, 0));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (currentId, depth) = queue.Dequeue();
|
||||
|
||||
if (depth >= request.MaxDepth)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get outgoing links (dependencies)
|
||||
var outgoingLinks = await _linkStore.GetBySourceAsync(currentId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var link in outgoingLinks)
|
||||
{
|
||||
// Filter by predicate types if specified
|
||||
if (link.LinkType != AttestationLinkType.DependsOn)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
links.Add(link);
|
||||
|
||||
if (!nodes.ContainsKey(link.TargetAttestationId))
|
||||
{
|
||||
var targetNode = await _nodeProvider.GetNodeAsync(
|
||||
link.TargetAttestationId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (targetNode is not null)
|
||||
{
|
||||
// Skip layer attestations if not requested
|
||||
if (!request.IncludeLayers && targetNode.IsLayerAttestation)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by predicate type if specified
|
||||
if (request.IncludePredicateTypes is { } types &&
|
||||
!types.Contains(targetNode.PredicateType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var nodeWithDepth = targetNode with { Depth = depth + 1 };
|
||||
nodes[link.TargetAttestationId] = nodeWithDepth;
|
||||
queue.Enqueue((link.TargetAttestationId, depth + 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
missingIds.Add(link.TargetAttestationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort nodes by depth
|
||||
var sortedNodes = nodes.Values
|
||||
.OrderBy(n => n.Depth)
|
||||
.ThenBy(n => n.AttestationId)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new AttestationChain
|
||||
{
|
||||
RootAttestationId = root.AttestationId,
|
||||
ArtifactDigest = request.ArtifactDigest,
|
||||
Nodes = sortedNodes,
|
||||
Links = [.. links.Distinct()],
|
||||
IsComplete = missingIds.Count == 0,
|
||||
ResolvedAt = _timeProvider.GetUtcNow(),
|
||||
MissingAttestations = missingIds.Count > 0 ? [.. missingIds] : null
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImmutableArray<AttestationChainNode>> GetUpstreamAsync(
|
||||
string attestationId,
|
||||
int maxDepth = 10,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var nodes = new Dictionary<string, AttestationChainNode>();
|
||||
var queue = new Queue<(string AttestationId, int Depth)>();
|
||||
queue.Enqueue((attestationId, 0));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (currentId, depth) = queue.Dequeue();
|
||||
|
||||
if (depth >= maxDepth)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get incoming links (dependents - those that depend on this)
|
||||
var incomingLinks = await _linkStore.GetByTargetAsync(currentId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var link in incomingLinks.Where(l => l.LinkType == AttestationLinkType.DependsOn))
|
||||
{
|
||||
if (!nodes.ContainsKey(link.SourceAttestationId) && link.SourceAttestationId != attestationId)
|
||||
{
|
||||
var node = await _nodeProvider.GetNodeAsync(link.SourceAttestationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (node is not null)
|
||||
{
|
||||
nodes[link.SourceAttestationId] = node with { Depth = depth + 1 };
|
||||
queue.Enqueue((link.SourceAttestationId, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [.. nodes.Values.OrderBy(n => n.Depth).ThenBy(n => n.AttestationId)];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImmutableArray<AttestationChainNode>> GetDownstreamAsync(
|
||||
string attestationId,
|
||||
int maxDepth = 10,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var nodes = new Dictionary<string, AttestationChainNode>();
|
||||
var queue = new Queue<(string AttestationId, int Depth)>();
|
||||
queue.Enqueue((attestationId, 0));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (currentId, depth) = queue.Dequeue();
|
||||
|
||||
if (depth >= maxDepth)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get outgoing links (dependencies)
|
||||
var outgoingLinks = await _linkStore.GetBySourceAsync(currentId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var link in outgoingLinks.Where(l => l.LinkType == AttestationLinkType.DependsOn))
|
||||
{
|
||||
if (!nodes.ContainsKey(link.TargetAttestationId) && link.TargetAttestationId != attestationId)
|
||||
{
|
||||
var node = await _nodeProvider.GetNodeAsync(link.TargetAttestationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (node is not null)
|
||||
{
|
||||
nodes[link.TargetAttestationId] = node with { Depth = depth + 1 };
|
||||
queue.Enqueue((link.TargetAttestationId, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [.. nodes.Values.OrderBy(n => n.Depth).ThenBy(n => n.AttestationId)];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImmutableArray<AttestationLink>> GetLinksAsync(
|
||||
string attestationId,
|
||||
LinkDirection direction = LinkDirection.Both,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var links = new List<AttestationLink>();
|
||||
|
||||
if (direction is LinkDirection.Outgoing or LinkDirection.Both)
|
||||
{
|
||||
var outgoing = await _linkStore.GetBySourceAsync(attestationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
links.AddRange(outgoing);
|
||||
}
|
||||
|
||||
if (direction is LinkDirection.Incoming or LinkDirection.Both)
|
||||
{
|
||||
var incoming = await _linkStore.GetByTargetAsync(attestationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
links.AddRange(incoming);
|
||||
}
|
||||
|
||||
return [.. links.Distinct()];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttestationChainNode?> FindRootAttestationAsync(
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _nodeProvider.FindRootByArtifactAsync(artifactDigest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> AreLinkedAsync(
|
||||
string sourceId,
|
||||
string targetId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Check direct link first
|
||||
if (await _linkStore.ExistsAsync(sourceId, targetId, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check indirect path via BFS
|
||||
var visited = new HashSet<string>();
|
||||
var queue = new Queue<string>();
|
||||
queue.Enqueue(sourceId);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
if (!visited.Add(current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var outgoing = await _linkStore.GetBySourceAsync(current, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var link in outgoing)
|
||||
{
|
||||
if (link.TargetAttestationId == targetId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!visited.Contains(link.TargetAttestationId))
|
||||
{
|
||||
queue.Enqueue(link.TargetAttestationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttestationChain?> ResolveUpstreamAsync(
|
||||
string attestationId,
|
||||
int maxDepth = 5,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startNode = await _nodeProvider.GetNodeAsync(attestationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (startNode is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var nodes = new Dictionary<string, AttestationChainNode>
|
||||
{
|
||||
[attestationId] = startNode with { Depth = 0, IsRoot = false }
|
||||
};
|
||||
var links = new List<AttestationLink>();
|
||||
var queue = new Queue<(string AttestationId, int Depth)>();
|
||||
queue.Enqueue((attestationId, 0));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (currentId, depth) = queue.Dequeue();
|
||||
|
||||
if (depth >= maxDepth)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get incoming links (those that depend on this attestation)
|
||||
var incomingLinks = await _linkStore.GetByTargetAsync(currentId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var link in incomingLinks)
|
||||
{
|
||||
links.Add(link);
|
||||
|
||||
if (!nodes.ContainsKey(link.SourceAttestationId))
|
||||
{
|
||||
var node = await _nodeProvider.GetNodeAsync(link.SourceAttestationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (node is not null)
|
||||
{
|
||||
nodes[link.SourceAttestationId] = node with { Depth = depth + 1 };
|
||||
queue.Enqueue((link.SourceAttestationId, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BuildChainFromNodes(startNode, nodes, links);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttestationChain?> ResolveDownstreamAsync(
|
||||
string attestationId,
|
||||
int maxDepth = 5,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startNode = await _nodeProvider.GetNodeAsync(attestationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (startNode is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var nodes = new Dictionary<string, AttestationChainNode>
|
||||
{
|
||||
[attestationId] = startNode with { Depth = 0, IsRoot = true }
|
||||
};
|
||||
var links = new List<AttestationLink>();
|
||||
var queue = new Queue<(string AttestationId, int Depth)>();
|
||||
queue.Enqueue((attestationId, 0));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (currentId, depth) = queue.Dequeue();
|
||||
|
||||
if (depth >= maxDepth)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get outgoing links (dependencies)
|
||||
var outgoingLinks = await _linkStore.GetBySourceAsync(currentId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var link in outgoingLinks)
|
||||
{
|
||||
links.Add(link);
|
||||
|
||||
if (!nodes.ContainsKey(link.TargetAttestationId))
|
||||
{
|
||||
var node = await _nodeProvider.GetNodeAsync(link.TargetAttestationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (node is not null)
|
||||
{
|
||||
nodes[link.TargetAttestationId] = node with { Depth = depth + 1 };
|
||||
queue.Enqueue((link.TargetAttestationId, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BuildChainFromNodes(startNode, nodes, links);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttestationChain?> ResolveFullChainAsync(
|
||||
string attestationId,
|
||||
int maxDepth = 5,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startNode = await _nodeProvider.GetNodeAsync(attestationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (startNode is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var nodes = new Dictionary<string, AttestationChainNode>
|
||||
{
|
||||
[attestationId] = startNode with { Depth = 0 }
|
||||
};
|
||||
var links = new List<AttestationLink>();
|
||||
var visited = new HashSet<string>();
|
||||
var queue = new Queue<(string AttestationId, int Depth, bool IsUpstream)>();
|
||||
|
||||
// Traverse both directions
|
||||
queue.Enqueue((attestationId, 0, true)); // Upstream
|
||||
queue.Enqueue((attestationId, 0, false)); // Downstream
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (currentId, depth, isUpstream) = queue.Dequeue();
|
||||
var visitKey = $"{currentId}:{(isUpstream ? "up" : "down")}";
|
||||
|
||||
if (!visited.Add(visitKey) || depth >= maxDepth)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isUpstream)
|
||||
{
|
||||
// Get incoming links
|
||||
var incomingLinks = await _linkStore.GetByTargetAsync(currentId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var link in incomingLinks)
|
||||
{
|
||||
if (!links.Any(l => l.SourceAttestationId == link.SourceAttestationId &&
|
||||
l.TargetAttestationId == link.TargetAttestationId))
|
||||
{
|
||||
links.Add(link);
|
||||
}
|
||||
|
||||
if (!nodes.ContainsKey(link.SourceAttestationId))
|
||||
{
|
||||
var node = await _nodeProvider.GetNodeAsync(link.SourceAttestationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (node is not null)
|
||||
{
|
||||
nodes[link.SourceAttestationId] = node with { Depth = depth + 1 };
|
||||
queue.Enqueue((link.SourceAttestationId, depth + 1, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Get outgoing links
|
||||
var outgoingLinks = await _linkStore.GetBySourceAsync(currentId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var link in outgoingLinks)
|
||||
{
|
||||
if (!links.Any(l => l.SourceAttestationId == link.SourceAttestationId &&
|
||||
l.TargetAttestationId == link.TargetAttestationId))
|
||||
{
|
||||
links.Add(link);
|
||||
}
|
||||
|
||||
if (!nodes.ContainsKey(link.TargetAttestationId))
|
||||
{
|
||||
var node = await _nodeProvider.GetNodeAsync(link.TargetAttestationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (node is not null)
|
||||
{
|
||||
nodes[link.TargetAttestationId] = node with { Depth = depth + 1 };
|
||||
queue.Enqueue((link.TargetAttestationId, depth + 1, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BuildChainFromNodes(startNode, nodes, links);
|
||||
}
|
||||
|
||||
private AttestationChain BuildChainFromNodes(
|
||||
AttestationChainNode startNode,
|
||||
Dictionary<string, AttestationChainNode> nodes,
|
||||
List<AttestationLink> links)
|
||||
{
|
||||
// Determine root and leaf nodes
|
||||
var sourceIds = links.Select(l => l.SourceAttestationId).ToHashSet();
|
||||
var targetIds = links.Select(l => l.TargetAttestationId).ToHashSet();
|
||||
|
||||
var updatedNodes = nodes.Values.Select(n =>
|
||||
{
|
||||
var hasIncoming = targetIds.Contains(n.AttestationId);
|
||||
var hasOutgoing = sourceIds.Contains(n.AttestationId);
|
||||
return n with
|
||||
{
|
||||
IsRoot = !hasIncoming || n.AttestationId == startNode.AttestationId,
|
||||
IsLeaf = !hasOutgoing
|
||||
};
|
||||
}).OrderBy(n => n.Depth).ThenBy(n => n.AttestationId).ToImmutableArray();
|
||||
|
||||
return new AttestationChain
|
||||
{
|
||||
RootAttestationId = startNode.AttestationId,
|
||||
ArtifactDigest = startNode.SubjectDigest,
|
||||
Nodes = updatedNodes,
|
||||
Links = [.. links.Distinct()],
|
||||
IsComplete = true,
|
||||
ResolvedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides attestation node information for chain resolution.
|
||||
/// </summary>
|
||||
public interface IAttestationNodeProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an attestation node by ID.
|
||||
/// </summary>
|
||||
Task<AttestationChainNode?> GetNodeAsync(
|
||||
string attestationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds the root attestation for an artifact.
|
||||
/// </summary>
|
||||
Task<AttestationChainNode?> FindRootByArtifactAsync(
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all attestation nodes for a subject digest.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AttestationChainNode>> GetBySubjectAsync(
|
||||
string subjectDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DependencyInjectionRoutine.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Description: DI registration for attestation chain services.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Chain;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection extensions for attestation chain services.
|
||||
/// </summary>
|
||||
public static class ChainDependencyInjectionRoutine
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds attestation chain services with in-memory stores (for testing/development).
|
||||
/// </summary>
|
||||
public static IServiceCollection AddAttestationChainInMemory(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<InMemoryAttestationLinkStore>();
|
||||
services.TryAddSingleton<IAttestationLinkStore>(sp => sp.GetRequiredService<InMemoryAttestationLinkStore>());
|
||||
services.TryAddSingleton<InMemoryAttestationNodeProvider>();
|
||||
services.TryAddSingleton<IAttestationNodeProvider>(sp => sp.GetRequiredService<InMemoryAttestationNodeProvider>());
|
||||
services.TryAddSingleton<IAttestationLinkResolver, AttestationLinkResolver>();
|
||||
services.TryAddSingleton<AttestationChainValidator>();
|
||||
services.TryAddSingleton<AttestationChainBuilder>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds attestation chain validation services.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddAttestationChainValidation(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<AttestationChainValidator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds attestation chain resolver with custom stores.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddAttestationChainResolver<TLinkStore, TNodeProvider>(
|
||||
this IServiceCollection services)
|
||||
where TLinkStore : class, IAttestationLinkStore
|
||||
where TNodeProvider : class, IAttestationNodeProvider
|
||||
{
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IAttestationLinkStore, TLinkStore>();
|
||||
services.TryAddSingleton<IAttestationNodeProvider, TNodeProvider>();
|
||||
services.TryAddSingleton<IAttestationLinkResolver, AttestationLinkResolver>();
|
||||
services.TryAddSingleton<AttestationChainValidator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IAttestationLinkResolver.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T004
|
||||
// Description: Interface for resolving attestation chains from any point.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Chain;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves attestation chains from storage.
|
||||
/// </summary>
|
||||
public interface IAttestationLinkResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the full attestation chain for an artifact.
|
||||
/// </summary>
|
||||
/// <param name="request">Chain resolution request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Resolved attestation chain.</returns>
|
||||
Task<AttestationChain> ResolveChainAsync(
|
||||
AttestationChainRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all upstream (parent) attestations for an attestation.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The attestation ID.</param>
|
||||
/// <param name="maxDepth">Maximum depth to traverse.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of upstream attestation nodes.</returns>
|
||||
Task<ImmutableArray<AttestationChainNode>> GetUpstreamAsync(
|
||||
string attestationId,
|
||||
int maxDepth = 10,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all downstream (child) attestations for an attestation.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The attestation ID.</param>
|
||||
/// <param name="maxDepth">Maximum depth to traverse.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of downstream attestation nodes.</returns>
|
||||
Task<ImmutableArray<AttestationChainNode>> GetDownstreamAsync(
|
||||
string attestationId,
|
||||
int maxDepth = 10,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all links for an attestation.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The attestation ID.</param>
|
||||
/// <param name="direction">Direction of links to return.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of attestation links.</returns>
|
||||
Task<ImmutableArray<AttestationLink>> GetLinksAsync(
|
||||
string attestationId,
|
||||
LinkDirection direction = LinkDirection.Both,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds the root attestation for an artifact.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">The artifact digest.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The root attestation node, or null if not found.</returns>
|
||||
Task<AttestationChainNode?> FindRootAttestationAsync(
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if two attestations are linked (directly or indirectly).
|
||||
/// </summary>
|
||||
/// <param name="sourceId">Source attestation ID.</param>
|
||||
/// <param name="targetId">Target attestation ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if linked, false otherwise.</returns>
|
||||
Task<bool> AreLinkedAsync(
|
||||
string sourceId,
|
||||
string targetId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the upstream chain starting from an attestation.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The starting attestation ID.</param>
|
||||
/// <param name="maxDepth">Maximum traversal depth.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Chain containing upstream attestations, or null if not found.</returns>
|
||||
Task<AttestationChain?> ResolveUpstreamAsync(
|
||||
string attestationId,
|
||||
int maxDepth = 5,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the downstream chain starting from an attestation.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The starting attestation ID.</param>
|
||||
/// <param name="maxDepth">Maximum traversal depth.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Chain containing downstream attestations, or null if not found.</returns>
|
||||
Task<AttestationChain?> ResolveDownstreamAsync(
|
||||
string attestationId,
|
||||
int maxDepth = 5,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the full chain (both directions) starting from an attestation.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The starting attestation ID.</param>
|
||||
/// <param name="maxDepth">Maximum traversal depth in each direction.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Chain containing all related attestations, or null if not found.</returns>
|
||||
Task<AttestationChain?> ResolveFullChainAsync(
|
||||
string attestationId,
|
||||
int maxDepth = 5,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direction for querying links.
|
||||
/// </summary>
|
||||
public enum LinkDirection
|
||||
{
|
||||
/// <summary>
|
||||
/// Get links where this attestation is the source (outgoing).
|
||||
/// </summary>
|
||||
Outgoing,
|
||||
|
||||
/// <summary>
|
||||
/// Get links where this attestation is the target (incoming).
|
||||
/// </summary>
|
||||
Incoming,
|
||||
|
||||
/// <summary>
|
||||
/// Get all links (both directions).
|
||||
/// </summary>
|
||||
Both
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store for attestation links.
|
||||
/// </summary>
|
||||
public interface IAttestationLinkStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores a link between attestations.
|
||||
/// </summary>
|
||||
Task StoreAsync(AttestationLink link, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stores multiple links.
|
||||
/// </summary>
|
||||
Task StoreBatchAsync(IEnumerable<AttestationLink> links, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all links where the attestation is the source.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<AttestationLink>> GetBySourceAsync(
|
||||
string sourceAttestationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all links where the attestation is the target.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<AttestationLink>> GetByTargetAsync(
|
||||
string targetAttestationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific link by source and target.
|
||||
/// </summary>
|
||||
Task<AttestationLink?> GetAsync(
|
||||
string sourceId,
|
||||
string targetId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a link exists.
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(
|
||||
string sourceId,
|
||||
string targetId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all links for an attestation.
|
||||
/// </summary>
|
||||
Task DeleteByAttestationAsync(
|
||||
string attestationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InMemoryAttestationLinkStore.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T007
|
||||
// Description: In-memory implementation of attestation link store.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Chain;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IAttestationLinkStore"/>.
|
||||
/// Suitable for testing and single-instance scenarios.
|
||||
/// </summary>
|
||||
public sealed class InMemoryAttestationLinkStore : IAttestationLinkStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Source, string Target), AttestationLink> _links = new();
|
||||
private readonly ConcurrentDictionary<string, ConcurrentBag<AttestationLink>> _bySource = new();
|
||||
private readonly ConcurrentDictionary<string, ConcurrentBag<AttestationLink>> _byTarget = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StoreAsync(AttestationLink link, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var key = (link.SourceAttestationId, link.TargetAttestationId);
|
||||
if (_links.TryAdd(key, link))
|
||||
{
|
||||
// Add to source index
|
||||
var sourceBag = _bySource.GetOrAdd(link.SourceAttestationId, _ => []);
|
||||
sourceBag.Add(link);
|
||||
|
||||
// Add to target index
|
||||
var targetBag = _byTarget.GetOrAdd(link.TargetAttestationId, _ => []);
|
||||
targetBag.Add(link);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StoreBatchAsync(IEnumerable<AttestationLink> links, CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var link in links)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await StoreAsync(link, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<AttestationLink>> GetBySourceAsync(
|
||||
string sourceAttestationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_bySource.TryGetValue(sourceAttestationId, out var links))
|
||||
{
|
||||
return Task.FromResult(links.Distinct().ToImmutableArray());
|
||||
}
|
||||
|
||||
return Task.FromResult(ImmutableArray<AttestationLink>.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<AttestationLink>> GetByTargetAsync(
|
||||
string targetAttestationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_byTarget.TryGetValue(targetAttestationId, out var links))
|
||||
{
|
||||
return Task.FromResult(links.Distinct().ToImmutableArray());
|
||||
}
|
||||
|
||||
return Task.FromResult(ImmutableArray<AttestationLink>.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<AttestationLink?> GetAsync(
|
||||
string sourceId,
|
||||
string targetId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_links.TryGetValue((sourceId, targetId), out var link);
|
||||
return Task.FromResult(link);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsAsync(
|
||||
string sourceId,
|
||||
string targetId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return Task.FromResult(_links.ContainsKey((sourceId, targetId)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteByAttestationAsync(
|
||||
string attestationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Remove from main dictionary and indexes
|
||||
var keysToRemove = _links.Keys
|
||||
.Where(k => k.Source == attestationId || k.Target == attestationId)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_links.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
// Clean up indexes
|
||||
_bySource.TryRemove(attestationId, out _);
|
||||
_byTarget.TryRemove(attestationId, out _);
|
||||
|
||||
// Remove from other bags where this attestation appears as the other side
|
||||
foreach (var kvp in _bySource)
|
||||
{
|
||||
// ConcurrentBag doesn't support removal, but we can rebuild
|
||||
var filtered = kvp.Value.Where(l => l.TargetAttestationId != attestationId).ToList();
|
||||
if (filtered.Count != kvp.Value.Count)
|
||||
{
|
||||
_bySource[kvp.Key] = new ConcurrentBag<AttestationLink>(filtered);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var kvp in _byTarget)
|
||||
{
|
||||
var filtered = kvp.Value.Where(l => l.SourceAttestationId != attestationId).ToList();
|
||||
if (filtered.Count != kvp.Value.Count)
|
||||
{
|
||||
_byTarget[kvp.Key] = new ConcurrentBag<AttestationLink>(filtered);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all links in the store.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<AttestationLink> GetAll() => _links.Values.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Clears all links from the store.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_links.Clear();
|
||||
_bySource.Clear();
|
||||
_byTarget.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of links in the store.
|
||||
/// </summary>
|
||||
public int Count => _links.Count;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InMemoryAttestationNodeProvider.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T009
|
||||
// Description: In-memory implementation of attestation node provider.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Chain;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IAttestationNodeProvider"/>.
|
||||
/// Suitable for testing and single-instance scenarios.
|
||||
/// </summary>
|
||||
public sealed class InMemoryAttestationNodeProvider : IAttestationNodeProvider
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AttestationChainNode> _nodes = new();
|
||||
private readonly ConcurrentDictionary<string, string> _artifactRoots = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<AttestationChainNode?> GetNodeAsync(
|
||||
string attestationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_nodes.TryGetValue(attestationId, out var node);
|
||||
return Task.FromResult(node);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<AttestationChainNode?> FindRootByArtifactAsync(
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_artifactRoots.TryGetValue(artifactDigest, out var rootId) &&
|
||||
_nodes.TryGetValue(rootId, out var node))
|
||||
{
|
||||
return Task.FromResult<AttestationChainNode?>(node);
|
||||
}
|
||||
|
||||
return Task.FromResult<AttestationChainNode?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AttestationChainNode>> GetBySubjectAsync(
|
||||
string subjectDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var nodes = _nodes.Values
|
||||
.Where(n => n.SubjectDigest == subjectDigest)
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AttestationChainNode>>(nodes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a node to the store.
|
||||
/// </summary>
|
||||
public void AddNode(AttestationChainNode node)
|
||||
{
|
||||
_nodes[node.AttestationId] = node;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the root attestation for an artifact.
|
||||
/// </summary>
|
||||
public void SetArtifactRoot(string artifactDigest, string rootAttestationId)
|
||||
{
|
||||
_artifactRoots[artifactDigest] = rootAttestationId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a node from the store.
|
||||
/// </summary>
|
||||
public bool RemoveNode(string attestationId)
|
||||
{
|
||||
return _nodes.TryRemove(attestationId, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all nodes in the store.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<AttestationChainNode> GetAll() => _nodes.Values.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Clears all nodes from the store.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_nodes.Clear();
|
||||
_artifactRoots.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of nodes in the store.
|
||||
/// </summary>
|
||||
public int Count => _nodes.Count;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InTotoStatementMaterials.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T003
|
||||
// Description: Extension models for in-toto materials linking.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Chain;
|
||||
|
||||
/// <summary>
|
||||
/// A material reference for in-toto statement linking.
|
||||
/// Materials represent upstream attestations or artifacts that the statement depends on.
|
||||
/// </summary>
|
||||
public sealed record InTotoMaterial
|
||||
{
|
||||
/// <summary>
|
||||
/// URI identifying the material.
|
||||
/// For attestation references: attestation:sha256:{hash}
|
||||
/// For artifacts: {registry}/{repository}@sha256:{hash}
|
||||
/// </summary>
|
||||
[JsonPropertyName("uri")]
|
||||
[JsonPropertyOrder(0)]
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the material.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
[JsonPropertyOrder(1)]
|
||||
public required ImmutableDictionary<string, string> Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional annotations about the material.
|
||||
/// </summary>
|
||||
[JsonPropertyName("annotations")]
|
||||
[JsonPropertyOrder(2)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ImmutableDictionary<string, string>? Annotations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a material reference for an attestation.
|
||||
/// </summary>
|
||||
public static InTotoMaterial ForAttestation(string attestationDigest, string predicateType)
|
||||
{
|
||||
var normalizedDigest = attestationDigest.StartsWith("sha256:")
|
||||
? attestationDigest.Substring(7)
|
||||
: attestationDigest;
|
||||
|
||||
return new InTotoMaterial
|
||||
{
|
||||
Uri = $"attestation:sha256:{normalizedDigest}",
|
||||
Digest = ImmutableDictionary.Create<string, string>()
|
||||
.Add("sha256", normalizedDigest),
|
||||
Annotations = ImmutableDictionary.Create<string, string>()
|
||||
.Add("predicateType", predicateType)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a material reference for a container image.
|
||||
/// </summary>
|
||||
public static InTotoMaterial ForImage(string imageRef, string digest)
|
||||
{
|
||||
var normalizedDigest = digest.StartsWith("sha256:")
|
||||
? digest.Substring(7)
|
||||
: digest;
|
||||
|
||||
return new InTotoMaterial
|
||||
{
|
||||
Uri = $"{imageRef}@sha256:{normalizedDigest}",
|
||||
Digest = ImmutableDictionary.Create<string, string>()
|
||||
.Add("sha256", normalizedDigest)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a material reference for a Git commit.
|
||||
/// </summary>
|
||||
public static InTotoMaterial ForGitCommit(string repository, string commitSha)
|
||||
{
|
||||
return new InTotoMaterial
|
||||
{
|
||||
Uri = $"git+{repository}@{commitSha}",
|
||||
Digest = ImmutableDictionary.Create<string, string>()
|
||||
.Add("sha1", commitSha),
|
||||
Annotations = ImmutableDictionary.Create<string, string>()
|
||||
.Add("vcs", "git")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a material reference for a container layer.
|
||||
/// </summary>
|
||||
public static InTotoMaterial ForLayer(string imageRef, string layerDigest, int layerIndex)
|
||||
{
|
||||
var normalizedDigest = layerDigest.StartsWith("sha256:")
|
||||
? layerDigest.Substring(7)
|
||||
: layerDigest;
|
||||
|
||||
return new InTotoMaterial
|
||||
{
|
||||
Uri = $"{imageRef}#layer/{layerIndex}",
|
||||
Digest = ImmutableDictionary.Create<string, string>()
|
||||
.Add("sha256", normalizedDigest),
|
||||
Annotations = ImmutableDictionary.Create<string, string>()
|
||||
.Add("layerIndex", layerIndex.ToString())
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for adding materials to an in-toto statement.
|
||||
/// </summary>
|
||||
public sealed class MaterialsBuilder
|
||||
{
|
||||
private readonly List<InTotoMaterial> _materials = [];
|
||||
|
||||
/// <summary>
|
||||
/// Adds an attestation as a material reference.
|
||||
/// </summary>
|
||||
public MaterialsBuilder AddAttestation(string attestationDigest, string predicateType)
|
||||
{
|
||||
_materials.Add(InTotoMaterial.ForAttestation(attestationDigest, predicateType));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an image as a material reference.
|
||||
/// </summary>
|
||||
public MaterialsBuilder AddImage(string imageRef, string digest)
|
||||
{
|
||||
_materials.Add(InTotoMaterial.ForImage(imageRef, digest));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a Git commit as a material reference.
|
||||
/// </summary>
|
||||
public MaterialsBuilder AddGitCommit(string repository, string commitSha)
|
||||
{
|
||||
_materials.Add(InTotoMaterial.ForGitCommit(repository, commitSha));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a layer as a material reference.
|
||||
/// </summary>
|
||||
public MaterialsBuilder AddLayer(string imageRef, string layerDigest, int layerIndex)
|
||||
{
|
||||
_materials.Add(InTotoMaterial.ForLayer(imageRef, layerDigest, layerIndex));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom material.
|
||||
/// </summary>
|
||||
public MaterialsBuilder Add(InTotoMaterial material)
|
||||
{
|
||||
_materials.Add(material);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the materials list.
|
||||
/// </summary>
|
||||
public ImmutableArray<InTotoMaterial> Build() => [.. _materials];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constants for material annotations.
|
||||
/// </summary>
|
||||
public static class MaterialAnnotations
|
||||
{
|
||||
public const string PredicateType = "predicateType";
|
||||
public const string LayerIndex = "layerIndex";
|
||||
public const string Vcs = "vcs";
|
||||
public const string Format = "format";
|
||||
public const string MediaType = "mediaType";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// URI scheme prefixes for materials.
|
||||
/// </summary>
|
||||
public static class MaterialUriSchemes
|
||||
{
|
||||
public const string Attestation = "attestation:";
|
||||
public const string Git = "git+";
|
||||
public const string Oci = "oci://";
|
||||
public const string Pkg = "pkg:";
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ILayerAttestationService.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T015
|
||||
// Description: Interface for layer-specific attestation operations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Layers;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and managing per-layer attestations.
|
||||
/// </summary>
|
||||
public interface ILayerAttestationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an attestation for a single layer.
|
||||
/// </summary>
|
||||
/// <param name="request">The layer attestation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result of the attestation creation.</returns>
|
||||
Task<LayerAttestationResult> CreateLayerAttestationAsync(
|
||||
LayerAttestationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates attestations for multiple layers in a batch (efficient signing).
|
||||
/// </summary>
|
||||
/// <param name="request">The batch attestation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Results for all layer attestations.</returns>
|
||||
Task<BatchLayerAttestationResult> CreateBatchLayerAttestationsAsync(
|
||||
BatchLayerAttestationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all layer attestations for an image.
|
||||
/// </summary>
|
||||
/// <param name="imageDigest">The image digest.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Layer attestation results ordered by layer index.</returns>
|
||||
Task<ImmutableArray<LayerAttestationResult>> GetLayerAttestationsAsync(
|
||||
string imageDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific layer attestation.
|
||||
/// </summary>
|
||||
/// <param name="imageDigest">The image digest.</param>
|
||||
/// <param name="layerOrder">The layer order (0-based).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The layer attestation result, or null if not found.</returns>
|
||||
Task<LayerAttestationResult?> GetLayerAttestationAsync(
|
||||
string imageDigest,
|
||||
int layerOrder,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a layer attestation.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The attestation ID to verify.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<LayerAttestationVerifyResult> VerifyLayerAttestationAsync(
|
||||
string attestationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of layer attestation verification.
|
||||
/// </summary>
|
||||
public sealed record LayerAttestationVerifyResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The attestation ID that was verified.
|
||||
/// </summary>
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether verification succeeded.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification errors if any.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> Errors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The signer identity if verification succeeded.
|
||||
/// </summary>
|
||||
public string? SignerIdentity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When verification was performed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful verification result.
|
||||
/// </summary>
|
||||
public static LayerAttestationVerifyResult Success(
|
||||
string attestationId,
|
||||
string? signerIdentity,
|
||||
DateTimeOffset verifiedAt) => new()
|
||||
{
|
||||
AttestationId = attestationId,
|
||||
IsValid = true,
|
||||
Errors = [],
|
||||
SignerIdentity = signerIdentity,
|
||||
VerifiedAt = verifiedAt
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed verification result.
|
||||
/// </summary>
|
||||
public static LayerAttestationVerifyResult Failure(
|
||||
string attestationId,
|
||||
ImmutableArray<string> errors,
|
||||
DateTimeOffset verifiedAt) => new()
|
||||
{
|
||||
AttestationId = attestationId,
|
||||
IsValid = false,
|
||||
Errors = errors,
|
||||
VerifiedAt = verifiedAt
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LayerAttestation.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T014
|
||||
// Description: Models for per-layer attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Layers;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a layer-specific attestation.
|
||||
/// </summary>
|
||||
public sealed record LayerAttestationRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The parent image digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The layer digest (sha256).
|
||||
/// </summary>
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public required string LayerDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The layer order (0-based index).
|
||||
/// </summary>
|
||||
[JsonPropertyName("layerOrder")]
|
||||
public required int LayerOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The SBOM digest for this layer.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomDigest")]
|
||||
public required string SbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The SBOM format (cyclonedx, spdx).
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomFormat")]
|
||||
public required string SbomFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The SBOM content bytes.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public byte[]? SbomContent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tenant ID for multi-tenant environments.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional media type of the layer.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mediaType")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional layer size in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public long? Size { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch request for creating multiple layer attestations.
|
||||
/// </summary>
|
||||
public sealed record BatchLayerAttestationRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The parent image digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The image reference (registry/repo:tag).
|
||||
/// </summary>
|
||||
[JsonPropertyName("imageRef")]
|
||||
public required string ImageRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual layer attestation requests.
|
||||
/// </summary>
|
||||
[JsonPropertyName("layers")]
|
||||
public required ImmutableArray<LayerAttestationRequest> Layers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tenant ID for multi-tenant environments.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to link layer attestations to parent image attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("linkToParent")]
|
||||
public bool LinkToParent { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// The parent image attestation ID to link to (if LinkToParent is true).
|
||||
/// </summary>
|
||||
[JsonPropertyName("parentAttestationId")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ParentAttestationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating a layer attestation.
|
||||
/// </summary>
|
||||
public sealed record LayerAttestationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The layer digest this attestation is for.
|
||||
/// </summary>
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public required string LayerDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The layer order.
|
||||
/// </summary>
|
||||
[JsonPropertyName("layerOrder")]
|
||||
public required int LayerOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The generated attestation ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestationId")]
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The DSSE envelope digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("envelopeDigest")]
|
||||
public required string EnvelopeDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the attestation was created successfully.
|
||||
/// </summary>
|
||||
[JsonPropertyName("success")]
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if creation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the attestation was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of batch layer attestation creation.
|
||||
/// </summary>
|
||||
public sealed record BatchLayerAttestationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The parent image digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Results for each layer.
|
||||
/// </summary>
|
||||
[JsonPropertyName("layers")]
|
||||
public required ImmutableArray<LayerAttestationResult> Layers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether all layers were attested successfully.
|
||||
/// </summary>
|
||||
[JsonPropertyName("allSucceeded")]
|
||||
public bool AllSucceeded => Layers.All(l => l.Success);
|
||||
|
||||
/// <summary>
|
||||
/// Number of successful attestations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("successCount")]
|
||||
public int SuccessCount => Layers.Count(l => l.Success);
|
||||
|
||||
/// <summary>
|
||||
/// Number of failed attestations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("failedCount")]
|
||||
public int FailedCount => Layers.Count(l => !l.Success);
|
||||
|
||||
/// <summary>
|
||||
/// Total processing time.
|
||||
/// </summary>
|
||||
[JsonPropertyName("processingTime")]
|
||||
public required TimeSpan ProcessingTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the batch operation completed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("completedAt")]
|
||||
public required DateTimeOffset CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Links created between layers and parent.
|
||||
/// </summary>
|
||||
[JsonPropertyName("linksCreated")]
|
||||
public int LinksCreated { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer SBOM predicate for in-toto statement.
|
||||
/// </summary>
|
||||
public sealed record LayerSbomPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// The predicate type URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicateType")]
|
||||
public static string PredicateType => "StellaOps.LayerSBOM@1";
|
||||
|
||||
/// <summary>
|
||||
/// The parent image digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The layer order (0-based).
|
||||
/// </summary>
|
||||
[JsonPropertyName("layerOrder")]
|
||||
public required int LayerOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The SBOM format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomFormat")]
|
||||
public required string SbomFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The SBOM digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomDigest")]
|
||||
public required string SbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of components in the SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the layer SBOM was generated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tool that generated the SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatorTool")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? GeneratorTool { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Generator tool version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatorVersion")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? GeneratorVersion { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LayerAttestationService.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T016
|
||||
// Description: Implementation of layer-specific attestation service.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.Core.Chain;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Layers;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and managing per-layer attestations.
|
||||
/// </summary>
|
||||
public sealed class LayerAttestationService : ILayerAttestationService
|
||||
{
|
||||
private readonly ILayerAttestationSigner _signer;
|
||||
private readonly ILayerAttestationStore _store;
|
||||
private readonly IAttestationLinkStore _linkStore;
|
||||
private readonly AttestationChainBuilder _chainBuilder;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public LayerAttestationService(
|
||||
ILayerAttestationSigner signer,
|
||||
ILayerAttestationStore store,
|
||||
IAttestationLinkStore linkStore,
|
||||
AttestationChainBuilder chainBuilder,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_signer = signer;
|
||||
_store = store;
|
||||
_linkStore = linkStore;
|
||||
_chainBuilder = chainBuilder;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LayerAttestationResult> CreateLayerAttestationAsync(
|
||||
LayerAttestationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Create the layer SBOM predicate
|
||||
var predicate = new LayerSbomPredicate
|
||||
{
|
||||
ImageDigest = request.ImageDigest,
|
||||
LayerOrder = request.LayerOrder,
|
||||
SbomFormat = request.SbomFormat,
|
||||
SbomDigest = request.SbomDigest,
|
||||
GeneratedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
// Sign the attestation
|
||||
var signResult = await _signer.SignLayerAttestationAsync(
|
||||
request.LayerDigest,
|
||||
predicate,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!signResult.Success)
|
||||
{
|
||||
return new LayerAttestationResult
|
||||
{
|
||||
LayerDigest = request.LayerDigest,
|
||||
LayerOrder = request.LayerOrder,
|
||||
AttestationId = string.Empty,
|
||||
EnvelopeDigest = string.Empty,
|
||||
Success = false,
|
||||
Error = signResult.Error,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
// Store the attestation
|
||||
var result = new LayerAttestationResult
|
||||
{
|
||||
LayerDigest = request.LayerDigest,
|
||||
LayerOrder = request.LayerOrder,
|
||||
AttestationId = signResult.AttestationId,
|
||||
EnvelopeDigest = signResult.EnvelopeDigest,
|
||||
Success = true,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
await _store.StoreAsync(request.ImageDigest, result, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new LayerAttestationResult
|
||||
{
|
||||
LayerDigest = request.LayerDigest,
|
||||
LayerOrder = request.LayerOrder,
|
||||
AttestationId = string.Empty,
|
||||
EnvelopeDigest = string.Empty,
|
||||
Success = false,
|
||||
Error = ex.Message,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BatchLayerAttestationResult> CreateBatchLayerAttestationsAsync(
|
||||
BatchLayerAttestationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var results = new List<LayerAttestationResult>();
|
||||
var linksCreated = 0;
|
||||
|
||||
// Sort layers by order for consistent processing
|
||||
var orderedLayers = request.Layers.OrderBy(l => l.LayerOrder).ToList();
|
||||
|
||||
// Create predicates for batch signing
|
||||
var predicates = orderedLayers.Select(layer => new LayerSbomPredicate
|
||||
{
|
||||
ImageDigest = request.ImageDigest,
|
||||
LayerOrder = layer.LayerOrder,
|
||||
SbomFormat = layer.SbomFormat,
|
||||
SbomDigest = layer.SbomDigest,
|
||||
GeneratedAt = _timeProvider.GetUtcNow()
|
||||
}).ToList();
|
||||
|
||||
// Batch sign all layers (T018 - efficient batch signing)
|
||||
var signResults = await _signer.BatchSignLayerAttestationsAsync(
|
||||
orderedLayers.Select(l => l.LayerDigest).ToList(),
|
||||
predicates,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Process results
|
||||
for (var i = 0; i < orderedLayers.Count; i++)
|
||||
{
|
||||
var layer = orderedLayers[i];
|
||||
var signResult = signResults[i];
|
||||
|
||||
var result = new LayerAttestationResult
|
||||
{
|
||||
LayerDigest = layer.LayerDigest,
|
||||
LayerOrder = layer.LayerOrder,
|
||||
AttestationId = signResult.AttestationId,
|
||||
EnvelopeDigest = signResult.EnvelopeDigest,
|
||||
Success = signResult.Success,
|
||||
Error = signResult.Error,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
results.Add(result);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// Store the attestation
|
||||
await _store.StoreAsync(request.ImageDigest, result, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Create link to parent if requested
|
||||
if (request.LinkToParent && !string.IsNullOrEmpty(request.ParentAttestationId))
|
||||
{
|
||||
var linkResult = await _chainBuilder.CreateLinkAsync(
|
||||
request.ParentAttestationId,
|
||||
result.AttestationId,
|
||||
AttestationLinkType.DependsOn,
|
||||
new LinkMetadata
|
||||
{
|
||||
Reason = $"Layer {layer.LayerOrder} attestation",
|
||||
Annotations = ImmutableDictionary<string, string>.Empty
|
||||
.Add("layerOrder", layer.LayerOrder.ToString())
|
||||
.Add("layerDigest", layer.LayerDigest)
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (linkResult.IsSuccess)
|
||||
{
|
||||
linksCreated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
return new BatchLayerAttestationResult
|
||||
{
|
||||
ImageDigest = request.ImageDigest,
|
||||
Layers = [.. results],
|
||||
ProcessingTime = stopwatch.Elapsed,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
LinksCreated = linksCreated
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImmutableArray<LayerAttestationResult>> GetLayerAttestationsAsync(
|
||||
string imageDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _store.GetByImageAsync(imageDigest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LayerAttestationResult?> GetLayerAttestationAsync(
|
||||
string imageDigest,
|
||||
int layerOrder,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _store.GetAsync(imageDigest, layerOrder, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LayerAttestationVerifyResult> VerifyLayerAttestationAsync(
|
||||
string attestationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _signer.VerifyAsync(attestationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for signing layer attestations.
|
||||
/// </summary>
|
||||
public interface ILayerAttestationSigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs a single layer attestation.
|
||||
/// </summary>
|
||||
Task<LayerSignResult> SignLayerAttestationAsync(
|
||||
string layerDigest,
|
||||
LayerSbomPredicate predicate,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Signs multiple layer attestations in a batch.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<LayerSignResult>> BatchSignLayerAttestationsAsync(
|
||||
IReadOnlyList<string> layerDigests,
|
||||
IReadOnlyList<LayerSbomPredicate> predicates,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a layer attestation.
|
||||
/// </summary>
|
||||
Task<LayerAttestationVerifyResult> VerifyAsync(
|
||||
string attestationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of signing a layer attestation.
|
||||
/// </summary>
|
||||
public sealed record LayerSignResult
|
||||
{
|
||||
public required string AttestationId { get; init; }
|
||||
public required string EnvelopeDigest { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for storing layer attestations.
|
||||
/// </summary>
|
||||
public interface ILayerAttestationStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores a layer attestation result.
|
||||
/// </summary>
|
||||
Task StoreAsync(
|
||||
string imageDigest,
|
||||
LayerAttestationResult result,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all layer attestations for an image.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<LayerAttestationResult>> GetByImageAsync(
|
||||
string imageDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific layer attestation.
|
||||
/// </summary>
|
||||
Task<LayerAttestationResult?> GetAsync(
|
||||
string imageDigest,
|
||||
int layerOrder,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of layer attestation store for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryLayerAttestationStore : ILayerAttestationStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<int, LayerAttestationResult>> _store = new();
|
||||
|
||||
public Task StoreAsync(
|
||||
string imageDigest,
|
||||
LayerAttestationResult result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var imageStore = _store.GetOrAdd(imageDigest, _ => new());
|
||||
imageStore[result.LayerOrder] = result;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<ImmutableArray<LayerAttestationResult>> GetByImageAsync(
|
||||
string imageDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_store.TryGetValue(imageDigest, out var imageStore))
|
||||
{
|
||||
return Task.FromResult(imageStore.Values
|
||||
.OrderBy(r => r.LayerOrder)
|
||||
.ToImmutableArray());
|
||||
}
|
||||
|
||||
return Task.FromResult(ImmutableArray<LayerAttestationResult>.Empty);
|
||||
}
|
||||
|
||||
public Task<LayerAttestationResult?> GetAsync(
|
||||
string imageDigest,
|
||||
int layerOrder,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_store.TryGetValue(imageDigest, out var imageStore) &&
|
||||
imageStore.TryGetValue(layerOrder, out var result))
|
||||
{
|
||||
return Task.FromResult<LayerAttestationResult?>(result);
|
||||
}
|
||||
|
||||
return Task.FromResult<LayerAttestationResult?>(null);
|
||||
}
|
||||
|
||||
public void Clear() => _store.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of layer attestation signer for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryLayerAttestationSigner : ILayerAttestationSigner
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, byte[]> _signatures = new();
|
||||
|
||||
public InMemoryLayerAttestationSigner(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public Task<LayerSignResult> SignLayerAttestationAsync(
|
||||
string layerDigest,
|
||||
LayerSbomPredicate predicate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var attestationId = ComputeAttestationId(layerDigest, predicate);
|
||||
var envelopeDigest = ComputeEnvelopeDigest(attestationId);
|
||||
|
||||
// Store "signature" for verification
|
||||
_signatures[attestationId] = Encoding.UTF8.GetBytes(attestationId);
|
||||
|
||||
return Task.FromResult(new LayerSignResult
|
||||
{
|
||||
AttestationId = attestationId,
|
||||
EnvelopeDigest = envelopeDigest,
|
||||
Success = true
|
||||
});
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LayerSignResult>> BatchSignLayerAttestationsAsync(
|
||||
IReadOnlyList<string> layerDigests,
|
||||
IReadOnlyList<LayerSbomPredicate> predicates,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var results = new List<LayerSignResult>();
|
||||
for (var i = 0; i < layerDigests.Count; i++)
|
||||
{
|
||||
var attestationId = ComputeAttestationId(layerDigests[i], predicates[i]);
|
||||
var envelopeDigest = ComputeEnvelopeDigest(attestationId);
|
||||
|
||||
_signatures[attestationId] = Encoding.UTF8.GetBytes(attestationId);
|
||||
|
||||
results.Add(new LayerSignResult
|
||||
{
|
||||
AttestationId = attestationId,
|
||||
EnvelopeDigest = envelopeDigest,
|
||||
Success = true
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<LayerSignResult>>(results);
|
||||
}
|
||||
|
||||
public Task<LayerAttestationVerifyResult> VerifyAsync(
|
||||
string attestationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_signatures.ContainsKey(attestationId))
|
||||
{
|
||||
return Task.FromResult(LayerAttestationVerifyResult.Success(
|
||||
attestationId,
|
||||
"test-signer",
|
||||
_timeProvider.GetUtcNow()));
|
||||
}
|
||||
|
||||
return Task.FromResult(LayerAttestationVerifyResult.Failure(
|
||||
attestationId,
|
||||
["Attestation not found"],
|
||||
_timeProvider.GetUtcNow()));
|
||||
}
|
||||
|
||||
private static string ComputeAttestationId(string layerDigest, LayerSbomPredicate predicate)
|
||||
{
|
||||
var content = $"{layerDigest}:{predicate.ImageDigest}:{predicate.LayerOrder}:{predicate.SbomDigest}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string ComputeEnvelopeDigest(string attestationId)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"envelope:{attestationId}"));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
<PackageReference Include="JsonSchema.Net" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Sodium.Core" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Schemas\*.json" />
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Sodium;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Verification;
|
||||
|
||||
@@ -223,7 +224,7 @@ public static partial class CheckpointSignatureVerifier
|
||||
return false;
|
||||
}
|
||||
|
||||
// Note format: "<body>\n\n— origin <base64sig>\n"
|
||||
// Note format: "<body>\n\n- origin <base64sig>\n"
|
||||
var separator = signedCheckpoint.IndexOf("\n\n", StringComparison.Ordinal);
|
||||
string signatureSection;
|
||||
|
||||
@@ -348,18 +349,65 @@ public static partial class CheckpointSignatureVerifier
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an Ed25519 signature (placeholder for actual implementation).
|
||||
/// Verifies an Ed25519 signature using libsodium.
|
||||
/// </summary>
|
||||
private static bool VerifyEd25519(byte[] data, byte[] signature, byte[] publicKey)
|
||||
{
|
||||
// .NET 10 may have built-in Ed25519 support
|
||||
// For now, this is a placeholder that would use a library like NSec
|
||||
// In production, this would call the appropriate Ed25519 verification
|
||||
try
|
||||
{
|
||||
// Ed25519 signatures are 64 bytes
|
||||
if (signature.Length != 64)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: Implement Ed25519 verification when .NET 10 supports it natively
|
||||
// or use NSec.Cryptography
|
||||
byte[] keyBytes = publicKey;
|
||||
|
||||
return false;
|
||||
// Check if PEM encoded - extract DER
|
||||
if (TryExtractPem(publicKey, out var der))
|
||||
{
|
||||
keyBytes = ExtractRawEd25519PublicKey(der);
|
||||
}
|
||||
else if (IsEd25519SubjectPublicKeyInfo(publicKey))
|
||||
{
|
||||
// Already DER encoded SPKI
|
||||
keyBytes = ExtractRawEd25519PublicKey(publicKey);
|
||||
}
|
||||
|
||||
// Raw Ed25519 public keys are 32 bytes
|
||||
if (keyBytes.Length != 32)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use libsodium for Ed25519 verification
|
||||
return PublicKeyAuth.VerifyDetached(signature, data, keyBytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts raw Ed25519 public key bytes from SPKI DER encoding.
|
||||
/// </summary>
|
||||
private static byte[] ExtractRawEd25519PublicKey(byte[] spki)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(spki, AsnEncodingRules.DER);
|
||||
var sequence = reader.ReadSequence();
|
||||
// Skip algorithm identifier
|
||||
_ = sequence.ReadSequence();
|
||||
// Read BIT STRING containing the public key
|
||||
var bitString = sequence.ReadBitString(out _);
|
||||
return bitString;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return spki; // Return original if extraction fails
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsEd25519PublicKey(ReadOnlySpan<byte> publicKey)
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ChainController.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T020-T024
|
||||
// Description: API controller for attestation chain queries.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using StellaOps.Attestor.WebService.Models;
|
||||
using StellaOps.Attestor.WebService.Services;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// API controller for attestation chain queries and visualization.
|
||||
/// Enables traversal of attestation relationships and dependency graphs.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/chains")]
|
||||
[Authorize("attestor:read")]
|
||||
[EnableRateLimiting("attestor-reads")]
|
||||
public sealed class ChainController : ControllerBase
|
||||
{
|
||||
private readonly IChainQueryService _chainQueryService;
|
||||
private readonly ILogger<ChainController> _logger;
|
||||
|
||||
public ChainController(
|
||||
IChainQueryService chainQueryService,
|
||||
ILogger<ChainController> logger)
|
||||
{
|
||||
_chainQueryService = chainQueryService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get upstream (parent) attestations from a starting attestation.
|
||||
/// Traverses the chain following "depends on" relationships.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The attestation ID to start from (sha256:...)</param>
|
||||
/// <param name="maxDepth">Maximum traversal depth (default: 5, max: 10)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Chain response with upstream attestations</returns>
|
||||
[HttpGet("{attestationId}/upstream")]
|
||||
[ProducesResponseType(typeof(AttestationChainResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetUpstreamChainAsync(
|
||||
[FromRoute] string attestationId,
|
||||
[FromQuery] int? maxDepth,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attestationId))
|
||||
{
|
||||
return BadRequest(new { error = "attestationId is required" });
|
||||
}
|
||||
|
||||
var depth = Math.Clamp(maxDepth ?? 5, 1, 10);
|
||||
|
||||
_logger.LogDebug("Getting upstream chain for {AttestationId} with depth {Depth}",
|
||||
attestationId, depth);
|
||||
|
||||
var result = await _chainQueryService.GetUpstreamChainAsync(attestationId, depth, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return NotFound(new { error = $"Attestation {attestationId} not found" });
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get downstream (child) attestations from a starting attestation.
|
||||
/// Traverses the chain following attestations that depend on this one.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The attestation ID to start from (sha256:...)</param>
|
||||
/// <param name="maxDepth">Maximum traversal depth (default: 5, max: 10)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Chain response with downstream attestations</returns>
|
||||
[HttpGet("{attestationId}/downstream")]
|
||||
[ProducesResponseType(typeof(AttestationChainResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetDownstreamChainAsync(
|
||||
[FromRoute] string attestationId,
|
||||
[FromQuery] int? maxDepth,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attestationId))
|
||||
{
|
||||
return BadRequest(new { error = "attestationId is required" });
|
||||
}
|
||||
|
||||
var depth = Math.Clamp(maxDepth ?? 5, 1, 10);
|
||||
|
||||
_logger.LogDebug("Getting downstream chain for {AttestationId} with depth {Depth}",
|
||||
attestationId, depth);
|
||||
|
||||
var result = await _chainQueryService.GetDownstreamChainAsync(attestationId, depth, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return NotFound(new { error = $"Attestation {attestationId} not found" });
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the full attestation chain (both directions) from a starting point.
|
||||
/// Returns a complete graph of all related attestations.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The attestation ID to start from (sha256:...)</param>
|
||||
/// <param name="maxDepth">Maximum traversal depth in each direction (default: 5, max: 10)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Chain response with full attestation graph</returns>
|
||||
[HttpGet("{attestationId}")]
|
||||
[ProducesResponseType(typeof(AttestationChainResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetFullChainAsync(
|
||||
[FromRoute] string attestationId,
|
||||
[FromQuery] int? maxDepth,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attestationId))
|
||||
{
|
||||
return BadRequest(new { error = "attestationId is required" });
|
||||
}
|
||||
|
||||
var depth = Math.Clamp(maxDepth ?? 5, 1, 10);
|
||||
|
||||
_logger.LogDebug("Getting full chain for {AttestationId} with depth {Depth}",
|
||||
attestationId, depth);
|
||||
|
||||
var result = await _chainQueryService.GetFullChainAsync(attestationId, depth, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return NotFound(new { error = $"Attestation {attestationId} not found" });
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a graph visualization of the attestation chain.
|
||||
/// Supports Mermaid, DOT (Graphviz), and JSON formats.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The attestation ID to start from (sha256:...)</param>
|
||||
/// <param name="format">Output format: mermaid, dot, or json (default: mermaid)</param>
|
||||
/// <param name="maxDepth">Maximum traversal depth (default: 5, max: 10)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Graph visualization in requested format</returns>
|
||||
[HttpGet("{attestationId}/graph")]
|
||||
[ProducesResponseType(typeof(ChainGraphResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetChainGraphAsync(
|
||||
[FromRoute] string attestationId,
|
||||
[FromQuery] string? format,
|
||||
[FromQuery] int? maxDepth,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attestationId))
|
||||
{
|
||||
return BadRequest(new { error = "attestationId is required" });
|
||||
}
|
||||
|
||||
var graphFormat = ParseGraphFormat(format);
|
||||
var depth = Math.Clamp(maxDepth ?? 5, 1, 10);
|
||||
|
||||
_logger.LogDebug("Getting chain graph for {AttestationId} in format {Format} with depth {Depth}",
|
||||
attestationId, graphFormat, depth);
|
||||
|
||||
var result = await _chainQueryService.GetChainGraphAsync(attestationId, graphFormat, depth, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return NotFound(new { error = $"Attestation {attestationId} not found" });
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all attestations for an artifact with optional chain expansion.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">The artifact digest (sha256:...)</param>
|
||||
/// <param name="chain">Whether to include the full chain (default: false)</param>
|
||||
/// <param name="maxDepth">Maximum chain traversal depth (default: 5, max: 10)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Attestations for the artifact with optional chain</returns>
|
||||
[HttpGet("artifact/{artifactDigest}")]
|
||||
[ProducesResponseType(typeof(ArtifactChainResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetAttestationsForArtifactAsync(
|
||||
[FromRoute] string artifactDigest,
|
||||
[FromQuery] bool? chain,
|
||||
[FromQuery] int? maxDepth,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactDigest))
|
||||
{
|
||||
return BadRequest(new { error = "artifactDigest is required" });
|
||||
}
|
||||
|
||||
var includeChain = chain ?? false;
|
||||
var depth = Math.Clamp(maxDepth ?? 5, 1, 10);
|
||||
|
||||
_logger.LogDebug("Getting attestations for artifact {ArtifactDigest} with chain={IncludeChain}",
|
||||
artifactDigest, includeChain);
|
||||
|
||||
var result = await _chainQueryService.GetAttestationsForArtifactAsync(
|
||||
artifactDigest, includeChain, depth, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return NotFound(new { error = $"No attestations found for artifact {artifactDigest}" });
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
private static GraphFormat ParseGraphFormat(string? format)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(format))
|
||||
{
|
||||
return GraphFormat.Mermaid;
|
||||
}
|
||||
|
||||
return format.ToLowerInvariant() switch
|
||||
{
|
||||
"mermaid" => GraphFormat.Mermaid,
|
||||
"dot" => GraphFormat.Dot,
|
||||
"graphviz" => GraphFormat.Dot,
|
||||
"json" => GraphFormat.Json,
|
||||
_ => GraphFormat.Mermaid
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ChainApiModels.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T020
|
||||
// Description: API response models for attestation chain queries.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.Core.Chain;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Response containing attestation chain traversal results.
|
||||
/// </summary>
|
||||
public sealed record AttestationChainResponse
|
||||
{
|
||||
[JsonPropertyName("attestationId")]
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
[JsonPropertyName("direction")]
|
||||
public required string Direction { get; init; } // "upstream", "downstream", "full"
|
||||
|
||||
[JsonPropertyName("maxDepth")]
|
||||
public required int MaxDepth { get; init; }
|
||||
|
||||
[JsonPropertyName("queryTime")]
|
||||
public required DateTimeOffset QueryTime { get; init; }
|
||||
|
||||
[JsonPropertyName("nodes")]
|
||||
public required ImmutableArray<AttestationNodeDto> Nodes { get; init; }
|
||||
|
||||
[JsonPropertyName("links")]
|
||||
public required ImmutableArray<AttestationLinkDto> Links { get; init; }
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public required AttestationChainSummaryDto Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A node in the attestation chain graph.
|
||||
/// </summary>
|
||||
public sealed record AttestationNodeDto
|
||||
{
|
||||
[JsonPropertyName("attestationId")]
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
[JsonPropertyName("subjectDigest")]
|
||||
public required string SubjectDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("depth")]
|
||||
public required int Depth { get; init; }
|
||||
|
||||
[JsonPropertyName("isRoot")]
|
||||
public required bool IsRoot { get; init; }
|
||||
|
||||
[JsonPropertyName("isLeaf")]
|
||||
public required bool IsLeaf { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A link (edge) in the attestation chain graph.
|
||||
/// </summary>
|
||||
public sealed record AttestationLinkDto
|
||||
{
|
||||
[JsonPropertyName("sourceId")]
|
||||
public required string SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("targetId")]
|
||||
public required string TargetId { get; init; }
|
||||
|
||||
[JsonPropertyName("linkType")]
|
||||
public required string LinkType { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics for the chain traversal.
|
||||
/// </summary>
|
||||
public sealed record AttestationChainSummaryDto
|
||||
{
|
||||
[JsonPropertyName("totalNodes")]
|
||||
public required int TotalNodes { get; init; }
|
||||
|
||||
[JsonPropertyName("totalLinks")]
|
||||
public required int TotalLinks { get; init; }
|
||||
|
||||
[JsonPropertyName("maxDepthReached")]
|
||||
public required int MaxDepthReached { get; init; }
|
||||
|
||||
[JsonPropertyName("rootCount")]
|
||||
public required int RootCount { get; init; }
|
||||
|
||||
[JsonPropertyName("leafCount")]
|
||||
public required int LeafCount { get; init; }
|
||||
|
||||
[JsonPropertyName("predicateTypes")]
|
||||
public required ImmutableArray<string> PredicateTypes { get; init; }
|
||||
|
||||
[JsonPropertyName("isComplete")]
|
||||
public required bool IsComplete { get; init; }
|
||||
|
||||
[JsonPropertyName("truncatedReason")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? TruncatedReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Graph visualization format options.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum GraphFormat
|
||||
{
|
||||
Mermaid,
|
||||
Dot,
|
||||
Json
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing graph visualization.
|
||||
/// </summary>
|
||||
public sealed record ChainGraphResponse
|
||||
{
|
||||
[JsonPropertyName("attestationId")]
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public required GraphFormat Format { get; init; }
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public required string Content { get; init; }
|
||||
|
||||
[JsonPropertyName("nodeCount")]
|
||||
public required int NodeCount { get; init; }
|
||||
|
||||
[JsonPropertyName("linkCount")]
|
||||
public required int LinkCount { get; init; }
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for artifact chain lookup.
|
||||
/// </summary>
|
||||
public sealed record ArtifactChainResponse
|
||||
{
|
||||
[JsonPropertyName("artifactDigest")]
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("queryTime")]
|
||||
public required DateTimeOffset QueryTime { get; init; }
|
||||
|
||||
[JsonPropertyName("attestations")]
|
||||
public required ImmutableArray<AttestationSummaryDto> Attestations { get; init; }
|
||||
|
||||
[JsonPropertyName("chain")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public AttestationChainResponse? Chain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of an attestation for artifact lookup.
|
||||
/// </summary>
|
||||
public sealed record AttestationSummaryDto
|
||||
{
|
||||
[JsonPropertyName("attestationId")]
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLogIndex")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("upstreamCount")]
|
||||
public required int UpstreamCount { get; init; }
|
||||
|
||||
[JsonPropertyName("downstreamCount")]
|
||||
public required int DownstreamCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ChainQueryService.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T021-T024
|
||||
// Description: Implementation of attestation chain query service.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using StellaOps.Attestor.Core.Chain;
|
||||
using StellaOps.Attestor.WebService.Models;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for querying attestation chains and their relationships.
|
||||
/// </summary>
|
||||
public sealed class ChainQueryService : IChainQueryService
|
||||
{
|
||||
private readonly IAttestationLinkResolver _linkResolver;
|
||||
private readonly IAttestationLinkStore _linkStore;
|
||||
private readonly IAttestationNodeProvider _nodeProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ChainQueryService> _logger;
|
||||
|
||||
private const int MaxAllowedDepth = 10;
|
||||
private const int MaxNodes = 500;
|
||||
|
||||
public ChainQueryService(
|
||||
IAttestationLinkResolver linkResolver,
|
||||
IAttestationLinkStore linkStore,
|
||||
IAttestationNodeProvider nodeProvider,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ChainQueryService> logger)
|
||||
{
|
||||
_linkResolver = linkResolver;
|
||||
_linkStore = linkStore;
|
||||
_nodeProvider = nodeProvider;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttestationChainResponse?> GetUpstreamChainAsync(
|
||||
string attestationId,
|
||||
int maxDepth = 5,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var depth = Math.Clamp(maxDepth, 1, MaxAllowedDepth);
|
||||
|
||||
var chain = await _linkResolver.ResolveUpstreamAsync(attestationId, depth, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (chain is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildChainResponse(attestationId, chain, "upstream", depth);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttestationChainResponse?> GetDownstreamChainAsync(
|
||||
string attestationId,
|
||||
int maxDepth = 5,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var depth = Math.Clamp(maxDepth, 1, MaxAllowedDepth);
|
||||
|
||||
var chain = await _linkResolver.ResolveDownstreamAsync(attestationId, depth, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (chain is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildChainResponse(attestationId, chain, "downstream", depth);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AttestationChainResponse?> GetFullChainAsync(
|
||||
string attestationId,
|
||||
int maxDepth = 5,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var depth = Math.Clamp(maxDepth, 1, MaxAllowedDepth);
|
||||
|
||||
var chain = await _linkResolver.ResolveFullChainAsync(attestationId, depth, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (chain is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildChainResponse(attestationId, chain, "full", depth);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ArtifactChainResponse?> GetAttestationsForArtifactAsync(
|
||||
string artifactDigest,
|
||||
bool includeChain = false,
|
||||
int maxDepth = 5,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var attestations = await _nodeProvider.GetBySubjectAsync(artifactDigest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (attestations.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var summaries = new List<AttestationSummaryDto>();
|
||||
foreach (var node in attestations)
|
||||
{
|
||||
var upstreamLinks = await _linkStore.GetByTargetAsync(node.AttestationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var downstreamLinks = await _linkStore.GetBySourceAsync(node.AttestationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
summaries.Add(new AttestationSummaryDto
|
||||
{
|
||||
AttestationId = node.AttestationId,
|
||||
PredicateType = node.PredicateType,
|
||||
CreatedAt = node.CreatedAt,
|
||||
Status = "verified",
|
||||
RekorLogIndex = null,
|
||||
UpstreamCount = upstreamLinks.Length,
|
||||
DownstreamCount = downstreamLinks.Length
|
||||
});
|
||||
}
|
||||
|
||||
AttestationChainResponse? chainResponse = null;
|
||||
if (includeChain && summaries.Count > 0)
|
||||
{
|
||||
var depth = Math.Clamp(maxDepth, 1, MaxAllowedDepth);
|
||||
var primaryAttestation = summaries.OrderByDescending(s => s.CreatedAt).First();
|
||||
chainResponse = await GetFullChainAsync(primaryAttestation.AttestationId, depth, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new ArtifactChainResponse
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
QueryTime = _timeProvider.GetUtcNow(),
|
||||
Attestations = [.. summaries.OrderByDescending(s => s.CreatedAt)],
|
||||
Chain = chainResponse
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ChainGraphResponse?> GetChainGraphAsync(
|
||||
string attestationId,
|
||||
GraphFormat format = GraphFormat.Mermaid,
|
||||
int maxDepth = 5,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var depth = Math.Clamp(maxDepth, 1, MaxAllowedDepth);
|
||||
|
||||
var chain = await _linkResolver.ResolveFullChainAsync(attestationId, depth, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (chain is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = format switch
|
||||
{
|
||||
GraphFormat.Mermaid => GenerateMermaidGraph(chain),
|
||||
GraphFormat.Dot => GenerateDotGraph(chain),
|
||||
GraphFormat.Json => GenerateJsonGraph(chain),
|
||||
_ => GenerateMermaidGraph(chain)
|
||||
};
|
||||
|
||||
return new ChainGraphResponse
|
||||
{
|
||||
AttestationId = attestationId,
|
||||
Format = format,
|
||||
Content = content,
|
||||
NodeCount = chain.Nodes.Length,
|
||||
LinkCount = chain.Links.Length,
|
||||
GeneratedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
private AttestationChainResponse BuildChainResponse(
|
||||
string attestationId,
|
||||
AttestationChain chain,
|
||||
string direction,
|
||||
int requestedDepth)
|
||||
{
|
||||
var nodeCount = chain.Nodes.Length;
|
||||
var isTruncated = nodeCount >= MaxNodes;
|
||||
var maxDepthReached = chain.Nodes.Length > 0
|
||||
? chain.Nodes.Max(n => n.Depth)
|
||||
: 0;
|
||||
|
||||
var rootNodes = chain.Nodes.Where(n => n.IsRoot).ToImmutableArray();
|
||||
var leafNodes = chain.Nodes.Where(n => n.IsLeaf).ToImmutableArray();
|
||||
var predicateTypes = chain.Nodes
|
||||
.Select(n => n.PredicateType)
|
||||
.Distinct()
|
||||
.ToImmutableArray();
|
||||
|
||||
var nodes = chain.Nodes.Select(n => new AttestationNodeDto
|
||||
{
|
||||
AttestationId = n.AttestationId,
|
||||
PredicateType = n.PredicateType,
|
||||
SubjectDigest = n.SubjectDigest,
|
||||
CreatedAt = n.CreatedAt,
|
||||
Depth = n.Depth,
|
||||
IsRoot = n.IsRoot,
|
||||
IsLeaf = n.IsLeaf,
|
||||
Metadata = n.Metadata?.Count > 0 ? n.Metadata : null
|
||||
}).ToImmutableArray();
|
||||
|
||||
var links = chain.Links.Select(l => new AttestationLinkDto
|
||||
{
|
||||
SourceId = l.SourceAttestationId,
|
||||
TargetId = l.TargetAttestationId,
|
||||
LinkType = l.LinkType.ToString(),
|
||||
CreatedAt = l.CreatedAt,
|
||||
Reason = l.Metadata?.Reason
|
||||
}).ToImmutableArray();
|
||||
|
||||
return new AttestationChainResponse
|
||||
{
|
||||
AttestationId = attestationId,
|
||||
Direction = direction,
|
||||
MaxDepth = requestedDepth,
|
||||
QueryTime = _timeProvider.GetUtcNow(),
|
||||
Nodes = nodes,
|
||||
Links = links,
|
||||
Summary = new AttestationChainSummaryDto
|
||||
{
|
||||
TotalNodes = nodeCount,
|
||||
TotalLinks = chain.Links.Length,
|
||||
MaxDepthReached = maxDepthReached,
|
||||
RootCount = rootNodes.Length,
|
||||
LeafCount = leafNodes.Length,
|
||||
PredicateTypes = predicateTypes,
|
||||
IsComplete = !isTruncated && maxDepthReached < requestedDepth,
|
||||
TruncatedReason = isTruncated ? $"Result truncated at {MaxNodes} nodes" : null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateMermaidGraph(AttestationChain chain)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("graph TD");
|
||||
|
||||
// Add node definitions with shapes based on predicate type
|
||||
foreach (var node in chain.Nodes)
|
||||
{
|
||||
var shortId = GetShortId(node.AttestationId);
|
||||
var label = $"{node.PredicateType}\\n{shortId}";
|
||||
|
||||
var shape = node.PredicateType.ToUpperInvariant() switch
|
||||
{
|
||||
"SBOM" => $" {shortId}[/{label}/]",
|
||||
"VEX" => $" {shortId}[({label})]",
|
||||
"VERDICT" => $" {shortId}{{{{{label}}}}}",
|
||||
_ => $" {shortId}[{label}]"
|
||||
};
|
||||
|
||||
sb.AppendLine(shape);
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
|
||||
// Add edges with link type labels
|
||||
foreach (var link in chain.Links)
|
||||
{
|
||||
var sourceShort = GetShortId(link.SourceAttestationId);
|
||||
var targetShort = GetShortId(link.TargetAttestationId);
|
||||
var linkLabel = link.LinkType.ToString().ToLowerInvariant();
|
||||
sb.AppendLine($" {sourceShort} -->|{linkLabel}| {targetShort}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GenerateDotGraph(AttestationChain chain)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("digraph attestation_chain {");
|
||||
sb.AppendLine(" rankdir=TB;");
|
||||
sb.AppendLine(" node [fontname=\"Helvetica\"];");
|
||||
sb.AppendLine();
|
||||
|
||||
// Add node definitions
|
||||
foreach (var node in chain.Nodes)
|
||||
{
|
||||
var shortId = GetShortId(node.AttestationId);
|
||||
var shape = node.PredicateType.ToUpperInvariant() switch
|
||||
{
|
||||
"SBOM" => "parallelogram",
|
||||
"VEX" => "ellipse",
|
||||
"VERDICT" => "diamond",
|
||||
_ => "box"
|
||||
};
|
||||
|
||||
sb.AppendLine($" \"{shortId}\" [label=\"{node.PredicateType}\\n{shortId}\", shape={shape}];");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
|
||||
// Add edges
|
||||
foreach (var link in chain.Links)
|
||||
{
|
||||
var sourceShort = GetShortId(link.SourceAttestationId);
|
||||
var targetShort = GetShortId(link.TargetAttestationId);
|
||||
var linkLabel = link.LinkType.ToString().ToLowerInvariant();
|
||||
sb.AppendLine($" \"{sourceShort}\" -> \"{targetShort}\" [label=\"{linkLabel}\"];");
|
||||
}
|
||||
|
||||
sb.AppendLine("}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GenerateJsonGraph(AttestationChain chain)
|
||||
{
|
||||
var graph = new
|
||||
{
|
||||
nodes = chain.Nodes.Select(n => new
|
||||
{
|
||||
id = n.AttestationId,
|
||||
shortId = GetShortId(n.AttestationId),
|
||||
type = n.PredicateType,
|
||||
subject = n.SubjectDigest,
|
||||
depth = n.Depth,
|
||||
isRoot = n.IsRoot,
|
||||
isLeaf = n.IsLeaf
|
||||
}).ToArray(),
|
||||
edges = chain.Links.Select(l => new
|
||||
{
|
||||
source = l.SourceAttestationId,
|
||||
target = l.TargetAttestationId,
|
||||
type = l.LinkType.ToString()
|
||||
}).ToArray()
|
||||
};
|
||||
|
||||
return System.Text.Json.JsonSerializer.Serialize(graph, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
}
|
||||
|
||||
private static string GetShortId(string attestationId)
|
||||
{
|
||||
if (attestationId.StartsWith("sha256:", StringComparison.Ordinal) && attestationId.Length > 15)
|
||||
{
|
||||
return attestationId[7..15];
|
||||
}
|
||||
|
||||
return attestationId.Length > 8 ? attestationId[..8] : attestationId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IChainQueryService.cs
|
||||
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
|
||||
// Task: T020
|
||||
// Description: Service interface for attestation chain queries.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.WebService.Models;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for querying attestation chains and their relationships.
|
||||
/// </summary>
|
||||
public interface IChainQueryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets upstream (parent) attestations from a starting point.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The attestation ID to start from.</param>
|
||||
/// <param name="maxDepth">Maximum traversal depth.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Chain response with upstream attestations.</returns>
|
||||
Task<AttestationChainResponse?> GetUpstreamChainAsync(
|
||||
string attestationId,
|
||||
int maxDepth = 5,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets downstream (child) attestations from a starting point.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The attestation ID to start from.</param>
|
||||
/// <param name="maxDepth">Maximum traversal depth.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Chain response with downstream attestations.</returns>
|
||||
Task<AttestationChainResponse?> GetDownstreamChainAsync(
|
||||
string attestationId,
|
||||
int maxDepth = 5,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full chain (both directions) from a starting point.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The attestation ID to start from.</param>
|
||||
/// <param name="maxDepth">Maximum traversal depth in each direction.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Chain response with full attestation graph.</returns>
|
||||
Task<AttestationChainResponse?> GetFullChainAsync(
|
||||
string attestationId,
|
||||
int maxDepth = 5,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all attestations for an artifact with optional chain expansion.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">The artifact digest (sha256:...).</param>
|
||||
/// <param name="includeChain">Whether to include the full chain.</param>
|
||||
/// <param name="maxDepth">Maximum chain traversal depth.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Artifact chain response.</returns>
|
||||
Task<ArtifactChainResponse?> GetAttestationsForArtifactAsync(
|
||||
string artifactDigest,
|
||||
bool includeChain = false,
|
||||
int maxDepth = 5,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a graph visualization for a chain.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The attestation ID to start from.</param>
|
||||
/// <param name="format">The output format (Mermaid, Dot, Json).</param>
|
||||
/// <param name="maxDepth">Maximum traversal depth.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Graph visualization response.</returns>
|
||||
Task<ChainGraphResponse?> GetChainGraphAsync(
|
||||
string attestationId,
|
||||
GraphFormat format = GraphFormat.Mermaid,
|
||||
int maxDepth = 5,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
Reference in New Issue
Block a user